@yottameta/yotta-skills-plugin 0.25.1 → 0.27.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.
@@ -0,0 +1,680 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * YottaSkills Hub local panel server (yotta-skills view).
5
+ *
6
+ * Loopback-only HTTP server that wraps the existing Hub libraries. No parallel
7
+ * state: every payload comes from hub / hub-adopt / snapshots / evidence and
8
+ * every write goes through the same functions the CLI uses, so the panel and
9
+ * the CLI share one ledger.
10
+ *
11
+ * Security shell: Host + Origin validation, strict CSP, no-store, noindex,
12
+ * session token required for write requests, body-size cap, serialized writes,
13
+ * destructive confirm strings.
14
+ */
15
+
16
+ const crypto = require('crypto');
17
+ const fs = require('fs');
18
+ const http = require('http');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ const hubLib = require('./hub');
23
+ const hubAdoptLib = require('./hub-adopt');
24
+ const hubScanLib = require('./hub-scan');
25
+ const discoveryLib = require('./agent-discovery');
26
+ const snapshotLib = require('./install-snapshot');
27
+ const evidenceLib = require('./install-evidence');
28
+ const healthLib = require('./install-health');
29
+ const scanLib = require('./skills-scan');
30
+ const routeLib = require('./route');
31
+ const routeFeaturesLib = require('./route-features');
32
+ const routeDynamicLib = require('./route-dynamic');
33
+ const providerLib = require('./provider');
34
+ const usageLib = require('./usage-journal');
35
+
36
+ const DEFAULT_HOST = '127.0.0.1';
37
+ const DEFAULT_PORT = 8789;
38
+ const MAX_BODY_BYTES = 256 * 1024;
39
+ const MAX_JSONL_BYTES = 2 * 1024 * 1024;
40
+ const DEFAULT_LIMIT = 100;
41
+ const MAX_LIMIT = 500;
42
+ const TOKEN_HEADER = 'x-yotta-view-token';
43
+ const TOKEN_PLACEHOLDER = '__YOTTA_VIEW_TOKEN__';
44
+ const CONFIRM = { unlink: 'unlink', rollback: 'rollback' };
45
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
46
+
47
+ function nowIso() {
48
+ return new Date().toISOString();
49
+ }
50
+
51
+ function normalizeDir(dir) {
52
+ const resolved = path.resolve(dir);
53
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
54
+ }
55
+
56
+ function sameDir(left, right) {
57
+ return normalizeDir(left) === normalizeDir(right);
58
+ }
59
+
60
+ function isInside(root, target) {
61
+ const relative = path.relative(path.resolve(root), path.resolve(target));
62
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
63
+ }
64
+
65
+ function viewHostName(req) {
66
+ const raw = String(req.headers.host || '').trim();
67
+ if (!raw) return '';
68
+ try {
69
+ return new URL('http://' + raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
70
+ } catch (_) {
71
+ return '';
72
+ }
73
+ }
74
+
75
+ function viewHostAllowed(req, bindHost) {
76
+ const name = viewHostName(req);
77
+ if (!name) return false;
78
+ const bind = String(bindHost || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
79
+ if (bind !== '127.0.0.1' && bind !== 'localhost' && bind !== '::1') return true;
80
+ return name === '127.0.0.1' || name === 'localhost' || name === '::1';
81
+ }
82
+
83
+ function viewOriginAllowed(req) {
84
+ const origin = req.headers.origin;
85
+ if (!origin) return true;
86
+ let originHost = '';
87
+ try {
88
+ originHost = new URL(String(origin)).host.toLowerCase();
89
+ } catch (_) {
90
+ return false;
91
+ }
92
+ const requestHost = String(req.headers.host || '').trim().toLowerCase();
93
+ return Boolean(requestHost) && originHost === requestHost;
94
+ }
95
+
96
+ function viewFetchSiteAllowed(req) {
97
+ const site = String(req.headers['sec-fetch-site'] || '').trim().toLowerCase();
98
+ if (!site) return true;
99
+ return site === 'same-origin' || site === 'none';
100
+ }
101
+
102
+ function readJsonlTail(file, limit) {
103
+ const maxItems = Math.max(1, Math.min(MAX_LIMIT, limit || DEFAULT_LIMIT));
104
+ const result = { path: file, entries: [], invalid: 0, truncated: false };
105
+ let text = '';
106
+ try {
107
+ const stat = fs.statSync(file);
108
+ const start = Math.max(0, stat.size - MAX_JSONL_BYTES);
109
+ result.truncated = start > 0;
110
+ const fd = fs.openSync(file, 'r');
111
+ try {
112
+ const length = stat.size - start;
113
+ const buffer = Buffer.alloc(length);
114
+ fs.readSync(fd, buffer, 0, length, start);
115
+ text = buffer.toString('utf8');
116
+ } finally {
117
+ fs.closeSync(fd);
118
+ }
119
+ } catch (_) {
120
+ return result;
121
+ }
122
+ if (result.truncated) {
123
+ const firstBreak = text.indexOf('\n');
124
+ text = firstBreak >= 0 ? text.slice(firstBreak + 1) : '';
125
+ }
126
+ const lines = text.split(/\r?\n/).filter((line) => line.trim());
127
+ for (const line of lines) {
128
+ try {
129
+ result.entries.push(JSON.parse(line));
130
+ } catch (_) {
131
+ result.invalid += 1;
132
+ }
133
+ }
134
+ if (result.entries.length > maxItems) {
135
+ result.entries = result.entries.slice(result.entries.length - maxItems);
136
+ }
137
+ return result;
138
+ }
139
+
140
+ function hubAuditPath(hubDir) {
141
+ return hubLib.hubPaths(hubDir).audit;
142
+ }
143
+
144
+ function discovery(ctx) {
145
+ return discoveryLib.discoverHosts({ homeDir: ctx.homeDir, env: ctx.env });
146
+ }
147
+
148
+ function scanEngineInfo(ctx, found) {
149
+ const engine = hubScanLib.scanEngineForHub(ctx.hubDir, found, { python: ctx.python, verify: ctx.verify });
150
+ if (!engine) return { available: false, path: null };
151
+ return { available: true, path: engine.path || engine.engine || null };
152
+ }
153
+
154
+ function overviewPayload(ctx) {
155
+ const status = hubLib.status({ hubDir: ctx.hubDir, manifest: ctx.manifest, homeDir: ctx.homeDir, env: ctx.env });
156
+ const doctor = hubLib.doctor({
157
+ hubDir: ctx.hubDir,
158
+ manifest: ctx.manifest,
159
+ homeDir: ctx.homeDir,
160
+ env: ctx.env,
161
+ discovery: discovery(ctx),
162
+ });
163
+ const audit = readJsonlTail(hubAuditPath(ctx.hubDir), 8);
164
+ return {
165
+ version: ctx.version,
166
+ hubDir: ctx.hubDir,
167
+ standard: hubLib.STANDARD_ID,
168
+ generatedAt: nowIso(),
169
+ status: {
170
+ summary: status.summary,
171
+ skills: status.skills.map((skill) => ({
172
+ slug: skill.slug,
173
+ version: skill.version,
174
+ origin: skill.origin,
175
+ status: skill.status,
176
+ source: skill.source,
177
+ sourceType: skill.sourceType,
178
+ scanVerdict: skill.scanVerdict,
179
+ links: skill.links.map((link) => ({ dir: link.dir, label: link.label, status: link.status })),
180
+ })),
181
+ },
182
+ doctor: { ok: doctor.ok, summary: doctor.summary, checks: doctor.checks },
183
+ audit: { count: audit.entries.length, recent: audit.entries, invalid: audit.invalid },
184
+ };
185
+ }
186
+
187
+ function hostsPayload(ctx) {
188
+ const found = discovery(ctx);
189
+ const links = hubLib.linkStatus(ctx.hubDir);
190
+ const hubSkills = hubLib.scanHubSkills(ctx.hubDir).map((skill) => ({
191
+ slug: skill.slug,
192
+ version: skill.version,
193
+ description: skill.description,
194
+ slugMismatch: skill.slugMismatch,
195
+ }));
196
+ const hosts = found.hosts.map((host) => {
197
+ const hostLinks = links.filter((link) => sameDir(link.dir, host.dir));
198
+ return {
199
+ ...host,
200
+ hubLinks: hostLinks.filter((link) => link.status === 'ok').length,
201
+ brokenLinks: hostLinks.filter((link) => link.status !== 'ok').length,
202
+ };
203
+ });
204
+ return {
205
+ generatedAt: found.generatedAt,
206
+ hubDir: ctx.hubDir,
207
+ standard: hubLib.STANDARD_ID,
208
+ hosts,
209
+ installed: found.installed,
210
+ links,
211
+ hubSkills,
212
+ summary: {
213
+ hosts: hosts.length,
214
+ existing: hosts.filter((host) => host.exists).length,
215
+ installedMarks: found.installed.length,
216
+ links: links.length,
217
+ brokenLinks: links.filter((link) => link.status !== 'ok').length,
218
+ },
219
+ };
220
+ }
221
+
222
+ function adoptScanPayload(ctx) {
223
+ const found = discovery(ctx);
224
+ const scan = hubAdoptLib.scanCandidates({ hubDir: ctx.hubDir, discovery: found });
225
+ return { ...scan, scan: scanEngineInfo(ctx, found) };
226
+ }
227
+
228
+ function linksPayload(ctx) {
229
+ const links = hubLib.linkStatus(ctx.hubDir);
230
+ return {
231
+ hubDir: ctx.hubDir,
232
+ standard: hubLib.STANDARD_ID,
233
+ links,
234
+ summary: {
235
+ links: links.length,
236
+ ok: links.filter((link) => link.status === 'ok').length,
237
+ broken: links.filter((link) => link.status !== 'ok').length,
238
+ },
239
+ };
240
+ }
241
+
242
+ function linkPlanPayload(ctx, targetDir) {
243
+ const found = discovery(ctx);
244
+ const host = found.hosts.find((item) => sameDir(item.dir, targetDir));
245
+ if (!host) {
246
+ return { error: '目标目录不在已发现的宿主目录中;请先在宿主矩阵中确认该目录。', code: 400 };
247
+ }
248
+ const result = hubLib.linkSkills({
249
+ hubDir: ctx.hubDir,
250
+ targetDir: host.dir,
251
+ dryRun: true,
252
+ manifest: ctx.manifest,
253
+ });
254
+ return { hubDir: ctx.hubDir, label: host.label, targetDir: host.dir, results: result.results };
255
+ }
256
+
257
+ function recordsPayload(ctx, limit) {
258
+ const evidencePath = evidenceLib.evidencePath({ homeDir: ctx.homeDir });
259
+ const auditPath = hubAuditPath(ctx.hubDir);
260
+ const snapshots = snapshotLib.listSnapshots(ctx.homeDir);
261
+ const maxItems = Math.max(1, Math.min(MAX_LIMIT, limit || DEFAULT_LIMIT));
262
+ return {
263
+ hubDir: ctx.hubDir,
264
+ evidence: readJsonlTail(evidencePath, maxItems),
265
+ audit: readJsonlTail(auditPath, maxItems),
266
+ snapshots: snapshots.slice(0, maxItems),
267
+ };
268
+ }
269
+
270
+ function rollbackListPayload(ctx, limit) {
271
+ const snapshots = snapshotLib.listSnapshots(ctx.homeDir);
272
+ const maxItems = Math.max(1, Math.min(MAX_LIMIT, limit || DEFAULT_LIMIT));
273
+ return {
274
+ ok: true,
275
+ count: snapshots.filter((row) => row.valid).length,
276
+ snapshots: snapshots.slice(0, maxItems),
277
+ };
278
+ }
279
+
280
+ function routePayload(ctx, request) {
281
+ const registry = scanLib.readRegistry();
282
+ const yottaSlugs = new Set([...routeLib.defaultYottaSlugs(), ...(ctx.manifest || []).map((s) => s.slug)]);
283
+ const result = routeLib.routeRequest(request, { registry, yottaSlugs });
284
+ try {
285
+ const usage = usageLib.readUsage();
286
+ const payload = routeFeaturesLib.buildRouteFeatures({
287
+ request,
288
+ registry,
289
+ staticResult: result,
290
+ usage,
291
+ playbooks: routeLib.PLAYBOOKS,
292
+ });
293
+ const run = providerLib.runCapability('o1.route', payload);
294
+ result.dynamic = (run.status === 'active' && run.data && typeof run.data === 'object')
295
+ ? routeDynamicLib.applyDynamicData(result, run.data, registry, { providerId: run.provider_id })
296
+ : routeDynamicLib.dynamicBlock(run.status, run.provider_id, run.note || run.message || '');
297
+ } catch (error) {
298
+ result.dynamic = routeDynamicLib.dynamicBlock('error', '', '动态路由装载失败:' + error.message);
299
+ }
300
+ return result;
301
+ }
302
+
303
+ function slugList(value) {
304
+ if (!Array.isArray(value)) return [];
305
+ const out = [];
306
+ for (const item of value) {
307
+ const slug = String(item || '').trim().toLowerCase();
308
+ if (!slug) continue;
309
+ if (!SLUG_RE.test(slug)) return { error: '技能 slug 非法: ' + slug };
310
+ if (!out.includes(slug)) out.push(slug);
311
+ }
312
+ return out;
313
+ }
314
+
315
+ function restoreFromSnapshot(ctx, slug, snapshotPath) {
316
+ const root = snapshotLib.snapshotRoot(ctx.homeDir, slug);
317
+ if (!isInside(root, snapshotPath)) {
318
+ return { error: '快照路径不在该技能的快照目录内,已拒绝。', code: 400 };
319
+ }
320
+ const validation = snapshotLib.validateSnapshot(snapshotPath);
321
+ if (!validation.ok) return { error: '快照不可用: ' + (validation.reason || '未知原因'), code: 400 };
322
+ const source = validation.source;
323
+ if (!source || !path.isAbsolute(source)) {
324
+ return { error: '快照缺少安装来源路径(旧版快照),请改用 CLI rollback --dir <技能目录>。', code: 400 };
325
+ }
326
+ if (isInside(path.join(ctx.homeDir, '.yottaskills', 'snapshots'), source)) {
327
+ return { error: '快照记录的来源路径异常(指向快照区),已拒绝。', code: 400 };
328
+ }
329
+ const restored = snapshotLib.restoreSnapshot(snapshotPath, source);
330
+ if (!restored.ok) return { error: restored.error || '恢复失败', code: 500 };
331
+ const family = (ctx.manifest || []).find((item) => item.slug === slug) || null;
332
+ let doctor = null;
333
+ try {
334
+ doctor = healthLib.checkInstalledSkill({
335
+ slug,
336
+ target: source,
337
+ expectedVersion: restored.version && restored.version !== 'unknown' ? restored.version : null,
338
+ expectedPackage: family ? family.pkg : null,
339
+ registry: scanLib.readRegistry(),
340
+ });
341
+ } catch (error) {
342
+ doctor = { ok: false, errors: ['恢复后体检失败: ' + error.message], warnings: [], fixes: [] };
343
+ }
344
+ try {
345
+ evidenceLib.appendEvidence({
346
+ event: 'rollback',
347
+ skill: slug,
348
+ package: family ? family.pkg : null,
349
+ version: restored.version,
350
+ decision: doctor && doctor.ok ? 'ok' : 'fail',
351
+ snapshot: snapshotPath,
352
+ source,
353
+ via: 'view',
354
+ }, { homeDir: ctx.homeDir });
355
+ } catch (error) {
356
+ doctor = Object.assign({}, doctor || {}, { ok: false });
357
+ doctor.errors = (doctor.errors || []).concat('回滚证据写入失败: ' + error.message);
358
+ }
359
+ return {
360
+ ok: Boolean(doctor && doctor.ok),
361
+ slug,
362
+ version: restored.version,
363
+ snapshot: snapshotPath,
364
+ source,
365
+ doctor,
366
+ };
367
+ }
368
+
369
+ function writeAdopt(ctx, body) {
370
+ if (body.skipScan || body.allowUnverified) {
371
+ return {
372
+ code: 400,
373
+ payload: { error: '面板不提供跳过 / 降级元信扫描;如已确认风险,请使用 CLI:yotta-skills hub adopt --apply --include <slug> --skip-scan(或 --allow-unverified)' },
374
+ };
375
+ }
376
+ const found = discovery(ctx);
377
+ const include = slugList(body.include);
378
+ if (include.error) return { code: 400, payload: { error: include.error } };
379
+ const scanResult = hubAdoptLib.scanCandidates({ hubDir: ctx.hubDir, discovery: found });
380
+ const payload = hubAdoptLib.applyCandidates({
381
+ hubDir: ctx.hubDir,
382
+ candidates: scanResult.candidates,
383
+ include,
384
+ force: Boolean(body.force),
385
+ inPlace: Boolean(body.inPlace),
386
+ skipScan: false,
387
+ allowUnverified: false,
388
+ manifest: ctx.manifest,
389
+ homeDir: ctx.homeDir,
390
+ env: ctx.env,
391
+ scan: (skillDir) => (typeof ctx.scanSkill === 'function'
392
+ ? ctx.scanSkill(skillDir, found, { skipScan: false })
393
+ : hubScanLib.hubScanSkill(ctx.hubDir, found, { python: ctx.python, verify: ctx.verify }, skillDir)),
394
+ });
395
+ const summary = {
396
+ imported: payload.results.filter((item) => item.status === 'imported').length,
397
+ blocked: payload.results.filter((item) => item.status === 'blocked').length,
398
+ conflict: payload.results.filter((item) => item.status === 'conflict').length,
399
+ skipped: payload.results.filter((item) => item.status === 'skip').length,
400
+ error: payload.results.filter((item) => item.status === 'error').length,
401
+ };
402
+ return { code: 200, payload: { ...payload, summary } };
403
+ }
404
+
405
+ function writeLink(ctx, body) {
406
+ const found = discovery(ctx);
407
+ const targetDir = String(body.targetDir || '');
408
+ const host = found.hosts.find((item) => sameDir(item.dir, targetDir));
409
+ if (!host) return { code: 400, payload: { error: '目标目录不在已发现的宿主目录中。' } };
410
+ if (body.force) {
411
+ return {
412
+ code: 400,
413
+ payload: { error: '面板 v1 不执行强制覆盖(--force);如需备份并替换同名真目录,请使用 CLI:yotta-skills hub link --dir <目录> --force' },
414
+ };
415
+ }
416
+ const slugs = slugList(body.slugs);
417
+ if (slugs.error) return { code: 400, payload: { error: slugs.error } };
418
+ const result = hubLib.linkSkills({
419
+ hubDir: ctx.hubDir,
420
+ targetDir: host.dir,
421
+ agentId: host.agentId,
422
+ label: host.label,
423
+ slugs,
424
+ dryRun: false,
425
+ force: false,
426
+ manifest: ctx.manifest,
427
+ });
428
+ const failed = result.results.filter((item) => item.status === 'error').length;
429
+ return { code: failed ? 500 : 200, payload: { ...result, label: host.label, failed } };
430
+ }
431
+
432
+ function writeUnlink(ctx, body) {
433
+ if (String(body.confirm || '') !== CONFIRM.unlink) {
434
+ return { code: 400, payload: { error: '破坏性操作确认不匹配;请确认后再执行。' } };
435
+ }
436
+ const found = discovery(ctx);
437
+ const targetDir = String(body.targetDir || '');
438
+ const host = found.hosts.find((item) => sameDir(item.dir, targetDir));
439
+ if (!host) return { code: 400, payload: { error: '目标目录不在已发现的宿主目录中。' } };
440
+ const slugs = slugList(body.slugs);
441
+ if (slugs.error) return { code: 400, payload: { error: slugs.error } };
442
+ const result = hubLib.unlinkSkills({ hubDir: ctx.hubDir, targetDir: host.dir, slugs, dryRun: false });
443
+ const failed = result.results.filter((item) => item.status === 'error').length;
444
+ return { code: failed ? 500 : 200, payload: { ...result, label: host.label, failed } };
445
+ }
446
+
447
+ function writeRollback(ctx, body) {
448
+ if (String(body.confirm || '') !== CONFIRM.rollback) {
449
+ return { code: 400, payload: { error: '破坏性操作确认不匹配;请确认后再执行。' } };
450
+ }
451
+ const slug = String(body.slug || '').trim().toLowerCase();
452
+ if (!SLUG_RE.test(slug)) return { code: 400, payload: { error: '技能 slug 非法。' } };
453
+ const snapshot = String(body.snapshot || '').trim();
454
+ if (!snapshot) return { code: 400, payload: { error: '缺少快照路径。' } };
455
+ const result = restoreFromSnapshot(ctx, slug, snapshot);
456
+ return { code: result.error ? (result.code || 400) : 200, payload: result };
457
+ }
458
+
459
+ function json(res, code, value) {
460
+ if (res.writableEnded) return;
461
+ res.writeHead(code, {
462
+ 'Content-Type': 'application/json; charset=utf-8',
463
+ 'Cache-Control': 'no-store',
464
+ 'X-Content-Type-Options': 'nosniff',
465
+ 'Referrer-Policy': 'no-referrer',
466
+ });
467
+ res.end(JSON.stringify(value));
468
+ }
469
+
470
+ function text(res, code, value) {
471
+ if (res.writableEnded) return;
472
+ res.writeHead(code, {
473
+ 'Content-Type': 'text/plain; charset=utf-8',
474
+ 'Cache-Control': 'no-store',
475
+ 'X-Content-Type-Options': 'nosniff',
476
+ });
477
+ res.end(value);
478
+ }
479
+
480
+ function htmlResponse(res, html) {
481
+ res.writeHead(200, {
482
+ 'Content-Type': 'text/html; charset=utf-8',
483
+ 'Cache-Control': 'no-store',
484
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
485
+ 'X-Content-Type-Options': 'nosniff',
486
+ 'X-Frame-Options': 'DENY',
487
+ 'Referrer-Policy': 'no-referrer',
488
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
489
+ });
490
+ res.end(html);
491
+ }
492
+
493
+ function renderHtml(source, token) {
494
+ const html = String(source || '');
495
+ const meta = '<meta name="yotta-view-token" content="' + token + '">';
496
+ if (html.includes(TOKEN_PLACEHOLDER)) return html.split(TOKEN_PLACEHOLDER).join(token);
497
+ if (html.includes('</head>')) return html.replace('</head>', meta + '</head>');
498
+ return meta + html;
499
+ }
500
+
501
+ function tokenAllowed(req, token) {
502
+ const provided = String(req.headers[TOKEN_HEADER] || '');
503
+ if (!provided || !token) return false;
504
+ const a = Buffer.from(provided);
505
+ const b = Buffer.from(token);
506
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
507
+ }
508
+
509
+ function readBody(req, cb) {
510
+ let size = 0;
511
+ let tooLarge = false;
512
+ const chunks = [];
513
+ req.on('data', (chunk) => {
514
+ size += chunk.length;
515
+ if (size > MAX_BODY_BYTES) {
516
+ tooLarge = true;
517
+ return;
518
+ }
519
+ chunks.push(chunk);
520
+ });
521
+ req.on('end', () => {
522
+ if (tooLarge) return cb({ tooLarge: true });
523
+ let parsed = {};
524
+ try {
525
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
526
+ } catch (_) {
527
+ return cb({ invalid: true });
528
+ }
529
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return cb({ invalid: true });
530
+ cb({ body: parsed });
531
+ });
532
+ req.on('error', () => cb({ invalid: true }));
533
+ }
534
+
535
+ function queryLimit(url) {
536
+ const raw = parseInt(url.searchParams.get('limit') || String(DEFAULT_LIMIT), 10);
537
+ if (!Number.isFinite(raw) || raw < 1) return DEFAULT_LIMIT;
538
+ return Math.min(MAX_LIMIT, raw);
539
+ }
540
+
541
+ function handleGet(ctx, pathname, url, res) {
542
+ if (pathname === '/api/status') {
543
+ return json(res, 200, {
544
+ ok: true,
545
+ version: ctx.version,
546
+ hubDir: ctx.hubDir,
547
+ standard: hubLib.STANDARD_ID,
548
+ host: ctx.bindHost,
549
+ });
550
+ }
551
+ if (pathname === '/api/overview') return json(res, 200, overviewPayload(ctx));
552
+ if (pathname === '/api/hosts') return json(res, 200, hostsPayload(ctx));
553
+ if (pathname === '/api/adopt/scan') return json(res, 200, adoptScanPayload(ctx));
554
+ if (pathname === '/api/links') return json(res, 200, linksPayload(ctx));
555
+ if (pathname === '/api/links/plan') {
556
+ const target = url.searchParams.get('dir') || '';
557
+ if (!target) return json(res, 400, { error: '缺少 dir 参数(目标宿主目录)。' });
558
+ const payload = linkPlanPayload(ctx, target);
559
+ return json(res, payload.error ? payload.code || 400 : 200, payload);
560
+ }
561
+ if (pathname === '/api/doctor') {
562
+ return json(res, 200, hubLib.doctor({ hubDir: ctx.hubDir, manifest: ctx.manifest, homeDir: ctx.homeDir, env: ctx.env }));
563
+ }
564
+ if (pathname === '/api/records') return json(res, 200, recordsPayload(ctx, queryLimit(url)));
565
+ if (pathname === '/api/rollback/list') return json(res, 200, rollbackListPayload(ctx, queryLimit(url)));
566
+ if (pathname === '/api/route') {
567
+ const request = String(url.searchParams.get('request') || '').trim();
568
+ if (!request) return json(res, 400, { error: '缺少 request 参数(需求摘要)。' });
569
+ return json(res, 200, routePayload(ctx, request));
570
+ }
571
+ return json(res, 404, { error: 'not found' });
572
+ }
573
+
574
+ function handlePost(ctx, pathname, req, res) {
575
+ return readBody(req, (parsed) => {
576
+ if (parsed.tooLarge) return json(res, 413, { error: '请求体过大。' });
577
+ if (parsed.invalid) return json(res, 400, { error: '请求体必须是 JSON 对象。' });
578
+ const body = parsed.body || {};
579
+ const run = () => {
580
+ if (pathname === '/api/adopt/apply') return writeAdopt(ctx, body);
581
+ if (pathname === '/api/links/apply') return writeLink(ctx, body);
582
+ if (pathname === '/api/links/remove') return writeUnlink(ctx, body);
583
+ if (pathname === '/api/rollback/apply') return writeRollback(ctx, body);
584
+ return { code: 404, payload: { error: 'not found' } };
585
+ };
586
+ ctx.queue(run)
587
+ .then((result) => json(res, result.code || 200, result.payload))
588
+ .catch((error) => json(res, 500, { error: error && error.message ? error.message : String(error) }));
589
+ });
590
+ }
591
+
592
+ function createHubViewServer(options) {
593
+ const opts = options || {};
594
+ const bindHost = opts.host || DEFAULT_HOST;
595
+ const requestedToken = opts.token || crypto.randomBytes(24).toString('base64url');
596
+ const token = /^[A-Za-z0-9_-]{8,128}$/.test(requestedToken) ? requestedToken : crypto.randomBytes(24).toString('base64url');
597
+ const html = renderHtml(opts.html, token);
598
+ let chain = Promise.resolve();
599
+ const ctx = {
600
+ hubDir: path.resolve(opts.hubDir || hubLib.resolveHubDir({})),
601
+ homeDir: opts.homeDir || os.homedir(),
602
+ env: opts.env || process.env,
603
+ manifest: opts.manifest || [],
604
+ version: opts.version || '',
605
+ python: opts.python || null,
606
+ verify: opts.verify || null,
607
+ scanSkill: opts.scanSkill || null,
608
+ bindHost,
609
+ queue(task) {
610
+ const run = chain.then(task, task);
611
+ chain = run.catch(() => {});
612
+ return run;
613
+ },
614
+ };
615
+ const server = http.createServer((req, res) => {
616
+ if (!viewHostAllowed(req, bindHost) || !viewOriginAllowed(req) || !viewFetchSiteAllowed(req)) {
617
+ return text(res, 403, 'forbidden');
618
+ }
619
+ let url;
620
+ try {
621
+ url = new URL(req.url, 'http://' + (req.headers.host || 'localhost'));
622
+ } catch (_) {
623
+ return text(res, 400, 'bad request');
624
+ }
625
+ const pathname = url.pathname;
626
+ if (req.method === 'GET' && pathname === '/') return htmlResponse(res, html);
627
+ if (pathname.startsWith('/api/')) {
628
+ if (req.method === 'GET') {
629
+ try {
630
+ return handleGet(ctx, pathname, url, res);
631
+ } catch (error) {
632
+ return json(res, 500, { error: error && error.message ? error.message : String(error) });
633
+ }
634
+ }
635
+ if (req.method === 'POST') {
636
+ if (!tokenAllowed(req, token)) return json(res, 403, { error: '写操作需要有效的本机会话令牌;请从 yotta-skills view 页面操作。' });
637
+ return handlePost(ctx, pathname, req, res);
638
+ }
639
+ return json(res, 405, { error: 'method not allowed' });
640
+ }
641
+ return text(res, 404, 'not found');
642
+ });
643
+ server.on('error', (error) => {
644
+ if (typeof opts.onError === 'function') {
645
+ opts.onError(error);
646
+ return;
647
+ }
648
+ const message = 'yotta-skills view 启动失败: ' + (error && error.message ? error.message : String(error));
649
+ console.error(message);
650
+ });
651
+ return {
652
+ server,
653
+ token,
654
+ host: bindHost,
655
+ hubDir: ctx.hubDir,
656
+ listen(port, host, cb) {
657
+ const targetHost = host || bindHost;
658
+ const targetPort = port === undefined || port === null ? (opts.port || DEFAULT_PORT) : port;
659
+ return server.listen(targetPort, targetHost, cb);
660
+ },
661
+ close() {
662
+ return new Promise((resolve) => {
663
+ if (!server.listening) return resolve();
664
+ server.close(() => resolve());
665
+ });
666
+ },
667
+ };
668
+ }
669
+
670
+ module.exports = {
671
+ DEFAULT_HOST,
672
+ DEFAULT_PORT,
673
+ TOKEN_HEADER,
674
+ TOKEN_PLACEHOLDER,
675
+ CONFIRM,
676
+ createHubViewServer,
677
+ readJsonlTail,
678
+ viewHostAllowed,
679
+ viewOriginAllowed,
680
+ };