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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.2",
3
+ "version": "0.9.0-preview.4",
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",
@@ -0,0 +1,94 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+
4
+ // Regression test for SELECT * fragility on GET /api/nodes.
5
+ //
6
+ // Prior behavior: `SELECT * FROM nodes` returned whatever columns the
7
+ // table happened to have. When RFC-024 added config_revision +
8
+ // config_snapshot columns (read by the per-node GET /api/nodes/:id/config
9
+ // endpoint, NOT meant for the list endpoint), they silently leaked into
10
+ // the list response and broke V3 Networks tests that asserted on the
11
+ // nodes-list row shape.
12
+ //
13
+ // Current behavior: explicit column list — the response contract is:
14
+ // node_id, node_name, alias, runtime, model, config_path, channels,
15
+ // server, hostname, network_id, created_at, updated_at
16
+ // This test runs the same SQL the handler uses, on a row that DOES have
17
+ // config_revision + config_snapshot populated, and asserts those fields
18
+ // do NOT appear in the returned row. If anyone reverts the handler to
19
+ // SELECT *, or adds an internal column to the SELECT, this test breaks.
20
+
21
+ const TEST_NETWORK = "net_test_shape";
22
+ const TEST_NODE = "node_test_shape_abc";
23
+
24
+ beforeEach(() => {
25
+ // Best-effort cleanup (test runs in shared test DB; rows are isolated by id)
26
+ try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
27
+ });
28
+ afterAll(() => {
29
+ try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
30
+ });
31
+
32
+ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
33
+ test("explicit columns: internal config_revision/config_snapshot NOT in response", () => {
34
+ // Insert a row with the leak-prone columns populated. Use INSERT OR
35
+ // REPLACE so concurrent test runs don't trip on PRIMARY KEY.
36
+ db.run(
37
+ `INSERT OR REPLACE INTO nodes (
38
+ node_id, node_name, alias, runtime, model, config_path,
39
+ channels, server, hostname, network_id,
40
+ config_revision, config_snapshot
41
+ ) VALUES (
42
+ ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
43
+ )`,
44
+ [TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
45
+ "/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
46
+ 7, JSON.stringify({ model: "claude-opus", flags: { x: true } })],
47
+ );
48
+
49
+ // Run the same SQL the handler runs (mirror; if handler changes, this
50
+ // assertion still locks the shape).
51
+ const rows = db.all<Record<string, unknown>>(
52
+ `SELECT node_id, node_name, alias, runtime, model,
53
+ config_path, channels, server, hostname,
54
+ network_id, created_at, updated_at
55
+ FROM nodes WHERE node_id = ?1`,
56
+ TEST_NODE,
57
+ );
58
+
59
+ expect(rows).toHaveLength(1);
60
+ const row = rows[0]!;
61
+ // Expected fields present
62
+ for (const k of ["node_id", "node_name", "alias", "runtime", "model",
63
+ "config_path", "channels", "server", "hostname",
64
+ "network_id", "created_at", "updated_at"]) {
65
+ expect(row).toHaveProperty(k);
66
+ }
67
+ // Internal fields NOT present (these are the leak the test guards against)
68
+ expect(row).not.toHaveProperty("config_revision");
69
+ expect(row).not.toHaveProperty("config_snapshot");
70
+ // Belt: full key set is exactly the 12 contract fields
71
+ expect(Object.keys(row).sort()).toEqual([
72
+ "alias", "channels", "config_path", "created_at", "hostname",
73
+ "model", "network_id", "node_id", "node_name", "runtime",
74
+ "server", "updated_at",
75
+ ]);
76
+ });
77
+
78
+ test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
79
+ db.run(
80
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
81
+ config_revision, config_snapshot)
82
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
83
+ [TEST_NODE, "test-shape", "test-shape", TEST_NETWORK,
84
+ 42, JSON.stringify({ model: "claude-opus" })],
85
+ );
86
+ // Mirrors the SELECT in GET /api/nodes/:id/config (server/src/index.ts):
87
+ const cfgRow = db.get<{ config_revision: number; config_snapshot: string }>(
88
+ "SELECT config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
89
+ TEST_NODE,
90
+ );
91
+ expect(cfgRow?.config_revision).toBe(42);
92
+ expect(cfgRow?.config_snapshot).toContain("claude-opus");
93
+ });
94
+ });
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 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
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;
@@ -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
+ }