@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 +1 -1
- package/src/auth.ts +18 -14
- package/src/create-node.ts +13 -6
- package/src/daemon-token-owner-http.test.ts +226 -0
- package/src/db.ts +3 -0
- package/src/node-lifecycle-read-http.test.ts +2 -1
- package/src/node-lifecycle-read.ts +1 -0
- package/src/node-token-ownership.ts +36 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/requirement-events-http.test.ts +12 -1
- package/src/requirements-last-event-http.test.ts +6 -1
- package/src/scheduled-tasks.ts +5 -3
- package/src/server.ts +5 -3
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/tools.ts +44 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
-
*
|
|
250
|
-
*
|
|
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
|
-
//
|
|
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 =
|
|
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
|
}
|
package/src/create-node.ts
CHANGED
|
@@ -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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 ||
|
|
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(() => {
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -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(
|
|
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 >
|
|
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 >
|
|
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(
|
|
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
|
|
3692
|
-
let touchSql =
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
|
1775
|
-
//
|
|
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'
|
|
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
|
|
1934
|
-
let touchSql =
|
|
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,
|