@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.3
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 +3 -2
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +163 -1
- 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 +299 -0
- package/src/db.ts +60 -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/tools.ts +491 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.3",
|
|
4
4
|
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
50
50
|
"bun-types": "^1.3.13",
|
|
51
|
-
"hono": "^4.12.25"
|
|
51
|
+
"hono": "^4.12.25",
|
|
52
|
+
"zod": "^4.4.3"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/auth.ts
CHANGED
|
@@ -211,7 +211,9 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
211
211
|
`SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
212
212
|
u.username, u.display_name, u.email, u.role
|
|
213
213
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
214
|
-
WHERE t.token_hash = ?1
|
|
214
|
+
WHERE t.token_hash = ?1
|
|
215
|
+
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
216
|
+
AND t.revoked_at IS NULL`,
|
|
215
217
|
tHash);
|
|
216
218
|
|
|
217
219
|
if (!row) return null;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { describe, expect, test, beforeEach } from "bun:test";
|
|
18
18
|
import { db, uuidv4 } from "./db.js";
|
|
19
|
-
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
19
|
+
import { upsertNodeWithSec1Guard, finalizePendingMatchingUpdates } from "./tools.js";
|
|
20
20
|
|
|
21
21
|
function insertNode(opts: { node_id?: string; alias: string; network_id: string | null }): string {
|
|
22
22
|
const id = opts.node_id ?? uuidv4();
|
|
@@ -445,6 +445,168 @@ describe("SEC trust-root — REAL driver via upsertNodeWithSec1Guard (catches gu
|
|
|
445
445
|
});
|
|
446
446
|
});
|
|
447
447
|
|
|
448
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
449
|
+
// finalizePendingMatchingUpdates — Option A restart-finalize matcher
|
|
450
|
+
// (通信牛 #290 final CHANGE_REQ 2026-06-28). Real-driver tests: drive
|
|
451
|
+
// the helper that production report_status delegates to + assert on
|
|
452
|
+
// the persisted state. Same single-source-of-truth pattern as
|
|
453
|
+
// upsertNodeWithSec1Guard (prevents inline-mirror test drift).
|
|
454
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
455
|
+
describe("finalizePendingMatchingUpdates — Option A restart-finalize via content match", () => {
|
|
456
|
+
function insertPendingUpdate(opts: {
|
|
457
|
+
update_id?: string;
|
|
458
|
+
node_id: string;
|
|
459
|
+
network_id: string;
|
|
460
|
+
patch: any;
|
|
461
|
+
apply_mode: "hot" | "restart" | "restart_only";
|
|
462
|
+
status?: "pending" | "restarting";
|
|
463
|
+
}): string {
|
|
464
|
+
const id = opts.update_id ?? `cu_${uuidv4()}`;
|
|
465
|
+
db.run(
|
|
466
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, 'test')`,
|
|
467
|
+
[id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
|
|
468
|
+
);
|
|
469
|
+
return id;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
test("empty patch (restart_only) → finalizes on ANY snapshot (restart itself = goal)", () => {
|
|
473
|
+
const nid = insertNode({ alias: "n1", network_id: "netA" });
|
|
474
|
+
const uid = insertPendingUpdate({
|
|
475
|
+
node_id: nid, network_id: "netA", patch: {}, apply_mode: "restart_only", status: "restarting",
|
|
476
|
+
});
|
|
477
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "any", flags: {} });
|
|
478
|
+
expect(r.finalizedCount).toBe(1);
|
|
479
|
+
expect(r.finalizedIds).toContain(uid);
|
|
480
|
+
const row = db.get<any>("SELECT status, new_revision FROM node_config_updates WHERE update_id = ?1", uid);
|
|
481
|
+
expect(row.status).toBe("applied");
|
|
482
|
+
expect(row.new_revision).toBe(1);
|
|
483
|
+
const node = db.get<any>("SELECT config_revision FROM nodes WHERE node_id = ?1", nid);
|
|
484
|
+
expect(node.config_revision).toBe(1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("model patch + snapshot.model matches → finalized", () => {
|
|
488
|
+
const nid = insertNode({ alias: "n2", network_id: "netA" });
|
|
489
|
+
const uid = insertPendingUpdate({
|
|
490
|
+
node_id: nid, network_id: "netA",
|
|
491
|
+
patch: { model: "claude-opus-4-new", flags: {} },
|
|
492
|
+
apply_mode: "restart",
|
|
493
|
+
});
|
|
494
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
495
|
+
model: "claude-opus-4-new", flags: { maxTurns: 50 },
|
|
496
|
+
});
|
|
497
|
+
expect(r.finalizedCount).toBe(1);
|
|
498
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("model patch + snapshot.model DIFFERENT → NOT finalized (left pending)", () => {
|
|
502
|
+
const nid = insertNode({ alias: "n3", network_id: "netA" });
|
|
503
|
+
const uid = insertPendingUpdate({
|
|
504
|
+
node_id: nid, network_id: "netA",
|
|
505
|
+
patch: { model: "claude-opus-4-new", flags: {} },
|
|
506
|
+
apply_mode: "restart",
|
|
507
|
+
});
|
|
508
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
509
|
+
model: "claude-sonnet-3-old", flags: {},
|
|
510
|
+
});
|
|
511
|
+
expect(r.finalizedCount).toBe(0);
|
|
512
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("flags patch + every flag matches snapshot → finalized", () => {
|
|
516
|
+
const nid = insertNode({ alias: "n4", network_id: "netA" });
|
|
517
|
+
const uid = insertPendingUpdate({
|
|
518
|
+
node_id: nid, network_id: "netA",
|
|
519
|
+
patch: { flags: { maxTurns: 99, budget: 500 } },
|
|
520
|
+
apply_mode: "hot",
|
|
521
|
+
});
|
|
522
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
523
|
+
flags: { maxTurns: 99, budget: 500, dangerouslySkipPermissions: true },
|
|
524
|
+
});
|
|
525
|
+
expect(r.finalizedCount).toBe(1);
|
|
526
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("flags patch + ONE flag mismatch → NOT finalized (any field absent = no match)", () => {
|
|
530
|
+
const nid = insertNode({ alias: "n5", network_id: "netA" });
|
|
531
|
+
const uid = insertPendingUpdate({
|
|
532
|
+
node_id: nid, network_id: "netA",
|
|
533
|
+
patch: { flags: { maxTurns: 99, budget: 500 } },
|
|
534
|
+
apply_mode: "hot",
|
|
535
|
+
});
|
|
536
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
537
|
+
flags: { maxTurns: 99, budget: 999 }, // budget wrong
|
|
538
|
+
});
|
|
539
|
+
expect(r.finalizedCount).toBe(0);
|
|
540
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("flags patch + flag absent from snapshot → NOT finalized", () => {
|
|
544
|
+
const nid = insertNode({ alias: "n6", network_id: "netA" });
|
|
545
|
+
const uid = insertPendingUpdate({
|
|
546
|
+
node_id: nid, network_id: "netA",
|
|
547
|
+
patch: { flags: { permissionMode: "auto" } },
|
|
548
|
+
apply_mode: "restart",
|
|
549
|
+
});
|
|
550
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
551
|
+
flags: { maxTurns: 50 }, // permissionMode missing entirely
|
|
552
|
+
});
|
|
553
|
+
expect(r.finalizedCount).toBe(0);
|
|
554
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("no pending update → no-op (idempotent on routine heartbeats)", () => {
|
|
558
|
+
const nid = insertNode({ alias: "n7", network_id: "netA" });
|
|
559
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: { maxTurns: 50 } });
|
|
560
|
+
expect(r.finalizedCount).toBe(0);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("restarting status finalizes too (not just pending)", () => {
|
|
564
|
+
const nid = insertNode({ alias: "n8", network_id: "netA" });
|
|
565
|
+
const uid = insertPendingUpdate({
|
|
566
|
+
node_id: nid, network_id: "netA",
|
|
567
|
+
patch: { model: "new" },
|
|
568
|
+
apply_mode: "restart",
|
|
569
|
+
status: "restarting",
|
|
570
|
+
});
|
|
571
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "new", flags: {} });
|
|
572
|
+
expect(r.finalizedCount).toBe(1);
|
|
573
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("two nodes with separate pending updates → finalize only matching one", () => {
|
|
577
|
+
const nidA = insertNode({ alias: "match-this", network_id: "netA" });
|
|
578
|
+
const nidB = insertNode({ alias: "leave-alone", network_id: "netA" });
|
|
579
|
+
const uidA = insertPendingUpdate({
|
|
580
|
+
node_id: nidA, network_id: "netA",
|
|
581
|
+
patch: { model: "matched-model" },
|
|
582
|
+
apply_mode: "restart",
|
|
583
|
+
});
|
|
584
|
+
const uidB = insertPendingUpdate({
|
|
585
|
+
node_id: nidB, network_id: "netA",
|
|
586
|
+
patch: { model: "different-model" },
|
|
587
|
+
apply_mode: "restart",
|
|
588
|
+
});
|
|
589
|
+
finalizePendingMatchingUpdates(nidA, { model: "matched-model", flags: {} });
|
|
590
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uidA).status).toBe("applied");
|
|
591
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uidB).status).toBe("pending");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("config_revision bumps to base+1 for single finalize (multi-pending blocked by F-C index)", () => {
|
|
595
|
+
// F-C partial unique index (db.ts) prevents two non-terminal updates
|
|
596
|
+
// for the same node, so the multi-finalize scenario is unreachable
|
|
597
|
+
// at the DB layer. This test pins that ONE pending finalize → revision = base + 1.
|
|
598
|
+
const nid = insertNode({ alias: "n10", network_id: "netA" });
|
|
599
|
+
insertPendingUpdate({
|
|
600
|
+
node_id: nid, network_id: "netA",
|
|
601
|
+
patch: { model: "v1" },
|
|
602
|
+
apply_mode: "restart",
|
|
603
|
+
});
|
|
604
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "v1", flags: {} });
|
|
605
|
+
expect(r.finalizedCount).toBe(1);
|
|
606
|
+
expect(db.get<any>("SELECT config_revision FROM nodes WHERE node_id = ?1", nid).config_revision).toBe(1);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
448
610
|
describe("F-B polish — reaper anchors on COALESCE(acked_at, created_at), not created_at", () => {
|
|
449
611
|
// 通信龙 catch: reaper threshold = 60s and drain hard-cap = 60s overlap.
|
|
450
612
|
// A healthy-but-slow restart (drain 60s + respawn time) could exceed
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ValidationError,
|
|
4
|
+
validateName, validateRuntime, validateModel, validateFlagValue,
|
|
5
|
+
validateEnvRefs, validateChannelsP1, serializeEnvLocal, buildAnetArgs,
|
|
6
|
+
MAX_ENV_KEYS_PER_NODE,
|
|
7
|
+
} from "./create-node-validate.js";
|
|
8
|
+
|
|
9
|
+
const okSecret = (k: string, _net: string, key: string) => k === key ? `value-of-${k}` : undefined;
|
|
10
|
+
const allow = (k: string) => new Set<string>([k, "ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
11
|
+
|
|
12
|
+
describe("validateName (§4.2.2)", () => {
|
|
13
|
+
test("good names", () => {
|
|
14
|
+
for (const n of ["a", "demo-bot", "node_1", "z2", "abc-def-ghi"]) {
|
|
15
|
+
expect(() => validateName(n)).not.toThrow();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
test("rejects shell-injection-like names", () => {
|
|
19
|
+
for (const n of [";rm -rf /", "demo bot", "Demo", "1demo", "-demo", "demo;rm", "demo$INJECT", "x".repeat(65)]) {
|
|
20
|
+
expect(() => validateName(n)).toThrow(ValidationError);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("validateRuntime / validateModel (§4.2.2)", () => {
|
|
26
|
+
test("runtime enum", () => {
|
|
27
|
+
expect(() => validateRuntime("claude-agent-sdk")).not.toThrow();
|
|
28
|
+
expect(() => validateRuntime("bash")).toThrow(ValidationError);
|
|
29
|
+
expect(() => validateRuntime("")).toThrow(ValidationError);
|
|
30
|
+
});
|
|
31
|
+
test("model with dots, colons, dashes OK; bad chars rejected", () => {
|
|
32
|
+
expect(() => validateModel("claude-opus-4.6")).not.toThrow();
|
|
33
|
+
expect(() => validateModel("claude-opus-4-6")).not.toThrow();
|
|
34
|
+
expect(() => validateModel("vendor:model")).not.toThrow();
|
|
35
|
+
expect(() => validateModel("gpt-4o")).not.toThrow();
|
|
36
|
+
expect(() => validateModel("bad model")).toThrow(ValidationError);
|
|
37
|
+
expect(() => validateModel("x;rm")).toThrow(ValidationError);
|
|
38
|
+
expect(() => validateModel("")).toThrow(ValidationError);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("validateFlagValue (§4.2.2)", () => {
|
|
43
|
+
test("budget decimal allowed", () => {
|
|
44
|
+
expect(() => validateFlagValue("budget", 5.5)).not.toThrow();
|
|
45
|
+
expect(() => validateFlagValue("budget", 0)).not.toThrow();
|
|
46
|
+
expect(() => validateFlagValue("budget", 1001)).toThrow(ValidationError);
|
|
47
|
+
expect(() => validateFlagValue("budget", -1)).toThrow(ValidationError);
|
|
48
|
+
});
|
|
49
|
+
test("maxTurns integer 1..9999", () => {
|
|
50
|
+
expect(() => validateFlagValue("maxTurns", 50)).not.toThrow();
|
|
51
|
+
expect(() => validateFlagValue("maxTurns", 5.5)).toThrow(ValidationError);
|
|
52
|
+
expect(() => validateFlagValue("maxTurns", 0)).toThrow(ValidationError);
|
|
53
|
+
expect(() => validateFlagValue("maxTurns", "DROP TABLE")).toThrow(ValidationError);
|
|
54
|
+
});
|
|
55
|
+
test("dangerouslySkipPermissions boolean", () => {
|
|
56
|
+
expect(() => validateFlagValue("dangerouslySkipPermissions", true)).not.toThrow();
|
|
57
|
+
expect(() => validateFlagValue("dangerouslySkipPermissions", "true")).toThrow(ValidationError);
|
|
58
|
+
});
|
|
59
|
+
test("permissionMode enum", () => {
|
|
60
|
+
expect(() => validateFlagValue("permissionMode", "plan")).not.toThrow();
|
|
61
|
+
expect(() => validateFlagValue("permissionMode", "anything-else")).toThrow(ValidationError);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("validateEnvRefs (§4.4.7 + B1 G7/G8 sub-cases)", () => {
|
|
66
|
+
test("good keys resolve", () => {
|
|
67
|
+
const env = validateEnvRefs(["ANTHROPIC_API_KEY"], {
|
|
68
|
+
callerNetworkId: "n", daemonAllowList: allow("ANTHROPIC_API_KEY"),
|
|
69
|
+
networkSecretsGet: (n, k) => okSecret("ANTHROPIC_API_KEY", n, k),
|
|
70
|
+
});
|
|
71
|
+
expect(env).toEqual({ ANTHROPIC_API_KEY: "value-of-ANTHROPIC_API_KEY" });
|
|
72
|
+
});
|
|
73
|
+
test("G7: PATH rejected (exact denylist)", () => {
|
|
74
|
+
expect(() => validateEnvRefs(["PATH"], {
|
|
75
|
+
callerNetworkId: "n", daemonAllowList: allow("PATH"),
|
|
76
|
+
networkSecretsGet: () => "evil:/tmp/bin",
|
|
77
|
+
})).toThrow(ValidationError);
|
|
78
|
+
});
|
|
79
|
+
test("G8: LD_PRELOAD / DYLD_* / BUN_* / NPM_* / NODE_OPTIONS rejected (prefix)", () => {
|
|
80
|
+
for (const k of ["LD_PRELOAD", "DYLD_INSERT_LIBRARIES", "BUN_INSTALL", "NPM_TOKEN", "NPM_CONFIG_REGISTRY", "NODE_PATH", "NODE_OPTIONS"]) {
|
|
81
|
+
expect(() => validateEnvRefs([k], {
|
|
82
|
+
callerNetworkId: "n", daemonAllowList: new Set([k]),
|
|
83
|
+
networkSecretsGet: () => "x",
|
|
84
|
+
})).toThrow(ValidationError);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test("G1 bad regex (lowercase / digit-first / too long)", () => {
|
|
88
|
+
for (const k of ["path", "1KEY", "_KEY", "a".repeat(65)]) {
|
|
89
|
+
expect(() => validateEnvRefs([k], {
|
|
90
|
+
callerNetworkId: "n", daemonAllowList: new Set([k]), networkSecretsGet: () => "x",
|
|
91
|
+
})).toThrow(ValidationError);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
test("G2 duplicate", () => {
|
|
95
|
+
expect(() => validateEnvRefs(["A_KEY", "A_KEY"], {
|
|
96
|
+
callerNetworkId: "n", daemonAllowList: new Set(["A_KEY"]), networkSecretsGet: () => "x",
|
|
97
|
+
})).toThrow(ValidationError);
|
|
98
|
+
});
|
|
99
|
+
test("G3 over max count", () => {
|
|
100
|
+
const refs = Array.from({ length: MAX_ENV_KEYS_PER_NODE + 1 }, (_, i) => `KEY_${i}`);
|
|
101
|
+
const allowAll = new Set(refs);
|
|
102
|
+
expect(() => validateEnvRefs(refs, {
|
|
103
|
+
callerNetworkId: "n", daemonAllowList: allowAll, networkSecretsGet: () => "x",
|
|
104
|
+
})).toThrow(ValidationError);
|
|
105
|
+
});
|
|
106
|
+
test("G4 not in vault", () => {
|
|
107
|
+
expect(() => validateEnvRefs(["ANTHROPIC_API_KEY"], {
|
|
108
|
+
callerNetworkId: "n", daemonAllowList: allow("ANTHROPIC_API_KEY"),
|
|
109
|
+
networkSecretsGet: () => undefined,
|
|
110
|
+
})).toThrow(ValidationError);
|
|
111
|
+
});
|
|
112
|
+
test("G5 not in daemon allowlist", () => {
|
|
113
|
+
expect(() => validateEnvRefs(["OPENAI_API_KEY"], {
|
|
114
|
+
callerNetworkId: "n", daemonAllowList: new Set(["ANTHROPIC_API_KEY"]),
|
|
115
|
+
networkSecretsGet: () => "x",
|
|
116
|
+
})).toThrow(ValidationError);
|
|
117
|
+
});
|
|
118
|
+
test("undefined/empty refs is OK (no env at all)", () => {
|
|
119
|
+
expect(validateEnvRefs(undefined, { callerNetworkId: "n", daemonAllowList: new Set(), networkSecretsGet: () => undefined })).toEqual({});
|
|
120
|
+
expect(validateEnvRefs([], { callerNetworkId: "n", daemonAllowList: new Set(), networkSecretsGet: () => undefined })).toEqual({});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("serializeEnvLocal (§4.4.7 G6 — safe escape)", () => {
|
|
125
|
+
test("newline in secret value escapes to literal \\n, no line pollution", () => {
|
|
126
|
+
const out = serializeEnvLocal({ KEY: 'foo\nevil="KEY2"' });
|
|
127
|
+
// The \n must be the 2-char literal `\n` in the file, NOT an actual
|
|
128
|
+
// newline. There must be NO bare `evil=` token on its own line.
|
|
129
|
+
expect(out).toBe('KEY="foo\\nevil=\\"KEY2\\""\n');
|
|
130
|
+
expect(out.split("\n").length).toBe(2); // 1 data line + trailing empty
|
|
131
|
+
});
|
|
132
|
+
test("backslash escaped first (so subsequent escapes survive)", () => {
|
|
133
|
+
expect(serializeEnvLocal({ KEY: "a\\b" })).toBe('KEY="a\\\\b"\n');
|
|
134
|
+
});
|
|
135
|
+
test("multi-key", () => {
|
|
136
|
+
expect(serializeEnvLocal({ A: "x", B: "y" })).toBe('A="x"\nB="y"\n');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("validateChannelsP1 (§4.2.5 C5)", () => {
|
|
141
|
+
test("empty / omitted OK", () => {
|
|
142
|
+
expect(() => validateChannelsP1([])).not.toThrow();
|
|
143
|
+
expect(() => validateChannelsP1(undefined)).not.toThrow();
|
|
144
|
+
expect(() => validateChannelsP1(null)).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
test("non-empty rejected (any element)", () => {
|
|
147
|
+
expect(() => validateChannelsP1(["telegram"])).toThrow(ValidationError);
|
|
148
|
+
expect(() => validateChannelsP1([null])).toThrow(ValidationError);
|
|
149
|
+
expect(() => validateChannelsP1([{}])).toThrow(ValidationError);
|
|
150
|
+
});
|
|
151
|
+
test("not-an-array rejected", () => {
|
|
152
|
+
expect(() => validateChannelsP1("telegram")).toThrow(ValidationError);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
|
|
157
|
+
test("happy path", () => {
|
|
158
|
+
const args = buildAnetArgs({
|
|
159
|
+
name: "demo-bot", runtime: "claude-agent-sdk", model: "claude-opus-4-6",
|
|
160
|
+
flags: { maxTurns: 50, budget: 5 },
|
|
161
|
+
});
|
|
162
|
+
expect(args).toEqual(["node", "create", "demo-bot", "--runtime", "claude-agent-sdk", "--model", "claude-opus-4-6", "--max-turns", "50", "--budget", "5"]);
|
|
163
|
+
});
|
|
164
|
+
test("permissionMode kebab-case", () => {
|
|
165
|
+
const args = buildAnetArgs({
|
|
166
|
+
name: "x", runtime: "codex-sdk", model: "gpt-4o",
|
|
167
|
+
flags: { permissionMode: "plan" },
|
|
168
|
+
});
|
|
169
|
+
expect(args).toContain("--permission-mode");
|
|
170
|
+
expect(args[args.indexOf("--permission-mode") + 1]).toBe("plan");
|
|
171
|
+
});
|
|
172
|
+
test("rejects bad name with shell metachar", () => {
|
|
173
|
+
expect(() => buildAnetArgs({ name: ";rm -rf /", runtime: "claude-agent-sdk", model: "x" })).toThrow(ValidationError);
|
|
174
|
+
});
|
|
175
|
+
test("rejects bad flag key", () => {
|
|
176
|
+
expect(() => buildAnetArgs({
|
|
177
|
+
name: "x", runtime: "claude-agent-sdk", model: "x",
|
|
178
|
+
flags: { evilKey: 1 } as any,
|
|
179
|
+
})).toThrow(ValidationError);
|
|
180
|
+
});
|
|
181
|
+
test("rejects channels at build time too (E end-to-end injection)", () => {
|
|
182
|
+
expect(() => buildAnetArgs({
|
|
183
|
+
name: "x", runtime: "claude-agent-sdk", model: "x",
|
|
184
|
+
channels: ["telegram"],
|
|
185
|
+
} as any)).toThrow(ValidationError);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// RFC-026 v4 §4.2.2 (F2 structured) + §4.4.7 (C1+B1 env_refs) + §4.2.6
|
|
2
|
+
// (B2 fork wrapper) — pure validators + arg builder. No DB access. Used
|
|
3
|
+
// by hub create_node MCP tool (RPC entry) AND by daemon get_create_request
|
|
4
|
+
// (re-check, defense in depth).
|
|
5
|
+
//
|
|
6
|
+
// All validation errors throw ValidationError with a stable `code` field
|
|
7
|
+
// that the MCP tool surface maps to a JSON {ok:false, error: <code>}
|
|
8
|
+
// payload (RFC §4.6).
|
|
9
|
+
|
|
10
|
+
import { isReservedEnvKey } from "./shared/reserved-env.js";
|
|
11
|
+
|
|
12
|
+
export class ValidationError extends Error {
|
|
13
|
+
constructor(public code: string, public detail?: Record<string, unknown>) {
|
|
14
|
+
super(`${code}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
|
|
15
|
+
this.name = "ValidationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// §4.2.2 — structural enums.
|
|
20
|
+
export const RUNTIMES = ["claude-agent-sdk", "codex-sdk", "grok-build-acp"] as const;
|
|
21
|
+
export type Runtime = typeof RUNTIMES[number];
|
|
22
|
+
|
|
23
|
+
export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout"] as const;
|
|
24
|
+
export type FlagKey = typeof FLAG_KEYS[number];
|
|
25
|
+
|
|
26
|
+
export const PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"] as const;
|
|
27
|
+
|
|
28
|
+
// §4.4.7 — env_refs limits.
|
|
29
|
+
export const MAX_ENV_KEYS_PER_NODE = 32;
|
|
30
|
+
export const MAX_ENV_VALUE_BYTES = 16 * 1024;
|
|
31
|
+
const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
32
|
+
|
|
33
|
+
// §4.2.2 name + model.
|
|
34
|
+
const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
35
|
+
const MODEL_RE = /^[a-zA-Z0-9._:\-]+$/;
|
|
36
|
+
|
|
37
|
+
export function validateName(s: unknown): asserts s is string {
|
|
38
|
+
if (typeof s !== "string" || !NAME_RE.test(s)) {
|
|
39
|
+
throw new ValidationError("node_name_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateRuntime(s: unknown): asserts s is Runtime {
|
|
44
|
+
if (typeof s !== "string" || !(RUNTIMES as readonly string[]).includes(s)) {
|
|
45
|
+
throw new ValidationError("runtime_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateModel(s: unknown): asserts s is string {
|
|
50
|
+
if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
|
|
51
|
+
throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateFlagValue(k: string, v: unknown): void {
|
|
56
|
+
switch (k) {
|
|
57
|
+
case "permissionMode":
|
|
58
|
+
if (typeof v !== "string" || !(PERMISSION_MODES as readonly string[]).includes(v)) {
|
|
59
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be one of default/acceptEdits/plan/bypassPermissions" });
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
case "dangerouslySkipPermissions":
|
|
63
|
+
if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
|
|
64
|
+
return;
|
|
65
|
+
case "maxTurns":
|
|
66
|
+
if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 9999) {
|
|
67
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..9999" });
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
case "budget":
|
|
71
|
+
if (typeof v !== "number" || !Number.isFinite(v) || (v as number) < 0 || (v as number) > 1000) {
|
|
72
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be number 0..1000 (decimals OK)" });
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
case "timeout":
|
|
76
|
+
if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 86400) {
|
|
77
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..86400" });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
default:
|
|
81
|
+
throw new ValidationError("flag_key_unknown", { field: k });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// §4.4.7 — env_refs 7-step gate. networkSecretsGet is injected so
|
|
86
|
+
// callers can supply either a real DB lookup (hub) or a fixture (tests).
|
|
87
|
+
// The 6th step also reports the resolved value size; we return the
|
|
88
|
+
// resolved-but-not-yet-used map keyed by env-key so the caller can build
|
|
89
|
+
// env_blob without re-querying.
|
|
90
|
+
export interface EnvRefsContext {
|
|
91
|
+
callerNetworkId: string;
|
|
92
|
+
daemonAllowList: ReadonlySet<string>;
|
|
93
|
+
networkSecretsGet: (networkId: string, key: string) => string | undefined;
|
|
94
|
+
}
|
|
95
|
+
export function validateEnvRefs(
|
|
96
|
+
refs: unknown,
|
|
97
|
+
ctx: EnvRefsContext,
|
|
98
|
+
): Record<string, string> {
|
|
99
|
+
if (!Array.isArray(refs)) {
|
|
100
|
+
if (refs === undefined || refs === null) return {};
|
|
101
|
+
throw new ValidationError("env_refs_invalid", { reason: "must be array" });
|
|
102
|
+
}
|
|
103
|
+
// ① regex
|
|
104
|
+
for (const k of refs) {
|
|
105
|
+
if (typeof k !== "string" || !ENV_KEY_RE.test(k)) {
|
|
106
|
+
throw new ValidationError("env_key_invalid", { key: typeof k === "string" ? k.slice(0, 64) : typeof k });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ② reserved denylist (v4 B1)
|
|
110
|
+
for (const k of refs) {
|
|
111
|
+
if (isReservedEnvKey(k)) {
|
|
112
|
+
throw new ValidationError("env_key_reserved", { key: k });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ③ duplicate detection (must come before ④ count gate to give a
|
|
116
|
+
// truthful error code rather than "too many" for dup-spam).
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
for (const k of refs as string[]) {
|
|
119
|
+
if (seen.has(k)) throw new ValidationError("env_key_duplicate", { key: k });
|
|
120
|
+
seen.add(k);
|
|
121
|
+
}
|
|
122
|
+
// ④ count cap
|
|
123
|
+
if (seen.size > MAX_ENV_KEYS_PER_NODE) {
|
|
124
|
+
throw new ValidationError("env_key_too_many", { count: seen.size, max: MAX_ENV_KEYS_PER_NODE });
|
|
125
|
+
}
|
|
126
|
+
const out: Record<string, string> = {};
|
|
127
|
+
// ⑤ vault presence + ⑥ value size cap
|
|
128
|
+
for (const k of seen) {
|
|
129
|
+
const v = ctx.networkSecretsGet(ctx.callerNetworkId, k);
|
|
130
|
+
if (v === undefined) throw new ValidationError("secret_not_in_vault", { key: k });
|
|
131
|
+
if (Buffer.byteLength(v, "utf8") > MAX_ENV_VALUE_BYTES) {
|
|
132
|
+
throw new ValidationError("secret_too_large", { key: k, max: MAX_ENV_VALUE_BYTES });
|
|
133
|
+
}
|
|
134
|
+
out[k] = v;
|
|
135
|
+
}
|
|
136
|
+
// ⑦ daemon allowlist (per-host whitelist of which secrets this
|
|
137
|
+
// machine is allowed to receive; declared in daemon config by the
|
|
138
|
+
// host operator)
|
|
139
|
+
for (const k of seen) {
|
|
140
|
+
if (!ctx.daemonAllowList.has(k)) {
|
|
141
|
+
throw new ValidationError("secret_not_in_daemon_allowlist", { key: k });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// §4.4.7 — safe serializer for .env.local. Dotenv-quoted with escape
|
|
148
|
+
// for backslash / double-quote / newline / carriage-return so that a
|
|
149
|
+
// value containing `\n"evil=KEY2"` cannot pollute the next line.
|
|
150
|
+
export function serializeEnvLocal(env: Record<string, string>): string {
|
|
151
|
+
return Object.entries(env).map(([k, v]) => {
|
|
152
|
+
const esc = String(v)
|
|
153
|
+
.replace(/\\/g, "\\\\") // 1) backslashes first (or later escapes get un-escaped)
|
|
154
|
+
.replace(/"/g, '\\"') // 2) double-quotes
|
|
155
|
+
.replace(/\n/g, "\\n") // 3) newline → literal \n
|
|
156
|
+
.replace(/\r/g, "\\r"); // 4) carriage return → literal \r
|
|
157
|
+
return `${k}="${esc}"`;
|
|
158
|
+
}).join("\n") + "\n";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// §4.2.5 — channels fail-closed in P1.
|
|
162
|
+
export function validateChannelsP1(channels: unknown): void {
|
|
163
|
+
if (channels === undefined || channels === null) return;
|
|
164
|
+
if (!Array.isArray(channels)) {
|
|
165
|
+
throw new ValidationError("channels_not_supported_in_p1", { reason: "expected array or omitted" });
|
|
166
|
+
}
|
|
167
|
+
if (channels.length > 0) {
|
|
168
|
+
throw new ValidationError("channels_not_supported_in_p1", { received: channels.length, p3_tracker: "RFC-026 §5 P3" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// §4.2.2 — fully validated NodeSpec, ready to feed into buildAnetArgs.
|
|
173
|
+
export interface NodeSpec {
|
|
174
|
+
name: string;
|
|
175
|
+
runtime: Runtime;
|
|
176
|
+
model: string;
|
|
177
|
+
flags?: Record<string, unknown>;
|
|
178
|
+
channels?: unknown;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// kebab-case helper for flag names. permissionMode → permission-mode.
|
|
182
|
+
function kebab(k: string): string {
|
|
183
|
+
return k.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// §4.2.2 — build the argv that gets fed verbatim to execFile (no
|
|
187
|
+
// shell, no string concat). Each value has already passed type/enum
|
|
188
|
+
// validation, so String() coercion is safe.
|
|
189
|
+
export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
190
|
+
validateName(spec.name);
|
|
191
|
+
validateRuntime(spec.runtime);
|
|
192
|
+
validateModel(spec.model);
|
|
193
|
+
validateChannelsP1(spec.channels);
|
|
194
|
+
|
|
195
|
+
const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime, "--model", spec.model];
|
|
196
|
+
if (spec.flags && typeof spec.flags === "object") {
|
|
197
|
+
for (const [k, v] of Object.entries(spec.flags)) {
|
|
198
|
+
if (!(FLAG_KEYS as readonly string[]).includes(k)) {
|
|
199
|
+
throw new ValidationError("flag_key_unknown", { field: k });
|
|
200
|
+
}
|
|
201
|
+
validateFlagValue(k, v);
|
|
202
|
+
args.push(`--${kebab(k)}`, String(v));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return args;
|
|
206
|
+
}
|