@yottameta/yotta-skills-plugin 0.19.16 → 0.19.18

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.
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { spawnSync } = require('child_process');
5
+ const manifestLib = require('./manifest');
6
+
7
+ const PHASES = ['setup', 'doctor', 'rollback'];
8
+
9
+ function parseJsonResult(stdout) {
10
+ const text = String(stdout || '').trim();
11
+ if (!text) return null;
12
+ try {
13
+ return JSON.parse(text);
14
+ } catch (_) {
15
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
16
+ for (let i = lines.length - 1; i >= 0; i--) {
17
+ if (!lines[i].startsWith('{')) continue;
18
+ try {
19
+ return JSON.parse(lines[i]);
20
+ } catch (_) {
21
+ // Keep looking for the structured result line.
22
+ }
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function runLifecycleScript(scriptPath, args, options) {
29
+ const opts = options || {};
30
+ if (!scriptPath || !fs.existsSync(scriptPath) || !fs.statSync(scriptPath).isFile()) {
31
+ return {
32
+ ok: false,
33
+ skipped: false,
34
+ result: null,
35
+ error: '生命周期脚本不存在: ' + (scriptPath || '未提供'),
36
+ stdout: '',
37
+ stderr: '',
38
+ exitCode: null,
39
+ };
40
+ }
41
+ if (!/\.(?:js|cjs)$/i.test(scriptPath)) {
42
+ return {
43
+ ok: false,
44
+ skipped: false,
45
+ result: null,
46
+ error: '生命周期脚本必须为 Node.js .js/.cjs 文件',
47
+ stdout: '',
48
+ stderr: '',
49
+ exitCode: null,
50
+ };
51
+ }
52
+
53
+ const spawn = opts.spawnSync || spawnSync;
54
+ const argv = [scriptPath].concat(Array.isArray(args) ? args : []).concat(['--json']);
55
+ let proc;
56
+ try {
57
+ proc = spawn(process.execPath, argv, {
58
+ cwd: opts.cwd || path.dirname(scriptPath),
59
+ encoding: 'utf8',
60
+ timeout: opts.timeout || 30000,
61
+ maxBuffer: opts.maxBuffer || 4 * 1024 * 1024,
62
+ });
63
+ } catch (error) {
64
+ return {
65
+ ok: false,
66
+ skipped: false,
67
+ result: null,
68
+ error: error.message,
69
+ stdout: '',
70
+ stderr: '',
71
+ exitCode: null,
72
+ };
73
+ }
74
+
75
+ const stdout = proc.stdout || '';
76
+ const stderr = proc.stderr || '';
77
+ const parsed = parseJsonResult(stdout);
78
+ const exitCode = proc.status;
79
+ if (proc.error) {
80
+ return { ok: false, skipped: false, result: parsed, error: proc.error.message, stdout, stderr, exitCode };
81
+ }
82
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
83
+ return {
84
+ ok: false,
85
+ skipped: false,
86
+ result: null,
87
+ error: '生命周期脚本未返回合法 JSON' + (stderr ? ':' + stderr.trim() : ''),
88
+ stdout,
89
+ stderr,
90
+ exitCode,
91
+ };
92
+ }
93
+ if (exitCode !== 0) {
94
+ return {
95
+ ok: false,
96
+ skipped: false,
97
+ result: parsed,
98
+ error: parsed.error || stderr.trim() || ('生命周期脚本退出码 ' + exitCode),
99
+ stdout,
100
+ stderr,
101
+ exitCode,
102
+ };
103
+ }
104
+ if (parsed.ok === false) {
105
+ return {
106
+ ok: false,
107
+ skipped: false,
108
+ result: parsed,
109
+ error: parsed.error || '生命周期脚本返回失败',
110
+ stdout,
111
+ stderr,
112
+ exitCode,
113
+ };
114
+ }
115
+ return { ok: true, skipped: false, result: parsed, error: null, stdout, stderr, exitCode };
116
+ }
117
+
118
+ function runPhase(packageDir, manifest, phase, context) {
119
+ if (!PHASES.includes(phase)) {
120
+ return { ok: false, skipped: false, result: null, error: '未知生命周期阶段: ' + phase };
121
+ }
122
+ const ctx = context || {};
123
+ const install = manifest && manifest.install ? manifest.install : {};
124
+ const rel = install[phase];
125
+ if (!rel) {
126
+ return { ok: true, skipped: true, result: null, error: null };
127
+ }
128
+ if (!manifestLib.isSafeRelativePath(rel)) {
129
+ return { ok: false, skipped: false, result: null, error: '生命周期脚本路径不安全: ' + rel };
130
+ }
131
+ const root = path.resolve(packageDir);
132
+ const scriptPath = path.resolve(root, rel);
133
+ const relative = path.relative(root, scriptPath);
134
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
135
+ return { ok: false, skipped: false, result: null, error: '生命周期脚本越出包目录: ' + rel };
136
+ }
137
+
138
+ const args = [];
139
+ if (ctx.skillDir) args.push('--skill-dir', ctx.skillDir);
140
+ if (ctx.packageDir || packageDir) args.push('--package-dir', ctx.packageDir || packageDir);
141
+ if (ctx.dest) args.push('--dest', ctx.dest);
142
+ if (ctx.snapshot) args.push('--snapshot', ctx.snapshot);
143
+ return runLifecycleScript(scriptPath, args, {
144
+ ...ctx.options,
145
+ cwd: (ctx.options && ctx.options.cwd) || root,
146
+ });
147
+ }
148
+
149
+ module.exports = { PHASES, parseJsonResult, runLifecycleScript, runPhase };
@@ -0,0 +1,387 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const manifestLib = require('./manifest');
6
+ const gateLib = require('./verify-gate');
7
+ const lifecycleLib = require('./install-lifecycle');
8
+ const healthLib = require('./install-health');
9
+ const snapshotLib = require('./install-snapshot');
10
+ const hookAdapterLib = require('./hook-adapter');
11
+
12
+ function nowTag() {
13
+ return new Date().toISOString().replace(/[:.]/g, '-');
14
+ }
15
+
16
+ function sleepSync(ms) {
17
+ const array = new Int32Array(new SharedArrayBuffer(4));
18
+ Atomics.wait(array, 0, 0, ms);
19
+ }
20
+
21
+ function renameWithRetry(from, to, options) {
22
+ const rename = (options && options.rename) || fs.renameSync;
23
+ const sleep = (options && options.sleep) || sleepSync;
24
+ let lastError;
25
+ for (let attempt = 0; attempt < 5; attempt++) {
26
+ try {
27
+ return rename(from, to);
28
+ } catch (error) {
29
+ lastError = error;
30
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 4) throw error;
31
+ sleep(100 * (attempt + 1));
32
+ }
33
+ }
34
+ throw lastError;
35
+ }
36
+
37
+ function isSafeTarEntry(entry) {
38
+ const value = String(entry || '').replace(/\\/g, '/');
39
+ if (value !== 'package' && !value.startsWith('package/')) return false;
40
+ if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return false;
41
+ return !value.split('/').includes('..');
42
+ }
43
+
44
+ function copyTree(src, dst, copyDir) {
45
+ fs.rmSync(dst, { recursive: true, force: true });
46
+ fs.mkdirSync(dst, { recursive: true });
47
+ copyDir(src, dst);
48
+ }
49
+
50
+ function restoreBackup(target, backup, hadTarget) {
51
+ if (backup && fs.existsSync(backup)) {
52
+ if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
53
+ renameWithRetry(backup, target);
54
+ return true;
55
+ }
56
+ if (!hadTarget && fs.existsSync(target)) {
57
+ fs.rmSync(target, { recursive: true, force: true });
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function createInstaller(deps) {
64
+ const homeDir = deps.homeDir || os.homedir();
65
+ const runPhase = deps.runPhase || lifecycleLib.runPhase;
66
+ const checkInstalledSkill = deps.checkInstalledSkill || healthLib.checkInstalledSkill;
67
+ const captureSnapshot = deps.createSnapshot || snapshotLib.createSnapshot;
68
+ const evaluateHook = deps.evaluateHook || hookAdapterLib.evaluateHook;
69
+ const appendHookEvidence = deps.appendHookEvidence || hookAdapterLib.appendHookEvidence;
70
+
71
+ function record(entry) {
72
+ return deps.appendEvidence(entry, { homeDir });
73
+ }
74
+
75
+ function safeRecord(entry) {
76
+ try {
77
+ return record(entry);
78
+ } catch (_) {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ return function installOne(skill, dest, opts) {
84
+ const target = path.join(dest, skill.slug);
85
+ const existingVersion = deps.readInstalledVersion(target);
86
+ if (!opts.force && existingVersion === skill.version) {
87
+ return { skill, status: 'skip', version: existingVersion, note: '已是最新', exitCode: 0 };
88
+ }
89
+
90
+ let tmp = null;
91
+ let staged = null;
92
+ let snapshot = null;
93
+ let backup = null;
94
+ const hadTarget = fs.existsSync(target);
95
+ let manifest = null;
96
+ let extracted = null;
97
+ let lifecycle = { setup: null, builtin_doctor: null, doctor: null, rollback: null };
98
+ try {
99
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yotta-skills-'));
100
+ const packDir = path.join(tmp, 'pack');
101
+ fs.mkdirSync(packDir, { recursive: true });
102
+ const packed = deps.runNpmPack(skill, opts, packDir);
103
+ if (packed.error) {
104
+ safeRecord({ event: 'before_install', skill: skill.slug, decision: 'fail', error: packed.error });
105
+ return { skill, status: 'fail', version: null, note: packed.error, exitCode: 1 };
106
+ }
107
+
108
+ const extractDir = path.join(tmp, 'extract');
109
+ fs.mkdirSync(extractDir, { recursive: true });
110
+ extracted = deps.extractTarball(packed.tarball, extractDir);
111
+ if (extracted.error) {
112
+ safeRecord({ event: 'before_install', skill: skill.slug, decision: 'fail', error: extracted.error });
113
+ return { skill, status: 'fail', version: packed.resolved, note: extracted.error, exitCode: 1 };
114
+ }
115
+
116
+ const resolvedSkill = { ...skill, version: packed.resolved || skill.version };
117
+ const loaded = manifestLib.loadManifest({ pkgDir: extracted.pkgDir, skill: resolvedSkill });
118
+ if (loaded.errors.length > 0) {
119
+ const note = loaded.errors.join('; ');
120
+ safeRecord({ event: 'manifest', skill: skill.slug, decision: 'block', errors: loaded.errors });
121
+ return { skill, status: 'fail', version: packed.resolved, note, exitCode: 6 };
122
+ }
123
+ manifest = loaded.manifest;
124
+ if (manifest.trust !== 'yottameta') {
125
+ safeRecord({
126
+ event: 'manifest',
127
+ skill: skill.slug,
128
+ decision: 'block',
129
+ errors: ['trust 必须为 yottameta'],
130
+ });
131
+ return {
132
+ skill,
133
+ status: 'fail',
134
+ version: packed.resolved,
135
+ note: 'manifest trust 必须为 yottameta',
136
+ exitCode: 6,
137
+ };
138
+ }
139
+
140
+ let gate;
141
+ let scan;
142
+ if (opts.skipScan) {
143
+ gate = { ok: true, engine: null, mode: 'explicit-unverified' };
144
+ scan = { decision: 'unverified', verdict: null, counts: null, warn: true, mode: 'explicit-unverified' };
145
+ } else {
146
+ gate = deps.ensureGate({ skill, extracted, dest, opts });
147
+ if (!gate.ok) {
148
+ safeRecord({ event: 'before_install', skill: skill.slug, decision: 'block', error: gate.error });
149
+ return { skill, status: 'fail', version: packed.resolved, note: gate.error, exitCode: 5 };
150
+ }
151
+ const scanResult = deps.scanTarget(gate.engine, extracted.pkgDir);
152
+ if (!scanResult.ok) {
153
+ safeRecord({ event: 'before_install', skill: skill.slug, decision: 'block', error: scanResult.error });
154
+ return { skill, status: 'fail', version: packed.resolved, note: scanResult.error, exitCode: 5 };
155
+ }
156
+ const verdict = gateLib.evaluateVerdict(scanResult.verdict);
157
+ let hookEvaluation = { decision: 'allow', evidence: [], user_message: '无 hook 声明' };
158
+ try {
159
+ hookEvaluation = evaluateHook({
160
+ host: opts.host || 'codex',
161
+ event: 'before_install',
162
+ manifest,
163
+ capabilities: deps.hookCapabilities || hookAdapterLib.capabilitiesForHost(opts.host || 'codex'),
164
+ context: opts.skipScan
165
+ ? { wrapperRegistered: false, checks: {} }
166
+ : {
167
+ wrapperRegistered: true,
168
+ checks: {
169
+ scan_skill: {
170
+ ok: !verdict.block,
171
+ evidence: { audit_log: 'install-log.jsonl' },
172
+ },
173
+ },
174
+ },
175
+ });
176
+ for (const entry of hookEvaluation.evidence) {
177
+ appendHookEvidence(entry, { homeDir });
178
+ }
179
+ } catch (error) {
180
+ hookEvaluation = {
181
+ decision: 'block',
182
+ evidence: [],
183
+ user_message: 'hook 适配器失败: ' + error.message,
184
+ };
185
+ }
186
+ if (verdict.block || hookEvaluation.decision === 'block') {
187
+ const hookReason = hookEvaluation.decision === 'block'
188
+ ? 'before_install hook 阻断: ' + hookEvaluation.user_message
189
+ : '元信 verdict: ' + scanResult.verdict;
190
+ safeRecord({
191
+ event: 'before_install',
192
+ skill: skill.slug,
193
+ decision: 'block',
194
+ verdict: scanResult.verdict,
195
+ counts: scanResult.counts,
196
+ hook_decision: hookEvaluation.decision,
197
+ });
198
+ return {
199
+ skill,
200
+ status: 'fail',
201
+ version: packed.resolved,
202
+ note: hookReason,
203
+ exitCode: 5,
204
+ };
205
+ }
206
+ scan = {
207
+ decision: verdict.decision,
208
+ verdict: scanResult.verdict,
209
+ counts: scanResult.counts,
210
+ warn: verdict.warn,
211
+ };
212
+ }
213
+
214
+ fs.mkdirSync(dest, { recursive: true });
215
+ const stagingRoot = path.join(dest, '.yottaskills-staging');
216
+ fs.mkdirSync(stagingRoot, { recursive: true });
217
+ staged = fs.mkdtempSync(path.join(stagingRoot, skill.slug + '-'));
218
+ copyTree(extracted.pkgDir, staged, deps.copyDir);
219
+
220
+ if (fs.existsSync(target)) {
221
+ const captured = captureSnapshot(target, {
222
+ homeDir,
223
+ slug: skill.slug,
224
+ version: existingVersion || 'unknown',
225
+ copyDir: deps.copyDir,
226
+ });
227
+ snapshot = captured.path;
228
+ backup = path.join(dest, '.yottaskills-backup-' + skill.slug + '-' + process.pid);
229
+ renameWithRetry(target, backup);
230
+ }
231
+
232
+ renameWithRetry(staged, target);
233
+ staged = null;
234
+
235
+ function lifecycleSummary(result) {
236
+ if (!result) return null;
237
+ return {
238
+ ok: !!result.ok,
239
+ skipped: !!result.skipped,
240
+ error: result.error || null,
241
+ result: result.result || null,
242
+ };
243
+ }
244
+
245
+ function rollbackAfterFailure(reason, phase) {
246
+ let restoreError = null;
247
+ let rollbackResult = { ok: false, skipped: true, error: null, result: null };
248
+ try {
249
+ restoreBackup(target, backup, hadTarget);
250
+ backup = null;
251
+ } catch (error) {
252
+ restoreError = error.message;
253
+ }
254
+ try {
255
+ const rollbackPhase = runPhase(extracted.pkgDir, manifest, 'rollback', {
256
+ skillDir: target,
257
+ packageDir: extracted.pkgDir,
258
+ dest,
259
+ snapshot,
260
+ });
261
+ if (!rollbackPhase.skipped) rollbackResult = rollbackPhase;
262
+ } catch (error) {
263
+ rollbackResult = { ok: false, skipped: false, error: error.message, result: null };
264
+ }
265
+ lifecycle.rollback = lifecycleSummary(rollbackResult);
266
+ const rollbackError = restoreError || (rollbackResult.ok || rollbackResult.skipped ? null : rollbackResult.error);
267
+ safeRecord({
268
+ event: 'rollback',
269
+ skill: skill.slug,
270
+ package: skill.pkg,
271
+ version: packed.resolved || skill.version,
272
+ decision: rollbackError ? 'fail' : 'ok',
273
+ phase,
274
+ reason,
275
+ restore_error: restoreError,
276
+ rollback_error: rollbackResult.ok || rollbackResult.skipped ? null : rollbackResult.error,
277
+ snapshot,
278
+ });
279
+ return {
280
+ skill,
281
+ status: 'fail',
282
+ version: packed.resolved || skill.version,
283
+ note: reason + (rollbackError ? ';回滚失败: ' + rollbackError : ''),
284
+ exitCode: 1,
285
+ snapshot,
286
+ lifecycle,
287
+ rollback_reason: reason,
288
+ };
289
+ }
290
+
291
+ lifecycle.setup = runPhase(extracted.pkgDir, manifest, 'setup', {
292
+ skillDir: target,
293
+ packageDir: extracted.pkgDir,
294
+ dest,
295
+ snapshot,
296
+ });
297
+ if (!lifecycle.setup.ok) {
298
+ return rollbackAfterFailure('setup 失败: ' + lifecycle.setup.error, 'setup');
299
+ }
300
+
301
+ lifecycle.builtin_doctor = checkInstalledSkill({
302
+ slug: skill.slug,
303
+ target,
304
+ expectedVersion: packed.resolved || skill.version,
305
+ expectedPackage: skill.pkg,
306
+ });
307
+ if (!lifecycle.builtin_doctor.ok) {
308
+ return rollbackAfterFailure(
309
+ '内置 doctor 失败: ' + lifecycle.builtin_doctor.errors.join('; '),
310
+ 'builtin-doctor',
311
+ );
312
+ }
313
+
314
+ lifecycle.doctor = runPhase(extracted.pkgDir, manifest, 'doctor', {
315
+ skillDir: target,
316
+ packageDir: extracted.pkgDir,
317
+ dest,
318
+ snapshot,
319
+ });
320
+ if (!lifecycle.doctor.ok) {
321
+ return rollbackAfterFailure('doctor 失败: ' + lifecycle.doctor.error, 'doctor');
322
+ }
323
+
324
+ let evidence;
325
+ try {
326
+ evidence = record({
327
+ event: 'before_install',
328
+ skill: skill.slug,
329
+ package: skill.pkg,
330
+ version: packed.resolved || skill.version,
331
+ gate_mode: gate.mode,
332
+ bootstrap_scan: gate.mode === 'trusted-bootstrap' && skill.slug === 'yotta-verify' ? 'self' : null,
333
+ verdict: scan.verdict,
334
+ decision: scan.decision,
335
+ snapshot,
336
+ });
337
+ } catch (error) {
338
+ return rollbackAfterFailure('证据写入失败: ' + error.message, 'evidence');
339
+ }
340
+
341
+ safeRecord({
342
+ event: 'after_install',
343
+ skill: skill.slug,
344
+ package: skill.pkg,
345
+ version: packed.resolved || skill.version,
346
+ decision: 'allow',
347
+ snapshot,
348
+ lifecycle: {
349
+ setup: lifecycleSummary(lifecycle.setup),
350
+ builtin_doctor: lifecycleSummary(lifecycle.builtin_doctor),
351
+ doctor: lifecycleSummary(lifecycle.doctor),
352
+ },
353
+ });
354
+
355
+ if (backup && fs.existsSync(backup)) {
356
+ try {
357
+ fs.rmSync(backup, { recursive: true, force: true });
358
+ } catch (_) {
359
+ // The new version is already active; a stale backup is safer than a failed install.
360
+ }
361
+ backup = null;
362
+ }
363
+
364
+ return {
365
+ skill,
366
+ status: 'ok',
367
+ version: packed.resolved || skill.version,
368
+ note: null,
369
+ gate: { ...scan, mode: gate.mode },
370
+ evidence,
371
+ snapshot,
372
+ lifecycle,
373
+ exitCode: 0,
374
+ };
375
+ } catch (error) {
376
+ restoreBackup(target, backup, hadTarget);
377
+ backup = null;
378
+ safeRecord({ event: 'install', skill: skill.slug, decision: 'fail', error: error.message });
379
+ return { skill, status: 'fail', version: null, note: error.message, exitCode: 1 };
380
+ } finally {
381
+ if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
382
+ if (staged) fs.rmSync(staged, { recursive: true, force: true });
383
+ }
384
+ };
385
+ }
386
+
387
+ module.exports = { createInstaller, renameWithRetry, isSafeTarEntry };