@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.
- package/package.json +2 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-host-supervisors-fallback.test.ts +262 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/db.ts +196 -0
- package/src/index.ts +216 -3
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +1776 -13
- package/src/update-provider.test.ts +234 -0
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import {
|
|
4
|
+
validateBaseUrl,
|
|
5
|
+
ProbeValidationError,
|
|
6
|
+
} from "./probe-validate.js";
|
|
7
|
+
|
|
8
|
+
// RFC-028 P1.5 — update_provider invariants. Tests the building blocks
|
|
9
|
+
// the MCP handler relies on:
|
|
10
|
+
// - SQL-level SEC-1 (provider_id × network_id uniqueness across tenants)
|
|
11
|
+
// - audit_log row written for every update
|
|
12
|
+
// - validateBaseUrl re-runs against vendor from DB (not patch) on
|
|
13
|
+
// base_url change → vendor immutability bypass attempt fails
|
|
14
|
+
// - patch-empty noop path
|
|
15
|
+
// - DELETE FROM provider_models + INSERT replacement is atomic
|
|
16
|
+
//
|
|
17
|
+
// Boundary behaviour (zod-strict patch wrapper, MCP -32602 on extras,
|
|
18
|
+
// admin gate, cross-tenant rejection) is covered by the docker e2e
|
|
19
|
+
// (qa-rfc028-update-provider). The unit layer focuses on the impl
|
|
20
|
+
// guarantees that survive even if the MCP boundary changes.
|
|
21
|
+
|
|
22
|
+
const NET_A = "net_test_pa_alpha";
|
|
23
|
+
const NET_B = "net_test_pa_beta";
|
|
24
|
+
const PROV_A = "prov_test_p15_alpha";
|
|
25
|
+
const PROV_B = "prov_test_p15_beta";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
for (const p of [PROV_A, PROV_B]) {
|
|
29
|
+
try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
for (const p of [PROV_A, PROV_B]) {
|
|
37
|
+
try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function seedProvider(provider_id: string, network_id: string, opts: Partial<{ name: string; vendor: string; base_url: string; enabled: number }> = {}) {
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
|
|
46
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
|
|
47
|
+
[provider_id, network_id, opts.name ?? "anthro-prod", opts.vendor ?? "anthropic",
|
|
48
|
+
opts.base_url ?? "https://api.anthropic.com", "ANTHROPIC_API_KEY",
|
|
49
|
+
Date.now(), "test", opts.enabled ?? 1],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("update_provider — SQL invariants (RFC-028 P1.5)", () => {
|
|
54
|
+
test("SEC-1: provider_id is unique-per-network — netA query for netB provider_id returns nothing", () => {
|
|
55
|
+
seedProvider(PROV_A, NET_A);
|
|
56
|
+
seedProvider(PROV_B, NET_B);
|
|
57
|
+
// The handler's lookup query: SELECT ... WHERE provider_id = ? AND network_id = ?
|
|
58
|
+
const fromNetA = db.get<any>(
|
|
59
|
+
"SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
|
|
60
|
+
PROV_B, NET_A,
|
|
61
|
+
);
|
|
62
|
+
expect(fromNetA == null).toBe(true);
|
|
63
|
+
// Sanity: PROV_B does exist in its own network
|
|
64
|
+
const fromNetB = db.get<any>(
|
|
65
|
+
"SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
|
|
66
|
+
PROV_B, NET_B,
|
|
67
|
+
);
|
|
68
|
+
expect(fromNetB?.provider_id).toBe(PROV_B);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("vendor immutability via validateBaseUrl: changing base_url uses DB vendor, attempt to widen via patched vendor fails", () => {
|
|
72
|
+
seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
|
|
73
|
+
// Handler reads vendor from DB row, NOT from patch. Suppose patch tried
|
|
74
|
+
// to set vendor=evil + base_url=https://attacker.example.com — the
|
|
75
|
+
// handler ignores the vendor from patch (it's not a field in the
|
|
76
|
+
// zod patch schema), and validateBaseUrl runs with vendor='anthropic'
|
|
77
|
+
// from the DB, so the host check is applied.
|
|
78
|
+
const dbVendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
|
|
79
|
+
expect(dbVendor).toBe("anthropic");
|
|
80
|
+
// attempting to use base_url not in anthropic allowlist fails
|
|
81
|
+
expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(ProbeValidationError);
|
|
82
|
+
expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(/probe_target_forbidden/);
|
|
83
|
+
// valid anthropic host passes
|
|
84
|
+
expect(() => validateBaseUrl(dbVendor, "https://api.anthropic.com/v1")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("patch noop: enabled set to same value → SET clause skipped", () => {
|
|
88
|
+
seedProvider(PROV_A, NET_A, { enabled: 1 });
|
|
89
|
+
const before = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
|
|
90
|
+
expect(before).toBe(1);
|
|
91
|
+
// simulate the handler's diff logic: if patch.enabled === row.enabled, skip
|
|
92
|
+
const newEnabled = true ? 1 : 0;
|
|
93
|
+
const shouldUpdate = newEnabled !== before;
|
|
94
|
+
expect(shouldUpdate).toBe(false);
|
|
95
|
+
// verify no SET ran (row unchanged)
|
|
96
|
+
const after = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
|
|
97
|
+
expect(after).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("enabled toggle 1→0 written + audit log row created in same transaction", () => {
|
|
101
|
+
seedProvider(PROV_A, NET_A, { enabled: 1 });
|
|
102
|
+
db.exec("BEGIN");
|
|
103
|
+
db.run("UPDATE providers SET enabled = 0 WHERE provider_id = ?1", [PROV_A]);
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
106
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
107
|
+
["u_test", "test", PROV_A, JSON.stringify({ diff: { enabled: { before: true, after: false } } }), NET_A],
|
|
108
|
+
);
|
|
109
|
+
db.exec("COMMIT");
|
|
110
|
+
expect(db.get<{enabled:number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)?.enabled).toBe(0);
|
|
111
|
+
const audit = db.get<any>("SELECT action, target_id, detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", PROV_A);
|
|
112
|
+
expect(audit?.action).toBe("update_provider");
|
|
113
|
+
expect(audit?.target_id).toBe(PROV_A);
|
|
114
|
+
expect(JSON.parse(audit!.detail).diff.enabled.after).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("audit detail does NOT contain secret_key_ref VALUE under any patch", () => {
|
|
118
|
+
seedProvider(PROV_A, NET_A);
|
|
119
|
+
// Even if an attacker tried to slip secret into the diff (impossible
|
|
120
|
+
// through the schema, but defense-in-depth), the audit serializer
|
|
121
|
+
// only includes the fields actually in `diff`. Secret isn't a patch
|
|
122
|
+
// field, so it can't reach the diff. Verify the absence:
|
|
123
|
+
const auditPayload = JSON.stringify({ diff: { name: { before: "old", after: "new" } } });
|
|
124
|
+
expect(auditPayload).not.toContain("secret");
|
|
125
|
+
expect(auditPayload).not.toContain("ANTHROPIC_API_KEY");
|
|
126
|
+
expect(auditPayload).not.toContain("sk-");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("provider_models DELETE+INSERT atomicity (transaction wraps both)", () => {
|
|
130
|
+
seedProvider(PROV_A, NET_A);
|
|
131
|
+
db.run(
|
|
132
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
|
|
133
|
+
VALUES ('pm_old1', ?1, 'claude-old-1', 1, ?2), ('pm_old2', ?1, 'claude-old-2', 1, ?2)`,
|
|
134
|
+
[PROV_A, Date.now()],
|
|
135
|
+
);
|
|
136
|
+
expect(db.all("SELECT model_id FROM provider_models WHERE provider_id = ?1", PROV_A).length).toBe(2);
|
|
137
|
+
// Replace in one transaction
|
|
138
|
+
db.exec("BEGIN");
|
|
139
|
+
db.run("DELETE FROM provider_models WHERE provider_id = ?1", [PROV_A]);
|
|
140
|
+
db.run(
|
|
141
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
|
|
142
|
+
VALUES ('pm_new1', ?1, 'claude-new-1', 1, ?2)`,
|
|
143
|
+
[PROV_A, Date.now()],
|
|
144
|
+
);
|
|
145
|
+
db.exec("COMMIT");
|
|
146
|
+
const rows = db.all<{model_name: string}>("SELECT model_name FROM provider_models WHERE provider_id = ?1", PROV_A);
|
|
147
|
+
expect(rows.length).toBe(1);
|
|
148
|
+
expect(rows[0].model_name).toBe("claude-new-1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("base_url change rejected via validateBaseUrl when host not in vendor allowlist", () => {
|
|
152
|
+
seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
|
|
153
|
+
const vendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
|
|
154
|
+
// any host other than api.anthropic.com is rejected
|
|
155
|
+
expect(() => validateBaseUrl(vendor, "https://api.openai.com")).toThrow(/probe_target_forbidden/);
|
|
156
|
+
expect(() => validateBaseUrl(vendor, "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
|
|
157
|
+
expect(() => validateBaseUrl(vendor, "http://api.anthropic.com")).toThrow(/probe_base_url_invalid/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("provider_disabled error distinct from provider_not_found in probe lookup", () => {
|
|
161
|
+
seedProvider(PROV_A, NET_A, { enabled: 0 });
|
|
162
|
+
// The new probe handler does the lookup WITHOUT enabled filter, then
|
|
163
|
+
// checks .enabled — distinguishing not_found vs disabled.
|
|
164
|
+
const row = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2", PROV_A, NET_A);
|
|
165
|
+
expect(row).toBeTruthy();
|
|
166
|
+
expect(row!.enabled).toBe(0);
|
|
167
|
+
// dashboard renders different message for these two cases
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("no-op same-value patch: NO audit_log row written (通信牛 nit)", () => {
|
|
171
|
+
// Regression for the post-merge fix: when patch fields all match the
|
|
172
|
+
// existing row (e.g. dashboard saves an unchanged form), the handler
|
|
173
|
+
// must early-return WITHOUT inserting an empty-diff audit row.
|
|
174
|
+
// Empty audit rows pollute the trail and make `forced_*` / sensitive
|
|
175
|
+
// actions harder to find.
|
|
176
|
+
seedProvider(PROV_A, NET_A, { name: "same-name", enabled: 1 });
|
|
177
|
+
const auditBefore = db.get<{n:number}>(
|
|
178
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
|
|
179
|
+
PROV_A,
|
|
180
|
+
)!.n;
|
|
181
|
+
|
|
182
|
+
// Mirror the handler's diff-check logic on a same-value patch
|
|
183
|
+
const row = db.get<{name:string, enabled:number, base_url:string}>(
|
|
184
|
+
"SELECT name, enabled, base_url FROM providers WHERE provider_id = ?1", PROV_A,
|
|
185
|
+
)!;
|
|
186
|
+
const patch = { name: "same-name", enabled: true };
|
|
187
|
+
const sets: string[] = [];
|
|
188
|
+
if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
|
|
189
|
+
if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
|
|
190
|
+
const willReplaceModels = false; // no patch.models
|
|
191
|
+
|
|
192
|
+
// sets MUST be empty for a same-value patch
|
|
193
|
+
expect(sets.length).toBe(0);
|
|
194
|
+
expect(willReplaceModels).toBe(false);
|
|
195
|
+
|
|
196
|
+
// Handler returns here WITHOUT writing audit_log → count unchanged
|
|
197
|
+
const auditAfter = db.get<{n:number}>(
|
|
198
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
|
|
199
|
+
PROV_A,
|
|
200
|
+
)!.n;
|
|
201
|
+
expect(auditAfter).toBe(auditBefore);
|
|
202
|
+
expect(auditAfter).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("real change DOES write audit_log row (positive guard for the early-return)", () => {
|
|
206
|
+
// Belt-and-suspenders: no-op early-return MUST NOT trigger when even
|
|
207
|
+
// one field actually changes. Otherwise we'd silently lose audit
|
|
208
|
+
// coverage for real updates.
|
|
209
|
+
seedProvider(PROV_A, NET_A, { name: "old-name", enabled: 1 });
|
|
210
|
+
const row = db.get<{name:string, enabled:number}>(
|
|
211
|
+
"SELECT name, enabled FROM providers WHERE provider_id = ?1", PROV_A,
|
|
212
|
+
)!;
|
|
213
|
+
const patch = { name: "new-name", enabled: true };
|
|
214
|
+
const sets: string[] = [];
|
|
215
|
+
if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
|
|
216
|
+
if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
|
|
217
|
+
expect(sets).toEqual(["name"]);
|
|
218
|
+
|
|
219
|
+
db.exec("BEGIN");
|
|
220
|
+
db.run("UPDATE providers SET name = ?1 WHERE provider_id = ?2", ["new-name", PROV_A]);
|
|
221
|
+
db.run(
|
|
222
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
223
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
224
|
+
["u_test", "test", PROV_A, JSON.stringify({ diff: { name: { before: "old-name", after: "new-name" } }, fields_changed: ["name"] }), NET_A],
|
|
225
|
+
);
|
|
226
|
+
db.exec("COMMIT");
|
|
227
|
+
|
|
228
|
+
const audit = db.get<any>(
|
|
229
|
+
"SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider' ORDER BY id DESC LIMIT 1",
|
|
230
|
+
PROV_A,
|
|
231
|
+
)!;
|
|
232
|
+
expect(JSON.parse(audit.detail).fields_changed).toEqual(["name"]);
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/uploads.test.ts
CHANGED
|
@@ -312,3 +312,69 @@ describe("indexEntryPath", () => {
|
|
|
312
312
|
expect(p).toBe(`/srv/up/.index/${id}.json`);
|
|
313
313
|
});
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
import { stripHostLocalPathsForCrossHostSafe } from "./uploads";
|
|
317
|
+
|
|
318
|
+
describe("stripHostLocalPathsForCrossHostSafe (#222 cross-host attachment safety)", () => {
|
|
319
|
+
test("strips path when file_id is present (cross-host safe — agent will fetch via /api/files/<id>)", () => {
|
|
320
|
+
const meta = {
|
|
321
|
+
attachments: [
|
|
322
|
+
{ type: "file", file_id: "abc12345", path: "/home/hub/uploads/abc12345.png", mime: "image/png" },
|
|
323
|
+
],
|
|
324
|
+
otherKey: "preserved",
|
|
325
|
+
};
|
|
326
|
+
const out = stripHostLocalPathsForCrossHostSafe(meta);
|
|
327
|
+
expect(out.attachments[0]).toEqual({ type: "file", file_id: "abc12345", mime: "image/png" });
|
|
328
|
+
expect(out.attachments[0].path).toBeUndefined();
|
|
329
|
+
expect(out.otherKey).toBe("preserved");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("PRESERVES path when file_id is absent (feishu single-host fallback)", () => {
|
|
333
|
+
// 🔴 通信龙 PR #222 pre-review nit — feishu-bridge ships path-only
|
|
334
|
+
// attachments (writes /work/feishu-attachments/...). If we strip
|
|
335
|
+
// unconditionally those break too. Lock the conditional contract.
|
|
336
|
+
const meta = {
|
|
337
|
+
attachments: [
|
|
338
|
+
{ type: "file", path: "/work/feishu-attachments/feishu-local/oc_xxx/img.jpg", mime: "image/jpeg" },
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
const out = stripHostLocalPathsForCrossHostSafe(meta);
|
|
342
|
+
expect(out.attachments[0].path).toBe("/work/feishu-attachments/feishu-local/oc_xxx/img.jpg");
|
|
343
|
+
expect(out.attachments[0].file_id).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("mixed array: strips path on entries with file_id, preserves on path-only entries", () => {
|
|
347
|
+
const meta = {
|
|
348
|
+
attachments: [
|
|
349
|
+
{ type: "file", file_id: "id1xxxxx", path: "/hub/local/id1.png", mime: "image/png" },
|
|
350
|
+
{ type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" },
|
|
351
|
+
{ type: "file", file_id: "id2yyyyy", mime: "image/gif" }, // already path-free
|
|
352
|
+
],
|
|
353
|
+
};
|
|
354
|
+
const out = stripHostLocalPathsForCrossHostSafe(meta);
|
|
355
|
+
expect(out.attachments[0]).toEqual({ type: "file", file_id: "id1xxxxx", mime: "image/png" });
|
|
356
|
+
expect(out.attachments[1]).toEqual({ type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" });
|
|
357
|
+
expect(out.attachments[2]).toEqual({ type: "file", file_id: "id2yyyyy", mime: "image/gif" });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("no-op when meta has no attachments array", () => {
|
|
361
|
+
const meta = { something: "else" };
|
|
362
|
+
expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("no-op when nothing to strip (returns SAME object reference, not a copy)", () => {
|
|
366
|
+
const meta = { attachments: [{ type: "file", file_id: "x12345xx", mime: "image/png" }] };
|
|
367
|
+
expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("handles null / non-object meta gracefully", () => {
|
|
371
|
+
expect(stripHostLocalPathsForCrossHostSafe(null)).toBe(null);
|
|
372
|
+
expect(stripHostLocalPathsForCrossHostSafe(undefined as any)).toBe(undefined);
|
|
373
|
+
expect(stripHostLocalPathsForCrossHostSafe("string" as any)).toBe("string");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("handles non-array attachments field gracefully", () => {
|
|
377
|
+
const meta = { attachments: "not-an-array" };
|
|
378
|
+
expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
|
|
379
|
+
});
|
|
380
|
+
});
|
package/src/uploads.ts
CHANGED
|
@@ -241,6 +241,42 @@ export type TaskAttachment = {
|
|
|
241
241
|
size?: number;
|
|
242
242
|
};
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* #222 cross-host attachment safety — strip `path` from meta.attachments
|
|
246
|
+
* entries that ALSO carry a `file_id`. Reason: the hub's /api/upload
|
|
247
|
+
* returns `{file_id, path}` where `path` is a hub-machine-local
|
|
248
|
+
* absolute path. Senders sometimes echo `path` back into meta.attachments.
|
|
249
|
+
* When the receiving agent runs on a different host (Vincent 2026-06-30
|
|
250
|
+
* toodadev2 case), that path string is meaningless. agent-node's
|
|
251
|
+
* fetch-attachment.ts will fetch via `file_id` regardless, but leaving
|
|
252
|
+
* the stale `path` in the JSON misleads any tooling that reads it.
|
|
253
|
+
*
|
|
254
|
+
* CRITICAL — conditional, not unconditional (通信龙 PR #222 pre-review
|
|
255
|
+
* nit): feishu-bridge currently writes `/work/feishu-attachments/<conn>/
|
|
256
|
+
* <chat>/` directly and ships path-only attachments (NO file_id). If we
|
|
257
|
+
* strip path unconditionally those break too. Only strip when file_id
|
|
258
|
+
* is present (the "safe to drop" signal). Single-host feishu fallback
|
|
259
|
+
* preserved.
|
|
260
|
+
*
|
|
261
|
+
* Shared by tools.ts (MCP send_task) and index.ts (REST /api/task) so
|
|
262
|
+
* both transports apply the same sanitization to meta.
|
|
263
|
+
*/
|
|
264
|
+
export function stripHostLocalPathsForCrossHostSafe(meta: any): any {
|
|
265
|
+
if (!meta || typeof meta !== "object") return meta;
|
|
266
|
+
if (!Array.isArray((meta as any).attachments)) return meta;
|
|
267
|
+
let mutated = false;
|
|
268
|
+
const sanitized = (meta as any).attachments.map((a: any) => {
|
|
269
|
+
if (a && typeof a === "object" && typeof a.file_id === "string" && a.file_id && typeof a.path === "string") {
|
|
270
|
+
mutated = true;
|
|
271
|
+
const { path: _stripped, ...rest } = a;
|
|
272
|
+
return rest;
|
|
273
|
+
}
|
|
274
|
+
return a;
|
|
275
|
+
});
|
|
276
|
+
if (!mutated) return meta;
|
|
277
|
+
return { ...meta, attachments: sanitized };
|
|
278
|
+
}
|
|
279
|
+
|
|
244
280
|
/** Light validator usable from the REST handler. Returns null on success or an error message string. */
|
|
245
281
|
export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
|
|
246
282
|
if (input === undefined || input === null) return { ok: true, attachments: [] };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// RFC-028 P1 §4.1 vault layer tests. F2 lazy gate is the critical
|
|
2
|
+
// invariant — existing prod hub升级 without ANET_HUB_SECRET_VAULT_KEY
|
|
3
|
+
// must NOT boot-fail. Every test runs with deterministic key isolation.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
encryptSecret, decryptSecret,
|
|
8
|
+
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
9
|
+
vaultStatusForBoot, vaultMasterKeyFingerprint,
|
|
10
|
+
_resetVaultKeyForTest, VaultError,
|
|
11
|
+
} from "./vault.js";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const TEST_KEY_HEX = "0123456789abcdef".repeat(4); // 32 bytes hex = 64 chars
|
|
15
|
+
|
|
16
|
+
function setKey(hex: string | undefined): void {
|
|
17
|
+
if (hex === undefined) delete process.env.ANET_HUB_SECRET_VAULT_KEY;
|
|
18
|
+
else process.env.ANET_HUB_SECRET_VAULT_KEY = hex;
|
|
19
|
+
_resetVaultKeyForTest();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db.run("DELETE FROM network_secrets");
|
|
24
|
+
db.run("DELETE FROM provider_models");
|
|
25
|
+
db.run("DELETE FROM providers");
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => { _resetVaultKeyForTest(); delete process.env.ANET_HUB_SECRET_VAULT_KEY; });
|
|
28
|
+
|
|
29
|
+
describe("RFC-028 P1 §4.1 F2 — LAZY GATE (critical: don't brick prod hub)", () => {
|
|
30
|
+
test("hub boot WITHOUT env + WITHOUT vault data → vaultStatusForBoot says configured=false, needsKeyToOp=false (boots OK)", () => {
|
|
31
|
+
setKey(undefined);
|
|
32
|
+
const s = vaultStatusForBoot();
|
|
33
|
+
expect(s.configured).toBe(false);
|
|
34
|
+
expect(s.tablesHaveData).toBe(false);
|
|
35
|
+
expect(s.needsKeyToOp).toBe(false);
|
|
36
|
+
// Crucially: no throw, no crash, hub keeps running.
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("hub boot WITH env + WITHOUT vault data → configured=true, tablesEmpty (no fingerprint needed)", () => {
|
|
40
|
+
setKey(TEST_KEY_HEX);
|
|
41
|
+
const s = vaultStatusForBoot();
|
|
42
|
+
expect(s.configured).toBe(true);
|
|
43
|
+
expect(s.tablesHaveData).toBe(false);
|
|
44
|
+
expect(s.needsKeyToOp).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("hub WITHOUT env + WITH vault data → needsKeyToOp=true (banner warning, NO throw)", () => {
|
|
48
|
+
setKey(TEST_KEY_HEX);
|
|
49
|
+
vaultUpsert("net_test", "TEST_KEY", "plaintext-value");
|
|
50
|
+
setKey(undefined);
|
|
51
|
+
const s = vaultStatusForBoot();
|
|
52
|
+
expect(s.configured).toBe(false);
|
|
53
|
+
expect(s.tablesHaveData).toBe(true);
|
|
54
|
+
expect(s.needsKeyToOp).toBe(true);
|
|
55
|
+
// Still no throw — banner is informational; throw only happens on
|
|
56
|
+
// actual vault op (test below).
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("vault op WITHOUT env throws vault_master_key_missing (lazy throw, not boot throw)", () => {
|
|
60
|
+
setKey(undefined);
|
|
61
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(VaultError);
|
|
62
|
+
try { vaultUpsert("net_test", "K", "v"); }
|
|
63
|
+
catch (e: any) { expect(e.code).toBe("vault_master_key_missing"); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("vault op WITH invalid env (not 32 bytes hex) throws vault_master_key_invalid", () => {
|
|
67
|
+
setKey("not-hex");
|
|
68
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(/vault_master_key_invalid/);
|
|
69
|
+
setKey("aabbcc"); // too short
|
|
70
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(/vault_master_key_invalid/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("RFC-028 P1 §4.1 — AES-GCM encrypt/decrypt round-trip", () => {
|
|
75
|
+
test("encrypt+decrypt roundtrip preserves plaintext", () => {
|
|
76
|
+
setKey(TEST_KEY_HEX);
|
|
77
|
+
const pt = "sk-ant-api03-abc123xyz";
|
|
78
|
+
const enc = encryptSecret(pt);
|
|
79
|
+
expect(enc.ciphertext.length).toBeGreaterThan(0);
|
|
80
|
+
expect(enc.iv.length).toBe(12);
|
|
81
|
+
expect(enc.tag.length).toBe(16);
|
|
82
|
+
expect(decryptSecret(enc)).toBe(pt);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("ciphertext is unique per call (random IV)", () => {
|
|
86
|
+
setKey(TEST_KEY_HEX);
|
|
87
|
+
const a = encryptSecret("same-secret");
|
|
88
|
+
const b = encryptSecret("same-secret");
|
|
89
|
+
expect(a.iv.equals(b.iv)).toBe(false);
|
|
90
|
+
expect(a.ciphertext.equals(b.ciphertext)).toBe(false);
|
|
91
|
+
// Both decrypt back to same plaintext
|
|
92
|
+
expect(decryptSecret(a)).toBe("same-secret");
|
|
93
|
+
expect(decryptSecret(b)).toBe("same-secret");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("tamper detection — flipping ciphertext bit throws vault_decrypt_failed", () => {
|
|
97
|
+
setKey(TEST_KEY_HEX);
|
|
98
|
+
const enc = encryptSecret("important");
|
|
99
|
+
enc.ciphertext[0] ^= 0x01;
|
|
100
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("tamper detection — flipping tag bit throws vault_decrypt_failed", () => {
|
|
104
|
+
setKey(TEST_KEY_HEX);
|
|
105
|
+
const enc = encryptSecret("important");
|
|
106
|
+
enc.tag[0] ^= 0x01;
|
|
107
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("wrong key cannot decrypt", () => {
|
|
111
|
+
setKey(TEST_KEY_HEX);
|
|
112
|
+
const enc = encryptSecret("important");
|
|
113
|
+
setKey("ff".repeat(32)); // different key
|
|
114
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("RFC-028 P1 §4.1 — DB ops + key listing (永不返 value)", () => {
|
|
119
|
+
test("vaultUpsert + vaultGet roundtrip via DB", () => {
|
|
120
|
+
setKey(TEST_KEY_HEX);
|
|
121
|
+
vaultUpsert("net_A", "ANTHROPIC_API_KEY", "sk-ant-prod-1");
|
|
122
|
+
expect(vaultGet("net_A", "ANTHROPIC_API_KEY")).toBe("sk-ant-prod-1");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("vaultUpsert replaces existing key (same network_id + key)", () => {
|
|
126
|
+
setKey(TEST_KEY_HEX);
|
|
127
|
+
vaultUpsert("net_B", "K1", "v1");
|
|
128
|
+
vaultUpsert("net_B", "K1", "v2");
|
|
129
|
+
expect(vaultGet("net_B", "K1")).toBe("v2");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("vaultGet returns null for missing key (does NOT throw)", () => {
|
|
133
|
+
setKey(TEST_KEY_HEX);
|
|
134
|
+
expect(vaultGet("net_X", "MISSING")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("vaultListKeys returns key NAMES only (no values, ever)", () => {
|
|
138
|
+
setKey(TEST_KEY_HEX);
|
|
139
|
+
vaultUpsert("net_L", "KEY_A", "value-a");
|
|
140
|
+
vaultUpsert("net_L", "KEY_B", "value-b");
|
|
141
|
+
const keys = vaultListKeys("net_L");
|
|
142
|
+
expect(keys).toEqual(["KEY_A", "KEY_B"]); // sorted; no values
|
|
143
|
+
expect(JSON.stringify(keys)).not.toContain("value-a");
|
|
144
|
+
expect(JSON.stringify(keys)).not.toContain("value-b");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("network isolation — netA's secret不 visible to netB", () => {
|
|
148
|
+
setKey(TEST_KEY_HEX);
|
|
149
|
+
vaultUpsert("net_A", "SHARED_NAME", "value-from-A");
|
|
150
|
+
vaultUpsert("net_B", "SHARED_NAME", "value-from-B");
|
|
151
|
+
expect(vaultGet("net_A", "SHARED_NAME")).toBe("value-from-A");
|
|
152
|
+
expect(vaultGet("net_B", "SHARED_NAME")).toBe("value-from-B");
|
|
153
|
+
expect(vaultListKeys("net_A")).toEqual(["SHARED_NAME"]);
|
|
154
|
+
expect(vaultListKeys("net_B")).toEqual(["SHARED_NAME"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("vaultDelete removes key + returns true; second delete returns false", () => {
|
|
158
|
+
setKey(TEST_KEY_HEX);
|
|
159
|
+
vaultUpsert("net_D", "K", "v");
|
|
160
|
+
expect(vaultDelete("net_D", "K")).toBe(true);
|
|
161
|
+
expect(vaultGet("net_D", "K")).toBeNull();
|
|
162
|
+
expect(vaultDelete("net_D", "K")).toBe(false); // no-op
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("RFC-028 P1 §4.1 — master key fingerprint", () => {
|
|
167
|
+
test("fingerprint same for same key, different for different key", () => {
|
|
168
|
+
setKey(TEST_KEY_HEX);
|
|
169
|
+
const fp1 = vaultMasterKeyFingerprint();
|
|
170
|
+
expect(fp1.length).toBe(16);
|
|
171
|
+
setKey(TEST_KEY_HEX); // re-set same key
|
|
172
|
+
expect(vaultMasterKeyFingerprint()).toBe(fp1);
|
|
173
|
+
setKey("ff".repeat(32));
|
|
174
|
+
expect(vaultMasterKeyFingerprint()).not.toBe(fp1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("RFC-028 P1 §4.1 — DB ciphertext storage (PRAGMA-style: NO plaintext in DB)", () => {
|
|
179
|
+
test("BLOB stored is ciphertext, not plaintext (raw SELECT verifies)", () => {
|
|
180
|
+
setKey(TEST_KEY_HEX);
|
|
181
|
+
const plaintext = "sk-very-secret-key-do-not-leak";
|
|
182
|
+
vaultUpsert("net_P", "ANTHROPIC_API_KEY", plaintext);
|
|
183
|
+
const raw = db.get<{ ciphertext: Buffer | Uint8Array; iv: Buffer | Uint8Array; tag: Buffer | Uint8Array }>(
|
|
184
|
+
"SELECT ciphertext, iv, tag FROM network_secrets WHERE network_id = ?1 AND key = ?2",
|
|
185
|
+
"net_P", "ANTHROPIC_API_KEY",
|
|
186
|
+
);
|
|
187
|
+
expect(raw).not.toBeNull();
|
|
188
|
+
const ctBuf = Buffer.isBuffer(raw!.ciphertext) ? raw!.ciphertext : Buffer.from(raw!.ciphertext as any);
|
|
189
|
+
// Plaintext substring MUST NOT appear in ciphertext bytes
|
|
190
|
+
expect(ctBuf.toString("binary")).not.toContain(plaintext);
|
|
191
|
+
expect(ctBuf.toString("utf8")).not.toContain(plaintext);
|
|
192
|
+
// Even as hex / base64 / partial — bytes must not match plaintext bytes
|
|
193
|
+
const plainBytes = Buffer.from(plaintext, "utf8");
|
|
194
|
+
let found = false;
|
|
195
|
+
for (let i = 0; i <= ctBuf.length - plainBytes.length; i++) {
|
|
196
|
+
if (ctBuf.subarray(i, i + plainBytes.length).equals(plainBytes)) { found = true; break; }
|
|
197
|
+
}
|
|
198
|
+
expect(found).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|