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

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/tools.ts CHANGED
@@ -267,7 +267,7 @@ function guardNodePermissionTools(server: McpServer, tokenId: string, networkId:
267
267
  }
268
268
  }
269
269
 
270
- export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, listing: { includeProtocol?: boolean } = {}) {
270
+ export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, listing: { includeProtocol?: boolean } = {}, callerIdentityRejected = false) {
271
271
  // 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
272
272
  // 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
273
273
  const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
@@ -278,9 +278,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
278
278
  // an agent's send_task call always claimed from='hub' and peer agents
279
279
  // couldn't tell who actually asked them. Network-bound node tokens are an
280
280
  // identity boundary: they must not spoof another node via from_session.
281
- const defaultFrom = (clientFrom?: string) => (callerTokenIsNetwork && callerAlias) ? callerAlias : (clientFrom || callerAlias || "hub");
281
+ const defaultFrom = (clientFrom?: string) => {
282
+ if (callerTokenIsNetwork && callerIdentityRejected) return "";
283
+ return (callerTokenIsNetwork && callerAlias) ? callerAlias : (clientFrom || callerAlias || "hub");
284
+ };
282
285
  const fromIdentityMismatchReply = (clientFrom?: string) => {
283
286
  const requestedFrom = clientFrom?.trim();
287
+ if (callerTokenIsNetwork && callerIdentityRejected) {
288
+ return {
289
+ content: [{
290
+ type: "text" as const,
291
+ text: JSON.stringify({
292
+ ok: false,
293
+ error: "from_session_identity_mismatch",
294
+ message: "network token from_session does not match token-bound node alias",
295
+ token_alias: "",
296
+ requested_from_session: requestedFrom ?? "",
297
+ }),
298
+ }],
299
+ };
300
+ }
284
301
  if (!callerTokenIsNetwork || !callerAlias || !requestedFrom || requestedFrom === callerAlias) return null;
285
302
  return {
286
303
  content: [{
@@ -916,6 +933,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
916
933
  if (!canWrite(effectiveNetId)) {
917
934
  return writeDeniedReply(effectiveNetId);
918
935
  }
936
+ // A rejected network token must not heartbeat, rename its token, or open a session.
937
+ if (callerIdentityRejected) {
938
+ return {
939
+ content: [{
940
+ type: "text" as const,
941
+ text: JSON.stringify({
942
+ ok: false,
943
+ error: "alias_identity_mismatch",
944
+ message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
945
+ token_alias: "",
946
+ reported_alias: alias,
947
+ }),
948
+ }],
949
+ };
950
+ }
919
951
  const canonical = resolveCanonicalAlias(sessionNetId, alias);
920
952
  let effectiveAlias = canonical.alias;
921
953
  if (canonical.renamed) {
@@ -969,11 +1001,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
969
1001
  };
970
1002
  }
971
1003
  console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
972
- if (callerTokenIsNetwork && callerTokenId) {
973
- try {
974
- db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
975
- } catch {}
976
- }
977
1004
  // #507 — a second copy of this node (same token + alias) may still be connected;
978
1005
  // its sibling's shutdown offline must not take the node offline under it.
979
1006
  const offlineDeferred = deferOfflineIfAnotherCopyConnected(effectiveAlias, sessionNetId, status, resume_id);
@@ -1162,6 +1189,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1162
1189
  [uuidv4(), sessionNetId, resume_id, effectiveAlias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
1163
1190
  );
1164
1191
  }
1192
+ if (callerTokenIsNetwork && callerTokenId) {
1193
+ try {
1194
+ db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
1195
+ } catch {}
1196
+ }
1165
1197
  });
1166
1198
  // #448 — only the node token bound to this alias may speak for its health
1167
1199
  // (same rule as the *_capable flags above).
@@ -1204,10 +1236,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1204
1236
  } catch {}
1205
1237
  }
1206
1238
 
1207
- // V2: upsert nodes table for persistent node identity. SEC-1
1208
- // gate (PR A #287 follow-up, 通信牛 catch 2026-06-28): delegate
1209
- // to upsertNodeWithSec1Guard so production + test exercise the
1210
- // exact same code path. See helper below registerTools.
1239
+ // A copied node_id must not relabel the row. The heartbeat itself still
1240
+ // succeeds: only a token rejected as impersonation fails the whole call.
1241
+ // identity_mismatch must not leave this session pointing at the other row.
1211
1242
  if (node_id) {
1212
1243
  try {
1213
1244
  const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
@@ -1226,8 +1257,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1226
1257
  hostname: hn ?? null,
1227
1258
  config_snapshot: cfgSnap ?? null,
1228
1259
  });
1229
- // The session row above already took node_id; a refused identity claim
1230
- // must not leave this session pointing at another node's row.
1231
1260
  if (upserted.result === "refused" && upserted.reason === "identity_mismatch") {
1232
1261
  db.run("UPDATE sessions SET node_id = NULL WHERE resume_id = ?1 AND node_id = ?2", [resume_id, node_id]);
1233
1262
  }
@@ -3655,7 +3684,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3655
3684
 
