@mebular/mcp 0.0.0-stage → 0.1.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/src/serve.mjs ADDED
@@ -0,0 +1,3478 @@
1
+ // Streamable HTTP server(G6.3):/mcp、/healthz、OAuth 最小 AS+RS、单实例锁。
2
+ //
3
+ // - 传输:SDK WebStandardStreamableHTTPServerTransport(stateful + JSON 响应)。
4
+ // - 认证:none(stdio 除外;环回可无)/ bearer(tokensFile 存 sha256)/ oauth(内置最小 AS+RS)。
5
+ // - fail closed:非环回必须 TLS 且 auth != none,否则拒绝启动。
6
+ // - 单实例:<home>/lock O_EXCL + PID 存活检测 + 陈旧回收。
7
+
8
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, realpathSync } from 'node:fs';
11
+ import { chmod, copyFile, mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ import { dirname, join, sep } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
17
+ import { TOOL_ALL_SCOPES } from './tools.mjs';
18
+ import { attachFleetEvents, resolveFleetNamespace } from './fleet-events.mjs';
19
+ import { READ_ROUTES, buildHandoffPlan } from './admin.mjs';
20
+ import { configPath } from './config.mjs';
21
+ import { createServiceFacade } from './service-facade.mjs';
22
+
23
+ // ---------- 控制台配置写入(schema 驱动:editable 可写 / status-only 只读 / internal 默认不可写) ----------
24
+ // 单一真源:packages/mcp/src/config-schema.mjs。写入策略(原子写 + .bak 备份;全部需重启生效):
25
+ // editable → 允许;status-only → 400(自动推导);internal → 仅显式 writable 允许;未知 → 400。
26
+ import { CONFIG_SCHEMA_BY_PATH, isWritable, writeRejection } from './config-schema.mjs';
27
+ import {
28
+ inspectJoinToken,
29
+ isProvisionHome,
30
+ provisionCreate,
31
+ provisionJoin,
32
+ provisionJoinInto,
33
+ provisionRestore,
34
+ provisionStatus,
35
+ snapshotProvisionState,
36
+ writeProvisionRecord,
37
+ } from './provision.mjs';
38
+ import {
39
+ acquireActiveLock,
40
+ cloudError,
41
+ homeKey,
42
+ masterFingerprintFromHex,
43
+ newProfileId,
44
+ normalizeProfileName,
45
+ profileIdForHome,
46
+ readRegistry,
47
+ registerProfile,
48
+ renameProfile,
49
+ resolveCloudRoot,
50
+ PROFILES_DIR,
51
+ } from './profiles.mjs';
52
+ import {
53
+ APPLY_HEALTH_TIMEOUT_MS,
54
+ RESTART_DEBOUNCE_MS,
55
+ classifyChanges,
56
+ hotPaths,
57
+ runRestart,
58
+ spawnSupervisor,
59
+ writeApplyPending,
60
+ } from './config-apply.mjs';
61
+
62
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
63
+
64
+ /**
65
+ * F-C2:**组合校验**(针对合并后的完整配置)——不允许存出「把自己搞挂」的组合。
66
+ * 规则:mcp.http.tls=true ⇒ 必须给出 tlsKey 与 tlsCert;非回环 host ⇒ auth != none 且 tls=true(且证书齐备)。
67
+ */
68
+ function validateConfigCombination(merged) {
69
+ const http = merged?.mcp?.http ?? {};
70
+ const errors = [];
71
+ const host = typeof http.host === 'string' && http.host.length > 0 ? http.host : '127.0.0.1';
72
+ const tlsOn = http.tls === true;
73
+ const hasCert = typeof http.tlsKey === 'string' && http.tlsKey.length > 0 && typeof http.tlsCert === 'string' && http.tlsCert.length > 0;
74
+ if (tlsOn && !hasCert) {
75
+ errors.push('启用 TLS(mcp.http.tls=true)需要同时填写 mcp.http.tlsKey 与 mcp.http.tlsCert(证书路径)');
76
+ }
77
+ if (!LOOPBACK_HOSTS.has(host)) {
78
+ if ((http.auth ?? 'none') === 'none') {
79
+ errors.push(`非回环监听(host=${host})不允许 auth=none:请设 mcp.http.auth=bearer/oauth`);
80
+ }
81
+ if (!tlsOn) errors.push(`非回环监听(host=${host})要求 mcp.http.tls=true(并配 tlsKey/tlsCert)`);
82
+ }
83
+ return errors;
84
+ }
85
+
86
+ function flattenPatch(patch, prefix = '') {
87
+ const out = [];
88
+ for (const [key, value] of Object.entries(patch ?? {})) {
89
+ const path = prefix ? `${prefix}.${key}` : key;
90
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
91
+ out.push(...flattenPatch(value, path));
92
+ } else {
93
+ out.push([path, value]);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function validateConfigPatch(patch) {
100
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
101
+ return { errors: ['patch 必须是对象'] };
102
+ }
103
+ const errors = [];
104
+ const entries = [];
105
+ for (const [path, raw] of flattenPatch(patch)) {
106
+ const entry = CONFIG_SCHEMA_BY_PATH.get(path);
107
+ if (!entry || !isWritable(path)) {
108
+ const rejection = writeRejection(path);
109
+ if (rejection === 'status-only') {
110
+ errors.push(`「${entry?.ui?.label ?? path}」为只读项(值由运行时 / 自动推导,如 relay 池、自动同步):不能通过控制台修改;如需变更请手工编辑 config.json`);
111
+ } else {
112
+ errors.push(`不允许修改 ${path}(设备身份 / 存储 / 加密等请手工编辑 config.json)`);
113
+ }
114
+ continue;
115
+ }
116
+ const spec = { kind: entry.kind, ...(entry.spec ?? {}) };
117
+ if (spec.kind === 'bool') {
118
+ if (typeof raw !== 'boolean') errors.push(`${path} 需要布尔值`);
119
+ else entries.push({ path, value: raw });
120
+ } else if (spec.kind === 'int' || spec.kind === 'num') {
121
+ if (raw === null && spec.nullable) {
122
+ entries.push({ path, value: null });
123
+ } else if (typeof raw !== 'number' || !Number.isFinite(raw) || (spec.kind === 'int' && !Number.isInteger(raw))) {
124
+ errors.push(`${path} 需要${spec.kind === 'int' ? '整数' : '数字'}`);
125
+ } else if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
126
+ errors.push(`${path} 超出范围(${spec.min ?? '-∞'} ~ ${spec.max ?? '∞'})`);
127
+ } else {
128
+ entries.push({ path, value: raw });
129
+ }
130
+ } else if (spec.kind === 'string') {
131
+ if (typeof raw !== 'string') errors.push(`${path} 需要字符串`);
132
+ else if (raw.trim().length === 0 && spec.empty === 'delete') entries.push({ path, value: null });
133
+ else if (spec.nonEmpty && raw.trim().length === 0) errors.push(`${path} 需要非空字符串`);
134
+ else entries.push({ path, value: raw.trim() });
135
+ } else if (spec.kind === 'enum') {
136
+ if (!spec.values.includes(raw)) errors.push(`${path} 必须是 ${spec.values.join(' | ')}`);
137
+ else entries.push({ path, value: raw });
138
+ } else if (spec.kind === 'list') {
139
+ if (!Array.isArray(raw) || raw.some((v) => typeof v !== 'string')) {
140
+ errors.push(`${path} 需要字符串数组`);
141
+ } else {
142
+ const list = [...new Set(raw.map((v) => v.trim()).filter(Boolean))];
143
+ if (spec.prefix && list.some((v) => !v.startsWith(spec.prefix))) {
144
+ errors.push(`${path} 每项需以 ${spec.prefix} 开头的 multiaddr`);
145
+ } else if (list.length === 0 && spec.empty === 'delete') {
146
+ entries.push({ path, value: null });
147
+ } else {
148
+ entries.push({ path, value: list });
149
+ }
150
+ }
151
+ }
152
+ }
153
+ if (entries.length === 0 && errors.length === 0) errors.push('patch 为空');
154
+ return { errors, entries };
155
+ }
156
+
157
+ function setAtPath(root, path, value) {
158
+ const keys = path.split('.');
159
+ let node = root;
160
+ for (const key of keys.slice(0, -1)) {
161
+ if (node[key] === null || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
162
+ node = node[key];
163
+ }
164
+ node[keys[keys.length - 1]] = value;
165
+ }
166
+
167
+ function deleteAtPath(root, path) {
168
+ const keys = path.split('.');
169
+ const chain = [root];
170
+ let node = root;
171
+ for (const key of keys.slice(0, -1)) {
172
+ if (node[key] === null || typeof node[key] !== 'object' || Array.isArray(node[key])) return;
173
+ node = node[key];
174
+ chain.push(node);
175
+ }
176
+ delete node[keys[keys.length - 1]];
177
+ // 修剪残留的空对象(如删除 intervalMs 后 sync.antiEntropy 变空)
178
+ for (let i = chain.length - 1; i > 0; i -= 1) {
179
+ if (Object.keys(chain[i]).length === 0) delete chain[i - 1][keys[i - 1]];
180
+ else break;
181
+ }
182
+ }
183
+ import { endpointHostname, isLoopbackHost } from './lan-host.mjs';
184
+ import { activateJoinInvite, buildJoinToken } from './jointoken.mjs';
185
+ import { renderSvgQr } from './qr.mjs';
186
+ import { clearRuntimeFile, writeRuntimeFile } from './runtime-file.mjs';
187
+
188
+ // R1.3:记忆面 memory.* + 任务面 task.*(两轴独立;同一轴内 write ⇒ read、admin ⇒ 其余)
189
+ const SCOPES = ['memory.read', 'memory.write', 'memory.admin', 'task.read', 'task.write'];
190
+ const SCOPE_IMPLIES = {
191
+ 'memory.read': ['memory.read'],
192
+ 'memory.write': ['memory.read', 'memory.write'],
193
+ 'memory.admin': ['memory.read', 'memory.write', 'memory.admin'],
194
+ 'task.read': ['task.read'],
195
+ 'task.write': ['task.read', 'task.write'],
196
+ };
197
+ const DEFAULT_SCOPES = ['memory.read'];
198
+ const ACCESS_TTL = 900; // 15min
199
+ const REFRESH_TTL = 30 * 24 * 3600; // 30d
200
+ const RATE_WINDOW_MS = 60_000;
201
+ // 优雅停止的有限兜底:MCP/SSE/keep-alive 连接可能一直不主动断开。
202
+ // 先收 MCP sessions 与连接,再等 listener;最坏情况下整体不超过该值。
203
+ const CLOSE_TIMEOUT_MS = 5_000;
204
+ const SESSION_CLOSE_TIMEOUT_MS = 1_000;
205
+
206
+ /** 等待 promise 落定,最多 ms;用于关闭流程里不阻塞后续步骤。 */
207
+ function settleWithin(promise, ms) {
208
+ return new Promise((resolve) => {
209
+ let done = false;
210
+ let timer = null;
211
+ const finish = () => {
212
+ if (done) return;
213
+ done = true;
214
+ clearTimeout(timer);
215
+ resolve();
216
+ };
217
+ timer = setTimeout(finish, Math.max(0, ms));
218
+ Promise.resolve(promise).then(finish, finish);
219
+ });
220
+ }
221
+
222
+ // /healthz、/app/embed/info 与 <home>/runtime.json 共用的对外版本号与 embed 协议版本。
223
+ // 单一真源:不要在 buildEmbedInfo / runtime 写入处再写字面量。
224
+ export const APP_VERSION = '0.1.0';
225
+ export const EMBED_PROTOCOL = 1;
226
+
227
+ // ---------- 控制台静态托管(D1) ----------
228
+ // 路径白名单:只认识这几个确切文件名,天然防目录穿越。允许 MEBULAR_CONSOLE_DIR 覆盖。
229
+ const CONSOLE_DIR =
230
+ process.env.MEBULAR_CONSOLE_DIR ?? join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'console');
231
+ // locale pack 数据层:唯一注册表 + 每语言目录(只读 JSON)。
232
+ // 允许 MEBULAR_LOCALES_DIR 覆盖(桌面打包时 extraResources 落在 <resources>/locales)。
233
+ const LOCALES_DIR =
234
+ process.env.MEBULAR_LOCALES_DIR ?? join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'locales');
235
+ const LOCALE_CODE_RE = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/;
236
+ const LOCALE_PACK_FILES = new Set(['console.json', 'desktop.json', 'errors.json']);
237
+ const CONSOLE_FILES = {
238
+ 'index.html': 'text/html; charset=utf-8',
239
+ 'shell-boot.js': 'text/javascript; charset=utf-8', // 走查修复 B:壳安全区首帧引导脚本
240
+ 'console.css': 'text/css; charset=utf-8',
241
+ 'console.js': 'text/javascript; charset=utf-8',
242
+ 'host.js': 'text/javascript; charset=utf-8',
243
+ 'dropdown.mjs': 'text/javascript; charset=utf-8',
244
+ 'assistants.js': 'text/javascript; charset=utf-8',
245
+ 'assistant-status.js': 'text/javascript; charset=utf-8',
246
+ 'deep-link.js': 'text/javascript; charset=utf-8',
247
+ 'settings-ia.js': 'text/javascript; charset=utf-8',
248
+ 'i18n.mjs': 'text/javascript; charset=utf-8',
249
+ 'starfield.js': 'text/javascript; charset=utf-8',
250
+ 'wizard.js': 'text/javascript; charset=utf-8',
251
+ 'nebula.js': 'text/javascript; charset=utf-8',
252
+ 'stars.js': 'text/javascript; charset=utf-8',
253
+ };
254
+
255
+ // CSRF:页面加载签发(SameSite=Strict cookie + 响应头),写请求要求头与 cookie 双提交且 token 在签发集合内。
256
+ const CSRF_COOKIE = 'mebular_csrf';
257
+ const CSRF_TTL_MS = 12 * 3600_000;
258
+ const issuedCsrf = new Map(); // token -> expiresAt
259
+
260
+ function issueCsrf() {
261
+ const token = randomUUID().replace(/-/g, '');
262
+ issuedCsrf.set(token, Date.now() + CSRF_TTL_MS);
263
+ if (issuedCsrf.size > 512) {
264
+ const now = Date.now();
265
+ for (const [key, exp] of issuedCsrf) if (exp < now) issuedCsrf.delete(key);
266
+ }
267
+ return token;
268
+ }
269
+
270
+ function parseCookies(header) {
271
+ const out = {};
272
+ if (typeof header !== 'string') return out;
273
+ for (const part of header.split(';')) {
274
+ const idx = part.indexOf('=');
275
+ if (idx === -1) continue;
276
+ out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function csrfValid(req) {
282
+ const header = req.headers['x-mebular-csrf'];
283
+ const token = typeof header === 'string' ? header : '';
284
+ if (!token || !issuedCsrf.has(token)) return false;
285
+ if ((issuedCsrf.get(token) ?? 0) < Date.now()) {
286
+ issuedCsrf.delete(token);
287
+ return false;
288
+ }
289
+ const cookie = parseCookies(req.headers['cookie'])[CSRF_COOKIE];
290
+ return cookie === token;
291
+ }
292
+
293
+ export function consoleSecurityHeaders(res, { tls = false, embed = false, loopback = false } = {}) {
294
+ res.setHeader('x-content-type-options', 'nosniff');
295
+ res.setHeader('referrer-policy', 'no-referrer');
296
+ const frameable = embed === true && loopback === true;
297
+ if (!frameable) res.setHeader('x-frame-options', 'DENY');
298
+ const frameAncestors = frameable
299
+ ? 'http://127.0.0.1:* http://localhost:*'
300
+ : "'none'";
301
+ res.setHeader(
302
+ 'content-security-policy',
303
+ `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors ${frameAncestors}`,
304
+ );
305
+ void tls;
306
+ }
307
+
308
+ async function sendConsoleFile(res, file, { embed = false, loopback = false } = {}) {
309
+ const resolved = join(CONSOLE_DIR, file);
310
+ let content;
311
+ try {
312
+ content = await readFile(resolved);
313
+ } catch {
314
+ return sendJson(res, 404, {
315
+ error: 'console_not_found',
316
+ message: `未找到控制台资源 ${file}(${resolved})。请确认 packages/console 存在,或用 MEBULAR_CONSOLE_DIR 指定。`,
317
+ });
318
+ }
319
+ res.statusCode = 200;
320
+ res.setHeader('content-type', CONSOLE_FILES[file]);
321
+ res.setHeader('cache-control', file === 'index.html' ? 'no-store' : 'no-cache');
322
+ consoleSecurityHeaders(res, { embed, loopback });
323
+ res.end(content);
324
+ }
325
+
326
+ /**
327
+ * Client-usable host for runtime.json. Wildcard binds are reachable on loopback;
328
+ * `localhost` is normalized so the file never depends on resolver behavior.
329
+ * @param {string} host
330
+ */
331
+ function runtimeHost(host) {
332
+ if (host === '0.0.0.0') return '127.0.0.1';
333
+ if (host === '::' || host === '::0') return '::1';
334
+ if (host === 'localhost') return '127.0.0.1';
335
+ return host;
336
+ }
337
+
338
+ /** DSH 发现契约:只暴露非敏感信息;非 loopback 明确不提供嵌入。 */
339
+ export function buildEmbedInfo({ origin, loopback = false } = {}) {
340
+ const base = String(origin ?? '').replace(/\/+$/, '');
341
+ const canEmbed = loopback === true;
342
+ return {
343
+ ok: true,
344
+ name: 'mebular',
345
+ version: APP_VERSION,
346
+ embedProtocol: EMBED_PROTOCOL,
347
+ consoleUrl: `${base}/console`,
348
+ embedUrl: `${base}/console/?embed=dsh`,
349
+ mcpUrl: `${base}/mcp`,
350
+ loopback: canEmbed,
351
+ capabilities: { embedConsole: canEmbed },
352
+ };
353
+ }
354
+
355
+ function safeEqual(a, b) {
356
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
357
+ const ab = Buffer.from(a);
358
+ const bb = Buffer.from(b);
359
+ if (ab.length !== bb.length) return false;
360
+ return timingSafeEqual(ab, bb);
361
+ }
362
+
363
+ function basicPassword(header) {
364
+ if (typeof header !== 'string' || !header.startsWith('Basic ')) return null;
365
+ try {
366
+ const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf-8');
367
+ const idx = decoded.indexOf(':');
368
+ return idx === -1 ? null : decoded.slice(idx + 1);
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ function makeRateLimiter(max) {
375
+ const hits = new Map();
376
+ return (key) => {
377
+ const now = Date.now();
378
+ const list = (hits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS);
379
+ list.push(now);
380
+ hits.set(key, list);
381
+ if (list.length > max) {
382
+ return { limited: true, retryAfter: Math.max(1, Math.ceil((RATE_WINDOW_MS - (now - list[0])) / 1000)) };
383
+ }
384
+ return { limited: false };
385
+ };
386
+ }
387
+
388
+ // ---------- 客户端注册 / 同意码 / 撤销持久(D45) ----------
389
+
390
+ async function loadClientRegistry(clientsFile) {
391
+ if (!existsSync(clientsFile)) return [];
392
+ try {
393
+ const data = JSON.parse(await readFile(clientsFile, 'utf-8'));
394
+ return Array.isArray(data.clients) ? data.clients : [];
395
+ } catch (error) {
396
+ throw new Error(`clientsFile 损坏:${clientsFile}(${error.message})`);
397
+ }
398
+ }
399
+
400
+ async function findClient(clientsFile, clientId) {
401
+ if (!clientId) return null;
402
+ return (await loadClientRegistry(clientsFile)).find((c) => c.clientId === clientId) ?? null;
403
+ }
404
+
405
+ async function saveClientRegistry(clientsFile, clients) {
406
+ await mkdir(dirname(clientsFile), { recursive: true });
407
+ await writeFile(clientsFile, JSON.stringify({ clients }, null, 2), 'utf-8');
408
+ await chmod(clientsFile, 0o600);
409
+ }
410
+
411
+ async function consumeConsent(consentFile, code) {
412
+ if (!code || !existsSync(consentFile)) return null;
413
+ let data;
414
+ try {
415
+ data = JSON.parse(await readFile(consentFile, 'utf-8'));
416
+ } catch {
417
+ return null;
418
+ }
419
+ const codes = Array.isArray(data.codes) ? data.codes : [];
420
+ const idx = codes.findIndex((c) => c.code === code && !c.usedAt && c.exp > Date.now());
421
+ if (idx === -1) return null;
422
+ codes[idx].usedAt = new Date().toISOString();
423
+ await writeFile(consentFile, JSON.stringify({ codes }, null, 2), 'utf-8');
424
+ await chmod(consentFile, 0o600);
425
+ return codes[idx];
426
+ }
427
+
428
+ async function loadRevoked(revokedFile) {
429
+ if (!existsSync(revokedFile)) return new Set();
430
+ try {
431
+ const data = JSON.parse(await readFile(revokedFile, 'utf-8'));
432
+ return new Set(Array.isArray(data.jti) ? data.jti : []);
433
+ } catch {
434
+ return new Set();
435
+ }
436
+ }
437
+
438
+ async function persistRevoked(revokedFile, revoked) {
439
+ await mkdir(dirname(revokedFile), { recursive: true });
440
+ await writeFile(revokedFile, JSON.stringify({ jti: [...revoked], updatedAt: new Date().toISOString() }, null, 2), 'utf-8');
441
+ await chmod(revokedFile, 0o600);
442
+ }
443
+
444
+ // ---------- 单实例锁 ----------
445
+
446
+ export function pidAlive(pid) {
447
+ try {
448
+ process.kill(pid, 0);
449
+ return true;
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+
455
+ export async function acquireLock(home, storagePath) {
456
+ const path = join(home, 'lock');
457
+ await mkdir(dirname(path), { recursive: true });
458
+ const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), storagePath });
459
+ const write = () => writeFile(path, payload, 'utf-8');
460
+ const release = async () => { await unlink(path).catch(() => undefined); };
461
+
462
+ if (!existsSync(path)) {
463
+ await writeFile(path, payload, { encoding: 'utf-8', flag: 'wx' });
464
+ return { path, release };
465
+ }
466
+ let held = null;
467
+ try {
468
+ held = JSON.parse(await readFile(path, 'utf-8'));
469
+ } catch {
470
+ // 损坏锁按陈旧处理
471
+ }
472
+ if (held?.pid && pidAlive(held.pid)) {
473
+ const error = new Error(
474
+ `存储已被占用:pid ${held.pid}(自 ${held.startedAt},${held.storagePath})。请复用该 serve 实例,或先停止它。`,
475
+ );
476
+ error.code = 'MCP_STORAGE_LOCKED';
477
+ throw error;
478
+ }
479
+ // 陈旧锁回收
480
+ await write();
481
+ return { path, release };
482
+ }
483
+
484
+ // ---------- bearer ----------
485
+
486
+ export function hashToken(token) {
487
+ return createHash('sha256').update(token).digest('hex');
488
+ }
489
+
490
+ export async function loadTokens(tokensFile) {
491
+ if (!tokensFile || !existsSync(tokensFile)) return [];
492
+ try {
493
+ const data = JSON.parse(await readFile(tokensFile, 'utf-8'));
494
+ return Array.isArray(data.tokens) ? data.tokens : [];
495
+ } catch (error) {
496
+ throw new Error(`tokensFile 损坏:${tokensFile}(${error.message})`);
497
+ }
498
+ }
499
+
500
+ async function verifyBearer(token, tokensFile) {
501
+ const digest = hashToken(token);
502
+ const tokens = await loadTokens(tokensFile);
503
+ const record = tokens.find((t) => t.sha256 === digest && !t.revoked);
504
+ if (!record) return null;
505
+ return { tokenId: record.id ?? 'bearer', scopes: record.scope ?? [] };
506
+ }
507
+
508
+ // ---------- OAuth 最小 AS/RS ----------
509
+
510
+ function b64url(bytes) {
511
+ return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
512
+ }
513
+ function b64urlToBytes(s) {
514
+ return new Uint8Array(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'));
515
+ }
516
+ function ab(bytes) {
517
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
518
+ }
519
+
520
+ async function loadOrCreateSigningKey(home) {
521
+ const path = join(home, 'auth', 'as-key.json');
522
+ if (existsSync(path)) {
523
+ const data = JSON.parse(await readFile(path, 'utf-8'));
524
+ const privateKey = await crypto.subtle.importKey('pkcs8', ab(b64urlToBytes(data.privateKeyPkcs8)), { name: 'Ed25519' }, true, ['sign']);
525
+ return { privateKey, publicRaw: b64urlToBytes(data.publicKey), kid: data.kid };
526
+ }
527
+ const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
528
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', kp.privateKey));
529
+ const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
530
+ const kid = randomUUID();
531
+ await mkdir(dirname(path), { recursive: true });
532
+ await writeFile(path, JSON.stringify({ privateKeyPkcs8: b64url(pkcs8), publicKey: b64url(publicRaw), kid }), 'utf-8');
533
+ await chmod(path, 0o600);
534
+ return { privateKey: kp.privateKey, publicRaw, kid };
535
+ }
536
+
537
+ async function signJwt(signingKey, payload) {
538
+ const header = { alg: 'EdDSA', typ: 'JWT', kid: signingKey.kid };
539
+ const body = `${b64url(Buffer.from(JSON.stringify(header)))}.${b64url(Buffer.from(JSON.stringify(payload)))}`;
540
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, signingKey.privateKey, new TextEncoder().encode(body)));
541
+ return `${body}.${b64url(sig)}`;
542
+ }
543
+
544
+ async function verifyJwt(signingKey, token, { issuer, resource, revoked }) {
545
+ const parts = String(token).split('.');
546
+ if (parts.length !== 3) return null;
547
+ const [h, p, s] = parts;
548
+ const data = new TextEncoder().encode(`${h}.${p}`);
549
+ const key = await crypto.subtle.importKey('raw', ab(signingKey.publicRaw), { name: 'Ed25519' }, false, ['verify']);
550
+ const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, ab(b64urlToBytes(s)), data);
551
+ if (!ok) return null;
552
+ let payload;
553
+ try {
554
+ payload = JSON.parse(Buffer.from(b64urlToBytes(p), 'utf8').toString('utf-8'));
555
+ } catch {
556
+ return null;
557
+ }
558
+ const now = Math.floor(Date.now() / 1000);
559
+ if (payload.iss !== issuer) return null;
560
+ if (payload.aud !== resource) return null;
561
+ if (typeof payload.exp !== 'number' || payload.exp < now) return null;
562
+ if (revoked.has(payload.jti)) return null;
563
+ return payload;
564
+ }
565
+
566
+ function metadataFor(issuer, { registrationEnabled = false } = {}) {
567
+ const resource = `${issuer}/mcp`;
568
+ return {
569
+ protectedResource: {
570
+ resource,
571
+ authorization_servers: [issuer],
572
+ scopes_supported: SCOPES,
573
+ bearer_methods_supported: ['header'],
574
+ },
575
+ authorizationServer: {
576
+ issuer,
577
+ authorization_endpoint: `${issuer}/authorize`,
578
+ token_endpoint: `${issuer}/token`,
579
+ ...(registrationEnabled ? { registration_endpoint: `${issuer}/register` } : {}),
580
+ jwks_uri: `${issuer}/jwks`,
581
+ response_types_supported: ['code'],
582
+ grant_types_supported: ['authorization_code', 'refresh_token'],
583
+ code_challenge_methods_supported: ['S256'],
584
+ token_endpoint_auth_methods_supported: ['none'],
585
+ scopes_supported: SCOPES,
586
+ },
587
+ };
588
+ }
589
+
590
+ // ---------- 请求/响应桥 ----------
591
+
592
+ function toWebRequest(req, body, origin) {
593
+ const headers = new Headers();
594
+ for (const [k, v] of Object.entries(req.headers)) {
595
+ if (Array.isArray(v)) for (const item of v) headers.append(k, item);
596
+ else if (v !== undefined) headers.set(k, v);
597
+ }
598
+ return new Request(new URL(req.url, origin), {
599
+ method: req.method,
600
+ headers,
601
+ ...(body && body.length > 0 && req.method !== 'GET' && req.method !== 'HEAD' ? { body } : {}),
602
+ });
603
+ }
604
+
605
+ async function writeWebResponse(res, response) {
606
+ res.statusCode = response.status;
607
+ for (const [k, v] of response.headers) res.setHeader(k, v);
608
+ if (response.body) {
609
+ const reader = response.body.getReader();
610
+ for (;;) {
611
+ const { done, value } = await reader.read();
612
+ if (done) break;
613
+ res.write(Buffer.from(value));
614
+ }
615
+ }
616
+ res.end();
617
+ }
618
+
619
+ /**
620
+ * C2:邀请令牌的地址 hints —— 邀请方可达 multiaddr(含 /p2p/<peerId>)+ 可共享 relay seeds + pubReachable。
621
+ * 只影响「候选地址簿」,与授权/成员判定无关。缺节点(网络未启用)时返回空 hints(令牌与旧格式等价)。
622
+ */
623
+ function collectInviteHints(app, config) {
624
+ const node = app?.node;
625
+ const addrs = node && typeof node.getLocalMultiaddrs === 'function' ? node.getLocalMultiaddrs() ?? [] : [];
626
+ const peerId = node?.peerId?.id ?? null;
627
+ const endpoints = [];
628
+ for (const addr of addrs) {
629
+ if (typeof addr !== 'string' || addr.includes('/p2p-circuit')) continue;
630
+ const withPeer = /\/p2p\//.test(addr) || !peerId ? addr : `${addr}/p2p/${peerId}`;
631
+ if (!endpoints.includes(withPeer)) endpoints.push(withPeer);
632
+ }
633
+ const relaySeeds = Array.isArray(config?.network?.relaySeeds)
634
+ ? config.network.relaySeeds.filter((entry) => typeof entry === 'string' && entry.length > 0)
635
+ : [];
636
+ return {
637
+ ...(endpoints.length > 0 ? { endpoints } : {}),
638
+ ...(relaySeeds.length > 0 ? { relaySeeds } : {}),
639
+ ...(endpoints.length > 0 ? { pubReachable: endpoints.some((addr) => !isLoopbackMultiaddr(addr)) } : {}),
640
+ };
641
+ }
642
+
643
+ /** 地址是否为回环(pubReachable 提示用)。 */
644
+ function isLoopbackMultiaddr(address) {
645
+ const host = /\/(?:ip4|ip6|dns4|dns6|dns)\/([^/]+)/.exec(String(address))?.[1] ?? '';
646
+ return ['127.0.0.1', '::1', 'localhost', ''].includes(host) || host.startsWith('127.');
647
+ }
648
+
649
+ // ---------- 安全三档(S2):写端点门禁 + 安全后端端点 ----------
650
+ //
651
+ // S1 未合并时 `getSecurityTier()` 缺失 → fleet security 模块按 low 处理(不炸、不放宽)。
652
+ // 本文件的 `securityWriteGate` 只做**本机程序门槛**;权威采纳(会签/恢复码)在 core 推导。
653
+ let fleetSecurityPromise = null;
654
+
655
+ /** 延迟加载 @mebular/fleet 安全模块(测试/未构建时返回 null,门禁按 low 放行)。 */
656
+ export function loadFleetSecurity() {
657
+ if (fleetSecurityPromise === null) {
658
+ fleetSecurityPromise = import('../../fleet/dist/index.js').catch(() => null);
659
+ }
660
+ return fleetSecurityPromise;
661
+ }
662
+
663
+ /**
664
+ * 写端点门禁:medium 要动态码;high 要会签批准/恢复码;low 放行。
665
+ * 导出供 S2-3 单测直接断言(HTTP 层同样走这里)。
666
+ */
667
+ export async function securityWriteGate({ app, home, path, input = {}, namespace = 'tasks' }) {
668
+ const lib = await loadFleetSecurity();
669
+ if (!lib || typeof lib.evaluateSecurityGate !== 'function') return { ok: true };
670
+ let action = null;
671
+ let subject;
672
+ let level;
673
+ if (path === '/admin/api/invite') {
674
+ action = 'invite';
675
+ } else if (/^\/admin\/api\/devices\/[^/]+\/revoke$/.test(path)) {
676
+ action = 'revoke';
677
+ subject = decodeURIComponent(path.split('/')[4] ?? '');
678
+ } else if (path === '/admin/api/security/tier') {
679
+ action = 'tier';
680
+ level = typeof input.level === 'string' ? input.level : undefined;
681
+ }
682
+ if (action === null) return { ok: true };
683
+ // S4:恢复码不是预签证明,必须由端点 derive + sign 后才落盘。
684
+ // 这里只放行 revoke/tier 的 recoveryCode 请求;档位/当前 keyId 的权威校验在端点内完成,
685
+ // 避免在门禁里提前消费一次性 nonce,也避免把码写进任何错误路径。
686
+ const recoveryCode = typeof input.recoveryCode === 'string' ? input.recoveryCode.trim() : '';
687
+ if (recoveryCode.length > 0 && (action === 'revoke' || action === 'tier')) return { ok: true };
688
+ return lib.evaluateSecurityGate(app, home, {
689
+ action,
690
+ ...(typeof input.code === 'string' ? { code: input.code } : {}),
691
+ ...(input.cosign !== undefined ? { cosign: input.cosign } : {}),
692
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass } : {}),
693
+ ...(typeof input.approvalId === 'string' ? { approvalId: input.approvalId } : {}),
694
+ ...(subject !== undefined ? { subject } : {}),
695
+ ...(level !== undefined ? { level } : {}),
696
+ namespace: typeof input.namespace === 'string' && input.namespace.length > 0 ? input.namespace : namespace,
697
+ });
698
+ }
699
+
700
+ /**
701
+ * S4:恢复码代签(服务端 derive → 对齐当刻 keyId → signBreakGlass)。
702
+ * 仅高档可用;返回证明供 writeDeviceRevoke / writeSecurityTier 落盘。
703
+ * 本函数及调用方都不得记录/回显 code(只在内存里派生私钥)。
704
+ */
705
+ async function signRecoveryProof({ lib, app, code, op, subject, level, initiator }) {
706
+ const state = await lib.readSecurityState(app);
707
+ if (state.level !== 'high') {
708
+ return {
709
+ ok: false,
710
+ status: 409,
711
+ error: 'recovery_not_applicable',
712
+ message: `当前不是高档(${state.level}):恢复码只用于替代高档会签,无需输入。`,
713
+ };
714
+ }
715
+ // 以 core 的当刻派生为准(getSecurityTier().recoveryKeyId):不在服务端另建登记口径。
716
+ const currentKeyId = state.recoveryKeyId;
717
+ if (currentKeyId === undefined) {
718
+ return {
719
+ ok: false,
720
+ status: 409,
721
+ error: 'recovery_missing',
722
+ message: '尚未生成恢复码:请先在安全卡生成/轮换恢复码。',
723
+ };
724
+ }
725
+ let derived;
726
+ try {
727
+ derived = lib.deriveRecoveryKey(code);
728
+ } catch {
729
+ return {
730
+ ok: false,
731
+ status: 400,
732
+ error: 'recovery_invalid',
733
+ message: '恢复码格式不正确:请完整输入 34 位(连字符可省略)。',
734
+ };
735
+ }
736
+ if (derived.keyId !== currentKeyId) {
737
+ return {
738
+ ok: false,
739
+ status: 403,
740
+ error: 'recovery_mismatch',
741
+ message: '恢复码不匹配当前 keyId(可能已被轮换):请使用最新恢复码。',
742
+ };
743
+ }
744
+ const signed = lib.signBreakGlass({
745
+ code,
746
+ op,
747
+ initiator,
748
+ ...(subject !== undefined ? { subject } : {}),
749
+ ...(level !== undefined ? { level } : {}),
750
+ });
751
+ const verified = lib.verifyBreakGlassProof(signed.proof, {
752
+ publicKeyHex: derived.publicKeyHex,
753
+ expectedDigest: signed.digest,
754
+ currentKeyId,
755
+ });
756
+ if (!verified.ok) {
757
+ return {
758
+ ok: false,
759
+ status: 500,
760
+ error: 'recovery_sign_failed',
761
+ message: '恢复码签名校验失败:请重试;若持续失败请轮换恢复码。',
762
+ };
763
+ }
764
+ return { ok: true, proof: signed.proof, digest: signed.digest, nonce: signed.nonce };
765
+ }
766
+
767
+ function sendJson(res, status, data) {
768
+ const body = JSON.stringify(data);
769
+ res.statusCode = status;
770
+ res.setHeader('content-type', 'application/json');
771
+ res.end(body);
772
+ }
773
+
774
+ /** 持久化运行期订阅集(热生效;空 = 订阅全部,删除显式键)。 */
775
+ async function persistSubscriptionNamespaces(home, next) {
776
+ const cfgFile = configPath(home);
777
+ let current = {};
778
+ try {
779
+ current = JSON.parse(await readFile(cfgFile, 'utf-8'));
780
+ } catch {
781
+ current = {};
782
+ }
783
+ const merged = JSON.parse(JSON.stringify(current));
784
+ if (next.length === 0) delete merged.sync?.namespaces;
785
+ else {
786
+ merged.sync = merged.sync && typeof merged.sync === 'object' ? merged.sync : {};
787
+ merged.sync.namespaces = next;
788
+ }
789
+ const tmp = `${cfgFile}.tmp-${process.pid}`;
790
+ await writeFile(tmp, JSON.stringify(merged, null, 2), 'utf-8');
791
+ if (existsSync(cfgFile)) await copyFile(cfgFile, `${cfgFile}.bak`);
792
+ await rename(tmp, cfgFile);
793
+ }
794
+
795
+ function readBody(req) {
796
+ return new Promise((resolve, reject) => {
797
+ const chunks = [];
798
+ req.on('data', (c) => chunks.push(c));
799
+ req.on('end', () => resolve(Buffer.concat(chunks)));
800
+ req.on('error', reject);
801
+ });
802
+ }
803
+
804
+ /** 请求体所需 scope(可多条;记忆与任务两轴互不蕴含)。 */
805
+ function requiredScopeForBody(body) {
806
+ let messages;
807
+ try {
808
+ const parsed = JSON.parse(body.toString('utf-8'));
809
+ messages = Array.isArray(parsed) ? parsed : [parsed];
810
+ } catch {
811
+ return ['memory.read'];
812
+ }
813
+ const required = new Set();
814
+ for (const msg of messages) {
815
+ if (!msg || typeof msg.method !== 'string') continue;
816
+ if (msg.method === 'initialize' || msg.method.startsWith('notifications/') || msg.method === 'ping') continue;
817
+ if (msg.method === 'tools/call') {
818
+ required.add(TOOL_ALL_SCOPES[msg.params?.name] ?? 'memory.read');
819
+ } else if (msg.method === 'prompts/list' || msg.method === 'prompts/get'
820
+ || msg.method === 'resources/list' || msg.method === 'resources/read' || msg.method === 'resources/templates/list') {
821
+ // 评审:prompts/resources 按声明 scope 校验(静态 memory_policy 属读面);不得随纯元数据放宽
822
+ required.add('memory.read');
823
+ }
824
+ // 纯元数据(tools/list、initialize、ping、notifications/*):只要求已认证
825
+ // —— 统一入口的工具清单横跨记忆与任务两轴,按 memory.read 要求会挡住 task.read-only 令牌。
826
+ }
827
+ return [...required];
828
+ }
829
+
830
+ /** 授权是否覆盖所需 scope(按轴蕴含:memory.admin ⊇ memory.write ⊇ memory.read;task.write ⊇ task.read)。 */
831
+ function hasScope(granted, required) {
832
+ const list = Array.isArray(required) ? required : [required];
833
+ const grantedList = granted ?? [];
834
+ return list.every((req) => grantedList.some((s) => (SCOPE_IMPLIES[s] ?? []).includes(req)));
835
+ }
836
+
837
+ /**
838
+ * 启动 HTTP MCP server。
839
+ * @returns {Promise<{ server: import('node:http').Server, host: string, port: number, auth: string, close: () => Promise<void> }>}
840
+ */
841
+ export async function startHttpServer({
842
+ home,
843
+ app: initialApp = null,
844
+ service: initialService = null,
845
+ config: initialConfig = {},
846
+ buildServer,
847
+ host = '127.0.0.1',
848
+ port = 7331,
849
+ auth = 'none',
850
+ tls = false,
851
+ tlsKey,
852
+ tlsCert,
853
+ tokensFile,
854
+ deviceId: initialDeviceId = null,
855
+ namespace: initialNamespace = 'tasks',
856
+ joinEndpoint: initialJoinEndpoint,
857
+ // 生效运行时快照(console 设置卡展示用;含 CLI/env 覆盖后的实际值)
858
+ runtime: initialRuntime = null,
859
+ // D1 只读;D2 打开写端点(仍需 memory.admin scope + CSRF)
860
+ writesEnabled: initialWritesEnabled = false,
861
+ // C:一键重启执行器(可注入;verify:config 用 MEBULAR_RESTART_DRY_RUN 或注入替身)
862
+ restartRunner = null,
863
+ // 引导态(空家目录):只服务 /healthz + /console 引导页 + /app/provision/*;其余 409 provision_required
864
+ provision: initialProvision = false,
865
+ // P1:同一进程内热初始化。返回新的运行时上下文;成功后本 server 从 UNINITIALIZED → READY。
866
+ initializeRuntime = null,
867
+ // 关闭钩子:热初始化后由 server.mjs 关闭 app / join / 广播计时器(HTTP server 本身由本函数关闭)
868
+ onClose = null,
869
+ }) {
870
+ // P1:生命周期状态。所有路由共用**可变**上下文;create/join 成功后就地切换,不重启进程 / 端口。
871
+ let app = initialApp;
872
+ let service = initialService;
873
+ let config = initialConfig ?? {};
874
+ let runtime = initialRuntime;
875
+ let deviceId = initialDeviceId;
876
+ let namespace = initialNamespace;
877
+ let joinEndpoint = initialJoinEndpoint;
878
+ let writesEnabled = initialWritesEnabled;
879
+ let phase = initialProvision ? 'UNINITIALIZED' : 'READY';
880
+ let hotInitialized = false;
881
+ let activeLock = null;
882
+ const isLoopback = host === '127.0.0.1' || host === 'localhost' || host === '::1';
883
+ if (!isLoopback && (auth === 'none' || !tls)) {
884
+ const error = new Error(
885
+ `fail closed:非环回地址 ${host} 必须启用 TLS 且 auth != none(当前 auth=${auth}, tls=${tls})`,
886
+ );
887
+ error.code = 'MCP_INSECURE_CONFIG';
888
+ throw error;
889
+ }
890
+ if (!isLoopback && tls && !tlsKey) {
891
+ const error = new Error('fail closed:TLS 需要提供证书(tlsKey/tlsCert)');
892
+ error.code = 'MCP_INSECURE_CONFIG';
893
+ throw error;
894
+ }
895
+
896
+ // 邀请 nonce 状态与 join 服务共用同一 storagePath(生成新令牌时撤销旧 active)。
897
+ const computeJoinStateStoragePath = () => (typeof runtime?.storagePath === 'string'
898
+ ? runtime.storagePath
899
+ : typeof config?.storagePath === 'string' ? config.storagePath : join(home, 'store.jsonl'));
900
+ let joinStateStoragePath = computeJoinStateStoragePath();
901
+
902
+ // 忆云档案上下文(root 解析 + 单活)。自定义 home / 危险 env → available:false(档案端点 503/403)。
903
+ const cloud = resolveCloudRoot({ home, env: process.env });
904
+ let cloudId = app && service && cloud.available
905
+ ? await profileIdForHome(cloud.root, home).catch(() => null)
906
+ : null;
907
+
908
+ /** 热初始化成功后把路由可见的上下文整体切换;失败时调用方负责回滚文件并保持 UNINITIALIZED。 */
909
+ async function activateRuntime(next, { viaHotInit = false } = {}) {
910
+ if (!next?.app || !next?.service) throw new Error('初始化结果缺少 app/service');
911
+ app = next.app;
912
+ service = next.service;
913
+ config = next.config ?? {};
914
+ runtime = next.runtime ?? null;
915
+ deviceId = next.deviceId ?? null;
916
+ namespace = next.namespace ?? 'tasks';
917
+ joinEndpoint = next.joinEndpoint;
918
+ if (next.writesEnabled !== undefined) writesEnabled = Boolean(next.writesEnabled);
919
+ if (next.activeLock) activeLock = next.activeLock;
920
+ joinStateStoragePath = computeJoinStateStoragePath();
921
+ cloudId = cloud.available ? await profileIdForHome(cloud.root, home).catch(() => null) : null;
922
+ phase = 'READY';
923
+ if (viaHotInit) hotInitialized = true;
924
+ return { phase, cloudId };
925
+ }
926
+
927
+ const scheme = tls ? 'https' : 'http';
928
+ // D45:OAuth 硬化配置(密钥/secret 不落 config,仅 env)
929
+ const adminSecret = process.env.MEBULAR_OAUTH_ADMIN_SECRET ?? null;
930
+ const registerSecret = process.env.MEBULAR_OAUTH_REGISTER_SECRET ?? null;
931
+ const registrationEnabled = Boolean(registerSecret);
932
+ const clientsFile = process.env.MEBULAR_OAUTH_CLIENTS_FILE ?? join(home, 'auth', 'clients.json');
933
+ const consentFile = process.env.MEBULAR_OAUTH_CONSENT_FILE ?? join(home, 'auth', 'consent.json');
934
+ const revokedFile = process.env.MEBULAR_OAUTH_REVOKED_FILE ?? join(home, 'auth', 'revoked.json');
935
+ const rateLimit = makeRateLimiter(Number(process.env.MEBULAR_OAUTH_RATE_LIMIT ?? 60));
936
+
937
+ // origin/issuer 在 listen 后按实际端口重算(支持 --port 0)
938
+ let origin = `${scheme}://${isLoopback ? '127.0.0.1' : host}:${port}`;
939
+ let issuer = process.env.MEBULAR_OAUTH_ISSUER ?? origin;
940
+ let metadata = metadataFor(issuer, { registrationEnabled });
941
+ const signingKey = auth === 'oauth' ? await loadOrCreateSigningKey(home) : null;
942
+ const codes = new Map();
943
+ const revoked = await loadRevoked(revokedFile);
944
+
945
+ // W3:**多会话** transport(每个 `mcp-session-id` 一条;CLI 每次调用可各自 initialize)。
946
+ // 无 session 头的请求(如 CLI initialize)新建会话;会话经 `onsessioninitialized` 登记,关闭时回收。
947
+ //
948
+ // P2:UNINITIALIZED 也允许 /mcp 完成 initialize / list;server 先持有惰性 service 代理,
949
+ // 热初始化后同一会话内的 tools/call 直接转发到真实 MemoryService(不重连、不重新 initialize)。
950
+ const runtimeReady = () => phase === 'READY' && Boolean(service);
951
+ const serviceFacade = createServiceFacade({ getService: () => service, isReady: runtimeReady });
952
+ const mcpSessions = new Map(); // sessionId → { transport, server }
953
+ async function transportFor(sessionId) {
954
+ if (sessionId) return mcpSessions.get(sessionId)?.transport ?? null;
955
+ const server = buildServer(serviceFacade, {
956
+ isReady: runtimeReady,
957
+ // 任务工具复用**当前** live app(热初始化后 app 会切换,故用 getter):同一 EventLog 才会触发
958
+ // 本地 append / push-on-write / fleet SSE,而不是另开一个离线实例写同一份库。
959
+ toolContext: () => ({ home, ...(app ? { mebular: app } : {}) }),
960
+ });
961
+ const entry = { transport: null, server };
962
+ const transport = new WebStandardStreamableHTTPServerTransport({
963
+ sessionIdGenerator: () => randomUUID(),
964
+ enableJsonResponse: true,
965
+ onsessioninitialized: (id) => {
966
+ entry.transport = transport;
967
+ mcpSessions.set(id, entry);
968
+ },
969
+ });
970
+ transport.onclose = () => {
971
+ const id = transport.sessionId;
972
+ if (id) mcpSessions.delete(id);
973
+ void server.close().catch(() => undefined);
974
+ };
975
+ entry.transport = transport;
976
+ await server.connect(transport);
977
+ return transport;
978
+ }
979
+
980
+ async function authenticate(req, body, requiredOverride) {
981
+ if (auth === 'none') return { ok: true };
982
+ const header = req.headers['authorization'];
983
+ if (typeof header !== 'string' || !header.startsWith('Bearer ')) {
984
+ return { ok: false, status: 401, message: 'missing bearer token', challenge: 'Bearer' };
985
+ }
986
+ const token = header.slice('Bearer '.length);
987
+ let grant = null;
988
+ if (auth === 'bearer') {
989
+ grant = await verifyBearer(token, tokensFile);
990
+ } else {
991
+ const payload = await verifyJwt(signingKey, token, { issuer, resource: `${issuer}/mcp`, revoked });
992
+ if (payload && payload.type === 'access') grant = { tokenId: payload.jti, scopes: payload.scope ?? [] };
993
+ }
994
+ if (!grant) {
995
+ return { ok: false, status: 401, message: 'invalid token', challenge: 'Bearer error="invalid_token"' };
996
+ }
997
+ const required = requiredOverride ?? requiredScopeForBody(body);
998
+ if (!hasScope(grant.scopes, required)) {
999
+ const need = Array.isArray(required) ? required.join(' ') : required;
1000
+ return { ok: false, status: 403, message: `insufficient scope: need ${need}`, challenge: `Bearer error="insufficient_scope", scope="${need}"`, tokenId: grant.tokenId };
1001
+ }
1002
+ return { ok: true, tokenId: grant.tokenId, scopes: grant.scopes };
1003
+ }
1004
+
1005
+ /**
1006
+ * /mcp:UNINITIALIZED 与 READY 共用同一条路径(loopback + 现有 auth 语义)。
1007
+ * 未初始化时由 service facade + registerTools 的 readiness guard 返回业务错误,
1008
+ * 传输层本身仍正常完成 initialize / tools/list / prompts/list。
1009
+ */
1010
+ async function handleMcp(req, res, body) {
1011
+ const check = await authenticate(req, body);
1012
+ if (!check.ok) {
1013
+ res.statusCode = check.status;
1014
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
1015
+ return sendJson(res, check.status, { jsonrpc: '2.0', error: { code: -32001, message: check.message }, id: null });
1016
+ }
1017
+ const sessionId = req.headers['mcp-session-id'];
1018
+ const transport = await transportFor(typeof sessionId === 'string' ? sessionId : undefined);
1019
+ if (!transport) {
1020
+ return sendJson(res, 404, { jsonrpc: '2.0', error: { code: -32001, message: 'unknown session' }, id: null });
1021
+ }
1022
+ const webRequest = toWebRequest(req, body, origin);
1023
+ const response = await transport.handleRequest(webRequest);
1024
+ return writeWebResponse(res, response);
1025
+ }
1026
+
1027
+ // ---------- 控制台路由(D1 只读 + 写端点 CSRF/scope 门) ----------
1028
+
1029
+ function setCsrfCookie(res, token) {
1030
+ const attrs = [`${CSRF_COOKIE}=${token}`, 'Path=/', 'SameSite=Strict', `Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`];
1031
+ if (tls) attrs.push('Secure');
1032
+ res.setHeader('set-cookie', attrs.join('; '));
1033
+ }
1034
+
1035
+ async function handleConsole(req, res, url) {
1036
+ const path = url.pathname;
1037
+ const embed = url.searchParams.get('embed') === 'dsh';
1038
+ // 目录语义:无尾斜杠时补一个,保证 HTML 内相对资源(./console.css 等)解析到 /console/ 下
1039
+ if (path === '/console') {
1040
+ res.statusCode = 308;
1041
+ res.setHeader('location', `/console/${url.search}`);
1042
+ return res.end();
1043
+ }
1044
+ if (path === '/console/' || path === '/console/index.html') {
1045
+ const token = issueCsrf();
1046
+ setCsrfCookie(res, token);
1047
+ res.setHeader('x-mebular-csrf', token);
1048
+ return sendConsoleFile(res, 'index.html', { embed, loopback: isLoopback });
1049
+ }
1050
+ if (path.startsWith('/console/')) {
1051
+ const file = path.slice('/console/'.length);
1052
+ if (Object.prototype.hasOwnProperty.call(CONSOLE_FILES, file)) {
1053
+ return sendConsoleFile(res, file, { embed, loopback: isLoopback });
1054
+ }
1055
+ return sendJson(res, 404, { error: 'console_not_found', path });
1056
+ }
1057
+ return sendJson(res, 404, { error: 'not_found', path });
1058
+ }
1059
+
1060
+ /**
1061
+ * /locales/*:只读 locale pack 数据层。
1062
+ * 白名单:manifest.json 或 <locale>/<console|desktop|errors>.json;只允许 JSON 扩展名。
1063
+ * 目录内 realpath 校验,拒绝 traversal / 符号链接逃逸。
1064
+ */
1065
+ async function handleLocales(req, res, url) {
1066
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1067
+ return sendJson(res, 405, { error: 'method_not_allowed' });
1068
+ }
1069
+ let relative;
1070
+ try {
1071
+ relative = decodeURIComponent(url.pathname.slice('/locales/'.length));
1072
+ } catch {
1073
+ return sendJson(res, 404, { error: 'locale_not_found', path: url.pathname });
1074
+ }
1075
+ if (!relative || relative.includes('\0') || relative.includes('\\')) {
1076
+ return sendJson(res, 404, { error: 'locale_not_found', path: url.pathname });
1077
+ }
1078
+ const parts = relative.split('/');
1079
+ const valid = relative === 'manifest.json'
1080
+ || (parts.length === 2 && LOCALE_CODE_RE.test(parts[0]) && LOCALE_PACK_FILES.has(parts[1]));
1081
+ if (!valid) return sendJson(res, 404, { error: 'locale_not_found', path: url.pathname });
1082
+
1083
+ let root;
1084
+ let resolved;
1085
+ try {
1086
+ root = realpathSync(LOCALES_DIR);
1087
+ resolved = realpathSync(join(root, relative));
1088
+ } catch {
1089
+ return sendJson(res, 404, { error: 'locale_not_found', path: url.pathname });
1090
+ }
1091
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
1092
+ return sendJson(res, 404, { error: 'locale_not_found', path: url.pathname });
1093
+ }
1094
+ let content;
1095
+ try {
1096
+ content = await readFile(resolved);
1097
+ } catch {
1098
+ return sendJson(res, 404, { error: 'locale_not_found', path: url.pathname });
1099
+ }
1100
+ res.statusCode = 200;
1101
+ res.setHeader('content-type', 'application/json; charset=utf-8');
1102
+ res.setHeader('cache-control', 'no-cache');
1103
+ consoleSecurityHeaders(res);
1104
+ res.end(content);
1105
+ }
1106
+
1107
+ async function handleSecurityRead(req, res) {
1108
+ if (!app || !service) {
1109
+ return sendJson(res, 503, { error: 'console_unavailable', message: 'Mebular 尚未初始化' });
1110
+ }
1111
+ const check = await authenticate(req, Buffer.alloc(0), 'memory.read');
1112
+ if (!check.ok) {
1113
+ res.statusCode = check.status;
1114
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
1115
+ return sendJson(res, check.status, { error: 'unauthorized', message: check.message });
1116
+ }
1117
+ const lib = await loadFleetSecurity();
1118
+ if (!lib) {
1119
+ return sendJson(res, 200, {
1120
+ ok: true,
1121
+ level: 'low',
1122
+ admitted: [],
1123
+ rejections: [],
1124
+ coreTierAvailable: false,
1125
+ totp: { imported: false, failures: 0, lockedUntil: 0, backoffUntil: 0, lastAcceptedAt: 0 },
1126
+ recovery: { registered: false },
1127
+ pendingApprovals: [],
1128
+ recentApprovals: [],
1129
+ });
1130
+ }
1131
+ const state = await lib.readSecurityState(app);
1132
+ const events = await lib.listPolicyEvents(app);
1133
+ const registry = lib.collectRecoveryRegistry(events);
1134
+ const latest = lib.latestRecoveryRecord(events);
1135
+ const totp = await lib.readTotpStatus(home).catch(() => ({
1136
+ imported: false, failures: 0, lockedUntil: 0, backoffUntil: 0, lastAcceptedAt: 0,
1137
+ }));
1138
+ return sendJson(res, 200, {
1139
+ ok: true,
1140
+ ...state,
1141
+ totp,
1142
+ recovery: latest === null
1143
+ ? { registered: false }
1144
+ : {
1145
+ registered: true,
1146
+ keyId: latest.keyId,
1147
+ mode: latest.mode,
1148
+ createdAt: latest.createdAt,
1149
+ lastUsedAt: registry.lastUsedAt ?? null,
1150
+ current: registry.currentKeyId === latest.keyId,
1151
+ },
1152
+ pendingApprovals: lib.pendingApprovals(events),
1153
+ recentApprovals: lib.approvalsFromEvents(events).slice(-50),
1154
+ });
1155
+ }
1156
+
1157
+ async function handleAdminRead(req, res, path, url) {
1158
+ const builder = READ_ROUTES[path];
1159
+ if (!builder) return false;
1160
+ // P2:助手列表只读本机配置,UNINITIALIZED 也开放;其余 admin 读数据面仍需 READY。
1161
+ if ((!app || !service) && path !== '/admin/api/clients') {
1162
+ return sendJson(res, 503, { error: 'console_unavailable', message: 'Mebular 尚未初始化' });
1163
+ }
1164
+ const check = await authenticate(req, Buffer.alloc(0), 'memory.read');
1165
+ if (!check.ok) {
1166
+ res.statusCode = check.status;
1167
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
1168
+ return sendJson(res, check.status, { error: 'unauthorized', message: check.message });
1169
+ }
1170
+ // 注意:CSRF token 只在 /console/ 页面加载时签发。若在 overview 轮询里重新签发,
1171
+ // cookie 会被每次轮询改写,与并发的写请求竞争(读 cookie 后、发出写之前又来一次
1172
+ // overview)→ header 与 cookie 不一致而 403。token 12h 有效,页面加载签发一次即可。
1173
+ let payload;
1174
+ try {
1175
+ payload = await builder({ app, service, config, runtime, home, url, cloud });
1176
+ } catch (error) {
1177
+ if (error?.code === 'CLOUD_UNAVAILABLE') {
1178
+ return sendJson(res, 503, { ok: false, error: 'CLOUD_UNAVAILABLE', message: error.message, reason: error.reason ?? null });
1179
+ }
1180
+ if (error?.code === 'CLOUD_REGISTRY_INVALID') {
1181
+ return sendJson(res, 500, { ok: false, error: 'CLOUD_REGISTRY_INVALID', message: error.message });
1182
+ }
1183
+ if (error?.code === 'CLOUD_REGISTRY_LOCKED') {
1184
+ return sendJson(res, 503, { ok: false, error: 'CLOUD_REGISTRY_LOCKED', message: error.message });
1185
+ }
1186
+ throw error;
1187
+ }
1188
+ if (path === '/admin/api/overview') {
1189
+ payload.features = { writes: Boolean(writesEnabled) };
1190
+ }
1191
+ return sendJson(res, 200, payload);
1192
+ }
1193
+
1194
+ // ---------- /admin/events(SSE:状态与同步实时脉冲) ----------
1195
+
1196
+ async function handleAdminEvents(req, res, url) {
1197
+ if (phase !== 'READY') {
1198
+ return sendJson(res, 409, { error: 'provision_required', provision: true, phase, message: '实例尚未初始化:请先在控制台完成「建新 / 加入」' });
1199
+ }
1200
+ if (!app || !service) {
1201
+ return sendJson(res, 503, { error: 'console_unavailable', message: 'Mebular 尚未初始化' });
1202
+ }
1203
+ if (auth !== 'none' && !req.headers['authorization']) {
1204
+ const token = url.searchParams.get('token');
1205
+ if (token) req.headers.authorization = `Bearer ${token}`;
1206
+ }
1207
+ const check = await authenticate(req, Buffer.alloc(0), 'memory.read');
1208
+ if (!check.ok) {
1209
+ res.statusCode = check.status;
1210
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
1211
+ return sendJson(res, check.status, { error: 'unauthorized', message: check.message });
1212
+ }
1213
+ // 任务域在写响应头前解析,避免 hello 之后到监听接入之间漏掉短暂窗口内的事件。
1214
+ const fleetNamespace = await resolveFleetNamespace({ home, fallback: namespace });
1215
+ res.writeHead(200, {
1216
+ 'content-type': 'text/event-stream; charset=utf-8',
1217
+ 'cache-control': 'no-cache, no-transform',
1218
+ connection: 'keep-alive',
1219
+ 'x-accel-buffering': 'no',
1220
+ });
1221
+ const send = (event, data) => {
1222
+ try {
1223
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1224
+ } catch {
1225
+ // 连接已断开
1226
+ }
1227
+ };
1228
+ send('hello', { at: Date.now() });
1229
+
1230
+ // fleet:真实任务 `created` 事件 → 控制台实时绘制航道/舰队(只读广播;契约见 fleet-events.mjs)。
1231
+ // 本地 append 与远端同步应用(events-applied)都接入;清理时统一移除监听。
1232
+ let detachFleet = null;
1233
+ try {
1234
+ detachFleet = attachFleetEvents({
1235
+ eventLog: app.eventLog,
1236
+ sync: app.sync,
1237
+ namespace: fleetNamespace,
1238
+ send,
1239
+ });
1240
+ } catch {
1241
+ // 任务面不可用时不影响其余 SSE 事件
1242
+ detachFleet = null;
1243
+ }
1244
+
1245
+ const onSync = (result) => send('sync', {
1246
+ at: Date.now(),
1247
+ peerDeviceId: result?.peerDeviceId,
1248
+ sentEvents: result?.sentEvents,
1249
+ receivedEvents: result?.receivedEvents,
1250
+ // P3/SP-29:显式信号接线(诊断字段;缺省不发送)
1251
+ ...(result?.reportedAhead !== undefined ? { reportedAhead: result.reportedAhead } : {}),
1252
+ ...(result?.denied !== undefined ? { denied: result.denied } : {}),
1253
+ ...(result?.subscriptionRejected !== undefined ? { subscriptionRejected: result.subscriptionRejected } : {}),
1254
+ ...(result?.ackedOutgoing !== undefined ? { ackedOutgoing: result.ackedOutgoing } : {}),
1255
+ ...(result?.watermarkDownCorrections !== undefined ? { watermarkDownCorrections: result.watermarkDownCorrections } : {}),
1256
+ ...(result?.unknownEventTypes !== undefined ? { unknownEventTypes: result.unknownEventTypes } : {}),
1257
+ });
1258
+ const onFail = (payload) => send('sync-failed', {
1259
+ at: Date.now(),
1260
+ message: String(payload?.error?.message ?? payload?.error ?? ''),
1261
+ });
1262
+ // P3:收敛状态翻转(每对端)→ 控制台即时刷新(只读;状态本体走 /admin/api/devices)
1263
+ let offConvergence = null;
1264
+ try {
1265
+ offConvergence = typeof app.onConvergence === 'function'
1266
+ ? app.onConvergence((transition) => send('convergence', {
1267
+ at: Date.now(),
1268
+ peerDeviceId: transition?.peerDeviceId,
1269
+ from: transition?.from,
1270
+ to: transition?.to,
1271
+ reasons: transition?.reasons,
1272
+ }))
1273
+ : null;
1274
+ } catch {
1275
+ offConvergence = null;
1276
+ }
1277
+ try {
1278
+ app.sync.on('sync-completed', onSync);
1279
+ app.sync.on('sync-failed', onFail);
1280
+ } catch {
1281
+ // 门面未初始化时 sync 不可访问
1282
+ }
1283
+
1284
+ let closed = false;
1285
+ const emitStatus = async () => {
1286
+ if (closed) return;
1287
+ try {
1288
+ const status = await service.status();
1289
+ send('status', {
1290
+ at: Date.now(),
1291
+ running: status.running,
1292
+ nodeCount: status.nodeCount,
1293
+ pendingEventCount: status.pendingEventCount,
1294
+ peerId: status.peerId,
1295
+ });
1296
+ } catch {
1297
+ // 保活失败忽略
1298
+ }
1299
+ };
1300
+ await emitStatus();
1301
+ const timer = setInterval(emitStatus, 2500);
1302
+ const cleanup = () => {
1303
+ if (closed) return;
1304
+ closed = true;
1305
+ clearInterval(timer);
1306
+ try {
1307
+ app.sync.off('sync-completed', onSync);
1308
+ app.sync.off('sync-failed', onFail);
1309
+ } catch {
1310
+ // ignore
1311
+ }
1312
+ try { offConvergence?.(); } catch { /* ignore */ }
1313
+ try { detachFleet?.(); } catch { /* ignore */ }
1314
+ try { res.end(); } catch { /* ignore */ }
1315
+ };
1316
+ req.on('close', cleanup);
1317
+ res.on('close', cleanup);
1318
+ return undefined;
1319
+ }
1320
+
1321
+ const WRITE_PATTERNS = [
1322
+ /^\/admin\/api\/clouds\/(join|rename|inspect)$/,
1323
+ /^\/admin\/api\/subscriptions$/,
1324
+ /^\/admin\/api\/namespaces$/,
1325
+ /^\/admin\/api\/devices\/self\/name$/,
1326
+ /^\/admin\/api\/devices\/([^/]+)\/(revoke|restore|connect|disconnect|sync|reset-watermarks)$/,
1327
+ /^\/admin\/api\/config$/,
1328
+ /^\/admin\/api\/restart$/,
1329
+ /^\/admin\/api\/memories\/([^/]+)\/(archive|unarchive|delete)$/,
1330
+ /^\/admin\/api\/invite$/,
1331
+ /^\/admin\/api\/security\/tier$/,
1332
+ /^\/admin\/api\/security\/approvals$/,
1333
+ /^\/admin\/api\/security\/approvals\/([^/]+)\/(approve|deny)$/,
1334
+ /^\/admin\/api\/security\/totp\/(show|import)$/,
1335
+ /^\/admin\/api\/security\/recovery\/(generate|rotate)$/,
1336
+ /^\/admin\/api\/namespaces\/([^/]+)\/(rejoin|leave)$/,
1337
+ /^\/admin\/api\/service\/(install|uninstall)$/,
1338
+ /^\/admin\/api\/clients\/([^/]+)\/(connect|rollback|skills|preview|verify|recheck)$/,
1339
+ /^\/admin\/api\/assistants$/,
1340
+ /^\/admin\/api\/assistants\/([^/]+)$/,
1341
+ /^\/admin\/api\/assistants\/([^/]+)\/remove$/,
1342
+ ];
1343
+
1344
+ // P2:UNINITIALIZED 仅开放本机助手配置所需的读写动作(列表 / 预览 / 接入 / 验证 / 重探)。
1345
+ // 不暴露 memory/network/admin 数据面;写端点仍走 handleAdminWrite 的 Origin + CSRF + scope 门。
1346
+ const UNINITIALIZED_CLIENT_ACTION = /^\/admin\/api\/clients\/([^/]+)\/(connect|preview|verify|recheck)$/;
1347
+
1348
+ function parseJsonBody(body) {
1349
+ try {
1350
+ const parsed = JSON.parse(body?.toString('utf-8') || '{}');
1351
+ return parsed && typeof parsed === 'object' ? parsed : {};
1352
+ } catch {
1353
+ return null;
1354
+ }
1355
+ }
1356
+
1357
+ // ---------- 忆云档案端点(A:控制台准备;App/CLI 负责真正的停旧/起新) ----------
1358
+
1359
+ const publicProfile = (profile, currentId = null) => ({
1360
+ id: profile.id,
1361
+ name: profile.name,
1362
+ home: profile.home,
1363
+ kind: profile.kind,
1364
+ createdAt: profile.createdAt ?? null,
1365
+ lastUsedAt: profile.lastUsedAt ?? null,
1366
+ nameUpdatedAt: profile.nameUpdatedAt ?? null,
1367
+ masterFingerprint: profile.masterFingerprint ?? null,
1368
+ deviceId: profile.deviceId ?? null,
1369
+ ...(currentId !== null ? { current: profile.id === currentId } : {}),
1370
+ });
1371
+
1372
+ const scrubToken = (value, token) => (
1373
+ typeof token === 'string' && token.length > 0 ? String(value).split(token).join('[redacted]') : String(value)
1374
+ );
1375
+
1376
+ function sendCloudError(res, error, { disabled = false } = {}) {
1377
+ const code = error?.code ?? 'CLOUD_FAILED';
1378
+ const status = {
1379
+ CONFIRMATION_REQUIRED: 400,
1380
+ TOKEN_REQUIRED: 400,
1381
+ TOKEN_INVALID: 400,
1382
+ TOKEN_EXPIRED: 400,
1383
+ TOKEN_REJECTED: 400,
1384
+ INVALID_NAME: 400,
1385
+ NAME_TAKEN: 400,
1386
+ CLOUD_ARCHIVED: 409,
1387
+ CLOUD_BLOCKED: 403,
1388
+ PROFILE_NOT_FOUND: 404,
1389
+ CLOUD_ALREADY_JOINED: 409,
1390
+ ALREADY_PROVISIONED: 409,
1391
+ ENDPOINT_UNREACHABLE: 502,
1392
+ CLOUD_UNAVAILABLE: disabled ? 403 : 503,
1393
+ CLOUD_DISABLED: 403,
1394
+ CLOUD_REGISTRY_INVALID: 500,
1395
+ CLOUD_REGISTRY_LOCKED: 503,
1396
+ CLOUD_PROFILE_PATH_INVALID: 500,
1397
+ PROFILE_JOIN_FAILED: 500,
1398
+ }[code] ?? 500;
1399
+ const errorName = disabled && code === 'CLOUD_UNAVAILABLE' ? 'CLOUD_DISABLED' : code;
1400
+ return sendJson(res, status, {
1401
+ ok: false,
1402
+ error: errorName,
1403
+ message: error?.message ?? String(error),
1404
+ ...(error?.existing !== undefined ? { existing: error.existing } : {}),
1405
+ ...(error?.reason !== undefined ? { reason: error.reason } : {}),
1406
+ });
1407
+ }
1408
+
1409
+ async function decodeCloudToken(token) {
1410
+ const fleet = await import('../../fleet/dist/index.js');
1411
+ return inspectJoinToken(fleet.decodeJoinToken, token);
1412
+ }
1413
+
1414
+ function cloudUnavailableError() {
1415
+ return cloudError(
1416
+ 'CLOUD_DISABLED',
1417
+ `当前为自定义 home / 受限 env(${cloud.reason ?? 'unavailable'}):忆云档案功能不可用`,
1418
+ { reason: cloud.reason ?? 'unavailable' },
1419
+ );
1420
+ }
1421
+
1422
+ const parseListenPort = (configValue) => {
1423
+ const listen = configValue?.network?.libp2p?.listen;
1424
+ const first = Array.isArray(listen) ? listen.find((entry) => typeof entry === 'string') : null;
1425
+ const port = first ? Number(/\/tcp\/(\d+)/.exec(first)?.[1] ?? 0) : 0;
1426
+ return Number.isInteger(port) && port > 0 ? port : 0;
1427
+ };
1428
+
1429
+ // 当前守护的**实际**监听端口(config 写 0 时也能复用;取不到再回退 config)。
1430
+ const currentListenPort = () => {
1431
+ try {
1432
+ const addrs = app?.node?.getLocalMultiaddrs?.() ?? [];
1433
+ for (const addr of addrs) {
1434
+ const port = Number(/\/tcp\/(\d+)/.exec(String(addr))?.[1] ?? 0);
1435
+ if (Number.isInteger(port) && port > 0) return port;
1436
+ }
1437
+ } catch {
1438
+ // 回退 config
1439
+ }
1440
+ return parseListenPort(config);
1441
+ };
1442
+
1443
+ async function handleCloudInspect(req, res, body) {
1444
+ const input = parseJsonBody(body) ?? {};
1445
+ const token = typeof input.token === 'string' ? input.token.trim() : '';
1446
+ if (token.length === 0) return sendCloudError(res, cloudError('TOKEN_REQUIRED', '需要加入令牌'));
1447
+ let parsed;
1448
+ try {
1449
+ parsed = await decodeCloudToken(token);
1450
+ } catch (error) {
1451
+ return sendCloudError(res, error);
1452
+ }
1453
+ const fingerprint = masterFingerprintFromHex(parsed.masterPublicKey);
1454
+ let alreadyJoined = null;
1455
+ if (cloud.available && fingerprint !== null) {
1456
+ try {
1457
+ const state = await readRegistry(cloud.root);
1458
+ const duplicate = state.registry.profiles.find((profile) => profile.masterFingerprint === fingerprint);
1459
+ if (duplicate) alreadyJoined = { id: duplicate.id, name: duplicate.name };
1460
+ } catch {
1461
+ // inspect 不因注册表瞬时错误失败
1462
+ }
1463
+ }
1464
+ return sendJson(res, 200, {
1465
+ ok: true,
1466
+ inviterDeviceId: parsed.inviterDeviceId,
1467
+ namespace: parsed.namespace,
1468
+ masterFingerprint: fingerprint,
1469
+ expiresAt: parsed.expiresAt,
1470
+ alreadyJoined,
1471
+ });
1472
+ }
1473
+
1474
+ async function handleCloudJoin(req, res, body) {
1475
+ const input = parseJsonBody(body) ?? {};
1476
+ if (input.confirm !== true) {
1477
+ return sendCloudError(res, cloudError('CONFIRMATION_REQUIRED', '加入忆云需要显式确认(confirm:true)'));
1478
+ }
1479
+ if (!cloud.available) return sendCloudError(res, cloudUnavailableError(), { disabled: true });
1480
+ const token = typeof input.token === 'string' ? input.token.trim() : '';
1481
+ if (token.length === 0) return sendCloudError(res, cloudError('TOKEN_REQUIRED', '需要加入令牌'));
1482
+
1483
+ let parsed;
1484
+ try {
1485
+ parsed = await decodeCloudToken(token);
1486
+ } catch (error) {
1487
+ return sendCloudError(res, error);
1488
+ }
1489
+ const fingerprint = masterFingerprintFromHex(parsed.masterPublicKey);
1490
+
1491
+ // 指纹重复:不创建目录;返回已有条目,前端提供「切换到它」。
1492
+ let state;
1493
+ try {
1494
+ state = await readRegistry(cloud.root);
1495
+ } catch (error) {
1496
+ return sendCloudError(res, error, { disabled: true });
1497
+ }
1498
+ if (fingerprint !== null) {
1499
+ const duplicate = state.registry.profiles.find((profile) => profile.masterFingerprint === fingerprint);
1500
+ if (duplicate) {
1501
+ return sendCloudError(res, cloudError(
1502
+ 'CLOUD_ALREADY_JOINED',
1503
+ `这台设备已加入「${duplicate.name}」,可直接切换`,
1504
+ { existing: { id: duplicate.id, name: duplicate.name } },
1505
+ ));
1506
+ }
1507
+ }
1508
+ // 名字先校验(不消耗令牌/不建目录);锁内 registerProfile 仍会再查一次防并发。
1509
+ if (input.name !== undefined) {
1510
+ let name;
1511
+ try {
1512
+ name = normalizeProfileName(input.name);
1513
+ } catch (error) {
1514
+ return sendCloudError(res, error);
1515
+ }
1516
+ if (state.registry.profiles.some((profile) => profile.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase())) {
1517
+ return sendCloudError(res, cloudError('NAME_TAKEN', '已有同名忆云,请换一个名字'));
1518
+ }
1519
+ }
1520
+
1521
+ const profilesDir = join(cloud.root, PROFILES_DIR);
1522
+ // profiles/ 已存在时做 realpath containment:防止有人把它 symlink 到 root 外。
1523
+ if (existsSync(profilesDir)) {
1524
+ try {
1525
+ const realProfiles = realpathSync(profilesDir);
1526
+ const realRoot = realpathSync(cloud.root);
1527
+ if (realProfiles !== realRoot && !realProfiles.startsWith(`${realRoot}${sep}`)) {
1528
+ throw cloudError('CLOUD_PROFILE_PATH_INVALID', 'profiles/ 指向 root 之外,拒绝创建忆云目录');
1529
+ }
1530
+ } catch (error) {
1531
+ if (error?.code === 'CLOUD_PROFILE_PATH_INVALID') return sendCloudError(res, error);
1532
+ return sendCloudError(res, cloudError(
1533
+ 'PROFILE_JOIN_FAILED',
1534
+ `无法校验忆云目录:${scrubToken(error?.message ?? error, token)}`,
1535
+ ));
1536
+ }
1537
+ }
1538
+ try {
1539
+ await mkdir(profilesDir, { recursive: true, mode: 0o700 });
1540
+ } catch (error) {
1541
+ return sendCloudError(res, cloudError(
1542
+ 'PROFILE_JOIN_FAILED',
1543
+ `本机无法创建忆云目录:${scrubToken(error?.message ?? error, token)}`,
1544
+ ));
1545
+ }
1546
+ let id = null;
1547
+ let newHome = null;
1548
+ for (let attempt = 0; attempt < 5; attempt += 1) {
1549
+ let candidate;
1550
+ try {
1551
+ candidate = newProfileId(state.registry.profiles);
1552
+ } catch (error) {
1553
+ return sendCloudError(res, cloudError('PROFILE_JOIN_FAILED', error?.message ?? String(error)));
1554
+ }
1555
+ const candidateHome = join(profilesDir, candidate);
1556
+ try {
1557
+ await mkdir(candidateHome, { mode: 0o700 });
1558
+ id = candidate;
1559
+ newHome = candidateHome;
1560
+ break;
1561
+ } catch (error) {
1562
+ if (error?.code === 'EEXIST') continue;
1563
+ return sendCloudError(res, cloudError(
1564
+ 'PROFILE_JOIN_FAILED',
1565
+ `本机无法创建忆云目录:${scrubToken(error?.message ?? error, token)}`,
1566
+ ));
1567
+ }
1568
+ }
1569
+ if (newHome === null) {
1570
+ return sendCloudError(res, cloudError('PROFILE_JOIN_FAILED', '无法分配新的忆云目录:请重试'));
1571
+ }
1572
+
1573
+ // 标记只含 id/时间/pid(绝不含令牌);失败时整目录删除(尚未注册)。
1574
+ try {
1575
+ await writeFile(
1576
+ join(newHome, '.provisioning'),
1577
+ JSON.stringify({ id, at: new Date().toISOString(), pid: process.pid }),
1578
+ { mode: 0o600 },
1579
+ );
1580
+ } catch (error) {
1581
+ await rm(newHome, { recursive: true, force: true }).catch(() => undefined);
1582
+ return sendCloudError(res, cloudError(
1583
+ 'PROFILE_JOIN_FAILED',
1584
+ `本机无法写入忆云目录:${scrubToken(error?.message ?? error, token)}`,
1585
+ ));
1586
+ }
1587
+
1588
+ let registered = false;
1589
+ try {
1590
+ const mcpPort = runtime?.mcp?.port ?? config?.mcp?.http?.port ?? 7331;
1591
+ const joinPort = runtime?.join?.port ?? config?.joinService?.port ?? 4002;
1592
+ const listenPort = currentListenPort();
1593
+ const result = await provisionJoinInto({
1594
+ home: newHome,
1595
+ token,
1596
+ ports: { mcp: mcpPort, join: joinPort, listen: listenPort },
1597
+ rollbackDirectory: true,
1598
+ ...(Number.isFinite(input.timeoutMs) ? { timeoutMs: input.timeoutMs } : {}),
1599
+ });
1600
+ // 校验落盘:config + 委派身份 + 指纹一致(缺失/不一致 → 失败,且整目录删除)
1601
+ if (!existsSync(configPath(newHome)) || !existsSync(join(newHome, 'store.jsonl.identity.json'))) {
1602
+ throw cloudError('PROFILE_JOIN_FAILED', '新忆云落盘不完整(config/身份缺失),已回滚');
1603
+ }
1604
+ if (fingerprint !== null && result.fingerprint !== null && result.fingerprint !== fingerprint) {
1605
+ throw cloudError('PROFILE_JOIN_FAILED', '新忆云主公钥指纹与令牌不一致,已回滚');
1606
+ }
1607
+
1608
+ const registeredResult = await registerProfile(cloud.root, {
1609
+ id,
1610
+ name: input.name,
1611
+ kind: 'delegated',
1612
+ masterFingerprint: fingerprint ?? result.fingerprint ?? null,
1613
+ deviceId: result.deviceId,
1614
+ createdAt: new Date().toISOString(),
1615
+ });
1616
+ registered = true;
1617
+ const profile = registeredResult.value;
1618
+ const current = registeredResult.registry.current;
1619
+ await rm(join(newHome, '.provisioning'), { force: true }).catch(() => undefined);
1620
+ await writeProvisionRecord(newHome, { ...result, at: new Date().toISOString() }).catch(() => undefined);
1621
+ return sendJson(res, 201, {
1622
+ ok: true,
1623
+ profile: publicProfile(profile),
1624
+ current,
1625
+ switch: { required: true, command: `mebular cloud use ${profile.id}` },
1626
+ });
1627
+ } catch (error) {
1628
+ if (!registered) await rm(newHome, { recursive: true, force: true }).catch(() => undefined);
1629
+ const known = new Set([
1630
+ 'CONFIRMATION_REQUIRED', 'TOKEN_REQUIRED', 'TOKEN_INVALID', 'TOKEN_EXPIRED', 'TOKEN_REJECTED',
1631
+ 'ENDPOINT_UNREACHABLE', 'CLOUD_ALREADY_JOINED', 'INVALID_NAME', 'NAME_TAKEN', 'PROFILE_EXISTS',
1632
+ 'INVALID_PROFILE_ID', 'PROFILE_JOIN_FAILED', 'CLOUD_DISABLED', 'CLOUD_UNAVAILABLE',
1633
+ 'CLOUD_REGISTRY_INVALID', 'CLOUD_REGISTRY_LOCKED', 'CLOUD_PROFILE_PATH_INVALID',
1634
+ ]);
1635
+ const wrapped = known.has(error?.code)
1636
+ ? error
1637
+ : cloudError('PROFILE_JOIN_FAILED', scrubToken(error?.message ?? error, token));
1638
+ // 走到这里时令牌可能已在邀请方处兑换(兑换后本机写失败)——文案必须说明需重新生成。
1639
+ if (['PROFILE_JOIN_FAILED', 'CLOUD_REGISTRY_LOCKED', 'CLOUD_REGISTRY_INVALID'].includes(wrapped.code)) {
1640
+ wrapped.message = `${wrapped.message}(令牌可能已在对方处兑换;请让邀请方重新生成)`;
1641
+ }
1642
+ return sendCloudError(res, wrapped, { disabled: true });
1643
+ }
1644
+ }
1645
+
1646
+ async function handleCloudRename(req, res, body) {
1647
+ if (!cloud.available) return sendCloudError(res, cloudUnavailableError(), { disabled: true });
1648
+ const input = parseJsonBody(body) ?? {};
1649
+ const id = typeof input.id === 'string' ? input.id : '';
1650
+ if (id.length === 0) return sendCloudError(res, cloudError('PROFILE_NOT_FOUND', '需要忆云 id'));
1651
+ try {
1652
+ const state = await readRegistry(cloud.root);
1653
+ if (!state.registry.profiles.some((profile) => profile.id === id)) {
1654
+ throw cloudError('PROFILE_NOT_FOUND', `未找到忆云:${id}`);
1655
+ }
1656
+ // 第一版:只有当前忆云能写网络级 `cloud_name`;归档忆云不广播旧名。
1657
+ if (id !== state.registry.current) {
1658
+ throw cloudError(
1659
+ 'CLOUD_ARCHIVED',
1660
+ '归档忆云不支持网络改名;请先切换到该忆云后再改名',
1661
+ { id, current: state.registry.current },
1662
+ );
1663
+ }
1664
+ const name = normalizeProfileName(input.name);
1665
+ if (state.registry.profiles.some((profile) => profile.id !== id
1666
+ && String(profile.name).trim().toLocaleLowerCase() === name.toLocaleLowerCase())) {
1667
+ throw cloudError('NAME_TAKEN', '已有同名忆云,请换一个名字');
1668
+ }
1669
+ const blocked = await app.getBlockedDevices();
1670
+ if (blocked.includes(app.deviceId)) {
1671
+ throw cloudError('CLOUD_BLOCKED', '本机已被移出当前忆云,不能改名');
1672
+ }
1673
+ // 先写网络事件,再更新本机 registry 缓存;失败时 registry 不变(fallback 仍可用)。
1674
+ const event = await app.setCloudName({ name });
1675
+ const result = await renameProfile(cloud.root, id, name);
1676
+ let derived = null;
1677
+ try {
1678
+ derived = await app.getCloudName();
1679
+ } catch {
1680
+ derived = null;
1681
+ }
1682
+ return sendJson(res, 200, {
1683
+ ok: true,
1684
+ profile: publicProfile(result.value, result.registry.current),
1685
+ cloud: {
1686
+ id,
1687
+ name,
1688
+ nameSource: 'network',
1689
+ nameUpdatedAt: result.value.nameUpdatedAt ?? null,
1690
+ eventId: event.id,
1691
+ },
1692
+ derived: derived ?? null,
1693
+ });
1694
+ } catch (error) {
1695
+ return sendCloudError(res, error, { disabled: true });
1696
+ }
1697
+ }
1698
+
1699
+ /** 按会话证书把 deviceId 映射到在线连接;找不到返回 null */
1700
+ function findConnectionByDevice(deviceId) {
1701
+ const node = app?.node;
1702
+ if (!node || !node.isRunning()) return null;
1703
+ for (const connection of node.getConnectionManager().getConnections()) {
1704
+ const session = node.getHandshake().getSession(connection.peerId);
1705
+ if (session?.state === 'authenticated' && session.certificate?.deviceId === deviceId) {
1706
+ return { connection, session };
1707
+ }
1708
+ }
1709
+ return null;
1710
+ }
1711
+
1712
+ // G:保存即生效——同进程防抖窗口内的多次保存合并为一次重启;supervisor 负责健康校验 / 回滚兜底。
1713
+ let applyRestartTimer = null;
1714
+ let applyPendingFields = new Set();
1715
+ let applyPendingPort = null;
1716
+ let applyAttempt = 0;
1717
+
1718
+ function scheduleApplyRestart({ fields, port, timeoutMs }) {
1719
+ for (const field of fields) applyPendingFields.add(field);
1720
+ if (typeof port === 'number' && port > 0) applyPendingPort = port;
1721
+ if (applyRestartTimer) return { debounced: true };
1722
+ applyRestartTimer = setTimeout(() => {
1723
+ applyRestartTimer = null;
1724
+ const list = [...applyPendingFields];
1725
+ applyPendingFields = new Set();
1726
+ const target = applyPendingPort;
1727
+ applyPendingPort = null;
1728
+ void performApplyRestart(list, target, timeoutMs).catch(() => undefined);
1729
+ }, RESTART_DEBOUNCE_MS);
1730
+ applyRestartTimer.unref?.();
1731
+ return { debounced: false };
1732
+ }
1733
+
1734
+ async function performApplyRestart(fields, port, timeoutMs) {
1735
+ applyAttempt += 1;
1736
+ const attempt = applyAttempt;
1737
+ await writeApplyPending(home, {
1738
+ attempt,
1739
+ at: new Date().toISOString(),
1740
+ fields,
1741
+ attempts: 1,
1742
+ port: port ?? null,
1743
+ oldPid: process.pid,
1744
+ });
1745
+ // 先起监督进程(它等旧进程退出 → 校验新实例健康 → 不健康则回滚),再触发重启
1746
+ const supervisorPath = join(dirname(fileURLToPath(import.meta.url)), 'apply-supervisor.mjs');
1747
+ spawnSupervisor({
1748
+ supervisorPath,
1749
+ args: [
1750
+ '--home', home,
1751
+ '--config', configPath(home),
1752
+ '--old-pid', String(process.pid),
1753
+ '--port', String(port ?? 0),
1754
+ '--fields', fields.join(','),
1755
+ ...(timeoutMs ? ['--timeout', String(timeoutMs)] : []),
1756
+ ],
1757
+ });
1758
+ await runRestart({ home, kind: process.env.MEBULAR_SERVICE_KIND });
1759
+ }
1760
+
1761
+ /**
1762
+ * 默认重启执行器(C):由 @mebular/service 读服务计划(launchd/systemd/计划任务),
1763
+ * 先返回 202 再**分离式**触发(守护被重启/终止后命令仍能完成)。
1764
+ * - 前台 nohup 运行(无 MEBULAR_SERVICE_KIND)→ registered:false → 接口给手动指引
1765
+ * - MEBULAR_RESTART_DRY_RUN=1 → 只回计划不执行(verify:config 用)
1766
+ */
1767
+ async function defaultRestartRunner() {
1768
+ const kind = process.env.MEBULAR_SERVICE_KIND;
1769
+ const cmdOverride = String(process.env.MEBULAR_RESTART_CMD ?? '').trim();
1770
+ if (cmdOverride) {
1771
+ const dryRun = process.env.MEBULAR_RESTART_DRY_RUN === '1';
1772
+ return {
1773
+ registered: true,
1774
+ command: cmdOverride,
1775
+ dryRun,
1776
+ trigger: async () => { await runRestart({ home, kind: kind ?? undefined }); },
1777
+ };
1778
+ }
1779
+ if (!kind) {
1780
+ return {
1781
+ registered: false,
1782
+ // R-1 高-4:命令串用真实 home(不写死 ~/.mebular)
1783
+ command: `nohup mebular serve > ${home}/serve.log 2>&1 &`,
1784
+ manual: `未以服务方式运行(前台 nohup):请手动重启(nohup mebular serve > ${home}/serve.log 2>&1 &)`,
1785
+ };
1786
+ }
1787
+ let plan;
1788
+ try {
1789
+ const { restartPlanFor } = await import('../../service/dist/index.js');
1790
+ plan = restartPlanFor({ kind, args: [], heartbeatDir: home, env: { MEBULAR_HOME: home } }, { home });
1791
+ } catch (error) {
1792
+ return { registered: false, manual: `服务管理不可用(${String(error?.message ?? error)}):请手动重启` };
1793
+ }
1794
+ const command = plan.commands.map((entry) => entry.join(' ')).join(' && ') || null;
1795
+ if (!plan.registered) return { registered: false, command, manual: plan.manual };
1796
+ const dryRun = process.env.MEBULAR_RESTART_DRY_RUN === '1';
1797
+ return {
1798
+ registered: true,
1799
+ command,
1800
+ commands: plan.commands,
1801
+ dryRun,
1802
+ trigger: async () => {
1803
+ for (const [cmd, ...args] of plan.commands) {
1804
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
1805
+ child.unref();
1806
+ }
1807
+ },
1808
+ };
1809
+ }
1810
+
1811
+ // P2:UNINITIALIZED 的助手动作——只读写本机助手配置,不触碰 app/service 数据面。
1812
+ // 返回 false 表示不是受支持的助手动作(调用方继续按原 503 处理)。
1813
+ async function handleUninitializedClientAction(req, res, path, _body) {
1814
+ const match = path.match(UNINITIALIZED_CLIENT_ACTION);
1815
+ if (!match) return false;
1816
+ const { connectClient, previewClient, listAssistantsAsync, assistantUserHome, daemonMcpUrl } = await import('./clients.mjs');
1817
+ const id = decodeURIComponent(match[1]);
1818
+ const action = match[2];
1819
+ const mcpUrl = daemonMcpUrl({ host: req.headers?.host, encrypted: Boolean(req.socket?.encrypted) });
1820
+ const opts = { home, cwd: process.cwd(), mcpUrl };
1821
+ try {
1822
+ if (action === 'preview') {
1823
+ return sendJson(res, 200, previewClient(id, opts));
1824
+ }
1825
+ if (action === 'verify') {
1826
+ // 未初始化不是「连接失败」:返回稳定的待初始化结果,让 UI 保留配置并等待 READY。
1827
+ return sendJson(res, 200, {
1828
+ ok: false,
1829
+ state: 'pending-initialization',
1830
+ needsManual: false,
1831
+ pendingInitialization: true,
1832
+ reason: 'not_initialized',
1833
+ message: '待初始化:请先新建或加入忆云,再验证连接',
1834
+ steps: [],
1835
+ });
1836
+ }
1837
+ if (action === 'recheck') {
1838
+ const out = await listAssistantsAsync({ ...opts, userHome: assistantUserHome(), refresh: true });
1839
+ const client = out.assistants.find((entry) => entry.id === id) ?? null;
1840
+ if (!client) return sendJson(res, 404, { ok: false, error: 'unknown_client', id });
1841
+ return sendJson(res, 200, { ok: true, client, cached: false, pendingInitialization: true });
1842
+ }
1843
+ const result = await connectClient(id, opts);
1844
+ return sendJson(res, result.ok ? 200 : 422, result);
1845
+ } catch (error) {
1846
+ return sendJson(res, 500, { ok: false, error: 'client_action_failed', message: String(error?.message ?? error) });
1847
+ }
1848
+ }
1849
+
1850
+ // D2 写端点实现(memory.admin scope + CSRF 已在 handleAdminWrite 校验)
1851
+ async function applyAdminWrite(req, res, path, body) {
1852
+ if (!app || !service) {
1853
+ if (phase !== 'READY') {
1854
+ const handled = await handleUninitializedClientAction(req, res, path, body);
1855
+ if (handled !== false) return handled;
1856
+ }
1857
+ return sendJson(res, 503, { error: 'console_unavailable', message: 'Mebular 尚未初始化' });
1858
+ }
1859
+
1860
+ // 忆云档案写端点:**不**经 securityWriteGate(§6.3:加入是出站动作,语义相反)。
1861
+ if (path === '/admin/api/clouds/inspect') return handleCloudInspect(req, res, body);
1862
+ if (path === '/admin/api/clouds/join') return handleCloudJoin(req, res, body);
1863
+ if (path === '/admin/api/clouds/rename') return handleCloudRename(req, res, body);
1864
+
1865
+ // S2:安全三档写端点门禁(medium 动态码 / high 会签或恢复码;low 放行)。
1866
+ const gateInput = parseJsonBody(body) ?? {};
1867
+ const gate = await securityWriteGate({ app, home, path, input: gateInput, namespace });
1868
+ if (!gate.ok) {
1869
+ if (gate.retryAfterMs !== undefined) res.setHeader('retry-after', String(Math.ceil(gate.retryAfterMs / 1000)));
1870
+ return sendJson(res, gate.status ?? 403, {
1871
+ ok: false,
1872
+ error: gate.error ?? 'security_gate',
1873
+ message: gate.message ?? '安全门槛未通过',
1874
+ ...(gate.retryAfterMs !== undefined ? { retryAfterMs: gate.retryAfterMs } : {}),
1875
+ });
1876
+ }
1877
+
1878
+ // S2:安全后端端点(tier 读写 / 批准请求 / 待批准 / 批准 / 拒绝 / 恢复码状态)。
1879
+ // S2:TOTP 分发(显示需先输入一次动态码;导入写审计)。
1880
+ const totpAction = path.match(/^\/admin\/api\/security\/totp\/(show|import)$/);
1881
+ if (totpAction) {
1882
+ const lib = await loadFleetSecurity();
1883
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用' });
1884
+ const input = parseJsonBody(body) ?? {};
1885
+ if (totpAction[1] === 'show') {
1886
+ let seed;
1887
+ try {
1888
+ seed = await lib.readTotpSeed(home);
1889
+ } catch (error) {
1890
+ return sendJson(res, 400, { ok: false, error: 'totp_invalid_file', message: String(error?.message ?? error) });
1891
+ }
1892
+ if (seed === null) {
1893
+ if (input.generate !== true) {
1894
+ return sendJson(res, 409, {
1895
+ ok: false,
1896
+ error: 'totp_not_configured',
1897
+ message: '本机尚未导入动态码种子:请从一台已有设备导入,或由 root 生成新种子',
1898
+ });
1899
+ }
1900
+ if (!app.identity?.hasMasterPrivateKey?.()) {
1901
+ return sendJson(res, 403, { ok: false, error: 'root_required', message: '仅 root(持有主密钥私钥的设备)可生成动态码种子' });
1902
+ }
1903
+ seed = await lib.writeTotpSeed(home, lib.generateTotpSecret());
1904
+ } else {
1905
+ if (typeof input.code !== 'string' || input.code.trim().length === 0) {
1906
+ return sendJson(res, 403, { ok: false, error: 'totp_required', message: '显示动态码种子前需要输入一次本机动态码(code)' });
1907
+ }
1908
+ const check = await lib.verifyTotp(seed.secret, input.code, Date.now(), { dir: home });
1909
+ if (!check.ok) {
1910
+ return sendJson(res, check.reason === 'locked' ? 429 : 403, {
1911
+ ok: false,
1912
+ error: check.reason === 'locked' ? 'totp_locked' : check.reason === 'replay' ? 'totp_replayed' : 'totp_invalid',
1913
+ message: check.reason === 'locked'
1914
+ ? `动态码已锁定:请在 ${Math.ceil((check.retryAfterMs ?? 0) / 1000)} 秒后重试`
1915
+ : check.reason === 'replay' ? '动态码已使用过(重放被拒)' : '动态码无效:请检查设备时间',
1916
+ ...(check.retryAfterMs !== undefined ? { retryAfterMs: check.retryAfterMs } : {}),
1917
+ });
1918
+ }
1919
+ }
1920
+ const share = await lib.renderTotpShare(seed.secret, deviceId, { qr: true });
1921
+ const svg = await lib.renderTotpShareSvg(seed.secret, deviceId);
1922
+ await lib.writeSecurityTotpAudit(app, {
1923
+ device: deviceId,
1924
+ fingerprint: seed.fingerprint,
1925
+ issuedAt: Date.now(),
1926
+ note: 'console show',
1927
+ });
1928
+ return sendJson(res, 200, {
1929
+ ok: true,
1930
+ device: deviceId,
1931
+ fingerprint: seed.fingerprint,
1932
+ secret: seed.secret,
1933
+ groups: share.groups,
1934
+ otpauth: share.otpauth,
1935
+ qr: share.qrText ?? null,
1936
+ qrSvg: svg,
1937
+ warning: '种子等于这一档的钥匙:只在两台你自己的设备之间当面出示;不要截图、不要发聊天工具。',
1938
+ });
1939
+ }
1940
+ const inputSecret = typeof input.secret === 'string'
1941
+ ? input.secret
1942
+ : typeof input.uri === 'string' ? input.uri : '';
1943
+ if (inputSecret.length === 0) {
1944
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: '需要 secret(base32)或 uri(otpauth://…)' });
1945
+ }
1946
+ try {
1947
+ const record = await lib.importTotpSeed(home, inputSecret, {
1948
+ ...(typeof input.expectedFingerprint === 'string' ? { expectedFingerprint: input.expectedFingerprint } : {}),
1949
+ });
1950
+ await lib.writeSecurityTotpAudit(app, {
1951
+ device: deviceId,
1952
+ fingerprint: record.fingerprint,
1953
+ issuedAt: Date.now(),
1954
+ note: 'console import',
1955
+ });
1956
+ return sendJson(res, 200, { ok: true, device: deviceId, fingerprint: record.fingerprint, createdAt: record.createdAt });
1957
+ } catch (error) {
1958
+ return sendJson(res, 400, { ok: false, error: 'totp_import_failed', message: String(error?.message ?? error) });
1959
+ }
1960
+ }
1961
+
1962
+ // S2:恢复码生成/轮换(新码只返回一次)。
1963
+ const recoveryAction = path.match(/^\/admin\/api\/security\/recovery\/(generate|rotate)$/);
1964
+ if (recoveryAction) {
1965
+ const lib = await loadFleetSecurity();
1966
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用' });
1967
+ const input = parseJsonBody(body) ?? {};
1968
+ const events = await lib.listPolicyEvents(app);
1969
+ if (recoveryAction[1] === 'generate') {
1970
+ const existing = lib.latestRecoveryRecord(events);
1971
+ if (existing !== null) {
1972
+ return sendJson(res, 409, {
1973
+ ok: false,
1974
+ error: 'recovery_exists',
1975
+ message: `已存在恢复码(keyId ${existing.keyId.slice(0, 8)}…):请使用轮换`,
1976
+ });
1977
+ }
1978
+ const material = lib.generateRecoveryCode();
1979
+ const event = await lib.writeSecurityRecovery(app, {
1980
+ keyId: material.keyId,
1981
+ publicKey: material.publicKeyHex,
1982
+ createdAt: material.createdAt,
1983
+ mode: 'set',
1984
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
1985
+ });
1986
+ return sendJson(res, 201, {
1987
+ ok: true,
1988
+ keyId: material.keyId,
1989
+ code: material.code,
1990
+ codeFormatted: lib.formatRecoveryCode(material.code),
1991
+ eventId: event.id,
1992
+ shownOnce: true,
1993
+ });
1994
+ }
1995
+ const oldCode = typeof input.oldCode === 'string' ? input.oldCode : typeof input.code === 'string' ? input.code : '';
1996
+ if (oldCode.length === 0) {
1997
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: '轮换需要 oldCode(当前恢复码)' });
1998
+ }
1999
+ const registry = lib.collectRecoveryRegistry(events);
2000
+ const state = await lib.readSecurityState(app);
2001
+ const currentKeyId = state.recoveryKeyId ?? registry.currentKeyId ?? lib.latestRecoveryRecord(events)?.keyId;
2002
+ if (currentKeyId === undefined) {
2003
+ return sendJson(res, 409, { ok: false, error: 'recovery_missing', message: '尚未生成恢复码' });
2004
+ }
2005
+ let oldDerived;
2006
+ try {
2007
+ oldDerived = lib.deriveRecoveryKey(oldCode);
2008
+ } catch (error) {
2009
+ return sendJson(res, 400, { ok: false, error: 'recovery_invalid', message: String(error?.message ?? error) });
2010
+ }
2011
+ if (oldDerived.keyId !== currentKeyId) {
2012
+ return sendJson(res, 403, { ok: false, error: 'recovery_mismatch', message: '恢复码不匹配当前 keyId(可能已被轮换)' });
2013
+ }
2014
+ const material = lib.generateRecoveryCode();
2015
+ const signed = lib.signBreakGlass({
2016
+ code: oldCode,
2017
+ op: 'rotate',
2018
+ initiator: deviceId,
2019
+ oldKeyId: currentKeyId,
2020
+ newPublicKey: material.publicKeyHex,
2021
+ });
2022
+ const event = await lib.writeSecurityRecovery(app, {
2023
+ keyId: material.keyId,
2024
+ publicKey: material.publicKeyHex,
2025
+ createdAt: material.createdAt,
2026
+ mode: 'rotate',
2027
+ nonce: signed.proof.nonce,
2028
+ breakGlass: signed.proof,
2029
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2030
+ });
2031
+ return sendJson(res, 200, {
2032
+ ok: true,
2033
+ oldKeyId: currentKeyId,
2034
+ keyId: material.keyId,
2035
+ code: material.code,
2036
+ codeFormatted: lib.formatRecoveryCode(material.code),
2037
+ eventId: event.id,
2038
+ shownOnce: true,
2039
+ });
2040
+ }
2041
+
2042
+ if (path === '/admin/api/security/tier') {
2043
+ const input = parseJsonBody(body) ?? {};
2044
+ const level = typeof input.level === 'string' ? input.level : '';
2045
+ if (!['low', 'medium', 'high'].includes(level)) {
2046
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: 'level 必须是 low|medium|high' });
2047
+ }
2048
+ const lib = await loadFleetSecurity();
2049
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用(@mebular/fleet 未构建)' });
2050
+ const state = await lib.readSecurityState(app);
2051
+ const events = await lib.listPolicyEvents(app);
2052
+ const suppliedRecoveryCode = typeof input.recoveryCode === 'string' ? input.recoveryCode.trim() : '';
2053
+ let breakGlass = input.breakGlass;
2054
+ if (suppliedRecoveryCode.length > 0) {
2055
+ if (input.breakGlass !== undefined || input.cosign !== undefined || input.nonce !== undefined
2056
+ || typeof input.approvalId === 'string') {
2057
+ return sendJson(res, 400, {
2058
+ ok: false,
2059
+ error: 'bad_request',
2060
+ message: 'recoveryCode 不能与 breakGlass / 会签批准 / 自定义 nonce 同时使用:请只选一种执行方式。',
2061
+ });
2062
+ }
2063
+ if (level === 'high') {
2064
+ return sendJson(res, 409, {
2065
+ ok: false,
2066
+ error: 'recovery_not_applicable',
2067
+ message: '恢复码只用于高档降档;升档或保持高档无需恢复码。',
2068
+ });
2069
+ }
2070
+ const signed = await signRecoveryProof({
2071
+ lib,
2072
+ app,
2073
+ code: suppliedRecoveryCode,
2074
+ op: 'tier',
2075
+ level,
2076
+ initiator: app.deviceId ?? deviceId ?? '',
2077
+ });
2078
+ if (!signed.ok) {
2079
+ return sendJson(res, signed.status, { ok: false, error: signed.error, message: signed.message });
2080
+ }
2081
+ breakGlass = signed.proof;
2082
+ }
2083
+ let recoveryCode;
2084
+ let recoveryKeyId = state.recoveryKeyId;
2085
+ if (level === 'high' && state.level !== 'high') {
2086
+ const registry = lib.collectRecoveryRegistry(events);
2087
+ if (registry.currentKeyId === undefined && recoveryKeyId === undefined) {
2088
+ const material = lib.generateRecoveryCode();
2089
+ await lib.writeSecurityRecovery(app, {
2090
+ keyId: material.keyId,
2091
+ publicKey: material.publicKeyHex,
2092
+ createdAt: material.createdAt,
2093
+ mode: 'set',
2094
+ note: 'tier-upgrade-high',
2095
+ });
2096
+ recoveryCode = material.code;
2097
+ recoveryKeyId = material.keyId;
2098
+ }
2099
+ }
2100
+ let cosign = input.cosign;
2101
+ let approvalNonce;
2102
+ if (cosign === undefined && breakGlass === undefined && typeof input.approvalId === 'string') {
2103
+ const approval = lib.findApproval(events, input.approvalId);
2104
+ cosign = approval?.cosign;
2105
+ approvalNonce = approval?.nonce;
2106
+ }
2107
+ const written = await lib.writeSecurityTier(app, {
2108
+ level,
2109
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2110
+ ...(input.nonce !== undefined ? { nonce: input.nonce } : approvalNonce !== undefined ? { nonce: approvalNonce } : {}),
2111
+ ...(cosign !== undefined ? { cosign } : {}),
2112
+ ...(breakGlass !== undefined ? { breakGlass } : {}),
2113
+ });
2114
+ return sendJson(res, 200, {
2115
+ ok: true,
2116
+ level,
2117
+ ...(written.id !== undefined ? { eventId: written.id } : {}),
2118
+ ...(suppliedRecoveryCode.length > 0 ? { method: 'breakGlass', auditLabel: '恢复码' } : {}),
2119
+ ...(recoveryKeyId !== undefined ? { recoveryKeyId } : {}),
2120
+ ...(recoveryCode !== undefined ? {
2121
+ recoveryCode,
2122
+ recoveryCodeFormatted: lib.formatRecoveryCode(recoveryCode),
2123
+ mustAcknowledge: true,
2124
+ message: '已生成离线恢复码:只显示这一次,请抄写到离线介质后确认已保存。',
2125
+ } : {}),
2126
+ });
2127
+ }
2128
+
2129
+ if (path === '/admin/api/security/approvals') {
2130
+ const input = parseJsonBody(body) ?? {};
2131
+ const op = input.op;
2132
+ if (!['kick', 'join', 'tier', 'restore'].includes(op)) {
2133
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: 'op 必须是 kick|join|tier|restore' });
2134
+ }
2135
+ if ((op === 'kick' || op === 'restore') && typeof input.target !== 'string') {
2136
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: `${op} 需要 target(设备)` });
2137
+ }
2138
+ if (op === 'join' && typeof input.namespace !== 'string') {
2139
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: 'join 需要 namespace' });
2140
+ }
2141
+ if (op === 'tier' && !['low', 'medium', 'high'].includes(input.level)) {
2142
+ return sendJson(res, 400, { ok: false, error: 'bad_request', message: 'tier 需要 level=low|medium|high' });
2143
+ }
2144
+ const lib = await loadFleetSecurity();
2145
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用' });
2146
+ const approvalId = randomUUID();
2147
+ const nonce = randomUUID();
2148
+ const digest = lib.securityActionDigest({
2149
+ op,
2150
+ initiator: deviceId,
2151
+ ...(op === 'kick' || op === 'restore' ? { subject: input.target } : {}),
2152
+ ...(op === 'join' ? { namespace: input.namespace } : {}),
2153
+ ...(op === 'tier' ? { level: input.level } : {}),
2154
+ nonce,
2155
+ });
2156
+ const record = {
2157
+ approvalId,
2158
+ op,
2159
+ decision: 'request',
2160
+ initiator: deviceId,
2161
+ ...(op === 'kick' || op === 'restore' ? { target: input.target } : {}),
2162
+ ...(op === 'join' ? { namespace: input.namespace } : {}),
2163
+ ...(op === 'tier' ? { level: input.level } : {}),
2164
+ nonce,
2165
+ digest,
2166
+ pairingCode: lib.pairingCodeForDigest(digest),
2167
+ expiresAt: Date.now() + 10 * 60_000,
2168
+ issuedAt: Date.now(),
2169
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2170
+ };
2171
+ const event = await lib.writeSecurityApproval(app, record);
2172
+ return sendJson(res, 201, { ok: true, ...record, eventId: event.id });
2173
+ }
2174
+
2175
+ const approvalAction = path.match(/^\/admin\/api\/security\/approvals\/([^/]+)\/(approve|deny)$/);
2176
+ if (approvalAction) {
2177
+ const approvalId = decodeURIComponent(approvalAction[1]);
2178
+ const decision = approvalAction[2];
2179
+ const lib = await loadFleetSecurity();
2180
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用' });
2181
+ const events = await lib.listPolicyEvents(app);
2182
+ const request = lib.approvalsFromEvents(events).find((record) => record.approvalId === approvalId && record.decision === 'request');
2183
+ if (!request) return sendJson(res, 404, { ok: false, error: 'approval_not_found', message: '待批准请求不存在或已处理' });
2184
+ if (request.initiator === deviceId) {
2185
+ return sendJson(res, 403, { ok: false, error: 'self_approval', message: '发起设备不能批准自己的动作' });
2186
+ }
2187
+ if (decision === 'deny') {
2188
+ const record = {
2189
+ ...request,
2190
+ decision: 'deny',
2191
+ issuedAt: Date.now(),
2192
+ };
2193
+ const event = await lib.writeSecurityApproval(app, record);
2194
+ return sendJson(res, 200, { ok: true, approvalId, decision: 'deny', eventId: event.id });
2195
+ }
2196
+ const identity = app.identity?.getDeviceKey?.(deviceId);
2197
+ if (!identity || identity.privateKey === undefined) {
2198
+ return sendJson(res, 409, { ok: false, error: 'identity_unavailable', message: '本机设备身份不可用,无法会签' });
2199
+ }
2200
+ const chain = Array.isArray(identity.certificateChain) && identity.certificateChain.length > 0
2201
+ ? identity.certificateChain
2202
+ : identity.certificate !== undefined ? [identity.certificate] : [];
2203
+ const cosign = await lib.signCosignProof({
2204
+ privateKey: identity.privateKey,
2205
+ approver: deviceId,
2206
+ approverCertificateChain: chain,
2207
+ approvalId,
2208
+ op: request.op,
2209
+ digest: request.digest,
2210
+ pairingCode: request.pairingCode,
2211
+ });
2212
+ const record = {
2213
+ ...request,
2214
+ decision: 'approve',
2215
+ cosign,
2216
+ issuedAt: Date.now(),
2217
+ };
2218
+ const event = await lib.writeSecurityApproval(app, record);
2219
+ return sendJson(res, 200, { ok: true, approvalId, decision: 'approve', eventId: event.id, cosign });
2220
+ }
2221
+
2222
+ // 新设备上车:生成一次性加入令牌(需 joinService 已启用;由守护 identity 生成)
2223
+ if (path === '/admin/api/invite') {
2224
+ // C7:二维码 + 文本一起给(QR 内容 = 内联令牌文本;缺可选依赖 → 只给文本,不报错)
2225
+ if (typeof deviceId !== 'string' || typeof joinEndpoint !== 'string') {
2226
+ return sendJson(res, 409, {
2227
+ ok: false,
2228
+ error: 'join_disabled',
2229
+ message: '未启用加入服务:在「设置 → 常用配置」开启 joinService.enabled 并重启 serve',
2230
+ });
2231
+ }
2232
+ const input = parseJsonBody(body) ?? {};
2233
+ // 被移出设备不生成新令牌(作者级否定)。
2234
+ if (typeof app.getBlockedDevices === 'function') {
2235
+ const blocked = await app.getBlockedDevices().catch(() => []);
2236
+ if (Array.isArray(blocked) && blocked.includes(deviceId)) {
2237
+ return sendJson(res, 403, {
2238
+ ok: false,
2239
+ error: 'inviter_blocked',
2240
+ message: '本机已被移出网络(device_revoke 已采纳):不能再生成加入令牌',
2241
+ });
2242
+ }
2243
+ }
2244
+ // §3.7 ④:高档下把已核对的批准随响应交给新设备(内嵌到首现记录用)。
2245
+ let securityApproval = null;
2246
+ if (typeof input.approvalId === 'string' && input.approvalId.length > 0) {
2247
+ const lib = await loadFleetSecurity();
2248
+ if (lib) {
2249
+ const events = await lib.listPolicyEvents(app);
2250
+ const approval = lib.findApproval(events, input.approvalId);
2251
+ if (!approval) {
2252
+ return sendJson(res, 409, { ok: false, error: 'approval_missing', message: '批准记录不存在或未生效' });
2253
+ }
2254
+ securityApproval = {
2255
+ approvalId: approval.approvalId,
2256
+ nonce: approval.nonce,
2257
+ initiator: approval.initiator,
2258
+ ...(approval.cosign !== undefined ? { cosign: approval.cosign } : {}),
2259
+ };
2260
+ }
2261
+ }
2262
+ const ns = typeof input.namespace === 'string' && input.namespace ? input.namespace : namespace;
2263
+ const ttlMs = Number.isInteger(input.ttlMs) && input.ttlMs > 0 ? input.ttlMs : 900_000;
2264
+ // F-C6:端点默认取守护计算的**可达**地址(通配 bind → 本机 LAN IPv4);允许调用方显式覆盖
2265
+ // (控制台邀请面板可填写),令牌内 endpoint 与返回 endpoint 始终一致。
2266
+ const override = typeof input.endpoint === 'string' && input.endpoint.trim() ? input.endpoint.trim() : null;
2267
+ if (override && endpointHostname(override) === null) {
2268
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'invite_endpoint_invalid', message: 'endpoint 需为 http(s) URL,例如 http://192.168.1.20:4002' });
2269
+ }
2270
+ const endpoint = override ?? joinEndpoint;
2271
+ // C2:令牌携带邀请方可达端点(hints),新设备 join 后写入地址簿 → 自动选路
2272
+ const hints = collectInviteHints(app, config);
2273
+ const nonce = randomBytes(16).toString('hex');
2274
+ const token = await buildJoinToken({
2275
+ mebular: app,
2276
+ deviceId,
2277
+ namespace: ns,
2278
+ endpoint,
2279
+ ttlMs,
2280
+ nonce,
2281
+ ...(securityApproval !== null ? { securityApproval } : {}),
2282
+ ...hints,
2283
+ });
2284
+ // 单一有效邀请:新令牌激活时把上一个 active nonce 放进 revoked(join 服务验证时拒绝)。
2285
+ await activateJoinInvite(joinStateStoragePath, nonce);
2286
+ const inline = Buffer.from(JSON.stringify(token), 'utf-8').toString('base64');
2287
+ const endpointHost = endpointHostname(endpoint);
2288
+ const invalidEndpoint = endpointHost === null;
2289
+ const loopback = endpointHost !== null && isLoopbackHost(endpointHost);
2290
+ const qrWarnings = [];
2291
+ const qrSvg = await renderSvgQr(inline, { onWarn: (m) => qrWarnings.push(m) });
2292
+ return sendJson(res, 201, {
2293
+ ok: true,
2294
+ token: inline,
2295
+ // C7:服务端渲染 SVG(data-uri 由前端组装);库不可用时为 null
2296
+ qrSvg: qrSvg ? qrSvg.value : null,
2297
+ ...(qrWarnings.length > 0 ? { qrWarnings } : {}),
2298
+ endpoint,
2299
+ endpointSource: override ? 'override' : 'daemon',
2300
+ namespace: ns,
2301
+ expiresAt: token.expiresAt,
2302
+ ttlMs,
2303
+ ...(securityApproval !== null ? { securityApproval } : {}),
2304
+ ...(invalidEndpoint
2305
+ ? { warning: '邀请端点不是有效地址:请在「设置 → 高级 → 常驻服务」检查 joinService.bind/port,或手工修正 config.json 中的 joinService.endpoint。' }
2306
+ : loopback
2307
+ ? { warning: '该端点是回环地址,其他设备无法访问:请在「设置 → 高级 → 常驻服务」将 joinService.bind 设为 0.0.0.0。' }
2308
+ : {}),
2309
+ });
2310
+ }
2311
+
2312
+ // 控制台配置写入:curated 合并 + 原子写 + .bak 备份(全部需重启生效)
2313
+ if (path === '/admin/api/config') {
2314
+ const input = parseJsonBody(body);
2315
+ if (!input || typeof input.patch !== 'object') {
2316
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'config_patch_invalid', message: '需要 { patch: { ... } }' });
2317
+ }
2318
+ const { errors, entries } = validateConfigPatch(input.patch);
2319
+ if (errors.length > 0) {
2320
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'config_patch_invalid', message: errors.join(';'), details: errors });
2321
+ }
2322
+ const cfgFile = configPath(home);
2323
+ let current = {};
2324
+ try {
2325
+ current = JSON.parse(await readFile(cfgFile, 'utf-8'));
2326
+ } catch {
2327
+ current = {};
2328
+ }
2329
+ const merged = JSON.parse(JSON.stringify(current));
2330
+ for (const entry of entries) {
2331
+ if (entry.value === null) deleteAtPath(merged, entry.path);
2332
+ else setAtPath(merged, entry.path, entry.value);
2333
+ }
2334
+ // F-C2:组合校验(基于合并结果;拒绝会把自己搞挂的 host/auth/tls 组合)
2335
+ const comboErrors = validateConfigCombination(merged);
2336
+ if (comboErrors.length > 0) {
2337
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'config_patch_invalid', message: comboErrors.join(';'), details: comboErrors });
2338
+ }
2339
+ const changedPaths = entries.map((e) => e.path);
2340
+ const { restart: restartPaths, immediate: immediatePaths, selfLock } = classifyChanges(changedPaths, hotPaths());
2341
+ // G4:自锁防护——命中 auth/host/tls 需显式确认(重启后控制台可能不可达);未确认则不写不重启
2342
+ if (selfLock.length > 0 && input.confirm !== true) {
2343
+ return sendJson(res, 409, {
2344
+ ok: false,
2345
+ error: 'needs_confirmation',
2346
+ needsConfirmation: true,
2347
+ paths: selfLock,
2348
+ message: `改动命中 ${selfLock.join(', ')}:保存并重启后控制台可能无法访问。如失联请手工编辑 ${cfgFile} 改回并重启。请带 { confirm: true } 重试。`,
2349
+ });
2350
+ }
2351
+ const tmp = `${cfgFile}.tmp-${process.pid}`;
2352
+ await writeFile(tmp, JSON.stringify(merged, null, 2), 'utf-8');
2353
+ if (existsSync(cfgFile)) await copyFile(cfgFile, `${cfgFile}.bak`);
2354
+ await rename(tmp, cfgFile);
2355
+ const backup = existsSync(`${cfgFile}.bak`) ? `${cfgFile}.bak` : null;
2356
+ const base = { ok: true, applied: changedPaths, backup, path: cfgFile, config: merged };
2357
+
2358
+ // G1:全部即时生效项 → 不重启,就地生效(前端随即刷新 effective)
2359
+ if (restartPaths.length === 0) {
2360
+ return sendJson(res, 200, { ...base, restartRequired: false, effectiveImmediately: true, restart: { mode: 'none' }, immediatePaths });
2361
+ }
2362
+
2363
+ const autoRestart = process.env.MEBULAR_CONFIG_AUTORESTART !== '0';
2364
+ const serviceManaged = Boolean(process.env.MEBULAR_SERVICE_KIND) || Boolean(String(process.env.MEBULAR_RESTART_CMD ?? '').trim());
2365
+ // 前台 nohup(无服务托管)或显式关闭 → 手动指引;待重启语义保留(#80 检测仍有效)
2366
+ if (!autoRestart || !serviceManaged) {
2367
+ return sendJson(res, 200, {
2368
+ ...base,
2369
+ restartRequired: true,
2370
+ restarting: false,
2371
+ restart: {
2372
+ mode: 'foreground',
2373
+ command: `nohup mebular serve > ${home}/serve.log 2>&1 &`,
2374
+ backup,
2375
+ manual: `未以服务方式运行:请手动重启(nohup mebular serve > ${home}/serve.log 2>&1 &);如新配置起不来,用备份回滚:cp ${backup ?? `${cfgFile}.bak`} ${cfgFile}`,
2376
+ },
2377
+ });
2378
+ }
2379
+ // 服务托管 → 自动重启(保存动作即确认;防抖窗口内多次保存合并为一次)
2380
+ const targetPort = (() => {
2381
+ const raw = merged?.mcp?.http?.port;
2382
+ return typeof raw === 'number' && raw > 0 ? raw : (typeof runtime?.mcp?.port === 'number' ? runtime.mcp.port : 0);
2383
+ })();
2384
+ const timeoutMs = Number(process.env.MEBULAR_APPLY_TIMEOUT_MS ?? '') > 0 ? Number(process.env.MEBULAR_APPLY_TIMEOUT_MS) : APPLY_HEALTH_TIMEOUT_MS;
2385
+ const scheduled = scheduleApplyRestart({ fields: restartPaths, port: targetPort, timeoutMs });
2386
+ return sendJson(res, 200, {
2387
+ ...base,
2388
+ restartRequired: true,
2389
+ restarting: true,
2390
+ restart: {
2391
+ mode: 'service',
2392
+ debounceMs: RESTART_DEBOUNCE_MS,
2393
+ deferred: scheduled.debounced,
2394
+ fields: restartPaths,
2395
+ timeoutMs,
2396
+ },
2397
+ });
2398
+ }
2399
+
2400
+ // G-ML-1:记忆生命周期(动作式,无开关)——归档/解除归档(可逆)+ 删除(墓碑)
2401
+ const memoryActionMatch = path.match(/^\/admin\/api\/memories\/([^/]+)\/(archive|unarchive|delete)$/);
2402
+ if (memoryActionMatch) {
2403
+ const id = decodeURIComponent(memoryActionMatch[1]);
2404
+ const action = memoryActionMatch[2];
2405
+ if (action === 'delete') {
2406
+ const result = await service.delete([id]);
2407
+ if (result.deleted.length === 0) {
2408
+ return sendJson(res, 404, { ok: false, error: 'not_found', errorKey: 'memory_not_found', message: `记忆不存在:${id}` });
2409
+ }
2410
+ return sendJson(res, 200, { ok: true, action: 'delete', id, ...result });
2411
+ }
2412
+ const result = await service.archive([id], action === 'archive');
2413
+ if (result.notFound.length > 0) {
2414
+ return sendJson(res, 404, { ok: false, error: 'not_found', errorKey: 'memory_not_found', message: `记忆不存在:${id}` });
2415
+ }
2416
+ if (result.skipped.length > 0) {
2417
+ return sendJson(res, 409, { ok: false, error: 'conflict', errorKey: 'memory_deleted_archive', message: '已删除(墓碑)的记忆不可归档', id });
2418
+ }
2419
+ return sendJson(res, 200, { ok: true, action, id, ...result });
2420
+ }
2421
+
2422
+ // 一键重启(C):设置页「待重启」横幅 → 立即让保存的配置生效
2423
+ if (path === '/admin/api/restart') {
2424
+ const input = parseJsonBody(body) ?? {};
2425
+ if (input.confirm !== true) {
2426
+ return sendJson(res, 400, { error: 'confirmation_required', message: '重启需二次确认:请带 { confirm: true }' });
2427
+ }
2428
+ const runner = typeof restartRunner === 'function' ? restartRunner : defaultRestartRunner;
2429
+ const plan = await runner({ home });
2430
+ if (!plan?.registered) {
2431
+ return sendJson(res, 409, {
2432
+ ok: false,
2433
+ error: 'not_service_managed',
2434
+ message: plan?.manual ?? '未以服务方式运行:请手动重启',
2435
+ manual: plan?.manual ?? null,
2436
+ command: plan?.command ?? null,
2437
+ });
2438
+ }
2439
+ // 先返回 202,再触发重启(避免响应被重启信号打断)
2440
+ sendJson(res, 202, {
2441
+ ok: true,
2442
+ action: 'restart',
2443
+ command: plan.command,
2444
+ ...(plan.commands ? { commands: plan.commands } : {}),
2445
+ ...(plan.dryRun ? { dryRun: true } : {}),
2446
+ note: '守护正在重启;页面会在稍后轮询中自动恢复',
2447
+ });
2448
+ setImmediate(() => { void Promise.resolve(plan.trigger?.()).catch(() => undefined); });
2449
+ return true;
2450
+ }
2451
+
2452
+ // 新建记忆域(零门槛):写自签名 `namespace_declare`;不自动订阅。
2453
+ if (path === '/admin/api/namespaces') {
2454
+ const input = parseJsonBody(body);
2455
+ if (!input || typeof input.namespace !== 'string' || input.namespace.trim().length === 0) {
2456
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'namespace_required', message: '需要 { namespace }' });
2457
+ }
2458
+ const event = await app.declareNamespace({
2459
+ namespace: input.namespace,
2460
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2461
+ });
2462
+ const namespace = event.data?.declaration?.namespace ?? input.namespace;
2463
+ // 默认自订阅(零门槛):创建完就能写;用户仍可随时退订。
2464
+ const subscribed = input.subscribe !== false;
2465
+ if (subscribed) {
2466
+ await app.subscribeNamespace(namespace);
2467
+ await persistSubscriptionNamespaces(home, app.sync.getSubscriptionNamespaces());
2468
+ }
2469
+ return sendJson(res, 201, { ok: true, namespace, eventId: event.id, subscribed });
2470
+ }
2471
+
2472
+ // 订阅/退订(只改本机;热生效,不重启)
2473
+ if (path === '/admin/api/subscriptions') {
2474
+ const input = parseJsonBody(body);
2475
+ if (!input || typeof input.namespace !== 'string' || input.namespace.trim().length === 0) {
2476
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'namespace_required', message: '需要 { namespace, active?, nextNamespaces? }' });
2477
+ }
2478
+ const ns = input.namespace;
2479
+ const active = input.active !== false;
2480
+ const event = active
2481
+ ? await app.subscribeNamespace(ns)
2482
+ : await app.unsubscribeNamespace(ns);
2483
+ // 持久化 sync.namespaces(热生效:不重启;core 运行期已更新)
2484
+ const next = Array.isArray(input.nextNamespaces)
2485
+ ? input.nextNamespaces.filter((entry) => typeof entry === 'string' && entry.length > 0)
2486
+ : app.sync.getSubscriptionNamespaces();
2487
+ if (next.length > 0 || input.nextNamespaces !== undefined || !active) {
2488
+ await persistSubscriptionNamespaces(home, next);
2489
+ }
2490
+ return sendJson(res, 200, {
2491
+ ok: true,
2492
+ namespace: ns,
2493
+ active,
2494
+ eventId: event.id,
2495
+ subscriptions: app.sync.getSubscriptionNamespaces(),
2496
+ });
2497
+ }
2498
+
2499
+ // 本机设备名(只有本人可改;写自签名 device_name,不重签证书)。
2500
+ if (path === '/admin/api/devices/self/name') {
2501
+ const input = parseJsonBody(body) ?? {};
2502
+ if (typeof input.name !== 'string' || input.name.trim().length === 0) {
2503
+ return sendJson(res, 400, { error: 'invalid_name', message: '需要 { name }(1–24 个字符)' });
2504
+ }
2505
+ try {
2506
+ const event = await app.setDeviceName({ name: input.name });
2507
+ const derived = await app.getDeviceName(app.deviceId);
2508
+ return sendJson(res, 200, {
2509
+ ok: true,
2510
+ deviceId: app.deviceId,
2511
+ name: derived?.name ?? input.name,
2512
+ eventId: event.id,
2513
+ deviceName: derived ?? null,
2514
+ });
2515
+ } catch (error) {
2516
+ const status = error?.code === 'VALIDATION_INVALID_ARGUMENT' ? 400 : 500;
2517
+ return sendJson(res, status, {
2518
+ ok: false,
2519
+ error: error?.code ?? 'device_name_failed',
2520
+ message: error?.message ?? String(error),
2521
+ });
2522
+ }
2523
+ }
2524
+
2525
+ const deviceMatch = path.match(/^\/admin\/api\/devices\/([^/]+)\/(revoke|restore|connect|disconnect|sync|reset-watermarks)$/);
2526
+ if (deviceMatch) {
2527
+ const deviceId = decodeURIComponent(deviceMatch[1]);
2528
+ const action = deviceMatch[2];
2529
+
2530
+ if (action === 'revoke') {
2531
+ const input = parseJsonBody(body) ?? {};
2532
+ const lib = await loadFleetSecurity();
2533
+ const suppliedRecoveryCode = typeof input.recoveryCode === 'string' ? input.recoveryCode.trim() : '';
2534
+ if (suppliedRecoveryCode.length > 0) {
2535
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用(@mebular/fleet 未构建)' });
2536
+ if (input.breakGlass !== undefined || input.cosign !== undefined || input.nonce !== undefined
2537
+ || typeof input.approvalId === 'string') {
2538
+ return sendJson(res, 400, {
2539
+ ok: false,
2540
+ error: 'bad_request',
2541
+ message: 'recoveryCode 不能与 breakGlass / 会签批准 / 自定义 nonce 同时使用:请只选一种执行方式。',
2542
+ });
2543
+ }
2544
+ const signed = await signRecoveryProof({
2545
+ lib,
2546
+ app,
2547
+ code: suppliedRecoveryCode,
2548
+ op: 'kick',
2549
+ subject: deviceId,
2550
+ initiator: app.deviceId ?? deviceId ?? '',
2551
+ });
2552
+ if (!signed.ok) {
2553
+ return sendJson(res, signed.status, { ok: false, error: signed.error, message: signed.message });
2554
+ }
2555
+ const event = await lib.writeDeviceRevoke(app, {
2556
+ subject: deviceId,
2557
+ note: typeof input.note === 'string' ? input.note : 'recovery-code',
2558
+ breakGlass: signed.proof,
2559
+ });
2560
+ return sendJson(res, 200, {
2561
+ ok: true,
2562
+ deviceId,
2563
+ eventId: event.id,
2564
+ method: 'breakGlass',
2565
+ auditLabel: '恢复码',
2566
+ });
2567
+ }
2568
+ let cosign = input.cosign;
2569
+ let approvalNonce = typeof input.nonce === 'string' ? input.nonce : undefined;
2570
+ if (lib && (cosign !== undefined || typeof input.approvalId === 'string') && input.breakGlass === undefined) {
2571
+ const events = await lib.listPolicyEvents(app);
2572
+ const approvalId = typeof input.approvalId === 'string'
2573
+ ? input.approvalId
2574
+ : typeof cosign?.approvalId === 'string' ? cosign.approvalId : null;
2575
+ if (approvalId !== null) {
2576
+ const approval = lib.findApproval(events, approvalId);
2577
+ if (cosign === undefined) cosign = approval?.cosign;
2578
+ if (approvalNonce === undefined) approvalNonce = approval?.nonce;
2579
+ }
2580
+ }
2581
+ if (lib && (cosign !== undefined || input.breakGlass !== undefined)) {
2582
+ const event = await lib.writeDeviceRevoke(app, {
2583
+ subject: deviceId,
2584
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2585
+ ...(approvalNonce !== undefined ? { nonce: approvalNonce } : {}),
2586
+ ...(cosign !== undefined ? { cosign } : {}),
2587
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass } : {}),
2588
+ });
2589
+ return sendJson(res, 200, {
2590
+ ok: true,
2591
+ deviceId,
2592
+ eventId: event.id,
2593
+ method: input.breakGlass !== undefined ? 'breakGlass' : 'cosign',
2594
+ ...(input.breakGlass !== undefined ? { auditLabel: '恢复码' } : {}),
2595
+ });
2596
+ }
2597
+ const event = await app.revokeDevice({
2598
+ subject: deviceId,
2599
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2600
+ });
2601
+ return sendJson(res, 200, { ok: true, deviceId, eventId: event.id });
2602
+ }
2603
+
2604
+ if (action === 'restore') {
2605
+ const input = parseJsonBody(body) ?? {};
2606
+ const lib = await loadFleetSecurity();
2607
+ const suppliedRecoveryCode = typeof input.recoveryCode === 'string' ? input.recoveryCode.trim() : '';
2608
+ if (suppliedRecoveryCode.length > 0) {
2609
+ if (!lib) return sendJson(res, 503, { ok: false, error: 'security_unavailable', message: '安全模块不可用(@mebular/fleet 未构建)' });
2610
+ if (input.breakGlass !== undefined || input.cosign !== undefined || input.nonce !== undefined
2611
+ || typeof input.approvalId === 'string') {
2612
+ return sendJson(res, 400, {
2613
+ ok: false,
2614
+ error: 'bad_request',
2615
+ message: 'recoveryCode 不能与 breakGlass / 会签批准 / 自定义 nonce 同时使用:请只选一种执行方式。',
2616
+ });
2617
+ }
2618
+ const signed = await signRecoveryProof({
2619
+ lib,
2620
+ app,
2621
+ code: suppliedRecoveryCode,
2622
+ op: 'restore',
2623
+ subject: deviceId,
2624
+ initiator: app.deviceId ?? deviceId ?? '',
2625
+ });
2626
+ if (!signed.ok) {
2627
+ return sendJson(res, signed.status, { ok: false, error: signed.error, message: signed.message });
2628
+ }
2629
+ const event = await lib.writeDeviceRestore(app, {
2630
+ subject: deviceId,
2631
+ note: typeof input.note === 'string' ? input.note : 'recovery-code',
2632
+ breakGlass: signed.proof,
2633
+ });
2634
+ return sendJson(res, 200, {
2635
+ ok: true,
2636
+ deviceId,
2637
+ eventId: event.id,
2638
+ method: 'breakGlass',
2639
+ auditLabel: '恢复码',
2640
+ });
2641
+ }
2642
+ let cosign = input.cosign;
2643
+ let approvalNonce = typeof input.nonce === 'string' ? input.nonce : undefined;
2644
+ if (lib && (cosign !== undefined || typeof input.approvalId === 'string') && input.breakGlass === undefined) {
2645
+ const events = await lib.listPolicyEvents(app);
2646
+ const approvalId = typeof input.approvalId === 'string'
2647
+ ? input.approvalId
2648
+ : typeof cosign?.approvalId === 'string' ? cosign.approvalId : null;
2649
+ if (approvalId !== null) {
2650
+ const approval = lib.findApproval(events, approvalId);
2651
+ if (cosign === undefined) cosign = approval?.cosign;
2652
+ if (approvalNonce === undefined) approvalNonce = approval?.nonce;
2653
+ }
2654
+ }
2655
+ if (lib && (cosign !== undefined || input.breakGlass !== undefined)) {
2656
+ const event = await lib.writeDeviceRestore(app, {
2657
+ subject: deviceId,
2658
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2659
+ ...(approvalNonce !== undefined ? { nonce: approvalNonce } : {}),
2660
+ ...(cosign !== undefined ? { cosign } : {}),
2661
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass } : {}),
2662
+ });
2663
+ return sendJson(res, 200, {
2664
+ ok: true,
2665
+ deviceId,
2666
+ eventId: event.id,
2667
+ method: input.breakGlass !== undefined ? 'breakGlass' : 'cosign',
2668
+ ...(input.breakGlass !== undefined ? { auditLabel: '恢复码' } : {}),
2669
+ });
2670
+ }
2671
+ const event = await app.restoreDevice({
2672
+ subject: deviceId,
2673
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2674
+ });
2675
+ return sendJson(res, 200, { ok: true, deviceId, eventId: event.id });
2676
+ }
2677
+
2678
+ if (action === 'reset-watermarks') {
2679
+ await app.resetPeerWatermarks(deviceId);
2680
+ return sendJson(res, 200, { ok: true, deviceId });
2681
+ }
2682
+
2683
+ const node = app.node;
2684
+ if (!node || !node.isRunning()) {
2685
+ return sendJson(res, 409, { error: 'network_not_running', message: '网络未启用,无法连接或同步' });
2686
+ }
2687
+
2688
+ if (action === 'connect') {
2689
+ const input = parseJsonBody(body) ?? {};
2690
+ if (typeof input.address !== 'string' || input.address.length === 0) {
2691
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'address_required', message: '需要 { address }(对端 multiaddr,含 /p2p/…)' });
2692
+ }
2693
+ const peerId = { multihash: new Uint8Array(), pubKey: new Uint8Array(), id: deviceId };
2694
+ const connection = await node.connectToPeer(peerId, input.address);
2695
+ return sendJson(res, 200, { ok: true, deviceId, remoteAddress: connection.remoteAddress });
2696
+ }
2697
+
2698
+ const found = findConnectionByDevice(deviceId);
2699
+ if (!found) {
2700
+ return sendJson(res, 409, {
2701
+ error: 'not_connected',
2702
+ message: `${deviceId} 当前未连接;请先用 connect 提供对端 multiaddr`,
2703
+ });
2704
+ }
2705
+
2706
+ if (action === 'disconnect') {
2707
+ await node.disconnectPeer(found.connection.peerId);
2708
+ return sendJson(res, 200, { ok: true, deviceId });
2709
+ }
2710
+
2711
+ if (action === 'sync') {
2712
+ // v1.1 起会话由 SyncManager 常驻循环统一编排(PeerFrames 单消费者)。
2713
+ // 不能在裸信道上另起 syncWithDevice:会与会话循环抢帧,导致
2714
+ // "Sync timeout waiting for sync-hello" 并抛 500。
2715
+ // runAntiEntropyCycle 是公共触发路径:对在线且确有 pending 的对端
2716
+ // 唤醒发起方循环(响应方角色由 nudge 机制覆盖)。
2717
+ const before = await app.sync.getSyncStatus();
2718
+ await app.sync.runAntiEntropyCycle();
2719
+ return sendJson(res, 200, {
2720
+ ok: true,
2721
+ deviceId,
2722
+ triggered: before.pendingCount > 0,
2723
+ pendingBefore: before.pendingCount,
2724
+ });
2725
+ }
2726
+ }
2727
+
2728
+ // 分区动作:rejoin(重入恢复)/ leave(退出并交接;force 不走控制台)
2729
+ const nsActionMatch = path.match(/^\/admin\/api\/namespaces\/([^/]+)\/(rejoin|leave)$/);
2730
+ if (nsActionMatch) {
2731
+ const namespace = decodeURIComponent(nsActionMatch[1]);
2732
+ const action = nsActionMatch[2];
2733
+ const input = parseJsonBody(body) ?? {};
2734
+ if (action === 'rejoin') {
2735
+ const result = await app.rejoinNamespace({ namespace });
2736
+ return sendJson(res, result.ok ? 200 : 409, result);
2737
+ }
2738
+ if (input.force === true) {
2739
+ // SEALING §3:force 仅限本地 CLI,不经 MCP/远程
2740
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'handoff_force_local_only', message: 'force 仅限本地 CLI(SEALING §3),控制台不提供' });
2741
+ }
2742
+ if (typeof input.successor !== 'string' || input.successor.length === 0) {
2743
+ return sendJson(res, 400, { error: 'bad_request', errorKey: 'handoff_successor_required', message: '需要 { successor }(继任者 deviceId)' });
2744
+ }
2745
+ const result = await app.leaveNamespace({
2746
+ namespace,
2747
+ successor: input.successor,
2748
+ ...(typeof input.note === 'string' ? { note: input.note } : {}),
2749
+ });
2750
+ return sendJson(res, result.ok ? 200 : 409, result);
2751
+ }
2752
+
2753
+ // H-1:常驻运行(服务托管,动作式;服务命令带**守护自身 home**)。CI 用 MEBULAR_SERVICE_DRY_RUN=1 替身断言命令序列。
2754
+ const svcAction = path.match(/^\/admin\/api\/service\/(install|uninstall)$/);
2755
+ if (svcAction) {
2756
+ const { installServiceForHome, uninstallServiceForHome, serviceStatusForHome, resolveServiceHome } = await import('./service-control.mjs');
2757
+ const dryRun = process.env.MEBULAR_SERVICE_DRY_RUN === '1';
2758
+ const commands = [];
2759
+ const run = dryRun ? (cmd, args) => { commands.push([cmd, ...(args ?? [])]); return { status: 0, stdout: '', stderr: '' }; } : undefined;
2760
+ // 两套 home:home = 实例身份(descriptor),serviceHome = unit/manifest 落盘(用户 OS home)。
2761
+ const serviceHome = resolveServiceHome();
2762
+ try {
2763
+ const action = svcAction[1];
2764
+ const result = action === 'install'
2765
+ ? installServiceForHome({ home, serviceHome, ...(run ? { run } : {}) })
2766
+ : uninstallServiceForHome({ home, serviceHome, ...(run ? { run } : {}) });
2767
+ const status = serviceStatusForHome({ home, serviceHome, ...(run ? { run } : {}) });
2768
+ return sendJson(res, 200, { ok: true, action, dryRun, commands, result, status });
2769
+ } catch (error) {
2770
+ return sendJson(res, 500, { ok: false, action: svcAction[1], error: 'service_action_failed', message: String(error?.message ?? error) });
2771
+ }
2772
+ }
2773
+
2774
+ // UX-2:自定义助手条目 CRUD(注册表 = 内置 ∪ `<home>/assistants.custom.json`;内置不可改删)
2775
+ const assistantsMatch = path.match(/^\/admin\/api\/assistants(?:\/([^/]+))?(\/remove)?$/);
2776
+ if (assistantsMatch) {
2777
+ const { addCustomAssistant, updateCustomAssistant, removeCustomAssistant } = await import('./clients.mjs');
2778
+ const id = assistantsMatch[1] ? decodeURIComponent(assistantsMatch[1]) : null;
2779
+ const isRemove = assistantsMatch[2] === '/remove';
2780
+ const input = parseJsonBody(body) ?? {};
2781
+ const opts = { home, cwd: process.cwd() };
2782
+ try {
2783
+ if (isRemove && id) {
2784
+ const result = await removeCustomAssistant(id, opts);
2785
+ return sendJson(res, result.ok ? 200 : 422, result);
2786
+ }
2787
+ if (req.method === 'POST' && !id) {
2788
+ const result = await addCustomAssistant(input, opts);
2789
+ return sendJson(res, result.ok ? 200 : 422, result);
2790
+ }
2791
+ if ((req.method === 'PUT' || req.method === 'PATCH') && id) {
2792
+ const result = await updateCustomAssistant(id, input, opts);
2793
+ return sendJson(res, result.ok ? 200 : 422, result);
2794
+ }
2795
+ return sendJson(res, 405, { error: 'method_not_allowed', errorKey: 'invalid_action', message: '新增用 POST /admin/api/assistants;更新用 PUT/PATCH …/:id;删除用 POST …/:id/remove' });
2796
+ } catch (error) {
2797
+ return sendJson(res, 500, { ok: false, error: 'assistant_registry_failed', message: String(error?.message ?? error) });
2798
+ }
2799
+ }
2800
+
2801
+ // H-1:「连接我的 AI 助手」接入 / 预览 / 回滚 / Skill / 验证(只并 mebular 键;备份 + 原子写 + 幂等)
2802
+ const cliAction = path.match(/^\/admin\/api\/clients\/([^/]+)\/(connect|rollback|skills|preview|verify|recheck)$/);
2803
+ if (cliAction) {
2804
+ const { connectClient, rollbackClient, installSkills, previewClient, verifyAssistant, listAssistantsAsync, assistantUserHome, daemonMcpUrl } = await import('./clients.mjs');
2805
+ const id = decodeURIComponent(cliAction[1]);
2806
+ const action = cliAction[2];
2807
+ const input = parseJsonBody(body) ?? {};
2808
+ // F-FIX-B:与本 serve 实例同一守护端点(HTTP /mcp)→ 条目/片段/写入都用它
2809
+ const mcpUrl = daemonMcpUrl({ host: req.headers?.host, encrypted: Boolean(req.socket?.encrypted) });
2810
+ const opts = { home, cwd: process.cwd(), mcpUrl };
2811
+ try {
2812
+ if (action === 'preview') return sendJson(res, 200, previewClient(id, opts));
2813
+ if (action === 'verify') {
2814
+ // 验证未过也是**有效结果**(体里带 ok/state/steps),故一律 200 由 UI 读 body
2815
+ const result = await verifyAssistant(id, {
2816
+ ...opts,
2817
+ ...(input.userConfirmedReload === true ? { userConfirmedReload: true } : {}),
2818
+ ...(typeof input.backupPath === 'string' ? { backupPath: input.backupPath } : {}),
2819
+ });
2820
+ return sendJson(res, 200, result);
2821
+ }
2822
+ if (action === 'connect') {
2823
+ const result = await connectClient(id, opts);
2824
+ return sendJson(res, result.ok ? 200 : 422, result);
2825
+ }
2826
+ if (action === 'recheck') {
2827
+ // F-FIX-A:桌面白名单已允许 `recheck`(服务端此前 404)。语义 = 单客户端**强制重探**:
2828
+ // 只读某条状态,但会真实探测(CLI/进程/文件),故与 preview/connect 同族走动作端点
2829
+ // (同一鉴权 + CSRF 管线),并强制 `refresh:true` 绕过缓存——避免 GET 被缓存/预取而看不到即时状态。
2830
+ const out = await listAssistantsAsync({ home, userHome: assistantUserHome(), cwd: process.cwd(), refresh: true, mcpUrl });
2831
+ const client = out.assistants.find((c) => c.id === id) ?? null;
2832
+ if (!client) return sendJson(res, 404, { ok: false, error: 'unknown_client', id });
2833
+ return sendJson(res, 200, { ok: true, client, cached: false });
2834
+ }
2835
+ if (action === 'rollback') {
2836
+ const result = await rollbackClient(id, { ...opts, ...(typeof input.backupPath === 'string' ? { backupPath: input.backupPath } : {}) });
2837
+ return sendJson(res, result.ok ? 200 : 422, result);
2838
+ }
2839
+ const result = await installSkills(id, { ...opts, repoRoot: process.cwd() });
2840
+ return sendJson(res, result.ok ? 200 : 422, result);
2841
+ } catch (error) {
2842
+ return sendJson(res, 500, { ok: false, error: 'client_action_failed', message: String(error?.message ?? error) });
2843
+ }
2844
+ }
2845
+
2846
+ return sendJson(res, 404, { error: 'not_found', path });
2847
+ }
2848
+
2849
+ async function handleAdminWrite(req, res, path, body) {
2850
+ if (!WRITE_PATTERNS.some((re) => re.test(path))) return false;
2851
+ // H3:写请求仅允许 POST/PUT/PATCH
2852
+ if (!['POST', 'PUT', 'PATCH'].includes(req.method ?? '')) {
2853
+ return sendJson(res, 405, { error: 'method_not_allowed', message: '写端点仅接受 POST/PUT/PATCH' });
2854
+ }
2855
+ // H3:跨站点写防护——若浏览器带了 Origin,必须与本服务同源(CLI/脚本不带 Origin)
2856
+ const reqOrigin = req.headers.origin;
2857
+ if (typeof reqOrigin === 'string' && reqOrigin.length > 0 && reqOrigin !== origin) {
2858
+ return sendJson(res, 403, { error: 'forbidden', message: `跨站点写被拒(Origin=${reqOrigin})` });
2859
+ }
2860
+ // 1) 鉴权(写操作要求 memory.admin)
2861
+ const check = await authenticate(req, Buffer.alloc(0), 'memory.admin');
2862
+ if (!check.ok) {
2863
+ res.statusCode = check.status;
2864
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
2865
+ return sendJson(res, check.status, { error: 'unauthorized', message: check.message });
2866
+ }
2867
+ // 2) CSRF 双提交
2868
+ if (!csrfValid(req)) {
2869
+ return sendJson(res, 403, { error: 'forbidden', message: 'CSRF token 缺失或无效' });
2870
+ }
2871
+ // 3) D1:只读降级;D2 起由 applyAdminWrite 处理
2872
+ if (!writesEnabled || typeof applyAdminWrite !== 'function') {
2873
+ if (/^\/admin\/api\/clouds\/(join|rename|inspect)$/.test(path)) {
2874
+ return sendJson(res, 403, {
2875
+ ok: false,
2876
+ error: 'CLOUD_DISABLED',
2877
+ reason: 'read_only',
2878
+ message: '控制台处于只读模式:忆云写操作已禁用',
2879
+ });
2880
+ }
2881
+ return sendJson(res, 403, { error: 'forbidden', reason: 'console_read_only' });
2882
+ }
2883
+ return applyAdminWrite(req, res, path, body);
2884
+ }
2885
+
2886
+ const handler = async (req, res) => {
2887
+ try {
2888
+ const url = new URL(req.url, origin);
2889
+ const path = url.pathname;
2890
+ const body = req.method === 'POST' || req.method === 'PUT' ? await readBody(req) : Buffer.alloc(0);
2891
+
2892
+ if (path === '/healthz' && req.method === 'GET') {
2893
+ return sendJson(res, 200, {
2894
+ status: 'ok',
2895
+ name: 'mebular',
2896
+ version: APP_VERSION,
2897
+ auth,
2898
+ tls,
2899
+ // 实例身份:App 用 instance 校验端口上是不是目标忆云(200 但 instance 不符 = 另一张忆云)。
2900
+ instance: homeKey(home),
2901
+ ...(cloudId !== null ? { cloudId } : {}),
2902
+ });
2903
+ }
2904
+ // DSH 嵌入发现:匿名只读、无敏感信息;同样在 OAuth / /app 鉴权之前。
2905
+ if (path === '/app/embed/info') {
2906
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method_not_allowed' });
2907
+ res.setHeader('cache-control', 'no-store');
2908
+ return sendJson(res, 200, buildEmbedInfo({ origin, loopback: isLoopback }));
2909
+ }
2910
+ if (auth === 'oauth') {
2911
+ if (path === '/.well-known/oauth-protected-resource' && req.method === 'GET') {
2912
+ return sendJson(res, 200, metadata.protectedResource);
2913
+ }
2914
+ if (path === '/.well-known/oauth-authorization-server' && req.method === 'GET') {
2915
+ return sendJson(res, 200, metadata.authorizationServer);
2916
+ }
2917
+ if (path === '/jwks' && req.method === 'GET') {
2918
+ return sendJson(res, 200, {
2919
+ keys: [{ kty: 'OKP', crv: 'Ed25519', x: b64url(signingKey.publicRaw), kid: signingKey.kid, use: 'sig', alg: 'EdDSA' }],
2920
+ });
2921
+ }
2922
+ if (path === '/register' && req.method === 'POST') {
2923
+ // D45:默认禁用;仅 bootstrap secret 匹配时允许,且 scope 上限 memory.read
2924
+ if (!registrationEnabled) return sendJson(res, 404, { error: 'not_found' });
2925
+ const provided = req.headers['x-mebular-register-secret'];
2926
+ const bearer = typeof req.headers['authorization'] === 'string' && req.headers['authorization'].startsWith('Bearer ')
2927
+ ? req.headers['authorization'].slice('Bearer '.length)
2928
+ : null;
2929
+ if (!safeEqual(typeof provided === 'string' ? provided : '', registerSecret) && !safeEqual(bearer ?? '', registerSecret)) {
2930
+ return sendJson(res, 401, { error: 'invalid_client' });
2931
+ }
2932
+ let meta = {};
2933
+ try {
2934
+ meta = JSON.parse(body.toString('utf-8') || '{}');
2935
+ } catch {
2936
+ meta = {};
2937
+ }
2938
+ const redirectUris = Array.isArray(meta.redirect_uris) ? meta.redirect_uris.filter((s) => typeof s === 'string') : [];
2939
+ if (redirectUris.length === 0) return sendJson(res, 400, { error: 'invalid_redirect_uri' });
2940
+ const clientId = randomUUID();
2941
+ const clients = await loadClientRegistry(clientsFile);
2942
+ clients.push({ clientId, redirectUris, allowedScopes: [...DEFAULT_SCOPES], source: 'dcr', createdAt: new Date().toISOString() });
2943
+ await saveClientRegistry(clientsFile, clients);
2944
+ return sendJson(res, 201, { client_id: clientId, redirect_uris: redirectUris, scope: DEFAULT_SCOPES.join(' '), token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'] });
2945
+ }
2946
+ if (path === '/authorize' && (req.method === 'GET' || req.method === 'POST')) {
2947
+ const ip = req.socket?.remoteAddress ?? 'unknown';
2948
+ const rl = rateLimit(ip);
2949
+ if (rl.limited) {
2950
+ res.setHeader('retry-after', String(rl.retryAfter));
2951
+ return sendJson(res, 429, { error: 'too_many_requests' });
2952
+ }
2953
+ const params = req.method === 'GET' ? url.searchParams : new URLSearchParams(body.toString('utf-8'));
2954
+ if (params.get('response_type') !== 'code') {
2955
+ return sendJson(res, 400, { error: 'unsupported_response_type' });
2956
+ }
2957
+ const client = await findClient(clientsFile, params.get('client_id'));
2958
+ if (!client) return sendJson(res, 401, { error: 'invalid_client', error_description: 'client_id 未注册' });
2959
+ const redirectUri = params.get('redirect_uri');
2960
+ if (!redirectUri || !client.redirectUris.includes(redirectUri)) {
2961
+ return sendJson(res, 400, { error: 'invalid_request', error_description: 'redirect_uri 未注册(需精确匹配)' });
2962
+ }
2963
+ const codeChallenge = params.get('code_challenge');
2964
+ if (!codeChallenge || params.get('code_challenge_method') !== 'S256') {
2965
+ return sendJson(res, 400, { error: 'invalid_request', error_description: 'PKCE S256 required' });
2966
+ }
2967
+ // 用户认证/同意:管理员口令 或 一次性本地同意码,二者缺一即拒(绝不 302)
2968
+ const headerSecret = req.headers['x-mebular-admin-secret'];
2969
+ const suppliedSecret =
2970
+ params.get('admin_secret') ??
2971
+ (typeof headerSecret === 'string' ? headerSecret : null) ??
2972
+ basicPassword(req.headers['authorization']);
2973
+ let userAuthorized = false;
2974
+ let consentScopes = null;
2975
+ if (adminSecret && suppliedSecret && safeEqual(String(suppliedSecret), adminSecret)) {
2976
+ userAuthorized = true;
2977
+ } else {
2978
+ const consent = await consumeConsent(consentFile, params.get('consent_code'));
2979
+ if (consent) {
2980
+ userAuthorized = true;
2981
+ consentScopes = Array.isArray(consent.scopes) ? consent.scopes : DEFAULT_SCOPES;
2982
+ }
2983
+ }
2984
+ if (!userAuthorized) {
2985
+ return sendJson(res, 401, { error: 'access_denied', error_description: '需要管理员口令或本地同意码' });
2986
+ }
2987
+ const requested = (params.get('scope') ?? '').split(/\s+/).filter(Boolean);
2988
+ const requestedScopes = requested.length > 0 ? requested : [...DEFAULT_SCOPES];
2989
+ const unknown = requestedScopes.filter((s) => !SCOPES.includes(s));
2990
+ if (unknown.length > 0) return sendJson(res, 400, { error: 'invalid_scope', error_description: unknown.join(' ') });
2991
+ const notAllowed = requestedScopes.filter((s) => !client.allowedScopes.includes(s));
2992
+ if (notAllowed.length > 0) {
2993
+ return sendJson(res, 403, { error: 'insufficient_scope', error_description: `超出客户端 allowed_scopes: ${notAllowed.join(' ')}` });
2994
+ }
2995
+ const granted = consentScopes ? requestedScopes.filter((s) => consentScopes.includes(s)) : requestedScopes;
2996
+ if (granted.length === 0) return sendJson(res, 403, { error: 'insufficient_scope', error_description: '同意范围不含请求 scope' });
2997
+ const code = randomUUID();
2998
+ const state = params.get('state');
2999
+ codes.set(code, { clientId: client.clientId, redirectUri, codeChallenge, scope: granted, exp: Date.now() + 300_000 });
3000
+ const location = `${redirectUri}?code=${encodeURIComponent(code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
3001
+ res.statusCode = 302;
3002
+ res.setHeader('location', location);
3003
+ return res.end();
3004
+ }
3005
+ if (path === '/token' && req.method === 'POST') {
3006
+ const ip = req.socket?.remoteAddress ?? 'unknown';
3007
+ const rl = rateLimit(ip);
3008
+ if (rl.limited) {
3009
+ res.setHeader('retry-after', String(rl.retryAfter));
3010
+ return sendJson(res, 429, { error: 'too_many_requests' });
3011
+ }
3012
+ const params = new URLSearchParams(body.toString('utf-8'));
3013
+ const grantType = params.get('grant_type');
3014
+ const now = Math.floor(Date.now() / 1000);
3015
+ let scope = [...DEFAULT_SCOPES];
3016
+ let clientId = params.get('client_id');
3017
+ if (grantType === 'authorization_code') {
3018
+ const code = params.get('code') ?? '';
3019
+ const record = codes.get(code);
3020
+ if (!record || record.exp < Date.now()) return sendJson(res, 400, { error: 'invalid_grant' });
3021
+ const client = await findClient(clientsFile, clientId);
3022
+ if (!client || client.clientId !== record.clientId) return sendJson(res, 400, { error: 'invalid_client' });
3023
+ if (params.get('redirect_uri') !== record.redirectUri) {
3024
+ return sendJson(res, 400, { error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
3025
+ }
3026
+ const verifier = params.get('code_verifier') ?? '';
3027
+ const challenge = b64url(createHash('sha256').update(verifier).digest());
3028
+ if (challenge !== record.codeChallenge) return sendJson(res, 400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
3029
+ codes.delete(code);
3030
+ scope = record.scope;
3031
+ clientId = record.clientId;
3032
+ } else if (grantType === 'refresh_token') {
3033
+ const payload = await verifyJwt(signingKey, params.get('refresh_token') ?? '', { issuer, resource: `${issuer}/mcp`, revoked });
3034
+ if (!payload || payload.type !== 'refresh') return sendJson(res, 400, { error: 'invalid_grant' });
3035
+ if (clientId && payload.client_id && clientId !== payload.client_id) return sendJson(res, 400, { error: 'invalid_grant' });
3036
+ revoked.add(payload.jti); // 轮换:旧 refresh 失效,持久化
3037
+ await persistRevoked(revokedFile, revoked);
3038
+ scope = Array.isArray(payload.scope) ? payload.scope : DEFAULT_SCOPES;
3039
+ clientId = payload.client_id ?? clientId;
3040
+ } else {
3041
+ // D45:client_credentials 已移除
3042
+ return sendJson(res, 400, { error: 'unsupported_grant_type' });
3043
+ }
3044
+ const claims = { iss: issuer, aud: `${issuer}/mcp`, sub: 'user', client_id: clientId ?? null, scope };
3045
+ const access = await signJwt(signingKey, { ...claims, type: 'access', jti: randomUUID(), iat: now, exp: now + ACCESS_TTL });
3046
+ const refresh = await signJwt(signingKey, { ...claims, type: 'refresh', jti: randomUUID(), iat: now, exp: now + REFRESH_TTL });
3047
+ return sendJson(res, 200, { access_token: access, token_type: 'Bearer', expires_in: ACCESS_TTL, refresh_token: refresh, scope: scope.join(' ') });
3048
+ }
3049
+ if (path === '/token/revoke' && req.method === 'POST') {
3050
+ const params = new URLSearchParams(body.toString('utf-8'));
3051
+ const token = params.get('token') ?? '';
3052
+ const parts = token.split('.');
3053
+ if (parts.length === 3) {
3054
+ try {
3055
+ const payload = JSON.parse(Buffer.from(b64urlToBytes(parts[1]), 'utf8').toString('utf-8'));
3056
+ if (payload.jti) {
3057
+ revoked.add(payload.jti);
3058
+ await persistRevoked(revokedFile, revoked);
3059
+ }
3060
+ } catch {
3061
+ // ignore malformed
3062
+ }
3063
+ }
3064
+ return sendJson(res, 200, {});
3065
+ }
3066
+ }
3067
+ if (path === '/console' || path.startsWith('/console/')) {
3068
+ return handleConsole(req, res, url);
3069
+ }
3070
+ if (path === '/locales' || path.startsWith('/locales/')) {
3071
+ return handleLocales(req, res, url);
3072
+ }
3073
+ if (path === '/admin/events' && req.method === 'GET') {
3074
+ return handleAdminEvents(req, res, url);
3075
+ }
3076
+ // P1:热初始化完成后的重复 create/join —— 本进程曾处于引导态,返回结构化 409(不重启、不重复写盘)。
3077
+ // 普通正常实例仍保持「引导路径不泄漏」的 404 语义。
3078
+ if (phase === 'READY' && hotInitialized
3079
+ && (path === '/app/provision/create' || path === '/app/provision/join' || path === '/app/provision/restore')) {
3080
+ return sendJson(res, 409, {
3081
+ ok: false,
3082
+ error: 'already_provisioned',
3083
+ provisioned: true,
3084
+ initialized: true,
3085
+ restartRequired: false,
3086
+ message: '家目录已完成初始化:守护已在同一进程内进入正常态,无需重复建新 / 加入。',
3087
+ });
3088
+ }
3089
+ if (phase !== 'READY') {
3090
+ // 实际绑定端口(--port 0 时 port 参数为 0,但 listen 后系统已分配)
3091
+ const boundPort = (() => {
3092
+ const addr = server?.address();
3093
+ return typeof addr === 'object' && addr !== null ? addr.port : port;
3094
+ })();
3095
+ if (path === '/mcp') {
3096
+ // P2:UNINITIALIZED 也允许 MCP 握手/列表;tools/call 由统一 readiness guard 返回业务错误。
3097
+ return handleMcp(req, res, body);
3098
+ }
3099
+ if (path === '/admin/api/clients' && req.method === 'GET') {
3100
+ // P2:引导态只开放本机助手列表(不读 memory/network/admin 数据面)。
3101
+ return handleAdminRead(req, res, path, url);
3102
+ }
3103
+ if (UNINITIALIZED_CLIENT_ACTION.test(path)) {
3104
+ // P2:connect 只写本机助手配置;preview/verify/recheck 不触碰 app/service。
3105
+ // Origin + CSRF + 现有 auth scope 仍在 handleAdminWrite 内校验。
3106
+ const done = await handleAdminWrite(req, res, path, body);
3107
+ if (done !== false) return done;
3108
+ }
3109
+ if (path === '/app/provision/status' && req.method === 'GET') {
3110
+ return sendJson(res, 200, provisionStatus({
3111
+ home,
3112
+ mcpPort: boundPort,
3113
+ phase,
3114
+ provisioned: !isProvisionHome(home),
3115
+ }));
3116
+ }
3117
+ if (path === '/app/provision/create' || path === '/app/provision/join' || path === '/app/provision/restore') {
3118
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'method_not_allowed' });
3119
+ const reqOrigin = req.headers.origin;
3120
+ if (typeof reqOrigin === 'string' && reqOrigin.length > 0 && reqOrigin !== origin) {
3121
+ return sendJson(res, 403, { error: 'forbidden', message: `跨站点写被拒(Origin=${reqOrigin})` });
3122
+ }
3123
+ if (!csrfValid(req)) return sendJson(res, 403, { error: 'forbidden', message: 'CSRF token 缺失或无效' });
3124
+ if (phase === 'INITIALIZING') {
3125
+ return sendJson(res, 409, { ok: false, error: 'initialization_in_progress', phase, message: '初始化正在进行,请稍候' });
3126
+ }
3127
+ if (!isProvisionHome(home)) {
3128
+ return sendJson(res, 409, {
3129
+ ok: false,
3130
+ error: 'already_provisioned',
3131
+ provisioned: true,
3132
+ initialized: false,
3133
+ restartRequired: false,
3134
+ message: '家目录已完成初始化:无需重复建新 / 加入。',
3135
+ });
3136
+ }
3137
+ const input = parseJsonBody(body) ?? {};
3138
+ if (input.confirm !== true) {
3139
+ return sendJson(res, 400, { error: 'confirmation_required', message: '需要 { confirm: true }(写入配置并由当前进程完成初始化)' });
3140
+ }
3141
+ if (typeof initializeRuntime !== 'function') {
3142
+ return sendJson(res, 500, {
3143
+ ok: false,
3144
+ error: 'initializer_unavailable',
3145
+ message: '当前服务器未装配热初始化执行器:请升级 / 重启 mebular serve',
3146
+ });
3147
+ }
3148
+ const action = path.endsWith('/create') ? 'create' : path.endsWith('/join') ? 'join' : 'restore';
3149
+ // 先占住 INITIALIZING,避免两个并发 create/join 都通过上面的 UNINITIALIZED 检查。
3150
+ phase = 'INITIALIZING';
3151
+ let transaction = null;
3152
+ try {
3153
+ transaction = await snapshotProvisionState(home);
3154
+ const result = action === 'create'
3155
+ ? await provisionCreate({
3156
+ home,
3157
+ deviceName: typeof input.deviceName === 'string' ? input.deviceName : undefined,
3158
+ ...(typeof input.cloudName === 'string' ? { cloudName: input.cloudName } : {}),
3159
+ ...(typeof input.deviceId === 'string' ? { deviceId: input.deviceId } : {}),
3160
+ mcpPort: boundPort,
3161
+ joinPort: input.joinPort,
3162
+ listenPort: input.listenPort,
3163
+ })
3164
+ : action === 'join'
3165
+ ? await provisionJoin({
3166
+ home,
3167
+ token: typeof input.token === 'string' ? input.token : '',
3168
+ deviceName: typeof input.deviceName === 'string' ? input.deviceName : undefined,
3169
+ mcpPort: boundPort,
3170
+ joinPort: input.joinPort,
3171
+ listenPort: input.listenPort,
3172
+ timeoutMs: Number.isFinite(input.timeoutMs) ? input.timeoutMs : undefined,
3173
+ })
3174
+ : await provisionRestore({
3175
+ home,
3176
+ file: typeof input.file === 'string' ? input.file : '',
3177
+ code: typeof input.code === 'string' ? input.code : '',
3178
+ deviceName: typeof input.deviceName === 'string' ? input.deviceName : undefined,
3179
+ ...(typeof input.cloudName === 'string' ? { cloudName: input.cloudName } : {}),
3180
+ ...(typeof input.deviceId === 'string' ? { deviceId: input.deviceId } : {}),
3181
+ mcpPort: boundPort,
3182
+ joinPort: input.joinPort,
3183
+ listenPort: input.listenPort,
3184
+ });
3185
+ await writeProvisionRecord(home, { ...result, at: new Date().toISOString() });
3186
+ const next = await initializeRuntime({
3187
+ action,
3188
+ result,
3189
+ server: {
3190
+ host,
3191
+ port: boundPort,
3192
+ auth,
3193
+ tls,
3194
+ tlsKey,
3195
+ tlsCert,
3196
+ tokensFile,
3197
+ writesEnabled,
3198
+ },
3199
+ });
3200
+ await activateRuntime(next, { viaHotInit: true });
3201
+ return sendJson(res, 200, {
3202
+ ok: true,
3203
+ ...result,
3204
+ initialized: true,
3205
+ phase: 'READY',
3206
+ });
3207
+ } catch (error) {
3208
+ // 回滚本次新建的落盘产物;home 锁仍由当前进程持有(不释放/不重抢)。
3209
+ await transaction?.rollback().catch(() => undefined);
3210
+ phase = 'UNINITIALIZED';
3211
+ const code = error?.code ?? 'provision_failed';
3212
+ const status = code === 'ALREADY_PROVISIONED' ? 409 : 400;
3213
+ return sendJson(res, status, {
3214
+ ok: false,
3215
+ error: code,
3216
+ phase,
3217
+ message: String(error?.message ?? error),
3218
+ });
3219
+ }
3220
+ }
3221
+ if (path.startsWith('/admin/api/') || path.startsWith('/admin/') || path === '/mcp' || path.startsWith('/app/')) {
3222
+ return sendJson(res, 409, {
3223
+ error: 'provision_required',
3224
+ provision: true,
3225
+ phase,
3226
+ message: '实例尚未初始化:请打开控制台完成「建新 Mebular / 加入已有 Mebular」',
3227
+ });
3228
+ }
3229
+ return sendJson(res, 404, { error: 'not_found', path });
3230
+ }
3231
+ if (path.startsWith('/admin/api/')) {
3232
+ if (req.method === 'GET' && (path === '/admin/api/security' || path === '/admin/api/security/approvals')) return handleSecurityRead(req, res);
3233
+ const planMatch = path.match(/^\/admin\/api\/namespaces\/([^/]+)\/handoff-plan$/);
3234
+ if (req.method === 'GET' && planMatch) {
3235
+ if (!app || !service) {
3236
+ return sendJson(res, 503, { error: 'console_unavailable', message: 'Mebular 尚未初始化' });
3237
+ }
3238
+ const check = await authenticate(req, Buffer.alloc(0), 'memory.read');
3239
+ if (!check.ok) {
3240
+ res.statusCode = check.status;
3241
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
3242
+ return sendJson(res, check.status, { error: 'unauthorized', message: check.message });
3243
+ }
3244
+ const successor = url.searchParams.get('successor') ?? '';
3245
+ const out = await buildHandoffPlan({ app }, decodeURIComponent(planMatch[1]), successor);
3246
+ return sendJson(res, out.status, out.body);
3247
+ }
3248
+ if (req.method === 'GET' && READ_ROUTES[path]) return handleAdminRead(req, res, path, url);
3249
+ // 写端点路径:任何方法都交给 handleAdminWrite(非 POST/PUT/PATCH → 405;Origin/CSRF/scope 同处校验)
3250
+ if (WRITE_PATTERNS.some((re) => re.test(path))) {
3251
+ const done = await handleAdminWrite(req, res, path, body);
3252
+ if (done !== false) return;
3253
+ }
3254
+ return sendJson(res, 404, { error: 'not_found', path });
3255
+ }
3256
+ // W2 A3:本机 app 接口(loopback + token;单写者由 serve 锁保证)
3257
+ if (path.startsWith('/app/')) {
3258
+ const appRequired = req.method === 'POST' ? 'memory.write' : 'memory.read';
3259
+ const check = await authenticate(req, body, appRequired);
3260
+ if (!check.ok) {
3261
+ res.statusCode = check.status;
3262
+ if (check.challenge) res.setHeader('www-authenticate', check.challenge);
3263
+ return sendJson(res, check.status, { ok: false, error: check.message });
3264
+ }
3265
+ if (path === '/app/network' && req.method === 'GET') {
3266
+ return sendJson(res, 200, {
3267
+ ok: true,
3268
+ deviceId: deviceId ?? null,
3269
+ peerId: app.node?.peerId?.id ?? null,
3270
+ multiaddrs: app.node?.getLocalMultiaddrs?.() ?? [],
3271
+ });
3272
+ }
3273
+ if (path === '/app/namespaces' && req.method === 'GET') {
3274
+ const nodes = await app.graph.listNodes({});
3275
+ const namespaces = [...new Set(nodes.map((n) => n.namespace ?? 'default'))].sort();
3276
+ return sendJson(res, 200, { ok: true, namespaces, count: namespaces.length });
3277
+ }
3278
+ if (path === '/app/nodes' && req.method === 'POST') {
3279
+ let payload;
3280
+ try {
3281
+ payload = JSON.parse(body.toString('utf-8') || '{}');
3282
+ } catch {
3283
+ return sendJson(res, 400, { ok: false, error: 'invalid json' });
3284
+ }
3285
+ const type = typeof payload.type === 'string' && payload.type.length > 0 ? payload.type : null;
3286
+ if (!type) return sendJson(res, 400, { ok: false, error: 'type 必填' });
3287
+ const node = await app.graph.createNode(
3288
+ type,
3289
+ typeof payload.content === 'object' && payload.content !== null ? payload.content : {},
3290
+ Array.isArray(payload.edges) ? payload.edges : [],
3291
+ payload.namespace ? { namespace: payload.namespace } : {},
3292
+ );
3293
+ return sendJson(res, 200, { ok: true, node: { id: node.id, type: node.type, namespace: node.namespace ?? 'default' } });
3294
+ }
3295
+ if (path === '/app/nodes' && req.method === 'GET') {
3296
+ const filter = {};
3297
+ const ns = url.searchParams.get('namespace');
3298
+ const type = url.searchParams.get('type');
3299
+ const limit = Number(url.searchParams.get('limit') ?? '0');
3300
+ if (ns) filter.namespace = ns;
3301
+ if (type) filter.type = type;
3302
+ const nodes = await app.graph.listNodes(filter);
3303
+ const trimmed = Number.isInteger(limit) && limit > 0 ? nodes.slice(0, limit) : nodes;
3304
+ return sendJson(res, 200, {
3305
+ ok: true,
3306
+ count: trimmed.length,
3307
+ nodes: trimmed.map((n) => ({ id: n.id, type: n.type, namespace: n.namespace ?? 'default', content: n.content })),
3308
+ });
3309
+ }
3310
+ // W2 A4:join 邀请(守护用自身身份生成令牌)
3311
+ if (path === '/app/join/invite' && req.method === 'POST') {
3312
+ if (typeof deviceId !== 'string' || typeof joinEndpoint !== 'string') {
3313
+ return sendJson(res, 400, { ok: false, error: '守护未启用 joinService(缺 deviceId/joinEndpoint)' });
3314
+ }
3315
+ let payload;
3316
+ try {
3317
+ payload = JSON.parse(body.toString('utf-8') || '{}');
3318
+ } catch {
3319
+ payload = {};
3320
+ }
3321
+ const ns = typeof payload.namespace === 'string' && payload.namespace ? payload.namespace : namespace;
3322
+ const ttlMs = Number.isInteger(payload.ttlMs) && payload.ttlMs > 0 ? payload.ttlMs : 900_000;
3323
+ const endpoint = typeof payload.endpoint === 'string' && payload.endpoint ? payload.endpoint : joinEndpoint;
3324
+ const token = await buildJoinToken({
3325
+ mebular: app,
3326
+ deviceId,
3327
+ namespace: ns,
3328
+ endpoint,
3329
+ ttlMs,
3330
+ ...collectInviteHints(app, config),
3331
+ });
3332
+ await activateJoinInvite(joinStateStoragePath, token.nonce);
3333
+ const inline = Buffer.from(JSON.stringify(token), 'utf-8').toString('base64');
3334
+ return sendJson(res, 200, { ok: true, token: inline, endpoint, namespace: ns, expiresAt: token.expiresAt, nonce: token.nonce });
3335
+ }
3336
+ // W2:订阅(守护持有图表;fleet 客户端化)
3337
+ if (path === '/app/policy/subscriptions' && req.method === 'GET') {
3338
+ const device = url.searchParams.get('device');
3339
+ const ns = url.searchParams.get('namespace');
3340
+ if (device) {
3341
+ const namespaces = await app.getSubscriptions(device);
3342
+ return sendJson(res, 200, { ok: true, device, namespaces });
3343
+ }
3344
+ if (ns) {
3345
+ const subscriptions = await app.getNamespaceSubscriptions(ns);
3346
+ return sendJson(res, 200, { ok: true, namespace: ns, ...subscriptions });
3347
+ }
3348
+ return sendJson(res, 400, { ok: false, error: 'device 或 namespace 必填' });
3349
+ }
3350
+ if (path.startsWith('/app/policy/') && req.method === 'POST') {
3351
+ let payload;
3352
+ try {
3353
+ payload = JSON.parse(body.toString('utf-8') || '{}');
3354
+ } catch {
3355
+ return sendJson(res, 400, { ok: false, error: 'invalid json' });
3356
+ }
3357
+ if (path === '/app/policy/subscription') {
3358
+ const ns = typeof payload.namespace === 'string' && payload.namespace ? payload.namespace : namespace;
3359
+ if (!ns) return sendJson(res, 400, { ok: false, error: 'namespace 必填' });
3360
+ const event = payload.active === false
3361
+ ? await app.unsubscribeNamespace(ns)
3362
+ : await app.subscribeNamespace(ns);
3363
+ return sendJson(res, 200, { ok: true, eventId: event.id, namespace: ns, active: payload.active !== false });
3364
+ }
3365
+ if (path === '/app/policy/namespace') {
3366
+ if (typeof payload.namespace !== 'string' || !payload.namespace) return sendJson(res, 400, { ok: false, error: 'namespace 必填' });
3367
+ const event = await app.declareNamespace({ namespace: payload.namespace, ...(typeof payload.note === 'string' ? { note: payload.note } : {}) });
3368
+ return sendJson(res, 200, { ok: true, eventId: event.id });
3369
+ }
3370
+ }
3371
+ return sendJson(res, 404, { ok: false, error: 'not_found', path });
3372
+ }
3373
+ if (path === '/mcp') {
3374
+ return handleMcp(req, res, body);
3375
+ }
3376
+ return sendJson(res, 404, { error: 'not_found', path });
3377
+ } catch (error) {
3378
+ if (!res.headersSent) sendJson(res, 500, { error: 'internal_error', message: String(error?.message ?? error) });
3379
+ else res.end();
3380
+ }
3381
+ };
3382
+
3383
+ // root 级硬单活:仅已初始化档案的守护获取(引导态 app/service 为空,不参与)。
3384
+ // 被活 PID 占用且 home 不同 → CLOUD_ALREADY_ACTIVE(复用 <home>/lock 的 PID 检查纪律)。
3385
+ activeLock = app && service && cloud.available
3386
+ ? await acquireActiveLock({ root: cloud.root, home, profileId: cloudId })
3387
+ : null;
3388
+
3389
+ let server;
3390
+ try {
3391
+ if (tls) {
3392
+ server = https.createServer({ key: await readFile(tlsKey, 'utf-8'), cert: await readFile(tlsCert, 'utf-8') }, handler);
3393
+ } else {
3394
+ server = http.createServer(handler);
3395
+ }
3396
+ await new Promise((resolve, reject) => {
3397
+ server.once('error', reject);
3398
+ server.listen(port, host, () => resolve());
3399
+ });
3400
+ } catch (error) {
3401
+ await activeLock?.release?.().catch(() => undefined);
3402
+ throw error;
3403
+ }
3404
+ const bound = server.address();
3405
+ const actualPort = bound.port;
3406
+ const actualHost = runtimeHost(typeof bound.address === 'string' && bound.address.length > 0 ? bound.address : host);
3407
+ origin = `${scheme}://${isLoopback ? '127.0.0.1' : host}:${actualPort}`;
3408
+ issuer = process.env.MEBULAR_OAUTH_ISSUER ?? origin;
3409
+ metadata = metadataFor(issuer, { registrationEnabled });
3410
+ // 发现信息只在 listen 成功后落盘;失败只影响自动发现,不影响守护启动。
3411
+ await writeRuntimeFile(home, {
3412
+ version: APP_VERSION,
3413
+ host: actualHost,
3414
+ port: actualPort,
3415
+ embedProtocol: EMBED_PROTOCOL,
3416
+ }).catch((error) => {
3417
+ console.error(`RUNTIME_FILE_WRITE_FAILED ${error?.code ?? 'ERROR'}`);
3418
+ });
3419
+ let closePromise = null;
3420
+ const close = () => {
3421
+ if (closePromise) return closePromise;
3422
+ closePromise = (async () => {
3423
+ // 优雅停止的顺序很重要:MCP/SSE/keep-alive 连接不会自己断,
3424
+ // 先收 MCP sessions,再主动结束连接,最后才等 listener(带有限超时)。
3425
+ const deadline = Date.now() + CLOSE_TIMEOUT_MS;
3426
+ try {
3427
+ // 1) 先让每个 MCP session 的 transport/server 正常收尾。
3428
+ // 单个 session 卡住也不能拖死整个停止流程:最多等 SESSION_CLOSE_TIMEOUT_MS。
3429
+ const sessions = [...mcpSessions.values()];
3430
+ mcpSessions.clear();
3431
+ const sessionBudget = Math.max(0, Math.min(SESSION_CLOSE_TIMEOUT_MS, deadline - Date.now()));
3432
+ await Promise.all(sessions.map((entry) => settleWithin(
3433
+ Promise.resolve().then(() => entry.server.close()),
3434
+ sessionBudget,
3435
+ )));
3436
+ // 2) 结束空闲 keep-alive 与仍活跃的 SSE/streaming 连接,避免 server.close 永久等待。
3437
+ server.closeIdleConnections?.();
3438
+ server.closeAllConnections?.();
3439
+ // 3) 停止接受新连接;有限超时兜底,超时后强制断开并继续清理。
3440
+ await new Promise((resolve) => {
3441
+ let done = false;
3442
+ let timer = null;
3443
+ const finish = () => {
3444
+ if (done) return;
3445
+ done = true;
3446
+ clearTimeout(timer);
3447
+ resolve();
3448
+ };
3449
+ timer = setTimeout(() => {
3450
+ server.closeAllConnections?.();
3451
+ finish();
3452
+ }, Math.max(0, deadline - Date.now()));
3453
+ try {
3454
+ server.close(() => finish());
3455
+ } catch {
3456
+ finish();
3457
+ }
3458
+ });
3459
+ } finally {
3460
+ // 4) 无论前面如何都要清理;pid 守卫保证不误删新实例的 runtime.json。
3461
+ await clearRuntimeFile(home, { pid: process.pid }).catch(() => undefined);
3462
+ // 5) 保留最新 main 的生命周期收尾:root 单活锁 + 热初始化上下文(app/join/广播)。
3463
+ await activeLock?.release?.().catch(() => undefined);
3464
+ await onClose?.().catch(() => undefined);
3465
+ }
3466
+ })();
3467
+ return closePromise;
3468
+ };
3469
+ return {
3470
+ server,
3471
+ host,
3472
+ port: actualPort,
3473
+ auth,
3474
+ issuer,
3475
+ close,
3476
+ getState: () => ({ phase, hotInitialized, app, service, config, runtime, deviceId }),
3477
+ };
3478
+ }