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

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.
Files changed (122) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +654 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message-seam.test.ts +150 -0
  30. package/src/desktop-message.test.ts +368 -0
  31. package/src/ext-token-shared.test.ts +60 -0
  32. package/src/ext-validation-regression.test.ts +131 -0
  33. package/src/external-schedule-edits-http.test.ts +316 -0
  34. package/src/external-schedule-edits.ts +314 -0
  35. package/src/file-download-authz-dev-open.test.ts +135 -0
  36. package/src/file-download-authz.test.ts +403 -0
  37. package/src/file-download-date-bucket.test.ts +436 -0
  38. package/src/file-network-scope-dev-open.test.ts +97 -0
  39. package/src/file-network-scope.test.ts +731 -0
  40. package/src/health-redaction.test.ts +637 -0
  41. package/src/inbox-count-sse.test.ts +310 -0
  42. package/src/inbox-count.ts +29 -0
  43. package/src/index.ts +15 -2187
  44. package/src/lifecycle-guard.ts +50 -0
  45. package/src/list-host-supervisors.test.ts +314 -0
  46. package/src/mcp-write-network-resolution.test.ts +429 -0
  47. package/src/network-name-http.test.ts +71 -0
  48. package/src/network-scope.ts +101 -0
  49. package/src/node-attrs-validate.ts +88 -0
  50. package/src/node-attrs.test.ts +213 -0
  51. package/src/node-id-alias.test.ts +147 -0
  52. package/src/node-lifecycle-controllable.test.ts +64 -0
  53. package/src/node-lifecycle-controllable.ts +51 -0
  54. package/src/observer-avatar-http.test.ts +424 -0
  55. package/src/observer-push.test.ts +252 -0
  56. package/src/package-version-consistency.test.ts +25 -0
  57. package/src/peer-reply-atomic.test.ts +455 -0
  58. package/src/peer-reply-capability.test.ts +70 -0
  59. package/src/probe-validate.test.ts +221 -0
  60. package/src/probe-validate.ts +104 -0
  61. package/src/probe.test.ts +80 -0
  62. package/src/probe.ts +200 -0
  63. package/src/push.ts +163 -7
  64. package/src/redact-tokens.test.ts +65 -0
  65. package/src/redact-tokens.ts +40 -0
  66. package/src/reply-target-derivation.test.ts +96 -0
  67. package/src/require-explicit-test-db.ts +9 -0
  68. package/src/resolve-port.test.ts +57 -0
  69. package/src/resolve-port.ts +44 -0
  70. package/src/response-charset.test.ts +130 -0
  71. package/src/rest-explicit-columns-http.test.ts +212 -0
  72. package/src/rest-identity.test.ts +129 -0
  73. package/src/rest-identity.ts +100 -0
  74. package/src/rest-projections.ts +67 -0
  75. package/src/rest-write-network-resolution.test.ts +133 -0
  76. package/src/runtime-label.test.ts +22 -0
  77. package/src/runtime-label.ts +21 -0
  78. package/src/scheduled-run-terminal.test.ts +268 -0
  79. package/src/scheduled-tasks-http.test.ts +479 -0
  80. package/src/scheduled-tasks.ts +549 -0
  81. package/src/send-reply-agent-warning.test.ts +202 -0
  82. package/src/send-reply-alias-optional.test.ts +131 -0
  83. package/src/send-reply-attachments.test.ts +437 -0
  84. package/src/server.ts +3664 -0
  85. package/src/shared/external-schedule-contract.ts +60 -0
  86. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  87. package/src/shared/probe-host-allowlist.ts +128 -0
  88. package/src/side-thread-command-transport.test.ts +302 -0
  89. package/src/side-thread-command-transport.ts +318 -0
  90. package/src/side-thread-http-integration.test.ts +229 -0
  91. package/src/side-thread-http.ts +504 -0
  92. package/src/side-thread-production.test.ts +72 -0
  93. package/src/side-thread-production.ts +118 -0
  94. package/src/side-thread.test.ts +1067 -0
  95. package/src/side-thread.ts +2495 -0
  96. package/src/skillhub-http.test.ts +88 -0
  97. package/src/start-node.test.ts +109 -0
  98. package/src/stop-delete-node.test.ts +952 -0
  99. package/src/task-auth-origin-http.test.ts +87 -0
  100. package/src/task-auth-origin.test.ts +24 -0
  101. package/src/task-auth-origin.ts +7 -0
  102. package/src/task-consumption.test.ts +471 -0
  103. package/src/task-diagnostic-http.test.ts +145 -0
  104. package/src/task-diagnostic.test.ts +115 -0
  105. package/src/task-diagnostic.ts +118 -0
  106. package/src/task-idempotency-mcp.test.ts +142 -0
  107. package/src/task-idempotency.test.ts +60 -0
  108. package/src/task-idempotency.ts +75 -0
  109. package/src/task-lifecycle-watcher.test.ts +200 -0
  110. package/src/task-lifecycle-watcher.ts +49 -0
  111. package/src/task-network-resolution-http.test.ts +137 -0
  112. package/src/tasks-pagination-http.test.ts +137 -0
  113. package/src/tools.ts +2994 -191
  114. package/src/update-provider.test.ts +234 -0
  115. package/src/upload-early-reject-guard.test.ts +155 -0
  116. package/src/uploads-http.test.ts +92 -14
  117. package/src/uploads.test.ts +66 -0
  118. package/src/uploads.ts +130 -5
  119. package/src/user-inbox-read-http.test.ts +220 -0
  120. package/src/user-inbox-schema.test.ts +53 -0
  121. package/src/vault.test.ts +200 -0
  122. package/src/vault.ts +156 -0
