@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 +1 -1
- package/src/api-host-supervisors-runtime-readiness.test.ts +182 -0
- 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 +19 -2
- 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 +53 -60
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
-
|
|
243
|
-
|
|
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 (!
|
|
133
|
-
throw new ValidationError("flag_value_invalid", { field: k, reason:
|
|
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
|
-
|
|
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
|
+
});
|
package/src/node-health-store.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/src/node-permissions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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.
|