@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.5

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 };
@@ -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
+ });
@@ -0,0 +1,128 @@
1
+ // RFC-028 P1 §4.4 — shared probe SSRF guards.
2
+ // Host allowlist + private IP block + validateBaseUrl.
3
+ //
4
+ // MUST be byte-identical between hub (server/src/shared/) and daemon
5
+ // (agent-node/src/shared/). Enforced by probe-host-allowlist-drift.test.ts
6
+ // (same pattern as reserved-env.ts G9 drift guard, RFC-026 §4.4.7).
7
+ //
8
+ // Why mirror not symlink: hub and daemon are separate npm packages
9
+ // installed independently on different machines. A drift-checked source
10
+ // copy survives `npm pack` + global install; a symlink does not.
11
+ //
12
+ // Threat model the daemon re-check defends against:
13
+ // - Compromised hub records a malicious base_url in providers/spec
14
+ // - Hub bypassed validateBaseUrl (regression / different code path)
15
+ // - On-wire injection between hub→daemon SSE
16
+ // Daemon trusts process.execPath + this file's content, NOT what
17
+ // `get_probe_request` claims. If hub-side check is identical, daemon
18
+ // re-check is a no-op; if hub-side ever loosens, daemon stays strict.
19
+
20
+ export class ProbeValidationError extends Error {
21
+ constructor(public code: string, public detail?: Record<string, unknown>) {
22
+ super(`${code}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
23
+ this.name = "ProbeValidationError";
24
+ }
25
+ }
26
+
27
+ // ── §4.4.1 per-vendor host allowlist ──────────────────────────────
28
+ // `anthropic` vendor accepts Anthropic + curated 3rd-party Anthropic-
29
+ // compatible endpoints that Vincent / N站马 ship live providers
30
+ // against (DeepSeek + MiniMax both expose `/v1/messages` with the
31
+ // Anthropic protocol surface). Whitelist semantics preserved — NOT
32
+ // allow-any. New entries land here via PR with security review
33
+ // (network-admin-configurable allowlist is backlog: needs strong
34
+ // IP-range / shape guard before admins can edit).
35
+ //
36
+ // Per 通信龙 (RFC-028 P1.5+): the host whitelist is defense layer 1;
37
+ // layer 2 is the DNS-resolved IP block (FORBIDDEN_IPV{4,6}_RE below),
38
+ // applied AFTER allowlist passes. Any new host gets BOTH layers
39
+ // automatically — host in allowlist + DNS resolves to private/metadata
40
+ // IP still surfaces `probe_resolve_unsafe_ip`.
41
+ //
42
+ // P2 will add vendors (openai/zai/openrouter/qwen) as separate keys.
43
+ export const VENDOR_HOST_ALLOWLIST: Record<string, ReadonlyArray<RegExp>> = {
44
+ anthropic: [
45
+ /^api\.anthropic\.com$/,
46
+ /^api\.deepseek\.com$/, // Anthropic-compatible /v1/messages
47
+ /^api\.minimax\.chat$/, // MiniMax legacy domain
48
+ /^api\.minimax\.io$/, // MiniMax new domain (Vincent live use)
49
+ ],
50
+ // P2:
51
+ // openai: [/^api\.openai\.com$/],
52
+ // zai: [/^api\.z\.ai$/, /^open\.bigmodel\.cn$/],
53
+ // openrouter: [/^openrouter\.ai$/],
54
+ // qwen: [/^dashscope(-intl)?\.aliyuncs\.com$/],
55
+ };
56
+ export const SUPPORTED_VENDORS = Object.keys(VENDOR_HOST_ALLOWLIST);
57
+
58
+ // ── §4.4.2 private/reserved IP block (anti SSRF) ──────────────────
59
+ export const FORBIDDEN_IPV4_RE: ReadonlyArray<RegExp> = [
60
+ /^10\./,
61
+ /^127\./,
62
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
63
+ /^192\.168\./,
64
+ /^169\.254\./, // link-local + 169.254.169.254 cloud metadata
65
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // CGNAT
66
+ /^0\./,
67
+ /^22[4-9]\./, /^23[0-9]\./, // multicast
68
+ /^24[0-9]\./, /^25[0-5]\./, // experimental
69
+ ];
70
+ export const FORBIDDEN_IPV6_RE: ReadonlyArray<RegExp> = [
71
+ /^::1$/,
72
+ /^::$/,
73
+ /^fe80:/i, /^fc00:/i, /^fd00:/i,
74
+ ];
75
+
76
+ export function isLoopbackHost(host: string): boolean {
77
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
78
+ }
79
+
80
+ export function isForbiddenIp(ip: string): boolean {
81
+ // IPv4-mapped IPv6: ::ffff:10.0.0.1 — recurse on inner v4
82
+ if (ip.toLowerCase().startsWith("::ffff:")) {
83
+ return isForbiddenIp(ip.slice(7));
84
+ }
85
+ // Simple v4/v6 distinction by presence of '.' / ':'
86
+ if (ip.includes(":") && !ip.includes(".")) {
87
+ return FORBIDDEN_IPV6_RE.some(re => re.test(ip));
88
+ }
89
+ return FORBIDDEN_IPV4_RE.some(re => re.test(ip));
90
+ }
91
+
92
+ // ── §4.4.3 validateBaseUrl — boot/pre-fetch URL+host+vendor check ─
93
+ /** Pure, no I/O. Called by hub (upsert_provider write path) AND
94
+ * daemon (handleProbeDoorbell re-check after get_probe_request).
95
+ * allowLoopback: dev-only opt-in honoring http://localhost / 127.0.0.1.
96
+ * Production daemon MUST NOT set allowLoopback=true. */
97
+ export function validateBaseUrl(
98
+ vendor: string,
99
+ baseUrl: string,
100
+ opts: { allowLoopback?: boolean } = {},
101
+ ): void {
102
+ let u: URL;
103
+ try { u = new URL(baseUrl); }
104
+ catch {
105
+ throw new ProbeValidationError("probe_base_url_invalid", {
106
+ reason: "not a valid URL", baseUrl: baseUrl.slice(0, 100),
107
+ });
108
+ }
109
+
110
+ if (u.protocol !== "https:") {
111
+ if (!opts.allowLoopback || !isLoopbackHost(u.hostname) || u.protocol !== "http:") {
112
+ throw new ProbeValidationError("probe_base_url_invalid", {
113
+ reason: "must be https (or http+loopback with dev opt-in)",
114
+ });
115
+ }
116
+ }
117
+ const allowed = VENDOR_HOST_ALLOWLIST[vendor];
118
+ if (!allowed) {
119
+ throw new ProbeValidationError("vendor_not_supported", {
120
+ vendor, supported: SUPPORTED_VENDORS,
121
+ });
122
+ }
123
+ if (!allowed.some(re => re.test(u.hostname))) {
124
+ throw new ProbeValidationError("probe_target_forbidden", {
125
+ vendor, host: u.hostname, allowed: allowed.map(r => r.source),
126
+ });
127
+ }
128
+ }