@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.4",
3
+ "version": "0.9.0-preview.6",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,8 +29,23 @@ afterAll(() => {
29
29
  try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
30
30
  });
31
31
 
32
+ // Helper: mirror handler's role-extraction pass to mimic the production
33
+ // response shape exactly. Keeps the test the source of truth for the
34
+ // contract; if the handler changes, the assertions still lock what
35
+ // the dashboard / external clients see.
36
+ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
37
+ let role: string | null = null;
38
+ const snap = r.config_snapshot;
39
+ if (snap) {
40
+ try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
41
+ catch { /* malformed */ }
42
+ }
43
+ const { config_snapshot, ...rest } = r;
44
+ return { ...rest, role };
45
+ }
46
+
32
47
  describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
33
- test("explicit columns: internal config_revision/config_snapshot NOT in response", () => {
48
+ test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
34
49
  // Insert a row with the leak-prone columns populated. Use INSERT OR
35
50
  // REPLACE so concurrent test runs don't trip on PRIMARY KEY.
36
51
  db.run(
@@ -43,38 +58,101 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
43
58
  )`,
44
59
  [TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
45
60
  "/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
46
- 7, JSON.stringify({ model: "claude-opus", flags: { x: true } })],
61
+ 7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
47
62
  );
48
63
 
49
- // Run the same SQL the handler runs (mirror; if handler changes, this
50
- // assertion still locks the shape).
51
- const rows = db.all<Record<string, unknown>>(
64
+ // Mirror handler SQL + mapping
65
+ const raw = db.all<Record<string, unknown>>(
52
66
  `SELECT node_id, node_name, alias, runtime, model,
53
67
  config_path, channels, server, hostname,
54
- network_id, created_at, updated_at
68
+ network_id, created_at, updated_at,
69
+ config_snapshot
55
70
  FROM nodes WHERE node_id = ?1`,
56
71
  TEST_NODE,
57
72
  );
73
+ const rows = raw.map(mapRow);
58
74
 
59
75
  expect(rows).toHaveLength(1);
60
76
  const row = rows[0]!;
61
- // Expected fields present
77
+ // Expected fields present (13 = 12 contract + new role)
62
78
  for (const k of ["node_id", "node_name", "alias", "runtime", "model",
63
79
  "config_path", "channels", "server", "hostname",
64
- "network_id", "created_at", "updated_at"]) {
80
+ "network_id", "created_at", "updated_at", "role"]) {
65
81
  expect(row).toHaveProperty(k);
66
82
  }
67
- // Internal fields NOT present (these are the leak the test guards against)
83
+ // Internal fields NOT present
68
84
  expect(row).not.toHaveProperty("config_revision");
69
85
  expect(row).not.toHaveProperty("config_snapshot");
70
- // Belt: full key set is exactly the 12 contract fields
86
+ // role is EXTRACTED from config_snapshot JSON, not the raw blob
87
+ expect(row.role).toBe("host_supervisor");
88
+ // Belt: full key set is exactly the 13 contract fields
71
89
  expect(Object.keys(row).sort()).toEqual([
72
90
  "alias", "channels", "config_path", "created_at", "hostname",
73
- "model", "network_id", "node_id", "node_name", "runtime",
91
+ "model", "network_id", "node_id", "node_name", "role", "runtime",
74
92
  "server", "updated_at",
75
93
  ]);
76
94
  });
77
95
 
96
+ test("role is null when config_snapshot is empty / missing / malformed", () => {
97
+ // Empty snapshot → null
98
+ db.run(
99
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
100
+ VALUES (?1, ?2, ?3, ?4, NULL)`,
101
+ [TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
102
+ );
103
+ let row = mapRow(db.get<Record<string, unknown>>(
104
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
105
+ server, hostname, network_id, created_at, updated_at, config_snapshot
106
+ FROM nodes WHERE node_id = ?1`,
107
+ TEST_NODE,
108
+ )!);
109
+ expect(row.role).toBeNull();
110
+ expect(row).not.toHaveProperty("config_snapshot");
111
+
112
+ // Malformed JSON → null (handler doesn't throw, just leaves role null)
113
+ db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
114
+ row = mapRow(db.get<Record<string, unknown>>(
115
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
116
+ server, hostname, network_id, created_at, updated_at, config_snapshot
117
+ FROM nodes WHERE node_id = ?1`,
118
+ TEST_NODE,
119
+ )!);
120
+ expect(row.role).toBeNull();
121
+
122
+ // Snapshot without role key → null
123
+ db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
124
+ [JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
125
+ row = mapRow(db.get<Record<string, unknown>>(
126
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
127
+ server, hostname, network_id, created_at, updated_at, config_snapshot
128
+ FROM nodes WHERE node_id = ?1`,
129
+ TEST_NODE,
130
+ )!);
131
+ expect(row.role).toBeNull();
132
+ });
133
+
134
+ test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
135
+ // Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
136
+ db.run(
137
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
138
+ VALUES (?1, ?2, ?3, ?4, ?5)`,
139
+ [TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
140
+ JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
141
+ );
142
+ const rows = db.all<Record<string, unknown>>(
143
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
144
+ server, hostname, network_id, created_at, updated_at, config_snapshot
145
+ FROM nodes WHERE network_id = ?1`,
146
+ TEST_NETWORK,
147
+ ).map(mapRow);
148
+ // Dashboard's lookup: find first node with role==='host_supervisor'
149
+ const daemon = rows.find(r => r.role === "host_supervisor");
150
+ expect(daemon).toBeTruthy();
151
+ expect(daemon!.node_id).toBe(TEST_NODE);
152
+ // Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
153
+ expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
154
+ });
155
+
78
156
  test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
79
157
  db.run(
80
158
  `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
package/src/db.ts CHANGED
@@ -436,6 +436,76 @@ try {
436
436
  console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
437
437
  }
438
438
 
439
+ // RFC-028 P1 v4 §2.2 — providers + provider_models + network_secrets +
440
+ // probe_results. All 4 tables idempotent CREATE TABLE IF NOT EXISTS. F2
441
+ // lazy gate: tables created on hub boot regardless; vault master key
442
+ // (ANET_HUB_SECRET_VAULT_KEY env) only required when these tables get
443
+ // their first non-empty row (lazy in vault.ts). Empty tables = hub
444
+ // boots fine without env (critical for existing prod hub升级 不 brick).
445
+ db.exec(`
446
+ CREATE TABLE IF NOT EXISTS providers (
447
+ provider_id TEXT PRIMARY KEY,
448
+ network_id TEXT NOT NULL,
449
+ name TEXT NOT NULL,
450
+ vendor TEXT NOT NULL,
451
+ base_url TEXT NOT NULL,
452
+ secret_key_ref TEXT NOT NULL,
453
+ created_at INTEGER NOT NULL,
454
+ created_by TEXT NOT NULL,
455
+ enabled INTEGER NOT NULL DEFAULT 1,
456
+ UNIQUE(network_id, name)
457
+ );
458
+ CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
459
+ `);
460
+ db.exec(`
461
+ CREATE TABLE IF NOT EXISTS provider_models (
462
+ model_id TEXT PRIMARY KEY,
463
+ provider_id TEXT NOT NULL,
464
+ model_name TEXT NOT NULL,
465
+ display_name TEXT,
466
+ context_window INTEGER,
467
+ supports_vision INTEGER NOT NULL DEFAULT 0,
468
+ enabled INTEGER NOT NULL DEFAULT 1,
469
+ created_at INTEGER NOT NULL,
470
+ UNIQUE(provider_id, model_name)
471
+ );
472
+ CREATE INDEX IF NOT EXISTS idx_models_provider ON provider_models(provider_id);
473
+ `);
474
+ // network_secrets — encrypted-at-rest vault. ciphertext + iv + tag are
475
+ // AES-GCM output; plaintext NEVER touches this table. master key from
476
+ // ANET_HUB_SECRET_VAULT_KEY env (lazy gate in vault.ts).
477
+ db.exec(`
478
+ CREATE TABLE IF NOT EXISTS network_secrets (
479
+ network_id TEXT NOT NULL,
480
+ key TEXT NOT NULL,
481
+ ciphertext BLOB NOT NULL,
482
+ iv BLOB NOT NULL,
483
+ tag BLOB NOT NULL,
484
+ created_at INTEGER NOT NULL,
485
+ updated_at INTEGER NOT NULL,
486
+ PRIMARY KEY (network_id, key)
487
+ );
488
+ `);
489
+ // probe_results — connectivity matrix history. v3 R3 lock: error_label
490
+ // is HUB-DERIVED ONLY from (status, raw_status_code) — daemon CANNOT
491
+ // submit arbitrary error string. status enum mirrors ProbeAckPayload.
492
+ db.exec(`
493
+ CREATE TABLE IF NOT EXISTS probe_results (
494
+ probe_id TEXT PRIMARY KEY,
495
+ provider_id TEXT NOT NULL,
496
+ model_name TEXT NOT NULL,
497
+ daemon_node_id TEXT NOT NULL,
498
+ network_id TEXT NOT NULL,
499
+ status TEXT NOT NULL,
500
+ latency_ms INTEGER,
501
+ error_label TEXT,
502
+ probed_at INTEGER NOT NULL,
503
+ probed_by_user TEXT,
504
+ raw_status_code INTEGER
505
+ );
506
+ CREATE INDEX IF NOT EXISTS idx_probe_matrix ON probe_results(network_id, provider_id, model_name, daemon_node_id, probed_at DESC);
507
+ `);
508
+
439
509
  // ── V3: audit_log table ──
440
510
  db.exec(`
441
511
  CREATE TABLE IF NOT EXISTS audit_log (
package/src/index.ts CHANGED
@@ -1965,16 +1965,37 @@ Bun.serve({
1965
1965
  if (url.pathname === "/api/nodes") {
1966
1966
  const nodeId = url.searchParams.get("node_id");
1967
1967
  const alias = url.searchParams.get("alias");
1968
+ // `config_snapshot` SELECTed ONLY to extract the daemon `role` field
1969
+ // for dashboard discovery — full snapshot is NOT broadcast (would
1970
+ // re-introduce the #312 SELECT * fragility). Mapping step pulls
1971
+ // `role` out then drops `config_snapshot` from the response row.
1968
1972
  let sql = `SELECT node_id, node_name, alias, runtime, model,
1969
1973
  config_path, channels, server, hostname,
1970
- network_id, created_at, updated_at
1974
+ network_id, created_at, updated_at,
1975
+ config_snapshot
1971
1976
  FROM nodes WHERE 1=1`;
1972
1977
  const params: any[] = [];
1973
1978
  sql = addNetworkScope(sql, params, restScope);
1974
1979
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
1975
1980
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
1976
1981
  sql += " ORDER BY updated_at DESC";
1977
- const rows = db.all(sql, ...params);
1982
+ const rawRows = db.all<Record<string, any>>(sql, ...params);
1983
+ // Add a single new field `role: string | null` per row, extracted from
1984
+ // config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
1985
+ // via report_status surface here; dashboard uses this for daemon
1986
+ // discovery (replacing the prior `node_daemon_` prefix heuristic).
1987
+ // RFC-026 P1 P2 §9 will supersede this REST proxy with the
1988
+ // `list_host_supervisors` MCP tool.
1989
+ const rows = rawRows.map(r => {
1990
+ let role: string | null = null;
1991
+ const snap = r.config_snapshot;
1992
+ if (snap) {
1993
+ try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
1994
+ catch { /* malformed snapshot — leave role null */ }
1995
+ }
1996
+ const { config_snapshot, ...rest } = r;
1997
+ return { ...rest, role };
1998
+ });
1978
1999
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
1979
2000
  }
1980
2001
 
@@ -2185,3 +2206,22 @@ console.log(`
2185
2206
  ║ Health: http://${HOST}:${PORT}/health ║
2186
2207
  ╚══════════════════════════════════════════════════╝
2187
2208
  `);
2209
+
2210
+ // RFC-028 P1 boot banner — vault key configuration status.
2211
+ // F2 invariant: hub MUST boot regardless of vault state. This is
2212
+ // banner-only (informational); errors are raised lazily at vault op
2213
+ // time. needsKeyToOp=true means the operator should set
2214
+ // ANET_HUB_SECRET_VAULT_KEY before vault/provider features will work.
2215
+ try {
2216
+ const { vaultStatusForBoot } = await import("./vault.js");
2217
+ const s = vaultStatusForBoot();
2218
+ if (s.needsKeyToOp) {
2219
+ console.warn("[rfc-028 vault] ⚠️ network_secrets/providers have data BUT ANET_HUB_SECRET_VAULT_KEY is unset — vault ops will throw vault_master_key_missing until you set the env. Generate one: `openssl rand -hex 32` (must match the key used at write time).");
2220
+ } else if (s.configured) {
2221
+ console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
2222
+ } else {
2223
+ console.log("[rfc-028 vault] master key not set + no vault rows — vault gating is lazy; boot OK. Set ANET_HUB_SECRET_VAULT_KEY when enabling provider/secret features.");
2224
+ }
2225
+ } catch (e: any) {
2226
+ console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
2227
+ }
@@ -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
+ }