@@ -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). Any
103
- * restart-required field present → "restart". Otherwise → "hot".
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,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
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  ValidationError,
4
- validateName, validateRuntime, validateModel, validateFlagValue,
4
+ validateName, validateRuntime, validateModel, validateFlagValue, RUNTIMES,
5
5
  validateEnvRefs, validateChannelsP1, serializeEnvLocal, buildAnetArgs,
6
6
  MAX_ENV_KEYS_PER_NODE,
7
7
  } from "./create-node-validate.js";
@@ -169,6 +169,16 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
169
169
  expect(args).toContain("--permission-mode");
170
170
  expect(args[args.indexOf("--permission-mode") + 1]).toBe("plan");
171
171
  });
172
+ test("omitted model is allowed and does not emit --model", () => {
173
+ const args = buildAnetArgs({
174
+ name: "x", runtime: "codex-sdk",
175
+ flags: { permissionMode: "plan" },
176
+ });
177
+ expect(args).toEqual(["node", "create", "x", "--runtime", "codex-sdk", "--permission-mode", "plan"]);
178
+ });
179
+ test("empty model is still rejected", () => {
180
+ expect(() => buildAnetArgs({ name: "x", runtime: "codex-sdk", model: "" })).toThrow(ValidationError);
181
+ });
172
182
  test("rejects bad name with shell metachar", () => {
173
183
  expect(() => buildAnetArgs({ name: ";rm -rf /", runtime: "claude-agent-sdk", model: "x" })).toThrow(ValidationError);
174
184
  });
@@ -185,3 +195,72 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
185
195
  } as any)).toThrow(ValidationError);
186
196
  });
187
197
  });
