@sleep2agi/commhub-server 0.9.0-preview.57 → 0.9.0-preview.58
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/rules-file-duplicate-alias.test.ts +234 -0
- package/src/task-consumption.test.ts +11 -0
- package/src/tools.ts +50 -25
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.58",
|
|
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",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Duplicate-alias node rows vs the node pull/ack paths (field report).
|
|
2
|
+
//
|
|
3
|
+
// Re-creating a node under the same alias leaves the old `nodes` row behind, so
|
|
4
|
+
// one alias can own several rows in a network. The pull/ack tools used to
|
|
5
|
+
// resolve the caller by `WHERE alias = ? AND network_id = ?` + `db.get`, i.e.
|
|
6
|
+
// the first row SQLite yields — the stale one. Field shape: the client queued a
|
|
7
|
+
// rules-file read for the live node, the node pulled with its own (bound)
|
|
8
|
+
// token, the hub looked under the stale node_id, answered `request: null`, and
|
|
9
|
+
// the read timed out with only `[rules-file] doorbell received` in the node log.
|
|
10
|
+
//
|
|
11
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules-dup.db bun test src/rules-file-duplicate-alias.test.ts
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
import { registerTools } from "./tools.js";
|
|
18
|
+
|
|
19
|
+
const NET = "net_rules_dup";
|
|
20
|
+
const USER = "u_rules_dup_owner";
|
|
21
|
+
const ALIAS = "rules-dup-node";
|
|
22
|
+
// Inserted first → the row a plain `db.get` on the alias returns.
|
|
23
|
+
const STALE_ID = "node_rules_dup_stale";
|
|
24
|
+
const LIVE_ID = "node_rules_dup_live";
|
|
25
|
+
const STALE_TOKEN = "tok_rules_dup_stale";
|
|
26
|
+
const LIVE_TOKEN = "tok_rules_dup_live";
|
|
27
|
+
const UNBOUND_TOKEN = "tok_rules_dup_unbound";
|
|
28
|
+
|
|
29
|
+
function cleanup() {
|
|
30
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seedWorld(opts: { liveSession?: boolean } = {}) {
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
44
|
+
[USER, USER],
|
|
45
|
+
);
|
|
46
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, USER]);
|
|
47
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, NET]);
|
|
48
|
+
// Stale row first (older created_at AND lower rowid), live row second.
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:40:40', '2026-09-16 01:40:40')`,
|
|
51
|
+
[STALE_ID, ALIAS, ALIAS, NET],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:42:46', '2026-09-16 01:42:46')`,
|
|
55
|
+
[LIVE_ID, ALIAS, ALIAS, NET],
|
|
56
|
+
);
|
|
57
|
+
for (const [tok, bound, hash] of [
|
|
58
|
+
[STALE_TOKEN, STALE_ID, "hash_rules_dup_stale"],
|
|
59
|
+
[LIVE_TOKEN, LIVE_ID, "hash_rules_dup_live"],
|
|
60
|
+
[UNBOUND_TOKEN, null, "hash_rules_dup_unbound"],
|
|
61
|
+
] as const) {
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
64
|
+
[tok, USER, NET, `node:${ALIAS}`, hash, bound],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (opts.liveSession !== false) {
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
70
|
+
[`sdk-${LIVE_ID}`, ALIAS, NET, LIVE_ID],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Identity = { alias: string; isNetworkToken: boolean; tokenId: string };
|
|
76
|
+
const asUser: Identity = { alias: USER, isNetworkToken: false, tokenId: "tok_rules_dup_user" };
|
|
77
|
+
const asLive: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: LIVE_TOKEN };
|
|
78
|
+
const asStale: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: STALE_TOKEN };
|
|
79
|
+
const asUnbound: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: UNBOUND_TOKEN };
|
|
80
|
+
|
|
81
|
+
async function connect(id: Identity) {
|
|
82
|
+
const server = new McpServer({ name: "rules-dup-test", version: "1" });
|
|
83
|
+
registerTools(server, undefined, NET, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
84
|
+
const client = new Client({ name: "rules-dup-client", version: "1" });
|
|
85
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
86
|
+
await server.connect(st);
|
|
87
|
+
await client.connect(ct);
|
|
88
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
89
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
90
|
+
const first = r.content?.[0];
|
|
91
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
|
|
92
|
+
return JSON.parse(first.text);
|
|
93
|
+
};
|
|
94
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
95
|
+
return { call, close };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function queuedNodeId(requestId: string): string | undefined {
|
|
99
|
+
return db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", requestId)?.node_id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => { cleanup(); });
|
|
103
|
+
afterAll(cleanup);
|
|
104
|
+
|
|
105
|
+
describe("duplicate alias — rules file pull/ack", () => {
|
|
106
|
+
test("field case: read queued for the live node_id is pulled and acked by the live node's bound token", async () => {
|
|
107
|
+
seedWorld();
|
|
108
|
+
const u = await connect(asUser);
|
|
109
|
+
const n = await connect(asLive);
|
|
110
|
+
try {
|
|
111
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
112
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
113
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
114
|
+
expect(pulled).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
115
|
+
const ack = await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "AGENTS.md", exists: true, content: "# live" });
|
|
116
|
+
expect(ack).toMatchObject({ ok: true, status: "done" });
|
|
117
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: "# live" });
|
|
118
|
+
} finally { await u.close(); await n.close(); }
|
|
119
|
+
}, 20_000);
|
|
120
|
+
|
|
121
|
+
test("a token bound to the stale row can neither pull nor ack the live node's request", async () => {
|
|
122
|
+
seedWorld();
|
|
123
|
+
const u = await connect(asUser);
|
|
124
|
+
const stale = await connect(asStale);
|
|
125
|
+
try {
|
|
126
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
127
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
128
|
+
expect(await stale.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "# forged" }))
|
|
129
|
+
.toEqual({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
130
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
131
|
+
} finally { await u.close(); await stale.close(); }
|
|
132
|
+
}, 20_000);
|
|
133
|
+
|
|
134
|
+
test("the live token does not pull a request queued for the stale row", async () => {
|
|
135
|
+
seedWorld();
|
|
136
|
+
const u = await connect(asUser);
|
|
137
|
+
const n = await connect(asLive);
|
|
138
|
+
try {
|
|
139
|
+
await u.call("read_node_rules_file", { node_id: STALE_ID });
|
|
140
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
141
|
+
} finally { await u.close(); await n.close(); }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
|
|
144
|
+
test("client targeting by alias queues under the row the alias's session points at", async () => {
|
|
145
|
+
seedWorld();
|
|
146
|
+
const u = await connect(asUser);
|
|
147
|
+
try {
|
|
148
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
149
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
150
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
151
|
+
const sk = await u.call("list_node_skills", { alias: ALIAS });
|
|
152
|
+
expect(sk).toMatchObject({ ok: true });
|
|
153
|
+
expect(queuedNodeId(sk.request_id)).toBe(LIVE_ID);
|
|
154
|
+
} finally { await u.close(); }
|
|
155
|
+
}, 20_000);
|
|
156
|
+
|
|
157
|
+
test("without a session row, alias targeting falls back to the newest row", async () => {
|
|
158
|
+
seedWorld({ liveSession: false });
|
|
159
|
+
const u = await connect(asUser);
|
|
160
|
+
try {
|
|
161
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
162
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
163
|
+
} finally { await u.close(); }
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
test("the session mapping wins over recency: alias targeting picks the older row when the session points at it", async () => {
|
|
167
|
+
seedWorld({ liveSession: false });
|
|
168
|
+
db.run(
|
|
169
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
170
|
+
[`sdk-${STALE_ID}`, ALIAS, NET, STALE_ID],
|
|
171
|
+
);
|
|
172
|
+
const u = await connect(asUser);
|
|
173
|
+
try {
|
|
174
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
175
|
+
expect(queuedNodeId(enq.request_id)).toBe(STALE_ID);
|
|
176
|
+
} finally { await u.close(); }
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
test("a token bound to a node whose row is gone does not fall back to another row with its alias", async () => {
|
|
180
|
+
seedWorld();
|
|
181
|
+
db.run("UPDATE api_tokens SET bound_node_id = 'node_rules_dup_deleted' WHERE token_id = ?1", [STALE_TOKEN]);
|
|
182
|
+
const u = await connect(asUser);
|
|
183
|
+
const stale = await connect(asStale);
|
|
184
|
+
try {
|
|
185
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
186
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
187
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
188
|
+
} finally { await u.close(); await stale.close(); }
|
|
189
|
+
}, 20_000);
|
|
190
|
+
|
|
191
|
+
test("an unbound legacy token resolves its alias to the session-mapped row", async () => {
|
|
192
|
+
seedWorld();
|
|
193
|
+
const u = await connect(asUser);
|
|
194
|
+
const n = await connect(asUnbound);
|
|
195
|
+
try {
|
|
196
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
197
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
198
|
+
} finally { await u.close(); await n.close(); }
|
|
199
|
+
}, 20_000);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("duplicate alias — config update pull/ack", () => {
|
|
203
|
+
function queueUpdate(nodeId: string, updateId: string) {
|
|
204
|
+
db.run(
|
|
205
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{"model":"m"}', 'hot', 0, 'pending', ?4, 'tok_rules_dup_user')`,
|
|
206
|
+
[updateId, nodeId, NET, Date.now()],
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("the live node's bound token pulls and acks its own pending update", async () => {
|
|
211
|
+
seedWorld();
|
|
212
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live");
|
|
213
|
+
const n = await connect(asLive);
|
|
214
|
+
try {
|
|
215
|
+
const pulled = await n.call("get_config_update", {});
|
|
216
|
+
expect(pulled.update?.update_id).toBe("cu_rules_dup_live");
|
|
217
|
+
const ack = await n.call("ack_config_update", { update_id: "cu_rules_dup_live", status: "applied", new_revision: 1 });
|
|
218
|
+
expect(ack.ok).toBe(true);
|
|
219
|
+
expect(ack.ignored).toBeUndefined();
|
|
220
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live'")?.status).toBe("applied");
|
|
221
|
+
} finally { await n.close(); }
|
|
222
|
+
}, 20_000);
|
|
223
|
+
|
|
224
|
+
test("a token bound to the stale row cannot pull or ack the live node's update", async () => {
|
|
225
|
+
seedWorld();
|
|
226
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live2");
|
|
227
|
+
const stale = await connect(asStale);
|
|
228
|
+
try {
|
|
229
|
+
expect((await stale.call("get_config_update", {})).update).toBeNull();
|
|
230
|
+
await stale.call("ack_config_update", { update_id: "cu_rules_dup_live2", status: "applied", new_revision: 1 });
|
|
231
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live2'")?.status).toBe("pending");
|
|
232
|
+
} finally { await stale.close(); }
|
|
233
|
+
}, 20_000);
|
|
234
|
+
});
|
|
@@ -126,6 +126,17 @@ describe("task consumed_at identity and lifecycle", () => {
|
|
|
126
126
|
expect([...first.tasks, ...second.tasks].some((task: any) => task.task_id === "foreign-node-row")).toBe(false);
|
|
127
127
|
expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 100 })).error).toBe("from_node_id_identity_mismatch");
|
|
128
128
|
});
|
|
129
|
+
test("a legacy unbound node token is refused the durable cursor even for its own node", async () => {
|
|
130
|
+
// Pre-RFC-036 tokens carry no immutable node binding. The durable outbox
|
|
131
|
+
// cursor must stay closed to them (no bind-on-first-use); agent-node
|
|
132
|
+
// degrades to inbox-only compensation on this exact error code.
|
|
133
|
+
db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1", [`token_${NODE_A}`]);
|
|
134
|
+
const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
|
|
135
|
+
expect((await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
|
|
136
|
+
expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
|
|
137
|
+
const bound = db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", `token_${NODE_A}`);
|
|
138
|
+
expect(bound?.bound_node_id).toBeNull();
|
|
139
|
+
});
|
|
129
140
|
test("terminal sequence paginates beyond 2000 and admits late completion of an old task", async () => {
|
|
130
141
|
db.run(
|
|
131
142
|
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
|
package/src/tools.ts
CHANGED
|
@@ -2982,6 +2982,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2982
2982
|
},
|
|
2983
2983
|
);
|
|
2984
2984
|
|
|
2985
|
+
// One alias can own several `nodes` rows in a network: re-creating a node
|
|
2986
|
+
// under the same alias leaves the old row behind, and `db.get` on
|
|
2987
|
+
// `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
|
|
2988
|
+
// happens to yield first. A field report showed the result: the client
|
|
2989
|
+
// queued a rules-file read for the live node, the node pulled with its own
|
|
2990
|
+
// token, the hub resolved its alias to the stale row, found nothing pending
|
|
2991
|
+
// and returned `request: null` until the read timed out.
|
|
2992
|
+
//
|
|
2993
|
+
// resolveNodeByAlias: prefer the row the alias's session points at (sessions
|
|
2994
|
+
// are UNIQUE per network+alias and carry the node_id that last reported),
|
|
2995
|
+
// then the newest row. Used where only an alias is known (a client targeting
|
|
2996
|
+
// by alias; unbound legacy node tokens).
|
|
2997
|
+
const resolveNodeByAlias = (alias: string, networkId: string) =>
|
|
2998
|
+
db.get<{ node_id: string; alias: string; network_id: string | null }>(
|
|
2999
|
+
`SELECT n.node_id, n.alias, n.network_id FROM nodes n
|
|
3000
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
|
|
3001
|
+
WHERE n.alias = ?1 AND n.network_id = ?2
|
|
3002
|
+
ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
|
|
3003
|
+
LIMIT 1`,
|
|
3004
|
+
alias,
|
|
3005
|
+
networkId,
|
|
3006
|
+
) ?? null;
|
|
3007
|
+
// resolveCallerNode: the node a network token pulls/acks for. A token bound
|
|
3008
|
+
// to a node resolves to exactly that node (and never to another row that
|
|
3009
|
+
// shares its alias); only unbound legacy tokens fall back to the alias.
|
|
3010
|
+
// Callers have already required callerTokenIsNetwork + enforceNetworkId +
|
|
3011
|
+
// callerAlias.
|
|
3012
|
+
const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
|
|
3013
|
+
const bound = callerTokenId
|
|
3014
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
3015
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
3016
|
+
callerTokenId,
|
|
3017
|
+
enforceNetworkId,
|
|
3018
|
+
)?.bound_node_id ?? null
|
|
3019
|
+
: null;
|
|
3020
|
+
if (bound) {
|
|
3021
|
+
return db.get<{ node_id: string; network_id: string | null }>(
|
|
3022
|
+
"SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
3023
|
+
bound,
|
|
3024
|
+
enforceNetworkId,
|
|
3025
|
+
) ?? null;
|
|
3026
|
+
}
|
|
3027
|
+
return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
|
|
3028
|
+
};
|
|
3029
|
+
|
|
2985
3030
|
server.tool(
|
|
2986
3031
|
"get_config_update",
|
|
2987
3032
|
"Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
|
|
@@ -3005,11 +3050,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3005
3050
|
// Unconditional network_id filter (was previously conditional on
|
|
3006
3051
|
// enforceNetworkId being set; the new ntok guard above guarantees
|
|
3007
3052
|
// it's non-null so the filter is always applied).
|
|
3008
|
-
const node =
|
|
3009
|
-
"SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3010
|
-
callerAlias,
|
|
3011
|
-
enforceNetworkId,
|
|
3012
|
-
);
|
|
3053
|
+
const node = resolveCallerNode();
|
|
3013
3054
|
if (!node) {
|
|
3014
3055
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
|
|
3015
3056
|
}
|
|
@@ -3062,11 +3103,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3062
3103
|
// Cross-tenant guard: the ack-er must own the update being acked.
|
|
3063
3104
|
// Resolve node by caller's alias under the enforced network.
|
|
3064
3105
|
// Network filter is unconditional (guard above guarantees non-null).
|
|
3065
|
-
const node =
|
|
3066
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3067
|
-
callerAlias,
|
|
3068
|
-
enforceNetworkId,
|
|
3069
|
-
);
|
|
3106
|
+
const node = resolveCallerNode();
|
|
3070
3107
|
if (!node) {
|
|
3071
3108
|
// Silently ignore stale ack — return ok so the node doesn't retry forever.
|
|
3072
3109
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
@@ -3155,11 +3192,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3155
3192
|
node = resolved.row as any;
|
|
3156
3193
|
} else {
|
|
3157
3194
|
const scopeNet = effectiveNetId || "default";
|
|
3158
|
-
const byAlias =
|
|
3159
|
-
"SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3160
|
-
a.alias,
|
|
3161
|
-
scopeNet,
|
|
3162
|
-
);
|
|
3195
|
+
const byAlias = resolveNodeByAlias(a.alias, scopeNet);
|
|
3163
3196
|
if (byAlias) {
|
|
3164
3197
|
node = byAlias;
|
|
3165
3198
|
} else {
|
|
@@ -3278,11 +3311,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3278
3311
|
if (!callerAlias) {
|
|
3279
3312
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3280
3313
|
}
|
|
3281
|
-
const node =
|
|
3282
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3283
|
-
callerAlias,
|
|
3284
|
-
enforceNetworkId,
|
|
3285
|
-
);
|
|
3314
|
+
const node = resolveCallerNode();
|
|
3286
3315
|
// app#225 follow-up — no `nodes` row: this is a session-only target
|
|
3287
3316
|
// (claude-code). Its queue key is `session:<alias>`, always paired with the
|
|
3288
3317
|
// caller's network so a same-named alias in another network never matches.
|
|
@@ -3333,11 +3362,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3333
3362
|
if (!callerAlias) {
|
|
3334
3363
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3335
3364
|
}
|
|
3336
|
-
const node =
|
|
3337
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3338
|
-
callerAlias,
|
|
3339
|
-
enforceNetworkId,
|
|
3340
|
-
);
|
|
3365
|
+
const node = resolveCallerNode();
|
|
3341
3366
|
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3342
3367
|
const req = db.get<any>(
|
|
3343
3368
|
"SELECT request_id, node_id, network_id, status FROM node_rules_requests WHERE request_id = ?1",
|