@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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/README.md +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- 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-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -9,9 +9,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import {
|
|
10
10
|
ALLOWED_FLAGS,
|
|
11
11
|
SECURITY_SENSITIVE_FLAGS,
|
|
12
|
+
EDITABLE_CHANNELS,
|
|
12
13
|
isAllowedToChangeFlag,
|
|
13
14
|
computeApplyMode,
|
|
14
15
|
validatePatch,
|
|
16
|
+
narrowChannelsPatch,
|
|
15
17
|
} from "./config-apply-validate";
|
|
16
18
|
|
|
17
19
|
describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
|
|
@@ -258,3 +260,93 @@ describe("validatePatch — combinations", () => {
|
|
|
258
260
|
expect(r?.field).toBe("flags.maxTurns");
|
|
259
261
|
});
|
|
260
262
|
});
|
|
263
|
+
|
|
264
|
+
// ── #260 P5 — channels support (restart-tier, non-security-sensitive) ─
|
|
265
|
+
describe("EDITABLE_CHANNELS — mirrors agent-node runtime capability (cli.ts:671-676)", () => {
|
|
266
|
+
test("contains exactly telegram + feishu (channels agent-node actually forks workers for)", () => {
|
|
267
|
+
expect(EDITABLE_CHANNELS.size).toBe(2);
|
|
268
|
+
expect(EDITABLE_CHANNELS.has("telegram")).toBe(true);
|
|
269
|
+
expect(EDITABLE_CHANNELS.has("feishu")).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
test("commhub is NOT editable — it's the RPC transport, not a per-node channel worker", () => {
|
|
272
|
+
expect(EDITABLE_CHANNELS.has("commhub")).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
test("wechat is NOT editable — roadmap only on dashboard", () => {
|
|
275
|
+
expect(EDITABLE_CHANNELS.has("wechat")).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("narrowChannelsPatch — hostile input hygiene at the wire boundary", () => {
|
|
280
|
+
test("straight allow-listed input passes verbatim", () => {
|
|
281
|
+
expect(narrowChannelsPatch(["telegram", "feishu"])).toEqual(["telegram", "feishu"]);
|
|
282
|
+
});
|
|
283
|
+
test("empty array preserved — 'disable all editable channels' is a real state", () => {
|
|
284
|
+
expect(narrowChannelsPatch([])).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
test("non-array returns null (patch didn't touch channels)", () => {
|
|
287
|
+
expect(narrowChannelsPatch(undefined)).toBeNull();
|
|
288
|
+
expect(narrowChannelsPatch("telegram" as any)).toBeNull();
|
|
289
|
+
expect(narrowChannelsPatch({} as any)).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
test("case-folded to lower", () => {
|
|
292
|
+
expect(narrowChannelsPatch(["FEISHU", "Telegram"])).toEqual(["feishu", "telegram"]);
|
|
293
|
+
});
|
|
294
|
+
test("duplicates deduped (Set)", () => {
|
|
295
|
+
expect(narrowChannelsPatch(["telegram", "telegram", "feishu", "TELEGRAM"])).toEqual(["telegram", "feishu"]);
|
|
296
|
+
});
|
|
297
|
+
test("unknown channel keys dropped silently (roadmap wechat, transport commhub, evil keys)", () => {
|
|
298
|
+
expect(narrowChannelsPatch(["wechat", "telegram", "evil-hacker", "commhub"])).toEqual(["telegram"]);
|
|
299
|
+
});
|
|
300
|
+
test("non-string entries dropped (numbers, objects, nulls)", () => {
|
|
301
|
+
expect(narrowChannelsPatch(["telegram", 42, {}, null, "feishu"] as any)).toEqual(["telegram", "feishu"]);
|
|
302
|
+
});
|
|
303
|
+
test("SQL-injection-shaped value dropped (not exact key match)", () => {
|
|
304
|
+
expect(narrowChannelsPatch(["telegram; drop table users;", "feishu"])).toEqual(["feishu"]);
|
|
305
|
+
});
|
|
306
|
+
test("whitespace around a key still trims to the key", () => {
|
|
307
|
+
expect(narrowChannelsPatch([" feishu "])).toEqual(["feishu"]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("computeApplyMode — channels tier (restart)", () => {
|
|
312
|
+
test("channels-only patch → restart (boot forks per-channel workers)", () => {
|
|
313
|
+
expect(computeApplyMode(undefined, {}, ["feishu"])).toBe("restart");
|
|
314
|
+
});
|
|
315
|
+
test("empty-array channels → restart (still a state change)", () => {
|
|
316
|
+
expect(computeApplyMode(undefined, {}, [])).toBe("restart");
|
|
317
|
+
});
|
|
318
|
+
test("no channels key + no flags + no model → restart_only", () => {
|
|
319
|
+
expect(computeApplyMode(undefined, {}, undefined)).toBe("restart_only");
|
|
320
|
+
});
|
|
321
|
+
test("channels + hot-tier flag together stays restart (upgrade rules)", () => {
|
|
322
|
+
expect(computeApplyMode(undefined, { maxTurns: 5 }, ["telegram"])).toBe("restart");
|
|
323
|
+
});
|
|
324
|
+
test("channels + model → restart (model was already restart)", () => {
|
|
325
|
+
expect(computeApplyMode("gpt-5", {}, ["feishu"])).toBe("restart");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("validatePatch — channels defensive gate", () => {
|
|
330
|
+
test("valid channels array passes", () => {
|
|
331
|
+
expect(validatePatch(undefined, {}, ["telegram", "feishu"])).toBeNull();
|
|
332
|
+
expect(validatePatch(undefined, {}, [])).toBeNull();
|
|
333
|
+
expect(validatePatch(undefined, {}, ["telegram"])).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
test("commhub rejected — RPC transport, not a per-node channel", () => {
|
|
336
|
+
const r = validatePatch(undefined, {}, ["commhub"]);
|
|
337
|
+
expect(r?.field).toBe("channels.commhub");
|
|
338
|
+
});
|
|
339
|
+
test("out-of-allowlist channel rejected (caller bypassed narrowing)", () => {
|
|
340
|
+
const r = validatePatch(undefined, {}, ["wechat"]);
|
|
341
|
+
expect(r?.field).toBe("channels.wechat");
|
|
342
|
+
expect(r?.reason).toMatch(/allowlist/);
|
|
343
|
+
});
|
|
344
|
+
test("non-string entry rejected", () => {
|
|
345
|
+
const r = validatePatch(undefined, {}, [42] as any);
|
|
346
|
+
expect(r?.field).toBe("channels");
|
|
347
|
+
});
|
|
348
|
+
test("non-array rejected", () => {
|
|
349
|
+
const r = validatePatch(undefined, {}, "telegram" as any);
|
|
350
|
+
expect(r?.field).toBe("channels");
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -57,6 +57,35 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
|
|
|
57
57
|
"timeout",
|
|
58
58
|
]);
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Channels the dashboard may enable/disable on a node via
|
|
62
|
+
* update_node_config. Intentionally narrow — the panel only exposes
|
|
63
|
+
* enable/disable, per-channel secrets stay in the node's local
|
|
64
|
+
* config.json (see #260 wire discipline).
|
|
65
|
+
*
|
|
66
|
+
* The list mirrors agent-node's actual runtime capability. See
|
|
67
|
+
* agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
|
|
68
|
+
* worker inits (initTelegramChannel + initFeishuChannel), and any
|
|
69
|
+
* other channel type triggers `process.exit(1)` at boot. So the
|
|
70
|
+
* hub-side allow-list here is telegram + feishu ONLY. `commhub` is
|
|
71
|
+
* the RPC transport that every node speaks unconditionally; treating
|
|
72
|
+
* it as a per-node channel would be a UX lie (toggle looks off but
|
|
73
|
+
* commhub is always on).
|
|
74
|
+
*
|
|
75
|
+
* Dashboard PR #31 currently includes `commhub` in its own
|
|
76
|
+
* EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
|
|
77
|
+
* the next dashboard rally, and until then hub silently drops any
|
|
78
|
+
* `commhub` entry via narrowChannelsPatch.
|
|
79
|
+
*
|
|
80
|
+
* Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
|
|
81
|
+
* channel on/off is a lifecycle-tier operation like `restart_node`, not
|
|
82
|
+
* a privilege elevation. Cross-tenant reach is already gated by SEC-1.
|
|
83
|
+
*/
|
|
84
|
+
export const EDITABLE_CHANNELS = new Set<string>([
|
|
85
|
+
"telegram",
|
|
86
|
+
"feishu",
|
|
87
|
+
]);
|
|
88
|
+
|
|
60
89
|
/**
|
|
61
90
|
* Role-gate for the patch's flag set. SEC-2 enforcement lives here.
|
|
62
91
|
*
|
|
@@ -99,22 +128,53 @@ export function isAllowedToChangeFlag(
|
|
|
99
128
|
|
|
100
129
|
/**
|
|
101
130
|
* Classify the patch's required apply mode. Empty patch (model + flags
|
|
102
|
-
* both empty) → "restart_only" (used by restart_node tool).
|
|
103
|
-
* restart-required field present
|
|
131
|
+
* both empty, no channels) → "restart_only" (used by restart_node tool).
|
|
132
|
+
* Any restart-required field present, OR a channels change → "restart"
|
|
133
|
+
* (channels is restart-tier by design: the node reads its channel set
|
|
134
|
+
* once at boot in agent-node/src/cli.ts and forks per-channel workers
|
|
135
|
+
* from it, so a channel enable/disable takes effect on process restart
|
|
136
|
+
* only — see #260 P5 wire discipline). Otherwise → "hot".
|
|
104
137
|
*/
|
|
105
138
|
export function computeApplyMode(
|
|
106
139
|
model: string | undefined,
|
|
107
140
|
flags: Record<string, unknown>,
|
|
141
|
+
channels?: string[] | undefined,
|
|
108
142
|
): "hot" | "restart" | "restart_only" {
|
|
109
|
-
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
|
|
143
|
+
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
|
|
110
144
|
if (fieldCount === 0) return "restart_only";
|
|
111
145
|
if (model !== undefined) return "restart";
|
|
146
|
+
if (channels !== undefined) return "restart";
|
|
112
147
|
for (const key of Object.keys(flags)) {
|
|
113
148
|
if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
|
|
114
149
|
}
|
|
115
150
|
return "hot";
|
|
116
151
|
}
|
|
117
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Narrow + allow-list `channels` from untrusted patch JSON. Returns the
|
|
155
|
+
* final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
|
|
156
|
+
* order-preserving), OR null if the input isn't an array. Non-strings,
|
|
157
|
+
* unknown keys, empty strings, and duplicates are silently dropped —
|
|
158
|
+
* same fail-shape as the dashboard route's whitelist so the two sides
|
|
159
|
+
* behave identically end-to-end. Callers pass `undefined` through when
|
|
160
|
+
* the patch didn't include a channels key at all (a distinct case from
|
|
161
|
+
* `channels: []` which means "disable all editable channels").
|
|
162
|
+
*/
|
|
163
|
+
export function narrowChannelsPatch(raw: unknown): string[] | null {
|
|
164
|
+
if (!Array.isArray(raw)) return null;
|
|
165
|
+
const allow = EDITABLE_CHANNELS;
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
const out: string[] = [];
|
|
168
|
+
for (const v of raw) {
|
|
169
|
+
if (typeof v !== "string") continue;
|
|
170
|
+
const key = v.trim().toLowerCase();
|
|
171
|
+
if (!allow.has(key) || seen.has(key)) continue;
|
|
172
|
+
seen.add(key);
|
|
173
|
+
out.push(key);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
118
178
|
/**
|
|
119
179
|
* Validate the patch shape — allowlist + per-field range / enum. Returns
|
|
120
180
|
* null on pass, or `{ field, reason }` on reject. Caller wraps in error
|
|
@@ -123,12 +183,30 @@ export function computeApplyMode(
|
|
|
123
183
|
export function validatePatch(
|
|
124
184
|
model: string | undefined,
|
|
125
185
|
flags: Record<string, unknown>,
|
|
186
|
+
channels?: string[] | undefined,
|
|
126
187
|
): { field: string; reason: string } | null {
|
|
127
188
|
if (model !== undefined) {
|
|
128
189
|
if (typeof model !== "string" || model.length === 0 || model.length > 200) {
|
|
129
190
|
return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
|
|
130
191
|
}
|
|
131
192
|
}
|
|
193
|
+
if (channels !== undefined) {
|
|
194
|
+
// Zod already enforced string[], and narrowChannelsPatch dropped
|
|
195
|
+
// unknown keys before this point — but re-verify defensively so a
|
|
196
|
+
// future caller that skips narrowChannelsPatch can't smuggle
|
|
197
|
+
// unknown keys through validatePatch alone.
|
|
198
|
+
if (!Array.isArray(channels)) {
|
|
199
|
+
return { field: "channels", reason: "must be an array of strings" };
|
|
200
|
+
}
|
|
201
|
+
for (const c of channels) {
|
|
202
|
+
if (typeof c !== "string") {
|
|
203
|
+
return { field: "channels", reason: "must contain only strings" };
|
|
204
|
+
}
|
|
205
|
+
if (!EDITABLE_CHANNELS.has(c)) {
|
|
206
|
+
return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
132
210
|
for (const [key, val] of Object.entries(flags)) {
|
|
133
211
|
if (!ALLOWED_FLAGS.has(key)) {
|
|
134
212
|
return { field: `flags.${key}`, reason: "not in allowlist" };
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// #1353 Fix ① — hub gates create_node dispatch on the daemon's
|
|
7
|
+
// self-reported can_create_nodes bit. Before this, daemon reported
|
|
8
|
+
// can_create_nodes=false (via #1371 + #1377) → hub stored it → nothing
|
|
9
|
+
// read it → dispatch returned {ok:true, request_id} and pushed a
|
|
10
|
+
// doorbell to a daemon that had already told hub it couldn't create
|
|
11
|
+
// nodes. That's the exact "在线但静默失败" case the issue title reports.
|
|
12
|
+
//
|
|
13
|
+
// Vincent 2026-08-28 comment on #1353:
|
|
14
|
+
// "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
|
|
15
|
+
// 而不是发一个注定失败的 doorbell"
|
|
16
|
+
//
|
|
17
|
+
// 🔴 Pre-#1371 daemons (preview.55 and earlier) don't report the field
|
|
18
|
+
// at all. That must NOT fail-closed — the guard is "known-blocked",
|
|
19
|
+
// not "unknown-treated-as-blocked". Tested explicitly below.
|
|
20
|
+
|
|
21
|
+
const NET = "net_1353_gate";
|
|
22
|
+
const USER_ID = "u_1353_admin";
|
|
23
|
+
const DAEMON_ID = "node_1353_daemon";
|
|
24
|
+
const DAEMON_ALIAS = "gate-daemon";
|
|
25
|
+
const DAEMON_TOK = "tok_1353_daemon";
|
|
26
|
+
|
|
27
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
28
|
+
interface Reply { ok?: boolean; error?: string; blocked_reason?: string; request_id?: string; [k: string]: unknown; }
|
|
29
|
+
|
|
30
|
+
function cleanup() {
|
|
31
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
beforeEach(cleanup);
|
|
41
|
+
afterAll(cleanup);
|
|
42
|
+
|
|
43
|
+
/** #1545 —— 可选:写一条 sessions 心跳行。
|
|
44
|
+
* 默认**不写**,这样既有那 9 条测试的行为逐字不变;
|
|
45
|
+
* 只有要断言绝对年龄的用例才显式给 `heartbeatAgoMs`。 */
|
|
46
|
+
function seedHeartbeat(agoMs: number) {
|
|
47
|
+
const iso = new Date(Date.now() - agoMs).toISOString();
|
|
48
|
+
db.run(
|
|
49
|
+
`INSERT INTO sessions (alias, node_id, network_id, status, last_seen_at, updated_at)
|
|
50
|
+
VALUES (?1, ?2, ?3, 'idle', ?4, ?4)`,
|
|
51
|
+
[DAEMON_ALIAS, DAEMON_ID, NET, iso],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function seed(snapshot: object | string | null) {
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
58
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
59
|
+
[USER_ID, USER_ID],
|
|
60
|
+
);
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
63
|
+
VALUES (?1, ?1, ?2, datetime('now'))`,
|
|
64
|
+
[NET, USER_ID],
|
|
65
|
+
);
|
|
66
|
+
db.run(
|
|
67
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
68
|
+
VALUES (?1, ?2, 'admin', datetime('now'))`,
|
|
69
|
+
[USER_ID, NET],
|
|
70
|
+
);
|
|
71
|
+
const snap = snapshot === null
|
|
72
|
+
? null
|
|
73
|
+
: (typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot));
|
|
74
|
+
db.run(
|
|
75
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
76
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`,
|
|
77
|
+
[DAEMON_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET, snap, `host-${DAEMON_ALIAS}`],
|
|
78
|
+
);
|
|
79
|
+
db.run(
|
|
80
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
81
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
82
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tools(): Record<string, ToolHandler> {
|
|
87
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
88
|
+
const out: Record<string, ToolHandler> = {};
|
|
89
|
+
const origTool = server.tool.bind(server);
|
|
90
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
91
|
+
out[name] = handler;
|
|
92
|
+
return origTool(name, _desc, _schema, handler);
|
|
93
|
+
};
|
|
94
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
95
|
+
if (origRegisterTool) {
|
|
96
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
97
|
+
out[name] = handler;
|
|
98
|
+
return origRegisterTool(name, _cfg, handler);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
registerTools(
|
|
102
|
+
server, undefined,
|
|
103
|
+
/* enforceNetworkId */ NET,
|
|
104
|
+
/* enforceUserId */ USER_ID,
|
|
105
|
+
/* callerAlias */ null,
|
|
106
|
+
/* callerTokenIsNetwork */ true,
|
|
107
|
+
/* callerTokenId */ null,
|
|
108
|
+
);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
113
|
+
const r = await handler(args);
|
|
114
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const spec = {
|
|
118
|
+
name: "child-1353",
|
|
119
|
+
runtime: "claude-agent-sdk",
|
|
120
|
+
model: "claude-sonnet-4-5",
|
|
121
|
+
flags: {},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("#1353 Fix ① — hub gates create_node on daemon-reported can_create_nodes", () => {
|
|
125
|
+
test("🔴 witnessed-red: blocked daemon (can_create_nodes=false + reason) → refuse with reason, NO dispatch", async () => {
|
|
126
|
+
seed({
|
|
127
|
+
role: "host_supervisor",
|
|
128
|
+
daemon_capabilities: {
|
|
129
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
130
|
+
can_create_nodes: false,
|
|
131
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const t = tools();
|
|
135
|
+
const r = await call(t.create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
136
|
+
expect(r.ok).toBe(false);
|
|
137
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
138
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
139
|
+
// No request row inserted — hub must refuse BEFORE dispatch.
|
|
140
|
+
const rows = db.get<{ n: number }>(
|
|
141
|
+
"SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1",
|
|
142
|
+
DAEMON_ID,
|
|
143
|
+
);
|
|
144
|
+
expect(rows?.n).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("blocked daemon with each of the 4 reason codes surfaces that specific code", async () => {
|
|
148
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
149
|
+
for (const code of codes) {
|
|
150
|
+
cleanup();
|
|
151
|
+
seed({
|
|
152
|
+
role: "host_supervisor",
|
|
153
|
+
daemon_capabilities: {
|
|
154
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
155
|
+
can_create_nodes: false,
|
|
156
|
+
create_nodes_blocked_reason: code,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
160
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
161
|
+
expect(r.blocked_reason).toBe(code);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("blocked with .40-shape 'anet_bin_pin_unresolved' reason surfaces that literal (backcompat)", async () => {
|
|
166
|
+
// The Zod enum in tools.ts still accepts this coarse pre-#1377 code
|
|
167
|
+
// because agent-node@2.5.0-preview.40 daemons in the wild still emit
|
|
168
|
+
// it. If we ever swallow it into "unknown", those daemons' user
|
|
169
|
+
// messages get worse than they already are.
|
|
170
|
+
seed({
|
|
171
|
+
role: "host_supervisor",
|
|
172
|
+
daemon_capabilities: {
|
|
173
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
174
|
+
can_create_nodes: false,
|
|
175
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
179
|
+
expect(r.blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("blocked with NO reason field → fallback 'anet_bin_unknown' (defensive)", async () => {
|
|
183
|
+
seed({
|
|
184
|
+
role: "host_supervisor",
|
|
185
|
+
daemon_capabilities: {
|
|
186
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
187
|
+
can_create_nodes: false,
|
|
188
|
+
// no create_nodes_blocked_reason
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
192
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
193
|
+
expect(r.blocked_reason).toBe("anet_bin_unknown");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("can_create_nodes=true daemon dispatches normally (gate must not false-positive on healthy daemon)", async () => {
|
|
197
|
+
seed({
|
|
198
|
+
role: "host_supervisor",
|
|
199
|
+
daemon_capabilities: {
|
|
200
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
201
|
+
can_create_nodes: true,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
205
|
+
// Doesn't matter whether downstream dispatch succeeds — the point
|
|
206
|
+
// is that THIS gate did not reject. If reject, r.error would be
|
|
207
|
+
// "daemon_cannot_create_nodes".
|
|
208
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("🔴 pre-#1371 compat: snapshot with NO can_create_nodes field passes through (must NOT fail-closed)", async () => {
|
|
212
|
+
// preview.10 through preview.55 daemons never report this field.
|
|
213
|
+
// If we fail-closed on undefined we silently break every in-flight
|
|
214
|
+
// daemon that hasn't upgraded. Guard is "known-blocked", not
|
|
215
|
+
// "unknown-treated-as-blocked".
|
|
216
|
+
seed({
|
|
217
|
+
role: "host_supervisor",
|
|
218
|
+
daemon_capabilities: {
|
|
219
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
220
|
+
// no can_create_nodes at all
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
224
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("pre-#1371 compat: snapshot with no daemon_capabilities at all → passes through", async () => {
|
|
228
|
+
seed({ role: "host_supervisor" });
|
|
229
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
230
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("pre-#1371 compat: NULL config_snapshot passes through (very old daemon shape)", async () => {
|
|
234
|
+
seed(null);
|
|
235
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
236
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("malformed config_snapshot: permissive fallthrough (mirrors runtimes_supported catch), no crash", async () => {
|
|
240
|
+
seed("{not json");
|
|
241
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
242
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/* #1545 —— 拒绝的时候还要说**那个判断是什么时候做出来的**。
|
|
247
|
+
*
|
|
248
|
+
* 为什么这一格落在拒绝路径上最要紧:用户被挡住的这一刻,正好在决定"去修哪台机器"。
|
|
249
|
+
* 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
250
|
+
* 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
251
|
+
* (agent-node ≤ 2.5.0-preview.54),它可能早就好了 —— 先重启/升级它
|
|
252
|
+
* 只给 `blocked_reason` 的话,这两种情况给用户的字**一模一样**。 */
|
|
253
|
+
describe("#1545 拒绝文案带上「这个判断是多久以前做的」", () => {
|
|
254
|
+
const blockedSnap = (extra: object) => ({
|
|
255
|
+
role: "host_supervisor",
|
|
256
|
+
daemon_capabilities: {
|
|
257
|
+
can_create_nodes: false,
|
|
258
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
259
|
+
...extra,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/* 🔴 报出去的是**到现在为止**的绝对年龄,不是 daemon 报的那个原始时长。
|
|
264
|
+
* daemon 报的是「测完到**这份 report 发出**」;直接透传会漏掉心跳那一段,
|
|
265
|
+
* **朝更新鲜的方向低报** —— 正是这一格要防的方向。
|
|
266
|
+
* 绝对年龄 = (now − last_seen_at) + daemon 报的时长 */
|
|
267
|
+
test("daemon 报了年龄 + 有心跳时间 → 补上心跳那一段,标记 known", async () => {
|
|
268
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
269
|
+
seedHeartbeat(60_000); // 上次心跳在 60s 前
|
|
270
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
271
|
+
expect(r.ok).toBe(false);
|
|
272
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
273
|
+
expect(r.capability_age).toBe("known");
|
|
274
|
+
// 允许 ±5s 的执行抖动;关键是它**明显大于** 12,345,即心跳那一段确实被加上了
|
|
275
|
+
const got = r.capability_observed_ms_ago as number;
|
|
276
|
+
expect(got).toBeGreaterThanOrEqual(12_345 + 60_000 - 5_000);
|
|
277
|
+
expect(got).toBeLessThanOrEqual(12_345 + 60_000 + 5_000);
|
|
278
|
+
// 🔴 反向锚:如果实现是"原样透传",这个数会恰好等于 12,345
|
|
279
|
+
expect(got).not.toBe(12_345);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* 🔴 第三种 unknown,不能和「旧 daemon 没报」塌成同一个:
|
|
283
|
+
* 它报了,但 hub 这边没有可用的心跳时间 ⇒ **补不出绝对年龄**,而不是"年龄是 0"。 */
|
|
284
|
+
test("报了年龄但 hub 没有心跳时间 → unknown_no_heartbeat_time(不是 legacy,也不是 0)", async () => {
|
|
285
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
286
|
+
// 故意不 seedHeartbeat
|
|
287
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
288
|
+
expect(r.capability_age).toBe("unknown_no_heartbeat_time");
|
|
289
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/* 🔴 本组的重点:年龄未知时给的是**显式 null + 说明为什么的枚举**,
|
|
293
|
+
* 而不是省略这两个键。省略读起来像"没有这个问题",而调用方(常常是个 agent)
|
|
294
|
+
* 会默认它是刚测的 —— 那是朝「更可信」方向撒谎,比不给更糟。 */
|
|
295
|
+
test("🔴 daemon 没报年龄 → 两个键都在:null + unknown_legacy_daemon(不是省略)", async () => {
|
|
296
|
+
seed(blockedSnap({}));
|
|
297
|
+
seedHeartbeat(1_000); // 心跳齐全 ⇒ 唯一缺的就是 daemon 没报那一格
|
|
298
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
299
|
+
expect(r.ok).toBe(false);
|
|
300
|
+
expect("capability_observed_ms_ago" in r).toBe(true);
|
|
301
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
302
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
303
|
+
// 🔴 绝不能渲染成"刚测的"
|
|
304
|
+
expect(r.capability_observed_ms_ago).not.toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["非数字", "12"],
|
|
311
|
+
["null", null],
|
|
312
|
+
])("坏值当作没报(不 clamp 成一个看起来正常的年龄):%s", async (_l, v) => {
|
|
313
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: v }));
|
|
314
|
+
// 🔴 必须给心跳:否则这几条会因为"没有心跳时间"而变成
|
|
315
|
+
// unknown_no_heartbeat_time,**坏值那一维根本没被测到**
|
|
316
|
+
// (夹具只该在被测的那一维上不合法)。
|
|
317
|
+
seedHeartbeat(1_000);
|
|
318
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
319
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
320
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
321
|
+
// 分母自证:拒绝本身仍然发生了,不是因为整条路径没走到才"没有年龄"
|
|
322
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("边界:恰好一年被接受 ⇒ 上面那些不是因为恒判 unknown 才绿", async () => {
|
|
326
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
327
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: oneYear }));
|
|
328
|
+
seedHeartbeat(1_000);
|
|
329
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
330
|
+
expect(r.capability_age).toBe("known");
|
|
331
|
+
expect(r.capability_observed_ms_ago as number).toBeGreaterThanOrEqual(oneYear);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/* 🔴 hub 出**代码**,不出渲染好的修法命令 —— 那张 code → 命令 的表只有一个作者
|
|
335
|
+
* (@sleep2agi/agent-network 的 daemon-capability-display)。抄一份到 hub,
|
|
336
|
+
* 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时没有任何东西会红。 */
|
|
337
|
+
test("🔴 hub 不返回具体修法命令,只把人指到那唯一一份", async () => {
|
|
338
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 0 }));
|
|
339
|
+
seedHeartbeat(1_000);
|
|
340
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
341
|
+
const blob = JSON.stringify(r);
|
|
342
|
+
for (const leaked of ["chmod go-w", "npm i -g", "readlink -f", "/etc/anet-daemon"]) {
|
|
343
|
+
expect(blob).not.toContain(leaked);
|
|
344
|
+
}
|
|
345
|
+
expect(String(r.remediation_hint)).toContain("anet daemon list");
|
|
346
|
+
// 正控:这四条不是恒真 —— 它们确实是那份表里的真实内容
|
|
347
|
+
expect("该二进制 group/other 可写。一行修:chmod go-w \"$(command -v anet)\"").toContain("chmod go-w");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("健康 daemon 不受影响:正常派发,不带这两个键", async () => {
|
|
351
|
+
seed({ role: "host_supervisor", daemon_capabilities: { can_create_nodes: true } });
|
|
352
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
353
|
+
expect(r.ok).not.toBe(false);
|
|
354
|
+
expect("capability_age" in r).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { registerTools } from "./tools.js";
|
|
4
|
+
|
|
5
|
+
type ToolHandler = (args: any) => Promise<any> | any;
|
|
6
|
+
|
|
7
|
+
function captureSchemas(): Record<string, Record<string, any>> {
|
|
8
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
9
|
+
const schemas: Record<string, Record<string, any>> = {};
|
|
10
|
+
const origTool = server.tool.bind(server);
|
|
11
|
+
server.tool = (name: string, desc: string, schema: any, handler: ToolHandler) => {
|
|
12
|
+
schemas[name] = schema;
|
|
13
|
+
return origTool(name, desc, schema, handler);
|
|
14
|
+
};
|
|
15
|
+
registerTools(server, undefined, null, "u_schema_test", "schema-test", false, null);
|
|
16
|
+
return schemas;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("create_node MCP schema", () => {
|
|
20
|
+
test("model is optional on daemon create specs but empty strings stay invalid", () => {
|
|
21
|
+
const schemas = captureSchemas();
|
|
22
|
+
const nodeSpec = schemas.create_node.node_spec;
|
|
23
|
+
|
|
24
|
+
expect(nodeSpec.safeParse({
|
|
25
|
+
name: "child",
|
|
26
|
+
runtime: "claude-agent-sdk",
|
|
27
|
+
}).success).toBe(true);
|
|
28
|
+
expect(nodeSpec.safeParse({
|
|
29
|
+
name: "child",
|
|
30
|
+
runtime: "claude-agent-sdk",
|
|
31
|
+
model: null,
|
|
32
|
+
}).success).toBe(true);
|
|
33
|
+
expect(nodeSpec.safeParse({
|
|
34
|
+
name: "child",
|
|
35
|
+
runtime: "claude-agent-sdk",
|
|
36
|
+
model: "",
|
|
37
|
+
}).success).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|