@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 +1 -1
- package/src/api-nodes-shape.test.ts +94 -0
- package/src/auth.ts +3 -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/index.ts +16 -1
- 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 +396 -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.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
|
|
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
|
+
}
|