@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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 +130 -10
- 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-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -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-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- 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 +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- 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-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -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 +321 -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 +404 -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 +732 -0
- package/src/health-redaction.test.ts +642 -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/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -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 +224 -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-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -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/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- 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/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -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 +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -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-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -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/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -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/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -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 +3446 -257
- 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
|
@@ -84,6 +84,10 @@ type SSEClient = {
|
|
|
84
84
|
keepaliveTimer?: ReturnType<typeof setInterval>;
|
|
85
85
|
/** Stable key for log/diagnostic context. */
|
|
86
86
|
key: string;
|
|
87
|
+
/** 多用户 Agent 权限:订阅者的 user_id(有的话),权限变化时据此断开重连重新鉴权。 */
|
|
88
|
+
subscriberUserId?: string;
|
|
89
|
+
/** 只推满足条件的事件(受限成员的网络观察流用)。缺省 = 全推。 */
|
|
90
|
+
eventFilter?: (event: Record<string, unknown>) => boolean;
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
// 一个 session 可能有多个 SSE 连接(重连时短暂并存)
|
|
@@ -97,6 +101,45 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
97
101
|
return `${networkId || "global"}:${sessionName}`;
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
// ── #461 network observer channels ───────────────────────────────────
|
|
105
|
+
// Dashboard (or any network member) subscribes GET /events/network/:id
|
|
106
|
+
// and receives SUMMARY events for ALL new_task / new_reply traffic in
|
|
107
|
+
// that network — not just tasks it sent/received. Summary events carry
|
|
108
|
+
// ids + routing metadata only, never task/reply content.
|
|
109
|
+
//
|
|
110
|
+
// Key scheme: leading NUL byte makes an observer key impossible to
|
|
111
|
+
// forge from the session scheme above (`${networkId}:${sessionName}`)
|
|
112
|
+
// for any networkId the hub itself issues (net_* / "global"). Only a
|
|
113
|
+
// master-token caller could even inject a NUL into network_id via the
|
|
114
|
+
// legacy ?network_id= path, and master token is root already.
|
|
115
|
+
const OBSERVER_KEY_PREFIX = "\u0000netobs:";
|
|
116
|
+
const USER_KEY_PREFIX = "\u0000user:";
|
|
117
|
+
|
|
118
|
+
/** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
|
|
119
|
+
* `printableKey()` (below) and therefore the shape callers see via
|
|
120
|
+
* `getSSEStats().sessions`. Exported so downstream consumers (e.g.
|
|
121
|
+
* `/health` filter in server.ts) key off the same literal instead of
|
|
122
|
+
* each side re-writing the escape sequence — CR3 audit 4703b0e7
|
|
123
|
+
* found server.ts had `"netobs:"` (no `\\0`) which never matched and
|
|
124
|
+
* silently over-filtered observer keys. */
|
|
125
|
+
export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
|
|
126
|
+
|
|
127
|
+
function observerKey(networkId: string): string {
|
|
128
|
+
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function userKey(networkId: string, userId: string): string {
|
|
132
|
+
return `${USER_KEY_PREFIX}${networkId}:${userId}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Render a client key for logs / stats. Observer keys embed a raw NUL
|
|
136
|
+
* byte (collision guard above); printing that verbatim makes grep treat
|
|
137
|
+
* hub logs as binary and breaks log shippers, and getSSEStats feeds
|
|
138
|
+
* /health. Display as a visible "\\0" escape instead. */
|
|
139
|
+
function printableKey(key: string): string {
|
|
140
|
+
return key.split("\u0000").join("\\0");
|
|
141
|
+
}
|
|
142
|
+
|
|
100
143
|
// ─────────────────────────────────────────────────────────────────────
|
|
101
144
|
// Close + dispatch helpers
|
|
102
145
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -114,7 +157,7 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
114
157
|
// Already closed by the runtime, or controller errored. Either way
|
|
115
158
|
// we're done with it.
|
|
116
159
|
}
|
|
117
|
-
console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
|
|
160
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
118
161
|
}
|
|
119
162
|
|
|
120
163
|
/** Best-effort enqueue with backpressure guard. Returns:
|
|
@@ -215,8 +258,42 @@ function ensureLivenessSweep(): void {
|
|
|
215
258
|
|
|
216
259
|
/** 创建 SSE Response 并注册到 clients map */
|
|
217
260
|
export function createSSEStream(sessionName: string, networkId?: string | null): Response {
|
|
261
|
+
return createStreamForKey(
|
|
262
|
+
clientKey(sessionName, networkId),
|
|
263
|
+
{ type: "connected", session: sessionName, network_id: networkId ?? null },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
268
|
+
* Shares the exact same registration / backpressure / liveness
|
|
269
|
+
* machinery as session streams; only the key scheme differs. */
|
|
270
|
+
export function createNetworkObserverStream(
|
|
271
|
+
networkId: string,
|
|
272
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
|
|
273
|
+
): Response {
|
|
274
|
+
return createStreamForKey(
|
|
275
|
+
observerKey(networkId),
|
|
276
|
+
{ type: "connected", observer: true, network_id: networkId },
|
|
277
|
+
opts,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Desktop/user client SSE stream. Keyed by authenticated user_id, not
|
|
282
|
+
* alias/session, so Desktop does not need to mint a pseudo node token. */
|
|
283
|
+
export function createUserEventStream(networkId: string, userId: string): Response {
|
|
284
|
+
return createStreamForKey(
|
|
285
|
+
userKey(networkId, userId),
|
|
286
|
+
{ type: "connected", user: true, network_id: networkId, user_id: userId },
|
|
287
|
+
{ subscriberUserId: userId },
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function createStreamForKey(
|
|
292
|
+
key: string,
|
|
293
|
+
initialEvent: Record<string, unknown>,
|
|
294
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
|
|
295
|
+
): Response {
|
|
218
296
|
const encoder = new TextEncoder();
|
|
219
|
-
const key = clientKey(sessionName, networkId);
|
|
220
297
|
let client: SSEClient;
|
|
221
298
|
|
|
222
299
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -227,17 +304,19 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
227
304
|
stuckSince: null,
|
|
228
305
|
closed: false,
|
|
229
306
|
key,
|
|
307
|
+
...(opts.subscriberUserId ? { subscriberUserId: opts.subscriberUserId } : {}),
|
|
308
|
+
...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
|
|
230
309
|
};
|
|
231
310
|
|
|
232
311
|
if (!clients.has(key)) clients.set(key, []);
|
|
233
312
|
clients.get(key)!.push(client);
|
|
234
|
-
console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
|
|
313
|
+
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
235
314
|
|
|
236
315
|
// Send initial connected frame through the backpressure guard so
|
|
237
316
|
// even the first byte respects the contract.
|
|
238
317
|
tryEnqueueBytes(
|
|
239
318
|
client,
|
|
240
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
319
|
+
encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
|
|
241
320
|
);
|
|
242
321
|
|
|
243
322
|
// Periodic keepalive doubles as a half-open probe — if the
|
|
@@ -280,7 +359,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
280
359
|
|
|
281
360
|
return new Response(stream, {
|
|
282
361
|
headers: {
|
|
283
|
-
|
|
362
|
+
// #426: explicit charset so legacy Windows clients (PowerShell 5.1's
|
|
363
|
+
// Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
|
|
364
|
+
// Content-Type omits the charset) decode UTF-8 payloads correctly.
|
|
365
|
+
// Hub itself was already writing clean UTF-8 bytes; the client-side
|
|
366
|
+
// 双重编码样式 mojibake was the header telling the client to guess.
|
|
367
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
284
368
|
"Cache-Control": "no-cache, no-transform",
|
|
285
369
|
"Connection": "keep-alive",
|
|
286
370
|
},
|
|
@@ -294,7 +378,7 @@ function pruneClosed(key: string): void {
|
|
|
294
378
|
const alive = arr.filter((c) => !c.closed);
|
|
295
379
|
if (alive.length === 0) {
|
|
296
380
|
clients.delete(key);
|
|
297
|
-
console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
|
|
381
|
+
console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
|
|
298
382
|
} else if (alive.length !== arr.length) {
|
|
299
383
|
clients.set(key, alive);
|
|
300
384
|
}
|
|
@@ -327,6 +411,17 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
|
|
|
327
411
|
});
|
|
328
412
|
|
|
329
413
|
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
414
|
+
/** 这个 alias 上此刻是否真的有活着的 SSE 订阅者。
|
|
415
|
+
*
|
|
416
|
+
* 这是投递可达性的**实测**,区别于 resolveDeliveryTarget 里那个基于
|
|
417
|
+
* last_seen_at 的**猜测**。一个安静但连着的会话(例如遵守「停发心跳」
|
|
418
|
+
* 规则的 MCP 会话)在时间戳判据下是 offline,在这里是 true。 */
|
|
419
|
+
export function hasSubscribers(sessionName: string, networkId?: string | null): boolean {
|
|
420
|
+
const arr = clients.get(clientKey(sessionName, networkId));
|
|
421
|
+
if (!arr) return false;
|
|
422
|
+
return arr.some((c) => !c.closed);
|
|
423
|
+
}
|
|
424
|
+
|
|
330
425
|
export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
|
|
331
426
|
const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
|
|
332
427
|
const arr = clients.get(key);
|
|
@@ -347,6 +442,108 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
347
442
|
if (needPrune) pruneClosed(key);
|
|
348
443
|
}
|
|
349
444
|
|
|
445
|
+
/** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
|
|
446
|
+
*
|
|
447
|
+
* Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
|
|
448
|
+
* priority / message_id) — never task or reply content. The event is
|
|
449
|
+
* stamped with `network_id` + `scope: "network"` here so observers can
|
|
450
|
+
* always tell which network the event belongs to and that it came from
|
|
451
|
+
* the observer stream rather than their personal channel.
|
|
452
|
+
*
|
|
453
|
+
* No-op when networkId is falsy: legacy null-network traffic has no
|
|
454
|
+
* observer stream (the route requires an explicit network id). */
|
|
455
|
+
export function pushNetworkObserverEvent(
|
|
456
|
+
networkId: string | null | undefined,
|
|
457
|
+
event: Record<string, unknown>,
|
|
458
|
+
): void {
|
|
459
|
+
if (!networkId) return;
|
|
460
|
+
const key = observerKey(networkId);
|
|
461
|
+
const arr = clients.get(key);
|
|
462
|
+
if (!arr || arr.length === 0) return;
|
|
463
|
+
|
|
464
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
|
|
465
|
+
let needPrune = false;
|
|
466
|
+
|
|
467
|
+
for (const c of arr) {
|
|
468
|
+
if (c.closed) {
|
|
469
|
+
needPrune = true;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (c.eventFilter) {
|
|
473
|
+
let pass = false;
|
|
474
|
+
try { pass = c.eventFilter(event); } catch { pass = false; }
|
|
475
|
+
if (!pass) continue;
|
|
476
|
+
}
|
|
477
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
478
|
+
if (result === "dead") needPrune = true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (needPrune) pruneClosed(key);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Push an event to every active Desktop/web client for one user in one
|
|
485
|
+
* network. No-op for falsy network/user ids; auth must be enforced by
|
|
486
|
+
* the caller before invoking this helper. */
|
|
487
|
+
/** 这个用户此刻是否真的有活着的 SSE 订阅者。
|
|
488
|
+
*
|
|
489
|
+
* 与 alias 版 `hasSubscribers` 同形状:投递可达性的**实测**,
|
|
490
|
+
* 调用方据此把「送到了」和「没人听」区分开,而不是一律报成功。 */
|
|
491
|
+
export function hasUserSubscribers(
|
|
492
|
+
networkId: string | null | undefined,
|
|
493
|
+
userId: string | null | undefined,
|
|
494
|
+
): boolean {
|
|
495
|
+
if (!networkId || !userId) return false;
|
|
496
|
+
const arr = clients.get(userKey(networkId, userId));
|
|
497
|
+
if (!arr) return false;
|
|
498
|
+
return arr.some((c) => !c.closed);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function pushUserEvent(
|
|
502
|
+
networkId: string | null | undefined,
|
|
503
|
+
userId: string | null | undefined,
|
|
504
|
+
event: Record<string, unknown>,
|
|
505
|
+
): void {
|
|
506
|
+
if (!networkId || !userId) return;
|
|
507
|
+
const key = userKey(networkId, userId);
|
|
508
|
+
const arr = clients.get(key);
|
|
509
|
+
if (!arr || arr.length === 0) return;
|
|
510
|
+
|
|
511
|
+
const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
|
|
512
|
+
let needPrune = false;
|
|
513
|
+
|
|
514
|
+
for (const c of arr) {
|
|
515
|
+
if (c.closed) {
|
|
516
|
+
needPrune = true;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
520
|
+
if (result === "dead") needPrune = true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (needPrune) pruneClosed(key);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* 多用户 Agent 权限:某用户在某网络的授权 / 成员资格变了,断开他在这个网络里**订阅时带了身份**
|
|
528
|
+
* 的流(网络观察流、用户流),客户端重连时按新权限重新鉴权。返回断开的条数。
|
|
529
|
+
* Agent 频道(/events/<alias>)受限成员本来就订阅不了,不需要这里处理。
|
|
530
|
+
*/
|
|
531
|
+
export function closeUserStreamsInNetwork(networkId: string, userId: string): number {
|
|
532
|
+
let closed = 0;
|
|
533
|
+
for (const key of [observerKey(networkId), userKey(networkId, userId)]) {
|
|
534
|
+
const arr = clients.get(key);
|
|
535
|
+
if (!arr) continue;
|
|
536
|
+
for (const c of arr) {
|
|
537
|
+
if (!c.closed && c.subscriberUserId === userId) {
|
|
538
|
+
closeClient(c, "access-changed");
|
|
539
|
+
closed++;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
pruneClosed(key);
|
|
543
|
+
}
|
|
544
|
+
return closed;
|
|
545
|
+
}
|
|
546
|
+
|
|
350
547
|
export function __resetSSEClientsForTest(): void {
|
|
351
548
|
for (const arr of clients.values()) {
|
|
352
549
|
for (const c of arr) closeClient(c, "reset-for-test");
|
|
@@ -365,7 +562,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
|
|
|
365
562
|
for (const [name, arr] of clients) {
|
|
366
563
|
const alive = arr.filter((c) => !c.closed);
|
|
367
564
|
if (alive.length === 0) continue;
|
|
368
|
-
sessions[name] = alive.length;
|
|
565
|
+
sessions[printableKey(name)] = alive.length;
|
|
369
566
|
total += alive.length;
|
|
370
567
|
}
|
|
371
568
|
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
|
+
}
|
package/src/rename.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from "crypto";
|
|
16
16
|
import { db } from "./db";
|
|
17
17
|
import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
18
|
+
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
18
19
|
import { pushEvent } from "./push";
|
|
19
20
|
import { eventBus } from "./event_bus";
|
|
20
21
|
|
|
@@ -35,7 +36,8 @@ export interface CanonicalAlias {
|
|
|
35
36
|
|
|
36
37
|
function hasWriteAccess(userId: string, networkId: string): boolean {
|
|
37
38
|
const role = getUserNetworkRole(userId, networkId);
|
|
38
|
-
|
|
39
|
+
// 改名是对 Agent 的写操作;受限成员(只看授权 Agent 的成员)不能改。
|
|
40
|
+
return !!role && role !== "viewer" && !isAgentRestricted(userId, networkId);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// Resolve committed alias renames (old -> new), following short chains such
|
|
@@ -214,8 +216,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
214
216
|
// without this it would never receive node.renamed (#84 SSE channel fix:
|
|
215
217
|
// N站马 confirmed the dashboard listens on the user channel). All members get
|
|
216
218
|
// it, not just the owner, since any member's dashboard should reflect the rename.
|
|
219
|
+
// 多用户 Agent 权限:受限成员只收到他被授权的那个 Agent 的改名(否则等于把 alias 广播给他)。
|
|
217
220
|
for (const member of getNetworkMembers(txn.network_id)) {
|
|
218
|
-
if (member.username)
|
|
221
|
+
if (!member.username) continue;
|
|
222
|
+
if (!canSeeAgent(member.user_id, txn.network_id, { alias: txn.new_alias, nodeId: renamedNode?.node_id ?? null })) continue;
|
|
223
|
+
pushEvent(member.username, renamedEvent, txn.network_id);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
return { ok: true, txn_id: txnId };
|
|
@@ -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
|
+
});
|