@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/package.json +1 -1
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth.ts +85 -14
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +70 -26
- package/src/daemon-token-owner-http.test.ts +10 -5
- package/src/db.ts +15 -1
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-permissions.ts +45 -12
- package/src/requirement-due-reminders.ts +3 -3
- package/src/requirement-notify.ts +1 -1
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-errors.ts +4 -4
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-list-slim-http.test.ts +2 -2
- package/src/requirements-migrate.ts +34 -0
- package/src/requirements-priority-lowest.test.ts +1 -1
- package/src/requirements-stats.ts +4 -1
- package/src/requirements.ts +62 -17
- package/src/rest-identity.ts +29 -4
- package/src/server.ts +67 -23
- package/src/tools.ts +55 -26
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) =>
|
|
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
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
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
|
);
|