@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ // RFC-026 v4 hub-side runtime — pendingEnvBlobs Map (F1 mint-stream-
2
+ // evict) + sweeper (C4 orphan ntok revoke) + content-match finalize
3
+ // helper (called from report_status when a child first registers).
4
+ //
5
+ // The actual MCP tools (create_node / get_create_request /
6
+ // ack_create_request) are registered in tools.ts and call into this
7
+ // module. Keeping the heavy state + sweeper here makes the tool layer
8
+ // thin + this module independently testable.
9
+
10
+ import { db, uuidv4 } from "./db.js";
11
+
12
+ // ── §4.4 F1 — pendingEnvBlobs Map ─────────────────────────────────
13
+ // In-memory ONLY. Never touches disk / never enters the SQL store.
14
+ // Daemon `get_create_request(request_id)` consumes + evicts. Untaken
15
+ // blobs are GC'd at TTL expiry.
16
+
17
+ export interface PendingEnvBlob {
18
+ request_id: string;
19
+ daemon_node_id: string; // hub uses this for C2 cross-daemon check
20
+ env_blob: Record<string, string>; // KEY → secret value (plaintext, never logged)
21
+ child_token: string; // freshly minted ntok_ for the child
22
+ child_token_id: string; // tokens.token_id for sweeper revoke
23
+ expires_at: number; // ms epoch
24
+ }
25
+
26
+ const TTL_MS = 60_000; // 60s per RFC-026 v4 §4.4
27
+ const SWEEPER_INTERVAL_MS = 30_000; // 30s per RFC-026 v4 §4.4.8
28
+ const REAPER_TTL_MS = 60_000; // 60s timeout for pending/delivered rows
29
+
30
+ const pendingEnvBlobs = new Map<string, PendingEnvBlob>();
31
+ let gcTimer: ReturnType<typeof setInterval> | null = null;
32
+ let sweeperTimer: ReturnType<typeof setInterval> | null = null;
33
+
34
+ export function putPendingEnvBlob(b: Omit<PendingEnvBlob, "expires_at">): void {
35
+ pendingEnvBlobs.set(b.request_id, { ...b, expires_at: Date.now() + TTL_MS });
36
+ }
37
+
38
+ /** Get the pending env_blob for daemon. Returns null if not found OR
39
+ * if caller's daemon_node_id doesn't match (C2 cross-daemon guard).
40
+ * Atomically deletes the entry from the Map (one-shot consume). */
41
+ export function takePendingEnvBlob(
42
+ request_id: string,
43
+ callerDaemonNodeId: string,
44
+ ): PendingEnvBlob | null {
45
+ const b = pendingEnvBlobs.get(request_id);
46
+ if (!b) return null;
47
+ if (b.daemon_node_id !== callerDaemonNodeId) return null;
48
+ if (Date.now() > b.expires_at) {
49
+ pendingEnvBlobs.delete(request_id);
50
+ return null;
51
+ }
52
+ pendingEnvBlobs.delete(request_id); // §4.4 evict immediately
53
+ return b;
54
+ }
55
+
56
+ /** Test/diagnostic helper — does NOT consume. */
57
+ export function peekPendingEnvBlob(request_id: string): PendingEnvBlob | null {
58
+ return pendingEnvBlobs.get(request_id) || null;
59
+ }
60
+
61
+ export function evictExpired(now = Date.now()): number {
62
+ let evicted = 0;
63
+ for (const [k, v] of pendingEnvBlobs) {
64
+ if (now > v.expires_at) {
65
+ pendingEnvBlobs.delete(k);
66
+ evicted++;
67
+ }
68
+ }
69
+ return evicted;
70
+ }
71
+
72
+ export function startPendingEnvGcTimer(): void {
73
+ if (gcTimer) return;
74
+ gcTimer = setInterval(() => {
75
+ const n = evictExpired();
76
+ if (n > 0) console.log(`[create-node] GC: evicted ${n} expired env_blob(s)`);
77
+ }, 5_000);
78
+ (gcTimer as any).unref?.();
79
+ }
80
+
81
+ // ── §4.4.8 C4 — orphan ntok revoke sweeper ────────────────────────
82
+ // Scans `node_create_requests` for non-terminal rows older than
83
+ // REAPER_TTL_MS. Atomically marks request as failed/expired AND
84
+ // revokes the matching api_tokens.row (via request_id link) so the
85
+ // orphan child-ntok cannot be used by anyone who scraped it.
86
+
87
+ export function runOrphanSweepOnce(now = Date.now()): { swept: number; revoked: number } {
88
+ const cutoffAge = REAPER_TTL_MS;
89
+ const cutoffMs = now - cutoffAge;
90
+ // Two cohorts (per RFC §4.4.8 case table):
91
+ // F-1 status='pending' age > TTL → hub crashed before daemon get
92
+ // F-2 status='delivered' age > TTL → daemon got blob but never ack'd
93
+ const stale = db.all<{ request_id: string; status: string; child_token_id: string | null }>(
94
+ `SELECT request_id, status, child_token_id
95
+ FROM node_create_requests
96
+ WHERE status IN ('pending', 'delivered')
97
+ AND created_at < ?1`,
98
+ cutoffMs,
99
+ );
100
+ if (stale.length === 0) return { swept: 0, revoked: 0 };
101
+
102
+ let swept = 0; let revoked = 0;
103
+ // SQLite Bun driver doesn't expose BEGIN/COMMIT cleanly across the
104
+ // wrapper here; fold per-row into a transaction.
105
+ try {
106
+ db.exec("BEGIN");
107
+ for (const row of stale) {
108
+ // Revoke the child-ntok first (sweeper is server-side ground
109
+ // truth per §4.4.8). If child_token_id null somehow, skip
110
+ // revoke (request never made it that far) but still mark
111
+ // failed.
112
+ if (row.child_token_id) {
113
+ // db.run returns the SQLite adapter's RunResult (changes counter
114
+ // if driver-supported). Only count a revoke when the UPDATE
115
+ // actually touched a row — under the bun:sqlite adapter the
116
+ // changes prop is reliable; on adapters returning undefined we
117
+ // fall back to a post-UPDATE SELECT to confirm revoked_at IS
118
+ // NOT NULL (DB is ground truth). Avoids over-counting when
119
+ // child_token_id is stale or already revoked. (通信龙 nit 2)
120
+ const r = db.run(
121
+ `UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`,
122
+ [row.child_token_id],
123
+ );
124
+ const changes = (r as any)?.changes;
125
+ if (typeof changes === "number") {
126
+ if (changes > 0) revoked++;
127
+ } else {
128
+ // Adapter didn't report; verify via SELECT
129
+ const after = db.get<{ revoked_at: string | null }>(
130
+ `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`, row.child_token_id,
131
+ );
132
+ if (after?.revoked_at) revoked++;
133
+ }
134
+ }
135
+ const newStatus = row.status === "pending" ? "failed" : "expired";
136
+ const err = row.status === "pending" ? "sweeper_revoked_before_delivery" : "sweeper_revoked_after_delivery_no_ack";
137
+ db.run(
138
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
139
+ [newStatus, err, now, row.request_id],
140
+ );
141
+ // Also drop any lingering env_blob (defense; should already be
142
+ // gone for delivered, but pending could leak if GC hasn't run).
143
+ pendingEnvBlobs.delete(row.request_id);
144
+ swept++;
145
+ }
146
+ db.exec("COMMIT");
147
+ } catch (e: any) {
148
+ try { db.exec("ROLLBACK"); } catch { /* swallow */ }
149
+ console.warn(`[create-node] sweeper transaction failed: ${e?.message || e}`);
150
+ return { swept: 0, revoked: 0 };
151
+ }
152
+ if (swept > 0) {
153
+ console.log(`[commhub] ✓ create-node sweeper: swept ${swept} stale request(s), revoked ${revoked} child-ntok(s)`);
154
+ auditCreateNode({
155
+ action: "create_node_sweeper_revoked",
156
+ detail: { swept, revoked, sweeper_run_at_ms: now },
157
+ });
158
+ }
159
+ return { swept, revoked };
160
+ }
161
+
162
+ export function startSweeperTimer(): void {
163
+ if (sweeperTimer) return;
164
+ // Boot-time sweep: catch any rows left over from a prior process
165
+ // crash (case F-1: hub crashed after mint, before daemon get).
166
+ try { runOrphanSweepOnce(); } catch (e: any) {
167
+ console.warn(`[create-node] boot sweeper failed: ${e?.message || e}`);
168
+ }
169
+ sweeperTimer = setInterval(() => {
170
+ try { runOrphanSweepOnce(); }
171
+ catch (e: any) { console.warn(`[create-node] periodic sweeper failed: ${e?.message || e}`); }
172
+ }, SWEEPER_INTERVAL_MS);
173
+ (sweeperTimer as any).unref?.();
174
+ }
175
+
176
+ export function stopBackgroundTimersForTest(): void {
177
+ if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
178
+ if (sweeperTimer) { clearInterval(sweeperTimer); sweeperTimer = null; }
179
+ pendingEnvBlobs.clear();
180
+ }
181
+
182
+ // ── content-match finalize on child first register ────────────────
183
+ // Called from tools.ts report_status handler when a node registers (or
184
+ // re-registers). If there's a pending node_create_requests row whose
185
+ // `child_name` matches the incoming alias AND the daemon_node_id is
186
+ // in caller's network, mark it succeeded and stamp the real
187
+ // child_node_id. Mirrors RFC-024 finalizePendingMatchingUpdates.
188
+
189
+ export function finalizeCreateOnFirstRegister(
190
+ incoming: { node_id: string; alias: string; network_id: string | null },
191
+ ): { finalized: number; matchedIds: string[] } {
192
+ if (!incoming.node_id || !incoming.alias) return { finalized: 0, matchedIds: [] };
193
+ const net = incoming.network_id ?? "default";
194
+ const pending = db.all<{ request_id: string; daemon_node_id: string }>(
195
+ `SELECT request_id, daemon_node_id FROM node_create_requests
196
+ WHERE child_name = ?1 AND network_id = ?2 AND status IN ('delivered', 'pending')
197
+ ORDER BY created_at ASC`,
198
+ incoming.alias, net,
199
+ );
200
+ if (pending.length === 0) return { finalized: 0, matchedIds: [] };
201
+
202
+ const matchedIds: string[] = [];
203
+ for (const row of pending) {
204
+ const r = db.run(
205
+ `UPDATE node_create_requests
206
+ SET status = 'succeeded', child_node_id = ?1, acked_at = ?2
207
+ WHERE request_id = ?3 AND status IN ('delivered', 'pending')`,
208
+ [incoming.node_id, Date.now(), row.request_id],
209
+ );
210
+ matchedIds.push(row.request_id);
211
+ console.log(`[commhub] ✓ create-node finalize: request=${row.request_id} child=${incoming.alias} (${incoming.node_id}) daemon=${row.daemon_node_id}`);
212
+ auditCreateNode({
213
+ action: "create_node_succeeded",
214
+ network_id: net,
215
+ target_id: row.request_id,
216
+ detail: { child_node_id: incoming.node_id, child_alias: incoming.alias, daemon_node_id: row.daemon_node_id },
217
+ });
218
+ }
219
+ return { finalized: matchedIds.length, matchedIds };
220
+ }
221
+
222
+ export function newRequestId(): string {
223
+ return `cr_${uuidv4()}`;
224
+ }
225
+
226
+ // §4.1.4 C2 — resolve the caller daemon's node row by **token-bound
227
+ // identity** (PR #299 BLOCKER #1). Module-level so unit tests can
228
+ // import + call directly (per 通信龙 nit 1 — don't inline-mirror SQL
229
+ // in tests; that's exactly the helper-drift anti-pattern the tools.ts
230
+ // comment warns against).
231
+ export type DaemonResolveResult =
232
+ | { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
233
+ | { ok: false; error: string };
234
+
235
+ export function resolveCallerDaemonTokenBound(opts: {
236
+ callerTokenIsNetwork: boolean;
237
+ callerTokenId: string | null | undefined;
238
+ enforceNetworkId: string | null | undefined;
239
+ }): DaemonResolveResult {
240
+ if (!opts.callerTokenIsNetwork || !opts.callerTokenId) {
241
+ return { ok: false, error: "caller_not_a_daemon" };
242
+ }
243
+ if (!opts.enforceNetworkId) {
244
+ return { ok: false, error: "caller_not_a_daemon" };
245
+ }
246
+ const tokRow = db.get<{ name: string; network_id: string | null }>(
247
+ `SELECT name, network_id FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
248
+ opts.callerTokenId,
249
+ );
250
+ if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
251
+ return { ok: false, error: "caller_not_a_daemon" };
252
+ }
253
+ if (tokRow.network_id !== opts.enforceNetworkId) {
254
+ return { ok: false, error: "caller_not_a_daemon" };
255
+ }
256
+ const tokenAlias = tokRow.name.slice(5);
257
+ const nodeRow = db.get<{ node_id: string; alias: string; network_id: string }>(
258
+ `SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1`,
259
+ tokenAlias, tokRow.network_id,
260
+ );
261
+ if (!nodeRow) {
262
+ return { ok: false, error: "caller_not_a_daemon" };
263
+ }
264
+ return {
265
+ ok: true,
266
+ daemonNodeId: nodeRow.node_id,
267
+ daemonAlias: nodeRow.alias,
268
+ networkId: nodeRow.network_id,
269
+ };
270
+ }
271
+
272
+ // RFC-026 §4.5 — append a row to audit_log for every create_node
273
+ // lifecycle event. Best-effort: never throw out (calling tool should
274
+ // continue even if audit insert fails for any reason).
275
+ /** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode
276
+ * but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE +
277
+ * audit INSERT can ROLLBACK if the audit row would have been lost.
278
+ * Required for the "no audit-but state changed" window that §4.5 / D8
279
+ * explicitly closes (PR1 SF-2 review catch — auditCreateNode's
280
+ * swallow-and-warn defeats atomicity).
281
+ *
282
+ * Callers in a non-transaction context should keep using auditCreateNode
283
+ * (best-effort, never throws). */
284
+ export function auditCreateNodeStrict(input: {
285
+ action: Parameters<typeof auditCreateNode>[0]["action"];
286
+ user_id?: string | null;
287
+ username?: string | null;
288
+ network_id?: string | null;
289
+ target_id?: string | null;
290
+ detail: Record<string, unknown>;
291
+ }): void {
292
+ db.run(
293
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
294
+ VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
295
+ [
296
+ input.user_id || null,
297
+ input.username || null,
298
+ input.action,
299
+ input.target_id || null,
300
+ JSON.stringify(input.detail),
301
+ input.network_id || null,
302
+ ],
303
+ );
304
+ }
305
+
306
+ export function auditCreateNode(input: {
307
+ action:
308
+ | "create_node_dispatched"
309
+ | "create_node_rejected"
310
+ | "create_node_succeeded"
311
+ | "create_node_sweeper_revoked"
312
+ | "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
313
+ // RFC-027 §4.5 — stop/delete lifecycle audit action enum extension.
314
+ // Re-using auditCreateNode is fine: the action column carries the
315
+ // discriminator; target_type stays 'node_create_request' which is a
316
+ // misnomer for stop/delete rows but updating the schema would force a
317
+ // wider migration (see P1.1 issue).
318
+ | "stop_node_dispatched"
319
+ | "stop_node_completed"
320
+ | "delete_node_dispatched"
321
+ | "delete_node_completed"
322
+ | "forced_stop_with_in_flight"
323
+ | "backup_purged";
324
+ user_id?: string | null;
325
+ username?: string | null;
326
+ network_id?: string | null;
327
+ target_id?: string | null; // request_id
328
+ detail: Record<string, unknown>;
329
+ }): void {
330
+ try {
331
+ db.run(
332
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
333
+ VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
334
+ [
335
+ input.user_id || null,
336
+ input.username || null,
337
+ input.action,
338
+ input.target_id || null,
339
+ JSON.stringify(input.detail),
340
+ input.network_id || null,
341
+ ],
342
+ );
343
+ } catch (e: any) {
344
+ console.warn(`[commhub] audit_log insert (create_node) failed: ${e?.message || e}`);
345
+ }
346
+ }
@@ -64,6 +64,116 @@ beforeEach(() => {
64
64
  // fix, the function refuses to traverse and writes nothing.
65
65
  // ─────────────────────────────────────────────────────────────────────
66
66
 
67
+ // ─────────────────────────────────────────────────────────────────────
68
+ // Round-5 follow-up — chainReplyToParent return value gates SSE push
69
+ //
70
+ // 通信牛 catch on #275: the DB write is refused on cross-network, but
71
+ // the post-call code in tools.ts was still running:
72
+ //
73
+ // SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?
74
+ // SELECT from_name, task_id ... WHERE task_id = ?
75
+ // pushEvent(parent.from_name, { parent_task_id: <netA-id>, ... },
76
+ // effectiveNetId)
77
+ //
78
+ // That pushEvent leaks the foreign parent's task_id into the caller's
79
+ // network via SSE payload. The fix: chainReplyToParent now returns
80
+ // `{ chained, stoppedReason? }` so callers can skip the follow-up
81
+ // push when the chain didn't actually write. These tests pin the
82
+ // return contract directly. The SSE-gate is exercised at the call
83
+ // sites in tools.ts; pinning the contract here ensures the call-site
84
+ // behaviour can't drift back if someone refactors db.ts.
85
+ // ─────────────────────────────────────────────────────────────────────
86
+
87
+ describe("chainReplyToParent — return value gates SSE push (round5 follow-up)", () => {
88
+ test("cross-network refusal returns { chained: false, stoppedReason: 'cross_network' }", () => {
89
+ const parentA = insertTask({
90
+ from_name: "admin-a",
91
+ to_name: "worker-a",
92
+ network_id: "net-A",
93
+ status: "delivered",
94
+ });
95
+ const childB = insertTask({
96
+ from_name: "attacker-b",
97
+ to_name: "victim-b",
98
+ network_id: "net-B",
99
+ status: "replied",
100
+ parent_task_id: parentA,
101
+ });
102
+
103
+ const result = chainReplyToParent(childB, "payload", "replied", 5, "net-B");
104
+
105
+ expect(result.chained).toBe(false);
106
+ expect(result.stoppedReason).toBe("cross_network");
107
+ });
108
+
109
+ test("legitimate same-network chain returns { chained: true } (no stoppedReason)", () => {
110
+ const parentB = insertTask({
111
+ from_name: "admin-b",
112
+ to_name: "worker-b",
113
+ network_id: "net-B",
114
+ status: "delivered",
115
+ });
116
+ const childB = insertTask({
117
+ from_name: "child-b",
118
+ to_name: "leaf-b",
119
+ network_id: "net-B",
120
+ status: "replied",
121
+ parent_task_id: parentB,
122
+ });
123
+
124
+ const result = chainReplyToParent(childB, "ok", "replied", 5, "net-B");
125
+
126
+ expect(result.chained).toBe(true);
127
+ expect(result.stoppedReason).toBeUndefined();
128
+ });
129
+
130
+ test("child with no parent returns { chained: false } (no stoppedReason)", () => {
131
+ const orphan = insertTask({
132
+ from_name: "x",
133
+ to_name: "y",
134
+ network_id: "net-B",
135
+ status: "replied",
136
+ });
137
+
138
+ const result = chainReplyToParent(orphan, "ok", "replied", 5, "net-B");
139
+
140
+ expect(result.chained).toBe(false);
141
+ expect(result.stoppedReason).toBeUndefined();
142
+ });
143
+
144
+ test("3-level chain: same-net parent writes, foreign grandparent halts → chained=true, stoppedReason='cross_network'", () => {
145
+ const grandparent = insertTask({
146
+ from_name: "gp-admin",
147
+ to_name: "gp-worker",
148
+ network_id: "net-A",
149
+ status: "delivered",
150
+ });
151
+ const parent = insertTask({
152
+ from_name: "p-admin",
153
+ to_name: "p-worker",
154
+ network_id: "net-B",
155
+ status: "delivered",
156
+ parent_task_id: grandparent,
157
+ });
158
+ const child = insertTask({
159
+ from_name: "c",
160
+ to_name: "leaf",
161
+ network_id: "net-B",
162
+ status: "replied",
163
+ parent_task_id: parent,
164
+ });
165
+
166
+ const result = chainReplyToParent(child, "ok", "replied", 5, "net-B");
167
+
168
+ // We DID write into the same-net parent before the chain halted
169
+ // at the foreign grandparent. `chained: true` lets callers push
170
+ // SSE for the legitimate parent; `stoppedReason` makes it visible
171
+ // in logs/metrics that the chain didn't run to completion.
172
+ expect(result.chained).toBe(true);
173
+ expect(result.stoppedReason).toBe("cross_network");
174
+ });
175
+ });
176
+
67
177
  describe("chainReplyToParent — cross-network rejection (round5 F2 #3)", () => {
68
178
  test("blocks chain when parent.network_id != callerNetId", () => {
69
179
  // Network A: a task that should NEVER receive a foreign reply.