198
+
199
+ // ─── runtime_invalid 要说清「允许哪些」和「还有什么路」 ────────────────
200
+ //
201
+ // 2026-08-28 实测:从 Dashboard 建一个 codex-app-server 节点,用户拿到的是
202
+ // {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
203
+ // 而目标机器的 daemon 日志里一行都没有 —— 请求被 hub 拦在最前面,
204
+ // 用户无从知道①哪些允许②是不是打错了③有没有别的办法。
205
+ // 🔴 2026-08-28:这一节原本拿 `codex-app-server` / `opencode-cli` 当"非法 runtime"的例子。
206
+ // #1298 把 hub 的 RUNTIMES 从 3 个放开到 7 个之后,那两个变成了合法值,这两条测试因此红了 ——
207
+ // **测试正确地抓到了行为变更**。修法是换例子,不是削弱断言:
208
+ // 意图仍然是「非法 runtime 要给可操作的报错」,只是例子换成永远不会合法的字符串。
209
+ describe("validateRuntime — 报错要可操作", () => {
210
+ function thrown(v: unknown): any {
211
+ try { validateRuntime(v); } catch (e: any) { return e; }
212
+ throw new Error("expected validateRuntime to throw");
213
+ }
214
+
215
+ test("非法 runtime 的报错带上允许集合", () => {
216
+ const e = thrown("definitely-not-a-runtime");
217
+ expect(e.code).toBe("runtime_invalid");
218
+ expect(Array.isArray(e.detail?.allowed)).toBe(true);
219
+ // 🔴 断言它等于 RUNTIMES 本身,而不是等于一份手抄的清单 ——
220
+ // 手抄的那份会漂,漂了之后报错会理直气壮地告诉用户一组错的名字。
221
+ expect(e.detail.allowed).toEqual([...RUNTIMES]);
222
+ });
223
+
224
+ test("报错带上一条出路提示", () => {
225
+ const e = thrown("another-bogus-runtime");
226
+ expect(typeof e.detail?.hint).toBe("string");
227
+ expect(e.detail.hint.length).toBeGreaterThan(10);
228
+ // 提示必须点名那条真实存在的路,否则它只是安慰话
229
+ expect(e.detail.hint).toContain("anet node create");
230
+ });
231
+
232
+ test("仍然回报用户传进来的那个值(便于识别是不是打错了)", () => {
233
+ const e = thrown("codex-app-servr"); // 故意少一个 e
234
+ expect(e.detail.value).toBe("codex-app-servr");
235
+ });
236
+
237
+ test("🔴 合法 runtime 一个都不能被这次改动误伤", () => {
238
+ for (const r of RUNTIMES) {
239
+ expect(() => validateRuntime(r)).not.toThrow();
240
+ }
241
+ // 正控:非字符串必须仍然被拒 —— 否则上面那条循环全过也说明不了什么
242
+ expect(() => validateRuntime(123 as any)).toThrow();
243
+ expect(() => validateRuntime(undefined as any)).toThrow();
244
+ });
245
+ });
246
+
247
+ // #1298 —— daemon 远程创建放开到 7 个 runtime(Vincent 2026-08-28 定,
248
+ // 理由是兜底排错:节点不动了,人要能进去跟 Codex / Claude Code 对话)。
249
+ describe("#1298 RUNTIMES —— 七个都放行,且与 CLI 侧一致", () => {
250
+ test("三个共存 runtime 不再被拒", () => {
251
+ for (const r of ["codex-app-server", "grok-build-cli", "opencode-cli"]) {
252
+ expect(() => validateRuntime(r)).not.toThrow();
253
+ }
254
+ });
255
+ test("原有四个仍然放行(放开不该顺手改坏别的)", () => {
256
+ for (const r of ["claude-agent-sdk", "claude-code-cli", "codex-sdk", "grok-build-acp"]) {
257
+ expect(() => validateRuntime(r)).not.toThrow();
258
+ }
259
+ });
260
+ test("🔴 恰好七个 —— 多一个少一个都要有人解释", () => {
261
+ expect(RUNTIMES.length).toBe(7);
262
+ // 正控:不在名单里的仍然被拒,证明上面不是"什么都放行"
263
+ expect(() => validateRuntime("definitely-not-a-runtime")).toThrow();
264
+ expect(() => validateRuntime("")).toThrow();
265
+ });
266
+ });
@@ -17,7 +17,33 @@ export class ValidationError extends Error {
17
17
  }
18
18
 
19
19
  // §4.2.2 — structural enums.