3656
3685
  server.tool(
3657
3686
  "list_node_skills",
3658
- "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3687
+ "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description; team skills also include origin \"team\"). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…], roots, warnings}.",
3659
3688
  {
3660
3689
  ...NODE_ID_ALIAS_FIELDS,
3661
3690
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -3666,7 +3695,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3666
3695
 
3667
3696
  server.tool(
3668
3697
  "read_node_skill",
3669
- "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
3698
+ "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content, origin?}. origin is \"team\" only for a team skill.",
3670
3699
  {
3671
3700
  ...NODE_ID_ALIAS_FIELDS,
3672
3701
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -5423,7 +5452,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5423
5452
  "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
5424
5453
  {},
5425
5454
  async () => {
5426
- const callerDaemon = resolveCallerDaemonTokenBound();
5455
+ const callerDaemon = resolveCallerDaemonTokenBound(); // board #674: token-bound snapshot, not a lease
5427
5456
  if (!callerDaemon.ok) {
5428
5457
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5429
5458
  }
@@ -5446,18 +5475,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5446
5475
  AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
5447
5476
  callerDaemon.daemonNodeId, callerDaemon.networkId,
5448
5477
  );
5449
- const children: Array<{ child_node_id: string; alias: string; lifecycle_state: string; managed?: "adopted" }> = rows.map(r => ({
5478
+ const children: Array<{ child_node_id: string; alias: string; lifecycle_state: string; managed?: "adopted"; binding_request_id?: string }> = rows.map(r => ({
5450
5479
  child_node_id: r.child_node_id,
5451
5480
  alias: r.child_name,
5452
5481
  lifecycle_state: r.lifecycle_state ?? "active",
5453
5482
  }));
5454
- const adopted = db.all<{ node_id: string; alias: string; lifecycle_state: string | null }>(
5455
- `SELECT n.node_id,n.alias,n.lifecycle_state FROM node_daemon_bindings b
5483
+ const adopted = db.all<{ node_id: string; alias: string; lifecycle_state: string | null; request_id: string }>(
5484
+ `SELECT n.node_id,n.alias,n.lifecycle_state,b.request_id FROM node_daemon_bindings b
5456
5485
  JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
5457
5486
  WHERE b.daemon_node_id=?1 AND b.network_id=?2 AND b.status='active'`,
5458
5487
  callerDaemon.daemonNodeId, callerDaemon.networkId,
5459
5488
  );
5460
- for (const n of adopted) children.push({ child_node_id: n.node_id, alias: n.alias, lifecycle_state: n.lifecycle_state ?? "active", managed: "adopted" });
5489
+ for (const n of adopted) children.push({ child_node_id: n.node_id, alias: n.alias, lifecycle_state: n.lifecycle_state ?? "active", managed: "adopted", binding_request_id: n.request_id });
5461
5490
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
5462
5491
  },
5463
5492
  );
@@ -5964,7 +5993,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5964
5993
  const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
5965
5994
  if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
5966
5995
  const url = new URL(`http://mcp.internal${path}`);
5967
- const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
5996
+ const req = new Request(url, { method, headers: { "content-type": "application/json", "x-anet-accept-columns": "abandoned" }, body: body ? JSON.stringify(body) : undefined });
5968
5997
  const res = await handleRequirementsRequest({
5969
5998
  req, url,
5970
5999
  auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
@@ -5992,7 +6021,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5992
6021
  tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
5993
6022
  name: z.string().min(1).max(80).optional(),
5994
6023
  priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
5995
- column: z.enum(["pool", "doing", "done"]).optional(),
6024
+ column: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
5996
6025
  due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
5997
6026
  start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
5998
6027
  description: z.string().max(20_000).optional().describe("markdown"),
@@ -6008,7 +6037,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6008
6037
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
6009
6038
  // #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
6010
6039
  // 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
6011
- const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column; must match it if both are sent");
6040
+ const reqStatus = z.enum(["pool", "doing", "done", "abandoned"]).optional().describe("alias of column; must match it if both are sent");
6012
6041
  const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
6013
6042
  if (args.status === undefined) return call(args);
6014
6043
  if (args.column !== undefined && args.column !== args.status) return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ ...errorBody("status_conflicts_with_column"), status: 400 }) }] });
@@ -6024,7 +6053,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6024
6053
  const reqListShape = {
6025
6054
  network_id: z.string().max(200).optional(),
6026
6055
  seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
6027
- status: z.enum(["pool", "doing", "done"]).optional(),
6056
+ status: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
6028
6057
  project_id: z.string().max(200).optional(),
6029
6058
  owner: z.string().max(210).optional(),
6030
6059
  agent_owner: z.string().max(210).optional(),
@@ -6105,14 +6134,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6105
6134
 
6106
6135
  server.tool(
6107
6136
  "requirements_create",
6108
- "Create a requirement task. name is required. column (or its alias status) = pool / doing / done. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
6137
+ "Create a requirement task. name is required. column (or its alias status) = pool / doing / done / abandoned. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
6109
6138
  { network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
6110
6139
  async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
6111
6140
  );
6112
6141
 
6113
6142
  server.tool(
6114
6143
  "requirements_update",
6115
- "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
6144
+ "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done / abandoned. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
6116
6145
  { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
6117
6146
  async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
6118
6147
  );