@sleep2agi/commhub-server 0.9.0-preview.105 → 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 +60 -1
- package/src/create-node-validate.ts +46 -3
- package/src/db.ts +20 -0
- package/src/mcp-context-cost-http.test.ts +2 -1
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-store.ts +32 -3
- package/src/node-id-alias.test.ts +3 -1
- package/src/node-lifecycle-controllable.ts +2 -0
- package/src/node-permissions.ts +2 -0
- package/src/runtime-readiness.ts +83 -0
- package/src/server.ts +14 -3
- package/src/stop-delete-node.test.ts +8 -12
- package/src/tool-audience-http.test.ts +3 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools.ts +55 -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
|
}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
ValidationError,
|
|
4
4
|
validateName, validateRuntime, validateModel, validateFlagValue, RUNTIMES,
|
|
5
5
|
validateEnvRefs, validateChannelsP1, serializeEnvLocal, buildAnetArgs,
|
|
6
|
-
MAX_ENV_KEYS_PER_NODE,
|
|
6
|
+
MAX_ENV_KEYS_PER_NODE, validateFlagsForRuntime, COPRESENCE_FLAG_RUNTIMES,
|
|
7
7
|
} from "./create-node-validate.js";
|
|
8
8
|
|
|
9
9
|
const okSecret = (k: string, _net: string, key: string) => k === key ? `value-of-${k}` : undefined;
|
|
@@ -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)", () => {
|
|
@@ -273,3 +297,38 @@ describe("#1298 RUNTIMES —— 七个都放行,且与 CLI 侧一致", () => {
|
|
|
273
297
|
expect(() => validateRuntime("")).toThrow();
|
|
274
298
|
});
|
|
275
299
|
});
|
|
300
|
+
|
|
301
|
+
// #584 —— app「Codex(TUI 共存)」经 daemon 建出来的是无头节点:create_node 没有任何字段能表达「要共存」。
|
|
302
|
+
describe("#584 flags.copresence —— 建节点时的共存开关", () => {
|
|
303
|
+
test("is a known flag key and must be boolean", () => {
|
|
304
|
+
expect(() => validateFlagValue("copresence", true)).not.toThrow();
|
|
305
|
+
expect(() => validateFlagValue("copresence", false)).not.toThrow();
|
|
306
|
+
for (const bad of ["true", 1, null, {}]) {
|
|
307
|
+
try { validateFlagValue("copresence", bad); throw new Error("did not throw"); }
|
|
308
|
+
catch (e) { expect((e as ValidationError).code).toBe("flag_value_invalid"); }
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
test("only codex-app-server accepts it — elsewhere it would be swallowed silently", () => {
|
|
312
|
+
expect(() => validateFlagsForRuntime("codex-app-server", { copresence: true })).not.toThrow();
|
|
313
|
+
expect(() => validateFlagsForRuntime("claude-agent-sdk", { permissionMode: "default" })).not.toThrow();
|
|
314
|
+
expect(() => validateFlagsForRuntime("claude-agent-sdk", undefined)).not.toThrow();
|
|
315
|
+
for (const rt of ["claude-agent-sdk", "claude-code-cli", "codex-sdk", "grok-build-acp", "grok-build-cli", "opencode-cli"]) {
|
|
316
|
+
try { validateFlagsForRuntime(rt, { copresence: true }); throw new Error(`did not throw for ${rt}`); }
|
|
317
|
+
catch (e) {
|
|
318
|
+
expect((e as ValidationError).code).toBe("flag_not_applicable_to_runtime");
|
|
319
|
+
expect((e as ValidationError).detail).toMatchObject({ field: "copresence", runtime: rt, applicable: [...COPRESENCE_FLAG_RUNTIMES] });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
test("buildAnetArgs emits the CLI's boolean `--copresence` (no value), and nothing for false", () => {
|
|
324
|
+
expect(buildAnetArgs({ name: "cx", runtime: "codex-app-server", flags: { copresence: true } }))
|
|
325
|
+
.toEqual(["node", "create", "cx", "--runtime", "codex-app-server", "--copresence"]);
|
|
326
|
+
expect(buildAnetArgs({ name: "cx", runtime: "codex-app-server", flags: { copresence: false } }))
|
|
327
|
+
.toEqual(["node", "create", "cx", "--runtime", "codex-app-server"]);
|
|
328
|
+
expect(() => buildAnetArgs({ name: "cx", runtime: "codex-sdk", flags: { copresence: true } })).toThrow(ValidationError);
|
|
329
|
+
});
|
|
330
|
+
test("the old request shape (no flag) is unchanged", () => {
|
|
331
|
+
expect(buildAnetArgs({ name: "cx", runtime: "codex-app-server", flags: { permissionMode: "default" } }))
|
|
332
|
+
.toEqual(["node", "create", "cx", "--runtime", "codex-app-server", "--permission-mode", "default"]);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -46,7 +46,16 @@ export const RUNTIMES = [
|
|
|
46
46
|
] as const;
|
|
47
47
|
export type Runtime = typeof RUNTIMES[number];
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// #584 —— `copresence` 是**建节点时**的开关,不是 agent 的运行参数:为 true 时 daemon 把它写成子节点
|
|
50
|
+
// config 的 `codexCopresence: true`,之后一条 `anet node start` 就起共享 TUI(app 向导的「Codex(TUI 共存)」)。
|
|
51
|
+
// 🔴 故意放在 flags 里而不是 node_spec 顶层:老 hub 的 zod 会静默丢弃未知顶层字段、老 daemon 也静默忽略
|
|
52
|
+
// —— 那正是 #584 的形状(用户选了共存,拿到无头节点,没有任何报错)。flags 的未知键在老 hub(这份
|
|
53
|
+
// FLAG_KEYS)和老 daemon(buildAnetArgsDaemon)都**拒绝**,新 app 撞到老组件得到的是一条报错。
|
|
54
|
+
export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence"] as const;
|
|
55
|
+
|
|
56
|
+
/** #584 —— 只有这些 runtime 认 `flags.copresence`。目前只有 codex-app-server 的共存靠 config 字段
|
|
57
|
+
* (`codexCopresence`)决定;grok-build-cli / opencode-cli 各有自己的字段,接进来之前一律拒,不静默吞。 */
|
|
58
|
+
export const COPRESENCE_FLAG_RUNTIMES = ["codex-app-server"] as const;
|
|
50
59
|
export type FlagKey = typeof FLAG_KEYS[number];
|
|
51
60
|
|
|
52
61
|
export const PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"] as const;
|
|
@@ -99,6 +108,23 @@ export function validateModel(s: unknown): asserts s is string | undefined | nul
|
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
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
|
+
|
|
102
128
|
export function validateFlagValue(k: string, v: unknown): void {
|
|
103
129
|
switch (k) {
|
|
104
130
|
case "permissionMode":
|
|
@@ -120,15 +146,29 @@ export function validateFlagValue(k: string, v: unknown): void {
|
|
|
120
146
|
}
|
|
121
147
|
return;
|
|
122
148
|
case "timeout":
|
|
123
|
-
if (!
|
|
124
|
-
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 });
|
|
125
151
|
}
|
|
126
152
|
return;
|
|
153
|
+
case "copresence":
|
|
154
|
+
if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
|
|
155
|
+
return;
|
|
127
156
|
default:
|
|
128
157
|
throw new ValidationError("flag_key_unknown", { field: k });
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
/** #584 —— 逐键校验之外的跨字段一条:`copresence` 只对 COPRESENCE_FLAG_RUNTIMES 有意义。
|
|
162
|
+
* 在别的 runtime 上收下它而什么都不做,就是又一个「选了共存、拿到无头」。 */
|
|
163
|
+
export function validateFlagsForRuntime(runtime: string, flags: Record<string, unknown> | undefined | null): void {
|
|
164
|
+
if (!flags || !Object.prototype.hasOwnProperty.call(flags, "copresence")) return;
|
|
165
|
+
if (!(COPRESENCE_FLAG_RUNTIMES as readonly string[]).includes(runtime)) {
|
|
166
|
+
throw new ValidationError("flag_not_applicable_to_runtime", {
|
|
167
|
+
field: "copresence", runtime, applicable: [...COPRESENCE_FLAG_RUNTIMES],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
132
172
|
// §4.4.7 — env_refs 7-step gate. networkSecretsGet is injected so
|
|
133
173
|
// callers can supply either a real DB lookup (hub) or a fixture (tests).
|
|
134
174
|
// The 6th step also reports the resolved value size; we return the
|
|
@@ -281,8 +321,11 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
|
281
321
|
throw new ValidationError("flag_key_unknown", { field: k });
|
|
282
322
|
}
|
|
283
323
|
validateFlagValue(k, v);
|
|
324
|
+
// `anet node create --copresence` 是布尔开关,不吃值。
|
|
325
|
+
if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
|
|
284
326
|
args.push(`--${kebab(k)}`, String(v));
|
|
285
327
|
}
|
|
328
|
+
validateFlagsForRuntime(spec.runtime, spec.flags);
|
|
286
329
|
}
|
|
287
330
|
return args;
|
|
288
331
|
}
|
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
|
+
});
|