@sleep2agi/commhub-server 0.9.0-preview.108 → 0.9.0-preview.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.108",
3
+ "version": "0.9.0-preview.110",
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,118 @@
1
+ // Board #652 — create_node takes Chinese / Unicode node names (the alias); illegal
2
+ // characters are refused with a reason before any row is written; the in-flight
3
+ // uniqueness check still holds. Driven through the REAL /mcp entry (zod schema +
4
+ // handler) on a private port with a temp DB, then get_create_request as the daemon.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+
11
+ const DIR = mkdtempSync(join(tmpdir(), "anet-652-"));
12
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
13
+ process.env.HOST = "127.0.0.1";
14
+
15
+ const { db } = await import("./db.js");
16
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
17
+
18
+ const PW = "Unicode652Passw0rd!x";
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ let OWNER = "";
22
+ let OWNER_ID = "";
23
+ let NET = "";
24
+ let DAEMON_TOK = "";
25
+ const DAEMON_ID = "node_t652_daemon";
26
+
27
+ async function rpc(token: string, method: string, params?: unknown): Promise<any> {
28
+ const res = await fetch(`${BASE}/mcp`, {
29
+ method: "POST",
30
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
31
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) }),
32
+ });
33
+ const raw = await res.text();
34
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
35
+ return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
36
+ }
37
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
38
+ const out = await rpc(token, "tools/call", { name, arguments: args });
39
+ const text = out?.result?.content?.[0]?.text;
40
+ if (typeof text !== "string") return { _raw: out };
41
+ try { return JSON.parse(text); } catch { return { _text: text, isError: out.result.isError }; }
42
+ }
43
+ const create = (name: string) => mcp(OWNER, "create_node", {
44
+ daemon_node_id: DAEMON_ID, network_id: NET,
45
+ node_spec: { name, runtime: "claude-agent-sdk", model: "claude-sonnet-4-5", flags: {} },
46
+ });
47
+ const rows = (name: string) =>
48
+ db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1 AND child_name = ?2", DAEMON_ID, name)?.n ?? -1;
49
+
50
+ beforeAll(async () => {
51
+ const a = register(`t652_owner_${Date.now()}`, PW, undefined, "Owner");
52
+ if (!a.ok) throw new Error("register failed");
53
+ OWNER = a.token!; OWNER_ID = a.user!.user_id; NET = a.network_id!;
54
+ const mod: any = await import("./server.js");
55
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
56
+ BASE = `http://127.0.0.1:${hub.port}`;
57
+ const t = createNetworkTokenForNode(OWNER_ID, NET, "t652-daemon", DAEMON_ID);
58
+ if (!t.ok || !t.token) throw new Error("daemon token failed: " + t.error);
59
+ DAEMON_TOK = t.token;
60
+ db.run(
61
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, config_revision, config_snapshot)
62
+ VALUES (?1, 't652-daemon', 't652-daemon', 'claude-agent-sdk', 'm', '/tmp/cfg.json', '[]', 'h', 'h', ?2, 0, ?3)`,
63
+ [DAEMON_ID, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] } })],
64
+ );
65
+ });
66
+ afterAll(() => {
67
+ try { hub?.stop?.(true); } catch {}
68
+ rmSync(DIR, { recursive: true, force: true });
69
+ });
70
+
71
+ describe("#652 create_node via /mcp — Unicode names", () => {
72
+ test("「测试」 is accepted; the daemon gets the same alias", async () => {
73
+ const r = await create("测试");
74
+ expect(r.ok).toBe(true);
75
+ expect(String(r.request_id)).toMatch(/^cr_/);
76
+ expect(rows("测试")).toBe(1);
77
+ const g = await mcp(DAEMON_TOK, "get_create_request", { request_id: r.request_id });
78
+ expect(g.ok).toBe(true);
79
+ expect(g.node_spec.name).toBe("测试");
80
+ });
81
+
82
+ test("upper case / digit-first ASCII and 64 CJK characters pass the zod bound and the rule", async () => {
83
+ for (const name of ["研发助手A", "Demo2", "9lives", "测".repeat(64)]) {
84
+ const r = await create(name);
85
+ expect({ name, ok: r.ok, error: r.error }).toEqual({ name, ok: true, error: undefined });
86
+ }
87
+ });
88
+
89
+ test("surrounding whitespace is trimmed before it is stored", async () => {
90
+ const r = await create(" 前后空格 ");
91
+ expect(r.ok).toBe(true);
92
+ expect(rows("前后空格")).toBe(1);
93
+ expect(rows(" 前后空格 ")).toBe(0);
94
+ });
95
+
96
+ test("illegal characters are refused with reason + char + message, and no row is written", async () => {
97
+ for (const [name, reason] of [["a/b", "forbidden_char"], ["a\\b", "forbidden_char"], ["a:b", "forbidden_char"],
98
+ [".hidden", "forbidden_char"], ["..", "forbidden_char"], ["a b", "forbidden_char"], ["a\u0007b", "forbidden_char"],
99
+ ["-x", "leading_dash"], ["测".repeat(65), "too_long"]] as const) {
100
+ const r = await create(name);
101
+ expect({ name, error: r.error, reason: r.reason }).toEqual({ name, error: "node_name_invalid", reason });
102
+ expect(String(r.message ?? "").length).toBeGreaterThan(0);
103
+ expect(rows(name)).toBe(0);
104
+ }
105
+ const slash = await create("a/b");
106
+ expect(slash.char).toBe("/");
107
+ expect(slash.message).toContain("/");
108
+ });
109
+
110
+ test("uniqueness: a second in-flight request for the same Chinese name is a conflict", async () => {
111
+ const first = await create("重复名字");
112
+ expect(first.ok).toBe(true);
113
+ const second = await create("重复名字");
114
+ expect(second.ok).toBe(false);
115
+ expect(second.error).toBe("node_name_conflict");
116
+ expect(rows("重复名字")).toBe(1);
117
+ });
118
+ });
@@ -9,17 +9,31 @@ import {
9
9
  const okSecret = (k: string, _net: string, key: string) => k === key ? `value-of-${k}` : undefined;
10
10
  const allow = (k: string) => new Set<string>([k, "ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
11
11
 
12
- describe("validateName (§4.2.2)", () => {
13
- test("good names", () => {
14
- for (const n of ["a", "demo-bot", "node_1", "z2", "abc-def-ghi"]) {
12
+ describe("validateName (§4.2.2, #652 Unicode names)", () => {
13
+ test("good names — incl. Chinese, upper case, digits first", () => {
14
+ for (const n of ["a", "demo-bot", "node_1", "z2", "abc-def-ghi", "测试", "研发助手A", "Demo", "1demo", "测".repeat(64)]) {
15
15
  expect(() => validateName(n)).not.toThrow();
16
16
  }
17
17
  });
18
- test("rejects shell-injection-like names", () => {
19
- for (const n of [";rm -rf /", "demo bot", "Demo", "1demo", "-demo", "demo;rm", "demo$INJECT", "x".repeat(65)]) {
18
+ test("returns the trimmed name", () => {
19
+ expect(validateName(" 测试 ")).toBe("测试");
20
+ });
21
+ test("rejects shell-injection-like and path-like names with a reason", () => {
22
+ for (const n of [";rm -rf /", "demo bot", "-demo", "demo;rm", "demo$INJECT", "x".repeat(65), "测".repeat(65),
23
+ "a/b", "a\\b", "a:b", ".hidden", "..", "a\u0000b", "a`id`", "", " "]) {
20
24
  expect(() => validateName(n)).toThrow(ValidationError);
21
25
  }
22
26
  });
27
+ test("error carries reason + offending char + a readable message", () => {
28
+ try { validateName("a/b"); throw new Error("no throw"); }
29
+ catch (e: any) {
30
+ expect(e).toBeInstanceOf(ValidationError);
31
+ expect(e.code).toBe("node_name_invalid");
32
+ expect(e.detail.reason).toBe("forbidden_char");
33
+ expect(e.detail.char).toBe("/");
34
+ expect(String(e.detail.message)).toContain("/");
35
+ }
36
+ });
23
37
  });
24
38
 
25
39
  describe("validateRuntime / validateModel (§4.2.2)", () => {
@@ -8,6 +8,7 @@
8
8
  // payload (RFC §4.6).
9
9
 
10
10
  import { isReservedEnvKey } from "./shared/reserved-env.js";
11
+ import { checkNodeName } from "./shared/node-name.js";
11
12
 
12
13
  export class ValidationError extends Error {
13
14
  constructor(public code: string, public detail?: Record<string, unknown>) {
@@ -66,17 +67,26 @@ export const MAX_ENV_VALUE_BYTES = 16 * 1024;
66
67
  const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
67
68
 
68
69
  // §4.2.2 name + model.
69
- const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
70
+ // Board #652 — the name rule lives in ./shared/node-name.ts (byte-identical copy in
71
+ // agent-node/src/shared/, drift-tested). It used to be /^[a-z][a-z0-9_-]{0,63}$/ here.
70
72
  // OpenCode 共存的模型带 provider 前缀(`opencode/mimo-v2.5-free`,agent-node parseModelRef 按第一个 `/` 切),
71
73
  // 桌面向导 0.2.61 起就发这种形状 —— 允许**恰好一个**斜杠分隔的两段;每段仍是原字符集,且不能是纯点(`..`/`.`)。
72
74
  const MODEL_SEGMENT = "[a-zA-Z0-9._:\\-]+";
73
75
  const MODEL_RE = new RegExp(`^${MODEL_SEGMENT}(?:/${MODEL_SEGMENT})?$`);
74
76
  const MODEL_DOT_ONLY_SEGMENT = /(^|\/)\.+(\/|$)/;
75
77
 
76
- export function validateName(s: unknown): asserts s is string {
77
- if (typeof s !== "string" || !NAME_RE.test(s)) {
78
- throw new ValidationError("node_name_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
78
+ /** Board #652 — returns the normalized (trimmed) name the caller must store and forward. */
79
+ export function validateName(s: unknown): string {
80
+ const r = checkNodeName(s);
81
+ if (!r.ok) {
82
+ throw new ValidationError("node_name_invalid", {
83
+ value: typeof s === "string" ? s.slice(0, 80) : typeof s,
84
+ reason: r.error,
85
+ ...(r.char !== undefined ? { char: r.char } : {}),
86
+ message: r.message,
87
+ });
79
88
  }
89
+ return r.name;
80
90
  }
81
91
 
82
92
  export function validateRuntime(s: unknown): asserts s is Runtime {
@@ -0,0 +1,202 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ const dir = mkdtempSync(join(tmpdir(), "lifecycle-read-"));
7
+ process.env.COMMHUB_DB ||= join(dir, "hub.db");
8
+ process.env.COMMHUB_AUTH_TOKEN ||= "lifecycle-read-master-fixture";
9
+ let hub: ReturnType<typeof Bun.serve>, base: string;
10
+ let db: any, a: any, b: any, limited: any, daemonToken: string;
11
+ const ids = { active: "n_read_active", created: "node_read_created", manual: "node_read_manual", daemon: "n_read_daemon" };
12
+ const get = async (path: string, token = a.token) => {
13
+ const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
14
+ return { status: res.status, body: await res.json() as any };
15
+ };
16
+ const query = (kind: string, selector: string) => `/api/node-lifecycle-requests?kind=${kind}&${selector}`;
17
+
18
+ beforeAll(async () => {
19
+ ({ db } = await import("./db.js"));
20
+ const { register } = await import("./auth.js");
21
+ const suffix = Date.now();
22
+ a = register(`read_owner_${suffix}`, "ReadFixture-Strong1!");
23
+ b = register(`read_other_${suffix}`, "ReadFixture-Strong1!");
24
+ limited = register(`read_limited_${suffix}`, "ReadFixture-Strong1!");
25
+ expect(a.ok && b.ok && limited.ok).toBe(true);
26
+ for (const user of [a, b, limited]) db.run("UPDATE users SET role='user' WHERE user_id=?1", [user.user.user_id]);
27
+ db.run("INSERT INTO network_members(network_id,user_id,role,agent_access) VALUES(?1,?2,'viewer','granted')", [a.network_id, limited.user.user_id]);
28
+ for (const [key, id] of Object.entries(ids)) db.run(
29
+ "INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host',?5)",
30
+ [id, `read-${key}`, a.network_id, a.user.user_id, key === "daemon" ? JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }) : "{}"]);
31
+ for (const status of ["active", "pending", "refused", "revoked"]) {
32
+ const id = status === "active" ? ids.active : `n_read_${status}`;
33
+ if (status !== "active") db.run("INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?2,?3)", [id, `read-${status}`, a.network_id]);
34
+ db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,error,created_at,updated_at)
35
+ VALUES(?1,?2,?3,?4,'/private/fixture',?5,?6,?7,1,2)`,
36
+ [`adopt_read_${status}`, a.network_id, id, ids.daemon, a.user.user_id, status, status === "refused" ? "adopt_start_evidence_missing" : null]);
37
+ }
38
+ db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token)
39
+ VALUES('cr_read_created',?1,'created',?2,'codex-sdk','{}','[]','succeeded',1,'private-token')`, [ids.daemon, a.network_id]);
40
+ db.run(`INSERT INTO node_stop_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,action,created_by_token,status,error,created_at,acked_at)
41
+ VALUES('stop_read_failed',?1,?2,?3,'read-active','stop','private-token','stop_failed','adopt_explicit_private_socket_required',3,4)`, [a.network_id, ids.daemon, ids.active]);
42
+ db.run(`INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,error,created_at,acked_at)
43
+ VALUES('start_read_failed',?1,?2,?3,'read-active','private-token','start_failed','adopt_start_evidence_missing',5,6)`, [a.network_id, ids.daemon, ids.active]);
44
+ db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES('read-daemon-session','read-daemon',?1,?2,'idle',datetime('now'))", [ids.daemon, a.network_id]);
45
+ const { generateNetworkToken, hashToken } = await import("./db.js");
46
+ daemonToken = generateNetworkToken();
47
+ db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('read-daemon-token',?1,?2,'network','node:read-daemon',?3)", [a.user.user_id, a.network_id, hashToken(daemonToken)]);
48
+ const { bootServer } = await import("./server.js");
49
+ hub = bootServer({ port: 0, hostname: "127.0.0.1" });
50
+ base = `http://127.0.0.1:${hub.port}`;
51
+ }, 30_000);
52
+ afterAll(() => hub?.stop(true));
53
+
54
+ test("public errors redact free text for granted viewers on every projection", async () => {
55
+ db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
56
+ try {
57
+ for (const error of [
58
+ "/private/fixture/secret on fixture-host",
59
+ "not_my_child: daemon has no local record of fixture-node (fixture-id) — no children-map entry and no config at /private/fixture/fixture-node/config.json; refusing to signal processes by alias.",
60
+ "bin: ENOENT: /private/fixture/bin/anet on fixture-host",
61
+ "adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
62
+ ]) {
63
+ const expected = error === null || error === "adopt_start_timeout" ? error : "lifecycle_error";
64
+ for (const [kind, table, request] of [
65
+ ["adopt", "node_daemon_bindings", "adopt_read_active"],
66
+ ["start", "node_start_requests", "start_read_failed"],
67
+ ["stop", "node_stop_requests", "stop_read_failed"],
68
+ ]) {
69
+ db.run(`UPDATE ${table} SET error=?1 WHERE request_id=?2`, [error, request]);
70
+ for (const selector of [`request_id=${request}`, `node_id=${ids.active}`]) {
71
+ const r = await get(query(kind, selector), limited.token);
72
+ expect(r.status).toBe(200);
73
+ expect(r.body.request.error).toBe(expected);
74
+ }
75
+ }
76
+ const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes;
77
+ expect(nodes[0].adoption.error).toBe(expected);
78
+ }
79
+ } finally {
80
+ db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]);
81
+ db.run("UPDATE node_daemon_bindings SET error=NULL WHERE request_id='adopt_read_active'");
82
+ db.run("UPDATE node_start_requests SET error='adopt_start_evidence_missing' WHERE request_id='start_read_failed'");
83
+ db.run("UPDATE node_stop_requests SET error='adopt_explicit_private_socket_required' WHERE request_id='stop_read_failed'");
84
+ }
85
+ });
86
+
87
+ test("created projection prefers recorded child id and only falls back for legacy null", async () => {
88
+ const read = async () => (await get(`/api/nodes?network_id=${a.network_id}`)).body.nodes;
89
+ try {
90
+ expect((await read()).find((n: any) => n.node_id === ids.created).managed).toBe("created");
91
+ db.run("UPDATE node_create_requests SET child_node_id=?1 WHERE request_id='cr_read_created'", [ids.manual]);
92
+ let nodes = await read();
93
+ expect(nodes.find((n: any) => n.node_id === ids.manual).managed).toBe("created");
94
+ expect(nodes.find((n: any) => n.node_id === ids.created).managed).toBe("none");
95
+ db.run("UPDATE node_create_requests SET child_node_id='missing-child' WHERE request_id='cr_read_created'");
96
+ expect((await read()).find((n: any) => n.node_id === ids.created).managed).toBe("none");
97
+ db.run("UPDATE node_create_requests SET child_node_id=?1,network_id=?2 WHERE request_id='cr_read_created'", [ids.manual, b.network_id]);
98
+ nodes = await read();
99
+ expect(nodes.find((n: any) => n.node_id === ids.manual).managed).toBe("none");
100
+ } finally {
101
+ db.run("UPDATE node_create_requests SET child_node_id=NULL,network_id=?1 WHERE request_id='cr_read_created'", [a.network_id]);
102
+ }
103
+ });
104
+
105
+ test("authenticated port-0 HTTP projects actual authority, never an id prefix", async () => {
106
+ const { status, body } = await get(`/api/nodes?network_id=${a.network_id}`);
107
+ expect(status).toBe(200);
108
+ const rows = body.nodes;
109
+ expect(rows.find((r: any) => r.node_id === ids.created)).toMatchObject({ managed: "created", adoption: null, lifecycle_controllable: true });
110
+ expect(rows.find((r: any) => r.node_id === ids.manual)).toMatchObject({ managed: "none", adoption: null, lifecycle_controllable: false });
111
+ expect(rows.find((r: any) => r.node_id === ids.active)).toMatchObject({ managed: "adopted", adoption: { status: "active", request_id: "adopt_read_active" }, lifecycle_controllable: true });
112
+ for (const status of ["pending", "refused", "revoked"]) expect(rows.find((r: any) => r.node_id === `n_read_${status}`)).toMatchObject({ managed: "none", adoption: { status } });
113
+ });
114
+ test("adopt query preserves all persisted states, request id and bounded projection", async () => {
115
+ for (const status of ["pending", "active", "refused", "revoked"]) {
116
+ const r = await get(query("adopt", `request_id=adopt_read_${status}`));
117
+ expect(r.status).toBe(200);
118
+ expect(r.body.request).toMatchObject({ kind: "adopt", status, request_id: `adopt_read_${status}` });
119
+ expect(r.body.request.workdir).toBeUndefined();
120
+ expect(r.body.request.requested_by).toBeUndefined();
121
+ }
122
+ });
123
+ test("start/stop returns daemon failure codes; node selector returns latest, not success fiction", async () => {
124
+ for (const [kind, error] of [["start", "adopt_start_evidence_missing"], ["stop", "adopt_explicit_private_socket_required"]]) {
125
+ const r = await get(query(kind, `node_id=${ids.active}`));
126
+ expect(r.status).toBe(200);
127
+ expect(r.body.request).toMatchObject({ status: `${kind}_failed`, error, node_id: ids.active });
128
+ expect(r.body.request.created_by_token).toBeUndefined();
129
+ }
130
+ expect((await get(query("start", `node_id=${ids.manual}`))).body.request).toBeNull();
131
+ });
132
+ test("cross-network request and node guesses do not disclose data", async () => {
133
+ for (const kind of ["adopt", "start", "stop"]) {
134
+ const id = kind === "adopt" ? "adopt_read_active" : `${kind}_read_failed`;
135
+ const denied = await get(query(kind, `request_id=${id}`), b.token);
136
+ expect(denied.status).toBe(404);
137
+ expect(denied.body).toEqual((await get(query(kind, "request_id=nonexistent"), b.token)).body);
138
+ expect((await get(query(kind, `node_id=${ids.active}`), b.token)).status).toBe(404);
139
+ expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), b.token)).status).toBe(403);
140
+ }
141
+ });
142
+ test("same-network restricted viewer without node grants cannot read requests", async () => {
143
+ for (const kind of ["adopt", "start", "stop"]) {
144
+ expect((await get(query(kind, `node_id=${ids.active}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
145
+ const id = kind === "adopt" ? "adopt_read_active" : `${kind}_read_failed`;
146
+ expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
147
+ }
148
+ expect((await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes).toEqual([]);
149
+ });
150
+ test("current node grant allows reads; revocation immediately closes them", async () => {
151
+ db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
152
+ try {
153
+ expect((await get(query("adopt", "request_id=adopt_read_active"), limited.token)).status).toBe(200);
154
+ const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes;
155
+ expect(nodes).toHaveLength(1);
156
+ expect(nodes[0].managed).toBe("adopted");
157
+ } finally { db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]); }
158
+ expect((await get(query("adopt", "request_id=adopt_read_active"), limited.token)).status).toBe(404);
159
+ });
160
+ test("daemon and legacy master do not gain the new user read surface", async () => {
161
+ for (const token of [daemonToken, process.env.COMMHUB_AUTH_TOKEN!]) {
162
+ expect((await get(query("adopt", "request_id=adopt_read_active"), token)).status).toBe(403);
163
+ const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, token)).body.nodes;
164
+ expect(nodes.length).toBeGreaterThan(0);
165
+ expect(nodes.every((n: any) => !("managed" in n) && !("adoption" in n))).toBe(true);
166
+ }
167
+ expect((await get(query("adopt", "request_id=adopt_read_active"), "invalid")).status).toBe(401);
168
+ const queryOnly = await fetch(base + query("adopt", `request_id=adopt_read_active&token=${encodeURIComponent(a.token)}`));
169
+ expect([401, 403]).toContain(queryOnly.status);
170
+ });
171
+ test("host-supervisors capability respects user token and node visibility; absent stays absent", async () => {
172
+ const path = `/api/host-supervisors?network_id=${a.network_id}`;
173
+ expect((await get(path)).body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adopt_capable).toBe(true);
174
+ for (const token of [limited.token, daemonToken]) {
175
+ const r = await get(path, token);
176
+ expect((r.body.daemons ?? []).every((d: any) => !("adopt_capable" in d))).toBe(true);
177
+ }
178
+ db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: false } }), ids.daemon]);
179
+ expect((await get(path)).body.daemons[0].adopt_capable).toBe(false);
180
+ db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), ids.daemon]);
181
+ expect((await get(path)).body.daemons[0].adopt_capable).toBeUndefined();
182
+ });
183
+ test("invalid selectors rejected rather than listing every lifecycle request", async () => {
184
+ for (const suffix of ["", "kind=adopt", "kind=other&node_id=x", "kind=start&request_id=x&node_id=y", "kind=stop&node_id=x&node_id=y", "kind=start&request_id=x&node_id=", "kind=adopt&node_id=%20"])
185
+ expect((await get(`/api/node-lifecycle-requests?${suffix}`)).status).toBe(400);
186
+ });
187
+ test("request state refresh exposes pending, completion and binding-missing failure without caching", async () => {
188
+ for (const [kind, statuses] of [["start", ["pending", "delivered", "started", "start_failed", "timeout"]], ["stop", ["pending", "delivered", "stopped", "stop_failed", "noop_not_my_child"]]] as const) {
189
+ for (const status of statuses) {
190
+ const error = status.endsWith("failed") ? "adopt_active_binding_required" : null;
191
+ db.run(`UPDATE node_${kind}_requests SET status=?1,error=?2 WHERE request_id=?3`, [status, error, `${kind}_read_failed`]);
192
+ expect((await get(query(kind, `request_id=${kind}_read_failed`))).body.request).toMatchObject({ status, error });
193
+ }
194
+ }
195
+ });
196
+ test("join refuses a request whose stored network disagrees with its target node", async () => {
197
+ db.run("UPDATE node_start_requests SET network_id=?1 WHERE request_id='start_read_failed'", [b.network_id]);
198
+ try {
199
+ expect((await get(query("start", "request_id=start_read_failed"))).status).toBe(404);
200
+ expect((await get(query("start", "request_id=start_read_failed"), b.token)).status).toBe(404);
201
+ } finally { db.run("UPDATE node_start_requests SET network_id=?1 WHERE request_id='start_read_failed'", [a.network_id]); }
202
+ });
@@ -0,0 +1,98 @@
1
+ import { db } from "./db.js";
2
+ import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js";
3
+
4
+ // Exact public codes only; daemon exception text must never reach node viewers.
5
+ const publicLifecycleErrors = new Set([
6
+ "adopt_active_binding_required", "adopt_binding_unavailable",
7
+ "adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
8
+ "adopt_start_evidence_missing", "adopt_stop_evidence_missing",
9
+ "adopt_launch_mode_mismatch", "adopt_lifecycle_verification_failed",
10
+ "adopt_process_generation_changed", "adopt_process_still_running",
11
+ "adopt_process_stop_timeout", "adopt_process_tree_unstable",
12
+ "adopt_registry_identity_mismatch", "adopt_self_process_refused",
13
+ "adopt_start_timeout", "adopt_tmux_session_still_exists",
14
+ "adopt_local_verification_failed", "adopt_roots_not_configured",
15
+ "adopt_workdir_outside_roots", "adopt_workdir_not_absolute",
16
+ "adopt_alias_invalid", "adopt_alias_mismatch", "adopt_config_invalid",
17
+ "adopt_config_network_mismatch", "adopt_network_mismatch",
18
+ "adopt_hub_invalid", "adopt_hub_missing", "adopt_hub_mismatch",
19
+ "adopt_identity_ambiguous", "adopt_identity_not_found", "adopt_identity_changed",
20
+ "adopt_path_not_regular", "adopt_path_owner_mismatch", "adopt_path_writable_by_others",
21
+ "adopt_process_argv_mismatch", "adopt_process_home_mismatch",
22
+ "adopt_process_identity_mismatch", "adopt_process_changed",
23
+ "adopt_tmux_socket_mismatch", "adopt_pane_invalid", "adopt_pane_unverified",
24
+ "adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
25
+ "adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
26
+ "adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
27
+ "adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
28
+ "adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
29
+ "adopt_env_file_unsafe", "adopt_request_mismatch", "adopt_ack_rejected",
30
+ ]);
31
+ function publicLifecycleError(error: unknown): string | null {
32
+ return error == null ? null
33
+ : typeof error === "string" && publicLifecycleErrors.has(error) ? error : "lifecycle_error";
34
+ }
35
+
36
+ type Adoption = { request_id: string; daemon_node_id: string; status: string; error: string | null };
37
+ export type LifecycleProjection = { managed: "created" | "adopted" | "none"; adoption: Adoption | null };
38
+ const scopeNodes = (sql: string, params: any[], scope: RestNetworkScope) =>
39
+ addAgentNetworkScope(sql, params, scope, { network: "n.network_id", nodeId: "n.node_id", alias: "n.alias" });
40
+
41
+ /** Called only for authenticated human readers. Same visibility as /api/nodes;
42
+ * constant query count, no id-prefix or host-name inference of authority. */
43
+ export function lifecycleProjections(scope: RestNetworkScope): Map<string, LifecycleProjection> {
44
+ const result = new Map<string, LifecycleProjection>();
45
+ const bp: any[] = [];
46
+ const bindings = db.all<Adoption & { node_id: string }>(scopeNodes(
47
+ `SELECT b.node_id,b.request_id,b.daemon_node_id,b.status,b.error
48
+ FROM node_daemon_bindings b JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
49
+ WHERE 1=1`, bp, scope) + " ORDER BY b.created_at DESC,b.request_id DESC", ...bp);
50
+ for (const b of bindings) {
51
+ let projection = result.get(b.node_id);
52
+ if (!projection) {
53
+ projection = { managed: "none", adoption: { request_id: b.request_id, daemon_node_id: b.daemon_node_id, status: b.status, error: publicLifecycleError(b.error) } };
54
+ result.set(b.node_id, projection);
55
+ }
56
+ if (b.status === "active") projection.managed = "adopted";
57
+ }
58
+ const cp: any[] = [];
59
+ const created = db.all<{ node_id: string }>(scopeNodes(
60
+ `SELECT n.node_id FROM node_create_requests c JOIN nodes n
61
+ ON n.node_id=COALESCE(c.child_node_id, CASE WHEN substr(c.request_id,1,3)='cr_'
62
+ THEN 'node_' || substr(c.request_id,4) END) AND n.network_id=c.network_id
63
+ WHERE 1=1`, cp, scope), ...cp);
64
+ for (const n of created) result.set(n.node_id, { managed: "created", adoption: result.get(n.node_id)?.adoption ?? null });
65
+ return result;
66
+ }
67
+
68
+ /** Read-only, selector-bound lookup. Join the live node in the SAME network,
69
+ * then apply its current visibility (including restricted member grants).
70
+ * Result fields exclude credentials, PID and config; error values separately
71
+ * use the exact public-code allowlist (daemon text may contain workdir paths). */
72
+ export function lifecycleRequestResponse(url: URL, scope: RestNetworkScope): Response {
73
+ const kind = url.searchParams.get("kind");
74
+ const requestId = url.searchParams.get("request_id"), nodeId = url.searchParams.get("node_id");
75
+ const fail = (error: string, status: number) => Response.json({ ok: false, error }, { status });
76
+ if (!["adopt", "start", "stop"].includes(kind ?? "")
77
+ || url.searchParams.has("request_id") === url.searchParams.has("node_id")
78
+ || !(requestId ?? nodeId ?? "").trim()
79
+ || ["kind", "request_id", "node_id"].some(k => url.searchParams.getAll(k).length > 1)
80
+ || (requestId ?? nodeId ?? "").length > 200) return fail("invalid_lifecycle_query", 400);
81
+ // The table and column names below come only from this closed enum.
82
+ const table = kind === "adopt" ? "node_daemon_bindings" : kind === "start" ? "node_start_requests" : "node_stop_requests";
83
+ const nodeColumn = kind === "adopt" ? "node_id" : "child_node_id";
84
+ // A node lookup with no request must distinguish 'no requests' from 'not visible'.
85
+ if (nodeId) {
86
+ const params: any[] = [nodeId];
87
+ if (!db.get(scopeNodes("SELECT n.node_id FROM nodes n WHERE n.node_id=?1", params, scope), ...params))
88
+ return fail("node_not_found", 404);
89
+ }
90
+ const params: any[] = [requestId ?? nodeId];
91
+ const sql = scopeNodes(`SELECT r.request_id,r.${nodeColumn} AS node_id,r.network_id,r.daemon_node_id,
92
+ r.status,r.error,r.created_at,${kind === "adopt" ? "r.updated_at" : "r.acked_at,r.delivered_at"}
93
+ FROM ${table} r JOIN nodes n ON n.node_id=r.${nodeColumn} AND n.network_id=r.network_id
94
+ WHERE r.${requestId ? "request_id" : nodeColumn}=?1${kind === "stop" ? " AND r.action='stop'" : ""}`, params, scope);
95
+ const row = db.get<Record<string, unknown>>(sql + " ORDER BY r.created_at DESC,r.request_id DESC LIMIT 1", ...params);
96
+ if (!row && requestId) return fail("request_not_found", 404);
97
+ return Response.json({ ok: true, request: row ? { kind, ...row, error: publicLifecycleError(row.error) } : null });
98
+ }
package/src/server.ts CHANGED
@@ -27,6 +27,7 @@ import { pendingInboxCount } from "./inbox-count.js";
27
27
  import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
28
28
  import { expireStaleOpenTasks } from "./task-stale-open.js";
29
29
  import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
30
+ import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
30
31
  import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
31
32
  import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
32
33
  import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
@@ -2455,6 +2456,16 @@ return Bun.serve({
2455
2456
  return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
2456
2457
  }
2457
2458
 
2459
+ // #629: only actual user credentials gain lifecycle read metadata. Legacy
2460
+ // master/dev-open and network/daemon credentials keep their old surface.
2461
+ const lifecycleAuth = resolveRequestAuth(req, { allowQueryToken: false });
2462
+ const lifecycleUser = !!lifecycleAuth?.userId && !lifecycleAuth.networkId
2463
+ && !requestToken(req, { allowQueryToken: false }).startsWith("ntok_");
2464
+ if (url.pathname === "/api/node-lifecycle-requests" && req.method === "GET") {
2465
+ if (!lifecycleUser) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
2466
+ return withCors(req, lifecycleRequestResponse(url, restScope));
2467
+ }
2468
+
2458
2469
  const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
2459
2470
  const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
2460
2471
  ? { tokenId: sideActor.tokenId, networkId: sideActor.boundNetworkId, nodeId: sideActor.boundNodeId }
@@ -4468,6 +4479,7 @@ return Bun.serve({
4468
4479
  // would silently start dimming every daemon that hasn't
4469
4480
  // upgraded past preview.55.
4470
4481
  let snapCanCreate: boolean | undefined;
4482
+ let snapAdoptCapable: boolean | undefined;
4471
4483
  let snapBlockedReason: string | undefined;
4472
4484
  // #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
4473
4485
  // 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
@@ -4481,6 +4493,7 @@ return Bun.serve({
4481
4493
  const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
4482
4494
  snapRole = typeof parsed?.role === "string" ? parsed.role : null;
4483
4495
  const caps = parsed?.daemon_capabilities;
4496
+ if (typeof caps?.adopt_capable === "boolean") snapAdoptCapable = caps.adopt_capable;
4484
4497
  if (caps && typeof caps.can_create_nodes === "boolean") {
4485
4498
  snapCanCreate = caps.can_create_nodes;
4486
4499
  }
@@ -4498,10 +4511,10 @@ return Bun.serve({
4498
4511
  } catch { /* malformed */ }
4499
4512
  }
4500
4513
  return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
4501
- observedMsAgo: snapObservedMsAgo, readiness: snapReadiness };
4514
+ observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable };
4502
4515
  })
4503
4516
  .filter(({ role }) => role === "host_supervisor")
4504
- .map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness }) => {
4517
+ .map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable }) => {
4505
4518
  let online = false;
4506
4519
  let lastSeenAt: string | null = null;
4507
4520
  if (r.session_last_seen) {
@@ -4541,6 +4554,12 @@ return Bun.serve({
4541
4554
  allowed_secret_keys: secrets,
4542
4555
  host_telemetry: telemetry,
4543
4556
  };
4557
+ if (lifecycleUser && typeof adoptCapable === "boolean") {
4558
+ // Do not reveal even the new capability for a node the user cannot read.
4559
+ const visibleParams: any[] = [r.node_id];
4560
+ const visibleSql = addAgentNetworkScope("SELECT node_id FROM nodes WHERE node_id=?1", visibleParams, restScope, { alias: "alias", nodeId: "node_id" });
4561
+ if (db.get(visibleSql, ...visibleParams)) out.adopt_capable = adoptCapable;
4562
+ }
4544
4563
  // #1353 Fix ② — only emit the two capability keys when the
4545
4564
  // daemon actually reported them. Undefined vs. false is a
4546
4565
  // real distinction here (see Fix ② comment above).
@@ -4657,6 +4676,7 @@ return Bun.serve({
4657
4676
  const role = netRoles.get(r.network_id);
4658
4677
  return role === "owner" || role === "admin";
4659
4678
  };
4679
+ const management = lifecycleUser ? lifecycleProjections(restScope) : null;
4660
4680
  const rows = rawRows.map(r => {
4661
4681
  let role: string | null = null;
4662
4682
  const snap = r.config_snapshot;
@@ -4680,6 +4700,7 @@ return Bun.serve({
4680
4700
  role,
4681
4701
  lifecycle_controllable,
4682
4702
  lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
4703
+ ...(management ? (management.get(r.node_id) ?? { managed: "none", adoption: null }) : {}),
4683
4704
  viewer_can: { permission_mode: canSetPermissionMode(r) },
4684
4705
  };
4685
4706
  });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ NODE_FOLDER_RE, NODE_NAME_CASES, LEGACY_NODE_NAME_RE,
6
+ checkNodeName, nodeDirNameFor, nodeFolderSlug, fnv1aHex,
7
+ } from "./node-name.js";
8
+
9
+ // Board #652 — one name rule for hub + daemon (+ the app's pinned copy).
10
+
11
+ describe("#652 checkNodeName — shared vectors", () => {
12
+ for (const c of NODE_NAME_CASES) {
13
+ test(`${JSON.stringify(c.input).slice(0, 24)} → ${c.ok ? "ok" : c.error}`, () => {
14
+ const r = checkNodeName(c.input);
15
+ expect(r.ok).toBe(c.ok);
16
+ if (!r.ok) {
17
+ expect(r.error).toBe(c.error!);
18
+ expect(r.message.length).toBeGreaterThan(0);
19
+ }
20
+ if (c.slug !== undefined) expect(nodeFolderSlug(c.input)).toBe(c.slug);
21
+ });
22
+ }
23
+
24
+ test("the vector table really contains Chinese names that pass (old rule would refuse them)", () => {
25
+ const cjkOk = NODE_NAME_CASES.filter(c => c.ok && /\p{Script=Han}/u.test(c.input));
26
+ expect(cjkOk.length).toBeGreaterThan(0);
27
+ for (const c of cjkOk) expect(LEGACY_NODE_NAME_RE.test(c.input)).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("#652 folder slug", () => {
32
+ test("every slug — incl. every accepted vector — matches the ASCII folder rule", () => {
33
+ for (const c of NODE_NAME_CASES.filter(x => x.ok)) {
34
+ const s = nodeFolderSlug(c.input);
35
+ expect(NODE_FOLDER_RE.test(s)).toBe(true);
36
+ expect(/^[\x21-\x7e]+$/.test(s)).toBe(true);
37
+ }
38
+ });
39
+ test("non-ASCII → node-<6 hex>, deterministic", () => {
40
+ expect(nodeFolderSlug("测试")).toMatch(/^node-[0-9a-f]{6}$/);
41
+ expect(nodeFolderSlug("测试")).toBe(nodeFolderSlug(" 测试 "));
42
+ expect(nodeFolderSlug("测试")).not.toBe(nodeFolderSlug("测验"));
43
+ });
44
+ test("fnv1a known answers (pins the app reimplementation)", () => {
45
+ expect(fnv1aHex("")).toBe("811c9dc5");
46
+ expect(fnv1aHex("a")).toBe("e40c292c");
47
+ expect(fnv1aHex("foobar")).toBe("bf9cf968");
48
+ });
49
+ test("nodeDirNameFor keeps legacy names as-is (existing nodes never move)", () => {
50
+ for (const n of ["a", "demo-bot", "node_1", "my_agent"]) expect(nodeDirNameFor(n)).toBe(n);
51
+ expect(nodeDirNameFor("测试")).toBe(nodeFolderSlug("测试"));
52
+ expect(nodeDirNameFor("MyBot")).toBe("mybot");
53
+ });
54
+ });
55
+
56
+ describe("#652 hub/daemon node-name drift guard", () => {
57
+ test("byte-identical source files", () => {
58
+ const hub = readFileSync(join(import.meta.dir, "node-name.ts"), "utf-8");
59
+ const daemon = readFileSync(join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "node-name.ts"), "utf-8");
60
+ expect(daemon).toBe(hub);
61
+ });
62
+ });
@@ -0,0 +1,161 @@
1
+ // Board #652 — node name (= network alias) rules and the ASCII folder slug derived from it.
2
+ //
3
+ // Single source of truth for hub + daemon. This file is byte-identical in
4
+ // server/src/shared/node-name.ts and agent-node/src/shared/node-name.ts
5
+ // (node-name-drift.test.ts fails the build on any difference), and the app
6
+ // (sleep2agi/agent-network-app) keeps a copy pinned to NODE_NAME_CASES below.
7
+ //
8
+ // Before #652 the name had to match /^[a-z][a-z0-9_-]{0,63}$/, so the app
9
+ // wizard refused 「测试」 while the real fleet runs Chinese aliases started by
10
+ // hand. House rule: the alias may be Chinese, the working directory may not.
11
+ // So the name is now any Unicode letters/digits, and every directory derived
12
+ // from it uses nodeFolderSlug() (or the legacy name, for names the old rule
13
+ // already accepted — see nodeDirNameFor).
14
+
15
+ /** The old rule. Every name it accepts keeps its old directory (nodeDirNameFor). */
16
+ export const LEGACY_NODE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
17
+
18
+ /** Folder / slug rule: what a working directory or `.anet/nodes/<dir>` may be called. */
19
+ export const NODE_FOLDER_RE = /^[a-z][a-z0-9-]{0,63}$/;
20
+
21
+ export const NODE_NAME_MAX_CHARS = 64;
22
+
23
+ // First char: a letter, a digit or `_` (never `-`: `anet node start -x` reads as a flag;
24
+ // never `.`: hidden files, `..`). Rest: letters, digits, combining marks (Devanagari,
25
+ // Thai… need them), `_`, `-`. Nothing else — no whitespace, `/ \ :`, `.`, quotes,
26
+ // `$`, backticks, control characters: the name ends up in argv, tmux session names
27
+ // (tmux rewrites `.` and `:`), JSON, logs and file names in the trash directory.
28
+ const NODE_NAME_RE = /^[\p{L}\p{N}_][\p{L}\p{M}\p{N}_-]*$/u;
29
+
30
+ export type NodeNameError =
31
+ | "empty"
32
+ | "too_long"
33
+ | "leading_dash"
34
+ | "forbidden_char"
35
+ | "not_nfc";
36
+
37
+ export type NodeNameCheck =
38
+ | { ok: true; name: string }
39
+ | { ok: false; error: NodeNameError; char?: string; message: string };
40
+
41
+ /** Trim + NFC. Callers store the returned `name`, not the raw input. */
42
+ export function normalizeNodeName(raw: string): string {
43
+ return raw.trim().normalize("NFC");
44
+ }
45
+
46
+ function describeChar(ch: string): string {
47
+ const cp = ch.codePointAt(0) ?? 0;
48
+ if (cp < 0x20 || cp === 0x7f) return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
49
+ if (/\s/u.test(ch)) return "空格";
50
+ return ch;
51
+ }
52
+
53
+ /**
54
+ * Check a node name. `raw` is trimmed first; the trimmed, NFC form must be
55
+ * 1..64 characters (code points) of letters / digits / `_` / `-`, not starting with `-`.
56
+ */
57
+ export function checkNodeName(raw: unknown): NodeNameCheck {
58
+ if (typeof raw !== "string") return { ok: false, error: "empty", message: "名字不能为空" };
59
+ const trimmed = raw.trim();
60
+ if (trimmed.length === 0) return { ok: false, error: "empty", message: "名字不能为空" };
61
+ const chars = [...trimmed];
62
+ if (chars.length > NODE_NAME_MAX_CHARS) {
63
+ return { ok: false, error: "too_long", message: `名字最多 ${NODE_NAME_MAX_CHARS} 个字符` };
64
+ }
65
+ if (trimmed !== trimmed.normalize("NFC")) {
66
+ return { ok: false, error: "not_nfc", message: "名字需要是 Unicode NFC 规范形式" };
67
+ }
68
+ if (chars[0] === "-") {
69
+ return { ok: false, error: "leading_dash", char: "-", message: "名字不能以 - 开头" };
70
+ }
71
+ if (!NODE_NAME_RE.test(trimmed)) {
72
+ const bad = chars.find((ch, i) => !(i === 0 ? /^[\p{L}\p{N}_]$/u : /^[\p{L}\p{M}\p{N}_-]$/u).test(ch)) ?? chars[0]!;
73
+ return {
74
+ ok: false,
75
+ error: "forbidden_char",
76
+ char: bad,
77
+ message: `名字里不能有「${describeChar(bad)}」:只能用文字、字母、数字、_ 和 -`,
78
+ };
79
+ }
80
+ return { ok: true, name: trimmed };
81
+ }
82
+
83
+ export function isValidNodeName(raw: unknown): boolean {
84
+ return checkNodeName(raw).ok;
85
+ }
86
+
87
+ /** FNV-1a 32-bit over the UTF-8 bytes, 8 hex digits. Tiny, dependency-free, and the
88
+ * app reimplements it byte for byte (no crypto module needed in the browser / RN). */
89
+ export function fnv1aHex(s: string): string {
90
+ const bytes = new TextEncoder().encode(s);
91
+ let h = 0x811c9dc5;
92
+ for (const b of bytes) {
93
+ h ^= b;
94
+ h = Math.imul(h, 0x01000193) >>> 0;
95
+ }
96
+ return h.toString(16).padStart(8, "0");
97
+ }
98
+
99
+ /**
100
+ * ASCII folder name for a node name. Pure-ASCII names are transliterated
101
+ * (lowercase, runs of anything outside [a-z0-9] become one `-`, trimmed, `node-`
102
+ * prefixed when they do not start with a letter); anything else — and anything the
103
+ * transliteration empties — becomes `node-<first 6 hex of fnv1a(name)>`.
104
+ * The result always matches NODE_FOLDER_RE.
105
+ */
106
+ export function nodeFolderSlug(raw: string): string {
107
+ const name = normalizeNodeName(raw);
108
+ // eslint-disable-next-line no-control-regex
109
+ if (/^[\x00-\x7f]*$/.test(name)) {
110
+ let s = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
111
+ if (s && !/^[a-z]/.test(s)) s = `node-${s}`;
112
+ s = s.slice(0, NODE_NAME_MAX_CHARS).replace(/-+$/, "");
113
+ if (NODE_FOLDER_RE.test(s)) return s;
114
+ }
115
+ return `node-${fnv1aHex(name).slice(0, 6)}`;
116
+ }
117
+
118
+ /**
119
+ * The directory under `.anet/nodes/` a daemon-created node lives in.
120
+ * Names the old rule accepted keep their old directory (= the name) so nothing that
121
+ * worked before #652 moves; every other name gets nodeFolderSlug(name).
122
+ */
123
+ export function nodeDirNameFor(name: string): string {
124
+ return LEGACY_NODE_NAME_RE.test(name) ? name : nodeFolderSlug(name);
125
+ }
126
+
127
+ /** Shared test vectors — the app's copy must produce the same verdicts and slugs. */
128
+ export const NODE_NAME_CASES: ReadonlyArray<{ input: string; ok: boolean; error?: NodeNameError; slug?: string }> = [
129
+ { input: "测试", ok: true, slug: "node-f78149" },
130
+ { input: "研发助手A", ok: true, slug: "node-1d348f" },
131
+ { input: "AB测试牛", ok: true, slug: "node-048250" },
132
+ { input: "my-bot", ok: true, slug: "my-bot" },
133
+ { input: "MyBot_2", ok: true, slug: "mybot-2" },
134
+ { input: " spaced ", ok: true, slug: "spaced" },
135
+ { input: "123", ok: true, slug: "node-123" },
136
+ { input: "_x", ok: true, slug: "x" },
137
+ { input: "Ünïcödé", ok: true, slug: "node-030667" },
138
+ { input: "a".repeat(64), ok: true, slug: "a".repeat(64) },
139
+ { input: "测".repeat(64), ok: true, slug: "node-50c3fb" },
140
+ { input: "", ok: false, error: "empty" },
141
+ { input: " ", ok: false, error: "empty" },
142
+ { input: "a".repeat(65), ok: false, error: "too_long" },
143
+ { input: "测".repeat(65), ok: false, error: "too_long" },
144
+ { input: "-x", ok: false, error: "leading_dash" },
145
+ { input: "e\u0301", ok: false, error: "not_nfc" },
146
+ { input: ".hidden", ok: false, error: "forbidden_char" },
147
+ { input: "..", ok: false, error: "forbidden_char" },
148
+ { input: "a/b", ok: false, error: "forbidden_char" },
149
+ { input: "a\\b", ok: false, error: "forbidden_char" },
150
+ { input: "a:b", ok: false, error: "forbidden_char" },
151
+ { input: "a.b", ok: false, error: "forbidden_char" },
152
+ { input: "a b", ok: false, error: "forbidden_char" },
153
+ { input: "a\u0000b", ok: false, error: "forbidden_char" },
154
+ { input: "a\nb", ok: false, error: "forbidden_char" },
155
+ { input: "a$b", ok: false, error: "forbidden_char" },
156
+ { input: "a`b", ok: false, error: "forbidden_char" },
157
+ { input: "a'b", ok: false, error: "forbidden_char" },
158
+ { input: "a\"b", ok: false, error: "forbidden_char" },
159
+ { input: "a;b", ok: false, error: "forbidden_char" },
160
+ { input: "a*b", ok: false, error: "forbidden_char" },
161
+ ];
package/src/tools.ts CHANGED
@@ -3879,6 +3879,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3879
3879
  if (!sec1Ok) {
3880
3880
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
3881
3881
  }
3882
+ // board #627: a binding proves stop/start authority, not an exit-75
3883
+ // supervisor. No verified adopted-wrapper capability exists in this
3884
+ // protocol; a self-reported config_update_capable flag is not proof.
3885
+ // Created authority retains its existing restart behavior unchanged.
3886
+ if (!createdDaemon(nodeId) && activeBinding(nodeId)) {
3887
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false,
3888
+ error: "adopted_restart_requires_daemon", message: "Use Stop, then Start for this adopted node." }) }] };
3889
+ }
3882
3890
  // F-B reaper: same stale-supersede semantics as update_node_config,
3883
3891
  // with same acked_at-anchored liveness clock (see update_node_config
3884
3892
  // for the 通信龙 polish reasoning).
@@ -4100,7 +4108,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4100
4108
  {
4101
4109
  daemon_node_id: z.string().min(1).max(200),
4102
4110
  node_spec: z.object({
4103
- name: z.string().min(1).max(64),
4111
+ // #652 — 64 *code points* after trim is enforced by validateChildName (shared/node-name.ts);
4112
+ // zod's max counts UTF-16 units and surrounding whitespace, so it is only a coarse bound here.
4113
+ name: z.string().min(1).max(256),
4104
4114
  runtime: z.string().min(1).max(64),
4105
4115
  model: z.string().min(1).max(100).optional().nullable(),
4106
4116
  flags: z.record(z.string(), z.unknown()).optional(),
@@ -4249,7 +4259,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4249
4259
  // flag injection at the hub edge). Daemon repeats this; double
4250
4260
  // layer per RFC §4.2.2.
4251
4261
  try {
4252
- validateChildName(node_spec.name);
4262
+ // #652 — store/forward the trimmed name the validator returns.
4263
+ node_spec.name = validateChildName(node_spec.name);
4253
4264
  validateRuntime(node_spec.runtime);
4254
4265
  validateModel(node_spec.model);
4255
4266
  validateChannelsP1((node_spec as any).channels);