@sleep2agi/commhub-server 0.9.0-preview.106 → 0.9.0-preview.107

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.106",
3
+ "version": "0.9.0-preview.107",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,182 @@
1
+ // #622 —— daemon 逐 runtime 自检(runtime_readiness)从 report_status 一路走到
2
+ // /api/host-supervisors。三件事:
3
+ // 1. report_status 的 zod 形状收得宽:坏的 runtime_readiness 只丢它自己,
4
+ // 绝不拒掉整份 report(否则一台 daemon 发了个奇怪的诊断字段就在 hub 上失联);
5
+ // 2. 新字段会被**保留**(daemon_capabilities 不是 strict,schema 里没声明的键会被
6
+ // 静默剥掉 —— 不加 schema 就永远到不了 REST);
7
+ // 3. REST 只在 daemon 报了时输出,旧 daemon 的行里**没有**这个键;
8
+ // can_create_nodes 语义不变。
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import "./require-explicit-test-db.js";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { register, login } from "./auth.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
17
+
18
+ function captureToolSchemas(): Record<string, any> {
19
+ const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
20
+ const schemas: Record<string, any> = {};
21
+ const origTool = mcp.tool.bind(mcp);
22
+ mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
23
+ schemas[name] = schema;
24
+ return origTool(name, desc, schema, handler);
25
+ };
26
+ registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
27
+ return schemas;
28
+ }
29
+ const schemas = captureToolSchemas();
30
+ const snapSchema = () => schemas.report_status.config_snapshot;
31
+ const base = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
32
+
33
+ const READY = {
34
+ ok: true, state: "ready", reason: "可以创建", version: "2.1.290",
35
+ checked_at: "2026-10-06T03:00:00.000Z", cli: "found", auth: "present", network: "reachable",
36
+ };
37
+ const MISSING = {
38
+ ok: false, state: "missing_cli", reason: "新节点的 PATH 上找不到可用的 grok 命令", checked_at: "2026-10-06T03:00:00.000Z",
39
+ cli: "missing", auth: "present", network: "skipped",
40
+ };
41
+
42
+ describe("#622 report_status schema —— runtime_readiness", () => {
43
+ test("合法值透传(没被 daemon_capabilities 的非 strict 剥掉)", () => {
44
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: {
45
+ can_create_nodes: true,
46
+ runtime_readiness: { "claude-code-cli": READY, "grok-build-acp": MISSING, "codex-sdk": { ...READY, shared_login_count: 27 } },
47
+ } });
48
+ expect(parsed.daemon_capabilities.runtime_readiness["claude-code-cli"].state).toBe("ready");
49
+ expect(parsed.daemon_capabilities.runtime_readiness["grok-build-acp"].state).toBe("missing_cli");
50
+ expect(parsed.daemon_capabilities.runtime_readiness["codex-sdk"].shared_login_count).toBe(27);
51
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
52
+ });
53
+
54
+ test("🔴 坏形状绝不拒整份 report —— 只丢这一格", () => {
55
+ for (const bad of [
56
+ "string", 42, null, [], { x: "notobj" },
57
+ Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`rt${i}`, READY])),
58
+ ]) {
59
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: { can_create_nodes: true, runtime_readiness: bad } });
60
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
61
+ expect(parsed.daemon_capabilities.runtime_readiness).toBeUndefined();
62
+ }
63
+ });
64
+
65
+ test("单个字段坏:schema 不拒整份;读取侧消毒把它兜成 unknown", () => {
66
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: {
67
+ can_create_nodes: true,
68
+ runtime_readiness: { a: { ok: "yes", state: 7, reason: "x".repeat(5000), shared_login_count: -3 } },
69
+ } });
70
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
71
+ const a = sanitizeRuntimeReadiness(parsed.daemon_capabilities.runtime_readiness)!.a;
72
+ expect(a.ok).toBe(false);
73
+ expect(a.state).toBe("unknown");
74
+ expect(a.reason.length).toBe(600);
75
+ expect(a.shared_login_count).toBeUndefined();
76
+ });
77
+
78
+ test("总字节超 16 KB:整格丢掉(异常 daemon 不能往库里塞大对象)", () => {
79
+ const big = { a: { ...READY, reason: "x".repeat(20_000) } };
80
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: { can_create_nodes: true, runtime_readiness: big } });
81
+ expect(parsed.daemon_capabilities.runtime_readiness).toBeUndefined();
82
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("#622 sanitizeRuntimeReadiness(读取侧)", () => {
87
+ test("不认识的 state → unknown;ok 只有 ready 时才可能为真", () => {
88
+ const s = sanitizeRuntimeReadiness({
89
+ a: { ok: true, state: "something_new", reason: "r" },
90
+ b: { ok: true, state: "no_network", reason: "r" },
91
+ })!;
92
+ expect(s.a.state).toBe("unknown");
93
+ expect(s.a.ok).toBe(false);
94
+ expect(s.b.ok).toBe(false);
95
+ });
96
+ test("没报 / 空 → undefined(调用方据此不输出键)", () => {
97
+ expect(sanitizeRuntimeReadiness(undefined)).toBeUndefined();
98
+ expect(sanitizeRuntimeReadiness({})).toBeUndefined();
99
+ expect(sanitizeRuntimeReadiness([])).toBeUndefined();
100
+ });
101
+ test("未知枚举值的 cli/auth/network 被丢掉", () => {
102
+ const s = sanitizeRuntimeReadiness({ a: { ...READY, cli: "/home/x/bin/claude", network: "maybe" } })!;
103
+ expect(s.a.cli).toBeUndefined();
104
+ expect(s.a.network).toBeUndefined();
105
+ expect(s.a.auth).toBe("present");
106
+ });
107
+ });
108
+
109
+ let BASE = "";
110
+ let server: any = null;
111
+ let userToken = "";
112
+ let userNetworkId = "";
113
+ let userId = "";
114
+
115
+ beforeAll(async () => {
116
+ process.env.HOST = "127.0.0.1";
117
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
118
+ const password = "BootstrapPw123Aa!";
119
+ const seed = register(`rr_seed_${suffix}`, password, undefined, "seed");
120
+ if (!seed.ok) throw new Error("seed failed");
121
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`rr_seed_${suffix}`]);
122
+ const u = register(`rr_u_${suffix}`, password, undefined, "seed");
123
+ if (!u.ok || !u.token) throw new Error("user register failed");
124
+ userToken = u.token;
125
+ userNetworkId = u.network_id ?? "";
126
+ userId = login(`rr_u_${suffix}`, password).user!.user_id;
127
+ const { bootServer } = await import("./server.js");
128
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
129
+ BASE = `http://127.0.0.1:${server.port}`;
130
+ await new Promise((r) => setTimeout(r, 100));
131
+ });
132
+
133
+ afterAll(() => {
134
+ try { server?.stop?.(true); } catch {}
135
+ });
136
+
137
+ function seedDaemon(alias: string, snapshot: object): void {
138
+ db.run(
139
+ `INSERT OR REPLACE INTO nodes (
140
+ node_id, node_name, alias, runtime, model, config_path,
141
+ channels, server, hostname, network_id, config_revision, config_snapshot
142
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
143
+ [`rr_n_${alias}`, alias, alias, "claude-agent-sdk", null, "/tmp/cfg.json", "[]", "test-host", "test-host",
144
+ userNetworkId, 0, JSON.stringify(snapshot)],
145
+ );
146
+ db.run(
147
+ `INSERT OR REPLACE INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
148
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
149
+ [`rr_t_${alias}`, `hash_rr_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
150
+ );
151
+ }
152
+
153
+ async function listDaemons(): Promise<any[]> {
154
+ const res = await fetch(`${BASE}/api/host-supervisors`, { headers: { Authorization: `Bearer ${userToken}` } });
155
+ const body = await res.json();
156
+ if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
157
+ return body.daemons;
158
+ }
159
+
160
+ describe("#622 /api/host-supervisors 输出 runtime_readiness", () => {
161
+ test("新 daemon:逐 runtime 透传;can_create_nodes 不受影响", async () => {
162
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
163
+ seedDaemon("rr-new", { role: "host_supervisor", daemon_capabilities: {
164
+ runtimes_supported: ["claude-code-cli", "grok-build-acp"],
165
+ can_create_nodes: true,
166
+ runtime_readiness: { "claude-code-cli": READY, "grok-build-acp": MISSING },
167
+ } });
168
+ const d = (await listDaemons()).find((x) => x.alias === "rr-new");
169
+ expect(d.can_create_nodes).toBe(true);
170
+ expect(d.runtime_readiness["claude-code-cli"]).toEqual(READY);
171
+ expect(d.runtime_readiness["grok-build-acp"].state).toBe("missing_cli");
172
+ expect(d.runtime_readiness["grok-build-acp"].ok).toBe(false);
173
+ });
174
+
175
+ test("🔴 旧 daemon(不报):响应里没有这个键 —— 不是 {}、不是 null", async () => {
176
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
177
+ seedDaemon("rr-old", { role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"], can_create_nodes: true } });
178
+ const d = (await listDaemons()).find((x) => x.alias === "rr-old");
179
+ expect(d.can_create_nodes).toBe(true);
180
+ expect("runtime_readiness" in d).toBe(false);
181
+ });
182
+ });
@@ -249,6 +249,15 @@ describe("validatePatch — per-flag enum / range", () => {
249
249
  expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
250
250
  expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
251
251
  });
252
+ test("timeout: 0 = no limit; 1..999 is seconds-shaped and rejected (#605)", () => {
253
+ expect(validatePatch(undefined, { timeout: 0 })).toBeNull();
254
+ expect(validatePatch(undefined, { timeout: 1000 })).toBeNull();
255
+ for (const v of [1, 600, 999]) {
256
+ const r = validatePatch(undefined, { timeout: v });
257
+ expect(r?.field).toBe("flags.timeout");
258
+ expect(r?.reason).toContain("milliseconds");
259
+ }
260
+ });
252
261
  });
253
262
 
254
263
  describe("validatePatch — combinations", () => {
@@ -5,6 +5,8 @@
5
5
  // tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
6
6
  // caller identity, role gates).
7
7
 
8
+ import { isValidTimeoutMs, TIMEOUT_MS_REASON } from "./create-node-validate.js";
9
+
8
10
  /**
9
11
  * Fields the dashboard may change. Anything not in this list is rejected
10
12
  * by hub-side validation regardless of role — the UI cannot smuggle
@@ -239,8 +241,10 @@ export function validatePatch(
239
241
  }
240
242
  break;
241
243
  case "timeout":
242
- if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 3_600_000) {
243
- return { field: "flags.timeout", reason: "must be an integer ms in [0, 3_600_000]" };
244
+ // #605 —— 与 create_node 同一把尺子(create-node-validate.ts isValidTimeoutMs):
245
+ // 毫秒,0 或 1000..3_600_000;1..999 是「按秒填了」的形状,拒绝而不是给一个亚秒超时。
246
+ if (!isValidTimeoutMs(val)) {
247
+ return { field: "flags.timeout", reason: TIMEOUT_MS_REASON };
244
248
  }
245
249
  break;
246
250
  }
@@ -69,6 +69,30 @@ describe("validateFlagValue (§4.2.2)", () => {
69
69
  expect(() => validateFlagValue("permissionMode", "plan")).not.toThrow();
70
70
  expect(() => validateFlagValue("permissionMode", "anything-else")).toThrow(ValidationError);
71
71
  });
72
+ // #605 —— agent-node 按毫秒读 flags.timeout(默认 300000)。这里曾收 1..86400(秒的形状):
73
+ // 600 原样到子节点 = 0.6 秒超时;600000(10 分钟)反被拒。现在与 update_node_config 同一把尺子。
74
+ test("timeout is milliseconds: 0 or 1000..3_600_000 (#605)", () => {
75
+ for (const v of [0, 1000, 300_000, 600_000, 3_600_000]) {
76
+ expect(() => validateFlagValue("timeout", v)).not.toThrow();
77
+ }
78
+ for (const v of [1, 600, 999, 86_400 * 1000 + 1, 3_600_001, -1, 1500.5, "600000", null]) {
79
+ expect(() => validateFlagValue("timeout", v)).toThrow(ValidationError);
80
+ }
81
+ try { validateFlagValue("timeout", 600); throw new Error("unreachable"); }
82
+ catch (e) {
83
+ expect(e).toBeInstanceOf(ValidationError);
84
+ expect((e as ValidationError).code).toBe("flag_value_invalid");
85
+ expect(String((e as ValidationError).detail?.reason)).toContain("milliseconds");
86
+ }
87
+ });
88
+ test("timeout range matches update_node_config's (#605)", async () => {
89
+ const { validatePatch } = await import("./config-apply-validate.js");
90
+ for (const v of [0, 1, 600, 999, 1000, 600_000, 3_600_000, 3_600_001, -1, 1.5]) {
91
+ let createOk = true;
92
+ try { validateFlagValue("timeout", v); } catch { createOk = false; }
93
+ expect({ v, ok: createOk }).toEqual({ v, ok: validatePatch(undefined, { timeout: v }) === null });
94
+ }
95
+ });
72
96
  });
73
97
 
74
98
  describe("validateEnvRefs (§4.4.7 + B1 G7/G8 sub-cases)", () => {
@@ -108,6 +108,23 @@ export function validateModel(s: unknown): asserts s is string | undefined | nul
108
108
  }
109
109
  }
110
110
 
111
+ /**
112
+ * #605 —— `flags.timeout` 的单位是**毫秒**:agent-node 就是这么读的(cli.ts currentClaudeTimeoutMs /
113
+ * currentCodexTimeoutMs → util/timeout.ts resolveTimeoutMs、runtime/opencode-timeout.ts,默认 300000)。
114
+ * 这里曾按「秒」收 1..86400:填 600 想要 10 分钟的人拿到 0.6 秒、每个任务都超时;真正的毫秒值被拒。
115
+ *
116
+ * 范围与 update_node_config(config-apply-validate.ts)/ agent-node config-apply 同一把尺子:
117
+ * 0(不设上限)或 TIMEOUT_MS_MIN..TIMEOUT_MS_MAX。1..999 拒绝 —— 旧客户端按秒发的小值分辨不出意图,
118
+ * 不偷偷乘 1000,但也不再让任何人悄悄拿到一个亚秒超时。
119
+ */
120
+ export const TIMEOUT_MS_MIN = 1000;
121
+ export const TIMEOUT_MS_MAX = 3_600_000;
122
+ export const TIMEOUT_MS_REASON =
123
+ `timeout is in milliseconds: 0 (no limit) or an integer ${TIMEOUT_MS_MIN}..${TIMEOUT_MS_MAX} (e.g. 600000 = 10 min)`;
124
+ export function isValidTimeoutMs(v: unknown): v is number {
125
+ return typeof v === "number" && Number.isInteger(v) && (v === 0 || (v >= TIMEOUT_MS_MIN && v <= TIMEOUT_MS_MAX));
126
+ }
127
+
111
128
  export function validateFlagValue(k: string, v: unknown): void {
112
129
  switch (k) {
113
130
  case "permissionMode":
@@ -129,8 +146,8 @@ export function validateFlagValue(k: string, v: unknown): void {
129
146
  }
130
147
  return;
131
148
  case "timeout":
132
- if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 86400) {
133
- throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..86400" });
149
+ if (!isValidTimeoutMs(v)) {
150
+ throw new ValidationError("flag_value_invalid", { field: k, reason: TIMEOUT_MS_REASON });
134
151
  }
135
152
  return;
136
153
  case "copresence":
package/src/db.ts CHANGED
@@ -2104,3 +2104,23 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
2104
2104
  if (db.dialect === "postgres") {
2105
2105
  for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
2106
2106
  }
2107
+
2108
+ // Board #625: common SQLite/PostgreSQL DDL. Adoption records authority, not processes.
2109
+ db.exec(`
2110
+ CREATE TABLE IF NOT EXISTS node_daemon_bindings (
2111
+ request_id TEXT PRIMARY KEY,
2112
+ network_id TEXT NOT NULL,
2113
+ node_id TEXT NOT NULL,
2114
+ daemon_node_id TEXT NOT NULL,
2115
+ workdir TEXT NOT NULL,
2116
+ requested_by TEXT NOT NULL,
2117
+ status TEXT NOT NULL CHECK (status IN ('pending','active','refused','revoked')),
2118
+ error TEXT,
2119
+ created_at BIGINT NOT NULL,
2120
+ updated_at BIGINT NOT NULL
2121
+ );
2122
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ndb_live_node
2123
+ ON node_daemon_bindings(node_id) WHERE status IN ('pending','active');
2124
+ CREATE INDEX IF NOT EXISTS idx_ndb_daemon_network
2125
+ ON node_daemon_bindings(daemon_node_id,network_id,status);
2126
+ `);
@@ -25,7 +25,8 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
25
25
  * 上限跟着收到用户这一档(~1.7 KB 余量);节点 / 全部工具的上限在 tool-audience-http.test.ts。
26
26
  * 加工具或加说明超过它,就在这里改数字并在 PR 里说为什么。
27
27
  */
28
- const TOOLS_LIST_MAX_BYTES = 56_500;
28
+ // Board #625: two human adoption tools; measured 58 tools / 56,615 B.
29
+ const TOOLS_LIST_MAX_BYTES = 57_500;
29
30
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
30
31
  const TOOL_MAX_BYTES = 9_000;
31
32
  /** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
@@ -0,0 +1,132 @@
1
+ import { beforeEach, afterAll, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { buildControllableMap } from "./node-lifecycle-controllable.js";
6
+
7
+ const NET = "net_adoption_test", USER = "u_adoption_test", CHILD = "n_adoption_child", DAEMON = "node_adoption_daemon";
8
+ function cleanup() {
9
+ for (const table of ["node_daemon_bindings", "node_stop_requests", "node_start_requests", "node_create_requests", "audit_log", "sessions", "nodes", "api_tokens", "network_members", "networks"])
10
+ db.run(`DELETE FROM ${table} WHERE network_id=?1`, [NET]);
11
+ db.run("DELETE FROM users WHERE user_id=?1", [USER]);
12
+ }
13
+ beforeEach(() => {
14
+ cleanup();
15
+ db.run("INSERT INTO users(user_id,username,password_hash,role) VALUES(?1,?1,'x','user')", [USER]);
16
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,?1,?2)", [NET, USER]);
17
+ db.run("INSERT INTO network_members(user_id,network_id,role) VALUES(?1,?2,'owner')", [USER, NET]);
18
+ for (const [id, alias, snap] of [[DAEMON, "adoption-daemon", { role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }], [CHILD, "adoption-child", {}]] as const) {
19
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state) VALUES(?1,?2,?2,?3,?4,'test-host',?5,'active')`, [id, alias, NET, USER, JSON.stringify(snap)]);
20
+ }
21
+ db.run("INSERT INTO sessions(resume_id,session_id,alias,network_id,status,last_seen_at) VALUES('adoption-session','adoption-session','adoption-daemon',?1,'idle',datetime('now'))", [NET]);
22
+ db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('adoption-token',?1,?2,'network','node:adoption-daemon','adoption-hash')", [USER, NET]);
23
+ });
24
+ afterAll(cleanup);
25
+ function handlers(kind: "user" | "daemon" | "stranger" = "user") {
26
+ const server = new McpServer({ name: "adoption-test", version: "0" }) as any;
27
+ const out: any = {}, original = server.tool.bind(server);
28
+ server.tool = (name: string, desc: string, schema: any, handler: any) => { out[name] = handler; return original(name, desc, schema, handler); };
29
+ registerTools(server, undefined, kind === "daemon" ? NET : null, kind === "user" ? USER : null, null, kind === "daemon", kind === "daemon" ? "adoption-token" : null);
30
+ return out;
31
+ }
32
+ const call = async (handler: any, args: any = {}) => JSON.parse((await handler(args)).content[0].text);
33
+ const request = () => call(handlers().request_adopt_node, { node_id: CHILD, daemon_node_id: DAEMON, workdir: "/workspace/project" });
34
+ async function adopt() {
35
+ const r = await request(); expect(r.ok).toBe(true);
36
+ expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
37
+ return r.request_id;
38
+ }
39
+
40
+ test("unbound node_ cannot use an explicitly supplied daemon (witnessed red on main)", async () => {
41
+ db.run("UPDATE nodes SET node_id='node_unbound_child' WHERE node_id=?1", [CHILD]);
42
+ expect(await call(handlers().stop_node, { child_node_id: "node_unbound_child", daemon_node_id: DAEMON, network_id: NET })).toMatchObject({ ok: false, error: "daemon_not_resolvable" });
43
+ expect(db.all("SELECT * FROM node_stop_requests WHERE network_id=?1", NET)).toHaveLength(0);
44
+ });
45
+ test("two-phase binding, daemon-bound pull, controllable map, adopted delete refused", async () => {
46
+ const r = await request(); expect(r.ok).toBe(true);
47
+ expect(await call(handlers().stop_node, { child_node_id: CHILD, daemon_node_id: DAEMON, network_id: NET })).toMatchObject({ ok: false });
48
+ expect(await call(handlers("daemon").get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: true, node_id: CHILD, alias: "adoption-child", workdir: "/workspace/project" });
49
+ expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
50
+ expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true });
51
+ expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted" });
52
+ expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
53
+ expect(await call(handlers().delete_node, { child_node_id: CHILD, confirm_alias: "adoption-child", network_id: NET })).toMatchObject({ ok: false, error: "adopted_node_delete_unsupported" });
54
+ expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
55
+ });
56
+ test("adopted stop/start complete through existing protocol; revocation removes authority", async () => {
57
+ await adopt(); const u = handlers(), d = handlers("daemon");
58
+ const stop = await call(u.stop_node, { child_node_id: CHILD, network_id: NET }); expect(stop.ok).toBe(true);
59
+ expect((await call(d.get_stop_request, { request_id: stop.request_id })).ok).toBe(true);
60
+ expect((await call(d.ack_stop_request, { request_id: stop.request_id, status: "stopped" })).ok).toBe(true);
61
+ expect((await call(d.list_my_children)).children[0].lifecycle_state).toBe("stopped");
62
+ const start = await call(u.start_node, { child_node_id: CHILD, network_id: NET }); expect(start.ok).toBe(true);
63
+ expect((await call(d.get_start_request, { request_id: start.request_id })).ok).toBe(true);
64
+ expect((await call(d.ack_start_request, { request_id: start.request_id, status: "started", child_pid: 4321 })).ok).toBe(true);
65
+ expect((await call(u.unadopt_node, { node_id: CHILD })).ok).toBe(true);
66
+ expect((await call(d.list_my_children)).children).toHaveLength(0);
67
+ expect((await call(u.stop_node, { child_node_id: CHILD, daemon_node_id: DAEMON, network_id: NET })).ok).toBe(false);
68
+ });
69
+ test("pending cancellation cannot be resurrected; duplicate pending refuses", async () => {
70
+ const r = await request(); expect(r.ok).toBe(true);
71
+ expect((await request()).error).toBe("node_already_managed");
72
+ expect((await call(handlers().unadopt_node, { node_id: CHILD })).ok).toBe(true);
73
+ expect((await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_pending");
74
+ });
75
+ test("human-only and owner/admin fail closed; admin exception audited", async () => {
76
+ const args = { node_id: CHILD, daemon_node_id: DAEMON, workdir: "/workspace/project" };
77
+ expect((await call(handlers("daemon").request_adopt_node, args)).ok).toBe(false);
78
+ expect((await call(handlers("stranger").request_adopt_node, args)).ok).toBe(false);
79
+ db.run("UPDATE network_members SET role='member' WHERE network_id=?1", [NET]);
80
+ db.run("UPDATE nodes SET owner_user_id=NULL WHERE node_id=?1", [CHILD]);
81
+ expect((await request()).error).toBe("adopt_forbidden");
82
+ db.run("UPDATE network_members SET role='owner' WHERE network_id=?1", [NET]);
83
+ expect((await request()).ok).toBe(true);
84
+ expect(db.get("SELECT action FROM audit_log WHERE action='adopt_by_admin' AND network_id=?1", NET)).not.toBeNull();
85
+ });
86
+ test("viewer owner cannot request; membership loss before ack cannot activate", async () => {
87
+ db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1", [NET]);
88
+ expect((await request()).error).toBe("adopt_forbidden");
89
+ db.run("UPDATE network_members SET role='owner' WHERE network_id=?1", [NET]);
90
+ const r = await request(); expect(r.ok).toBe(true);
91
+ db.run("DELETE FROM network_members WHERE network_id=?1", [NET]);
92
+ expect((await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("adopt_forbidden");
93
+ });
94
+ test("wrong daemon cannot pull/ack; refusal releases reservation; in-flight unadopt is refused", async () => {
95
+ const r = await request(); expect(r.ok).toBe(true);
96
+ db.run("UPDATE node_daemon_bindings SET daemon_node_id='other-daemon' WHERE request_id=?1", [r.request_id]);
97
+ const d = handlers("daemon");
98
+ expect((await call(d.get_adopt_request, { request_id: r.request_id })).error).toBe("request_not_found");
99
+ expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_found");
100
+ db.run("UPDATE node_daemon_bindings SET daemon_node_id=?1 WHERE request_id=?2", [DAEMON, r.request_id]);
101
+ expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "refused", error: "adopt_roots_not_configured" })).status).toBe("refused");
102
+ await adopt();
103
+ expect((await call(handlers().stop_node, { child_node_id: CHILD, network_id: NET })).ok).toBe(true);
104
+ expect((await call(handlers().unadopt_node, { node_id: CHILD })).error).toBe("node_lifecycle_in_flight");
105
+ });
106
+ test("old daemon, hostname mismatch, stale heartbeat and cross-network daemon rejected", async () => {
107
+ db.run("UPDATE nodes SET config_snapshot='null' WHERE node_id=?1", [DAEMON]);
108
+ expect((await request()).error).toBe("daemon_not_adopt_capable");
109
+ db.run("UPDATE nodes SET config_snapshot='{}' WHERE node_id=?1", [DAEMON]);
110
+ expect((await request()).error).toBe("daemon_not_adopt_capable");
111
+ db.run("UPDATE nodes SET config_snapshot=?1,hostname='different' WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), DAEMON]);
112
+ expect((await request()).error).toBe("hostname_mismatch");
113
+ db.run("UPDATE nodes SET hostname='test-host' WHERE node_id=?1", [DAEMON]);
114
+ db.run("UPDATE sessions SET last_seen_at='2000-01-01 00:00:00' WHERE network_id=?1", [NET]);
115
+ expect((await request()).error).toBe("daemon_offline");
116
+ expect((await call(handlers().request_adopt_node, { node_id: CHILD, daemon_node_id: "other_network_daemon", workdir: "/workspace/project" })).error).toBe("daemon_not_found");
117
+ });
118
+ test("network boundaries apply to both human request and daemon pull/ack", async () => {
119
+ const r = await request(); expect(r.ok).toBe(true);
120
+ db.run("UPDATE node_daemon_bindings SET network_id='net_other_adoption_test' WHERE request_id=?1", [r.request_id]);
121
+ try {
122
+ const d = handlers("daemon");
123
+ expect((await call(d.get_adopt_request, { request_id: r.request_id })).error).toBe("request_not_found");
124
+ expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_found");
125
+ } finally { db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2", [NET, r.request_id]); }
126
+ db.run("UPDATE nodes SET network_id='net_other_adoption_test' WHERE node_id=?1", [DAEMON]);
127
+ try { expect((await request()).error).toBe("daemon_not_found"); }
128
+ finally { db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2", [NET, DAEMON]); }
129
+ db.run("UPDATE nodes SET network_id='net_other_adoption_test' WHERE node_id=?1", [CHILD]);
130
+ try { expect((await request()).error).toBe("adopt_forbidden"); }
131
+ finally { db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2", [NET, CHILD]); }
132
+ });
@@ -0,0 +1,130 @@
1
+ import { db, uuidv4 } from "./db.js";
2
+ import { getUserNetworkRole } from "./auth.js";
3
+ import { pushEvent } from "./push.js";
4
+ import { canRestWriteNetwork } from "./network-scope.js";
5
+ import { parseDbTimestampMs } from "./db-timestamp.js";
6
+
7
+ type Node = { node_id: string; alias: string; network_id: string; owner_user_id: string | null; hostname: string | null; config_snapshot: string | null };
8
+ type Binding = { request_id: string; network_id: string; node_id: string; daemon_node_id: string; workdir: string; requested_by: string; status: string; error: string | null };
9
+ type Human = { userId?: string | null; networkId?: string | null; isNode: boolean; canWrite: (networkId: string) => boolean };
10
+ type Daemon = { ok: true; daemonNodeId: string; networkId: string } | { ok: false; error: string };
11
+ const fail = (error: string) => ({ ok: false as const, error });
12
+ const snapshot = (node: Node) => {
13
+ try { const value = JSON.parse(node.config_snapshot || "{}"); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
14
+ catch { return {}; }
15
+ };
16
+
17
+ export function createdDaemon(nodeId: string): string | null {
18
+ if (!nodeId.startsWith("node_")) return null;
19
+ return db.get<{ daemon_node_id: string }>("SELECT daemon_node_id FROM node_create_requests WHERE request_id=?1", `cr_${nodeId.slice(5)}`)?.daemon_node_id ?? null;
20
+ }
21
+ export function activeBinding(nodeId: string): Binding | null {
22
+ return db.get<Binding>("SELECT * FROM node_daemon_bindings WHERE node_id=?1 AND status='active'", nodeId);
23
+ }
24
+ export function resolveManagedDaemon(nodeId: string): string | null {
25
+ return createdDaemon(nodeId) ?? activeBinding(nodeId)?.daemon_node_id ?? null;
26
+ }
27
+ function humanNode(ctx: Human, nodeId: string): Node | null {
28
+ if (ctx.isNode || !ctx.userId) return null;
29
+ const node = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", nodeId);
30
+ if (!node || (ctx.networkId && ctx.networkId !== node.network_id) || !ctx.canWrite(node.network_id)) return null;
31
+ const role = getUserNetworkRole(ctx.userId, node.network_id);
32
+ return node.owner_user_id === ctx.userId || role === "owner" || role === "admin" ? node : null;
33
+ }
34
+ function audit(binding: Binding, action: string, actor: string) {
35
+ db.run(`INSERT INTO audit_log(user_id,action,target_type,target_id,detail,network_id)
36
+ VALUES(?1,?2,'node_daemon_binding',?3,?4,?5)`,
37
+ [actor, action, binding.request_id, JSON.stringify({ node_id: binding.node_id, daemon_node_id: binding.daemon_node_id }), binding.network_id]);
38
+ }
39
+ function daemonReady(daemon: Node | null, node: Node): string | null {
40
+ if (!daemon || daemon.network_id !== node.network_id) return "daemon_not_found";
41
+ const snap = snapshot(daemon);
42
+ if (snap.role !== "host_supervisor" || snap.daemon_capabilities?.adopt_capable !== true) return "daemon_not_adopt_capable";
43
+ if (!node.hostname || daemon.hostname !== node.hostname) return "hostname_mismatch";
44
+ const session = db.get<{ last_seen_at: string }>(`SELECT last_seen_at FROM sessions WHERE alias=?1 AND network_id=?2`, daemon.alias, daemon.network_id);
45
+ const seen = parseDbTimestampMs(session?.last_seen_at ?? "");
46
+ if (!Number.isFinite(seen) || Date.now() - seen > 300_000) return "daemon_offline";
47
+ const token = db.get(`SELECT token_id FROM api_tokens WHERE name=?1 AND network_id=?2 AND revoked_at IS NULL`, `node:${daemon.alias}`, daemon.network_id);
48
+ return token ? null : "daemon_offline";
49
+ }
50
+
51
+ export function requestAdopt(ctx: Human, args: { node_id: string; daemon_node_id: string; workdir: string }) {
52
+ if (ctx.isNode || !ctx.userId) return fail("user_token_required");
53
+ if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
54
+ const node = humanNode(ctx, args.node_id);
55
+ if (!node) return fail("adopt_forbidden");
56
+ if (snapshot(node).role === "host_supervisor") return fail("cannot_adopt_daemon");
57
+ if (createdDaemon(node.node_id)) return fail("node_already_managed");
58
+ if (!args.workdir.trim() || /[\x00-\x1f]/.test(args.workdir)) return fail("invalid_workdir");
59
+ const daemon = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", args.daemon_node_id);
60
+ const refused = daemonReady(daemon, node);
61
+ if (refused) return fail(refused);
62
+ const now = Date.now();
63
+ const binding: Binding = { ...args, request_id: `adopt_${uuidv4()}`, network_id: node.network_id, requested_by: ctx.userId, status: "pending", error: null };
64
+ const inserted = db.transaction(() => {
65
+ const result = db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at)
66
+ VALUES(?1,?2,?3,?4,?5,?6,'pending',?7,?7) ON CONFLICT DO NOTHING`,
67
+ [binding.request_id, binding.network_id, node.node_id, args.daemon_node_id, args.workdir, ctx.userId, now]);
68
+ if (!result.changes) return false;
69
+ audit(binding, node.owner_user_id === ctx.userId ? "adopt_requested" : "adopt_by_admin", ctx.userId!);
70
+ return true;
71
+ });
72
+ if (!inserted) return fail("node_already_managed");
73
+ pushEvent(daemon!.alias, { type: "adopt_node", request_id: binding.request_id }, node.network_id);
74
+ return { ok: true, request_id: binding.request_id, status: "pending" };
75
+ }
76
+
77
+ function daemonBinding(caller: Daemon, requestId: string): Binding | null {
78
+ if (!caller.ok) return null;
79
+ return db.get<Binding>(`SELECT * FROM node_daemon_bindings WHERE request_id=?1 AND daemon_node_id=?2 AND network_id=?3`, requestId, caller.daemonNodeId, caller.networkId);
80
+ }
81
+ export function getAdopt(caller: Daemon, requestId: string) {
82
+ if (!caller.ok) return fail(caller.error);
83
+ const binding = daemonBinding(caller, requestId);
84
+ if (!binding) return fail("request_not_found");
85
+ if (binding.status !== "pending") return fail("request_not_pending");
86
+ const node = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1 AND network_id=?2", binding.node_id, binding.network_id);
87
+ if (!node) return fail("node_not_found");
88
+ return { ok: true, ...binding, alias: node.alias };
89
+ }
90
+ export function ackAdopt(caller: Daemon, args: { request_id: string; status: "adopted" | "refused"; error?: string }) {
91
+ if (!caller.ok) return fail(caller.error);
92
+ if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
93
+ const binding = daemonBinding(caller, args.request_id);
94
+ if (!binding) return fail("request_not_found");
95
+ return db.transaction(() => {
96
+ // Ownership/membership can change while local verification is in flight.
97
+ if (args.status === "adopted") {
98
+ const requester: Human = { userId: binding.requested_by, networkId: binding.network_id, isNode: false,
99
+ canWrite: networkId => canRestWriteNetwork({ userId: binding.requested_by, networkId: null }, networkId, false) };
100
+ const node = humanNode(requester, binding.node_id);
101
+ if (!node || createdDaemon(binding.node_id)) return fail("adopt_forbidden");
102
+ const refusal = daemonReady(db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", binding.daemon_node_id), node);
103
+ if (refusal) return fail(refusal);
104
+ }
105
+ const changed = db.run(`UPDATE node_daemon_bindings SET status=?1,error=?2,updated_at=?3 WHERE request_id=?4 AND status='pending'`,
106
+ [args.status === "adopted" ? "active" : "refused", args.error ?? null, Date.now(), args.request_id]);
107
+ if (!changed.changes) return fail("request_not_pending");
108
+ audit(binding, `adopt_${args.status}`, caller.daemonNodeId);
109
+ return { ok: true, status: args.status === "adopted" ? "active" : "refused" };
110
+ });
111
+ }
112
+ export function unadopt(ctx: Human, nodeId: string) {
113
+ if (ctx.isNode || !ctx.userId) return fail("user_token_required");
114
+ if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
115
+ const node = humanNode(ctx, nodeId);
116
+ if (!node) return fail("adopt_forbidden");
117
+ const binding = db.get<Binding>("SELECT * FROM node_daemon_bindings WHERE node_id=?1 AND status IN ('pending','active')", nodeId);
118
+ if (!binding) return fail("binding_not_found");
119
+ // Do not reassign authority while a daemon may already be executing a lifecycle command.
120
+ for (const table of ["node_stop_requests", "node_start_requests"]) {
121
+ if (db.get(`SELECT request_id FROM ${table} WHERE child_node_id=?1 AND network_id=?2 AND status IN ('pending','delivered')`, nodeId, node.network_id)) return fail("node_lifecycle_in_flight");
122
+ }
123
+ db.transaction(() => {
124
+ db.run("UPDATE node_daemon_bindings SET status='revoked',updated_at=?1 WHERE request_id=?2", [Date.now(), binding.request_id]);
125
+ audit(binding, "unadopted", ctx.userId!);
126
+ });
127
+ const daemon = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1 AND network_id=?2", binding.daemon_node_id, node.network_id);
128
+ if (daemon) pushEvent(daemon.alias, { type: "unadopt_node", node_id: nodeId, request_id: binding.request_id }, node.network_id);
129
+ return { ok: true, status: "revoked" };
130
+ }
@@ -0,0 +1,120 @@
1
+ // #594 step 1 — report_status.health.codex_login (non-secret codex login fingerprint +
2
+ // "shared with N other nodes on this host"). Additive on the #448 health channel.
3
+ //
4
+ // 跑法:cd server && COMMHUB_DB=/tmp/node-health-codex-login.db bun test src/node-health-codex-login.test.ts
5
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { db } from "./db.js";
10
+ import { registerTools } from "./tools.js";
11
+ import { clearNodeHealthStore, readNodeHealth } from "./node-health-store.js";
12
+ import { degradedLayers } from "./node-health-guard.js";
13
+
14
+ const NET = "net_health_codex_login_t";
15
+ const OWNER = "u_hcl_owner";
16
+ const NODE_A = "hcl-node-a";
17
+ const NODE_B = "hcl-node-b";
18
+ const NODE_A_ID = "node_hcl_a";
19
+ const NODE_B_ID = "node_hcl_b";
20
+ const T_A = "tok_hcl_node_a";
21
+ const T_B = "tok_hcl_node_b";
22
+
23
+ function cleanup() {
24
+ for (const t of ["sessions", "nodes", "api_tokens", "network_members", "networks"]) {
25
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
26
+ }
27
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
28
+ clearNodeHealthStore();
29
+ }
30
+ function seed() {
31
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [OWNER, OWNER]);
32
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
33
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
34
+ for (const [id, alias, tok] of [[NODE_A_ID, NODE_A, T_A], [NODE_B_ID, NODE_B, T_B]]) {
35
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`, [id, alias, alias, NET, OWNER]);
36
+ db.run(`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`, [tok, OWNER, NET, `node:${alias}`, `hash_${tok}`, id]);
37
+ }
38
+ }
39
+
40
+ async function connectAs(alias: string, tokenId: string) {
41
+ const server = new McpServer({ name: "health-test", version: "1" });
42
+ registerTools(server, undefined, NET, OWNER, alias, true, tokenId);
43
+ const client = new Client({ name: "health-client", version: "1" });
44
+ const [ct, st] = InMemoryTransport.createLinkedPair();
45
+ await server.connect(st);
46
+ await client.connect(ct);
47
+ const call = async (args: Record<string, unknown>) => {
48
+ const r: any = await client.callTool({ name: "report_status", arguments: args });
49
+ return { isError: r.isError === true, body: JSON.parse(r.content?.[0]?.text ?? "null") };
50
+ };
51
+ return { call, close: async () => { await client.close(); await server.close(); } };
52
+ }
53
+
54
+ const LOGIN = { fingerprint: "1a2b3c4d", shared_with: 2, shared_home_with: 1, codex_home: "/srv/ws/.anet/nodes/a/codex-home" };
55
+
56
+ beforeEach(() => { cleanup(); seed(); });
57
+ afterAll(cleanup);
58
+
59
+ describe("#594 report_status.health.codex_login passthrough", () => {
60
+ test("well-formed codex_login is kept next to the #448 layers and readable", async () => {
61
+ const a = await connectAs(NODE_A, T_A);
62
+ try {
63
+ const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { bridge: "ok", model_auth: "ok", codex_login: LOGIN } });
64
+ expect(r.isError).toBe(false);
65
+ } finally { await a.close(); }
66
+ expect(readNodeHealth(NET, NODE_A)?.health.codex_login).toEqual(LOGIN);
67
+ });
68
+
69
+ test("codex_login alone (codex-sdk runtime, no #448 monitor) is accepted", async () => {
70
+ const a = await connectAs(NODE_A, T_A);
71
+ try {
72
+ await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { codex_login: { fingerprint: "deadbeef", shared_with: 0 } } });
73
+ } finally { await a.close(); }
74
+ expect(readNodeHealth(NET, NODE_A)?.health.codex_login).toEqual({ fingerprint: "deadbeef", shared_with: 0 });
75
+ });
76
+
77
+ test("🔴 a shared login is NOT a degraded layer — dispatch must not refuse because of it", () => {
78
+ expect(degradedLayers({ bridge: "ok", model_auth: "ok", codex_login: LOGIN } as any)).toEqual([]);
79
+ });
80
+
81
+ test("🔴 anything that is not an 8-hex fingerprint is dropped (a token pasted there never gets stored), report still accepted", async () => {
82
+ const a = await connectAs(NODE_A, T_A);
83
+ const bads = [
84
+ { fingerprint: "FAKE-REFRESH-TOKEN-should-never-be-stored", shared_with: 1 },
85
+ { fingerprint: "a".repeat(64), shared_with: 1 },
86
+ { fingerprint: "1A2B3C4D", shared_with: 1 },
87
+ { fingerprint: "1a2b3c4d", shared_with: -1 },
88
+ { fingerprint: "1a2b3c4d", shared_with: 1.5 },
89
+ { fingerprint: "1a2b3c4d" },
90
+ "1a2b3c4d",
91
+ ];
92
+ try {
93
+ for (const bad of bads) {
94
+ const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { bridge: "ok", model_auth: "ok", codex_login: bad } });
95
+ expect(r.isError).toBe(false);
96
+ const h = readNodeHealth(NET, NODE_A)?.health;
97
+ expect(h?.model_auth).toBe("ok");
98
+ expect(h?.codex_login).toBeUndefined();
99
+ expect(JSON.stringify(h)).not.toContain("FAKE-REFRESH-TOKEN");
100
+ }
101
+ } finally { await a.close(); }
102
+ expect(db.get<{ s: string }>("SELECT status AS s FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.s).toBe("idle");
103
+ });
104
+
105
+ test("bad optional sub-fields degrade alone: control characters in codex_home are dropped, the rest kept", async () => {
106
+ const a = await connectAs(NODE_A, T_A);
107
+ try {
108
+ await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { codex_login: { fingerprint: "1a2b3c4d", shared_with: 1, shared_home_with: "x", codex_home: "/x\n; rm -rf /" } } });
109
+ } finally { await a.close(); }
110
+ expect(readNodeHealth(NET, NODE_A)?.health.codex_login).toEqual({ fingerprint: "1a2b3c4d", shared_with: 1 });
111
+ });
112
+
113
+ test("another node's token cannot set this node's codex_login", async () => {
114
+ const b = await connectAs(NODE_B, T_B);
115
+ try {
116
+ await b.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { codex_login: LOGIN } });
117
+ } finally { await b.close(); }
118
+ expect(readNodeHealth(NET, NODE_A)).toBeNull();
119
+ });
120
+ });
@@ -27,9 +27,35 @@ export const nodeHealthSchema = z.object({
27
27
  reason: z.string().max(40),
28
28
  }).optional().catch(undefined),
