@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.60
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/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +55 -7
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +710 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +320 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +641 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2227
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +104 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/observer-avatar-http.test.ts +428 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +483 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +3842 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3231 -204
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
package/src/push.ts
CHANGED
|
@@ -97,6 +97,45 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
97
97
|
return `${networkId || "global"}:${sessionName}`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// ── #461 network observer channels ───────────────────────────────────
|
|
101
|
+
// Dashboard (or any network member) subscribes GET /events/network/:id
|
|
102
|
+
// and receives SUMMARY events for ALL new_task / new_reply traffic in
|
|
103
|
+
// that network — not just tasks it sent/received. Summary events carry
|
|
104
|
+
// ids + routing metadata only, never task/reply content.
|
|
105
|
+
//
|
|
106
|
+
// Key scheme: leading NUL byte makes an observer key impossible to
|
|
107
|
+
// forge from the session scheme above (`${networkId}:${sessionName}`)
|
|
108
|
+
// for any networkId the hub itself issues (net_* / "global"). Only a
|
|
109
|
+
// master-token caller could even inject a NUL into network_id via the
|
|
110
|
+
// legacy ?network_id= path, and master token is root already.
|
|
111
|
+
const OBSERVER_KEY_PREFIX = "\u0000netobs:";
|
|
112
|
+
const USER_KEY_PREFIX = "\u0000user:";
|
|
113
|
+
|
|
114
|
+
/** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
|
|
115
|
+
* `printableKey()` (below) and therefore the shape callers see via
|
|
116
|
+
* `getSSEStats().sessions`. Exported so downstream consumers (e.g.
|
|
117
|
+
* `/health` filter in server.ts) key off the same literal instead of
|
|
118
|
+
* each side re-writing the escape sequence — CR3 audit 4703b0e7
|
|
119
|
+
* found server.ts had `"netobs:"` (no `\\0`) which never matched and
|
|
120
|
+
* silently over-filtered observer keys. */
|
|
121
|
+
export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
|
|
122
|
+
|
|
123
|
+
function observerKey(networkId: string): string {
|
|
124
|
+
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function userKey(networkId: string, userId: string): string {
|
|
128
|
+
return `${USER_KEY_PREFIX}${networkId}:${userId}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Render a client key for logs / stats. Observer keys embed a raw NUL
|
|
132
|
+
* byte (collision guard above); printing that verbatim makes grep treat
|
|
133
|
+
* hub logs as binary and breaks log shippers, and getSSEStats feeds
|
|
134
|
+
* /health. Display as a visible "\\0" escape instead. */
|
|
135
|
+
function printableKey(key: string): string {
|
|
136
|
+
return key.split("\u0000").join("\\0");
|
|
137
|
+
}
|
|
138
|
+
|
|
100
139
|
// ─────────────────────────────────────────────────────────────────────
|
|
101
140
|
// Close + dispatch helpers
|
|
102
141
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -114,7 +153,7 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
114
153
|
// Already closed by the runtime, or controller errored. Either way
|
|
115
154
|
// we're done with it.
|
|
116
155
|
}
|
|
117
|
-
console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
|
|
156
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
@@ -215,8 +254,33 @@ function ensureLivenessSweep(): void {
|
|
|
215
254
|
|
|
216
255
|
/** 创建 SSE Response 并注册到 clients map */
|
|
217
256
|
export function createSSEStream(sessionName: string, networkId?: string | null): Response {
|
|
257
|
+
return createStreamForKey(
|
|
258
|
+
clientKey(sessionName, networkId),
|
|
259
|
+
{ type: "connected", session: sessionName, network_id: networkId ?? null },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
264
|
+
* Shares the exact same registration / backpressure / liveness
|
|
265
|
+
* machinery as session streams; only the key scheme differs. */
|
|
266
|
+
export function createNetworkObserverStream(networkId: string): Response {
|
|
267
|
+
return createStreamForKey(
|
|
268
|
+
observerKey(networkId),
|
|
269
|
+
{ type: "connected", observer: true, network_id: networkId },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Desktop/user client SSE stream. Keyed by authenticated user_id, not
|
|
274
|
+
* alias/session, so Desktop does not need to mint a pseudo node token. */
|
|
275
|
+
export function createUserEventStream(networkId: string, userId: string): Response {
|
|
276
|
+
return createStreamForKey(
|
|
277
|
+
userKey(networkId, userId),
|
|
278
|
+
{ type: "connected", user: true, network_id: networkId, user_id: userId },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
|
|
218
283
|
const encoder = new TextEncoder();
|
|
219
|
-
const key = clientKey(sessionName, networkId);
|
|
220
284
|
let client: SSEClient;
|
|
221
285
|
|
|
222
286
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -231,13 +295,13 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
231
295
|
|
|
232
296
|
if (!clients.has(key)) clients.set(key, []);
|
|
233
297
|
clients.get(key)!.push(client);
|
|
234
|
-
console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
|
|
298
|
+
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
235
299
|
|
|
236
300
|
// Send initial connected frame through the backpressure guard so
|
|
237
301
|
// even the first byte respects the contract.
|
|
238
302
|
tryEnqueueBytes(
|
|
239
303
|
client,
|
|
240
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
304
|
+
encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
|
|
241
305
|
);
|
|
242
306
|
|
|
243
307
|
// Periodic keepalive doubles as a half-open probe — if the
|
|
@@ -280,7 +344,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
280
344
|
|
|
281
345
|
return new Response(stream, {
|
|
282
346
|
headers: {
|
|
283
|
-
|
|
347
|
+
// #426: explicit charset so legacy Windows clients (PowerShell 5.1's
|
|
348
|
+
// Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
|
|
349
|
+
// Content-Type omits the charset) decode UTF-8 payloads correctly.
|
|
350
|
+
// Hub itself was already writing clean UTF-8 bytes; the client-side
|
|
351
|
+
// 双重编码样式 mojibake was the header telling the client to guess.
|
|
352
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
284
353
|
"Cache-Control": "no-cache, no-transform",
|
|
285
354
|
"Connection": "keep-alive",
|
|
286
355
|
},
|
|
@@ -294,7 +363,7 @@ function pruneClosed(key: string): void {
|
|
|
294
363
|
const alive = arr.filter((c) => !c.closed);
|
|
295
364
|
if (alive.length === 0) {
|
|
296
365
|
clients.delete(key);
|
|
297
|
-
console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
|
|
366
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
|
|
298
367
|
} else if (alive.length !== arr.length) {
|
|
299
368
|
clients.set(key, alive);
|
|
300
369
|
}
|
|
@@ -327,6 +396,17 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
|
|
|
327
396
|
});
|
|
328
397
|
|
|
329
398
|
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
399
|
+
/** 这个 alias 上此刻是否真的有活着的 SSE 订阅者。
|
|
400
|
+
*
|
|
401
|
+
* 这是投递可达性的**实测**,区别于 resolveDeliveryTarget 里那个基于
|
|
402
|
+
* last_seen_at 的**猜测**。一个安静但连着的会话(例如遵守「停发心跳」
|
|
403
|
+
* 规则的 MCP 会话)在时间戳判据下是 offline,在这里是 true。 */
|
|
404
|
+
export function hasSubscribers(sessionName: string, networkId?: string | null): boolean {
|
|
405
|
+
const arr = clients.get(clientKey(sessionName, networkId));
|
|
406
|
+
if (!arr) return false;
|
|
407
|
+
return arr.some((c) => !c.closed);
|
|
408
|
+
}
|
|
409
|
+
|
|
330
410
|
export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
|
|
331
411
|
const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
|
|
332
412
|
const arr = clients.get(key);
|
|
@@ -347,6 +427,82 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
347
427
|
if (needPrune) pruneClosed(key);
|
|
348
428
|
}
|
|
349
429
|
|
|
430
|
+
/** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
|
|
431
|
+
*
|
|
432
|
+
* Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
|
|
433
|
+
* priority / message_id) — never task or reply content. The event is
|
|
434
|
+
* stamped with `network_id` + `scope: "network"` here so observers can
|
|
435
|
+
* always tell which network the event belongs to and that it came from
|
|
436
|
+
* the observer stream rather than their personal channel.
|
|
437
|
+
*
|
|
438
|
+
* No-op when networkId is falsy: legacy null-network traffic has no
|
|
439
|
+
* observer stream (the route requires an explicit network id). */
|
|
440
|
+
export function pushNetworkObserverEvent(
|
|
441
|
+
networkId: string | null | undefined,
|
|
442
|
+
event: Record<string, unknown>,
|
|
443
|
+
): void {
|
|
444
|
+
if (!networkId) return;
|
|
445
|
+
const key = observerKey(networkId);
|
|
446
|
+
const arr = clients.get(key);
|
|
447
|
+
if (!arr || arr.length === 0) return;
|
|
448
|
+
|
|
449
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
|
|
450
|
+
let needPrune = false;
|
|
451
|
+
|
|
452
|
+
for (const c of arr) {
|
|
453
|
+
if (c.closed) {
|
|
454
|
+
needPrune = true;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
458
|
+
if (result === "dead") needPrune = true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (needPrune) pruneClosed(key);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Push an event to every active Desktop/web client for one user in one
|
|
465
|
+
* network. No-op for falsy network/user ids; auth must be enforced by
|
|
466
|
+
* the caller before invoking this helper. */
|
|
467
|
+
/** 这个用户此刻是否真的有活着的 SSE 订阅者。
|
|
468
|
+
*
|
|
469
|
+
* 与 alias 版 `hasSubscribers` 同形状:投递可达性的**实测**,
|
|
470
|
+
* 调用方据此把「送到了」和「没人听」区分开,而不是一律报成功。 */
|
|
471
|
+
export function hasUserSubscribers(
|
|
472
|
+
networkId: string | null | undefined,
|
|
473
|
+
userId: string | null | undefined,
|
|
474
|
+
): boolean {
|
|
475
|
+
if (!networkId || !userId) return false;
|
|
476
|
+
const arr = clients.get(userKey(networkId, userId));
|
|
477
|
+
if (!arr) return false;
|
|
478
|
+
return arr.some((c) => !c.closed);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function pushUserEvent(
|
|
482
|
+
networkId: string | null | undefined,
|
|
483
|
+
userId: string | null | undefined,
|
|
484
|
+
event: Record<string, unknown>,
|
|
485
|
+
): void {
|
|
486
|
+
if (!networkId || !userId) return;
|
|
487
|
+
const key = userKey(networkId, userId);
|
|
488
|
+
const arr = clients.get(key);
|
|
489
|
+
if (!arr || arr.length === 0) return;
|
|
490
|
+
|
|
491
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
|
|
492
|
+
let needPrune = false;
|
|
493
|
+
|
|
494
|
+
for (const c of arr) {
|
|
495
|
+
if (c.closed) {
|
|
496
|
+
needPrune = true;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
500
|
+
if (result === "dead") needPrune = true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (needPrune) pruneClosed(key);
|
|
504
|
+
}
|
|
505
|
+
|
|
350
506
|
export function __resetSSEClientsForTest(): void {
|
|
351
507
|
for (const arr of clients.values()) {
|
|
352
508
|
for (const c of arr) closeClient(c, "reset-for-test");
|
|
@@ -365,7 +521,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
|
|
|
365
521
|
for (const [name, arr] of clients) {
|
|
366
522
|
const alive = arr.filter((c) => !c.closed);
|
|
367
523
|
if (alive.length === 0) continue;
|
|
368
|
-
sessions[name] = alive.length;
|
|
524
|
+
sessions[printableKey(name)] = alive.length;
|
|
369
525
|
total += alive.length;
|
|
370
526
|
}
|
|
371
527
|
return { total, sessions };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { redactTokens, redactMessageRow } from "./redact-tokens.js";
|
|
3
|
+
|
|
4
|
+
describe("#1459 ③ redact-at-read", () => {
|
|
5
|
+
test("🔴 各种前缀形状都被遮住,且前缀保留(读者仍知道泄漏的是哪一类)", () => {
|
|
6
|
+
const cases: Array<[string, string]> = [
|
|
7
|
+
["ntok_abcdef123456", "ntok_"],
|
|
8
|
+
["utok_abcdef123456", "utok_"],
|
|
9
|
+
["ghp_" + "A".repeat(24), "ghp_"],
|
|
10
|
+
["github_pat_" + "B".repeat(24), "github_pat_"],
|
|
11
|
+
["xoxb-1234567890-abcdef", "xoxb-"],
|
|
12
|
+
["sk-" + "C".repeat(20), "sk-"],
|
|
13
|
+
];
|
|
14
|
+
for (const [raw, prefix] of cases) {
|
|
15
|
+
const out = redactTokens(`before ${raw} after`);
|
|
16
|
+
expect(out).toContain("***redacted***");
|
|
17
|
+
expect(out).toContain(prefix);
|
|
18
|
+
expect(out).not.toContain(raw); // 原值一定不能整体幸存
|
|
19
|
+
expect(out).toContain("before"); // 周围文本不受影响
|
|
20
|
+
expect(out).toContain("after");
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("🔴 保守:不像凭据的内容一个字都不能改", () => {
|
|
25
|
+
for (const s of [
|
|
26
|
+
"任务完成,磁盘 96%",
|
|
27
|
+
"see https://example.com/a-b_c",
|
|
28
|
+
"sk-", // 太短,不是凭据
|
|
29
|
+
"ntok_", // 光前缀
|
|
30
|
+
"the stock ticker is SKU-12",
|
|
31
|
+
]) {
|
|
32
|
+
expect(redactTokens(s)).toBe(s);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("同一段里的多个 token 全部被遮", () => {
|
|
37
|
+
const out = redactTokens(`a ntok_aaaaaaaaaa b sk-${"z".repeat(20)} c`);
|
|
38
|
+
expect(out.match(/\*\*\*redacted\*\*\*/g)?.length).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("redactMessageRow 只碰自由文本字段,不动结构字段", () => {
|
|
42
|
+
const row = {
|
|
43
|
+
message_id: "dm_ntok_looking_but_is_an_id",
|
|
44
|
+
user_id: "u_1",
|
|
45
|
+
content: "here is ntok_abcdef123456",
|
|
46
|
+
title: "sk-" + "d".repeat(20),
|
|
47
|
+
meta_json: JSON.stringify({ note: "ghp_" + "E".repeat(24) }),
|
|
48
|
+
created_at: "2026-08-30 00:00:00",
|
|
49
|
+
};
|
|
50
|
+
const out = redactMessageRow(row);
|
|
51
|
+
expect(out.content).toContain("***redacted***");
|
|
52
|
+
expect(out.title).toContain("***redacted***");
|
|
53
|
+
expect(out.meta_json).toContain("***redacted***");
|
|
54
|
+
// 结构字段原样 —— 即便 message_id 里恰好有个像前缀的子串
|
|
55
|
+
expect(out.message_id).toBe(row.message_id);
|
|
56
|
+
expect(out.user_id).toBe(row.user_id);
|
|
57
|
+
expect(out.created_at).toBe(row.created_at);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("非字符串原样返回(null/数字不该被改成字符串)", () => {
|
|
61
|
+
expect(redactTokens(null as any)).toBeNull();
|
|
62
|
+
expect(redactTokens(42 as any)).toBe(42);
|
|
63
|
+
expect(redactMessageRow({ content: null, title: 7 } as any)).toEqual({ content: null, title: 7 } as any);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// #1459 ③ —— 读取时的 token 形状脱敏(redact-at-read)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么在**读取**侧而不是写入侧:写入方是 agent,不受信任。存储侧已经走
|
|
4
|
+
// normalizeMetaJson(跨主机路径脱敏,与 inbox 一致),那一层防的是"路径泄漏";
|
|
5
|
+
// 这一层防的是"有人把凭据塞进消息正文或 meta"。两层管的不是同一件事。
|
|
6
|
+
//
|
|
7
|
+
// 🔴 保守优先:宁可漏脱敏一个不像凭据的串,也不要把正常内容改花。所以只匹配
|
|
8
|
+
// 几个**有明确前缀**的形状,不做熵检测、不猜。
|
|
9
|
+
|
|
10
|
+
/** 有明确前缀的凭据形状。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 前缀用**捕获组显式声明**,不靠"切到第一个下划线"之类的推断 ——
|
|
13
|
+
* 第一版就是那么写的,`github_pat_xxx` 被切成了 `github_`,把"这是哪一类
|
|
14
|
+
* 凭据"这个唯一有用的信息也遮掉了。声明比推断短,也不会错。 */
|
|
15
|
+
const TOKEN_PATTERNS: ReadonlyArray<RegExp> = [
|
|
16
|
+
/\b(ntok_|utok_|atok_)[A-Za-z0-9_-]{6,}/g, // 本仓自己的 token
|
|
17
|
+
/\b(github_pat_)[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT(必须排在 ghp_ 之前无关,但更长的前缀先声明更清楚)
|
|
18
|
+
/\b(ghp_)[A-Za-z0-9]{20,}/g, // GitHub classic PAT
|
|
19
|
+
/\b(xox[bpoars]-)[A-Za-z0-9-]{10,}/g, // Slack
|
|
20
|
+
/\b(sk-)[A-Za-z0-9_-]{16,}/g, // OpenAI 风格
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** 对任意文本做 token 形状脱敏。非字符串原样返回。 */
|
|
24
|
+
export function redactTokens<T>(value: T): T {
|
|
25
|
+
if (typeof value !== "string") return value;
|
|
26
|
+
let out: string = value;
|
|
27
|
+
// 保留捕获到的前缀(让读者知道泄漏的是哪一类),其余一律遮掉;不保留尾部 ——
|
|
28
|
+
// 尾部对识别没帮助,却能帮攻击者缩小搜索空间。
|
|
29
|
+
for (const re of TOKEN_PATTERNS) out = out.replace(re, (_m, prefix: string) => `${prefix}***redacted***`);
|
|
30
|
+
return out as unknown as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 对一行消息做脱敏:只碰会承载自由文本的字段,不动 id/时间戳等结构字段。 */
|
|
34
|
+
export function redactMessageRow<T extends Record<string, unknown>>(row: T): T {
|
|
35
|
+
const out: Record<string, unknown> = { ...row };
|
|
36
|
+
for (const field of ["content", "title", "meta_json"]) {
|
|
37
|
+
if (typeof out[field] === "string") out[field] = redactTokens(out[field] as string);
|
|
38
|
+
}
|
|
39
|
+
return out as T;
|
|
40
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// f015d9d6 根因隔离:admin(dashboard/人类) 发给「无 node_id 的 MCP 会话」的任务,
|
|
2
|
+
// 其回执被 reply_target_mismatch 拒绝。
|
|
3
|
+
//
|
|
4
|
+
// 上报时给出的假设是「校验依赖投递绑定 ⇒ 任务还没真正 deliver 进会话时回执会被拒」。
|
|
5
|
+
// 本文件把两个变量分开量,一次只动一个:
|
|
6
|
+
// 变量 1 = 任务是否已投递(status created vs delivered,有无 SSE 订阅者)
|
|
7
|
+
// 变量 2 = 调用方是否显式传 alias
|
|
8
|
+
// 如果假设成立,变量 1 应当决定结果;如果 alias 推导才是决定因素,变量 2 才会。
|
|
9
|
+
import { beforeEach, afterAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
import { registerTools } from "./tools.js";
|
|
13
|
+
import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
|
|
14
|
+
|
|
15
|
+
const NET = "net_admin_reply_f015";
|
|
16
|
+
const USER = "user_admin_reply_f015";
|
|
17
|
+
const ADMIN = "admin-f015"; // 人类/dashboard 发起方,无 node
|
|
18
|
+
const AGENT = "agent-mcp-f015"; // MCP 会话:sessions 行 node_id = NULL
|
|
19
|
+
const AGENT_NODE = "node-agent-f015"; // token 绑定的 node(会话本身仍无 node_id)
|
|
20
|
+
|
|
21
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
22
|
+
|
|
23
|
+
function cleanup() {
|
|
24
|
+
__resetSSEClientsForTest();
|
|
25
|
+
for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
|
|
26
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
27
|
+
}
|
|
28
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
29
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function seed() {
|
|
34
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
|
|
35
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
|
|
36
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
|
|
37
|
+
db.run(
|
|
38
|
+
"INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', '{}', datetime('now'), datetime('now'), 'active')",
|
|
39
|
+
[AGENT_NODE, AGENT, NET],
|
|
40
|
+
);
|
|
41
|
+
// 🔴 关键夹具属性:会话行 node_id 为 NULL —— 这正是 MCP 挂载的 claude-code 会话的形状
|
|
42
|
+
db.run(
|
|
43
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now'), datetime('now'))",
|
|
44
|
+
[`cc-n_${AGENT}`, AGENT, NET],
|
|
45
|
+
);
|
|
46
|
+
db.run(
|
|
47
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
|
|
48
|
+
[`token-${AGENT}`, `hash-${AGENT}`, USER, NET, `node:${AGENT}`, AGENT_NODE],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toolsFor(alias: string): Record<string, ToolHandler> {
|
|
53
|
+
const server = new McpServer({ name: "admin-reply-f015", version: "0" }) as any;
|
|
54
|
+
const tools: Record<string, ToolHandler> = {};
|
|
55
|
+
const original = server.tool.bind(server);
|
|
56
|
+
server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
|
|
57
|
+
tools[name] = handler; return original(name, description, schema, handler);
|
|
58
|
+
};
|
|
59
|
+
registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
|
|
60
|
+
return tools;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parse(res: { content: Array<{ text: string }> }) { return JSON.parse(res.content[0].text); }
|
|
64
|
+
|
|
65
|
+
/** 造一条 admin(人类) → AGENT 的任务;delivered 控制「是否已投递」这一个变量。 */
|
|
66
|
+
function seedAdminTask(taskId: string, delivered: boolean) {
|
|
67
|
+
db.run(
|
|
68
|
+
`INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, content, status, priority, network_id, created_at)
|
|
69
|
+
VALUES (?1, ?2, NULL, ?3, NULL, 'ping', ?4, 'normal', ?5, datetime('now'))`,
|
|
70
|
+
[taskId, ADMIN, AGENT, delivered ? "delivered" : "created", NET],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("f015d9d6 — admin→无 node_id 的 MCP 会话,回执绑定", () => {
|
|
75
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
76
|
+
afterAll(() => cleanup());
|
|
77
|
+
|
|
78
|
+
test("变量1:任务【未投递】+ 省略 alias(走 #1085 推导)⇒ 回执应当成功", async () => {
|
|
79
|
+
seedAdminTask("t-undelivered", false);
|
|
80
|
+
const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-undelivered", text: "done", status: "completed" }));
|
|
81
|
+
expect(r).toEqual(expect.objectContaining({ ok: true }));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("变量1 对照:任务【已投递且有 SSE 订阅者】+ 省略 alias ⇒ 同样成功", async () => {
|
|
85
|
+
seedAdminTask("t-delivered", true);
|
|
86
|
+
createSSEStream(AGENT, NET);
|
|
87
|
+
const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-delivered", text: "done", status: "completed" }));
|
|
88
|
+
expect(r).toEqual(expect.objectContaining({ ok: true }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("变量2:显式传一个不等于任务发起方的 alias ⇒ reply_target_mismatch", async () => {
|
|
92
|
+
seedAdminTask("t-wrong-alias", true);
|
|
93
|
+
const r = parse(await toolsFor(AGENT).send_reply({ alias: "someone-else", in_reply_to: "t-wrong-alias", text: "done", status: "completed" }));
|
|
94
|
+
expect(r).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// report_status 的 host 遥测 schema 必须接受 agent-node **声明会发**的 null。
|
|
2
|
+
//
|
|
3
|
+
// 来源不是推理,是 #1225 复现容器里量到的一次真崩:`--network none` 里
|
|
4
|
+
// agent-node 一启动就死在
|
|
5
|
+
// MCP error -32602: Invalid input: expected string, received null at host.ip
|
|
6
|
+
// 而 register() 是 `await callCommHub("report_status", …)` 且没有 catch ——
|
|
7
|
+
// 于是**整个节点进程当场退出**,和 runtime 无关(opencode/claude/codex 一样)。
|
|
8
|
+
//
|
|
9
|
+
// 触发条件不是"容器",是"这台机器没有非回环 IPv4":
|
|
10
|
+
// agent-node 的 firstNonInternalIPv4()(host-telemetry.ts:48-59)在那种机器上
|
|
11
|
+
// 返回 null,而 HostTelemetry 的类型本来就写着 `ip: string | null`。
|
|
12
|
+
//
|
|
13
|
+
// 🔴 这里断言的是 **schema 本身**,不是 handler。直接调 handler 会绕过 zod ——
|
|
14
|
+
// 而缺陷恰恰只存在于 zod 那一层,绕过去就永远绿。
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { registerTools } from "./tools.js";
|
|
19
|
+
|
|
20
|
+
function captureToolSchemas(): Record<string, any> {
|
|
21
|
+
const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
|
|
22
|
+
const schemas: Record<string, any> = {};
|
|
23
|
+
const origTool = mcp.tool.bind(mcp);
|
|
24
|
+
mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
|
|
25
|
+
schemas[name] = schema;
|
|
26
|
+
return origTool(name, desc, schema, handler);
|
|
27
|
+
};
|
|
28
|
+
registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
|
|
29
|
+
return schemas;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const schemas = captureToolSchemas();
|
|
33
|
+
|
|
34
|
+
describe("report_status host telemetry schema", () => {
|
|
35
|
+
test("前提:抓到的确实是 report_status 的 host 形状", () => {
|
|
36
|
+
expect(schemas.report_status).toBeTruthy();
|
|
37
|
+
expect(schemas.report_status.host).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("🔴 host.ip = null 必须被接受(没有非回环 IPv4 的机器就是发 null)", () => {
|
|
41
|
+
const host = schemas.report_status.host;
|
|
42
|
+
const parsed = host.parse({ hostname: "boxy", ip: null });
|
|
43
|
+
expect(parsed.ip).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("🔴 host.hostname = null 同样被接受(与 ip 同为字符串字段,一起对齐)", () => {
|
|
47
|
+
const host = schemas.report_status.host;
|
|
48
|
+
expect(host.parse({ hostname: null, ip: "10.0.0.2" }).hostname).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("agent-node 那条完整心跳负载整体过 schema", () => {
|
|
52
|
+
// 形状抄自 agent-node/src/host-telemetry.ts 的 HostTelemetry:
|
|
53
|
+
// 没有网络、没有 /proc 时每一项都可能是 null。
|
|
54
|
+
const full = {
|
|
55
|
+
hostname: "unknown", ip: null,
|
|
56
|
+
cpu_load_1min: null, cpu_cores: null,
|
|
57
|
+
mem_total_gb: null, mem_used_gb: null, mem_avail_gb: null,
|
|
58
|
+
disk_total_gb: null, disk_used_gb: null, disk_avail_gb: null,
|
|
59
|
+
};
|
|
60
|
+
expect(() => schemas.report_status.host.parse(full)).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("放宽的只有 null —— 类型错的值仍然被拒", () => {
|
|
64
|
+
const host = schemas.report_status.host;
|
|
65
|
+
expect(() => host.parse({ ip: 12345 })).toThrow();
|
|
66
|
+
expect(() => host.parse({ ip: "x".repeat(201) })).toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("整条 report_status 负载(不只是 host 子对象)也接受 ip=null", () => {
|
|
70
|
+
const full = z.object(schemas.report_status);
|
|
71
|
+
expect(() => full.parse({
|
|
72
|
+
resume_id: "probe-resume", alias: "probe-node", status: "idle",
|
|
73
|
+
host: { hostname: "unknown", ip: null },
|
|
74
|
+
})).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
|
|
79
|
+
*
|
|
80
|
+
* 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
|
|
81
|
+
* 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
|
|
82
|
+
* 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
|
|
83
|
+
* 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
|
|
84
|
+
*
|
|
85
|
+
* 消毒在读取处(`/api/host-supervisors`),不在这里。 */
|
|
86
|
+
describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
|
|
87
|
+
const caps = () => schemas.report_status.config_snapshot;
|
|
88
|
+
|
|
89
|
+
test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
|
|
90
|
+
expect(caps()).toBeTruthy();
|
|
91
|
+
const parsed = caps().parse({
|
|
92
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
93
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
94
|
+
});
|
|
95
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("正常值:透传", () => {
|
|
99
|
+
const parsed = caps().parse({
|
|
100
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
101
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
|
|
102
|
+
});
|
|
103
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
|
|
107
|
+
const parsed = caps().parse({
|
|
108
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
109
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
|
|
110
|
+
});
|
|
111
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
|
|
115
|
+
* 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
|
|
116
|
+
test.each([
|
|
117
|
+
["负数", -1],
|
|
118
|
+
["非整数", 1234.5678],
|
|
119
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
120
|
+
["NaN", Number.NaN],
|
|
121
|
+
])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
|
|
122
|
+
expect(() => caps().parse({
|
|
123
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
124
|
+
daemon_capabilities: { can_create_nodes: false,
|
|
125
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
126
|
+
create_capability_observed_ms_ago: value },
|
|
127
|
+
})).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
|
|
131
|
+
* 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
|
|
132
|
+
* null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
|
|
133
|
+
test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
|
|
134
|
+
const parsed = caps().parse({
|
|
135
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
136
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
|
|
137
|
+
});
|
|
138
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
|
|
139
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
|
|
144
|
+
*
|
|
145
|
+
* 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
|
|
146
|
+
* hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
|
|
147
|
+
* strict 拒掉整份 report(节点当场失联,#1225 的形状)。
|
|
148
|
+
*
|
|
149
|
+
* 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
|
|
150
|
+
* 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
|
|
151
|
+
* 而不是「必须是某个值」。 */
|
|
152
|
+
describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
|
|
153
|
+
const rs = () => schemas.report_status;
|
|
154
|
+
const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
|
|
155
|
+
|
|
156
|
+
function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = schema.parse({ ...base, ...UNKNOWN });
|
|
159
|
+
return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
|
|
160
|
+
} catch { return "rejected"; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const SIDE_THREAD = {
|
|
164
|
+
supported: true, runtime: "codex", runtimeVersion: "1.0",
|
|
165
|
+
topology: "native-exact-fork", evidenceRevision: "r1",
|
|
166
|
+
};
|
|
167
|
+
const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
|
|
168
|
+
|
|
169
|
+
test.each([
|
|
170
|
+
["host", () => rs().host, {}, "dropped"],
|
|
171
|
+
["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
|
|
172
|
+
["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
|
|
173
|
+
["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
|
|
174
|
+
])("%s → 未知键被 %s", (_name, get, base, expected) => {
|
|
175
|
+
expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
|
|
179
|
+
const parsed: any = rs().config_snapshot.parse({
|
|
180
|
+
...SNAP_BASE,
|
|
181
|
+
daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
|
|
182
|
+
});
|
|
183
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
184
|
+
expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
|
|
188
|
+
expect(() => rs().config_snapshot.parse({
|
|
189
|
+
...SNAP_BASE,
|
|
190
|
+
side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
|
|
191
|
+
})).toThrow();
|
|
192
|
+
// 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
|
|
193
|
+
// 和 strict 无关。
|
|
194
|
+
expect(() => rs().config_snapshot.parse({
|
|
195
|
+
...SNAP_BASE, side_thread_capability: SIDE_THREAD,
|
|
196
|
+
})).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
|
|
200
|
+
* 那段注释描述的「不对称」就不存在了,而这条会先红。 */
|
|
201
|
+
test("这张表确实是不对称的(两种后果各至少一个)", () => {
|
|
202
|
+
const results = [
|
|
203
|
+
acceptsUnknownKey(rs().host, {}),
|
|
204
|
+
acceptsUnknownKey(rs().process_telemetry, {}),
|
|
205
|
+
acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
|
|
206
|
+
acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
|
|
207
|
+
];
|
|
208
|
+
expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
|
|
209
|
+
expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
|
|
210
|
+
});
|
|
211
|
+
});
|