@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.61
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 +88 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -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-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +55 -7
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -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 +710 -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 +320 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -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 +641 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2227
- 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/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +104 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/observer-avatar-http.test.ts +428 -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/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/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -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 +216 -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/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -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 +483 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-dedup-request-id.test.ts +229 -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 +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +3901 -0
- package/src/shared/external-schedule-contract.ts +60 -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 +233 -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 +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -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 +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3280 -206
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// #1353 Fix ① — hub gates create_node dispatch on the daemon's
|
|
7
|
+
// self-reported can_create_nodes bit. Before this, daemon reported
|
|
8
|
+
// can_create_nodes=false (via #1371 + #1377) → hub stored it → nothing
|
|
9
|
+
// read it → dispatch returned {ok:true, request_id} and pushed a
|
|
10
|
+
// doorbell to a daemon that had already told hub it couldn't create
|
|
11
|
+
// nodes. That's the exact "在线但静默失败" case the issue title reports.
|
|
12
|
+
//
|
|
13
|
+
// Vincent 2026-08-28 comment on #1353:
|
|
14
|
+
// "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
|
|
15
|
+
// 而不是发一个注定失败的 doorbell"
|
|
16
|
+
//
|
|
17
|
+
// 🔴 Pre-#1371 daemons (preview.55 and earlier) don't report the field
|
|
18
|
+
// at all. That must NOT fail-closed — the guard is "known-blocked",
|
|
19
|
+
// not "unknown-treated-as-blocked". Tested explicitly below.
|
|
20
|
+
|
|
21
|
+
const NET = "net_1353_gate";
|
|
22
|
+
const USER_ID = "u_1353_admin";
|
|
23
|
+
const DAEMON_ID = "node_1353_daemon";
|
|
24
|
+
const DAEMON_ALIAS = "gate-daemon";
|
|
25
|
+
const DAEMON_TOK = "tok_1353_daemon";
|
|
26
|
+
|
|
27
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
28
|
+
interface Reply { ok?: boolean; error?: string; blocked_reason?: string; request_id?: string; [k: string]: unknown; }
|
|
29
|
+
|
|
30
|
+
function cleanup() {
|
|
31
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
beforeEach(cleanup);
|
|
41
|
+
afterAll(cleanup);
|
|
42
|
+
|
|
43
|
+
/** #1545 —— 可选:写一条 sessions 心跳行。
|
|
44
|
+
* 默认**不写**,这样既有那 9 条测试的行为逐字不变;
|
|
45
|
+
* 只有要断言绝对年龄的用例才显式给 `heartbeatAgoMs`。 */
|
|
46
|
+
function seedHeartbeat(agoMs: number) {
|
|
47
|
+
const iso = new Date(Date.now() - agoMs).toISOString();
|
|
48
|
+
db.run(
|
|
49
|
+
`INSERT INTO sessions (alias, node_id, network_id, status, last_seen_at, updated_at)
|
|
50
|
+
VALUES (?1, ?2, ?3, 'idle', ?4, ?4)`,
|
|
51
|
+
[DAEMON_ALIAS, DAEMON_ID, NET, iso],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function seed(snapshot: object | string | null) {
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
58
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
59
|
+
[USER_ID, USER_ID],
|
|
60
|
+
);
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
63
|
+
VALUES (?1, ?1, ?2, datetime('now'))`,
|
|
64
|
+
[NET, USER_ID],
|
|
65
|
+
);
|
|
66
|
+
db.run(
|
|
67
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
68
|
+
VALUES (?1, ?2, 'admin', datetime('now'))`,
|
|
69
|
+
[USER_ID, NET],
|
|
70
|
+
);
|
|
71
|
+
const snap = snapshot === null
|
|
72
|
+
? null
|
|
73
|
+
: (typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot));
|
|
74
|
+
db.run(
|
|
75
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
76
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`,
|
|
77
|
+
[DAEMON_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET, snap, `host-${DAEMON_ALIAS}`],
|
|
78
|
+
);
|
|
79
|
+
db.run(
|
|
80
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
81
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
82
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tools(): Record<string, ToolHandler> {
|
|
87
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
88
|
+
const out: Record<string, ToolHandler> = {};
|
|
89
|
+
const origTool = server.tool.bind(server);
|
|
90
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
91
|
+
out[name] = handler;
|
|
92
|
+
return origTool(name, _desc, _schema, handler);
|
|
93
|
+
};
|
|
94
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
95
|
+
if (origRegisterTool) {
|
|
96
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
97
|
+
out[name] = handler;
|
|
98
|
+
return origRegisterTool(name, _cfg, handler);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
registerTools(
|
|
102
|
+
server, undefined,
|
|
103
|
+
/* enforceNetworkId */ NET,
|
|
104
|
+
/* enforceUserId */ USER_ID,
|
|
105
|
+
/* callerAlias */ null,
|
|
106
|
+
/* callerTokenIsNetwork */ true,
|
|
107
|
+
/* callerTokenId */ null,
|
|
108
|
+
);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
113
|
+
const r = await handler(args);
|
|
114
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const spec = {
|
|
118
|
+
name: "child-1353",
|
|
119
|
+
runtime: "claude-agent-sdk",
|
|
120
|
+
model: "claude-sonnet-4-5",
|
|
121
|
+
flags: {},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("#1353 Fix ① — hub gates create_node on daemon-reported can_create_nodes", () => {
|
|
125
|
+
test("🔴 witnessed-red: blocked daemon (can_create_nodes=false + reason) → refuse with reason, NO dispatch", async () => {
|
|
126
|
+
seed({
|
|
127
|
+
role: "host_supervisor",
|
|
128
|
+
daemon_capabilities: {
|
|
129
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
130
|
+
can_create_nodes: false,
|
|
131
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const t = tools();
|
|
135
|
+
const r = await call(t.create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
136
|
+
expect(r.ok).toBe(false);
|
|
137
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
138
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
139
|
+
// No request row inserted — hub must refuse BEFORE dispatch.
|
|
140
|
+
const rows = db.get<{ n: number }>(
|
|
141
|
+
"SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1",
|
|
142
|
+
DAEMON_ID,
|
|
143
|
+
);
|
|
144
|
+
expect(rows?.n).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("blocked daemon with each of the 4 reason codes surfaces that specific code", async () => {
|
|
148
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
149
|
+
for (const code of codes) {
|
|
150
|
+
cleanup();
|
|
151
|
+
seed({
|
|
152
|
+
role: "host_supervisor",
|
|
153
|
+
daemon_capabilities: {
|
|
154
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
155
|
+
can_create_nodes: false,
|
|
156
|
+
create_nodes_blocked_reason: code,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
160
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
161
|
+
expect(r.blocked_reason).toBe(code);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("blocked with .40-shape 'anet_bin_pin_unresolved' reason surfaces that literal (backcompat)", async () => {
|
|
166
|
+
// The Zod enum in tools.ts still accepts this coarse pre-#1377 code
|
|
167
|
+
// because agent-node@2.5.0-preview.40 daemons in the wild still emit
|
|
168
|
+
// it. If we ever swallow it into "unknown", those daemons' user
|
|
169
|
+
// messages get worse than they already are.
|
|
170
|
+
seed({
|
|
171
|
+
role: "host_supervisor",
|
|
172
|
+
daemon_capabilities: {
|
|
173
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
174
|
+
can_create_nodes: false,
|
|
175
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
179
|
+
expect(r.blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("blocked with NO reason field → fallback 'anet_bin_unknown' (defensive)", async () => {
|
|
183
|
+
seed({
|
|
184
|
+
role: "host_supervisor",
|
|
185
|
+
daemon_capabilities: {
|
|
186
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
187
|
+
can_create_nodes: false,
|
|
188
|
+
// no create_nodes_blocked_reason
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
192
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
193
|
+
expect(r.blocked_reason).toBe("anet_bin_unknown");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("can_create_nodes=true daemon dispatches normally (gate must not false-positive on healthy daemon)", async () => {
|
|
197
|
+
seed({
|
|
198
|
+
role: "host_supervisor",
|
|
199
|
+
daemon_capabilities: {
|
|
200
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
201
|
+
can_create_nodes: true,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
205
|
+
// Doesn't matter whether downstream dispatch succeeds — the point
|
|
206
|
+
// is that THIS gate did not reject. If reject, r.error would be
|
|
207
|
+
// "daemon_cannot_create_nodes".
|
|
208
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("🔴 pre-#1371 compat: snapshot with NO can_create_nodes field passes through (must NOT fail-closed)", async () => {
|
|
212
|
+
// preview.10 through preview.55 daemons never report this field.
|
|
213
|
+
// If we fail-closed on undefined we silently break every in-flight
|
|
214
|
+
// daemon that hasn't upgraded. Guard is "known-blocked", not
|
|
215
|
+
// "unknown-treated-as-blocked".
|
|
216
|
+
seed({
|
|
217
|
+
role: "host_supervisor",
|
|
218
|
+
daemon_capabilities: {
|
|
219
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
220
|
+
// no can_create_nodes at all
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
224
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("pre-#1371 compat: snapshot with no daemon_capabilities at all → passes through", async () => {
|
|
228
|
+
seed({ role: "host_supervisor" });
|
|
229
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
230
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("pre-#1371 compat: NULL config_snapshot passes through (very old daemon shape)", async () => {
|
|
234
|
+
seed(null);
|
|
235
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
236
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("malformed config_snapshot: permissive fallthrough (mirrors runtimes_supported catch), no crash", async () => {
|
|
240
|
+
seed("{not json");
|
|
241
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
242
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/* #1545 —— 拒绝的时候还要说**那个判断是什么时候做出来的**。
|
|
247
|
+
*
|
|
248
|
+
* 为什么这一格落在拒绝路径上最要紧:用户被挡住的这一刻,正好在决定"去修哪台机器"。
|
|
249
|
+
* 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
250
|
+
* 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
251
|
+
* (agent-node ≤ 2.5.0-preview.54),它可能早就好了 —— 先重启/升级它
|
|
252
|
+
* 只给 `blocked_reason` 的话,这两种情况给用户的字**一模一样**。 */
|
|
253
|
+
describe("#1545 拒绝文案带上「这个判断是多久以前做的」", () => {
|
|
254
|
+
const blockedSnap = (extra: object) => ({
|
|
255
|
+
role: "host_supervisor",
|
|
256
|
+
daemon_capabilities: {
|
|
257
|
+
can_create_nodes: false,
|
|
258
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
259
|
+
...extra,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/* 🔴 报出去的是**到现在为止**的绝对年龄,不是 daemon 报的那个原始时长。
|
|
264
|
+
* daemon 报的是「测完到**这份 report 发出**」;直接透传会漏掉心跳那一段,
|
|
265
|
+
* **朝更新鲜的方向低报** —— 正是这一格要防的方向。
|
|
266
|
+
* 绝对年龄 = (now − last_seen_at) + daemon 报的时长 */
|
|
267
|
+
test("daemon 报了年龄 + 有心跳时间 → 补上心跳那一段,标记 known", async () => {
|
|
268
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
269
|
+
seedHeartbeat(60_000); // 上次心跳在 60s 前
|
|
270
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
271
|
+
expect(r.ok).toBe(false);
|
|
272
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
273
|
+
expect(r.capability_age).toBe("known");
|
|
274
|
+
// 允许 ±5s 的执行抖动;关键是它**明显大于** 12,345,即心跳那一段确实被加上了
|
|
275
|
+
const got = r.capability_observed_ms_ago as number;
|
|
276
|
+
expect(got).toBeGreaterThanOrEqual(12_345 + 60_000 - 5_000);
|
|
277
|
+
expect(got).toBeLessThanOrEqual(12_345 + 60_000 + 5_000);
|
|
278
|
+
// 🔴 反向锚:如果实现是"原样透传",这个数会恰好等于 12,345
|
|
279
|
+
expect(got).not.toBe(12_345);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* 🔴 第三种 unknown,不能和「旧 daemon 没报」塌成同一个:
|
|
283
|
+
* 它报了,但 hub 这边没有可用的心跳时间 ⇒ **补不出绝对年龄**,而不是"年龄是 0"。 */
|
|
284
|
+
test("报了年龄但 hub 没有心跳时间 → unknown_no_heartbeat_time(不是 legacy,也不是 0)", async () => {
|
|
285
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
286
|
+
// 故意不 seedHeartbeat
|
|
287
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
288
|
+
expect(r.capability_age).toBe("unknown_no_heartbeat_time");
|
|
289
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/* 🔴 本组的重点:年龄未知时给的是**显式 null + 说明为什么的枚举**,
|
|
293
|
+
* 而不是省略这两个键。省略读起来像"没有这个问题",而调用方(常常是个 agent)
|
|
294
|
+
* 会默认它是刚测的 —— 那是朝「更可信」方向撒谎,比不给更糟。 */
|
|
295
|
+
test("🔴 daemon 没报年龄 → 两个键都在:null + unknown_legacy_daemon(不是省略)", async () => {
|
|
296
|
+
seed(blockedSnap({}));
|
|
297
|
+
seedHeartbeat(1_000); // 心跳齐全 ⇒ 唯一缺的就是 daemon 没报那一格
|
|
298
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
299
|
+
expect(r.ok).toBe(false);
|
|
300
|
+
expect("capability_observed_ms_ago" in r).toBe(true);
|
|
301
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
302
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
303
|
+
// 🔴 绝不能渲染成"刚测的"
|
|
304
|
+
expect(r.capability_observed_ms_ago).not.toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["非数字", "12"],
|
|
311
|
+
["null", null],
|
|
312
|
+
])("坏值当作没报(不 clamp 成一个看起来正常的年龄):%s", async (_l, v) => {
|
|
313
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: v }));
|
|
314
|
+
// 🔴 必须给心跳:否则这几条会因为"没有心跳时间"而变成
|
|
315
|
+
// unknown_no_heartbeat_time,**坏值那一维根本没被测到**
|
|
316
|
+
// (夹具只该在被测的那一维上不合法)。
|
|
317
|
+
seedHeartbeat(1_000);
|
|
318
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
319
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
320
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
321
|
+
// 分母自证:拒绝本身仍然发生了,不是因为整条路径没走到才"没有年龄"
|
|
322
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("边界:恰好一年被接受 ⇒ 上面那些不是因为恒判 unknown 才绿", async () => {
|
|
326
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
327
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: oneYear }));
|
|
328
|
+
seedHeartbeat(1_000);
|
|
329
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
330
|
+
expect(r.capability_age).toBe("known");
|
|
331
|
+
expect(r.capability_observed_ms_ago as number).toBeGreaterThanOrEqual(oneYear);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/* 🔴 hub 出**代码**,不出渲染好的修法命令 —— 那张 code → 命令 的表只有一个作者
|
|
335
|
+
* (@sleep2agi/agent-network 的 daemon-capability-display)。抄一份到 hub,
|
|
336
|
+
* 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时没有任何东西会红。 */
|
|
337
|
+
test("🔴 hub 不返回具体修法命令,只把人指到那唯一一份", async () => {
|
|
338
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 0 }));
|
|
339
|
+
seedHeartbeat(1_000);
|
|
340
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
341
|
+
const blob = JSON.stringify(r);
|
|
342
|
+
for (const leaked of ["chmod go-w", "npm i -g", "readlink -f", "/etc/anet-daemon"]) {
|
|
343
|
+
expect(blob).not.toContain(leaked);
|
|
344
|
+
}
|
|
345
|
+
expect(String(r.remediation_hint)).toContain("anet daemon list");
|
|
346
|
+
// 正控:这四条不是恒真 —— 它们确实是那份表里的真实内容
|
|
347
|
+
expect("该二进制 group/other 可写。一行修:chmod go-w \"$(command -v anet)\"").toContain("chmod go-w");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("健康 daemon 不受影响:正常派发,不带这两个键", async () => {
|
|
351
|
+
seed({ role: "host_supervisor", daemon_capabilities: { can_create_nodes: true } });
|
|
352
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
353
|
+
expect(r.ok).not.toBe(false);
|
|
354
|
+
expect("capability_age" in r).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -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";
|
|
@@ -35,6 +35,15 @@ describe("validateRuntime / validateModel (§4.2.2)", () => {
|
|
|
35
35
|
expect(() => validateModel("gpt-4o")).not.toThrow();
|
|
36
36
|
expect(() => validateModel("bad model")).toThrow(ValidationError);
|
|
37
37
|
expect(() => validateModel("x;rm")).toThrow(ValidationError);
|
|
38
|
+
// provider/model(OpenCode 共存;桌面向导 0.2.61 起发这种形状,Vincent 2026-09-08 撞到 model_invalid)
|
|
39
|
+
expect(() => validateModel("opencode/mimo-v2.5-free")).not.toThrow();
|
|
40
|
+
expect(() => validateModel("anthropic/claude-sonnet-4")).not.toThrow();
|
|
41
|
+
expect(() => validateModel("a/b/c")).toThrow(ValidationError); // 只许一个斜杠
|
|
42
|
+
expect(() => validateModel("/model")).toThrow(ValidationError); // 不能以斜杠开头
|
|
43
|
+
expect(() => validateModel("provider/")).toThrow(ValidationError); // 不能以斜杠结尾
|
|
44
|
+
expect(() => validateModel("../x")).toThrow(ValidationError); // 纯点段(路径穿越形状)
|
|
45
|
+
expect(() => validateModel("x/.")).toThrow(ValidationError);
|
|
46
|
+
expect(() => validateModel("open code/m")).toThrow(ValidationError);
|
|
38
47
|
expect(() => validateModel("")).toThrow(ValidationError);
|
|
39
48
|
});
|
|
40
49
|
});
|
|
@@ -169,6 +178,16 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
|
|
|
169
178
|
expect(args).toContain("--permission-mode");
|
|
170
179
|
expect(args[args.indexOf("--permission-mode") + 1]).toBe("plan");
|
|
171
180
|
});
|
|
181
|
+
test("omitted model is allowed and does not emit --model", () => {
|
|
182
|
+
const args = buildAnetArgs({
|
|
183
|
+
name: "x", runtime: "codex-sdk",
|
|
184
|
+
flags: { permissionMode: "plan" },
|
|
185
|
+
});
|
|
186
|
+
expect(args).toEqual(["node", "create", "x", "--runtime", "codex-sdk", "--permission-mode", "plan"]);
|
|
187
|
+
});
|
|
188
|
+
test("empty model is still rejected", () => {
|
|
189
|
+
expect(() => buildAnetArgs({ name: "x", runtime: "codex-sdk", model: "" })).toThrow(ValidationError);
|
|
190
|
+
});
|
|
172
191
|
test("rejects bad name with shell metachar", () => {
|
|
173
192
|
expect(() => buildAnetArgs({ name: ";rm -rf /", runtime: "claude-agent-sdk", model: "x" })).toThrow(ValidationError);
|
|
174
193
|
});
|
|
@@ -185,3 +204,72 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
|
|
|
185
204
|
} as any)).toThrow(ValidationError);
|
|
186
205
|
});
|
|
187
206
|
});
|
|
207
|
+
|
|
208
|
+
// ─── runtime_invalid 要说清「允许哪些」和「还有什么路」 ────────────────
|
|
209
|
+
//
|
|
210
|
+
// 2026-08-28 实测:从 Dashboard 建一个 codex-app-server 节点,用户拿到的是
|
|
211
|
+
// {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
|
|
212
|
+
// 而目标机器的 daemon 日志里一行都没有 —— 请求被 hub 拦在最前面,
|
|
213
|
+
// 用户无从知道①哪些允许②是不是打错了③有没有别的办法。
|
|
214
|
+
// 🔴 2026-08-28:这一节原本拿 `codex-app-server` / `opencode-cli` 当"非法 runtime"的例子。
|
|
215
|
+
// #1298 把 hub 的 RUNTIMES 从 3 个放开到 7 个之后,那两个变成了合法值,这两条测试因此红了 ——
|
|
216
|
+
// **测试正确地抓到了行为变更**。修法是换例子,不是削弱断言:
|
|
217
|
+
// 意图仍然是「非法 runtime 要给可操作的报错」,只是例子换成永远不会合法的字符串。
|
|
218
|
+
describe("validateRuntime — 报错要可操作", () => {
|
|
219
|
+
function thrown(v: unknown): any {
|
|
220
|
+
try { validateRuntime(v); } catch (e: any) { return e; }
|
|
221
|
+
throw new Error("expected validateRuntime to throw");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test("非法 runtime 的报错带上允许集合", () => {
|
|
225
|
+
const e = thrown("definitely-not-a-runtime");
|
|
226
|
+
expect(e.code).toBe("runtime_invalid");
|
|
227
|
+
expect(Array.isArray(e.detail?.allowed)).toBe(true);
|
|
228
|
+
// 🔴 断言它等于 RUNTIMES 本身,而不是等于一份手抄的清单 ——
|
|
229
|
+
// 手抄的那份会漂,漂了之后报错会理直气壮地告诉用户一组错的名字。
|
|
230
|
+
expect(e.detail.allowed).toEqual([...RUNTIMES]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("报错带上一条出路提示", () => {
|
|
234
|
+
const e = thrown("another-bogus-runtime");
|
|
235
|
+
expect(typeof e.detail?.hint).toBe("string");
|
|
236
|
+
expect(e.detail.hint.length).toBeGreaterThan(10);
|
|
237
|
+
// 提示必须点名那条真实存在的路,否则它只是安慰话
|
|
238
|
+
expect(e.detail.hint).toContain("anet node create");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("仍然回报用户传进来的那个值(便于识别是不是打错了)", () => {
|
|
242
|
+
const e = thrown("codex-app-servr"); // 故意少一个 e
|
|
243
|
+
expect(e.detail.value).toBe("codex-app-servr");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("🔴 合法 runtime 一个都不能被这次改动误伤", () => {
|
|
247
|
+
for (const r of RUNTIMES) {
|
|
248
|
+
expect(() => validateRuntime(r)).not.toThrow();
|
|
249
|
+
}
|
|
250
|
+
// 正控:非字符串必须仍然被拒 —— 否则上面那条循环全过也说明不了什么
|
|
251
|
+
expect(() => validateRuntime(123 as any)).toThrow();
|
|
252
|
+
expect(() => validateRuntime(undefined as any)).toThrow();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// #1298 —— daemon 远程创建放开到 7 个 runtime(Vincent 2026-08-28 定,
|
|
257
|
+
// 理由是兜底排错:节点不动了,人要能进去跟 Codex / Claude Code 对话)。
|
|
258
|
+
describe("#1298 RUNTIMES —— 七个都放行,且与 CLI 侧一致", () => {
|
|
259
|
+
test("三个共存 runtime 不再被拒", () => {
|
|
260
|
+
for (const r of ["codex-app-server", "grok-build-cli", "opencode-cli"]) {
|
|
261
|
+
expect(() => validateRuntime(r)).not.toThrow();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
test("原有四个仍然放行(放开不该顺手改坏别的)", () => {
|
|
265
|
+
for (const r of ["claude-agent-sdk", "claude-code-cli", "codex-sdk", "grok-build-acp"]) {
|
|
266
|
+
expect(() => validateRuntime(r)).not.toThrow();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
test("🔴 恰好七个 —— 多一个少一个都要有人解释", () => {
|
|
270
|
+
expect(RUNTIMES.length).toBe(7);
|
|
271
|
+
// 正控:不在名单里的仍然被拒,证明上面不是"什么都放行"
|
|
272
|
+
expect(() => validateRuntime("definitely-not-a-runtime")).toThrow();
|
|
273
|
+
expect(() => validateRuntime("")).toThrow();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -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;
|
|
@@ -32,7 +58,11 @@ const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
|
32
58
|
|
|
33
59
|
// §4.2.2 name + model.
|
|
34
60
|
const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
35
|
-
|
|
61
|
+
// OpenCode 共存的模型带 provider 前缀(`opencode/mimo-v2.5-free`,agent-node parseModelRef 按第一个 `/` 切),
|
|
62
|
+
// 桌面向导 0.2.61 起就发这种形状 —— 允许**恰好一个**斜杠分隔的两段;每段仍是原字符集,且不能是纯点(`..`/`.`)。
|
|
63
|
+
const MODEL_SEGMENT = "[a-zA-Z0-9._:\\-]+";
|
|
64
|
+
const MODEL_RE = new RegExp(`^${MODEL_SEGMENT}(?:/${MODEL_SEGMENT})?$`);
|
|
65
|
+
const MODEL_DOT_ONLY_SEGMENT = /(^|\/)\.+(\/|$)/;
|
|
36
66
|
|
|
37
67
|
export function validateName(s: unknown): asserts s is string {
|
|
38
68
|
if (typeof s !== "string" || !NAME_RE.test(s)) {
|
|
@@ -42,12 +72,29 @@ export function validateName(s: unknown): asserts s is string {
|
|
|
42
72
|
|
|
43
73
|
export function validateRuntime(s: unknown): asserts s is Runtime {
|
|
44
74
|
if (typeof s !== "string" || !(RUNTIMES as readonly string[]).includes(s)) {
|
|
45
|
-
|
|
75
|
+
// 🔴 原来只回一个 `runtime_invalid` + 用户传进来的那个值 —— **不说允许哪些,
|
|
76
|
+
// 也不说还有没有别的路**。2026-08-28 实测:从 Dashboard 建一个
|
|
77
|
+
// `codex-app-server` 节点,用户拿到的就是
|
|
78
|
+
// {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
|
|
79
|
+
// 而目标机器的 daemon 日志里一行都没有(请求根本没到)。
|
|
80
|
+
// 用户无从知道:①哪些是允许的 ②这个名字是不是打错了 ③有没有别的办法。
|
|
81
|
+
//
|
|
82
|
+
// `allowed` 直接由 RUNTIMES 展开,不是手写的第二份清单 ——
|
|
83
|
+
// 🔴 手写一份会漂移,而漂移之后报错会**理直气壮地告诉用户一组错的名字**。
|
|
84
|
+
throw new ValidationError("runtime_invalid", {
|
|
85
|
+
value: typeof s === "string" ? s.slice(0, 80) : typeof s,
|
|
86
|
+
allowed: [...RUNTIMES],
|
|
87
|
+
// 这句提示的前提是查过的:`agent-network/src/normalize-runtime.ts` 的
|
|
88
|
+
// `RuntimeName` / `SUPPORTED_RUNTIME_NAMES` 是 7 个,包含此处不放行的那几个,
|
|
89
|
+
// 所以「在目标机器上直接建」确实是一条真实存在的路,不是安慰话。
|
|
90
|
+
hint: "daemon 远程创建目前只放行以上 runtime;其余 runtime 可以在目标机器上直接 `anet node create --runtime <name>` 创建。",
|
|
91
|
+
});
|
|
46
92
|
}
|
|
47
93
|
}
|
|
48
94
|
|
|
49
|
-
export function validateModel(s: unknown): asserts s is string {
|
|
50
|
-
if (
|
|
95
|
+
export function validateModel(s: unknown): asserts s is string | undefined | null {
|
|
96
|
+
if (s === undefined || s === null) return;
|
|
97
|
+
if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s) || MODEL_DOT_ONLY_SEGMENT.test(s)) {
|
|
51
98
|
throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
52
99
|
}
|
|
53
100
|
}
|
|
@@ -173,7 +220,7 @@ export function validateChannelsP1(channels: unknown): void {
|
|
|
173
220
|
export interface NodeSpec {
|
|
174
221
|
name: string;
|
|
175
222
|
runtime: Runtime;
|
|
176
|
-
model
|
|
223
|
+
model?: string | null;
|
|
177
224
|
flags?: Record<string, unknown>;
|
|
178
225
|
channels?: unknown;
|
|
179
226
|
}
|
|
@@ -192,7 +239,8 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
|
192
239
|
validateModel(spec.model);
|
|
193
240
|
validateChannelsP1(spec.channels);
|
|
194
241
|
|
|
195
|
-
const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime
|
|
242
|
+
const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime];
|
|
243
|
+
if (spec.model) args.push("--model", spec.model);
|
|
196
244
|
if (spec.flags && typeof spec.flags === "object") {
|
|
197
245
|
for (const [k, v] of Object.entries(spec.flags)) {
|
|
198
246
|
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;
|