@sleep2agi/commhub-server 0.9.0-preview.106 → 0.9.0-preview.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-host-supervisors-runtime-readiness.test.ts +182 -0
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +15 -1
- package/src/config-apply-validate.test.ts +9 -0
- package/src/config-apply-validate.ts +6 -2
- package/src/create-node-validate.test.ts +24 -0
- package/src/create-node-validate.ts +20 -3
- package/src/db.ts +20 -0
- package/src/mcp-context-cost-http.test.ts +2 -1
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-store.ts +32 -3
- package/src/node-id-alias.test.ts +3 -1
- package/src/node-lifecycle-controllable.ts +2 -0
- package/src/node-permissions.ts +2 -0
- package/src/runtime-readiness.ts +83 -0
- package/src/server.ts +14 -3
- package/src/stop-delete-node.test.ts +8 -12
- package/src/tool-audience-http.test.ts +3 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools.ts +54 -61
|
@@ -184,6 +184,12 @@ function setupAlphaNetwork() {
|
|
|
184
184
|
seedMembership(USER_A_ID, NET_A, "admin");
|
|
185
185
|
seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK);
|
|
186
186
|
seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID);
|
|
187
|
+
// Board #625: happy paths must carry actual creation authority, not merely a node_ id.
|
|
188
|
+
db.run(
|
|
189
|
+
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
190
|
+
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
191
|
+
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
192
|
+
);
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
// ── stop_node ──────────────────────────────────────────────────────
|
|
@@ -418,12 +424,7 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
|
|
|
418
424
|
// f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
|
|
419
425
|
test("#1448 finding-4: stop/delete reject a wrong same-network daemon_node_id (daemon_child_mismatch)", async () => {
|
|
420
426
|
setupAlphaNetwork();
|
|
421
|
-
//
|
|
422
|
-
db.run(
|
|
423
|
-
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
424
|
-
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
425
|
-
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
426
|
-
);
|
|
427
|
+
// setupAlphaNetwork carries the creation authority.
|
|
427
428
|
// 一个同网、存在、但没创建 CHILD_A 的 daemon
|
|
428
429
|
const DAEMON_B = "node_sd_daemon_b";
|
|
429
430
|
seedDaemon(NET_A, DAEMON_B, "sd-daemon-b", USER_A_ID, "tok_sd_daemon_b");
|
|
@@ -454,12 +455,7 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
|
|
|
454
455
|
expect(readNode(HAND_ID)?.lifecycle_state).toBe("active");
|
|
455
456
|
const rows = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM node_stop_requests WHERE child_node_id = ?1`, HAND_ID);
|
|
456
457
|
expect(Number(rows?.n ?? 0)).toBe(0);
|
|
457
|
-
// 回归钉:
|
|
458
|
-
db.run(
|
|
459
|
-
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
460
|
-
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
461
|
-
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
462
|
-
);
|
|
458
|
+
// 回归钉:setupAlphaNetwork 中有 create 记录的 child 照常可停。
|
|
463
459
|
expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, network_id: NET_A })).ok).toBe(true);
|
|
464
460
|
});
|
|
465
461
|
|
|
@@ -182,7 +182,9 @@ describe("what each caller sees", () => {
|
|
|
182
182
|
describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
183
183
|
// 全部工具(= 改动前每个调用者拿到的)71–72 KB;改动后按角色。改数字时在 PR 里写为什么。
|
|
184
184
|
// 量出来(#478 合入时):全部 74 个工具 71,559 B;节点 52 个 57,632 B(−19%);人 56 个 54,780 B(−23%)。留 ~1.5 KB 余量。
|
|
185
|
-
|
|
185
|
+
// Board #625 adds 2 human tools + 2 hidden daemon protocol tools. Measured:
|
|
186
|
+
// all 78 / 73,969 B; user 58 / 56,615 B; node remains 52 / 58,320 B.
|
|
187
|
+
const CEILINGS = { node: 59_000, user: 57_500, all: 75_000 } as const;
|
|
186
188
|
test("node / user / all", async () => {
|
|
187
189
|
const node = await listTools(T.node_normal);
|
|
188
190
|
const user = await listTools(T.owner);
|
package/src/tool-audience.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { NODE_TOOL_CLASS, nodeIdentity, nodePermissionsFlag, broadcastVerdict, h
|
|
|
23
23
|
export type ToolAudience = "both" | "node" | "protocol" | "user";
|
|
24
24
|
|
|
25
25
|
export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
26
|
+
request_adopt_node: "user", unadopt_node: "user",
|
|
27
|
+
get_adopt_request: "protocol", ack_adopt_request: "protocol",
|
|
26
28
|
// ── 节点:用户令牌一律 network_token_required ──
|
|
27
29
|
report_status: "node",
|
|
28
30
|
// ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
|
package/src/tools.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
3
|
+
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
2
4
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
3
5
|
import { z } from "zod/v4";
|
|
4
|
-
import { nodeHealthSchema, recordNodeHealth } from "./node-health-store.js";
|
|
6
|
+
import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
|
|
5
7
|
import { assertNodeHealthy } from "./node-health-guard.js";
|
|
6
8
|
import { noteModelAuthHealth } from "./model-auth-notify.js";
|
|
7
9
|
import { numberedAliasInClause, parseAliasFilter } from "./alias-filter.js";
|
|
@@ -806,6 +808,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
806
808
|
// 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
|
|
807
809
|
// 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
|
|
808
810
|
daemon_capabilities: z.object({
|
|
811
|
+
adopt_capable: z.boolean().optional(),
|
|
809
812
|
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
810
813
|
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
811
814
|
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
@@ -880,6 +883,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
880
883
|
// `.catch(undefined)`:同上一格的立场 —— 一个诊断/展示字段不许拒掉整份 report。
|
|
881
884
|
// 读取侧再按形状消毒(create-node-validate.ts daemonDefaultWorkdirRoot)。
|
|
882
885
|
default_workdir_root: z.string().max(1024).optional().catch(undefined),
|
|
886
|
+
// #622 —— 逐 runtime 实测就绪度。收得宽、坏了只丢这一格(见 runtime-readiness.ts)。
|
|
887
|
+
runtime_readiness: runtimeReadinessSchema,
|
|
883
888
|
}).optional(),
|
|
884
889
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
885
890
|
// app#225 follow-up — the reporting process answers the `rules_file`
|
|
@@ -1153,7 +1158,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1153
1158
|
// #448 — only the node token bound to this alias may speak for its health
|
|
1154
1159
|
// (same rule as the *_capable flags above).
|
|
1155
1160
|
const acceptedHealth = nodeHealth && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias
|
|
1156
|
-
? nodeHealth
|
|
1161
|
+
? normalizeNodeHealth(nodeHealth) // #594 — strict codex_login shape (the wire schema is loose on purpose)
|
|
1157
1162
|
: undefined;
|
|
1158
1163
|
if (acceptedHealth) {
|
|
1159
1164
|
recordNodeHealth(sessionNetId, effectiveAlias, acceptedHealth);
|
|
@@ -4655,55 +4660,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4655
4660
|
delete_config: boolean;
|
|
4656
4661
|
confirm_alias?: string;
|
|
4657
4662
|
};
|
|
4658
|
-
//
|
|
4659
|
-
//
|
|
4660
|
-
// need to track the daemon→child mapping themselves; the hub has it on
|
|
4661
|
-
// node_create_requests at child creation time. Returns null when no
|
|
4662
|
-
// creation record exists (orphan node row OR pre-RFC-026 node) — caller
|
|
4663
|
-
// must then pass daemon_node_id explicitly. node_id derivation:
|
|
4664
|
-
// `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
|
|
4665
|
-
// and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
|
|
4663
|
+
// Board #625: creation authority first, then an acknowledged adoption binding.
|
|
4664
|
+
// Supplying a daemon id explicitly is routing, never proof of authority.
|
|
4666
4665
|
const resolveDaemonForChild = (child_node_id: string): string | null => {
|
|
4667
|
-
|
|
4668
|
-
const requestId = `cr_${child_node_id.slice(5)}`;
|
|
4669
|
-
const row = db.get<{ daemon_node_id: string }>(
|
|
4670
|
-
`SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
|
|
4671
|
-
requestId,
|
|
4672
|
-
);
|
|
4673
|
-
return row?.daemon_node_id ?? null;
|
|
4666
|
+
return resolveManagedDaemon(child_node_id);
|
|
4674
4667
|
};
|
|
4675
4668
|
|
|
4676
|
-
|
|
4677
|
-
* 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
|
|
4678
|
-
*
|
|
4679
|
-
* `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
|
|
4680
|
-
* ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
|
|
4681
|
-
* 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
|
|
4682
|
-
* daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
|
|
4683
|
-
* ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
|
|
4684
|
-
* 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
|
|
4685
|
-
* 是一条真的走得通的路。
|
|
4686
|
-
*
|
|
4687
|
-
* 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
|
|
4688
|
-
* **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
|
|
4689
|
-
* 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
|
|
4690
|
-
*
|
|
4691
|
-
* 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
|
|
4692
|
-
*/
|
|
4669
|
+
// Preserve old error codes for clients; both now require real managed authority.
|
|
4693
4670
|
const explainUnresolvableDaemon = (child_node_id: string) =>
|
|
4694
4671
|
child_node_id.startsWith("node_")
|
|
4695
4672
|
? {
|
|
4696
4673
|
ok: false, error: "daemon_not_resolvable",
|
|
4697
|
-
message: "no
|
|
4674
|
+
message: "no creation authority or active adoption binding; adopt this node before remote stop/start",
|
|
4698
4675
|
}
|
|
4699
4676
|
: {
|
|
4700
4677
|
ok: false, error: "not_daemon_managed",
|
|
4701
|
-
message:
|
|
4702
|
-
`node ${child_node_id} was not created by a daemon (it was started by hand with ` +
|
|
4703
|
-
`\`anet node start\` on some machine), so no daemon on the Hub can stop it ` +
|
|
4704
|
-
`(naming a daemon_node_id does not change that). ` +
|
|
4705
|
-
`Run \`anet node stop <alias>\` on that machine instead. ` +
|
|
4706
|
-
`Daemon-created children have ids beginning with \`node_\`; this one does not.`,
|
|
4678
|
+
message: "node has no creation authority or active adoption binding; adopt it or run `anet node stop <alias>` on its host",
|
|
4707
4679
|
};
|
|
4708
4680
|
|
|
4709
4681
|
const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
|
|
@@ -4818,27 +4790,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4818
4790
|
return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
|
|
4819
4791
|
}
|
|
4820
4792
|
|
|
4821
|
-
// #
|
|
4822
|
-
// daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
|
|
4823
|
-
// 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
|
|
4824
|
-
// 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
|
|
4825
|
-
// 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
|
|
4826
|
-
// 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
|
|
4827
|
-
//
|
|
4828
|
-
// 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
|
|
4829
|
-
// authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
|
|
4830
|
-
// 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
|
|
4793
|
+
// Board #625 closes the missing-create-record exception, regardless of id prefix.
|
|
4831
4794
|
const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
|
|
4832
|
-
|
|
4833
|
-
// authority==null 时放行调用方点名的任意同网 daemon → 门铃发给那台 daemon,
|
|
4834
|
-
// 它本地没这个 child 的记录,会走「按 alias 全机 pgrep + SIGTERM」的收敛分支,
|
|
4835
|
-
// 杀掉那台机器上所有同 alias 的 agent-node(不分工作目录/HOME/网络)。
|
|
4836
|
-
// 对 hand-started 节点这条路在概念上就不成立(见 explainUnresolvableDaemon ①),
|
|
4837
|
-
// 所以不管调用方传没传 daemon_node_id 一律拒。`node_` 开头但缺 create 记录的
|
|
4838
|
-
// (②,显式传 daemon 是文档化的出路)保持原行为。
|
|
4839
|
-
if (!authoritativeDaemonId && !args.child_node_id.startsWith("node_")) {
|
|
4795
|
+
if (!authoritativeDaemonId) {
|
|
4840
4796
|
return explainUnresolvableDaemon(args.child_node_id);
|
|
4841
4797
|
}
|
|
4798
|
+
if (args.action === "delete" && !createdDaemon(args.child_node_id) && activeBinding(args.child_node_id)) {
|
|
4799
|
+
return { ok: false, error: "adopted_node_delete_unsupported" };
|
|
4800
|
+
}
|
|
4842
4801
|
if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
|
|
4843
4802
|
return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
|
|
4844
4803
|
}
|
|
@@ -5462,15 +5421,49 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5462
5421
|
AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
|
|
5463
5422
|
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
5464
5423
|
);
|
|
5465
|
-
const children = rows.map(r => ({
|
|
5424
|
+
const children: Array<{ child_node_id: string; alias: string; lifecycle_state: string; managed?: "adopted" }> = rows.map(r => ({
|
|
5466
5425
|
child_node_id: r.child_node_id,
|
|
5467
5426
|
alias: r.child_name,
|
|
5468
5427
|
lifecycle_state: r.lifecycle_state ?? "active",
|
|
5469
5428
|
}));
|
|
5429
|
+
const adopted = db.all<{ node_id: string; alias: string; lifecycle_state: string | null }>(
|
|
5430
|
+
`SELECT n.node_id,n.alias,n.lifecycle_state FROM node_daemon_bindings b
|
|
5431
|
+
JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
|
|
5432
|
+
WHERE b.daemon_node_id=?1 AND b.network_id=?2 AND b.status='active'`,
|
|
5433
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
5434
|
+
);
|
|
5435
|
+
for (const n of adopted) children.push({ child_node_id: n.node_id, alias: n.alias, lifecycle_state: n.lifecycle_state ?? "active", managed: "adopted" });
|
|
5470
5436
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
|
|
5471
5437
|
},
|
|
5472
5438
|
);
|
|
5473
5439
|
|
|
5440
|
+
const adoptionHuman = () => ({ userId: enforceUserId, networkId: enforceNetworkId, isNode: callerTokenIsNetwork, canWrite });
|
|
5441
|
+
const adoptionReply = (result: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(result) }] });
|
|
5442
|
+
server.tool(
|
|
5443
|
+
"request_adopt_node",
|
|
5444
|
+
"Human owner/admin requests adoption. Only the target capable daemon may activate the binding after local verification; no process is signalled.",
|
|
5445
|
+
{ node_id: z.string().min(1).max(200), daemon_node_id: z.string().min(1).max(200), workdir: z.string().min(1).max(4096) },
|
|
5446
|
+
async args => adoptionReply(requestAdopt(adoptionHuman(), args)),
|
|
5447
|
+
);
|
|
5448
|
+
server.tool(
|
|
5449
|
+
"get_adopt_request",
|
|
5450
|
+
"Target daemon pulls a pending adoption request for local verification.",
|
|
5451
|
+
{ request_id: z.string().min(1).max(200) },
|
|
5452
|
+
async ({ request_id }) => adoptionReply(getAdopt(resolveCallerDaemonTokenBound(), request_id)),
|
|
5453
|
+
);
|
|
5454
|
+
server.tool(
|
|
5455
|
+
"ack_adopt_request",
|
|
5456
|
+
"Target daemon confirms local registration or refuses adoption. Revoked requests cannot reactivate.",
|
|
5457
|
+
{ request_id: z.string().min(1).max(200), status: z.enum(["adopted", "refused"]), error: z.string().max(1000).optional() },
|
|
5458
|
+
async args => adoptionReply(ackAdopt(resolveCallerDaemonTokenBound(), args)),
|
|
5459
|
+
);
|
|
5460
|
+
server.tool(
|
|
5461
|
+
"unadopt_node",
|
|
5462
|
+
"Human owner/admin revokes a pending or active binding without stopping the node.",
|
|
5463
|
+
{ node_id: z.string().min(1).max(200) },
|
|
5464
|
+
async ({ node_id }) => adoptionReply(unadopt(adoptionHuman(), node_id)),
|
|
5465
|
+
);
|
|
5466
|
+
|
|
5474
5467
|
// ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
|
|
5475
5468
|
// Background timers (probe GC + sweeper) idempotent; safe to call
|
|
5476
5469
|
// per registerTools.
|
|
@@ -5896,7 +5889,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5896
5889
|
vendor: blob.vendor,
|
|
5897
5890
|
base_url: blob.base_url,
|
|
5898
5891
|
model_name: blob.model_name,
|
|
5899
|
-
api_key: blob.api_key, // ephemeral; daemon writes to .env
|
|
5892
|
+
api_key: blob.api_key, // ephemeral; daemon writes to secrets.env or in-memory only
|
|
5900
5893
|
}) }] };
|
|
5901
5894
|
},
|
|
5902
5895
|
);
|