@sleep2agi/commhub-server 0.9.0-preview.109 → 0.9.0-preview.111

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/src/server.ts CHANGED
@@ -3,6 +3,8 @@ import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
3
3
  import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
4
4
  import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
5
5
  import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
6
+ import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
7
+ import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
6
8
  import { statusAliasResolverRead } from "./status-alias-resolver.js";
7
9
  import { readNodeHealth } from "./node-health-store.js";
8
10
  import { wantsAllTools } from "./tool-audience.js";
@@ -27,6 +29,7 @@ import { pendingInboxCount } from "./inbox-count.js";
27
29
  import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
28
30
  import { expireStaleOpenTasks } from "./task-stale-open.js";
29
31
  import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
32
+ import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
30
33
  import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
31
34
  import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
32
35
  import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
@@ -792,7 +795,7 @@ function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T &
792
795
  // ── REST input schema ───────────────────────────────
793
796
  const TaskSchema = z.object({
794
797
  alias: z.string().min(1).max(200),
795
- task: z.string().min(1).max(10000),
798
+ task: z.string().min(1).max(TASK_CONTENT_MAX),
796
799
  priority: z.enum(["high", "normal", "low"]).default("normal"),
797
800
  from: z.string().max(200).optional(),
798
801
  network_id: z.string().max(200).optional(),
@@ -2455,6 +2458,16 @@ return Bun.serve({
2455
2458
  return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
2456
2459
  }
2457
2460
 
2461
+ // #629: only actual user credentials gain lifecycle read metadata. Legacy
2462
+ // master/dev-open and network/daemon credentials keep their old surface.
2463
+ const lifecycleAuth = resolveRequestAuth(req, { allowQueryToken: false });
2464
+ const lifecycleUser = !!lifecycleAuth?.userId && !lifecycleAuth.networkId
2465
+ && !requestToken(req, { allowQueryToken: false }).startsWith("ntok_");
2466
+ if (url.pathname === "/api/node-lifecycle-requests" && req.method === "GET") {
2467
+ if (!lifecycleUser) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
2468
+ return withCors(req, lifecycleRequestResponse(url, restScope));
2469
+ }
2470
+
2458
2471
  const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
2459
2472
  const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
2460
2473
  ? { tokenId: sideActor.tokenId, networkId: sideActor.boundNetworkId, nodeId: sideActor.boundNodeId }
@@ -3677,8 +3690,8 @@ return Bun.serve({
3677
3690
  // immediately, without waiting for the agent's report_status to
3678
3691
  // arrive. Updating both `task` and `updated_at` is enough — we
3679
3692
  // leave `status` to the agent (idle → working → idle).
3680
- const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
3681
- let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
3693
+ const touchParams: any[] = [sessionTaskPreview(body.task), targetAlias];
3694
+ let touchSql = `UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE alias = ?2`;
3682
3695
  if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
3683
3696
  db.run(touchSql, touchParams);
3684
3697
  });
@@ -4468,6 +4481,7 @@ return Bun.serve({
4468
4481
  // would silently start dimming every daemon that hasn't
4469
4482
  // upgraded past preview.55.
4470
4483
  let snapCanCreate: boolean | undefined;
4484
+ let snapAdoptCapable: boolean | undefined;
4471
4485
  let snapBlockedReason: string | undefined;
4472
4486
  // #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
4473
4487
  // 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
@@ -4481,6 +4495,7 @@ return Bun.serve({
4481
4495
  const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
4482
4496
  snapRole = typeof parsed?.role === "string" ? parsed.role : null;
4483
4497
  const caps = parsed?.daemon_capabilities;
4498
+ if (typeof caps?.adopt_capable === "boolean") snapAdoptCapable = caps.adopt_capable;
4484
4499
  if (caps && typeof caps.can_create_nodes === "boolean") {
4485
4500
  snapCanCreate = caps.can_create_nodes;
4486
4501
  }
@@ -4498,10 +4513,10 @@ return Bun.serve({
4498
4513
  } catch { /* malformed */ }
4499
4514
  }
4500
4515
  return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
4501
- observedMsAgo: snapObservedMsAgo, readiness: snapReadiness };
4516
+ observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable };
4502
4517
  })
4503
4518
  .filter(({ role }) => role === "host_supervisor")
4504
- .map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness }) => {
4519
+ .map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable }) => {
4505
4520
  let online = false;
4506
4521
  let lastSeenAt: string | null = null;
4507
4522
  if (r.session_last_seen) {
@@ -4541,6 +4556,12 @@ return Bun.serve({
4541
4556
  allowed_secret_keys: secrets,
4542
4557
  host_telemetry: telemetry,
4543
4558
  };
4559
+ if (lifecycleUser && typeof adoptCapable === "boolean") {
4560
+ // Do not reveal even the new capability for a node the user cannot read.
4561
+ const visibleParams: any[] = [r.node_id];
4562
+ const visibleSql = addAgentNetworkScope("SELECT node_id FROM nodes WHERE node_id=?1", visibleParams, restScope, { alias: "alias", nodeId: "node_id" });
4563
+ if (db.get(visibleSql, ...visibleParams)) out.adopt_capable = adoptCapable;
4564
+ }
4544
4565
  // #1353 Fix ② — only emit the two capability keys when the
4545
4566
  // daemon actually reported them. Undefined vs. false is a
4546
4567
  // real distinction here (see Fix ② comment above).
@@ -4657,6 +4678,7 @@ return Bun.serve({
4657
4678
  const role = netRoles.get(r.network_id);
4658
4679
  return role === "owner" || role === "admin";
4659
4680
  };
4681
+ const management = lifecycleUser ? lifecycleProjections(restScope) : null;
4660
4682
  const rows = rawRows.map(r => {
4661
4683
  let role: string | null = null;
4662
4684
  const snap = r.config_snapshot;
@@ -4680,6 +4702,7 @@ return Bun.serve({
4680
4702
  role,
4681
4703
  lifecycle_controllable,
4682
4704
  lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
4705
+ ...(management ? (management.get(r.node_id) ?? { managed: "none", adoption: null }) : {}),
4683
4706
  viewer_can: { permission_mode: canSetPermissionMode(r) },
4684
4707
  };
4685
4708
  });
@@ -0,0 +1,14 @@
1
+ // Board #668. A dispatch preview is what the hub shows before the node
2
+ // reports. Once the session is already working, that text is the task in
3
+ // flight — a newer message waits in the inbox and must not replace it.
4
+ // Idle and every other status still take the preview.
5
+
6
+ const PLACEHOLDER = /^\?\d+$/;
7
+
8
+ /** SQL expression for `sessions.task` on dispatch. `placeholder` is a `?N` bind. */
9
+ export function sessionTaskOnDispatch(placeholder: string): string {
10
+ if (!PLACEHOLDER.test(placeholder)) {
11
+ throw new Error("sessionTaskOnDispatch placeholder must be a ?N bind");
12
+ }
13
+ return `CASE WHEN status = 'working' THEN task ELSE ${placeholder} END`;
14
+ }
@@ -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
+ ];
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { SESSION_TASK_PREVIEW_MAX, TASK_CONTENT_MAX, sessionTaskPreview } from "./task-content-limit.js";
5
+
6
+ // Board #668 — the node reports the whole task so the hub can match it
7
+ // against tasks.content. Both sides must agree on the cap.
8
+ const HUB_PATH = join(import.meta.dir, "task-content-limit.ts");
9
+ const NODE_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "task-content-limit.ts");
10
+
11
+ describe("#668 — hub/node task-content-limit drift guard", () => {
12
+ test("byte-identical source files", () => {
13
+ expect(readFileSync(NODE_PATH, "utf-8")).toBe(readFileSync(HUB_PATH, "utf-8"));
14
+ });
15
+
16
+ test("constants equal at runtime", async () => {
17
+ const node = await import(NODE_PATH);
18
+ expect(node.TASK_CONTENT_MAX).toBe(TASK_CONTENT_MAX);
19
+ expect(node.SESSION_TASK_PREVIEW_MAX).toBe(SESSION_TASK_PREVIEW_MAX);
20
+ });
21
+
22
+ test("preview is the first 200 chars", () => {
23
+ expect(SESSION_TASK_PREVIEW_MAX).toBe(200);
24
+ const long = "字".repeat(TASK_CONTENT_MAX);
25
+ expect(sessionTaskPreview(long)).toBe(long.slice(0, 200));
26
+ expect(sessionTaskPreview("short")).toBe("short");
27
+ });
28
+ });
@@ -0,0 +1,25 @@
1
+ // Board #668 — task text limits shared by hub and node.
2
+ //
3
+ // This file is byte-identical in server/src/shared/task-content-limit.ts and
4
+ // agent-node/src/shared/task-content-limit.ts (task-content-limit-drift.test.ts
5
+ // fails the build on any difference).
6
+ //
7
+ // TASK_CONTENT_MAX is the cap every dispatch entry (send_task, REST /api/task,
8
+ // scheduled tasks) and report_status.task accept. The node reports the whole
9
+ // task text so the hub can match it against tasks.content and write
10
+ // started_at. If the dispatch cap grew without the node and report_status
11
+ // following, long tasks would silently stop matching again.
12
+ //
13
+ // SESSION_TASK_PREVIEW_MAX is what the hub keeps in sessions.task. That column
14
+ // is readable by get_all_status, the full /api/status and members who may see
15
+ // the agent but not its conversations, so only a short preview is stored —
16
+ // the full text is used for matching, never persisted on the session row.
17
+
18
+ export const TASK_CONTENT_MAX = 10_000;
19
+
20
+ export const SESSION_TASK_PREVIEW_MAX = 200;
21
+
22
+ /** The preview stored in sessions.task — same cut as the dispatch preview. */
23
+ export function sessionTaskPreview(task: string): string {
24
+ return task.slice(0, SESSION_TASK_PREVIEW_MAX);
25
+ }
package/src/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
3
3
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
4
+ import { lockNodeAlias } from "./node-token-ownership.js";
4
5
  import { parseDbTimestampMs } from "./db-timestamp.js";
5
6
  import { z } from "zod/v4";
6
7
  import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
@@ -9,6 +10,8 @@ import { noteModelAuthHealth } from "./model-auth-notify.js";
9
10
  import { numberedAliasInClause, parseAliasFilter } from "./alias-filter.js";
10
11
  import { createHash } from "node:crypto";
11
12
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
13
+ import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
14
+ import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
12
15
  import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
13
16
  import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
14
17
  import { assertNodeActive } from "./lifecycle-guard.js";
@@ -680,7 +683,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
680
683
  resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
681
684
  alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
682
685
  status: z.enum(["working", "idle", "blocked", "error", "waiting_input", "offline"]),
683
- task: z.string().max(10000).optional().describe("Current task description"),
686
+ task: z.string().max(TASK_CONTENT_MAX).optional().describe("Current task description (the hub stores only the first 200 chars on the session row; the full text is matched against tasks.content)"),
684
687
  output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
685
688
  score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
686
689
  progress: z.number().min(0).max(100).optional().describe("Progress 0-100"),
@@ -1048,6 +1051,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1048
1051
  ) ?? null;
1049
1052
  const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
1050
1053
  (fresh ?? (handover?.[column] as T | null | undefined) ?? null);
1054
+ // #668 — the node reports the whole task so the tasks UPDATE below can match
1055
+ // tasks.content. sessions.task is readable far wider than the task row
1056
+ // (get_all_status, full /api/status, members who see the agent but not its
1057
+ // conversations), so only the dispatch-sized preview is stored here.
1058
+ const sessionTask = task == null ? null : sessionTaskPreview(task); // board668-session-preview
1051
1059
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
1052
1060
  db.run(
1053
1061
  `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, external_schedules, peer_reply_inbox_capable, last_seen_at, updated_at)
@@ -1079,7 +1087,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1079
1087
  external_schedules = COALESCE(?34, sessions.external_schedules),
1080
1088
  peer_reply_inbox_capable = ?35,
1081
1089
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
1082
- [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
1090
+ [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, sessionTask, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
1083
1091
  );
1084
1092
  if (handover) {
1085
1093
  // 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
@@ -1739,7 +1747,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1739
1747
  "Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead (open tasks already on the target), target_busy, est_wait_minutes (rough, null = no history) and, when the queue is long, a warning: queued, never refused — consider an idle node.",
1740
1748
  {
1741
1749
  alias: z.string().min(1).max(200).describe("Target session alias"),
1742
- task: z.string().min(1).max(10000).describe("Task content"),
1750
+ task: z.string().min(1).max(TASK_CONTENT_MAX).describe("Task content"),
1743
1751
  priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
1744
1752
  context: z.string().max(10000).optional(),
1745
1753
  from_session: z.string().max(200).optional(),
@@ -1771,8 +1779,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1771
1779
  }
1772
1780
 
1773
1781
  // Resolve parent_task_id: explicit > inferred (caller's most recent
1774
- // delivered/started inbox task that's still open). Inference is the
1775
- // safety net for when the LLM forgets to pass parent_task_id.
1782
+ // delivered inbox task). Inference is the safety net for when the LLM
1783
+ // forgets to pass parent_task_id.
1784
+ //
1785
+ // #668 — only 'delivered'. 'started' was never a tasks status. A task
1786
+ // the caller has acked or is running is NOT inferred: an unrelated
1787
+ // dispatch without parent_task_id would hang under it, the child's
1788
+ // reply would close it early (chainReplyToParent), and the node's own
1789
+ // reply would then be refused as reply_task_terminal.
1776
1790
  //
1777
1791
  // round5 F1 fix: the inference SELECT MUST be network-scoped. Without
1778
1792
  // it, a caller in network B can pick up the parent_task_id of a
@@ -1782,7 +1796,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1782
1796
  if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
1783
1797
  try {
1784
1798
  const recentParams: any[] = [from_session];
1785
- let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered','started')";
1799
+ let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered')"; // board668-parent-delivered-only
1786
1800
  recentSql = addScope(recentSql, recentParams, effectiveNetId);
1787
1801
  recentSql += " ORDER BY created_at DESC LIMIT 1";
1788
1802
  const recent = db.get<{ task_id: string }>(recentSql, ...recentParams);
@@ -1930,8 +1944,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1930
1944
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
1931
1945
  [id, fromNodeId, from_session, targetNodeId, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
1932
1946
  );
1933
- const touchParams: any[] = [task.slice(0, 200), targetAlias];
1934
- let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1947
+ const touchParams: any[] = [sessionTaskPreview(task), targetAlias];
1948
+ let touchSql = `UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE alias = ?2`;
1935
1949
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
1936
1950
  db.run(touchSql, touchParams);
1937
1951
  // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
@@ -4108,7 +4122,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4108
4122
  {
4109
4123
  daemon_node_id: z.string().min(1).max(200),
4110
4124
  node_spec: z.object({
4111
- name: z.string().min(1).max(64),
4125
+ // #652 — 64 *code points* after trim is enforced by validateChildName (shared/node-name.ts);
4126
+ // zod's max counts UTF-16 units and surrounding whitespace, so it is only a coarse bound here.
4127
+ name: z.string().min(1).max(256),
4112
4128
  runtime: z.string().min(1).max(64),
4113
4129
  model: z.string().min(1).max(100).optional().nullable(),
4114
4130
  flags: z.record(z.string(), z.unknown()).optional(),
@@ -4257,7 +4273,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4257
4273
  // flag injection at the hub edge). Daemon repeats this; double
4258
4274
  // layer per RFC §4.2.2.
4259
4275
  try {
4260
- validateChildName(node_spec.name);
4276
+ // #652 — store/forward the trimmed name the validator returns.
4277
+ node_spec.name = validateChildName(node_spec.name);
4261
4278
  validateRuntime(node_spec.runtime);
4262
4279
  validateModel(node_spec.model);
4263
4280
  validateChannelsP1((node_spec as any).channels);
@@ -4350,7 +4367,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4350
4367
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
4351
4368
  }
4352
4369
  db.run(
4353
- `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
4370
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2)`,
4354
4371
  [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
4355
4372
  );
4356
4373
 
@@ -6266,6 +6283,13 @@ export function trustedConfigSnapshotForNode(
6266
6283
  }
6267
6284
 
6268
6285
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
6286
+ return db.transaction(() => {
6287
+ if (input.callerNetworkId && input.alias) lockNodeAlias(input.callerNetworkId, input.alias, input.node_id ?? undefined);
6288
+ return upsertNodeIdentity(input);
6289
+ });
6290
+ }
6291
+
6292
+ function upsertNodeIdentity(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
6269
6293
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
6270
6294
  const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null; config_path: string | null }>(
6271
6295
  "SELECT network_id, owner_user_id, alias, config_path FROM nodes WHERE node_id = ?1",
@@ -6277,8 +6301,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6277
6301
  // report only that exact node. Legacy unbound ntok rows remain compatible,
6278
6302
  // but they can never consume owner-gated schedule intents.
6279
6303
  if (input.callerTokenId) {
6280
- const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
6281
- "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
6304
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null; node_identity_epoch: number; name: string }>(
6305
+ "SELECT bound_node_id, user_id, network_id, node_identity_epoch, name FROM api_tokens WHERE token_id = ?1",
6282
6306
  input.callerTokenId,
6283
6307
  );
6284
6308
  if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
@@ -6287,6 +6311,16 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6287
6311
  if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
6288
6312
  return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
6289
6313
  }
6314
+ // New name-only node tokens bind once, at their first registration. General
6315
+ // tokens renamed by report_status are deliberately NOT upgraded to epoch 2.
6316
+ if (token?.node_identity_epoch === 2 && !token.bound_node_id) {
6317
+ const conflict = db.get("SELECT node_id FROM nodes WHERE network_id=?1 AND alias=?2 AND node_id<>?3 LIMIT 1",
6318
+ callerNet, input.alias ?? "", input.node_id);
6319
+ if (conflict || token.name !== `node:${input.alias}` || token.user_id !== input.callerUserId || existing) {
6320
+ console.warn(`[commhub] 🚫 report_status node upsert refused (identity_mismatch): node_id=${input.node_id} first-binding conflict`);
6321
+ return { result: "refused", reason: "identity_mismatch", existingNet: existing?.network_id ?? null, callerNet };
6322
+ }
6323
+ }
6290
6324
  }
6291
6325
  if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
6292
6326
  return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
@@ -6351,6 +6385,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6351
6385
  callerNet ?? null,
6352
6386
  ],
6353
6387
  );
6388
+ if (input.callerTokenId) db.run(`UPDATE api_tokens SET bound_node_id=?1
6389
+ WHERE token_id=?2 AND node_identity_epoch=2 AND bound_node_id IS NULL`, [input.node_id, input.callerTokenId]);
6354
6390
  const trustedSnapshot = trustedConfigSnapshotForNode(
6355
6391
  input.config_snapshot,
6356
6392
  input.callerTokenId,