20
- export const RUNTIMES = ["claude-agent-sdk", "codex-sdk", "grok-build-acp"] as const;
20
+ // #1298 —— daemon 远程创建允许的 runtime。2026-08-28 从 3 个放开到 7 个。
21
+ //
22
+ // 放开之前挡着的问题是「daemon 建出来的是**无人值守**的后台进程,而三个共存 runtime
23
+ // 的本质是人和 agent 共用一个 TUI 会话 —— 建出来给谁用?」
24
+ //
25
+ // Vincent 2026-08-28 定,理由是**兜底排错**:
26
+ // 「某个节点突然不动了,人可以进去看。在 daemon 还没那么靠谱的情况下,
27
+ // 这非常重要 —— 至少可以跟 Codex 和 Claude Code 对话。」
28
+ // 🔴 关键在于 **attach 是「建好之后」的动作,不是「建的时候」的前提**。
29
+ //
30
+ // 支撑读数(2026-08-28 实测舰队):`通信牛` 跑 codex-app-server(共存 runtime),
31
+ // **764 次成功完成任务**,当天仍在跑 —— 比两个普通 runtime 的节点(61 次 / 3 次)
32
+ // 都多一个量级。⇒ 任务链路不需要人坐在屏幕前。
33
+ //
34
+ // 🔴 这七个名字与 `agent-network/src/normalize-runtime.ts` 的 `RuntimeName` 一致。
35
+ // 那边是 CLI 侧的全集;两边分叉过一次(daemon 侧的 VALID_RUNTIMES 早就是 7 个,
36
+ // 只有这里还是 3 个,而记录此事的 #1298 正文写的是「三道闸都关着」——
37
+ // **一份快照式的盘点,只在写下的那一刻是真的**)。
38
+ export const RUNTIMES = [
39
+ "claude-agent-sdk",
40
+ "claude-code-cli",
41
+ "codex-sdk",
42
+ "codex-app-server",
43
+ "grok-build-acp",
44
+ "grok-build-cli",
45
+ "opencode-cli",
46
+ ] as const;
21
47
  export type Runtime = typeof RUNTIMES[number];
22
48
 
23
49
  export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout"] as const;
@@ -42,11 +68,28 @@ export function validateName(s: unknown): asserts s is string {
42
68
 
43
69
  export function validateRuntime(s: unknown): asserts s is Runtime {
44
70
  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 });
71
+ // 🔴 原来只回一个 `runtime_invalid` + 用户传进来的那个值 —— **不说允许哪些,
72
+ // 也不说还有没有别的路**。2026-08-28 实测:从 Dashboard 建一个
73
+ // `codex-app-server` 节点,用户拿到的就是
74
+ // {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
75
+ // 而目标机器的 daemon 日志里一行都没有(请求根本没到)。
76
+ // 用户无从知道:①哪些是允许的 ②这个名字是不是打错了 ③有没有别的办法。
77
+ //
78
+ // `allowed` 直接由 RUNTIMES 展开,不是手写的第二份清单 ——
79
+ // 🔴 手写一份会漂移,而漂移之后报错会**理直气壮地告诉用户一组错的名字**。
80
+ throw new ValidationError("runtime_invalid", {
81
+ value: typeof s === "string" ? s.slice(0, 80) : typeof s,
82
+ allowed: [...RUNTIMES],
83
+ // 这句提示的前提是查过的:`agent-network/src/normalize-runtime.ts` 的
84
+ // `RuntimeName` / `SUPPORTED_RUNTIME_NAMES` 是 7 个,包含此处不放行的那几个,
85
+ // 所以「在目标机器上直接建」确实是一条真实存在的路,不是安慰话。
86
+ hint: "daemon 远程创建目前只放行以上 runtime;其余 runtime 可以在目标机器上直接 `anet node create --runtime <name>` 创建。",
87
+ });
46
88
  }
47
89
  }
48
90
 
