@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21

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,221 @@
1
+ // RFC-028 P1 §4.4 probe-validate tests — pure unit, no I/O.
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import {
5
+ validateBaseUrl, isForbiddenIp, isLoopbackHost, assertSecureTlsEnv,
6
+ ProbeAckPayloadSchema, deriveErrorLabel, rejectIfSecretLeaked,
7
+ VENDOR_HOST_ALLOWLIST, SUPPORTED_VENDORS, ProbeValidationError,
8
+ } from "./probe-validate.js";
9
+
10
+ describe("validateBaseUrl (§4.4.1 vendor host allowlist)", () => {
11
+ test("anthropic + api.anthropic.com → ok", () => {
12
+ expect(() => validateBaseUrl("anthropic", "https://api.anthropic.com/v1")).not.toThrow();
13
+ });
14
+ test("anthropic + api.openai.com → probe_target_forbidden", () => {
15
+ expect(() => validateBaseUrl("anthropic", "https://api.openai.com/v1")).toThrow(/probe_target_forbidden/);
16
+ });
17
+ test("anthropic + 169.254.169.254 (metadata) → probe_target_forbidden (host不在 allowlist)", () => {
18
+ expect(() => validateBaseUrl("anthropic", "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
19
+ });
20
+ test("unsupported vendor → vendor_not_supported", () => {
21
+ expect(() => validateBaseUrl("openai", "https://api.openai.com/v1")).toThrow(/vendor_not_supported/);
22
+ });
23
+ test("non-https → probe_base_url_invalid", () => {
24
+ expect(() => validateBaseUrl("anthropic", "http://api.anthropic.com/v1")).toThrow(/probe_base_url_invalid/);
25
+ });
26
+ test("http+loopback OK with allowLoopback opt-in", () => {
27
+ expect(() => validateBaseUrl("anthropic", "http://localhost:8080/v1", { allowLoopback: true }))
28
+ .toThrow(/probe_target_forbidden/); // 但 host 还是不在 allowlist, allowLoopback 只放过 protocol
29
+ });
30
+ test("malformed URL → probe_base_url_invalid", () => {
31
+ expect(() => validateBaseUrl("anthropic", "not a url")).toThrow(/probe_base_url_invalid/);
32
+ });
33
+ test("P1 SUPPORTED_VENDORS only contains anthropic", () => {
34
+ expect(SUPPORTED_VENDORS).toEqual(["anthropic"]);
35
+ });
36
+
37
+ // ── RFC-028 P1.5+ — Anthropic-compatible 3rd-party hosts on `anthropic` vendor.
38
+ // Whitelist semantics preserved; new entries require PR + security review.
39
+ // Host allowlist passes here; IP-level guard still fires at fetch time
40
+ // (covered by safelyFetchProbe + docker scenarios qa-rfc028 F + M.ssrf).
41
+ test("anthropic + api.deepseek.com → ok (Anthropic-compatible /v1/messages)", () => {
42
+ expect(() => validateBaseUrl("anthropic", "https://api.deepseek.com/v1")).not.toThrow();
43
+ });
44
+ test("anthropic + api.minimax.chat → ok (MiniMax legacy domain)", () => {
45
+ expect(() => validateBaseUrl("anthropic", "https://api.minimax.chat/v1")).not.toThrow();
46
+ });
47
+ test("anthropic + api.minimax.io → ok (MiniMax new domain, Vincent live)", () => {
48
+ expect(() => validateBaseUrl("anthropic", "https://api.minimax.io/v1")).not.toThrow();
49
+ });
50
+ test("subdomain of allowed host NOT accepted (regex anchored)", () => {
51
+ expect(() => validateBaseUrl("anthropic", "https://attacker.api.deepseek.com/v1")).toThrow(/probe_target_forbidden/);
52
+ expect(() => validateBaseUrl("anthropic", "https://api.minimax.io.attacker.example/v1")).toThrow(/probe_target_forbidden/);
53
+ });
54
+ test("homograph / look-alike host rejected (regex anchored)", () => {
55
+ expect(() => validateBaseUrl("anthropic", "https://api.deepseek.com.attacker/v1")).toThrow(/probe_target_forbidden/);
56
+ expect(() => validateBaseUrl("anthropic", "https://api-deepseek.com/v1")).toThrow(/probe_target_forbidden/);
57
+ });
58
+ test("VENDOR_HOST_ALLOWLIST.anthropic curated list = exactly 4 entries", () => {
59
+ // Lock the curated set — adding a 5th host MUST go through a PR
60
+ // bump this assertion (forces conscious security review).
61
+ expect(VENDOR_HOST_ALLOWLIST.anthropic.length).toBe(4);
62
+ const sources = VENDOR_HOST_ALLOWLIST.anthropic.map(r => r.source).sort();
63
+ expect(sources).toEqual([
64
+ "^api\\.anthropic\\.com$",
65
+ "^api\\.deepseek\\.com$",
66
+ "^api\\.minimax\\.chat$",
67
+ "^api\\.minimax\\.io$",
68
+ ]);
69
+ });
70
+ });
71
+
72
+ describe("isForbiddenIp (§4.4.2 private/reserved IP block — anti SSRF)", () => {
73
+ test("blocks IPv4 private + metadata + CGNAT", () => {
74
+ for (const ip of [
75
+ "10.0.0.1", "10.255.255.255",
76
+ "172.16.0.1", "172.31.255.255",
77
+ "192.168.1.1",
78
+ "127.0.0.1", "127.255.255.255",
79
+ "169.254.169.254", // cloud metadata
80
+ "169.254.0.0",
81
+ "100.64.0.1", "100.127.255.254", // CGNAT
82
+ "0.0.0.0",
83
+ "224.0.0.1", // multicast
84
+ "240.0.0.1", "255.255.255.255", // experimental/broadcast
85
+ ]) {
86
+ expect(isForbiddenIp(ip)).toBe(true);
87
+ }
88
+ });
89
+ test("allows public IPv4", () => {
90
+ for (const ip of ["1.1.1.1", "8.8.8.8", "104.16.0.1", "172.32.0.1", "172.15.255.254"]) {
91
+ expect(isForbiddenIp(ip)).toBe(false);
92
+ }
93
+ });
94
+ test("blocks IPv6 loopback + link-local + ULA", () => {
95
+ for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd00::1"]) {
96
+ expect(isForbiddenIp(ip)).toBe(true);
97
+ }
98
+ });
99
+ test("blocks IPv4-mapped IPv6 (anti绕)", () => {
100
+ expect(isForbiddenIp("::ffff:10.0.0.1")).toBe(true);
101
+ expect(isForbiddenIp("::ffff:169.254.169.254")).toBe(true);
102
+ expect(isForbiddenIp("::FFFF:127.0.0.1")).toBe(true);
103
+ });
104
+ test("allows public IPv6", () => {
105
+ expect(isForbiddenIp("2606:4700:4700::1111")).toBe(false);
106
+ expect(isForbiddenIp("2001:4860:4860::8888")).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe("isLoopbackHost", () => {
111
+ test("recognizes localhost/127.0.0.1/::1", () => {
112
+ expect(isLoopbackHost("localhost")).toBe(true);
113
+ expect(isLoopbackHost("127.0.0.1")).toBe(true);
114
+ expect(isLoopbackHost("::1")).toBe(true);
115
+ expect(isLoopbackHost("api.example.com")).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("assertSecureTlsEnv (§4.4 boot guard)", () => {
120
+ test("clean env passes", () => {
121
+ expect(() => assertSecureTlsEnv({})).not.toThrow();
122
+ });
123
+ test("NODE_TLS_REJECT_UNAUTHORIZED=0 throws probe_tls_insecure_disabled", () => {
124
+ expect(() => assertSecureTlsEnv({ NODE_TLS_REJECT_UNAUTHORIZED: "0" }))
125
+ .toThrow(/probe_tls_insecure_disabled/);
126
+ });
127
+ test("NODE_TLS_REJECT_UNAUTHORIZED=1 passes (default safe)", () => {
128
+ expect(() => assertSecureTlsEnv({ NODE_TLS_REJECT_UNAUTHORIZED: "1" })).not.toThrow();
129
+ });
130
+ });
131
+
132
+ describe("ProbeAckPayloadSchema (§4.4.4 v3 R3 strict whitelist)", () => {
133
+ test("minimal valid payload parses", () => {
134
+ const ok = { probe_id: "p_1", status: "ok", latency_ms: 180 };
135
+ expect(ProbeAckPayloadSchema.parse(ok)).toEqual(ok);
136
+ });
137
+ test("with raw_status_code parses", () => {
138
+ const r = ProbeAckPayloadSchema.parse({ probe_id: "p_2", status: "auth_fail", raw_status_code: 401, latency_ms: 50 });
139
+ expect(r.raw_status_code).toBe(401);
140
+ });
141
+ test("REJECTS extra error_message field (zod .strict, v3 R3 attacker daemon catch)", () => {
142
+ expect(() => ProbeAckPayloadSchema.parse({
143
+ probe_id: "p_3", status: "auth_fail", latency_ms: 50,
144
+ error_message: "Invalid API key: sk-ant-abc", // ← attacker smuggling
145
+ })).toThrow();
146
+ });
147
+ test("REJECTS extra response_body / response_headers / url / vendor_text", () => {
148
+ for (const extra of [
149
+ { response_body: "..." },
150
+ { response_headers: { foo: "bar" } },
151
+ { url: "https://leak" },
152
+ { vendor_text: "leak" },
153
+ { error: "leak" },
154
+ { detail: "leak" },
155
+ ]) {
156
+ expect(() => ProbeAckPayloadSchema.parse({
157
+ probe_id: "p", status: "ok", latency_ms: 1,
158
+ ...extra,
159
+ })).toThrow();
160
+ }
161
+ });
162
+ test("REJECTS bad enum status", () => {
163
+ expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "bogus", latency_ms: 1 })).toThrow();
164
+ });
165
+ test("REJECTS latency_ms > 60_000 or negative", () => {
166
+ expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "ok", latency_ms: 60_001 })).toThrow();
167
+ expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "ok", latency_ms: -1 })).toThrow();
168
+ });
169
+ });
170
+
171
+ describe("deriveErrorLabel (hub-only, daemon CANNOT submit)", () => {
172
+ test("ok → null", () => {
173
+ expect(deriveErrorLabel({ probe_id: "p", status: "ok", latency_ms: 10 })).toBeNull();
174
+ });
175
+ test("auth_fail with code → label includes HTTP code", () => {
176
+ expect(deriveErrorLabel({ probe_id: "p", status: "auth_fail", raw_status_code: 401, latency_ms: 10 }))
177
+ .toContain("401");
178
+ });
179
+ test("every enum has a label or null", () => {
180
+ for (const status of ["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error"] as const) {
181
+ const r = deriveErrorLabel({ probe_id: "p", status, latency_ms: 1 });
182
+ expect(typeof r === "string" || r === null).toBe(true);
183
+ }
184
+ });
185
+ });
186
+
187
+ describe("rejectIfSecretLeaked (§4.4.4 belt-and-suspenders)", () => {
188
+ test("clean ack passes", () => {
189
+ expect(() => rejectIfSecretLeaked(
190
+ JSON.stringify({ probe_id: "p", status: "ok" }),
191
+ ["sk-very-long-secret-1234567890"],
192
+ )).not.toThrow();
193
+ });
194
+ test("ack containing raw secret → ack_secret_leak (plain)", () => {
195
+ expect(() => rejectIfSecretLeaked(
196
+ JSON.stringify({ probe_id: "p", status: "fail", x: "sk-very-long-secret-1234567890" }),
197
+ ["sk-very-long-secret-1234567890"],
198
+ )).toThrow(/ack_secret_leak/);
199
+ });
200
+ test("ack containing URL-encoded secret → ack_secret_leak (url_encoded)", () => {
201
+ const secret = "sk/with+special=chars/here-long-enough";
202
+ const enc = encodeURIComponent(secret);
203
+ expect(() => rejectIfSecretLeaked(
204
+ JSON.stringify({ probe_id: "p", url: `https://x/?key=${enc}` }),
205
+ [secret],
206
+ )).toThrow(/ack_secret_leak/);
207
+ });
208
+ test("ack containing 12-char substring of long secret → ack_secret_leak (substring_12)", () => {
209
+ const secret = "sk-ant-api03-very-long-key-1234567890-abcdef";
210
+ expect(() => rejectIfSecretLeaked(
211
+ JSON.stringify({ probe_id: "p", x: "leak prefix sk-ant-api03 here" }),
212
+ [secret],
213
+ )).toThrow(/ack_secret_leak/);
214
+ });
215
+ test("short secret (<8) not checked (avoid false positive on tiny tokens)", () => {
216
+ expect(() => rejectIfSecretLeaked(
217
+ JSON.stringify({ probe_id: "p", x: "test" }),
218
+ ["test"],
219
+ )).not.toThrow();
220
+ });
221
+ });
@@ -0,0 +1,104 @@
1
+ // RFC-028 P1 §4.4 — probe path validators (pure, no I/O).
2
+ // Host allowlist + IP block + validateBaseUrl extracted into
3
+ // shared/probe-host-allowlist.ts (mirror of agent-node/src/shared/, G9
4
+ // drift guard enforces byte-identical). This module re-exports them so
5
+ // existing hub call sites keep working, and adds hub-only bits:
6
+ // PROBE_STATUS_ENUM + ProbeAckPayloadSchema + deriveErrorLabel +
7
+ // rejectIfSecretLeaked + assertSecureTlsEnv.
8
+
9
+ import { z } from "zod/v4";
10
+ export {
11
+ VENDOR_HOST_ALLOWLIST,
12
+ SUPPORTED_VENDORS,
13
+ ProbeValidationError,
14
+ validateBaseUrl,
15
+ isLoopbackHost,
16
+ isForbiddenIp,
17
+ } from "./shared/probe-host-allowlist.js";
18
+ import { ProbeValidationError } from "./shared/probe-host-allowlist.js";
19
+
20
+ // ── §4.4.5 boot-time TLS env guard (assert before any probe runs) ──
21
+
22
+ export function assertSecureTlsEnv(env: NodeJS.ProcessEnv = process.env): void {
23
+ if (env.NODE_TLS_REJECT_UNAUTHORIZED === "0") {
24
+ throw new ProbeValidationError("probe_tls_insecure_disabled", {
25
+ reason: "NODE_TLS_REJECT_UNAUTHORIZED=0 forbidden — TLS cert validation must be ON for SSRF defense",
26
+ });
27
+ }
28
+ }
29
+
30
+ // ── §4.4.4 ProbeAckPayload — STRICT whitelist (v3 R3) ─────────────
31
+ // Daemon ack returns ONLY these fields. Any extra string is dropped
32
+ // at the zod parse boundary. Hub additionally runs rejectIfSecretLeaked
33
+ // (defense in depth).
34
+
35
+ export const PROBE_STATUS_ENUM = [
36
+ "ok",
37
+ "auth_fail",
38
+ "quota",
39
+ "rate_limit",
40
+ "network_error",
41
+ "timeout",
42
+ "redirect_forbidden",
43
+ "vendor_5xx",
44
+ "other_4xx",
45
+ "tls_error",
46
+ // SSRF guards surface their own statuses so dashboard can show the
47
+ // exact reason; deriveErrorLabel maps to UI text.
48
+ "probe_resolve_unsafe_ip",
49
+ "probe_target_forbidden",
50
+ ] as const;
51
+ export type ProbeStatus = typeof PROBE_STATUS_ENUM[number];
52
+
53
+ export const ProbeAckPayloadSchema = z.object({
54
+ probe_id: z.string().min(1).max(200),
55
+ status: z.enum(PROBE_STATUS_ENUM),
56
+ raw_status_code: z.number().int().min(100).max(599).optional(),
57
+ latency_ms: z.number().int().min(0).max(60_000),
58
+ }).strict(); // .strict() rejects ANY extra field — error_message / response_body / etc smuggled by attacker daemon
59
+ export type ProbeAckPayload = z.infer<typeof ProbeAckPayloadSchema>;
60
+
61
+ /** Hub-side: map (status, raw_status_code) → human label.
62
+ * daemon NEVER submits this; hub computes from enum + numeric. */
63
+ export function deriveErrorLabel(ack: ProbeAckPayload): string | null {
64
+ switch (ack.status) {
65
+ case "ok": return null;
66
+ case "auth_fail": return `API key 校验失败 (HTTP ${ack.raw_status_code ?? "?"})`;
67
+ case "quota": return "API 额度用尽 (429)";
68
+ case "rate_limit": return "我方 rate limit (60req/min/provider) 触发";
69
+ case "network_error": return "网络不可达 (connect/DNS fail)";
70
+ case "timeout": return "连通性测试超时 (>30s)";
71
+ case "redirect_forbidden": return "vendor 返回 30x redirect, P1 一律拒";
72
+ case "vendor_5xx": return `vendor 服务端错 (HTTP ${ack.raw_status_code ?? "5xx"})`;
73
+ case "other_4xx": return `vendor 客户端错 (HTTP ${ack.raw_status_code ?? "4xx"})`;
74
+ case "tls_error": return "TLS 证书校验失败";
75
+ case "probe_resolve_unsafe_ip": return "SSRF 拒: base_url 解析到禁用 IP 段 (私网/metadata)";
76
+ case "probe_target_forbidden": return "SSRF 拒: base_url host 不在 vendor allowlist";
77
+ }
78
+ }
79
+
80
+ // ── §4.4.4 hub-side belt: reject if daemon ack contains a secret ──
81
+ // Even though daemon ack schema (zod .strict()) blocks string fields,
82
+ // this guard catches a daemon impl bug that smuggles secrets via
83
+ // some other means (e.g. probe_id stuffed with key value).
84
+ // Checks plaintext + URL-encoded + 12-char sliding window substring.
85
+
86
+ export function rejectIfSecretLeaked(ackJson: string, knownSecrets: ReadonlyArray<string>): void {
87
+ for (const s of knownSecrets) {
88
+ if (!s || s.length < 8) continue; // avoid false positive on tiny tokens
89
+ if (ackJson.includes(s)) {
90
+ throw new ProbeValidationError("ack_secret_leak", { reason: "plain", len: s.length });
91
+ }
92
+ const enc = encodeURIComponent(s);
93
+ if (enc !== s && ackJson.includes(enc)) {
94
+ throw new ProbeValidationError("ack_secret_leak", { reason: "url_encoded", len: s.length });
95
+ }
96
+ if (s.length >= 16) {
97
+ for (let i = 0; i <= s.length - 12; i++) {
98
+ if (ackJson.includes(s.slice(i, i + 12))) {
99
+ throw new ProbeValidationError("ack_secret_leak", { reason: "substring_12", offset: i });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
@@ -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 };
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ VENDOR_HOST_ALLOWLIST as HUB_ALLOW,
6
+ FORBIDDEN_IPV4_RE as HUB_V4,
7
+ FORBIDDEN_IPV6_RE as HUB_V6,
8
+ } from "./probe-host-allowlist.js";
9
+
10
+ // RFC-028 P1 fold-in #1 — hub-and-daemon probe-host-allowlist drift
11
+ // guard. Mirrors the G9 reserved-env drift test (RFC-026 §4.4.7).
12
+ // Two enforcement modes:
13
+ // 1. byte-identical source files
14
+ // 2. runtime set-equality of the imported constants (vendor regex
15
+ // source strings + private-IP regex source strings)
16
+ // If a future contributor edits one side without the other, this test
17
+ // breaks the merge — the daemon-side compromised-hub defense depends
18
+ // on the two lists being identical.
19
+
20
+ const HUB_PATH = join(import.meta.dir, "probe-host-allowlist.ts");
21
+ const DAEMON_PATH = join(
22
+ import.meta.dir,
23
+ "..", "..", "..", "agent-node", "src", "shared", "probe-host-allowlist.ts",
24
+ );
25
+
26
+ describe("RFC-028 P1 — hub/daemon probe-host-allowlist drift guard", () => {
27
+ test("byte-identical source files", () => {
28
+ const hub = readFileSync(HUB_PATH, "utf-8");
29
+ const daemon = readFileSync(DAEMON_PATH, "utf-8");
30
+ expect(daemon).toBe(hub);
31
+ });
32
+
33
+ test("VENDOR_HOST_ALLOWLIST regex sources equal at runtime", async () => {
34
+ const daemonMod = await import(DAEMON_PATH);
35
+ const daemonAllow = daemonMod.VENDOR_HOST_ALLOWLIST as Record<string, ReadonlyArray<RegExp>>;
36
+ // vendor set equality
37
+ expect(new Set(Object.keys(daemonAllow))).toEqual(new Set(Object.keys(HUB_ALLOW)));
38
+ // per-vendor regex source array equality (order matters — both should
39
+ // be authored in identical order to keep diffs minimal)
40
+ for (const vendor of Object.keys(HUB_ALLOW)) {
41
+ const hubSources = HUB_ALLOW[vendor].map(r => r.source);
42
+ const daemonSources = daemonAllow[vendor].map(r => r.source);
43
+ expect(daemonSources).toEqual(hubSources);
44
+ }
45
+ });
46
+
47
+ test("FORBIDDEN_IPV4_RE + FORBIDDEN_IPV6_RE sources equal at runtime", async () => {
48
+ const daemonMod = await import(DAEMON_PATH);
49
+ const daemonV4 = (daemonMod.FORBIDDEN_IPV4_RE as ReadonlyArray<RegExp>).map(r => r.source);
50
+ const daemonV6 = (daemonMod.FORBIDDEN_IPV6_RE as ReadonlyArray<RegExp>).map(r => r.source);
51
+ expect(daemonV4).toEqual(HUB_V4.map(r => r.source));
52
+ expect(daemonV6).toEqual(HUB_V6.map(r => r.source));
53
+ });
54
+ });