@sleep2agi/commhub-server 0.9.0-preview.110 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.110",
3
+ "version": "0.9.0-preview.111",
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",
package/src/auth.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, groupTx, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
+ import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
6
7
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
7
8
  import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
8
9
 
@@ -136,7 +137,7 @@ export function register(username: string, password: string, email?: string, dis
136
137
  const userToken = generateUserToken();
137
138
  const userTokenId = generateId("tok");
138
139
  db.run(
139
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
140
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
140
141
  [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
141
142
  );
142
143
 
@@ -144,7 +145,7 @@ export function register(username: string, password: string, email?: string, dis
144
145
  const networkToken = generateNetworkToken();
145
146
  const networkTokenId = generateId("tok");
146
147
  db.run(
147
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
148
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
148
149
  [networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
149
150
  );
150
151
 
@@ -216,7 +217,7 @@ export function login(username: string, password: string, client: SessionClientI
216
217
  const userToken = generateUserToken();
217
218
  const tokenId = generateId("tok");
218
219
  db.run(
219
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
220
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
220
221
  [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
221
222
  );
222
223
 
@@ -246,8 +247,9 @@ export function login(username: string, password: string, client: SessionClientI
246
247
  *
247
248
  * RFC-036: callers that provide nodeId atomically establish the immutable
248
249
  * node-owner binding before the plaintext token is returned. The 3-argument
249
- * legacy form remains available for old clients, but its token has no bound
250
- * node id and therefore cannot consume external-schedule edit intents.
250
+ * name-only form remains available: new identities bind on first registration,
251
+ * and a verified same-holder refresh binds to the existing unique node. This
252
+ * does not fill NULL row owners or grant external-schedule ownership.
251
253
  */
252
254
  export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
253
255
  // Verify user is a member of this network with write access
@@ -262,16 +264,18 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
262
264
  const tokenId = generateId("tok");
263
265
  try {
264
266
  db.transaction(() => {
267
+ nodeId = checkNodeTokenClaim(userId, networkId, nodeName, nodeId);
265
268
  if (nodeId) {
266
- const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
267
- "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
269
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null }>(
270
+ "SELECT network_id, owner_user_id, alias FROM nodes WHERE node_id = ?1",
268
271
  nodeId,
269
272
  );
270
273
  if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
274
+ if (existing?.alias && existing.alias !== nodeName) throw new Error("node_owner_mismatch");
271
275
  // Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
272
276
  // refresh must not turn knowledge of a legacy node_id into ownership.
273
- // They remain read-only until a separately authorized migration.
274
- if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
277
+ // A same-holder refresh binds the token, without filling the row owner.
278
+ if (existing && !existing.owner_user_id && !legacyNodeHolder(userId, networkId, nodeName, nodeId)) throw new Error("node_owner_unclaimed");
275
279
  if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
276
280
  db.run(
277
281
  `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
@@ -280,7 +284,7 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
280
284
  node_name = COALESCE(nodes.node_name, ?2),
281
285
  alias = COALESCE(nodes.alias, ?2),
282
286
  network_id = COALESCE(nodes.network_id, ?3),
283
- owner_user_id = COALESCE(nodes.owner_user_id, ?4),
287
+ owner_user_id = nodes.owner_user_id,
284
288
  updated_at = datetime('now')`,
285
289
  [nodeId, nodeName, networkId, userId],
286
290
  );
@@ -293,7 +297,7 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
293
297
  }
294
298
  }
295
299
  db.run(
296
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
300
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6, 2)",
297
301
  [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
298
302
  );
299
303
  });
@@ -505,7 +509,7 @@ export function createToken(userId: string, name: string, networkId?: string): {
505
509
  const token = generateToken();
506
510
  const tokenId = generateId("tok");
507
511
  db.run(
508
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
512
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
509
513
  [tokenId, hashToken(token), userId, networkId || null, name, "full"]
510
514
  );
511
515
  return { ok: true, token, token_id: tokenId };
@@ -520,7 +524,7 @@ export function issueUserToken(userId: string, name = "user-login"): { token: st
520
524
  const token = generateUserToken();
521
525
  const tokenId = generateId("tok");
522
526
  db.run(
523
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, NULL, ?4, 'user')",
527
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', 1)",
524
528
  [tokenId, hashToken(token), userId, name]
525
529
  );
526
530
  return { token, token_id: tokenId };
@@ -748,7 +752,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
748
752
  // Auto-create a token for this network
749
753
  const token = generateToken();
750
754
  const tokenId = generateId("tok");
751
- db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
755
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
752
756
  [tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
753
757
  return { ok: true, network_id: invite.network_id, role: invite.role };
754
758
  }
@@ -243,8 +243,8 @@ export function resolveCallerDaemonTokenBound(opts: {
243
243
  if (!opts.enforceNetworkId) {
244
244
  return { ok: false, error: "caller_not_a_daemon" };
245
245
  }
246
- const tokRow = db.get<{ name: string; network_id: string | null }>(
247
- `SELECT name, network_id FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
246
+ const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
247
+ `SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
248
248
  opts.callerTokenId,
249
249
  );
250
250
  if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
@@ -254,13 +254,20 @@ export function resolveCallerDaemonTokenBound(opts: {
254
254
  return { ok: false, error: "caller_not_a_daemon" };
255
255
  }
256
256
  const tokenAlias = tokRow.name.slice(5);
257
- const nodeRow = db.get<{ node_id: string; alias: string; network_id: string }>(
258
- `SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1`,
259
- tokenAlias, tokRow.network_id,
260
- );
257
+ type DaemonRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
258
+ const rows = tokRow.bound_node_id
259
+ ? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
260
+ : db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
261
+ const nodeRow = rows.length === 1 ? rows[0] : undefined;
261
262
  if (!nodeRow) {
262
263
  return { ok: false, error: "caller_not_a_daemon" };
263
264
  }
265
+ // Legacy daemon tokens have no bound_node_id: retain their owner's authority.
266
+ // A token name is a lookup hint, never proof of authority over that node.
267
+ const ownsNode = !!tokRow.user_id && tokRow.user_id === nodeRow.owner_user_id;
268
+ const boundToNode = tokRow.bound_node_id === nodeRow.node_id;
269
+ const legacyOwnerless = nodeRow.owner_user_id === null && tokRow.node_identity_epoch === 0;
270
+ if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false, error: "caller_not_a_daemon" };
264
271
  return {
265
272
  ok: true,
266
273
  daemonNodeId: nodeRow.node_id,
@@ -0,0 +1,226 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
3
+ import { nodeTokenReissueError } from "../../agent-network/src/cli-errors";
4
+
5
+ process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/owner678-")}/hub.db`;
6
+ let db: any, hub: ReturnType<typeof Bun.serve>, base: string;
7
+ let owner: any, member: any, outsider: any, legacy: string, mismatched: string, bound: string;
8
+ let wrongBound: string, ownerless: string, revoked: string, crossNetwork: string, requestId: string;
9
+ const daemon = "n_owner_fixture_daemon", child = "n_owner_fixture_child";
10
+
11
+ async function call(token: string, name: string, args: Record<string, unknown> = {}) {
12
+ const response = await fetch(`${base}/mcp`, {
13
+ method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json",
14
+ Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
15
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
16
+ });
17
+ expect(response.status).toBe(200);
18
+ const raw = await response.text(), lines = raw.split("\n").filter(line => line.startsWith("data:"));
19
+ const message = JSON.parse(lines.length ? lines.at(-1)!.slice(5) : raw);
20
+ expect(message.error).toBeUndefined();
21
+ return JSON.parse(message.result.content[0].text);
22
+ }
23
+ async function mint(token: string, network: string, name: string, nodeId?: string) {
24
+ const response = await fetch(`${base}/api/auth/node-token`, { method: "POST",
25
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
26
+ body: JSON.stringify({ network_id: network, node_name: name, ...(nodeId ? { node_id: nodeId } : {}) }),
27
+ });
28
+ return { status: response.status, body: await response.json() as any };
29
+ }
30
+ beforeAll(async () => {
31
+ ({ db } = await import("./db.js"));
32
+ const { register } = await import("./auth.js");
33
+ const { generateNetworkToken, hashToken } = await import("./db.js");
34
+ owner = register("owner678", "Fixture-Strong1!");
35
+ member = register("member678", "Fixture-Strong1!");
36
+ outsider = register("outside678", "Fixture-Strong1!");
37
+ db.run("UPDATE users SET role='user' WHERE user_id IN (?1,?2)", [member.user.user_id, outsider.user.user_id]);
38
+ db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, member.user.user_id]);
39
+ for (const id of [daemon, child, "n_ownerless_fixture"]) {
40
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state)
41
+ VALUES(?1,?1,?1,?2,?3,'fixture-host',?4,'active')`, [id, owner.network_id,
42
+ id === "n_ownerless_fixture" ? null : owner.user.user_id,
43
+ JSON.stringify(id === daemon ? { role: "host_supervisor", daemon_capabilities: { adopt_capable: true } } : {})]);
44
+ }
45
+ db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES(?1,?1,?1,?2,'idle',datetime('now'))", [daemon, owner.network_id]);
46
+ function credential(label: string, user: string, node: string | null = null, alias = daemon, network = owner.network_id) {
47
+ const token = generateNetworkToken();
48
+ db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,bound_node_id)
49
+ VALUES(?1,?2,?3,'network',?4,?5,?6)`, [label, user, network, `node:${alias}`, hashToken(token), node]);
50
+ return token;
51
+ }
52
+ legacy = credential("owner678_legacy", owner.user.user_id);
53
+ mismatched = credential("owner678_mismatch", member.user.user_id);
54
+ bound = credential("owner678_bound", member.user.user_id, daemon);
55
+ wrongBound = credential("owner678_wrong_bound", member.user.user_id, "n_absent_bound");
56
+ ownerless = credential("owner678_ownerless", member.user.user_id, null, "n_ownerless_fixture");
57
+ db.run("UPDATE api_tokens SET node_identity_epoch=1 WHERE token_id='owner678_ownerless'");
58
+ revoked = credential("owner678_revoked", owner.user.user_id);
59
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id='owner678_revoked'");
60
+ crossNetwork = credential("owner678_cross", outsider.user.user_id, daemon, daemon, outsider.network_id);
61
+ const { bootServer } = await import("./server.js");
62
+ hub = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${hub.port}`;
63
+ const request = await call(owner.token, "request_adopt_node", { node_id: child, daemon_node_id: daemon, workdir: "/fixture/project" });
64
+ expect(request.ok).toBe(true); requestId = request.request_id;
65
+ }, 60000);
66
+ afterAll(() => hub?.stop(true));
67
+
68
+ test("name-only issuance checks existing ownership atomically", async () => {
69
+ const count = () => db.get("SELECT COUNT(*) AS n FROM api_tokens").n;
70
+ const before = count();
71
+ expect(await mint(member.token, owner.network_id, daemon)).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
72
+ expect(await mint(owner.token, owner.network_id, "n_ownerless_fixture")).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
73
+ expect(count()).toBe(before);
74
+ // Legacy owner refresh and a new node name remain supported.
75
+ const refresh = await mint(owner.token, owner.network_id, daemon);
76
+ expect(refresh.status).toBe(200);
77
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
78
+ expect((await mint(member.token, owner.network_id, "fresh678")).status).toBe(200);
79
+ expect((await mint(member.token, owner.network_id, daemon, daemon)).body.error).toBe("node_owner_mismatch");
80
+ });
81
+
82
+ // Every daemon-facing handler must fail at the shared identity boundary,
83
+ // not incidentally at request lookup or argument validation.
84
+ const endpoints: [string, Record<string, unknown>][] = [
85
+ ["list_my_pending_create_requests", {}], ["list_my_pending_lifecycle_requests", {}],
86
+ ["get_create_request", { request_id: "cr_fixture_absent" }],
87
+ ["ack_create_request", { request_id: "cr_fixture_absent", status: "started" }],
88
+ ["get_stop_request", { request_id: "sr_fixture_absent" }],
89
+ ["ack_stop_request", { request_id: "sr_fixture_absent", status: "stopped" }],
90
+ ["get_start_request", { request_id: "start_fixture_absent" }],
91
+ ["ack_start_request", { request_id: "start_fixture_absent", status: "started" }],
92
+ ["list_my_children", {}], ["get_adopt_request", {}], ["ack_adopt_request", { status: "adopted" }],
93
+ ["get_probe_request", { probe_id: "probe_fixture_absent" }],
94
+ ["ack_probe_request", { probe_id: "probe_fixture_absent", status: "ok", latency_ms: 1 }],
95
+ ];
96
+ for (const [name, arguments_] of endpoints) {
97
+ test(`daemon identity boundary: ${name}`, async () => {
98
+ const args = name.includes("adopt_request") ? { ...arguments_, request_id: requestId } : arguments_;
99
+ for (const token of [mismatched, wrongBound, ownerless, owner.token, member.token]) {
100
+ expect(await call(token, name, args)).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
101
+ }
102
+ expect(db.get("SELECT status FROM node_daemon_bindings WHERE request_id=?1", requestId).status).toBe("pending");
103
+ });
104
+ }
105
+
106
+ test("legacy owner and exact bound identity remain supported", async () => {
107
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id='owner678_legacy'").bound_node_id).toBeNull();
108
+ for (const token of [legacy, bound]) {
109
+ expect((await call(token, "list_my_children")).ok).toBe(true);
110
+ expect((await call(token, "list_my_pending_create_requests")).ok).toBe(true);
111
+ expect((await call(token, "list_my_pending_lifecycle_requests")).ok).toBe(true);
112
+ expect((await call(token, "get_adopt_request", { request_id: requestId })).ok).toBe(true);
113
+ }
114
+ // An explicit binding does not bypass the network or revocation boundaries.
115
+ expect(await call(crossNetwork, "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
116
+ const denied = await fetch(`${base}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${revoked}` } });
117
+ expect(denied.status).toBe(401);
118
+ expect(await call(legacy, "ack_adopt_request", { request_id: requestId, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
119
+ expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted" });
120
+ const stop = await call(owner.token, "stop_node", { node_id: child, network_id: owner.network_id });
121
+ expect(stop.ok).toBe(true);
122
+ expect((await call(legacy, "get_stop_request", { request_id: stop.request_id })).ok).toBe(true);
123
+ expect((await call(legacy, "ack_stop_request", { request_id: stop.request_id, status: "stopped" })).ok).toBe(true);
124
+ const start = await call(owner.token, "start_node", { node_id: child, network_id: owner.network_id });
125
+ expect(start.ok).toBe(true);
126
+ expect((await call(legacy, "get_start_request", { request_id: start.request_id })).ok).toBe(true);
127
+ expect(await call(legacy, "ack_start_request", { request_id: start.request_id, status: "started", child_pid: 4321 })).toMatchObject({ ok: true, status: "started" });
128
+ });
129
+
130
+ test("ownerless legacy daemon remains supported without rewriting its owner", async () => {
131
+ db.run("UPDATE api_tokens SET node_identity_epoch=0 WHERE token_id='owner678_ownerless'");
132
+ try {
133
+ expect((await call(ownerless, "list_my_children")).ok).toBe(true);
134
+ const refresh = await mint(member.token, owner.network_id, "n_ownerless_fixture");
135
+ expect(refresh.status).toBe(200);
136
+ expect(refresh.body.node_id).toBe("n_ownerless_fixture");
137
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
138
+ const withId = await mint(member.token, owner.network_id, "n_ownerless_fixture", "n_ownerless_fixture");
139
+ expect(withId.status).toBe(200);
140
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id='n_ownerless_fixture'").owner_user_id).toBeNull();
141
+ expect((await mint(owner.token, owner.network_id, "n_ownerless_fixture")).status).toBe(400);
142
+ } finally { db.run("UPDATE api_tokens SET node_identity_epoch=1 WHERE token_id='owner678_ownerless'"); }
143
+ });
144
+
145
+ test("new name-only registration binds once and its holder can refresh with either shape", async () => {
146
+ const first = await mint(member.token, owner.network_id, "refresh_fixture");
147
+ expect(first.status).toBe(200);
148
+ expect(db.get("SELECT node_identity_epoch FROM api_tokens WHERE token_id=?1", first.body.token_id).node_identity_epoch).toBe(2);
149
+ const result = await call(first.body.token, "report_status", {
150
+ resume_id: "refresh-fixture-session", alias: "refresh_fixture", status: "idle", node_id: "n_refresh_fixture",
151
+ });
152
+ expect(result.ok).toBe(true);
153
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", first.body.token_id).bound_node_id).toBe("n_refresh_fixture");
154
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id='n_refresh_fixture'").owner_user_id).toBeNull();
155
+ for (const id of [undefined, "n_refresh_fixture"]) {
156
+ const refresh = await mint(member.token, owner.network_id, "refresh_fixture", id);
157
+ expect(refresh.status).toBe(200);
158
+ expect(refresh.body.node_id).toBe("n_refresh_fixture");
159
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
160
+ }
161
+ expect((await mint(owner.token, owner.network_id, "refresh_fixture")).status).toBe(400);
162
+ });
163
+
164
+ test("fresh ID cannot mint another node alias", async () => {
165
+ const before = db.get("SELECT COUNT(*) AS n FROM api_tokens").n;
166
+ expect((await mint(member.token, owner.network_id, daemon, "n_fresh_impostor")).status).toBe(400);
167
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id='n_fresh_impostor'")).toBeNull();
168
+ expect(db.get("SELECT COUNT(*) AS n FROM api_tokens").n).toBe(before);
169
+ expect((await call(legacy, "list_my_children")).ok).toBe(true);
170
+ });
171
+
172
+ test("duplicate legacy aliases use bound IDs and unbound ambiguity fails closed", async () => {
173
+ // Historical duplicate rows: the member's row was inserted first. Neither
174
+ // lookup order nor alias can redirect the later owner's exact-bound token.
175
+ const { hashToken, generateNetworkToken } = await import("./db.js");
176
+ const tokens: string[] = [];
177
+ for (const [id, user] of [["n_duplicate_first", member.user.user_id], ["n_duplicate_owner", owner.user.user_id]]) {
178
+ db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id) VALUES(?1,'duplicate_fixture','duplicate_fixture',?2,?3)", [id, owner.network_id, user]);
179
+ const token = generateNetworkToken(); tokens.push(token);
180
+ db.run("INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,name,scope,bound_node_id) VALUES(?1,?2,?3,?4,'node:duplicate_fixture','network',?1)", [id, hashToken(token), user, owner.network_id]);
181
+ db.run("INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES(?1,?2,?1,?1,'/fixture',?3,'pending',1,1)", [id, owner.network_id, user]);
182
+ }
183
+ for (let i = 0; i < tokens.length; i++) {
184
+ const result = await call(tokens[i], "list_my_children");
185
+ expect(result.ok).toBe(true);
186
+ const ownId = i === 0 ? "n_duplicate_first" : "n_duplicate_owner";
187
+ expect((await call(tokens[i], "get_adopt_request", { request_id: ownId })).ok).toBe(true);
188
+ expect((await call(tokens[i], "get_adopt_request", { request_id: i === 0 ? "n_duplicate_owner" : "n_duplicate_first" })).ok).toBe(false);
189
+ }
190
+ db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_owner'");
191
+ expect(await call(tokens[1], "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
192
+ db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_first'");
193
+ expect(await call(tokens[0], "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
194
+ expect((await mint(owner.token, owner.network_id, "duplicate_fixture", "n_third_duplicate")).status).toBe(400);
195
+ });
196
+
197
+ test("all production token issuers explicitly mark the rollout epoch", () => {
198
+ const root = `${import.meta.dir}/../..`;
199
+ function sources(dir: string): string[] {
200
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
201
+ const path = `${dir}/${entry.name}`;
202
+ return entry.isDirectory() ? sources(path) : entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [path] : [];
203
+ });
204
+ }
205
+ const sourceFiles = ["server/src", "agent-network/src", "agent-network/bin", "agent-node/src"].flatMap(dir => sources(`${root}/${dir}`));
206
+ let count = 0;
207
+ for (const file of sourceFiles) {
208
+ const source = readFileSync(file, "utf8");
209
+ for (const match of source.matchAll(/INSERT\s+INTO\s+api_tokens\s*\(([^)]+)\)/gi)) {
210
+ expect(match[1]).toContain("node_identity_epoch"); count++;
211
+ }
212
+ }
213
+ expect(count).toBe(9);
214
+ expect(readFileSync(`${root}/agent-network/bin/cli.ts`, "utf8")).toContain("'admin-reset', 'user', 1)");
215
+ });
216
+
217
+ test("revoked ownerless credential cannot be reclaimed by doctor and gives actionable guidance", async () => {
218
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE name='node:n_ownerless_fixture'");
219
+ const result = await mint(member.token, owner.network_id, "n_ownerless_fixture");
220
+ expect(result.status).toBe(400);
221
+ expect(result.body.error).toBe("node_owner_mismatch");
222
+ expect(nodeTokenReissueError(result.body)).toContain("这个节点没有归属,请联系管理员认领(#682)");
223
+ expect(nodeTokenReissueError({ error: "node_owner_unclaimed" })).toContain("请联系管理员认领(#682)");
224
+ expect(nodeTokenReissueError({ error: "network_not_found" })).toBe("network_not_found");
225
+ expect(readFileSync(`${import.meta.dir}/../../agent-network/bin/cli.ts`, "utf8")).toContain("body ? nodeTokenReissueError(body) : r.status");
226
+ });
package/src/db.ts CHANGED
@@ -640,6 +640,9 @@ for (const ddl of [
640
640
  "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
641
641
  "ALTER TABLE api_tokens ADD COLUMN role TEXT",
642
642
  "ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
643
+ // Existing tokens retain epoch 0. Every production issuer writes 1 (general)
644
+ // or 2 (ownership-checked node token); 0 is a rollout boundary, not a clock.
645
+ "ALTER TABLE api_tokens ADD COLUMN node_identity_epoch INTEGER NOT NULL DEFAULT 0",
643
646
  // 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
644
647
  "ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
645
648
  "ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
@@ -59,8 +59,9 @@ test("public errors redact free text for granted viewers on every projection", a
59
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
60
  "bin: ENOENT: /private/fixture/bin/anet on fixture-host",
61
61
  "adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
62
+ "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
62
63
  ]) {
63
- const expected = error === null || error === "adopt_start_timeout" ? error : "lifecycle_error";
64
+ const expected = error === null || ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed"].includes(error) ? error : "lifecycle_error";
64
65
  for (const [kind, table, request] of [
65
66
  ["adopt", "node_daemon_bindings", "adopt_read_active"],
66
67
  ["start", "node_start_requests", "start_read_failed"],
@@ -3,6 +3,7 @@ import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js"
3
3
 
4
4
  // Exact public codes only; daemon exception text must never reach node viewers.
5
5
  const publicLifecycleErrors = new Set([
6
+ "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
6
7
  "adopt_active_binding_required", "adopt_binding_unavailable",
7
8
  "adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
8
9
  "adopt_start_evidence_missing", "adopt_stop_evidence_missing",
@@ -0,0 +1,36 @@
1
+ import { db } from "./db.js";
2
+
3
+ // Call inside the caller's transaction. Serialize registration and issuance for
4
+ // this alias on PG; SQLite already serializes writes on its single connection.
5
+ export function lockNodeAlias(network: string, alias: string, nodeId?: string): void {
6
+ if (db.dialect !== "postgres") return;
7
+ db.get("SELECT pg_advisory_xact_lock(hashtext(?1))", JSON.stringify([network, "alias", alias]));
8
+ if (nodeId) db.get("SELECT pg_advisory_xact_lock(hashtext(?1))", JSON.stringify([network, "id", nodeId]));
9
+ }
10
+
11
+ export function legacyNodeHolder(user: string, network: string, alias: string, nodeId: string): boolean {
12
+ return !!db.get(`SELECT token_id FROM api_tokens WHERE user_id=?1 AND network_id=?2
13
+ AND name=?3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now'))
14
+ AND (bound_node_id=?4 OR (bound_node_id IS NULL AND node_identity_epoch=0)) LIMIT 1`,
15
+ user, network, `node:${alias}`, nodeId);
16
+ }
17
+
18
+ /** Resolve a name-only refresh to the existing identity, never claim its owner. */
19
+ export function checkNodeTokenClaim(user: string, network: string, alias: string, nodeId?: string): string | undefined {
20
+ lockNodeAlias(network, alias, nodeId);
21
+ const rows = db.all<{node_id: string; owner_user_id: string | null}>(
22
+ "SELECT node_id, owner_user_id FROM nodes WHERE network_id=?1 AND alias=?2", network, alias);
23
+ if (rows.length > 1 || rows.some(row => nodeId && row.node_id !== nodeId)) throw Error("node_owner_mismatch");
24
+ const row = rows[0];
25
+ // Explicit-ID ownerless claims retain auth.ts's node_owner_unclaimed error;
26
+ // name-only requests need holder evidence before resolving to that ID.
27
+ if (row && (row.owner_user_id ? row.owner_user_id !== user : !nodeId && !legacyNodeHolder(user, network, alias, row.node_id))) {
28
+ throw Error("node_owner_mismatch");
29
+ }
30
+ if (!row && db.get(`SELECT token_id FROM api_tokens WHERE network_id=?1 AND name=?2
31
+ AND user_id<>?3 AND node_identity_epoch=2 AND revoked_at IS NULL
32
+ AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1`, network, `node:${alias}`, user)) {
33
+ throw Error("node_owner_mismatch");
34
+ }
35
+ return nodeId ?? row?.node_id;
36
+ }
@@ -0,0 +1,132 @@
1
+ // #667 — a restart from the right directory must clear the cwd warning the
2
+ // previous process left on the same resume_id. Hub stores
3
+ // `task = COALESCE(?11, sessions.task)`, so omitting task keeps the warning.
4
+ // Register sends task: "" once. Later idle reports omit task, so the
5
+ // description of a finished task stays. This boots a throwaway hub on
6
+ // port 0 (never 9200).
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { reportedTask } from "../../agent-node/src/project-dir-mismatch.ts";
13
+
14
+ const DIR = mkdtempSync(join(tmpdir(), "anet-667-clear-"));
15
+ process.env.COMMHUB_DB = join(DIR, "hub.db");
16
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
17
+ process.env.HOST = "127.0.0.1";
18
+
19
+ const PW = "ProjectDirWarn123!x";
20
+ const stamp = Date.now();
21
+ const ROOT = "/work";
22
+ const NODE_DIR = "/work/.anet/nodes/demo-node";
23
+ const CONFIG = `${NODE_DIR}/config.json`;
24
+
25
+ let db: { get: (sql: string, ...params: unknown[]) => { task: string | null } | null };
26
+ let hub: { port: number; stop: (close?: boolean) => void } | null = null;
27
+ let BASE = "";
28
+ let NET = "";
29
+ let node: { alias: string; nodeId: string; token: string };
30
+
31
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
32
+ const res = await fetch(`${BASE}/mcp`, {
33
+ method: "POST",
34
+ headers: {
35
+ Authorization: `Bearer ${token}`,
36
+ "Content-Type": "application/json",
37
+ Accept: "application/json, text/event-stream",
38
+ "MCP-Protocol-Version": "2025-03-26",
39
+ },
40
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
41
+ });
42
+ const raw = await res.text();
43
+ const data = raw.split("\n").filter((line) => line.startsWith("data:"));
44
+ const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
45
+ return JSON.parse(payload.result.content[0].text);
46
+ }
47
+
48
+ function sessionTask(): string | null {
49
+ return db.get("SELECT task FROM sessions WHERE resume_id = ?1", `sdk-${node.nodeId}`)?.task ?? null;
50
+ }
51
+
52
+ beforeAll(async () => {
53
+ ({ db } = await import("./db.js"));
54
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
55
+ const boss = register(`cwd_boss_${stamp}`, PW);
56
+ expect(boss.ok).toBe(true);
57
+ NET = boss.network_id!;
58
+ const alias = `demo-node-${stamp}`;
59
+ const nodeId = `n_cwd_${stamp}`;
60
+ const minted = createNetworkTokenForNode(boss.user!.user_id, NET, alias, nodeId);
61
+ expect(minted.ok).toBe(true);
62
+ node = { alias, nodeId, token: minted.token! };
63
+ const mod = await import("./server.js");
64
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
65
+ expect(hub.port).not.toBe(9200);
66
+ expect(hub.port).toBeGreaterThan(0);
67
+ BASE = `http://127.0.0.1:${hub.port}`;
68
+ }, 30_000);
69
+
70
+ afterAll(() => {
71
+ try { hub?.stop(true); } catch { /* already stopped */ }
72
+ try { rmSync(DIR, { recursive: true, force: true }); } catch { /* temp dir */ }
73
+ });
74
+
75
+ describe("#667 restart from the workspace root clears the cwd warning", () => {
76
+ test("wrong directory writes the warning; the right directory sends \"\" and the hub row is empty", async () => {
77
+ const resume = `sdk-${node.nodeId}`;
78
+ const wrong = reportedTask({ configPath: CONFIG, cwd: NODE_DIR, inFlight: 0, status: "idle" });
79
+ expect(wrong).toContain("目录不一致");
80
+ const started = await tool(node.token, "report_status", {
81
+ resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: wrong,
82
+ });
83
+ expect(started.ok).toBe(true);
84
+ expect(sessionTask()).toBe(wrong);
85
+
86
+ const kept = await tool(node.token, "report_status", {
87
+ resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET,
88
+ });
89
+ expect(kept.ok).toBe(true);
90
+ expect(sessionTask()).toBe(wrong);
91
+
92
+ const idle = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" });
93
+ expect(idle).toBeUndefined();
94
+ const cleared = idle ?? "";
95
+ expect(cleared).toBe("");
96
+ const fixed = await tool(node.token, "report_status", {
97
+ resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: cleared,
98
+ });
99
+ expect(fixed.ok).toBe(true);
100
+ expect(sessionTask()).toBe("");
101
+ });
102
+
103
+ test("after startup, a finished task's description stays on the hub", async () => {
104
+ const resume = `sdk-${node.nodeId}`;
105
+ const description = "BOARD667-FINISHED-TASK";
106
+ const startup = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" }) ?? "";
107
+ expect(startup).toBe("");
108
+ const registered = await tool(node.token, "report_status", {
109
+ resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: startup,
110
+ });
111
+ expect(registered.ok).toBe(true);
112
+ expect(sessionTask()).toBe("");
113
+
114
+ const working = reportedTask({
115
+ configPath: CONFIG, cwd: ROOT, inFlight: 1, status: "working", task: description,
116
+ });
117
+ expect(working).toBe(description);
118
+ const ran = await tool(node.token, "report_status", {
119
+ resume_id: resume, alias: node.alias, status: "working", node_id: node.nodeId, network_id: NET, task: working,
120
+ });
121
+ expect(ran.ok).toBe(true);
122
+ expect(sessionTask()).toBe(description);
123
+
124
+ const finished = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" });
125
+ expect(finished).toBeUndefined();
126
+ const done = await tool(node.token, "report_status", {
127
+ resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET,
128
+ });
129
+ expect(done.ok).toBe(true);
130
+ expect(sessionTask()).toBe(description);
131
+ });
132
+ });
@@ -73,10 +73,21 @@ beforeAll(async () => {
73
73
  // 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
74
74
  db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
75
75
  NODE = `n_ev_${stamp}`;
76
- db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "ev-node", "ev-node", NET]);
77
76
  const ntok = createNetworkTokenForNode(admin.id, NET, "ev-node");
78
77
  expect(ntok.ok).toBe(true);
79
78
  nodeToken = ntok.token!;
79
+ // Real legacy-client order: mint by name, then register; never fill owner.
80
+ const registration = await fetch(`${BASE}/mcp`, {
81
+ method: "POST",
82
+ headers: { Authorization: `Bearer ${nodeToken}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
83
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: {
84
+ resume_id: `ev-session-${stamp}`, alias: "ev-node", status: "idle", node_id: NODE,
85
+ } } }),
86
+ });
87
+ expect(registration.status).toBe(200);
88
+ await registration.text();
89
+ expect(db.get<{owner_user_id: string | null}>("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE)?.owner_user_id).toBeNull();
90
+ expect(db.get<{bound_node_id: string | null}>("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id!)?.bound_node_id).toBe(NODE);
80
91
  }, 30_000);
81
92
 
82
93
  afterAll(() => {
@@ -92,10 +92,15 @@ beforeAll(async () => {
92
92
  // 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
93
93
  db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
94
94
  NODE = `n_le_${stamp}`;
95
- db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "le-node", "le-node", NET]);
96
95
  const ntok = createNetworkTokenForNode(admin.id, NET, "le-node");
97
96
  expect(ntok.ok).toBe(true);
98
97
  nodeToken = ntok.token!;
98
+ // Real legacy-client order, with no owner backfill or administrator claim.
99
+ expect((await mcp(nodeToken, "report_status", {
100
+ resume_id: `le-session-${stamp}`, alias: "le-node", status: "idle", node_id: NODE,
101
+ })).data.ok).toBe(true);
102
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE).owner_user_id).toBeNull();
103
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id).bound_node_id).toBe(NODE);
99
104
  }, 30_000);
100
105
 
101
106
  afterAll(() => {
@@ -1,4 +1,6 @@
1
1
  import { db, logTaskEvent } from "./db.js";
2
+ import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
3
+ import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
2
4
  import { assertNodeActive } from "./lifecycle-guard.js";
3
5
  import { assertNodeHealthy } from "./node-health-guard.js";
4
6
  import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
@@ -366,7 +368,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
366
368
  // Dispatch is not completion. Keep the run open until the exact bound
367
369
  // task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
368
370
  db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
369
- db.run("UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3", [row.task_content.slice(0, 200), node.node_id, row.network_id]);
371
+ db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(row.task_content), node.node_id, row.network_id]);
370
372
  db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
371
373
  if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
372
374
  createdTaskId = taskId;
@@ -517,7 +519,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
517
519
  const name = typeof body.name === "string" ? body.name.trim() : "";
518
520
  const content = typeof body.task === "string" ? body.task.trim() : "";
519
521
  if (!name || name.length > 120) throw new Error("invalid_name");
520
- if (!content || content.length > 10_000) throw new Error("invalid_task");
522
+ if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
521
523
  const priority = typeof body.priority === "string" ? body.priority : "normal";
522
524
  if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
523
525
  const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
@@ -604,7 +606,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
604
606
  const content = body.task === undefined ? row.task_content : String(body.task).trim();
605
607
  const priority = body.priority === undefined ? row.priority : String(body.priority);
606
608
  if (!name || name.length > 120) throw new Error("invalid_name");
607
- if (!content || content.length > 10_000) throw new Error("invalid_task");
609
+ if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
608
610
  if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
609
611
  const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
610
612
  const parsed = body.schedule === undefined
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";
@@ -793,7 +795,7 @@ function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T &
793
795
  // ── REST input schema ───────────────────────────────
794
796
  const TaskSchema = z.object({
795
797
  alias: z.string().min(1).max(200),
796
- task: z.string().min(1).max(10000),
798
+ task: z.string().min(1).max(TASK_CONTENT_MAX),
797
799
  priority: z.enum(["high", "normal", "low"]).default("normal"),
798
800
  from: z.string().max(200).optional(),
799
801
  network_id: z.string().max(200).optional(),
@@ -3688,8 +3690,8 @@ return Bun.serve({
3688
3690
  // immediately, without waiting for the agent's report_status to
3689
3691
  // arrive. Updating both `task` and `updated_at` is enough — we
3690
3692
  // leave `status` to the agent (idle → working → idle).
3691
- const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
3692
- 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`;
3693
3695
  if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
3694
3696
  db.run(touchSql, touchParams);
3695
3697
  });
@@ -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,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。
@@ -4353,7 +4367,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4353
4367
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
4354
4368
  }
4355
4369
  db.run(
4356
- `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)`,
4357
4371
  [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
4358
4372
  );
4359
4373
 
@@ -6269,6 +6283,13 @@ export function trustedConfigSnapshotForNode(
6269
6283
  }
6270
6284
 
6271
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 {
6272
6293
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
6273
6294
  const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null; config_path: string | null }>(
6274
6295
  "SELECT network_id, owner_user_id, alias, config_path FROM nodes WHERE node_id = ?1",
@@ -6280,8 +6301,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6280
6301
  // report only that exact node. Legacy unbound ntok rows remain compatible,
6281
6302
  // but they can never consume owner-gated schedule intents.
6282
6303
  if (input.callerTokenId) {
6283
- const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
6284
- "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",
6285
6306
  input.callerTokenId,
6286
6307
  );
6287
6308
  if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
@@ -6290,6 +6311,16 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6290
6311
  if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
6291
6312
  return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
6292
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
+ }
6293
6324
  }
6294
6325
  if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
6295
6326
  return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
@@ -6354,6 +6385,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6354
6385
  callerNet ?? null,
6355
6386
  ],
6356
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]);
6357
6390
  const trustedSnapshot = trustedConfigSnapshotForNode(
6358
6391
  input.config_snapshot,
6359
6392
  input.callerTokenId,