49
- export function validateModel(s: unknown): asserts s is string {
91
+ export function validateModel(s: unknown): asserts s is string | undefined | null {
92
+ if (s === undefined || s === null) return;
50
93
  if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
51
94
  throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
52
95
  }
@@ -173,7 +216,7 @@ export function validateChannelsP1(channels: unknown): void {
173
216
  export interface NodeSpec {
174
217
  name: string;
175
218
  runtime: Runtime;
176
- model: string;
219
+ model?: string | null;
177
220
  flags?: Record<string, unknown>;
178
221
  channels?: unknown;
179
222
  }
@@ -192,7 +235,8 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
192
235
  validateModel(spec.model);
193
236
  validateChannelsP1(spec.channels);
194
237
 
195
- const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime, "--model", spec.model];
238
+ const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime];
239
+ if (spec.model) args.push("--model", spec.model);
196
240
  if (spec.flags && typeof spec.flags === "object") {
197
241
  for (const [k, v] of Object.entries(spec.flags)) {
198
242
  if (!(FLAG_KEYS as readonly string[]).includes(k)) {
@@ -272,8 +272,58 @@ export function resolveCallerDaemonTokenBound(opts: {
272
272
  // RFC-026 §4.5 — append a row to audit_log for every create_node
273
273
  // lifecycle event. Best-effort: never throw out (calling tool should
274
274
  // continue even if audit insert fails for any reason).
275
+ /** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode
276
+ * but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE +
277
+ * audit INSERT can ROLLBACK if the audit row would have been lost.
278
+ * Required for the "no audit-but state changed" window that §4.5 / D8
279
+ * explicitly closes (PR1 SF-2 review catch — auditCreateNode's
280
+ * swallow-and-warn defeats atomicity).
281
+ *
282
+ * Callers in a non-transaction context should keep using auditCreateNode
283
+ * (best-effort, never throws). */
284
+ export function auditCreateNodeStrict(input: {
285
+ action: Parameters<typeof auditCreateNode>[0]["action"];
286
+ user_id?: string | null;
287
+ username?: string | null;
288
+ network_id?: string | null;
289
+ target_id?: string | null;
290
+ detail: Record<string, unknown>;
291
+ }): void {
292
+ db.run(
293
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
294
+ VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
295
+ [
296
+ input.user_id || null,
297
+ input.username || null,
298
+ input.action,
299
+ input.target_id || null,
300
+ JSON.stringify(input.detail),
301
+ input.network_id || null,
302
+ ],
303
+ );
304
+ }
305
+
275
306
  export function auditCreateNode(input: {
276
- action: "create_node_dispatched" | "create_node_rejected" | "create_node_succeeded" | "create_node_sweeper_revoked";
307
+ action:
308
+ | "create_node_dispatched"
309
+ | "create_node_dispatch_attempted"
310
+ | "create_node_rejected"
311
+ | "create_node_succeeded"
312
+ | "create_node_sweeper_revoked"
313
+ | "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
314
+ // RFC-027 §4.5 — stop/delete lifecycle audit action enum extension.
315
+ // Re-using auditCreateNode is fine: the action column carries the
316
+ // discriminator; target_type stays 'node_create_request' which is a
317
+ // misnomer for stop/delete rows but updating the schema would force a
318
+ // wider migration (see P1.1 issue).
319
+ | "stop_node_dispatched"
320
+ | "stop_node_completed"
321
+ | "start_node_dispatched"
322
+ | "start_node_completed"
323
+ | "delete_node_dispatched"
324
+ | "delete_node_completed"
325
+ | "forced_stop_with_in_flight"
326
+ | "backup_purged";
277
327
  user_id?: string | null;
278
328
  username?: string | null;
279
329
  network_id?: string | null;
@@ -0,0 +1,103 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { isInteractiveDashboardTask } from "../../agent-node/src/inbox-dispatch";
6
+ import { shouldCreateScheduledGoal } from "../../agent-node/src/goals/routing";
7
+ import { createNetworkTokenForNode, register } from "./auth.js";
8
+ import { db } from "./db.js";
9
+
10
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dashboard-slash-http-"));
11
+ const TARGET = `dashboard-slash-target-${process.pid}`;
12
+ const TARGET_NODE_ID = `node_dashboard_slash_target_${process.pid}`;
13
+ const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
14
+ let server: any;
15
+ let base = "";
16
+ let userToken = "";
17
+ let nodeToken = "";
18
+ let networkId = "";
19
+ let requestSequence = 0;
20
+
21
+ beforeAll(async () => {
22
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
23
+ const username = `dashboard_slash_${Date.now()}`;
24
+ const registered = register(username, "DashboardSlash123!", undefined, "seed");
25
+ expect(registered.ok).toBe(true);
26
+ userToken = registered.token!;
27
+ networkId = registered.network_id!;
28
+ const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
29
+ const minted = createNetworkTokenForNode(userId, networkId, "dashboard-slash-node");
30
+ expect(minted.ok).toBe(true);
31
+ nodeToken = minted.token!;
32
+ db.run(
33
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
34
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
35
+ [TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
36
+ );
37
+ db.run(
38
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
39
+ VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
40
+ [TARGET_NODE_ID, TARGET, networkId],
41
+ );
42
+ const mod: any = await import("./server.js");
43
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
44
+ base = `http://127.0.0.1:${server.port}`;
45
+ });
46
+
47
+ afterAll(() => {
48
+ try { server?.stop?.(true); } catch {}
49
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
50
+ });
51
+
52
+ async function postAndRead(token: string, from: string, task: string) {
53
+ requestSequence += 1;
54
+ const clientRequestId = `dreq_${requestSequence.toString(16).padStart(32, "0")}`;
55
+ const response = await fetch(`${base}/api/task`, {
56
+ method: "POST",
57
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ alias: TARGET,
60
+ from,
61
+ network_id: networkId,
62
+ task,
63
+ meta: { source: "dashboard-chat", client_request_id: clientRequestId, auth_origin: "user" },
64
+ }),
65
+ });
66
+ const body = await response.json() as any;
67
+ expect([200, 202]).toContain(response.status);
68
+ const taskId = body.task_id ?? body.message_id;
69
+ const row = db.get<{ content: string; meta_json: string }>(
70
+ "SELECT content, meta_json FROM inbox WHERE id = ?1",
71
+ [taskId],
72
+ );
73
+ expect(row).toBeTruthy();
74
+ return { type: "task", content: row!.content, meta_json: row!.meta_json };
75
+ }
76
+
77
+ describe("real Hub → authenticated Dashboard slash routing", () => {
78
+ test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
79
+ for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
80
+ const row = await postAndRead(userToken, "dashboard-user", command);
81
+ expect(row.content).toBe(command);
82
+ expect(isInteractiveDashboardTask(row)).toBe(true);
83
+ for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
84
+ expect(shouldCreateScheduledGoal(row.content, runtime, true)).toBe(false);
85
+ }
86
+ }
87
+ });
88
+
89
+ test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
90
+ for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
91
+ const row = await postAndRead(userToken, "dashboard-user", command);
92
+ expect(isInteractiveDashboardTask(row)).toBe(true);
93
+ expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
94
+ }
95
+ });
96
+
97
+ test("node token cannot forge Dashboard pass-through and keeps legacy compatibility", async () => {
98
+ const row = await postAndRead(nodeToken, "dashboard-slash-node", "/loop 5m legacy automation");
99
+ expect(JSON.parse(row.meta_json).auth_origin).toBe("node");
100
+ expect(isInteractiveDashboardTask(row)).toBe(false);
101
+ expect(shouldCreateScheduledGoal(row.content, "codex-app-server", false)).toBe(true);
102
+ });
103
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ assertSafeTestDatabaseEnv,
4
+ resolveDatabaseTarget,
5
+ } from "./db-adapter";
6
+
7
+ const BASE_ENV: NodeJS.ProcessEnv = { HOME: "/nonexistent" };
8
+
9
+ describe("#435 inherited DATABASE_URL guard", () => {
10
+ for (const databaseUrl of [
11
+ "postgres://user:pw@prod.example:5432/commhub",
12
+ "postgresql://user:pw@prod.example:5432/commhub",
13
+ "sqlite:///tmp/not-an-escape",
14
+ ]) {
15
+ test(`NODE_ENV=test rejects ${databaseUrl.split(":", 1)[0]} DATABASE_URL`, () => {
16
+ expect(() => assertSafeTestDatabaseEnv({
17
+ ...BASE_ENV,
18
+ NODE_ENV: "test",
19
+ DATABASE_URL: databaseUrl,
20
+ })).toThrow(/REFUSING to honor inherited DATABASE_URL/);
21
+ });
22
+ }
23
+
24
+ test("test without DATABASE_URL leaves the existing SQLite guard in charge", () => {
25
+ expect(() => assertSafeTestDatabaseEnv({ ...BASE_ENV, NODE_ENV: "test" })).not.toThrow();
26
+ expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "test" }))
27
+ .toThrow(/REFUSING to open the default SQLite database/);
28
+ });
29
+
30
+ test("DATABASE_URL refusal wins even when COMMHUB_DB is also set", () => {
31
+ expect(() => resolveDatabaseTarget({
32
+ ...BASE_ENV,
33
+ NODE_ENV: "test",
34
+ DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
35
+ COMMHUB_DB: "/tmp/isolated.db",
36
+ })).toThrow(/REFUSING to honor inherited DATABASE_URL/);
37
+ });
38
+
39
+ test("production postgres branch remains unchanged without constructing it", () => {
40
+ const url = "postgres://user:pw@prod.example:5432/commhub";
41
+ expect(resolveDatabaseTarget({
42
+ ...BASE_ENV,
43
+ NODE_ENV: "production",
44
+ DATABASE_URL: url,
45
+ })).toEqual({ kind: "postgres", url });
46
+ });
47
+
48
+ test("unset NODE_ENV still selects postgres", () => {
49
+ const url = "postgresql://user:pw@prod.example:5432/commhub";
50
+ expect(resolveDatabaseTarget({ ...BASE_ENV, DATABASE_URL: url }))
51
+ .toEqual({ kind: "postgres", url });
52
+ });
53
+
54
+ test("explicit SQLite targets remain unchanged in test and production", () => {
55
+ for (const NODE_ENV of ["test", "production"]) {
56
+ expect(resolveDatabaseTarget({
57
+ ...BASE_ENV,
58
+ NODE_ENV,
59
+ COMMHUB_DB: "/tmp/isolated.db",
60
+ })).toEqual({ kind: "sqlite", path: "/tmp/isolated.db" });
61
+ }
62
+ });
63
+
64
+ test("non-server callers cannot silently fall through to the production default SQLite path", () => {
65
+ for (const NODE_ENV of [undefined, "development", "production"]) {
66
+ expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV }))
67
+ .toThrow(/explicit COMMHUB_DB|COMMHUB_SERVER=1/);
68
+ }
69
+ });
70
+
71
+ test("the explicit server boot capability permits the canonical default SQLite path", () => {
72
+ expect(resolveDatabaseTarget({
73
+ ...BASE_ENV,
74
+ NODE_ENV: "production",
75
+ COMMHUB_SERVER: "1",
76
+ })).toEqual({ kind: "sqlite", path: "/nonexistent/.commhub/commhub.db" });
77
+ });
78
+
79
+ test("lookalike server capability values fail closed", () => {
80
+ for (const COMMHUB_SERVER of ["", "true", "yes", "01", " 1"] ) {
81
+ expect(() => resolveDatabaseTarget({ ...BASE_ENV, COMMHUB_SERVER }))
82
+ .toThrow(/COMMHUB_SERVER=1/);
83
+ }
84
+ });
85
+ });