@sleep2agi/commhub-server 0.9.0-preview.130 → 0.9.0-preview.131
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/db.ts +3 -1
- package/src/rest-identity.test.ts +2 -1
- package/src/rest-identity.ts +20 -6
- package/src/server.ts +4 -4
- package/src/task-auth-origin-http.test.ts +3 -1
- package/src/task-network-resolution-http.test.ts +0 -1
- package/src/task-sender-http.test.ts +143 -0
- package/src/tools.ts +15 -4
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.131",
|
|
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/db.ts
CHANGED
|
@@ -1271,7 +1271,7 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_from_node ON tasks(from_node
|
|
|
1271
1271
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_to_node ON tasks(to_node_id)"); } catch {}
|
|
1272
1272
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_node ON inbox(node_id)"); } catch {}
|
|
1273
1273
|
|
|
1274
|
-
function backfillMessageNodeIds() {
|
|
1274
|
+
export function backfillMessageNodeIds() {
|
|
1275
1275
|
let inboxDirect = 0;
|
|
1276
1276
|
let inboxRenamed = 0;
|
|
1277
1277
|
let tasksToDirect = 0;
|
|
@@ -1391,6 +1391,7 @@ function backfillMessageNodeIds() {
|
|
|
1391
1391
|
LIMIT 1
|
|
1392
1392
|
)
|
|
1393
1393
|
WHERE from_node_id IS NULL
|
|
1394
|
+
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.username = tasks.from_name)
|
|
1394
1395
|
AND EXISTS (
|
|
1395
1396
|
SELECT 1 FROM sessions s
|
|
1396
1397
|
WHERE s.node_id IS NOT NULL
|
|
@@ -1416,6 +1417,7 @@ function backfillMessageNodeIds() {
|
|
|
1416
1417
|
LIMIT 1
|
|
1417
1418
|
)
|
|
1418
1419
|
WHERE from_node_id IS NULL
|
|
1420
|
+
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.username = tasks.from_name)
|
|
1419
1421
|
AND EXISTS (
|
|
1420
1422
|
SELECT 1 FROM rename_txn r
|
|
1421
1423
|
JOIN sessions s
|
|
@@ -52,10 +52,11 @@ describe("REST from_session identity binding", () => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
// The Dashboard posts as its logged-in user; this path must not change.
|
|
55
|
-
test("user token
|
|
55
|
+
test("user token explicitly naming the authenticated username is accepted", () => {
|
|
56
56
|
expect(resolveRestFromSession({
|
|
57
57
|
token: UTOK,
|
|
58
58
|
tokenName: "dashboard",
|
|
59
|
+
authenticatedUsername: "admin",
|
|
59
60
|
requestedFrom: "admin",
|
|
60
61
|
})).toEqual({ ok: true, fromSession: "admin" });
|
|
61
62
|
});
|
package/src/rest-identity.ts
CHANGED
|
@@ -50,6 +50,22 @@ export function nodeAliasForToken(token: string, tokenName: string | null | unde
|
|
|
50
50
|
return alias || null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Human dispatches use the authenticated username; node credentials represent nodes. */
|
|
54
|
+
export function resolveUserTaskFromSession(username: string | null | undefined, requestedFrom: unknown): RestIdentityResult {
|
|
55
|
+
const fromSession = username?.trim() || "api";
|
|
56
|
+
const requested = typeof requestedFrom === "string" ? requestedFrom.trim() : "";
|
|
57
|
+
if (requested && requested !== fromSession) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "from_session_identity_mismatch",
|
|
61
|
+
message: "user token task sender must match the authenticated username",
|
|
62
|
+
tokenAlias: fromSession,
|
|
63
|
+
requestedFromSession: requested,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, fromSession };
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
|
|
54
70
|
const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
|
|
55
71
|
let alias: string | null;
|
|
@@ -96,16 +112,14 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
|
|
|
96
112
|
return { ok: true, fromSession: "api" };
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// otherwise attribute the task to the authenticated username. Falling back
|
|
102
|
-
// to the transport label `api` is reserved for legacy auth contexts where
|
|
103
|
-
// the server genuinely has no user identity.
|
|
115
|
+
// Authenticated human callers are always represented by their username.
|
|
116
|
+
// Keep the unscoped development / legacy transport path separate.
|
|
104
117
|
if (!alias) {
|
|
105
118
|
const authenticatedUsername = typeof input.authenticatedUsername === "string"
|
|
106
119
|
? input.authenticatedUsername.trim()
|
|
107
120
|
: "";
|
|
108
|
-
|
|
121
|
+
if (authenticatedUsername) return resolveUserTaskFromSession(authenticatedUsername, input.requestedFrom);
|
|
122
|
+
return { ok: true, fromSession: requested || "api" };
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
// Node token claiming somebody else — refuse rather than silently rewrite,
|
package/src/server.ts
CHANGED
|
@@ -3672,9 +3672,7 @@ return Bun.serve({
|
|
|
3672
3672
|
// TUI*) as that node. Same table, same token, one transport hardened and
|
|
3673
3673
|
// its sibling not.
|
|
3674
3674
|
//
|
|
3675
|
-
//
|
|
3676
|
-
// posts as its logged-in user, and tightening that is a separate change
|
|
3677
|
-
// with its own compatibility surface.
|
|
3675
|
+
// Human dispatches use the authenticated username on both transports.
|
|
3678
3676
|
// The decision itself lives in rest-identity.ts as a pure function so the
|
|
3679
3677
|
// shipped path and the tested path are the same code.
|
|
3680
3678
|
const identity = resolveRestFromSession({
|
|
@@ -3695,7 +3693,9 @@ return Bun.serve({
|
|
|
3695
3693
|
}
|
|
3696
3694
|
const fromSession = identity.fromSession;
|
|
3697
3695
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
3698
|
-
const fromNodeId =
|
|
3696
|
+
const fromNodeId = restAuth && !requestToken(req).startsWith("ntok_")
|
|
3697
|
+
? null
|
|
3698
|
+
: resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
3699
3699
|
const targetNodeId = target.session?.node_id ?? null;
|
|
3700
3700
|
const actualTo = restActualTo(target, taskNetId);
|
|
3701
3701
|
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
@@ -9,6 +9,7 @@ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-auth-origin-http-"));
|
|
|
9
9
|
let server: any;
|
|
10
10
|
let base = "";
|
|
11
11
|
let userToken = "";
|
|
12
|
+
let userName = "";
|
|
12
13
|
let nodeToken = "";
|
|
13
14
|
let networkId = "";
|
|
14
15
|
const TARGET = `auth-origin-http-target-${process.pid}`;
|
|
@@ -22,6 +23,7 @@ const TARGET_RESUME_ID = `resume_auth_origin_target_${process.pid}`;
|
|
|
22
23
|
beforeAll(async () => {
|
|
23
24
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
24
25
|
const username = `auth_origin_${Date.now()}`;
|
|
26
|
+
userName = username;
|
|
25
27
|
const registered = register(username, "AuthOriginTest123!", undefined, "seed");
|
|
26
28
|
expect(registered.ok).toBe(true);
|
|
27
29
|
userToken = registered.token!;
|
|
@@ -80,7 +82,7 @@ async function postTask(token: string, from: string, marker: string) {
|
|
|
80
82
|
|
|
81
83
|
describe("POST /api/task server-authenticated origin", () => {
|
|
82
84
|
test("user token stamps user even when the client supplied another fact", async () => {
|
|
83
|
-
const meta = await postTask(userToken,
|
|
85
|
+
const meta = await postTask(userToken, userName, "origin-user");
|
|
84
86
|
expect(meta.auth_origin).toBe("user");
|
|
85
87
|
});
|
|
86
88
|
|
|
@@ -87,7 +87,6 @@ async function post(principal: Principal, marker: string, networkId?: string) {
|
|
|
87
87
|
headers: { Authorization: `Bearer ${principal.token}`, "Content-Type": "application/json" },
|
|
88
88
|
body: JSON.stringify({
|
|
89
89
|
alias: principal.target,
|
|
90
|
-
from: "api",
|
|
91
90
|
task: marker,
|
|
92
91
|
priority: "high",
|
|
93
92
|
...(networkId ? { network_id: networkId } : {}),
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { addNetworkMember, createNetworkTokenForNode, createToken, register } from "./auth.js";
|
|
3
|
+
import { backfillMessageNodeIds, db } from "./db.js";
|
|
4
|
+
|
|
5
|
+
if (!process.env.COMMHUB_DB && !process.env.COMMHUB_TEST_PG_URL) throw new Error("isolated database required");
|
|
6
|
+
const stamp = `${process.pid}_${Date.now()}`;
|
|
7
|
+
type Caller = { label: string; token: string; username: string; userId: string };
|
|
8
|
+
type Transport = "rest" | "mcp";
|
|
9
|
+
let server: any, base: string, net: string, foreignNet: string;
|
|
10
|
+
let owner: Caller, member: Caller, admin: Caller, viewer: Caller;
|
|
11
|
+
let callers: Caller[] = [];
|
|
12
|
+
let nodeToken: string;
|
|
13
|
+
const target = `sender-target-${stamp}`;
|
|
14
|
+
const memberNode = `sender-owned-${stamp}`;
|
|
15
|
+
const targetId = `n_sender_target_${stamp}`;
|
|
16
|
+
const memberNodeId = `n_sender_owned_${stamp}`;
|
|
17
|
+
const collisionId = `n_sender_collision_${stamp}`;
|
|
18
|
+
let markerSeq = 0;
|
|
19
|
+
|
|
20
|
+
function user(label: string): Caller & { networkId: string } {
|
|
21
|
+
const username = `sender_${label}_${stamp}`;
|
|
22
|
+
const r = register(username, "SenderFixturePass123!", undefined, "seed");
|
|
23
|
+
if (!r.ok || !r.user || !r.token || !r.network_id) throw new Error("fixture registration failed");
|
|
24
|
+
return { label, token: r.token, username, userId: r.user.user_id, networkId: r.network_id };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function node(alias: string, id: string, ownerId: string, status = "idle") {
|
|
28
|
+
const r = createNetworkTokenForNode(ownerId, net, alias, id);
|
|
29
|
+
if (!r.ok || !r.token) throw new Error("fixture node registration failed");
|
|
30
|
+
db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, last_seen_at)
|
|
31
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))`, [`resume_${id}`, alias, id, net, status]);
|
|
32
|
+
return r.token;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
const first = user("owner"); owner = first; net = first.networkId;
|
|
37
|
+
const second = user("member"); member = second; foreignNet = second.networkId;
|
|
38
|
+
admin = user("admin"); viewer = user("viewer");
|
|
39
|
+
db.run("UPDATE users SET role = 'user' WHERE user_id IN (?1, ?2, ?3)", [owner.userId, member.userId, viewer.userId]);
|
|
40
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.userId]);
|
|
41
|
+
for (const [c, role] of [[member, "member"], [admin, "admin"], [viewer, "viewer"]] as const) {
|
|
42
|
+
expect(addNetworkMember(net, c.userId, role, owner.userId, { agentAccess: "all" }).ok).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
node(target, targetId, owner.userId);
|
|
45
|
+
nodeToken = node(memberNode, memberNodeId, member.userId);
|
|
46
|
+
// Legacy rows may predate the current username reservation on node creation.
|
|
47
|
+
db.run(`INSERT INTO nodes (node_id, alias, node_name, network_id, owner_user_id)
|
|
48
|
+
VALUES (?1, ?2, ?2, ?3, ?4)`, [collisionId, member.username, net, owner.userId]);
|
|
49
|
+
db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, last_seen_at)
|
|
50
|
+
VALUES (?1, ?2, ?3, ?4, 'blocked', datetime('now'))`, [`resume_${collisionId}`, member.username, collisionId, net]);
|
|
51
|
+
db.run(`INSERT INTO tasks (task_id, from_name, to_name, to_node_id, network_id, status, content)
|
|
52
|
+
VALUES (?1, 'hub', ?2, ?3, ?4, 'delivered', 'fixture pending')`, [`parent_${stamp}`, member.username, collisionId, net]);
|
|
53
|
+
const plain = createToken(member.userId, "sender-cli");
|
|
54
|
+
const scoped = createToken(member.userId, "sender-scoped", net);
|
|
55
|
+
const named = createToken(member.userId, `node:${memberNode}`, net);
|
|
56
|
+
expect([plain.ok, scoped.ok, named.ok]).toEqual([true, true, true]);
|
|
57
|
+
callers = [owner, member, admin,
|
|
58
|
+
{ ...member, label: "atok-cli", token: plain.token! },
|
|
59
|
+
{ ...member, label: "atok-network", token: scoped.token! },
|
|
60
|
+
{ ...member, label: "atok-named", token: named.token! }];
|
|
61
|
+
const mod = await import("./server.js");
|
|
62
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
63
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
64
|
+
}, 30_000);
|
|
65
|
+
|
|
66
|
+
afterAll(() => { server?.stop(true); });
|
|
67
|
+
|
|
68
|
+
async function dispatch(transport: Transport, token: string, requested?: string, networkId = net) {
|
|
69
|
+
const marker = `sender-case-${stamp}-${++markerSeq}`;
|
|
70
|
+
const args: Record<string, unknown> = { alias: target, task: marker, network_id: networkId, meta: { source: "sender-fixture" } };
|
|
71
|
+
if (requested !== undefined) args[transport === "rest" ? "from" : "from_session"] = requested;
|
|
72
|
+
const res = await fetch(`${base}${transport === "rest" ? "/api/task" : "/mcp"}`, {
|
|
73
|
+
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json",
|
|
74
|
+
Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
75
|
+
body: JSON.stringify(transport === "rest" ? args : {
|
|
76
|
+
jsonrpc: "2.0", id: markerSeq, method: "tools/call", params: { name: "send_task", arguments: args },
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
const data = text.split("\n").filter(s => s.startsWith("data:"));
|
|
81
|
+
const payload = JSON.parse(data.length ? data.at(-1)!.slice(5).trim() : text);
|
|
82
|
+
const body = transport === "rest" ? payload : JSON.parse(payload.result.content[0].text);
|
|
83
|
+
const task = db.get<{ from_name: string; from_node_id: string | null; parent_task_id: string | null; meta_json: string }>(
|
|
84
|
+
"SELECT from_name, from_node_id, parent_task_id, meta_json FROM tasks WHERE content = ?1", marker);
|
|
85
|
+
const inbox = db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM inbox WHERE content = ?1", marker);
|
|
86
|
+
return { status: res.status, body, task, inboxCount: Number(inbox?.n ?? 0) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const transport of ["rest", "mcp"] as const) {
|
|
90
|
+
for (const label of ["owner", "member", "admin", "atok-cli", "atok-network", "atok-named"]) {
|
|
91
|
+
test(`authenticated sender: ${transport} ${label} uses its username`, async () => {
|
|
92
|
+
const c = callers.find(c => c.label === label)!;
|
|
93
|
+
for (const from of [undefined, ` ${c.username} `, ""]) {
|
|
94
|
+
const r = await dispatch(transport, c.token, from);
|
|
95
|
+
expect(r.body.task_id ?? r.body.message_id).toBeTruthy();
|
|
96
|
+
expect(r.task).toMatchObject({ from_name: c.username, from_node_id: null, parent_task_id: null });
|
|
97
|
+
expect(JSON.parse(r.task!.meta_json).auth_origin).toBe("user");
|
|
98
|
+
expect(r.inboxCount).toBe(1);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
test(`authenticated sender: ${transport} ${label} rejects another identity without writes`, async () => {
|
|
102
|
+
const c = callers.find(c => c.label === label)!;
|
|
103
|
+
for (const from of [memberNode, "hub", "api", `other-${stamp}`]) {
|
|
104
|
+
const r = await dispatch(transport, c.token, from);
|
|
105
|
+
if (transport === "rest") expect(r.status).toBe(403);
|
|
106
|
+
expect(r.body).toMatchObject({ ok: false, error: "from_session_identity_mismatch" });
|
|
107
|
+
expect(r.task).toBeNull();
|
|
108
|
+
expect(r.inboxCount).toBe(0);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
test(`authenticated sender: ${transport} node token retains node attribution`, async () => {
|
|
113
|
+
const r = await dispatch(transport, nodeToken, memberNode);
|
|
114
|
+
expect(r.body.task_id ?? r.body.message_id).toBeTruthy();
|
|
115
|
+
expect(r.task).toMatchObject({ from_name: memberNode, from_node_id: memberNodeId });
|
|
116
|
+
expect(JSON.parse(r.task!.meta_json).auth_origin).toBe("node");
|
|
117
|
+
});
|
|
118
|
+
test(`authenticated sender: ${transport} keeps viewer and network permissions`, async () => {
|
|
119
|
+
for (const [token, network] of [[viewer.token, net], [owner.token, foreignNet]]) {
|
|
120
|
+
const r = await dispatch(transport, token, undefined, network);
|
|
121
|
+
expect(r.body.ok).not.toBe(true);
|
|
122
|
+
expect(r.task).toBeNull();
|
|
123
|
+
expect(r.inboxCount).toBe(0);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
test(`authenticated sender: ${transport} does not change a same-named node's state`, async () => {
|
|
127
|
+
const r = await dispatch(transport, member.token);
|
|
128
|
+
expect(r.task?.from_node_id).toBeNull();
|
|
129
|
+
expect(r.task?.parent_task_id).toBeNull();
|
|
130
|
+
expect(db.get<{ status: string }>("SELECT status FROM sessions WHERE node_id = ?1", collisionId)?.status).toBe("blocked");
|
|
131
|
+
});
|
|
132
|
+
test(`authenticated sender: ${transport} preserves human attribution during backfill`, async () => {
|
|
133
|
+
await dispatch(transport, member.token);
|
|
134
|
+
backfillMessageNodeIds();
|
|
135
|
+
expect(db.all<{ from_node_id: string | null }>(
|
|
136
|
+
"SELECT from_node_id FROM tasks WHERE from_name = ?1", member.username).every(t => t.from_node_id === null)).toBe(true);
|
|
137
|
+
const legacyId = `legacy_sender_${transport}_${stamp}`;
|
|
138
|
+
db.run(`INSERT INTO tasks (task_id, from_name, to_name, network_id, content)
|
|
139
|
+
VALUES (?1, ?2, ?3, ?4, 'legacy fixture')`, [legacyId, memberNode, target, net]);
|
|
140
|
+
backfillMessageNodeIds();
|
|
141
|
+
expect(db.get<{ from_node_id: string | null }>("SELECT from_node_id FROM tasks WHERE task_id = ?1", legacyId)?.from_node_id).toBe(memberNodeId);
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js"
|
|
|
23
23
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
24
24
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
25
25
|
import { resolveInboxCaller } from "./inbox-caller.js";
|
|
26
|
+
import { resolveUserTaskFromSession } from "./rest-identity.js";
|
|
26
27
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
27
28
|
import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
28
29
|
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
@@ -1829,7 +1830,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1829
1830
|
meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
|
|
1830
1831
|
force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
|
|
1831
1832
|
},
|
|
1832
|
-
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => {
|
|
1833
|
+
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => {
|
|
1834
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch;
|
|
1835
|
+
const humanSender = !callerTokenIsNetwork && !!enforceUserId;
|
|
1836
|
+
const userIdentity = humanSender ? resolveUserTaskFromSession(callerAlias, _fromIn) : null;
|
|
1837
|
+
if (userIdentity && !userIdentity.ok) {
|
|
1838
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1839
|
+
ok: false, error: userIdentity.error, message: userIdentity.message,
|
|
1840
|
+
token_alias: userIdentity.tokenAlias, requested_from_session: userIdentity.requestedFromSession,
|
|
1841
|
+
}) }] };
|
|
1842
|
+
}
|
|
1843
|
+
const from_session = userIdentity?.ok ? userIdentity.fromSession : defaultFrom(_fromIn);
|
|
1833
1844
|
const effectiveNetId = getNetworkId(netId);
|
|
1834
1845
|
const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
|
|
1835
1846
|
? "node"
|
|
@@ -1865,7 +1876,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1865
1876
|
// network A dispatch and chain-reply into the wrong tenant. The
|
|
1866
1877
|
// explicit-parent path is verified below in F2.
|
|
1867
1878
|
let parentTaskId: string | null = parentIn ?? null;
|
|
1868
|
-
if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
|
|
1879
|
+
if (!humanSender && !parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
|
|
1869
1880
|
try {
|
|
1870
1881
|
const recentParams: any[] = [from_session];
|
|
1871
1882
|
let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered')"; // board668-parent-delivered-only
|
|
@@ -1996,7 +2007,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1996
2007
|
}
|
|
1997
2008
|
|
|
1998
2009
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
1999
|
-
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
2010
|
+
const fromNodeId = humanSender ? null : resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
2000
2011
|
const targetNodeId = target.session?.node_id ?? null;
|
|
2001
2012
|
const resolvedActualTo = actualTo(target, effectiveNetId);
|
|
2002
2013
|
// #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).
|
|
@@ -2021,7 +2032,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2021
2032
|
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
2022
2033
|
db.run(touchSql, touchParams);
|
|
2023
2034
|
// #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
|
|
2024
|
-
releaseBlockedSession(from_session, effectiveNetId);
|
|
2035
|
+
if (!humanSender) releaseBlockedSession(from_session, effectiveNetId);
|
|
2025
2036
|
});
|
|
2026
2037
|
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
|
|
2027
2038
|
// Only stamp the dedup index after the inbox/tasks transaction
|