29
29
  model_auth: z.enum(["ok", "revoked", "expired", "unknown"]).optional().catch(undefined),
30
+ // #594 —— codex 节点的登录指纹 + 本机另有几台节点持有同一份登录(见下方 codexLoginSchema)。
31
+ // 🔴 线上 schema 故意是 unknown:report_status 的 JSON schema 每个节点每次 tools/list 都要下发,
32
+ // #476 给它定了字节上限(tool-audience-http.test.ts)。严格校验在 recordNodeHealth 里做,效果相同:
33
+ // 不合规 → 这一格当没报,整份 report 照收。
34
+ codex_login: z.unknown().optional(),
30
35
  }).optional().catch(undefined);
31
36
 
32
- export type NodeHealth = NonNullable<z.infer<typeof nodeHealthSchema>>;
37
+ // #594 —— health.codex_login 的严格形状。纯展示/提示,**不是** degraded 层:共用登录今天还能跑,
38
+ // 只是迟早互相顶掉(#1918);派发不因它拒绝。fingerprint 必须是 8 位小写 hex(sha256(refresh_token)
39
+ // 截断,不是凭据)—— 节点误把 token 塞进来也存不下;计数是小的非负整数;codex_home 是节点自己的路径
40
+ // (给客户端拼 `CODEX_HOME=<codex_home> codex login --device-auth`),拒控制字符。
41
+ // 可选格各自降级;必需格(fingerprint / shared_with)不合规 → 整格丢弃。
42
+ export const codexLoginSchema = z.object({
43
+ fingerprint: z.string().regex(/^[0-9a-f]{8}$/),
44
+ shared_with: z.number().int().min(0).max(100_000),
45
+ shared_home_with: z.number().int().min(0).max(100_000).optional().catch(undefined),
46
+ codex_home: z.string().max(1024).regex(/^[^\u0000-\u001f\u007f]*$/).optional().catch(undefined),
47
+ });
48
+ export type CodexLoginHealth = z.infer<typeof codexLoginSchema>;
49
+
50
+ export type NodeHealth = Omit<NonNullable<z.infer<typeof nodeHealthSchema>>, "codex_login"> & { codex_login?: CodexLoginHealth };
51
+
52
+ /** 线上收到的 health → 存的那份:codex_login 按严格形状过一遍,不合规就去掉这一格。 */
53
+ export function normalizeNodeHealth(raw: NonNullable<z.infer<typeof nodeHealthSchema>>): NodeHealth {
54
+ const { codex_login: rawLogin, ...rest } = raw;
55
+ if (rawLogin === undefined) return rest;
56
+ const parsed = codexLoginSchema.safeParse(rawLogin);
57
+ return parsed.success ? { ...rest, codex_login: parsed.data } : rest;
58
+ }
33
59
 
