@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,80 @@
1
+ import { describe, expect, test, beforeEach } from "bun:test";
2
+ import {
3
+ putPendingProbeSecret,
4
+ takePendingProbeSecret,
5
+ peekPendingProbeSecret,
6
+ evictExpiredProbeSecrets,
7
+ stopBackgroundProbeTimersForTest,
8
+ type PendingProbeSecret,
9
+ } from "./probe.js";
10
+
11
+ // RFC-028 P1 fold-in #4 — takePendingProbeSecret 4-case unit. Mirrors
12
+ // RFC-026 §4.4 takePendingEnvBlob unit pattern. This is the C2 cross-
13
+ // daemon guard for probes: a daemon-X that intercepts a probe_id minted
14
+ // for daemon-Y must NOT be able to consume it. Wrong-caller branch must
15
+ // NOT evict (so the right daemon can still consume in TTL).
16
+
17
+ function mkSecret(over: Partial<PendingProbeSecret> = {}): Omit<PendingProbeSecret, "expires_at"> {
18
+ return {
19
+ probe_id: over.probe_id ?? "pr_test_1",
20
+ daemon_node_id: over.daemon_node_id ?? "node_daemon_alpha",
21
+ provider_id: over.provider_id ?? "prov_1",
22
+ vendor: over.vendor ?? "anthropic",
23
+ base_url: over.base_url ?? "https://api.anthropic.com",
24
+ model_name: over.model_name ?? "claude-opus-test",
25
+ api_key: over.api_key ?? "sk-secret-not-leaked",
26
+ network_id: over.network_id ?? "net_1",
27
+ };
28
+ }
29
+
30
+ describe("RFC-028 P1 — takePendingProbeSecret 4-case", () => {
31
+ beforeEach(() => stopBackgroundProbeTimersForTest());
32
+
33
+ test("case 1: happy path — right daemon, within TTL → returns + evicts", () => {
34
+ putPendingProbeSecret(mkSecret({ probe_id: "pr_happy" }));
35
+ expect(peekPendingProbeSecret("pr_happy")).not.toBeNull();
36
+ const out = takePendingProbeSecret("pr_happy", "node_daemon_alpha");
37
+ expect(out).not.toBeNull();
38
+ expect(out!.api_key).toBe("sk-secret-not-leaked");
39
+ // Evicted on success → second take returns null
40
+ expect(takePendingProbeSecret("pr_happy", "node_daemon_alpha")).toBeNull();
41
+ expect(peekPendingProbeSecret("pr_happy")).toBeNull();
42
+ });
43
+
44
+ test("case 2: wrong daemon → returns null AND does NOT evict (right daemon can still consume)", () => {
45
+ putPendingProbeSecret(mkSecret({ probe_id: "pr_wrongcaller", daemon_node_id: "node_daemon_alpha" }));
46
+ // attacker daemon tries to consume
47
+ const wrong = takePendingProbeSecret("pr_wrongcaller", "node_daemon_attacker");
48
+ expect(wrong).toBeNull();
49
+ // entry MUST still be there — peek confirms (this is the C2 invariant:
50
+ // a wrong-caller probe cannot DoS the legitimate one by triggering eviction)
51
+ const peeked = peekPendingProbeSecret("pr_wrongcaller");
52
+ expect(peeked).not.toBeNull();
53
+ expect(peeked!.daemon_node_id).toBe("node_daemon_alpha");
54
+ // right daemon STILL gets it
55
+ const right = takePendingProbeSecret("pr_wrongcaller", "node_daemon_alpha");
56
+ expect(right).not.toBeNull();
57
+ expect(right!.api_key).toBe("sk-secret-not-leaked");
58
+ });
59
+
60
+ test("case 3: not found → returns null (no side-effect)", () => {
61
+ const out = takePendingProbeSecret("pr_nonexistent", "node_daemon_alpha");
62
+ expect(out).toBeNull();
63
+ // also confirm peek is null
64
+ expect(peekPendingProbeSecret("pr_nonexistent")).toBeNull();
65
+ });
66
+
67
+ test("case 4: expired → returns null + evicts (so memory doesn't accumulate)", async () => {
68
+ // Use evictExpiredProbeSecrets's now-arg surrogate: put a fresh entry then
69
+ // ask take with a time-shifted view by mutating the map. Since the public
70
+ // API doesn't expose expires_at directly, we use the sweeper to verify
71
+ // expired entries get cleaned, then assert take returns null.
72
+ putPendingProbeSecret(mkSecret({ probe_id: "pr_expired" }));
73
+ // Sweep with a forced "now" 120s in the future → past TTL_MS (60s)
74
+ const n = evictExpiredProbeSecrets(Date.now() + 120_000);
75
+ expect(n).toBeGreaterThanOrEqual(1);
76
+ // Now any take must return null + the entry is gone
77
+ expect(takePendingProbeSecret("pr_expired", "node_daemon_alpha")).toBeNull();
78
+ expect(peekPendingProbeSecret("pr_expired")).toBeNull();
79
+ });
80
+ });
package/src/probe.ts ADDED
@@ -0,0 +1,200 @@
1
+ // RFC-028 P1 §2.5 — hub-side probe state (pendingProbeSecrets Map +
2
+ // orphan revoke sweeper + finalize helper). MCP tools register in
3
+ // tools.ts (calls into here).
4
+ //
5
+ // Mirrors RFC-026 §4.4 mint-stream-evict pattern:
6
+ // - hub creates probe row (metadata only) + stashes decrypted secret
7
+ // + child fetcher params in in-memory Map (TTL 60s)
8
+ // - daemon get_probe_request takes (one-shot consume + evict)
9
+ // - daemon ack_probe_request → hub deriveErrorLabel + final row write
10
+ // - sweeper revokes orphan rows + tokens (P2 not needed since probe
11
+ // doesn't mint long-lived tokens — short-window only)
12
+
13
+ import { db, uuidv4 } from "./db.js";
14
+ import { vaultGet, VaultError } from "./vault.js";
15
+ import {
16
+ validateBaseUrl, deriveErrorLabel, rejectIfSecretLeaked,
17
+ ProbeAckPayloadSchema, type ProbeAckPayload,
18
+ ProbeValidationError,
19
+ } from "./probe-validate.js";
20
+
21
+ // ── Probe ephemeral state Map (F1 mint-stream-evict, RFC-028 §2.5) ──
22
+
23
+ export interface PendingProbeSecret {
24
+ probe_id: string;
25
+ daemon_node_id: string; // C2 cross-daemon guard
26
+ provider_id: string;
27
+ vendor: string;
28
+ base_url: string;
29
+ model_name: string;
30
+ api_key: string; // plaintext, ephemeral, never persisted
31
+ network_id: string;
32
+ expires_at: number;
33
+ }
34
+
35
+ const TTL_MS = 60_000;
36
+ const SWEEPER_INTERVAL_MS = 30_000;
37
+ const REAPER_TTL_MS = 60_000;
38
+
39
+ const pendingProbeSecrets = new Map<string, PendingProbeSecret>();
40
+ let _gcTimer: ReturnType<typeof setInterval> | null = null;
41
+ let _sweeperTimer: ReturnType<typeof setInterval> | null = null;
42
+
43
+ export function putPendingProbeSecret(b: Omit<PendingProbeSecret, "expires_at">): void {
44
+ pendingProbeSecrets.set(b.probe_id, { ...b, expires_at: Date.now() + TTL_MS });
45
+ }
46
+
47
+ /** Caller-bound one-shot consume. Returns null if not found, wrong
48
+ * daemon, or expired; entry deleted on success. */
49
+ export function takePendingProbeSecret(probe_id: string, callerDaemonNodeId: string): PendingProbeSecret | null {
50
+ const b = pendingProbeSecrets.get(probe_id);
51
+ if (!b) return null;
52
+ if (b.daemon_node_id !== callerDaemonNodeId) return null; // wrong-caller probe: DON'T evict
53
+ if (Date.now() > b.expires_at) { pendingProbeSecrets.delete(probe_id); return null; }
54
+ pendingProbeSecrets.delete(probe_id);
55
+ return b;
56
+ }
57
+
58
+ export function peekPendingProbeSecret(probe_id: string): PendingProbeSecret | null {
59
+ return pendingProbeSecrets.get(probe_id) || null;
60
+ }
61
+
62
+ export function evictExpiredProbeSecrets(now = Date.now()): number {
63
+ let n = 0;
64
+ for (const [k, v] of pendingProbeSecrets) {
65
+ if (now > v.expires_at) { pendingProbeSecrets.delete(k); n++; }
66
+ }
67
+ return n;
68
+ }
69
+
70
+ export function startPendingProbeGcTimer(): void {
71
+ if (_gcTimer) return;
72
+ _gcTimer = setInterval(() => {
73
+ const n = evictExpiredProbeSecrets();
74
+ if (n > 0) console.log(`[probe] GC: evicted ${n} expired probe secret(s)`);
75
+ }, 5_000);
76
+ (_gcTimer as any).unref?.();
77
+ }
78
+
79
+ export function stopBackgroundProbeTimersForTest(): void {
80
+ if (_gcTimer) { clearInterval(_gcTimer); _gcTimer = null; }
81
+ if (_sweeperTimer) { clearInterval(_sweeperTimer); _sweeperTimer = null; }
82
+ pendingProbeSecrets.clear();
83
+ }
84
+
85
+ // ── Sweeper: terminal-transition probe_results rows that never got ack ──
86
+ // Mirrors RFC-026 §4.4.8 — probe rows older than REAPER_TTL_MS that
87
+ // are still status='pending' get marked timeout + error_label set.
88
+
89
+ export function runOrphanProbeSweepOnce(now = Date.now()): { swept: number } {
90
+ const cutoffMs = now - REAPER_TTL_MS;
91
+ const stale = db.all<{ probe_id: string }>(
92
+ `SELECT probe_id FROM probe_results WHERE status = 'pending' AND probed_at < ?1`,
93
+ cutoffMs,
94
+ );
95
+ if (stale.length === 0) return { swept: 0 };
96
+
97
+ let swept = 0;
98
+ try {
99
+ db.exec("BEGIN");
100
+ for (const row of stale) {
101
+ db.run(
102
+ `UPDATE probe_results SET status = ?1, error_label = ?2 WHERE probe_id = ?3 AND status = 'pending'`,
103
+ ["timeout", "连通性测试超时 (sweeper: 60s 无 daemon ack)", row.probe_id],
104
+ );
105
+ pendingProbeSecrets.delete(row.probe_id);
106
+ swept++;
107
+ }
108
+ db.exec("COMMIT");
109
+ } catch (e: any) {
110
+ try { db.exec("ROLLBACK"); } catch { /* ok */ }
111
+ console.warn(`[probe] sweeper failed: ${e?.message || e}`);
112
+ return { swept: 0 };
113
+ }
114
+ if (swept > 0) console.log(`[commhub] ✓ probe sweeper: swept ${swept} stale probe(s)`);
115
+ return { swept };
116
+ }
117
+
118
+ export function startProbeSweeperTimer(): void {
119
+ if (_sweeperTimer) return;
120
+ try { runOrphanProbeSweepOnce(); }
121
+ catch (e: any) { console.warn(`[probe] boot sweeper failed: ${e?.message || e}`); }
122
+ _sweeperTimer = setInterval(() => {
123
+ try { runOrphanProbeSweepOnce(); }
124
+ catch (e: any) { console.warn(`[probe] periodic sweeper failed: ${e?.message || e}`); }
125
+ }, SWEEPER_INTERVAL_MS);
126
+ (_sweeperTimer as any).unref?.();
127
+ }
128
+
129
+ // ── Helpers ─────────────────────────────────────────────────────────
130
+
131
+ export function newProbeId(): string {
132
+ return `pr_${uuidv4()}`;
133
+ }
134
+
135
+ /** Hub-side: receive daemon ack, validate, redact, derive label, write
136
+ * final row. Returns the persisted row OR throws ProbeValidationError. */
137
+ export function finalizeProbeAck(
138
+ rawAck: unknown,
139
+ caller: { network_id: string; daemon_node_id: string },
140
+ ): { ok: boolean; error_label: string | null; status: string } {
141
+ // 1. Schema parse — rejects extra fields via .strict()
142
+ const ack = ProbeAckPayloadSchema.parse(rawAck);
143
+ // 2. Pull the row (existence + caller binding)
144
+ const row = db.get<{ probe_id: string; daemon_node_id: string; provider_id: string; status: string }>(
145
+ `SELECT probe_id, daemon_node_id, provider_id, status FROM probe_results WHERE probe_id = ?1`,
146
+ ack.probe_id,
147
+ );
148
+ if (!row) throw new ProbeValidationError("probe_not_found", { probe_id: ack.probe_id });
149
+ if (row.daemon_node_id !== caller.daemon_node_id) {
150
+ throw new ProbeValidationError("not_your_probe", { probe_id: ack.probe_id });
151
+ }
152
+ if (row.status !== "pending") {
153
+ throw new ProbeValidationError("probe_not_pending", { current_status: row.status });
154
+ }
155
+ // 3. Belt-and-suspenders secret-leak guard on full ack JSON
156
+ // (catches daemon impl bug stuffing secret into probe_id etc.)
157
+ try {
158
+ const providerRow = db.get<{ secret_key_ref: string }>(
159
+ `SELECT secret_key_ref FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
160
+ row.provider_id, caller.network_id,
161
+ );
162
+ if (providerRow) {
163
+ const secretVal = vaultGet(caller.network_id, providerRow.secret_key_ref);
164
+ if (secretVal) {
165
+ rejectIfSecretLeaked(JSON.stringify(ack), [secretVal]);
166
+ }
167
+ }
168
+ } catch (e) {
169
+ if (e instanceof ProbeValidationError && e.code === "ack_secret_leak") {
170
+ // audit-log this serious event (impl pattern below in tools.ts)
171
+ console.warn(`[probe] ack_secret_leak: daemon=${caller.daemon_node_id} probe=${ack.probe_id} reason=${JSON.stringify(e.detail)}`);
172
+ // mark row as failed for safety
173
+ db.run(
174
+ `UPDATE probe_results SET status = 'tls_error', error_label = ?1 WHERE probe_id = ?2 AND status = 'pending'`,
175
+ ["内部安全异常: daemon ack 含 secret 值, 已拒并 audit", ack.probe_id],
176
+ );
177
+ throw e;
178
+ }
179
+ // VaultError (missing key etc.) — non-fatal, skip leak guard but log
180
+ if (e instanceof VaultError) {
181
+ console.warn(`[probe] leak-guard skipped (vault read failed): ${e.message}`);
182
+ } else {
183
+ throw e;
184
+ }
185
+ }
186
+
187
+ // 4. Derive error_label from enum + raw_status_code
188
+ const label = deriveErrorLabel(ack);
189
+ // 5. Final write
190
+ db.run(
191
+ `UPDATE probe_results
192
+ SET status = ?1, latency_ms = ?2, raw_status_code = ?3, error_label = ?4
193
+ WHERE probe_id = ?5 AND status = 'pending'`,
194
+ [ack.status, ack.latency_ms, ack.raw_status_code ?? null, label, ack.probe_id],
195
+ );
196
+ return { ok: true, status: ack.status, error_label: label };
197
+ }
198
+
199
+ // Re-export validate funcs for tools.ts convenience
200
+ export { validateBaseUrl, ProbeValidationError };