@sleep2agi/commhub-server 0.9.0-preview.111 → 0.9.0-preview.112
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/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/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/requirements.ts +3 -7
- package/src/rest-identity.ts +29 -4
- package/src/server.ts +58 -18
- package/src/tools.ts +47 -18
|
@@ -48,7 +48,7 @@ test("two-phase binding, daemon-bound pull, controllable map, adopted delete ref
|
|
|
48
48
|
expect(await call(handlers("daemon").get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: true, node_id: CHILD, alias: "adoption-child", workdir: "/workspace/project" });
|
|
49
49
|
expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
|
|
50
50
|
expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true });
|
|
51
|
-
expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted" });
|
|
51
|
+
expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted", binding_request_id: r.request_id });
|
|
52
52
|
expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
|
|
53
53
|
expect(await call(handlers().delete_node, { child_node_id: CHILD, confirm_alias: "adoption-child", network_id: NET })).toMatchObject({ ok: false, error: "adopted_node_delete_unsupported" });
|
|
54
54
|
expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
|
package/src/node-permissions.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { db } from "./db.js";
|
|
18
18
|
import { canMessageAgent, type AgentRef } from "./agent-access.js";
|
|
19
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
19
20
|
|
|
20
21
|
export type NodePermissionMode = "normal" | "readonly" | "restricted";
|
|
21
22
|
export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
|
|
@@ -71,23 +72,55 @@ function normalizeMode(raw: unknown): NodePermissionMode {
|
|
|
71
72
|
/** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
|
|
72
73
|
export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
|
|
73
74
|
if (!tokenId || !networkId) return null;
|
|
74
|
-
const row = db.get<{ user_id: string | null;
|
|
75
|
-
`SELECT
|
|
76
|
-
FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
|
|
77
|
-
WHERE t.token_id = ?1 AND t.network_id = ?2`,
|
|
75
|
+
const row = db.get<{ user_id: string | null; name: string | null }>(
|
|
76
|
+
`SELECT user_id, name FROM api_tokens WHERE token_id = ?1 AND network_id = ?2`,
|
|
78
77
|
tokenId, networkId,
|
|
79
78
|
);
|
|
80
79
|
if (!row) return null;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (
|
|
80
|
+
const resolved = resolveNodeCaller(tokenId);
|
|
81
|
+
// Revoked or not node-shaped: this lookup is not a second revocation gate.
|
|
82
|
+
if (!resolved.ok && resolved.reason === "not_a_node_token") {
|
|
83
|
+
const aliases = row.name?.startsWith("node:") ? [row.name.slice(5)] : [];
|
|
84
|
+
return {
|
|
85
|
+
networkId,
|
|
86
|
+
nodeId: null,
|
|
87
|
+
logKey: `token:${tokenId}`,
|
|
88
|
+
aliases,
|
|
89
|
+
ownerUserId: row.user_id,
|
|
90
|
+
mode: "normal",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!resolved.ok || resolved.networkId !== networkId) {
|
|
94
|
+
return {
|
|
95
|
+
networkId,
|
|
96
|
+
nodeId: null,
|
|
97
|
+
logKey: `token:${tokenId}`,
|
|
98
|
+
aliases: [],
|
|
99
|
+
ownerUserId: row.user_id,
|
|
100
|
+
mode: "normal",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (resolved.kind === "unregistered") {
|
|
104
|
+
return {
|
|
105
|
+
networkId,
|
|
106
|
+
nodeId: null,
|
|
107
|
+
logKey: `token:${tokenId}`,
|
|
108
|
+
aliases: resolved.alias ? [resolved.alias] : [],
|
|
109
|
+
ownerUserId: row.user_id,
|
|
110
|
+
mode: "normal",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const node = db.get<{ owner_user_id: string | null; permission_mode: string | null }>(
|
|
114
|
+
`SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
|
|
115
|
+
resolved.nodeId, networkId,
|
|
116
|
+
);
|
|
84
117
|
return {
|
|
85
118
|
networkId,
|
|
86
|
-
nodeId:
|
|
87
|
-
logKey:
|
|
88
|
-
aliases: [
|
|
89
|
-
ownerUserId:
|
|
90
|
-
mode: normalizeMode(
|
|
119
|
+
nodeId: resolved.nodeId,
|
|
120
|
+
logKey: resolved.nodeId,
|
|
121
|
+
aliases: [resolved.alias],
|
|
122
|
+
ownerUserId: node?.owner_user_id || row.user_id || null,
|
|
123
|
+
mode: normalizeMode(node?.permission_mode),
|
|
91
124
|
};
|
|
92
125
|
}
|
|
93
126
|
|
package/src/requirements.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
|
|
|
5
5
|
import { parsePeopleQuery, peopleDirectory, resolvePersonName } from "./requirements-people.js";
|
|
6
6
|
import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
|
|
7
7
|
import { db, logAudit } from "./db.js";
|
|
8
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
8
9
|
import { markGzipReusable } from "./http-gzip.js";
|
|
9
10
|
import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
|
|
10
11
|
import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
|
|
@@ -795,13 +796,8 @@ function actorOf(ctx: RequirementsRequestContext): Actor | null {
|
|
|
795
796
|
if (!ctx.auth) return null;
|
|
796
797
|
if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
|
|
797
798
|
const tokenId = ctx.auth.tokenId ?? null;
|
|
798
|
-
const
|
|
799
|
-
if (
|
|
800
|
-
const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
|
|
801
|
-
if (alias && ctx.auth.networkId) {
|
|
802
|
-
const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
|
|
803
|
-
if (node) return { kind: "node", id: node.node_id };
|
|
804
|
-
}
|
|
799
|
+
const resolved = tokenId ? resolveNodeCaller(tokenId) : null;
|
|
800
|
+
if (resolved?.ok && resolved.kind === "node") return { kind: "node", id: resolved.nodeId };
|
|
805
801
|
return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
|
|
806
802
|
}
|
|
807
803
|
|
package/src/rest-identity.ts
CHANGED
|
@@ -12,15 +12,20 @@
|
|
|
12
12
|
// task row and, once sender labels reach the copresence TUIs, rendered to a
|
|
13
13
|
// human as an attribution.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// Production network tokens pass tokenId and resolve through resolveNodeCaller.
|
|
16
|
+
// A token whose name is not `node:` (not_a_node_token, the registration token
|
|
17
|
+
// before report_status) falls back to nodeAliasForToken, same as main.
|
|
18
|
+
// tokenId omitted keeps the name-only shape used by the unit cases below.
|
|
19
|
+
|
|
20
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
18
21
|
|
|
19
22
|
export interface RestIdentityInput {
|
|
20
23
|
/** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
|
|
21
24
|
token: string;
|
|
22
25
|
/** `api_tokens.name`; for node tokens it is `node:<alias>`. */
|
|
23
26
|
tokenName: string | null | undefined;
|
|
27
|
+
/** Set on the production path. Network tokens then ignore tokenName as authority. */
|
|
28
|
+
tokenId?: string | null;
|
|
24
29
|
/** Username resolved from the authenticated token. */
|
|
25
30
|
authenticatedUsername?: string | null;
|
|
26
31
|
/** Caller-supplied `body.from`. */
|
|
@@ -46,8 +51,28 @@ export function nodeAliasForToken(token: string, tokenName: string | null | unde
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
|
|
49
|
-
const alias = nodeAliasForToken(input.token, input.tokenName);
|
|
50
54
|
const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
|
|
55
|
+
let alias: string | null;
|
|
56
|
+
if (input.token.startsWith("ntok_") && input.tokenId) {
|
|
57
|
+
const resolved = resolveNodeCaller(input.tokenId);
|
|
58
|
+
if (resolved.ok) {
|
|
59
|
+
alias = resolved.alias;
|
|
60
|
+
} else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
|
|
61
|
+
// Name is not `node:` yet. Main signs that as api, and refuses a claim.
|
|
62
|
+
// not_owner / bound_node_missing / ambiguous stay the hard reject.
|
|
63
|
+
alias = nodeAliasForToken(input.token, input.tokenName);
|
|
64
|
+
} else {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: "from_session_identity_mismatch",
|
|
68
|
+
message: "network token from_session does not match token-bound node alias",
|
|
69
|
+
tokenAlias: "",
|
|
70
|
+
requestedFromSession: requested,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
alias = nodeAliasForToken(input.token, input.tokenName);
|
|
75
|
+
}
|
|
51
76
|
|
|
52
77
|
// A network-bound token whose name we cannot resolve to an alias (`node:`,
|
|
53
78
|
// `node: `, or a name that was never node-scoped) is the degenerate case.
|
package/src/server.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
TASK_REST_SELECT,
|
|
76
76
|
} from "./rest-projections.js";
|
|
77
77
|
import { resolveRestFromSession } from "./rest-identity.js";
|
|
78
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
78
79
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
79
80
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
80
81
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
@@ -197,12 +198,12 @@ function sweepStaleRateLimits(): void {
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
200
|
-
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false): McpServer {
|
|
201
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false, callerIdentityRejected = false): McpServer {
|
|
201
202
|
const server = new McpServer({
|
|
202
203
|
name: "commhub",
|
|
203
204
|
version: "0.5.0",
|
|
204
205
|
});
|
|
205
|
-
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools });
|
|
206
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools }, callerIdentityRejected);
|
|
206
207
|
return server;
|
|
207
208
|
}
|
|
208
209
|
|
|
@@ -272,6 +273,16 @@ function nodeSubscriberInfo(req: Request, server: any, tokenId: string | null, a
|
|
|
272
273
|
return { nodeId, instanceId, remote, userAgent: req.headers.get("user-agent")?.slice(0, 200) ?? null };
|
|
273
274
|
}
|
|
274
275
|
|
|
276
|
+
// The stream belongs to a node id, not a name. A session that never stored
|
|
277
|
+
// node_id still owns its stream when this network has exactly one nodes row
|
|
278
|
+
// for the alias. Null does not match null.
|
|
279
|
+
function streamOwnerNodeId(alias: string, networkId: string): string | null {
|
|
280
|
+
const fromSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, networkId)?.node_id ?? null;
|
|
281
|
+
if (fromSession) return fromSession;
|
|
282
|
+
const rows = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", alias, networkId);
|
|
283
|
+
return rows.length === 1 ? rows[0].node_id : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
275
286
|
function isLocalhostIP(ip: string): boolean {
|
|
276
287
|
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
|
|
277
288
|
}
|
|
@@ -310,17 +321,24 @@ function isHubAdminCredential(resolved: { networkId: string | null; user: { role
|
|
|
310
321
|
function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
|
|
311
322
|
return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
|
|
312
323
|
}
|
|
313
|
-
function
|
|
324
|
+
function resolvedNodeAlias(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }): string | null {
|
|
325
|
+
if (!resolved.tokenId) return nodeTokenAlias(resolved);
|
|
326
|
+
const caller = resolveNodeCaller(resolved.tokenId);
|
|
327
|
+
if (!caller.ok) return null;
|
|
328
|
+
if (resolved.networkId && caller.networkId !== resolved.networkId) return null;
|
|
329
|
+
return caller.alias;
|
|
330
|
+
}
|
|
331
|
+
function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, networkId: unknown, oldAlias: unknown): boolean {
|
|
314
332
|
if (!resolved.networkId) return true;
|
|
315
|
-
return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias ===
|
|
333
|
+
return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === resolvedNodeAlias(resolved);
|
|
316
334
|
}
|
|
317
|
-
function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
|
|
335
|
+
function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, txnId: unknown): boolean {
|
|
318
336
|
if (!resolved.networkId) return true;
|
|
319
337
|
if (typeof txnId !== "string") return false;
|
|
320
338
|
const txn = db.get<{ network_id: string; old_alias: string; new_alias: string }>("SELECT network_id, old_alias, new_alias FROM rename_txn WHERE txn_id = ?1", txnId);
|
|
321
339
|
if (!txn || txn.network_id !== resolved.networkId) return false;
|
|
322
340
|
// commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
|
|
323
|
-
const self =
|
|
341
|
+
const self = resolvedNodeAlias(resolved);
|
|
324
342
|
return self === txn.old_alias || self === txn.new_alias;
|
|
325
343
|
}
|
|
326
344
|
|
|
@@ -1030,11 +1048,20 @@ return Bun.serve({
|
|
|
1030
1048
|
const token = requestToken(req);
|
|
1031
1049
|
const authCtx = resolveRequestAuth(req);
|
|
1032
1050
|
const enforceNetId = authCtx?.networkId || null;
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1051
|
+
// A resolved node token speaks as that node. A token whose name is not
|
|
1052
|
+
// `node:` (the registration network token) keeps the username. Only a
|
|
1053
|
+
// `node:` token refused as not_owner / bound_node_missing / ambiguous
|
|
1054
|
+
// is impersonation, and must not fall through to a client-supplied from.
|
|
1055
|
+
let callerAlias: string | null = authCtx?.username || null;
|
|
1056
|
+
let callerIdentityRejected = false;
|
|
1057
|
+
if (token?.startsWith("ntok_")) {
|
|
1058
|
+
const resolvedCaller = authCtx?.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
|
|
1059
|
+
if (resolvedCaller?.ok) callerAlias = resolvedCaller.alias;
|
|
1060
|
+
else if (resolvedCaller && resolvedCaller.reason !== "not_a_node_token") { // board679-plain-ntok
|
|
1061
|
+
callerAlias = null;
|
|
1062
|
+
callerIdentityRejected = true;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1038
1065
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1039
1066
|
sessionIdGenerator: undefined,
|
|
1040
1067
|
});
|
|
@@ -1052,7 +1079,7 @@ return Bun.serve({
|
|
|
1052
1079
|
labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
|
|
1053
1080
|
}
|
|
1054
1081
|
// #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
|
|
1055
|
-
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url));
|
|
1082
|
+
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url), callerIdentityRejected);
|
|
1056
1083
|
await mcpServer.connect(transport);
|
|
1057
1084
|
const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
|
|
1058
1085
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -1206,11 +1233,22 @@ return Bun.serve({
|
|
|
1206
1233
|
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
|
|
1207
1234
|
}));
|
|
1208
1235
|
if (streamDenied) return streamDenied;
|
|
1209
|
-
// #507 — the node's OWN stream
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
|
|
1213
|
-
|
|
1236
|
+
// #507 — the node's OWN stream is node-scoped: at most one per (network,
|
|
1237
|
+
// alias), newest wins. Own means the resolved node id equals this stream's
|
|
1238
|
+
// node id. A matching token name is not enough, and a monitor never supersedes.
|
|
1239
|
+
const resolvedCaller = authCtx.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
|
|
1240
|
+
const streamNodeId = streamOwnerNodeId(sessionName, scopedNetId);
|
|
1241
|
+
if (
|
|
1242
|
+
resolvedCaller?.ok
|
|
1243
|
+
&& resolvedCaller.kind === "node"
|
|
1244
|
+
&& resolvedCaller.nodeId
|
|
1245
|
+
&& streamNodeId
|
|
1246
|
+
&& resolvedCaller.nodeId === streamNodeId
|
|
1247
|
+
&& resolvedCaller.networkId === scopedNetId
|
|
1248
|
+
) {
|
|
1249
|
+
const info = nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId);
|
|
1250
|
+
if (!info.nodeId) info.nodeId = resolvedCaller.nodeId;
|
|
1251
|
+
return createSSEStream(sessionName, scopedNetId, { node: info });
|
|
1214
1252
|
}
|
|
1215
1253
|
return createSSEStream(sessionName, scopedNetId);
|
|
1216
1254
|
}
|
|
@@ -1395,8 +1433,9 @@ return Bun.serve({
|
|
|
1395
1433
|
// RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
|
|
1396
1434
|
const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
|
|
1397
1435
|
.map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
|
|
1436
|
+
const nodeCaller = resolved.networkId && resolved.tokenId ? resolveNodeCaller(resolved.tokenId) : null;
|
|
1398
1437
|
const credential = resolved.networkId
|
|
1399
|
-
? { kind: "node", network_id: resolved.networkId, node_alias:
|
|
1438
|
+
? { kind: "node", network_id: resolved.networkId, node_alias: nodeCaller?.ok ? nodeCaller.alias : null, acts_as_owner: false }
|
|
1400
1439
|
: { kind: "user" };
|
|
1401
1440
|
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
|
|
1402
1441
|
}
|
|
@@ -3546,6 +3585,7 @@ return Bun.serve({
|
|
|
3546
3585
|
const identity = resolveRestFromSession({
|
|
3547
3586
|
token: requestToken(req),
|
|
3548
3587
|
tokenName: restAuth?.tokenName,
|
|
3588
|
+
tokenId: restAuth?.tokenId,
|
|
3549
3589
|
authenticatedUsername: restAuth?.username,
|
|
3550
3590
|
requestedFrom: body.from,
|
|
3551
3591
|
});
|
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
|
}
|
|
@@ -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
|
);
|