@sleep2agi/commhub-server 0.9.0-preview.56 → 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/db.ts +3 -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/server.ts +1 -0
- package/src/tools.ts +55 -9
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/db.ts
CHANGED
|
@@ -127,6 +127,9 @@ for (const col of [
|
|
|
127
127
|
// that answers the rules-file doorbell. Sticky: set by a bound node token, never
|
|
128
128
|
// cleared by reports that omit it (agent-node status-only reports do).
|
|
129
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" },
|
|
130
133
|
]) {
|
|
131
134
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
132
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", "rules_file_capable", "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", "rules_file_capable",
|
|
22
|
+
"external_schedules", "rules_file_capable", "skills_capable",
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
25
|
export const AUDIT_LOG_REST_COLUMNS = [
|
package/src/server.ts
CHANGED
|
@@ -1682,6 +1682,7 @@ return Bun.serve({
|
|
|
1682
1682
|
external_schedules: externalSchedules,
|
|
1683
1683
|
// app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
|
|
1684
1684
|
rules_file_capable: s.rules_file_capable === 1,
|
|
1685
|
+
skills_capable: s.skills_capable === 1,
|
|
1685
1686
|
model: s.model ?? null,
|
|
1686
1687
|
runtime: normalizeRuntime(s.agent),
|
|
1687
1688
|
host: {
|
package/src/tools.ts
CHANGED
|
@@ -719,8 +719,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
719
719
|
// no `nodes` row, and config_snapshot is persisted per node_id. Only a
|
|
720
720
|
// node token bound to this same alias can set it; see the UPDATE below.
|
|
721
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(),
|
|
722
724
|
},
|
|
723
|
-
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 }) => {
|
|
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 }) => {
|
|
724
726
|
const effectiveNetId = getNetworkId(netId);
|
|
725
727
|
const sessionNetId = effectiveNetId ?? "default";
|
|
726
728
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -926,6 +928,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
926
928
|
if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
927
929
|
db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
928
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
|
+
}
|
|
929
934
|
if (host || proc) {
|
|
930
935
|
db.run(
|
|
931
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)
|
|
@@ -3113,12 +3118,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3113
3118
|
const RULES_FILE_MAX_BYTES = 256 * 1024;
|
|
3114
3119
|
const RULES_REQUEST_STALE_MS = 60_000;
|
|
3115
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
|
+
|
|
3116
3128
|
const enqueueRulesFileRequest = (
|
|
3117
|
-
op: "read" | "write",
|
|
3129
|
+
op: "read" | "write" | "skills_list" | "skill_read",
|
|
3118
3130
|
a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
|
|
3119
3131
|
) => {
|
|
3120
3132
|
const effectiveNetId = getNetworkId(a.network_id);
|
|
3121
|
-
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
|
|
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 .." }) }] };
|
|
3136
|
+
}
|
|
3122
3137
|
|
|
3123
3138
|
// Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
|
|
3124
3139
|
// an alias. An alias resolves to its `nodes` row when there is one; otherwise
|
|
@@ -3149,12 +3164,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3149
3164
|
node = byAlias;
|
|
3150
3165
|
} else {
|
|
3151
3166
|
const session = db.get<{ alias: string; network_id: string | null }>(
|
|
3152
|
-
|
|
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",
|
|
3153
3170
|
a.alias,
|
|
3154
3171
|
scopeNet,
|
|
3155
3172
|
);
|
|
3156
3173
|
if (!session) {
|
|
3157
|
-
return
|
|
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)" }) }] };
|
|
3158
3177
|
}
|
|
3159
3178
|
node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
|
|
3160
3179
|
}
|
|
@@ -3172,8 +3191,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3172
3191
|
|
|
3173
3192
|
// 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
|
|
3174
3193
|
// 非终态,不回收就把这个节点锁死。
|
|
3194
|
+
// Separate single-flight lanes: the client opens the rules file and the
|
|
3195
|
+
// skills list together; one must not block the other.
|
|
3175
3196
|
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3176
|
-
|
|
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",
|
|
3177
3200
|
nodeId,
|
|
3178
3201
|
);
|
|
3179
3202
|
if (inFlight) {
|
|
@@ -3191,7 +3214,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3191
3214
|
const networkId = node.network_id || "default";
|
|
3192
3215
|
db.run(
|
|
3193
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)`,
|
|
3194
|
-
[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"],
|
|
3195
3218
|
);
|
|
3196
3219
|
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3197
3220
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
@@ -3220,6 +3243,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3220
3243
|
async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
|
|
3221
3244
|
);
|
|
3222
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 }),
|
|
3267
|
+
);
|
|
3268
|
+
|
|
3223
3269
|
server.tool(
|
|
3224
3270
|
"get_rules_file_request",
|
|
3225
3271
|
"Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
|
|
@@ -3261,7 +3307,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3261
3307
|
request: {
|
|
3262
3308
|
request_id: req.request_id,
|
|
3263
3309
|
op: req.op,
|
|
3264
|
-
...(req.op === "write" ? { content: req.content ?? "" } : {}),
|
|
3310
|
+
...(req.op === "write" || req.op === "skill_read" ? { content: req.content ?? "" } : {}),
|
|
3265
3311
|
},
|
|
3266
3312
|
}),
|
|
3267
3313
|
}],
|
|
@@ -3356,7 +3402,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3356
3402
|
status,
|
|
3357
3403
|
file_name: row.file_name ?? null,
|
|
3358
3404
|
exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
|
|
3359
|
-
...(status === "done" && row.op
|
|
3405
|
+
...(status === "done" && row.op !== "write" ? { content: row.result_content ?? "" } : {}),
|
|
3360
3406
|
error: row.error ?? null,
|
|
3361
3407
|
age_ms: ageMs,
|
|
3362
3408
|
}),
|