@sleep2agi/commhub-server 0.9.0-preview.55 → 0.9.0-preview.57
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/alias-filter.test.ts +2 -2
- package/src/db.ts +7 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -1
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +4 -2
- package/src/server.ts +3 -0
- package/src/tools.ts +125 -31
- package/src/uploads.ts +1 -1
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.57",
|
|
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/alias-filter.test.ts
CHANGED
|
@@ -10,8 +10,8 @@ test("no filter given means no filtering, not an empty match", () => {
|
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
test("a single alias produces one placeholder", () => {
|
|
13
|
-
const f = parseAliasFilter("
|
|
14
|
-
expect(f.aliases).toEqual(["
|
|
13
|
+
const f = parseAliasFilter("节点甲");
|
|
14
|
+
expect(f.aliases).toEqual(["节点甲"]);
|
|
15
15
|
expect(f.sql).toBe(" AND alias IN (?)");
|
|
16
16
|
});
|
|
17
17
|
|
package/src/db.ts
CHANGED
|
@@ -123,6 +123,13 @@ for (const col of [
|
|
|
123
123
|
{ name: "process_in_flight_count", def: "INTEGER" },
|
|
124
124
|
{ name: "external_schedules", def: "TEXT" },
|
|
125
125
|
{ name: "peer_reply_inbox_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
126
|
+
// app#225 follow-up — a session (incl. claude-code sessions with no `nodes` row)
|
|
127
|
+
// that answers the rules-file doorbell. Sticky: set by a bound node token, never
|
|
128
|
+
// cleared by reports that omit it (agent-node status-only reports do).
|
|
129
|
+
{ name: "rules_file_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
130
|
+
// Node skills view — the same session answers skills_list / skill_read on the
|
|
131
|
+
// rules-file doorbell (node-skills.ts). Sticky, bound-token only, like above.
|
|
132
|
+
{ name: "skills_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
126
133
|
]) {
|
|
127
134
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
128
135
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Node skills view — list_node_skills / read_node_skill ride the rules-file
|
|
2
|
+
// queue + doorbell (ops skills_list / skill_read).
|
|
3
|
+
//
|
|
4
|
+
// Hub-side contract verified here:
|
|
5
|
+
// 1. report_status { skills_capable: true } is sticky and set only by the
|
|
6
|
+
// session's own bound node token.
|
|
7
|
+
// 2. list_node_skills / read_node_skill reach a session with no nodes row by
|
|
8
|
+
// alias; the node pulls with its own token; read carries the skill NAME
|
|
9
|
+
// (never a path) in the pulled request; results come back through
|
|
10
|
+
// get_rules_file_result for both ops.
|
|
11
|
+
// 3. Bad skill names are refused before any request row exists.
|
|
12
|
+
// 4. A session that can serve rules files but did not report skills_capable is
|
|
13
|
+
// refused up front for skills ops (skills_target_not_found).
|
|
14
|
+
// 5. Separate single-flight lanes: a pending rules read does not block a skills
|
|
15
|
+
// list and vice versa; a second skills request while one is pending is refused.
|
|
16
|
+
// 6. Network isolation and foreign acks are ignored.
|
|
17
|
+
//
|
|
18
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/skills-session.db bun test src/node-skills-transport.test.ts
|
|
19
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
21
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
22
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
23
|
+
import { db } from "./db.js";
|
|
24
|
+
import { registerTools } from "./tools.js";
|
|
25
|
+
|
|
26
|
+
const NET = "net_skills_session";
|
|
27
|
+
const OTHER_NET = "net_skills_session_other";
|
|
28
|
+
const USER = "u_skills_session_owner";
|
|
29
|
+
const SESSION_ALIAS = "skills-session-cc";
|
|
30
|
+
const SESSION_RESUME = "cc-skills-session-1";
|
|
31
|
+
const OTHER_ALIAS = "skills-session-peer";
|
|
32
|
+
const NODE_ROW_ALIAS = "skills-session-noderow";
|
|
33
|
+
const NODE_ROW_ID = "node_skills_session_noderow";
|
|
34
|
+
const T_SESSION = "tok_skills_session_cc";
|
|
35
|
+
const T_PEER = "tok_skills_session_peer";
|
|
36
|
+
const T_OTHER_NET_SAME_ALIAS = "tok_skills_session_other_net";
|
|
37
|
+
const T_NODE_ROW = "tok_skills_session_noderow";
|
|
38
|
+
|
|
39
|
+
function cleanup() {
|
|
40
|
+
for (const net of [NET, OTHER_NET]) {
|
|
41
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
42
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
|
|
43
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
44
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
45
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
46
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
47
|
+
}
|
|
48
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function seedWorld() {
|
|
52
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
|
|
53
|
+
for (const net of [NET, OTHER_NET]) {
|
|
54
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
55
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
56
|
+
}
|
|
57
|
+
const tok = (id: string, net: string, alias: string) => db.run(
|
|
58
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
59
|
+
[id, USER, net, `node:${alias}`, `hash_${id}`],
|
|
60
|
+
);
|
|
61
|
+
tok(T_SESSION, NET, SESSION_ALIAS);
|
|
62
|
+
tok(T_PEER, NET, OTHER_ALIAS);
|
|
63
|
+
tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
|
|
64
|
+
tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
|
|
65
|
+
db.run(
|
|
66
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
67
|
+
[NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
72
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user" };
|
|
73
|
+
const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user2" };
|
|
74
|
+
const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
|
|
75
|
+
const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
|
|
76
|
+
const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
|
|
77
|
+
const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
|
|
78
|
+
|
|
79
|
+
async function connect(id: Identity) {
|
|
80
|
+
const server = new McpServer({ name: "skills-session-test", version: "1" });
|
|
81
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
82
|
+
const client = new Client({ name: "skills-session-client", version: "1" });
|
|
83
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
84
|
+
await server.connect(st);
|
|
85
|
+
await client.connect(ct);
|
|
86
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
87
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
88
|
+
const first = r.content?.[0];
|
|
89
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
|
|
90
|
+
return JSON.parse(first.text);
|
|
91
|
+
};
|
|
92
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
93
|
+
return { call, close };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const capable = (alias: string, net = NET) =>
|
|
97
|
+
db.get<{ c: number }>("SELECT skills_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
|
|
98
|
+
const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
|
|
99
|
+
|
|
100
|
+
async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
|
|
101
|
+
const c = await connect(id);
|
|
102
|
+
try {
|
|
103
|
+
return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true, skills_capable: true } : {}) });
|
|
104
|
+
} finally { await c.close(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
108
|
+
afterAll(cleanup);
|
|
109
|
+
|
|
110
|
+
async function reportRulesOnly(id: Identity, alias: string, resume: string) {
|
|
111
|
+
const c = await connect(id);
|
|
112
|
+
try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", rules_file_capable: true }); }
|
|
113
|
+
finally { await c.close(); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("skills_capable is set only by the session's own node token", () => {
|
|
117
|
+
test("bound token sets it; a report without it does not clear it; a peer cannot set it", async () => {
|
|
118
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
119
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
120
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
|
|
121
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
122
|
+
const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
|
|
123
|
+
expect(r).toMatchObject({ ok: false });
|
|
124
|
+
}, 20_000);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("list / read by alias reach a session with no nodes row", () => {
|
|
128
|
+
test("list round trip: enqueue → pull (no content) → ack JSON → result carries it", async () => {
|
|
129
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
130
|
+
const u = await connect(asUser);
|
|
131
|
+
const s = await connect(asSession);
|
|
132
|
+
try {
|
|
133
|
+
const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
134
|
+
expect(enq).toMatchObject({ ok: true, op: "skills_list" });
|
|
135
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
136
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skills_list" });
|
|
137
|
+
const payload = JSON.stringify({ skills: [{ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d" }] });
|
|
138
|
+
expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "skills", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
|
|
139
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
140
|
+
expect(res).toMatchObject({ ok: true, op: "skills_list", status: "done", content: payload });
|
|
141
|
+
} finally { await u.close(); await s.close(); }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
|
|
144
|
+
test("read round trip: the pulled request carries the skill name only", async () => {
|
|
145
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
146
|
+
const u = await connect(asUser);
|
|
147
|
+
const s = await connect(asSession);
|
|
148
|
+
try {
|
|
149
|
+
const enq = await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" });
|
|
150
|
+
expect(enq).toMatchObject({ ok: true, op: "skill_read" });
|
|
151
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
152
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skill_read", content: "ship" });
|
|
153
|
+
const payload = JSON.stringify({ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d", content: "# ship" });
|
|
154
|
+
await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "ship", exists: true, content: payload });
|
|
155
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
|
|
156
|
+
} finally { await u.close(); await s.close(); }
|
|
157
|
+
}, 20_000);
|
|
158
|
+
|
|
159
|
+
test("bad names are refused before any row is written", async () => {
|
|
160
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
161
|
+
const u = await connect(asUser);
|
|
162
|
+
try {
|
|
163
|
+
for (const name of ["..", ".", "../CLAUDE.md", "a/b", "a b"]) {
|
|
164
|
+
expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name })).toMatchObject({ ok: false, error: "invalid_skill_name" });
|
|
165
|
+
}
|
|
166
|
+
expect(rowCount()).toBe(0);
|
|
167
|
+
} finally { await u.close(); }
|
|
168
|
+
}, 20_000);
|
|
169
|
+
|
|
170
|
+
test("rules-capable but not skills-capable → skills_target_not_found, no row", async () => {
|
|
171
|
+
await reportRulesOnly(asSession, SESSION_ALIAS, SESSION_RESUME);
|
|
172
|
+
const u = await connect(asUser);
|
|
173
|
+
try {
|
|
174
|
+
expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "skills_target_not_found" });
|
|
175
|
+
expect(rowCount()).toBe(0);
|
|
176
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
177
|
+
} finally { await u.close(); }
|
|
178
|
+
}, 20_000);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("single-flight lanes", () => {
|
|
182
|
+
test("a pending rules read does not block a skills list; a second skills request is refused", async () => {
|
|
183
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
184
|
+
const u = await connect(asUser);
|
|
185
|
+
try {
|
|
186
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
187
|
+
const list = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
188
|
+
expect(list).toMatchObject({ ok: true });
|
|
189
|
+
expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
|
|
190
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "request_in_flight" });
|
|
191
|
+
} finally { await u.close(); }
|
|
192
|
+
}, 20_000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("isolation", () => {
|
|
196
|
+
test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
|
|
197
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
198
|
+
const u = await connect(asUser);
|
|
199
|
+
const other = await connect(asOtherNetSameAlias);
|
|
200
|
+
const peer = await connect(asPeer);
|
|
201
|
+
try {
|
|
202
|
+
const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
203
|
+
expect((await other.call("get_rules_file_request", {})).request).toBeNull();
|
|
204
|
+
expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
205
|
+
expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
206
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
207
|
+
} finally { await u.close(); await other.close(); await peer.close(); }
|
|
208
|
+
}, 20_000);
|
|
209
|
+
});
|
|
@@ -45,7 +45,7 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
45
45
|
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
46
46
|
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
47
47
|
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
48
|
-
"external_schedules", "runtime", "host", "process_telemetry",
|
|
48
|
+
"external_schedules", "rules_file_capable", "skills_capable", "runtime", "host", "process_telemetry",
|
|
49
49
|
],
|
|
50
50
|
task: [
|
|
51
51
|
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
package/src/rest-projections.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const SESSION_REST_COLUMNS = [
|
|
|
19
19
|
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
20
20
|
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
21
21
|
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
22
|
-
"external_schedules",
|
|
22
|
+
"external_schedules", "rules_file_capable", "skills_capable",
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
25
|
export const AUDIT_LOG_REST_COLUMNS = [
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// app#225 follow-up — rules-file requests to a SESSION that has no `nodes` row
|
|
2
|
+
// (claude-code sessions launched by `anet node start` + the channel server).
|
|
3
|
+
//
|
|
4
|
+
// Hub-side contract verified here:
|
|
5
|
+
// 1. report_status { rules_file_capable: true } marks the session capable only
|
|
6
|
+
// when the caller is a node token bound to that same alias (a user token or
|
|
7
|
+
// another node's token cannot mark it); omitting the field later does not clear it.
|
|
8
|
+
// 2. read/write_node_rules_file { alias } reach such a session: the node pulls
|
|
9
|
+
// with its own token (queue key `session:<alias>`), acks, the client polls done.
|
|
10
|
+
// 3. An alias that has neither a node row nor a capable session is refused up
|
|
11
|
+
// front (no request row, no 60 s timeout for the user to sit through).
|
|
12
|
+
// 4. Network isolation: a same-named alias in another network neither sees nor
|
|
13
|
+
// acks the request; a caller in the other network cannot enqueue to it.
|
|
14
|
+
// 5. The original node_id path is unchanged (node row wins when both exist).
|
|
15
|
+
//
|
|
16
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules-session.db bun test src/rules-file-session-target.test.ts
|
|
17
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { db } from "./db.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const NET = "net_rules_session";
|
|
25
|
+
const OTHER_NET = "net_rules_session_other";
|
|
26
|
+
const USER = "u_rules_session_owner";
|
|
27
|
+
const SESSION_ALIAS = "rules-session-cc";
|
|
28
|
+
const SESSION_RESUME = "cc-rules-session-1";
|
|
29
|
+
const OTHER_ALIAS = "rules-session-peer";
|
|
30
|
+
const NODE_ROW_ALIAS = "rules-session-noderow";
|
|
31
|
+
const NODE_ROW_ID = "node_rules_session_noderow";
|
|
32
|
+
const T_SESSION = "tok_rules_session_cc";
|
|
33
|
+
const T_PEER = "tok_rules_session_peer";
|
|
34
|
+
const T_OTHER_NET_SAME_ALIAS = "tok_rules_session_other_net";
|
|
35
|
+
const T_NODE_ROW = "tok_rules_session_noderow";
|
|
36
|
+
|
|
37
|
+
function cleanup() {
|
|
38
|
+
for (const net of [NET, OTHER_NET]) {
|
|
39
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
40
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
|
|
41
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
42
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
43
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
44
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
45
|
+
}
|
|
46
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function seedWorld() {
|
|
50
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
|
|
51
|
+
for (const net of [NET, OTHER_NET]) {
|
|
52
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
53
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
54
|
+
}
|
|
55
|
+
const tok = (id: string, net: string, alias: string) => db.run(
|
|
56
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
57
|
+
[id, USER, net, `node:${alias}`, `hash_${id}`],
|
|
58
|
+
);
|
|
59
|
+
tok(T_SESSION, NET, SESSION_ALIAS);
|
|
60
|
+
tok(T_PEER, NET, OTHER_ALIAS);
|
|
61
|
+
tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
|
|
62
|
+
tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
|
|
63
|
+
db.run(
|
|
64
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
65
|
+
[NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
70
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_session_user" };
|
|
71
|
+
const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_session_user2" };
|
|
72
|
+
const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
|
|
73
|
+
const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
|
|
74
|
+
const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
|
|
75
|
+
const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
|
|
76
|
+
|
|
77
|
+
async function connect(id: Identity) {
|
|
78
|
+
const server = new McpServer({ name: "rules-session-test", version: "1" });
|
|
79
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
80
|
+
const client = new Client({ name: "rules-session-client", version: "1" });
|
|
81
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
82
|
+
await server.connect(st);
|
|
83
|
+
await client.connect(ct);
|
|
84
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
85
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
86
|
+
const first = r.content?.[0];
|
|
87
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
|
|
88
|
+
return JSON.parse(first.text);
|
|
89
|
+
};
|
|
90
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
91
|
+
return { call, close };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const capable = (alias: string, net = NET) =>
|
|
95
|
+
db.get<{ c: number }>("SELECT rules_file_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
|
|
96
|
+
|
|
97
|
+
async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
|
|
98
|
+
const c = await connect(id);
|
|
99
|
+
try {
|
|
100
|
+
return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true } : {}) });
|
|
101
|
+
} finally { await c.close(); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
105
|
+
afterAll(cleanup);
|
|
106
|
+
|
|
107
|
+
describe("rules_file_capable is set only by the session's own node token", () => {
|
|
108
|
+
test("bound node token sets it; a later report without the field does not clear it", async () => {
|
|
109
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
110
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
111
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
|
|
112
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
113
|
+
}, 20_000);
|
|
114
|
+
|
|
115
|
+
// The hub already refuses these reports outright (network_token_required /
|
|
116
|
+
// alias_identity_mismatch); the bound-token check on the UPDATE is defence in
|
|
117
|
+
// depth. The real session row is registered first so "stays 0" is not vacuous.
|
|
118
|
+
test("a user token reporting for the alias is refused and cannot mark it capable", async () => {
|
|
119
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
|
|
120
|
+
expect(capable(SESSION_ALIAS)).toBe(0);
|
|
121
|
+
const r = await registerSession(asUser, SESSION_ALIAS, SESSION_RESUME, true);
|
|
122
|
+
expect(r).toMatchObject({ ok: false, error: "network_token_required" });
|
|
123
|
+
expect(capable(SESSION_ALIAS)).toBe(0);
|
|
124
|
+
}, 20_000);
|
|
125
|
+
|
|
126
|
+
test("another node's token is refused and cannot mark someone else's session capable", async () => {
|
|
127
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
|
|
128
|
+
const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
|
|
129
|
+
expect(r).toMatchObject({ ok: false, error: "alias_identity_mismatch" });
|
|
130
|
+
expect(capable(SESSION_ALIAS)).toBe(0);
|
|
131
|
+
}, 20_000);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("read/write by alias reach a session with no nodes row", () => {
|
|
135
|
+
test("read round trip: enqueue by alias → session pulls with its own token → ack → done", async () => {
|
|
136
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
137
|
+
const u = await connect(asUser);
|
|
138
|
+
const s = await connect(asSession);
|
|
139
|
+
try {
|
|
140
|
+
const enq = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
|
|
141
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
142
|
+
expect(db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.node_id).toBe(`session:${SESSION_ALIAS}`);
|
|
143
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
144
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "read" });
|
|
145
|
+
expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "# rules\n" })).toMatchObject({ ok: true, status: "done" });
|
|
146
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "done", file_name: "CLAUDE.md", exists: true, content: "# rules\n" });
|
|
147
|
+
} finally { await u.close(); await s.close(); }
|
|
148
|
+
}, 20_000);
|
|
149
|
+
|
|
150
|
+
test("write carries content to the session", async () => {
|
|
151
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
152
|
+
const u = await connect(asUser);
|
|
153
|
+
const s = await connect(asSession);
|
|
154
|
+
try {
|
|
155
|
+
const enq = await u.call("write_node_rules_file", { alias: SESSION_ALIAS, content: "new body\n" });
|
|
156
|
+
expect(enq).toMatchObject({ ok: true, op: "write" });
|
|
157
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
158
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "write", content: "new body\n" });
|
|
159
|
+
} finally { await u.close(); await s.close(); }
|
|
160
|
+
}, 20_000);
|
|
161
|
+
|
|
162
|
+
test("alias with neither a node row nor a capable session is refused before any row is written", async () => {
|
|
163
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
|
|
164
|
+
const u = await connect(asUser);
|
|
165
|
+
try {
|
|
166
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "rules_file_target_not_found" });
|
|
167
|
+
expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE network_id = ?1", NET)?.c).toBe(0);
|
|
168
|
+
} finally { await u.close(); }
|
|
169
|
+
}, 20_000);
|
|
170
|
+
|
|
171
|
+
test("network isolation: same alias in another network neither pulls nor acks; other-network user cannot enqueue", async () => {
|
|
172
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
173
|
+
const u = await connect(asUser);
|
|
174
|
+
const x = await connect(asOtherNetSameAlias);
|
|
175
|
+
const ou = await connect(asOtherNetUser);
|
|
176
|
+
try {
|
|
177
|
+
const enq = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
|
|
178
|
+
expect(enq.ok).toBe(true);
|
|
179
|
+
expect(await x.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
180
|
+
expect(await x.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "stolen" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
181
|
+
expect(db.get<{ s: string }>("SELECT status AS s FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.s).toBe("pending");
|
|
182
|
+
expect(await ou.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "rules_file_target_not_found" });
|
|
183
|
+
} finally { await u.close(); await x.close(); await ou.close(); }
|
|
184
|
+
}, 20_000);
|
|
185
|
+
|
|
186
|
+
test("a peer node in the same network cannot pull or ack the session's request", async () => {
|
|
187
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
188
|
+
const u = await connect(asUser);
|
|
189
|
+
const p = await connect(asPeer);
|
|
190
|
+
try {
|
|
191
|
+
const enq = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
|
|
192
|
+
expect(await p.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
193
|
+
expect(await p.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "x" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
194
|
+
} finally { await u.close(); await p.close(); }
|
|
195
|
+
}, 20_000);
|
|
196
|
+
|
|
197
|
+
test("alias that has a nodes row resolves to that node_id (original queue key)", async () => {
|
|
198
|
+
const u = await connect(asUser);
|
|
199
|
+
const n = await connect(asNodeRow);
|
|
200
|
+
try {
|
|
201
|
+
const enq = await u.call("read_node_rules_file", { alias: NODE_ROW_ALIAS });
|
|
202
|
+
expect(enq.ok).toBe(true);
|
|
203
|
+
expect(db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.node_id).toBe(NODE_ROW_ID);
|
|
204
|
+
expect((await n.call("get_rules_file_request", {})).request?.request_id).toBe(enq.request_id);
|
|
205
|
+
} finally { await u.close(); await n.close(); }
|
|
206
|
+
}, 20_000);
|
|
207
|
+
});
|
|
@@ -105,8 +105,10 @@ describe("app#225 rules file — schema has no path", () => {
|
|
|
105
105
|
const shape = schema?.shape ?? schema?._def?.shape?.() ?? schema ?? {};
|
|
106
106
|
return Object.keys(shape).sort();
|
|
107
107
|
};
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// `alias` (app#225 follow-up) names a TARGET node/session, not a file —
|
|
109
|
+
// the no-path regex below still covers it.
|
|
110
|
+
expect(shapeKeys("read_node_rules_file")).toEqual(["alias", "child_node_id", "network_id", "node_id"]);
|
|
111
|
+
expect(shapeKeys("write_node_rules_file")).toEqual(["alias", "child_node_id", "content", "network_id", "node_id"]);
|
|
110
112
|
expect(shapeKeys("get_rules_file_request")).toEqual([]);
|
|
111
113
|
// ack 的 file_name 是节点**回报**它用了哪个文件,不是客户端**指定**用哪个文件。
|
|
112
114
|
expect(shapeKeys("ack_rules_file_request")).toEqual(["content", "error", "exists", "file_name", "request_id", "status"]);
|
package/src/server.ts
CHANGED
|
@@ -1680,6 +1680,9 @@ return Bun.serve({
|
|
|
1680
1680
|
return {
|
|
1681
1681
|
...publicSession,
|
|
1682
1682
|
external_schedules: externalSchedules,
|
|
1683
|
+
// app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
|
|
1684
|
+
rules_file_capable: s.rules_file_capable === 1,
|
|
1685
|
+
skills_capable: s.skills_capable === 1,
|
|
1683
1686
|
model: s.model ?? null,
|
|
1684
1687
|
runtime: normalizeRuntime(s.agent),
|
|
1685
1688
|
host: {
|
package/src/tools.ts
CHANGED
|
@@ -713,8 +713,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
713
713
|
create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
|
|
714
714
|
}).optional(),
|
|
715
715
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
716
|
+
// app#225 follow-up — the reporting process answers the `rules_file`
|
|
717
|
+
// doorbell (agent-node, and the claude-code channel server node-server).
|
|
718
|
+
// Top-level instead of config_snapshot: claude-code sessions usually have
|
|
719
|
+
// no `nodes` row, and config_snapshot is persisted per node_id. Only a
|
|
720
|
+
// node token bound to this same alias can set it; see the UPDATE below.
|
|
721
|
+
rules_file_capable: z.literal(true).optional(),
|
|
722
|
+
// Node skills view — same doorbell, ops skills_list / skill_read.
|
|
723
|
+
skills_capable: z.literal(true).optional(),
|
|
716
724
|
},
|
|
717
|
-
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap }) => {
|
|
725
|
+
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable }) => {
|
|
718
726
|
const effectiveNetId = getNetworkId(netId);
|
|
719
727
|
const sessionNetId = effectiveNetId ?? "default";
|
|
720
728
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -913,6 +921,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
913
921
|
handover.registered_at ?? null],
|
|
914
922
|
);
|
|
915
923
|
}
|
|
924
|
+
// app#225 follow-up — separate statement on purpose: the INSERT above is
|
|
925
|
+
// byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
|
|
926
|
+
// and only by a node token bound to this alias (a user token or another
|
|
927
|
+
// node cannot mark someone else's session as able to serve its files).
|
|
928
|
+
if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
929
|
+
db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
930
|
+
}
|
|
931
|
+
if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
932
|
+
db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
933
|
+
}
|
|
916
934
|
if (host || proc) {
|
|
917
935
|
db.run(
|
|
918
936
|
`INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, 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, created_at)
|
|
@@ -3100,23 +3118,67 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3100
3118
|
const RULES_FILE_MAX_BYTES = 256 * 1024;
|
|
3101
3119
|
const RULES_REQUEST_STALE_MS = 60_000;
|
|
3102
3120
|
|
|
3121
|
+
// Node skills view — skills_list / skill_read ride the same queue + doorbell.
|
|
3122
|
+
// `content` carries the skill NAME for skill_read (never a path; the node
|
|
3123
|
+
// validates it again against [A-Za-z0-9._-] and resolves it under its own
|
|
3124
|
+
// runtime's skills roots — node-skills.ts).
|
|
3125
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
3126
|
+
const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
|
|
3127
|
+
|
|
3103
3128
|
const enqueueRulesFileRequest = (
|
|
3104
|
-
op: "read" | "write",
|
|
3105
|
-
a: { node_id?: string; child_node_id?: string; network_id?: string; content?: string },
|
|
3129
|
+
op: "read" | "write" | "skills_list" | "skill_read",
|
|
3130
|
+
a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
|
|
3106
3131
|
) => {
|
|
3107
|
-
const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
|
|
3108
|
-
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3109
|
-
const nodeId = idArg.node_id;
|
|
3110
3132
|
const effectiveNetId = getNetworkId(a.network_id);
|
|
3111
|
-
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
if (!node) {
|
|
3115
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
3133
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
|
|
3134
|
+
if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
|
|
3135
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
|
|
3116
3136
|
}
|
|
3117
|
-
|
|
3118
|
-
|
|
3137
|
+
|
|
3138
|
+
// Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
|
|
3139
|
+
// an alias. An alias resolves to its `nodes` row when there is one; otherwise
|
|
3140
|
+
// to a live session in the caller's network that advertised
|
|
3141
|
+
// rules_file_capable (claude-code sessions mostly have no `nodes` row). The
|
|
3142
|
+
// queue key for such a session is `session:<alias>`, network-scoped.
|
|
3143
|
+
let node: { node_id: string; alias: string; network_id: string | null } | null = null;
|
|
3144
|
+
if (a.node_id || a.child_node_id || !a.alias) {
|
|
3145
|
+
const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
|
|
3146
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3147
|
+
const nodeId = idArg.node_id;
|
|
3148
|
+
const resolved = resolveTargetNode(nodeId, effectiveNetId);
|
|
3149
|
+
if (!resolved.row) {
|
|
3150
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
3151
|
+
}
|
|
3152
|
+
if (!resolved.sec1Ok) {
|
|
3153
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
|
|
3154
|
+
}
|
|
3155
|
+
node = resolved.row as any;
|
|
3156
|
+
} else {
|
|
3157
|
+
const scopeNet = effectiveNetId || "default";
|
|
3158
|
+
const byAlias = db.get<{ node_id: string; alias: string; network_id: string | null }>(
|
|
3159
|
+
"SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3160
|
+
a.alias,
|
|
3161
|
+
scopeNet,
|
|
3162
|
+
);
|
|
3163
|
+
if (byAlias) {
|
|
3164
|
+
node = byAlias;
|
|
3165
|
+
} else {
|
|
3166
|
+
const session = db.get<{ alias: string; network_id: string | null }>(
|
|
3167
|
+
isSkillsOp(op)
|
|
3168
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3169
|
+
: "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
|
|
3170
|
+
a.alias,
|
|
3171
|
+
scopeNet,
|
|
3172
|
+
);
|
|
3173
|
+
if (!session) {
|
|
3174
|
+
return isSkillsOp(op)
|
|
3175
|
+
? { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "skills_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its skills (its channel server may be too old)" }) }] }
|
|
3176
|
+
: { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
|
|
3177
|
+
}
|
|
3178
|
+
node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
|
|
3179
|
+
}
|
|
3119
3180
|
}
|
|
3181
|
+
const nodeId = node.node_id;
|
|
3120
3182
|
if (op === "write") {
|
|
3121
3183
|
if (typeof a.content !== "string") {
|
|
3122
3184
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
|
|
@@ -3129,8 +3191,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3129
3191
|
|
|
3130
3192
|
// 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
|
|
3131
3193
|
// 非终态,不回收就把这个节点锁死。
|
|
3194
|
+
// Separate single-flight lanes: the client opens the rules file and the
|
|
3195
|
+
// skills list together; one must not block the other.
|
|
3132
3196
|
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3133
|
-
|
|
3197
|
+
isSkillsOp(op)
|
|
3198
|
+
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
|
|
3199
|
+
: "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('read', 'write') ORDER BY created_at DESC LIMIT 1",
|
|
3134
3200
|
nodeId,
|
|
3135
3201
|
);
|
|
3136
3202
|
if (inFlight) {
|
|
@@ -3148,7 +3214,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3148
3214
|
const networkId = node.network_id || "default";
|
|
3149
3215
|
db.run(
|
|
3150
3216
|
`INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
3151
|
-
[requestId, nodeId, networkId, op, op === "write" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3217
|
+
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3152
3218
|
);
|
|
3153
3219
|
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3154
3220
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
@@ -3157,8 +3223,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3157
3223
|
server.tool(
|
|
3158
3224
|
"read_node_rules_file",
|
|
3159
3225
|
"Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
3160
|
-
{
|
|
3161
|
-
|
|
3226
|
+
{
|
|
3227
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3228
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
|
|
3229
|
+
network_id: z.string().max(200).optional(),
|
|
3230
|
+
},
|
|
3231
|
+
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
|
|
3162
3232
|
);
|
|
3163
3233
|
|
|
3164
3234
|
server.tool(
|
|
@@ -3166,10 +3236,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3166
3236
|
"Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
3167
3237
|
{
|
|
3168
3238
|
...NODE_ID_ALIAS_FIELDS,
|
|
3239
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
|
|
3169
3240
|
content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
|
|
3170
3241
|
network_id: z.string().max(200).optional(),
|
|
3171
3242
|
},
|
|
3172
|
-
async ({ node_id, child_node_id, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, content, network_id }),
|
|
3243
|
+
async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
|
|
3244
|
+
);
|
|
3245
|
+
|
|
3246
|
+
server.tool(
|
|
3247
|
+
"list_node_skills",
|
|
3248
|
+
"Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
|
|
3249
|
+
{
|
|
3250
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3251
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
|
|
3252
|
+
network_id: z.string().max(200).optional(),
|
|
3253
|
+
},
|
|
3254
|
+
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
|
|
3255
|
+
);
|
|
3256
|
+
|
|
3257
|
+
server.tool(
|
|
3258
|
+
"read_node_skill",
|
|
3259
|
+
"Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
|
|
3260
|
+
{
|
|
3261
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3262
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
|
|
3263
|
+
name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
|
|
3264
|
+
network_id: z.string().max(200).optional(),
|
|
3265
|
+
},
|
|
3266
|
+
async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
|
|
3173
3267
|
);
|
|
3174
3268
|
|
|
3175
3269
|
server.tool(
|
|
@@ -3189,12 +3283,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3189
3283
|
callerAlias,
|
|
3190
3284
|
enforceNetworkId,
|
|
3191
3285
|
);
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3286
|
+
// app#225 follow-up — no `nodes` row: this is a session-only target
|
|
3287
|
+
// (claude-code). Its queue key is `session:<alias>`, always paired with the
|
|
3288
|
+
// caller's network so a same-named alias in another network never matches.
|
|
3289
|
+
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3195
3290
|
const req = db.get<any>(
|
|
3196
|
-
"SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
|
|
3197
|
-
|
|
3291
|
+
"SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND network_id = ?2 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
|
|
3292
|
+
queueKey,
|
|
3293
|
+
enforceNetworkId,
|
|
3198
3294
|
);
|
|
3199
3295
|
if (!req) {
|
|
3200
3296
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
|
|
@@ -3211,7 +3307,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3211
3307
|
request: {
|
|
3212
3308
|
request_id: req.request_id,
|
|
3213
3309
|
op: req.op,
|
|
3214
|
-
...(req.op === "write" ? { content: req.content ?? "" } : {}),
|
|
3310
|
+
...(req.op === "write" || req.op === "skill_read" ? { content: req.content ?? "" } : {}),
|
|
3215
3311
|
},
|
|
3216
3312
|
}),
|
|
3217
3313
|
}],
|
|
@@ -3242,15 +3338,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3242
3338
|
callerAlias,
|
|
3243
3339
|
enforceNetworkId,
|
|
3244
3340
|
);
|
|
3245
|
-
|
|
3246
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
3247
|
-
}
|
|
3341
|
+
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3248
3342
|
const req = db.get<any>(
|
|
3249
|
-
"SELECT request_id, node_id, status FROM node_rules_requests WHERE request_id = ?1",
|
|
3343
|
+
"SELECT request_id, node_id, network_id, status FROM node_rules_requests WHERE request_id = ?1",
|
|
3250
3344
|
requestId,
|
|
3251
3345
|
);
|
|
3252
|
-
// 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。
|
|
3253
|
-
if (!req || req.node_id !==
|
|
3346
|
+
// 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
|
|
3347
|
+
if (!req || req.node_id !== queueKey || req.network_id !== enforceNetworkId) {
|
|
3254
3348
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
|
|
3255
3349
|
}
|
|
3256
3350
|
if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
|
|
@@ -3308,7 +3402,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3308
3402
|
status,
|
|
3309
3403
|
file_name: row.file_name ?? null,
|
|
3310
3404
|
exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
|
|
3311
|
-
...(status === "done" && row.op
|
|
3405
|
+
...(status === "done" && row.op !== "write" ? { content: row.result_content ?? "" } : {}),
|
|
3312
3406
|
error: row.error ?? null,
|
|
3313
3407
|
age_ms: ageMs,
|
|
3314
3408
|
}),
|
package/src/uploads.ts
CHANGED
|
@@ -336,7 +336,7 @@ export type TaskAttachment = {
|
|
|
336
336
|
* returns `{file_id, path}` where `path` is a hub-machine-local
|
|
337
337
|
* absolute path. Senders sometimes echo `path` back into meta.attachments.
|
|
338
338
|
* When the receiving agent runs on a different host (Vincent 2026-06-30
|
|
339
|
-
*
|
|
339
|
+
* a partner-team host case), that path string is meaningless. agent-node's
|
|
340
340
|
* fetch-attachment.ts will fetch via `file_id` regardless, but leaving
|
|
341
341
|
* the stale `path` in the JSON misleads any tooling that reads it.
|
|
342
342
|
*
|