34
60
  const store = new Map<string, { health: NodeHealth; at: number }>();
35
61
  const keyOf = (networkId: string | null | undefined, alias: string) => `${networkId ?? "default"}\0${alias}`;
@@ -48,9 +74,12 @@ export function nodeHealthNextExpiryAt(now = Date.now()): number {
48
74
  return next;
49
75
  }
50
76
 
51
- export function recordNodeHealth(networkId: string | null | undefined, alias: string, health: NodeHealth, now = Date.now()): void {
77
+ export function recordNodeHealth(
78
+ networkId: string | null | undefined, alias: string,
79
+ health: NodeHealth | NonNullable<z.infer<typeof nodeHealthSchema>>, now = Date.now(),
80
+ ): void {
52
81
  version++;
53
- store.set(keyOf(networkId, alias), { health, at: now });
82
+ store.set(keyOf(networkId, alias), { health: normalizeNodeHealth(health), at: now });
54
83
  // 有界:按 TTL 顺手清掉过期的,别让改过名/删掉的节点在内存里攒着。
55
84
  if (store.size > 256) {
56
85
  for (const [k, v] of store) if (now - v.at > NODE_HEALTH_TTL_MS) store.delete(k);
@@ -168,7 +168,9 @@ describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同
168
168
  const r = await call("stop_node", { node_id: DAEMON_CHILD });
169
169
  expect(r.ok).toBe(false);
170
170
  expect(r.error).toBe("daemon_not_resolvable");
171
- expect(r.message).toContain("pass daemon_node_id explicitly");
171
+ // Board #625: an explicit daemon id no longer substitutes for authority.
172
+ expect(r.message).not.toContain("pass daemon_node_id explicitly");
173
+ expect(r.message).toContain("adopt this node");
172
174
  });
173
175
 
174
176
  test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
@@ -24,8 +24,10 @@ export function childNodeIdForCreateRequest(requestId: unknown): string | null {
24
24
  /** 由创建记录建出「child node_id → daemon node_id」的映射。 */
25
25
  export function buildControllableMap(
26
26
  createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
27
+ bindings: Array<{ node_id: string; daemon_node_id: string }> = [],
27
28
  ): Map<string, string> {
28
29
  const m = new Map<string, string>();
30
+ for (const binding of bindings) m.set(binding.node_id, binding.daemon_node_id);
29
31
  for (const cr of createRequests) {
30
32
  const child = childNodeIdForCreateRequest(cr?.request_id);
31
33
  if (!child) continue;
@@ -220,6 +220,8 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
220
220
  get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
221
221
  get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
222
222
  list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
223
+ request_adopt_node: "human_only", unadopt_node: "human_only",
224
+ get_adopt_request: "always", ack_adopt_request: "always",
223
225
  send_desktop_message: "always", // 发给人的私信,不是派活
224
226
  // 读
225
227
  get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
@@ -0,0 +1,83 @@
1
+ // #622 —— daemon 逐 runtime 自检结果(`config_snapshot.daemon_capabilities.runtime_readiness`)。
2
+ //
3
+ // 上报侧:agent-node/src/runtime/runtime-readiness.ts。这里两件事:
4
+ // 1. report_status 的 zod 形状 —— **收得宽**:任何一格坏都只丢这一格(或整个
5
+ // runtime_readiness),绝不拒掉整份 report(同 create_capability_observed_ms_ago
6
+ // 的教训:zod 对象里一个已知字段验证失败 = 整份被拒 = 节点在 hub 上失联)。
7
+ // 2. /api/host-supervisors 读取时再消毒一次(库里可能存着任何旧形状)。
8
+ //
9
+ // 向后兼容:旧 daemon 不报 ⇒ 响应里**没有**这个键(不是 {}、不是 null)。
10
+
11
+ import { z } from "zod/v4";
12
+
13
+ export const READINESS_STATES = ["ready", "missing_cli", "not_logged_in", "no_network", "unknown"] as const;
14
+ export type ReadinessState = typeof READINESS_STATES[number];
15
+
16
+ const MAX_RUNTIMES = 16;
17
+ const MAX_REASON = 600;
18
+
19
+ const MAX_JSON_BYTES = 16 * 1024;
20
+
21
+ /** report_status 里 `daemon_capabilities.runtime_readiness` 的 schema。永不拒整份 report。
22
+ *
23
+ * 🔴 故意只验外形(对象、键数、总字节),逐格消毒放在读取侧 `sanitizeRuntimeReadiness`:
24
+ * 这份 schema 会进每个节点的 `tools/list`(#476 按角色的字节天花板),逐字段声明
25
+ * 一遍要多花 ~500 B;而读取侧本来就必须再消毒一次(库里可能存着任何旧形状)。
26
+ * 总字节上限挡住一个异常 daemon 往库里塞大对象。 */
27
+ export const runtimeReadinessSchema = z
28
+ .record(z.string().max(64), z.record(z.string(), z.unknown()))
29
+ .refine((r) => Object.keys(r).length <= MAX_RUNTIMES && JSON.stringify(r).length <= MAX_JSON_BYTES)
30
+ .optional()
31
+ .catch(undefined);
32
+
33
+ export interface RuntimeReadinessOut {
34
+ ok: boolean;
35
+ state: ReadinessState;
36
+ reason: string;
37
+ version?: string;
38
+ checked_at?: string;
39
+ cli?: string;
40
+ auth?: string;
41
+ network?: string;
42
+ shared_login_count?: number;
43
+ }
44
+
45
+ const ENUMS: Record<string, readonly string[]> = {
46
+ cli: ["found", "missing", "bundled", "unknown"],
47
+ auth: ["present", "absent", "not_required", "unknown"],
48
+ network: ["reachable", "unreachable", "skipped"],
49
+ };
50
+
51
+ /** 读取侧消毒。返回 undefined 表示「daemon 没报」—— 调用方据此**不输出**这个键。 */
52
+ export function sanitizeRuntimeReadiness(raw: unknown): Record<string, RuntimeReadinessOut> | undefined {
53
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
54
+ const out: Record<string, RuntimeReadinessOut> = {};
55
+ let n = 0;
56
+ for (const [rt, v] of Object.entries(raw as Record<string, unknown>)) {
57
+ if (n >= MAX_RUNTIMES) break;
58
+ if (typeof rt !== "string" || rt.length === 0 || rt.length > 64) continue;
59
+ if (!v || typeof v !== "object" || Array.isArray(v)) continue;
60
+ const e = v as Record<string, unknown>;
61
+ const state: ReadinessState = (READINESS_STATES as readonly string[]).includes(e.state as string)
62
+ ? e.state as ReadinessState
63
+ : "unknown";
64
+ const entry: RuntimeReadinessOut = {
65
+ // ok 只在 state=ready 时可能为真:不让一个自相矛盾的上报朝「没问题」方向错。
66
+ ok: e.ok === true && state === "ready",
67
+ state,
68
+ reason: typeof e.reason === "string" ? e.reason.slice(0, MAX_REASON) : "",
69
+ };
70
+ if (typeof e.version === "string" && e.version.length > 0) entry.version = e.version.slice(0, 64);
71
+ if (typeof e.checked_at === "string" && e.checked_at.length > 0) entry.checked_at = e.checked_at.slice(0, 40);
72
+ for (const k of ["cli", "auth", "network"] as const) {
73
+ if (typeof e[k] === "string" && ENUMS[k].includes(e[k] as string)) entry[k] = e[k] as string;
74
+ }
75
+ if (typeof e.shared_login_count === "number" && Number.isInteger(e.shared_login_count)
76
+ && e.shared_login_count >= 0 && e.shared_login_count <= 100_000) {
77
+ entry.shared_login_count = e.shared_login_count;
78
+ }
79
+ out[rt] = entry;
80
+ n++;
81
+ }
82
+ return Object.keys(out).length > 0 ? out : undefined;
83
+ }
package/src/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildServeErrorResponse } from "./serve-error.js";
2
+ import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
2
3
  import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
3
4
  import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
4
5
  import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
@@ -4474,6 +4475,7 @@ return Bun.serve({
4474
4475
  // 🔴 不是 clamp 成边界值:clamp 会把一个坏值变成一个**看起来正常的年龄**,
4475
4476
  // 而这一格存在的全部意义就是让人分辨新鲜和陈旧。
4476
4477
  let snapObservedMsAgo: number | undefined;
4478
+ let snapReadiness: ReturnType<typeof sanitizeRuntimeReadiness>;
4477
4479
  if (r.config_snapshot) {
4478
4480
  try {
4479
4481
  const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
@@ -4491,13 +4493,15 @@ return Bun.serve({
4491
4493
  && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000) {
4492
4494
  snapObservedMsAgo = rawAge;
4493
4495
  }
4496
+ // #622 —— 逐 runtime 就绪度;旧 daemon 不报 ⇒ undefined ⇒ 响应里没有这个键。
4497
+ snapReadiness = sanitizeRuntimeReadiness(caps?.runtime_readiness);
4494
4498
  } catch { /* malformed */ }
4495
4499
  }
4496
4500
  return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
4497
- observedMsAgo: snapObservedMsAgo };
4501
+ observedMsAgo: snapObservedMsAgo, readiness: snapReadiness };
4498
4502
  })
4499
4503
  .filter(({ role }) => role === "host_supervisor")
4500
- .map(({ row: r, canCreate, blockedReason, observedMsAgo }) => {
4504
+ .map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness }) => {
4501
4505
  let online = false;
4502
4506
  let lastSeenAt: string | null = null;
4503
4507
  if (r.session_last_seen) {
@@ -4562,6 +4566,10 @@ return Bun.serve({
4562
4566
  out.create_capability_observed_ms_ago = observedMsAgo;
4563
4567
  }
4564
4568
  }
4569
+ // #622 —— 逐 runtime 实测就绪度(CLI / 登录存在性 / 网络 / codex 共享登录数)。
4570
+ // 只在 daemon 真的报了时出现;**不影响** can_create_nodes(旧 app 依赖它的语义)。
4571
+ // 内容不含路径与凭据(上报侧只发存在性 + 版本号 + 通用修法),所以不按角色脱敏。
4572
+ if (readiness) out.runtime_readiness = readiness;
4565
4573
  // app「新建节点」确认页的默认工作目录根。出现即表示该 daemon 认 node_spec.workdir;
4566
4574
  // 缺席(老 daemon / 老 hub 的 zod 剥掉了它)时 app 藏起那一行、不发 workdir。
4567
4575
  // 🔴 它是那台机器的家目录路径 —— 与 ip_internal 同级,只给 admin/owner。
@@ -4628,7 +4636,10 @@ return Bun.serve({
4628
4636
  const crParams: any[] = [];
4629
4637
  let crSql = "SELECT request_id, daemon_node_id FROM node_create_requests WHERE 1=1";
4630
4638
  crSql = addNetworkScope(crSql, crParams, restScope);
4631
- return buildControllableMap(db.all<Record<string, any>>(crSql, ...crParams));
4639
+ const bindingParams: any[] = [];
4640
+ const bindingSql = addNetworkScope("SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE status='active'", bindingParams, restScope);
4641
+ return buildControllableMap(db.all<Record<string, any>>(crSql, ...crParams),
4642
+ db.all<{ node_id: string; daemon_node_id: string }>(bindingSql, ...bindingParams));
4632
4643
  } catch {
4633
4644
  // 表不存在/查询失败 ⇒ 全部按不可控处理,不让这一格拖垮整个列表
4634
4645
  return new Map<string, string>();
@@ -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
- // authority: CHILD_A 由 DAEMON_A 创建(resolveDaemonForChild 读 node_create_requests)
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
- // 回归钉:daemon 建的 child(有 create 记录)照常可停
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
- const CEILINGS = { node: 59_000, user: 56_500, all: 72_500 } as const;
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);
@@ -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
- // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
4659
- // The dashboard (and most callers of stop_node / delete_node) shouldn't
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
- if (!child_node_id.startsWith("node_")) return null;
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 node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
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
- // #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
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
- // #571 — 手工起的节点(id 不以 `node_` 开头)**没有**任何 daemon 链接。此前
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.