@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.
- package/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +654 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3664 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2994 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-read-http.test.ts +220 -0
- package/src/user-inbox-schema.test.ts +53 -0
- package/src/vault.test.ts +200 -0
- 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).
|
|
103
|
-
* restart-required field present
|
|
131
|
+
* both empty, no channels) → "restart_only" (used by restart_node tool).
|
|
132
|
+
* Any restart-required field present, OR a channels change → "restart"
|
|
133
|
+
* (channels is restart-tier by design: the node reads its channel set
|
|
134
|
+
* once at boot in agent-node/src/cli.ts and forks per-channel workers
|
|
135
|
+
* from it, so a channel enable/disable takes effect on process restart
|
|
136
|
+
* only — see #260 P5 wire discipline). Otherwise → "hot".
|
|
104
137
|
*/
|
|
105
138
|
export function computeApplyMode(
|
|
106
139
|
model: string | undefined,
|
|
107
140
|
flags: Record<string, unknown>,
|
|
141
|
+
channels?: string[] | undefined,
|
|
108
142
|
): "hot" | "restart" | "restart_only" {
|
|
109
|
-
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
|
|
143
|
+
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
|
|
110
144
|
if (fieldCount === 0) return "restart_only";
|
|
111
145
|
if (model !== undefined) return "restart";
|
|
146
|
+
if (channels !== undefined) return "restart";
|
|
112
147
|
for (const key of Object.keys(flags)) {
|
|
113
148
|
if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
|
|
114
149
|
}
|
|
115
150
|
return "hot";
|
|
116
151
|
}
|
|
117
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Narrow + allow-list `channels` from untrusted patch JSON. Returns the
|
|
155
|
+
* final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
|
|
156
|
+
* order-preserving), OR null if the input isn't an array. Non-strings,
|
|
157
|
+
* unknown keys, empty strings, and duplicates are silently dropped —
|
|
158
|
+
* same fail-shape as the dashboard route's whitelist so the two sides
|
|
159
|
+
* behave identically end-to-end. Callers pass `undefined` through when
|
|
160
|
+
* the patch didn't include a channels key at all (a distinct case from
|
|
161
|
+
* `channels: []` which means "disable all editable channels").
|
|
162
|
+
*/
|
|
163
|
+
export function narrowChannelsPatch(raw: unknown): string[] | null {
|
|
164
|
+
if (!Array.isArray(raw)) return null;
|
|
165
|
+
const allow = EDITABLE_CHANNELS;
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
const out: string[] = [];
|
|
168
|
+
for (const v of raw) {
|
|
169
|
+
if (typeof v !== "string") continue;
|
|
170
|
+
const key = v.trim().toLowerCase();
|
|
171
|
+
if (!allow.has(key) || seen.has(key)) continue;
|
|
172
|
+
seen.add(key);
|
|
173
|
+
out.push(key);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
118
178
|
/**
|
|
119
179
|
* Validate the patch shape — allowlist + per-field range / enum. Returns
|
|
120
180
|
* null on pass, or `{ field, reason }` on reject. Caller wraps in error
|
|
@@ -123,12 +183,30 @@ export function computeApplyMode(
|
|
|
123
183
|
export function validatePatch(
|
|
124
184
|
model: string | undefined,
|
|
125
185
|
flags: Record<string, unknown>,
|
|
186
|
+
channels?: string[] | undefined,
|
|
126
187
|
): { field: string; reason: string } | null {
|
|
127
188
|
if (model !== undefined) {
|
|
128
189
|
if (typeof model !== "string" || model.length === 0 || model.length > 200) {
|
|
129
190
|
return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
|
|
130
191
|
}
|
|
131
192
|
}
|
|
193
|
+
if (channels !== undefined) {
|
|
194
|
+
// Zod already enforced string[], and narrowChannelsPatch dropped
|
|
195
|
+
// unknown keys before this point — but re-verify defensively so a
|
|
196
|
+
// future caller that skips narrowChannelsPatch can't smuggle
|
|
197
|
+
// unknown keys through validatePatch alone.
|
|
198
|
+
if (!Array.isArray(channels)) {
|
|
199
|
+
return { field: "channels", reason: "must be an array of strings" };
|
|
200
|
+
}
|
|
201
|
+
for (const c of channels) {
|
|
202
|
+
if (typeof c !== "string") {
|
|
203
|
+
return { field: "channels", reason: "must contain only strings" };
|
|
204
|
+
}
|
|
205
|
+
if (!EDITABLE_CHANNELS.has(c)) {
|
|
206
|
+
return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
132
210
|
for (const [key, val] of Object.entries(flags)) {
|
|
133
211
|
if (!ALLOWED_FLAGS.has(key)) {
|
|
134
212
|
return { field: `flags.${key}`, reason: "not in allowlist" };
|
|
@@ -0,0 +1,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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)) {
|
package/src/create-node.ts
CHANGED
|
@@ -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:
|
|
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
|
+
});
|