@sleep2agi/commhub-server 0.9.0-preview.156 → 0.9.0-preview.158
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/api-nodes-shape.test.ts +95 -4
- package/src/node-attrs.test.ts +41 -0
- package/src/node-permissions.ts +1 -0
- package/src/opencode-catalog-report-http.test.ts +139 -0
- package/src/requirement-tag-ops-http.test.ts +42 -0
- package/src/scheduled-requirement-wake-http.test.ts +7 -1
- package/src/server.ts +27 -2
- package/src/tool-audience-http.test.ts +6 -0
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +65 -28
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.158",
|
|
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",
|
|
@@ -36,10 +36,25 @@ afterAll(() => {
|
|
|
36
36
|
// the dashboard / external clients see.
|
|
37
37
|
function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
38
38
|
let role: string | null = null;
|
|
39
|
+
// #2293 —— 与 handler 同一套读法:快照可能是文本或对象;解析失败/形状不对 = 这一格没有。
|
|
40
|
+
let opencodeCatalog: string[] | null = null;
|
|
41
|
+
let opencodeGeneration: "v1" | "v2" | null = null;
|
|
42
|
+
let opencodeMode: string | null = null;
|
|
43
|
+
let opencodeServer: string | null = null;
|
|
39
44
|
const snap = r.config_snapshot;
|
|
40
45
|
if (snap) {
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
let parsed: any = null;
|
|
47
|
+
try { parsed = typeof snap === "string" ? JSON.parse(snap) : snap; }
|
|
48
|
+
catch { parsed = null; }
|
|
49
|
+
role = parsed?.role ?? null;
|
|
50
|
+
const raw = parsed?.opencode_catalog;
|
|
51
|
+
if (Array.isArray(raw) && raw.every((m: unknown) => typeof m === "string" && m.length <= 400)) {
|
|
52
|
+
opencodeCatalog = raw.slice(0, 400);
|
|
53
|
+
}
|
|
54
|
+
const flags = parsed?.flags;
|
|
55
|
+
if (flags?.opencodeGeneration === "v1" || flags?.opencodeGeneration === "v2") opencodeGeneration = flags.opencodeGeneration;
|
|
56
|
+
if (typeof flags?.opencodeMode === "string" && flags.opencodeMode.length <= 32) opencodeMode = flags.opencodeMode;
|
|
57
|
+
if (typeof parsed?.opencode_server === "string" && parsed.opencode_server.length <= 200) opencodeServer = parsed.opencode_server;
|
|
43
58
|
}
|
|
44
59
|
const { config_snapshot, ...rest } = r;
|
|
45
60
|
return {
|
|
@@ -47,6 +62,10 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
47
62
|
tags: parseStoredTags(r.tags),
|
|
48
63
|
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
64
|
role,
|
|
65
|
+
opencode_catalog: opencodeCatalog,
|
|
66
|
+
opencode_generation: opencodeGeneration,
|
|
67
|
+
opencode_mode: opencodeMode,
|
|
68
|
+
opencode_server: opencodeServer,
|
|
50
69
|
};
|
|
51
70
|
}
|
|
52
71
|
|
|
@@ -83,7 +102,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
83
102
|
// Expected fields present (13 = 12 contract + new role)
|
|
84
103
|
for (const k of ["node_id", "node_name", "alias", "runtime", "model",
|
|
85
104
|
"config_path", "channels", "server", "hostname",
|
|
86
|
-
"network_id", "created_at", "updated_at", "role"]) {
|
|
105
|
+
"network_id", "created_at", "updated_at", "role", "opencode_catalog"]) {
|
|
87
106
|
expect(row).toHaveProperty(k);
|
|
88
107
|
}
|
|
89
108
|
// Internal fields NOT present
|
|
@@ -100,7 +119,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
100
119
|
expect(Object.keys(row).sort()).toEqual([
|
|
101
120
|
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
121
|
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
-
"node_name", "
|
|
122
|
+
"node_name", "opencode_catalog", "opencode_generation", "opencode_mode",
|
|
123
|
+
"opencode_server", "role", "runtime", "server", "tags", "team",
|
|
104
124
|
"updated_at",
|
|
105
125
|
]);
|
|
106
126
|
});
|
|
@@ -186,3 +206,74 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
186
206
|
expect(cfgRow?.config_snapshot).toContain("claude-opus");
|
|
187
207
|
});
|
|
188
208
|
});
|
|
209
|
+
|
|
210
|
+
// #2293(admin 2026-10-25)—— 节点自报的模型清单必须能到 /api/nodes:
|
|
211
|
+
// 「没有 daemon 绑定」的节点,客户端就是靠这一格给建议列表(否则只能手填)。
|
|
212
|
+
describe("#2293 opencode_catalog projection", () => {
|
|
213
|
+
const insert = (snapshot: unknown) => db.run(
|
|
214
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
215
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
216
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK, typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot)],
|
|
217
|
+
);
|
|
218
|
+
const read = () => mapRow(db.get<Record<string, unknown>>(
|
|
219
|
+
`SELECT node_id, node_name, alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1`,
|
|
220
|
+
TEST_NODE,
|
|
221
|
+
)!);
|
|
222
|
+
|
|
223
|
+
test("valid list reaches the row, order kept", () => {
|
|
224
|
+
insert({ opencode_catalog: ["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"] });
|
|
225
|
+
expect(read().opencode_catalog).toEqual(["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("absent / malformed / wrong shape → null, row still served", () => {
|
|
229
|
+
insert({ model: "x" });
|
|
230
|
+
expect(read().opencode_catalog).toBeNull();
|
|
231
|
+
insert("not-json{");
|
|
232
|
+
expect(read().opencode_catalog).toBeNull();
|
|
233
|
+
// 混了非字符串 → 整格作废(不猜、不部分展示)
|
|
234
|
+
insert({ opencode_catalog: ["a/b", 7] });
|
|
235
|
+
expect(read().opencode_catalog).toBeNull();
|
|
236
|
+
// 空列表是合法但无意义 → 照原样给出(客户端自己判断要不要建列表)
|
|
237
|
+
insert({ opencode_catalog: [] });
|
|
238
|
+
expect(read().opencode_catalog).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// #2293(PR #1092 在真机上拿不到值的原因)—— /api/nodes 不带整个 config_snapshot,
|
|
243
|
+
// 但「Runtime · V2」「OpenCode Server」两行只要三个标量:代际、模式、loopback 端点。
|
|
244
|
+
describe("#2293 opencode_* scalars", () => {
|
|
245
|
+
test("generation / mode / server come from the snapshot, nothing else leaks", () => {
|
|
246
|
+
db.run(
|
|
247
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
248
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
249
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK, JSON.stringify({
|
|
250
|
+
flags: { opencodeGeneration: "v2", opencodeMode: "copresence", secretOther: "must-not-leak" },
|
|
251
|
+
opencode_server: "http://127.0.0.1:4096",
|
|
252
|
+
})],
|
|
253
|
+
);
|
|
254
|
+
const row = mapRow(db.get<Record<string, unknown>>(
|
|
255
|
+
`SELECT node_id, node_name, alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1`,
|
|
256
|
+
TEST_NODE,
|
|
257
|
+
)!);
|
|
258
|
+
expect(row.opencode_generation).toBe("v2");
|
|
259
|
+
expect(row.opencode_mode).toBe("copresence");
|
|
260
|
+
expect(row.opencode_server).toBe("http://127.0.0.1:4096");
|
|
261
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
262
|
+
expect(JSON.stringify(row)).not.toContain("must-not-leak");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("unknown generation / absent fields → null (old nodes keep rendering)", () => {
|
|
266
|
+
db.run(
|
|
267
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
268
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
269
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK, JSON.stringify({ flags: { opencodeGeneration: "v9" } })],
|
|
270
|
+
);
|
|
271
|
+
const row = mapRow(db.get<Record<string, unknown>>(
|
|
272
|
+
`SELECT node_id, node_name, alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1`,
|
|
273
|
+
TEST_NODE,
|
|
274
|
+
)!);
|
|
275
|
+
expect(row.opencode_generation).toBeNull();
|
|
276
|
+
expect(row.opencode_mode).toBeNull();
|
|
277
|
+
expect(row.opencode_server).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/node-attrs.test.ts
CHANGED
|
@@ -215,3 +215,44 @@ describe("node display attrs — auth + scope", () => {
|
|
|
215
215
|
expect(r.status).toBe(404);
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
// #2293(admin 2026-10-25)—— 走**真正**的 HTTP,不镜像 handler:
|
|
220
|
+
// 客户端「Runtime · V2」「OpenCode Server」和「模型可选列表」要的三样东西
|
|
221
|
+
// (代际 / 模式 / loopback 端点)与「这台机器上报的模型 id 清单」都在 /api/nodes 上,
|
|
222
|
+
// 而整个 config_snapshot 仍然不外泄(终端 #312 的约定)。
|
|
223
|
+
describe("#2293 opencode bits on GET /api/nodes (real HTTP)", () => {
|
|
224
|
+
const OC_NODE = "node_attrs_opencode";
|
|
225
|
+
const OC_ALIAS = "attrs-opencode-node";
|
|
226
|
+
|
|
227
|
+
test("generation / mode / server / catalog are projected; config_snapshot is not", async () => {
|
|
228
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [OC_NODE]);
|
|
229
|
+
db.run(
|
|
230
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id, config_snapshot)
|
|
231
|
+
VALUES (?1, 'attrs-oc', ?2, 'opencode-sdk', 'deepseek/deepseek-flash', ?3, ?4)`,
|
|
232
|
+
[OC_NODE, OC_ALIAS, NET, JSON.stringify({
|
|
233
|
+
model: "deepseek/deepseek-flash",
|
|
234
|
+
flags: { opencodeGeneration: "v2", opencodeMode: "copresence", daemonSideSecret: "must-not-leak" },
|
|
235
|
+
opencode_server: "http://127.0.0.1:4096",
|
|
236
|
+
opencode_catalog: ["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"],
|
|
237
|
+
})],
|
|
238
|
+
);
|
|
239
|
+
const row = (await listNodes()).find((r) => r.node_id === OC_NODE)!;
|
|
240
|
+
expect(row).toBeDefined();
|
|
241
|
+
expect(row.opencode_generation).toBe("v2");
|
|
242
|
+
expect(row.opencode_mode).toBe("copresence");
|
|
243
|
+
expect(row.opencode_server).toBe("http://127.0.0.1:4096");
|
|
244
|
+
expect(row.opencode_catalog).toEqual(["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"]);
|
|
245
|
+
// 整个快照仍然不出现,且 flags 里的其它键不外泄。
|
|
246
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
247
|
+
expect(JSON.stringify(row)).not.toContain("must-not-leak");
|
|
248
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [OC_NODE]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a node that never reported them reads as null / [] (old node, no invented values)", async () => {
|
|
252
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
253
|
+
expect(row.opencode_generation ?? null).toBeNull();
|
|
254
|
+
expect(row.opencode_mode ?? null).toBeNull();
|
|
255
|
+
expect(row.opencode_server ?? null).toBeNull();
|
|
256
|
+
expect(row.opencode_catalog ?? null).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
package/src/node-permissions.ts
CHANGED
|
@@ -259,6 +259,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
259
259
|
request_adopt_node: "human_only", unadopt_node: "human_only",
|
|
260
260
|
get_adopt_request: "always", ack_adopt_request: "always",
|
|
261
261
|
send_desktop_message: "always", // 发给人的私信,不是派活
|
|
262
|
+
requirement_tags: "requirements",
|
|
262
263
|
// 读
|
|
263
264
|
get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
|
|
264
265
|
list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// #2293(admin 2026-10-25)—— 从**节点的真实上报**一路走到 `/api/nodes` 的投影。
|
|
2
|
+
//
|
|
3
|
+
// 为什么这条要单独写:`api-nodes-shape.test.ts` 是**镜像 handler** 的(文件里自己声明会漂),
|
|
4
|
+
// `node-attrs.test.ts` 是直接往库里塞一行快照。两者都没有走 `report_status` 的 zod schema ——
|
|
5
|
+
// 而 schema 恰恰是「节点发了、Hub 上却什么都没有」最容易出事的地方(#1545:往非 strict 对象里
|
|
6
|
+
// 加键会被静默丢弃)。这里用真 node 令牌 + 真 MCP `tools/call` + 真 `GET /api/nodes`。
|
|
7
|
+
//
|
|
8
|
+
// 断言三件事:
|
|
9
|
+
// 1. `opencode_catalog` / `opencode_server` / flags 里的代际·模式,经 schema 后**落到库里**;
|
|
10
|
+
// 2. `/api/nodes` 把它们投影出来,且**整个 config_snapshot 不外泄**;
|
|
11
|
+
// 3. 形状不对(嵌对象、混非字符串)时**心跳照收**,只是这一格没有 —— 不许拒整份上报。
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
18
|
+
import { db } from "./db.js";
|
|
19
|
+
|
|
20
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-catalog-report-"));
|
|
21
|
+
process.env.COMMHUB_DB ||= join(DIR, "commhub.db");
|
|
22
|
+
|
|
23
|
+
const stamp = `${process.pid}_${Date.now()}`;
|
|
24
|
+
const NET_ALIAS = `catalog-${stamp}`;
|
|
25
|
+
let hub: any;
|
|
26
|
+
let base = "";
|
|
27
|
+
let adminToken = "";
|
|
28
|
+
let nodeToken = "";
|
|
29
|
+
let networkId = "";
|
|
30
|
+
let nodeId = "";
|
|
31
|
+
|
|
32
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
33
|
+
const r = await fetch(`${base}${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
36
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
let json: any = null;
|
|
40
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* MCP may answer as SSE */ }
|
|
41
|
+
return { status: r.status, body: json, text };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 真 MCP `tools/call report_status`,和 agent-node 心跳走同一条入参路径。 */
|
|
45
|
+
async function reportStatus(snapshot: Record<string, unknown>) {
|
|
46
|
+
const r = await fetch(`${base}/mcp`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { Authorization: `Bearer ${nodeToken}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
51
|
+
params: {
|
|
52
|
+
name: "report_status",
|
|
53
|
+
arguments: {
|
|
54
|
+
resume_id: `r_catalog_${stamp}`, alias: NET_ALIAS, status: "idle", node_id: nodeId,
|
|
55
|
+
runtime: "opencode-sdk",
|
|
56
|
+
config_snapshot: { model: "deepseek/deepseek-flash", ...snapshot },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
await r.text();
|
|
62
|
+
return r.status;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function listNodes() {
|
|
66
|
+
const r = await send(adminToken, "GET", `/api/nodes?network_id=${networkId}`);
|
|
67
|
+
expect(r.status).toBe(200);
|
|
68
|
+
return (r.body?.nodes ?? []) as Array<Record<string, unknown>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
const admin = register(`catalog_admin_${stamp}`, "CatalogAdmin123!", undefined, "Catalog Admin");
|
|
73
|
+
expect(admin.ok).toBe(true);
|
|
74
|
+
adminToken = admin.token!;
|
|
75
|
+
networkId = admin.network_id!;
|
|
76
|
+
const ownerId = admin.user!.user_id;
|
|
77
|
+
nodeId = `n_catalog_${stamp}`;
|
|
78
|
+
const minted = createNetworkTokenForNode(ownerId, networkId, NET_ALIAS, nodeId);
|
|
79
|
+
expect(minted.ok).toBe(true);
|
|
80
|
+
nodeToken = minted.token!;
|
|
81
|
+
const mod: any = await import("./server.js");
|
|
82
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
83
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
84
|
+
}, 30_000);
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
try { hub?.stop?.(true); } catch { /* already down */ }
|
|
88
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("#2293 node-reported OpenCode catalog reaches GET /api/nodes", () => {
|
|
92
|
+
test("a real report_status lands the catalog and the scalars on the node row", async () => {
|
|
93
|
+
expect(await reportStatus({
|
|
94
|
+
flags: { opencodeGeneration: "v2", opencodeMode: "copresence", daemonSideSecret: "must-not-leak" },
|
|
95
|
+
opencode_catalog: ["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"],
|
|
96
|
+
opencode_server: "http://127.0.0.1:4096",
|
|
97
|
+
})).toBe(200);
|
|
98
|
+
|
|
99
|
+
const stored = db.get<{ config_snapshot: string }>("SELECT config_snapshot FROM nodes WHERE node_id = ?1", nodeId)!;
|
|
100
|
+
expect(JSON.parse(stored.config_snapshot).opencode_catalog).toEqual([
|
|
101
|
+
"deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const row = (await listNodes()).find(r => r.node_id === nodeId)!;
|
|
105
|
+
expect(row).toBeDefined();
|
|
106
|
+
expect(row.opencode_catalog).toEqual(["deepseek/deepseek-flash", "opencode/mimo-v2.6-flash-free"]);
|
|
107
|
+
expect(row.opencode_generation).toBe("v2");
|
|
108
|
+
expect(row.opencode_mode).toBe("copresence");
|
|
109
|
+
expect(row.opencode_server).toBe("http://127.0.0.1:4096");
|
|
110
|
+
// 整个快照仍然不出现在这一行里;flags 中的其它键也不外泄。
|
|
111
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
112
|
+
expect(JSON.stringify(row)).not.toContain("must-not-leak");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a malformed catalog drops that field only — the heartbeat is still accepted", async () => {
|
|
116
|
+
expect(await reportStatus({
|
|
117
|
+
flags: { opencodeGeneration: "v2" },
|
|
118
|
+
// 嵌对象 + 混入数字:形状不对,schema 的 `.catch(undefined)` 应当只丢这一格。
|
|
119
|
+
opencode_catalog: { models: ["deepseek/deepseek-flash"] },
|
|
120
|
+
opencode_server: 12345,
|
|
121
|
+
})).toBe(200);
|
|
122
|
+
|
|
123
|
+
const row = (await listNodes()).find(r => r.node_id === nodeId)!;
|
|
124
|
+
expect(row).toBeDefined();
|
|
125
|
+
expect(row.opencode_catalog).toBeNull();
|
|
126
|
+
expect(row.opencode_server).toBeNull();
|
|
127
|
+
// 代际这一格仍然照常投影(没被同一条错误连带丢掉)。
|
|
128
|
+
expect(row.opencode_generation).toBe("v2");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a node that never reported the fields reads as null on every one of them", async () => {
|
|
132
|
+
const other = (await listNodes()).find(r => r.node_id !== nodeId);
|
|
133
|
+
if (!other) return; // 该网络里只有一个节点时无需断言(不是失败)
|
|
134
|
+
expect(other.opencode_catalog ?? null).toBeNull();
|
|
135
|
+
expect(other.opencode_generation ?? null).toBeNull();
|
|
136
|
+
expect(other.opencode_mode ?? null).toBeNull();
|
|
137
|
+
expect(other.opencode_server ?? null).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -381,3 +381,45 @@ describe("改父级 reparent —— 被拒绝时不留副作用", () => {
|
|
|
381
381
|
expect(after?.registered ?? false).toBe(false);
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
|
+
|
|
385
|
+
// ── MCP 面(#2204 缺口 2)──
|
|
386
|
+
// 一个工具(action 分派)而不是 list + op 两个:tools/list 字节预算是硬约束,见 tools.ts 的注释。
|
|
387
|
+
describe("requirement_tags (MCP)", () => {
|
|
388
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>) {
|
|
389
|
+
const r = await fetch(`${BASE}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
390
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
|
|
391
|
+
const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
|
|
392
|
+
const envelope = JSON.parse(line ? line.slice(6) : raw);
|
|
393
|
+
try { return JSON.parse(envelope.result.content[0].text); } catch { return { mcpError: envelope.error ?? envelope.result }; }
|
|
394
|
+
}
|
|
395
|
+
const nodes = async (token: string) => (await send(token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
|
|
396
|
+
|
|
397
|
+
test("action=list 返回标签树(含 parent/depth/registered);action=reparent 走同一条 REST 语义", async () => {
|
|
398
|
+
expect((await send(admin.token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name: "MCP父", parent_id: null })).status).toBe(201);
|
|
399
|
+
expect((await send(admin.token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name: "MCP子", parent_id: null })).status).toBe(201);
|
|
400
|
+
|
|
401
|
+
const listed = await mcp(admin.token, "requirement_tags", { action: "list" });
|
|
402
|
+
const rows = listed.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
|
|
403
|
+
expect(rows.find(node => node.name === "MCP父")).toMatchObject({ parent_id: null, depth: 1, registered: true });
|
|
404
|
+
expect(rows.find(node => node.name === "MCP子")).toMatchObject({ parent_id: null, depth: 1 });
|
|
405
|
+
|
|
406
|
+
const moved = await mcp(admin.token, "requirement_tags", { action: "reparent", tag: "MCP子", parent: "MCP父" });
|
|
407
|
+
expect(moved).toMatchObject({ ok: true, op: "reparent", affected: 0, reparented: 1 });
|
|
408
|
+
expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父", depth: 2 });
|
|
409
|
+
|
|
410
|
+
// 与 REST 同一套错误码:不传 parent 键 ≠ 升到顶层
|
|
411
|
+
const missing = await mcp(admin.token, "requirement_tags", { action: "reparent", tag: "MCP子" });
|
|
412
|
+
expect(missing.error).toBe("invalid_tag_parent_id");
|
|
413
|
+
expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父" });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("节点令牌:能 list,但写操作被拒(403 permission_denied),树不变", async () => {
|
|
417
|
+
const listed = await mcp(nodeToken, "requirement_tags", { action: "list" });
|
|
418
|
+
expect(Array.isArray(listed.nodes)).toBe(true);
|
|
419
|
+
const write = await mcp(nodeToken, "requirement_tags", { action: "reparent", tag: "MCP子", parent: null });
|
|
420
|
+
expect(write.status).toBe(403);
|
|
421
|
+
// 写操作先过「要人的令牌」这道闸(tags_write),再到 owner/admin 判定;节点令牌停在前一道。
|
|
422
|
+
expect(["user_token_required", "permission_denied"]).toContain(write.error);
|
|
423
|
+
expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父" });
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -268,6 +268,12 @@ describe("#1279 authoritative scoped requirement-event wake over HTTP/MCP", () =
|
|
|
268
268
|
expect({exit:code,stderr:w.stderr().trim().slice(0,800)}).toEqual({exit:0,stderr:""});
|
|
269
269
|
results.push(JSON.parse(output.trim().split("\n").at(-1)!).wake);}
|
|
270
270
|
expect(results.sort()).toEqual([false,true]);expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
|
|
271
|
-
|
|
271
|
+
// #2198:显式预算。这条用例要起 2 个 Node 子进程、各自打开同一个磁盘 SQLite,
|
|
272
|
+
// 再用 /tmp 上的 gate 文件对齐起跑。默认 5s 在机器繁忙时会被打满,而**超时与断言
|
|
273
|
+
// 失败在报告里长得一样**(都只是一行 (fail)):实测 8 路并发 + 3× CPU 超订时,
|
|
274
|
+
// 11 轮里有 2 轮红,但每次红的都是 `timed out after 5000ms`,而 trues 恒为 1、
|
|
275
|
+
// revision 恒只 +1 —— 语义一直是对的,是预算不够。
|
|
276
|
+
// 这里只放宽**时间**,不放宽任何断言(仍然 [false,true]、仍然 revision+1、仍然 poll false)。
|
|
277
|
+
}, 30_000);
|
|
272
278
|
|
|
273
279
|
});
|
package/src/server.ts
CHANGED
|
@@ -5250,10 +5250,31 @@ return Bun.serve({
|
|
|
5250
5250
|
const management = lifecycleUser ? lifecycleProjections(restScope) : null;
|
|
5251
5251
|
const rows = rawRows.map(r => {
|
|
5252
5252
|
let role: string | null = null;
|
|
5253
|
+
// #2293(admin 2026-10-25)—— 节点自报的 OpenCode V2 模型 id 清单(`provider/model` 字符串数组)。
|
|
5254
|
+
// 客户端在**没有 daemon 绑定**时用它当建议列表。读法同 role:快照可能是文本或对象;
|
|
5255
|
+
// 解析失败/形状不对 = 这一格没有(null),不影响整行。
|
|
5256
|
+
let opencodeCatalog: string[] | null = null;
|
|
5257
|
+
let opencodeGeneration: "v1" | "v2" | null = null;
|
|
5258
|
+
let opencodeMode: string | null = null;
|
|
5259
|
+
let opencodeServer: string | null = null;
|
|
5253
5260
|
const snap = r.config_snapshot;
|
|
5254
5261
|
if (snap) {
|
|
5255
|
-
|
|
5256
|
-
|
|
5262
|
+
let parsed: any = null;
|
|
5263
|
+
try { parsed = typeof snap === "string" ? JSON.parse(snap) : snap; }
|
|
5264
|
+
catch { parsed = null; }
|
|
5265
|
+
role = parsed?.role ?? null;
|
|
5266
|
+
const raw = parsed?.opencode_catalog;
|
|
5267
|
+
if (Array.isArray(raw) && raw.every((m: unknown) => typeof m === "string" && m.length <= 400)) {
|
|
5268
|
+
opencodeCatalog = raw.slice(0, 400);
|
|
5269
|
+
}
|
|
5270
|
+
// #2293 —— 客户端「Runtime · V2」和「OpenCode Server」两行要的正是快照里这三格标量。
|
|
5271
|
+
// PR #1092 之前从 `node.config_snapshot` 读,但这个端点**刻意不带整个快照**
|
|
5272
|
+
// (见上方 #312 注释)⇒ 真机上两行是空的/整行不出现。这里只放三个标量出来,
|
|
5273
|
+
// 仍不暴露 flags 里的其它内容,更不暴露任何凭据。
|
|
5274
|
+
const flags = parsed?.flags;
|
|
5275
|
+
if (flags?.opencodeGeneration === "v1" || flags?.opencodeGeneration === "v2") opencodeGeneration = flags.opencodeGeneration;
|
|
5276
|
+
if (typeof flags?.opencodeMode === "string" && flags.opencodeMode.length <= 32) opencodeMode = flags.opencodeMode;
|
|
5277
|
+
if (typeof parsed?.opencode_server === "string" && parsed.opencode_server.length <= 200) opencodeServer = parsed.opencode_server;
|
|
5257
5278
|
}
|
|
5258
5279
|
const { config_snapshot, owner_user_id, ...rest } = r;
|
|
5259
5280
|
// `tags` is stored as JSON text; hand the dashboard a real array so
|
|
@@ -5269,6 +5290,10 @@ return Bun.serve({
|
|
|
5269
5290
|
tags: parseStoredTags(r.tags),
|
|
5270
5291
|
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
5271
5292
|
role,
|
|
5293
|
+
opencode_catalog: opencodeCatalog,
|
|
5294
|
+
opencode_generation: opencodeGeneration,
|
|
5295
|
+
opencode_mode: opencodeMode,
|
|
5296
|
+
opencode_server: opencodeServer,
|
|
5272
5297
|
lifecycle_controllable,
|
|
5273
5298
|
lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
|
|
5274
5299
|
...(management ? (management.get(r.node_id) ?? { managed: "none", adoption: null }) : {}),
|
|
@@ -211,6 +211,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
211
211
|
// assign_agent 合并成一个,尽量小)。实测 node 64,112 B / user 60,117 B,比原上限
|
|
212
212
|
// 分别高 412 / 279 B —— 这是有意的功能增长(组织架构可由 Agent 直接调),不是文案偷偷变长。
|
|
213
213
|
// 留约 400 B 余量;再要加工具时请先合并动作或砍描述,别连续抬上限。
|
|
214
|
+
// #2204(2026-10-25):标签管理给 MCP。为了**不抬上限**,做了两件事:
|
|
215
|
+
// ① 合并成一个工具 `requirement_tags`(action 分派 list/rename/merge/delete/color/reparent),
|
|
216
|
+
// 而不是 list + op 两个(照 #1894 `departments` 的先例);
|
|
217
|
+
// ② 砍掉一批冗长描述里重复的解释性从句(每条约 20–100 B,信息点全部保留)。
|
|
218
|
+
// 实测(含新工具):all 97 / 86,020 B;node 64 / 64,464 B;user 64 / 59,680 B —— 三个上限**一个都没动**。
|
|
219
|
+
// 下次再加工具,请先合并动作或砍描述,不要直接改这三个数。
|
|
214
220
|
const CEILINGS = { node: 64_500, user: 60_500, all: 86_500 } as const;
|
|
215
221
|
test("node / user / all", async () => {
|
|
216
222
|
const node = await listTools(T.node_normal);
|
package/src/tool-audience.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
43
43
|
get_probe_request: "protocol", ack_probe_request: "protocol",
|
|
44
44
|
get_opencode_catalog_request: "protocol", ack_opencode_catalog: "protocol",
|
|
45
45
|
// ── 人:节点令牌一律 user_token_required(项目管理)/ node_token_cannot_browse_files / node_token_cannot_read_logs ──
|
|
46
|
-
projects_create: "user", projects_update: "user",
|
|
46
|
+
projects_create: "user", projects_update: "user", requirement_tags: "both",
|
|
47
47
|
read_opencode_catalog: "user", get_opencode_catalog: "user",
|
|
48
48
|
list_node_files: "user", read_node_file: "user", tail_node_logs: "user",
|
|
49
49
|
// ── 两边都能调 ──
|
package/src/tools.ts
CHANGED
|
@@ -921,7 +921,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
921
921
|
revision: z.number().int().min(0).optional(),
|
|
922
922
|
}).strict()).max(64),
|
|
923
923
|
error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
|
|
924
|
-
}).strict().optional().describe("
|
|
924
|
+
}).strict().optional().describe("Node-reported schedules; never includes host paths or commands"),
|
|
925
925
|
// RFC-024 B6 — masked snapshot of the node's effective config
|
|
926
926
|
// (model + 6 dashboard-editable flags). Secrets ARE NOT in this
|
|
927
927
|
// shape (env._envRef stays on host); the dashboard reads this
|
|
@@ -934,6 +934,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
934
934
|
model: z.string().max(200).optional().nullable(),
|
|
935
935
|
flags: z.record(z.string(), z.unknown()).optional(),
|
|
936
936
|
config_revision: z.number().int().min(0).optional(),
|
|
937
|
+
// #2293(admin 2026-10-25)—— 节点自己上报的 OpenCode V2 模型 id 清单
|
|
938
|
+
// (`provider/model`)。没有 daemon 绑定的节点靠这一格给客户端可选列表,
|
|
939
|
+
// 否则 admin 只能手填。只收 id:名字/设置/headers 可能带凭据,一律不收。
|
|
940
|
+
// 排成**一个字符串数组**而不是对象,是为了不抬 tools/list 的字节上限
|
|
941
|
+
// (约定见 tool-audience-http.test.ts:加字段先砍描述或合并,别改那三个数)。
|
|
942
|
+
// `.catch(undefined)`:形状不对只丢这一格,绝不拒掉整份心跳。
|
|
943
|
+
opencode_catalog: z.array(z.string()).max(400).optional().catch(undefined),
|
|
944
|
+
// #2293 —— 这台机器上 OpenCode serve 的 loopback 端点(`http://127.0.0.1:PORT`)。
|
|
945
|
+
// 客户端「OpenCode Server」行此前只能写「端口未上报」。只收 loopback URL,不收密码。
|
|
946
|
+
opencode_server: z.string().max(200).optional().catch(undefined),
|
|
937
947
|
config_update_capable: z.boolean().optional(),
|
|
938
948
|
peer_reply_inbox_capable: z.literal(true).optional(),
|
|
939
949
|
side_thread_capability: z.object({
|
|
@@ -1993,7 +2003,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1993
2003
|
|
|
1994
2004
|
server.tool(
|
|
1995
2005
|
"send_task",
|
|
1996
|
-
"Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead
|
|
2006
|
+
"Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead, target_busy, est_wait_minutes (null = no history) and, when the queue is long, a warning. Always queued, never refused.",
|
|
1997
2007
|
{
|
|
1998
2008
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
1999
2009
|
task: z.string().min(1).max(TASK_CONTENT_MAX).describe("Task content"),
|
|
@@ -2002,8 +2012,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2002
2012
|
from_session: z.string().max(200).optional(),
|
|
2003
2013
|
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
2004
2014
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
2005
|
-
parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of.
|
|
2006
|
-
meta: z.any().optional().describe("Optional
|
|
2015
|
+
parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. The child's reply is auto-chained to the parent's originator, so the user sees the result even if the intermediate session ends."),
|
|
2016
|
+
meta: z.any().optional().describe("Optional task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
|
|
2007
2017
|
force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
|
|
2008
2018
|
},
|
|
2009
2019
|
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => {
|
|
@@ -2367,7 +2377,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2367
2377
|
// it always sent alias=undefined → -32602 "expected string, received
|
|
2368
2378
|
// undefined at alias". The target was already knowable from the task,
|
|
2369
2379
|
// so requiring the caller to pass it was a redundant-param trap.
|
|
2370
|
-
alias: z.string().min(1).max(200).optional().describe("Target
|
|
2380
|
+
alias: z.string().min(1).max(200).optional().describe("Target alias — optional; defaults to in_reply_to's sender"),
|
|
2371
2381
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
2372
2382
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
2373
2383
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
@@ -2378,7 +2388,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2378
2388
|
// `attachments` before this schema entry existed would see `ok:true`
|
|
2379
2389
|
// and never learn the attachments were dropped. Validated by
|
|
2380
2390
|
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
2381
|
-
attachments: z.any().optional().describe("Attachments, like send_task's
|
|
2391
|
+
attachments: z.any().optional().describe("Attachments, like send_task's: [{type:'file', file_id, name?, mime?, size?}]."),
|
|
2382
2392
|
// #507 — optional structured metadata (parity with send_task L837). If
|
|
2383
2393
|
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
2384
2394
|
// top-level wins (same rule as REST /api/task L2101).
|
|
@@ -2844,7 +2854,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2844
2854
|
);
|
|
2845
2855
|
server.tool(
|
|
2846
2856
|
"send_peer_reply",
|
|
2847
|
-
"Atomically finalize one node-owned task and enqueue one no-response result when the
|
|
2857
|
+
"Atomically finalize one node-owned task and enqueue one no-response result when the recipient advertises peer_reply_inbox_capable. Stored capability mismatch -> refused, not silently degraded.",
|
|
2848
2858
|
replyToolSchema,
|
|
2849
2859
|
(args) => handleReply(args, true),
|
|
2850
2860
|
);
|
|
@@ -3465,7 +3475,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3465
3475
|
|
|
3466
3476
|
server.tool(
|
|
3467
3477
|
"update_node_config",
|
|
3468
|
-
"Set the
|
|
3478
|
+
"Set the per-node config (model + flags) and push a doorbell; the node pulls, validates and applies (hot or restart per field). RFC-024.",
|
|
3469
3479
|
{
|
|
3470
3480
|
...NODE_ID_ALIAS_FIELDS,
|
|
3471
3481
|
base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
|
|
@@ -4034,7 +4044,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4034
4044
|
|
|
4035
4045
|
server.tool(
|
|
4036
4046
|
"read_node_rules_file",
|
|
4037
|
-
"Ask a node to send back its rules file (CLAUDE.md for claude
|
|
4047
|
+
"Ask a node to send back its rules file (CLAUDE.md for claude, else AGENTS.md) from its work dir. No path argument. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
4038
4048
|
{
|
|
4039
4049
|
...NODE_ID_ALIAS_FIELDS,
|
|
4040
4050
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
|
|
@@ -4045,7 +4055,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4045
4055
|
|
|
4046
4056
|
server.tool(
|
|
4047
4057
|
"write_node_rules_file",
|
|
4048
|
-
"Ask a node to
|
|
4058
|
+
"Ask a node to write a managed block into its rules file (CLAUDE.md / AGENTS.md). Poll get_rules_file_result. User logins only (node owner or network owner/admin).",
|
|
4049
4059
|
{
|
|
4050
4060
|
...NODE_ID_ALIAS_FIELDS,
|
|
4051
4061
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
|
|
@@ -4057,7 +4067,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4057
4067
|
|
|
4058
4068
|
server.tool(
|
|
4059
4069
|
"list_node_skills",
|
|
4060
|
-
"Ask a node
|
|
4070
|
+
"Ask a node for the skills its runtime loads (name, scope project|user|system, display path, description; team skills add origin \"team\"). No path. Poll get_rules_file_result: {skills:[...], roots, warnings}.",
|
|
4061
4071
|
{
|
|
4062
4072
|
...NODE_ID_ALIAS_FIELDS,
|
|
4063
4073
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
|
|
@@ -4068,7 +4078,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4068
4078
|
|
|
4069
4079
|
server.tool(
|
|
4070
4080
|
"read_node_skill",
|
|
4071
|
-
"
|
|
4081
|
+
"One skill's SKILL.md by name; the node resolves it under its own skills roots (no path). Poll get_rules_file_result: {name, scope, path_rel, description, content, origin?}; origin=\"team\" only for a team skill.",
|
|
4072
4082
|
{
|
|
4073
4083
|
...NODE_ID_ALIAS_FIELDS,
|
|
4074
4084
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
|
|
@@ -4080,7 +4090,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4080
4090
|
|
|
4081
4091
|
server.tool(
|
|
4082
4092
|
"list_node_files",
|
|
4083
|
-
"
|
|
4093
|
+
"List one level of the node's work dir (read-only). `path` relative (default root); absolute and .. refused. Poll get_rules_file_result: {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
|
|
4084
4094
|
{
|
|
4085
4095
|
...NODE_ID_ALIAS_FIELDS,
|
|
4086
4096
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
|
|
@@ -4092,7 +4102,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4092
4102
|
|
|
4093
4103
|
server.tool(
|
|
4094
4104
|
"read_node_file",
|
|
4095
|
-
"
|
|
4105
|
+
"One text file under the node's work dir (read-only, 256 KiB cap; binary / too large -> size only; secret-looking -> name only). `path` relative. Poll get_rules_file_result: {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
|
|
4096
4106
|
{
|
|
4097
4107
|
...NODE_ID_ALIAS_FIELDS,
|
|
4098
4108
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
|
|
@@ -4104,14 +4114,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4104
4114
|
|
|
4105
4115
|
server.tool(
|
|
4106
4116
|
"tail_node_logs",
|
|
4107
|
-
"Tail of
|
|
4117
|
+
"Tail of the node's own run log (read-only, redacted on the node). No path argument. User logins only (node owner or network owner/admin). Poll get_rules_file_result: {files, lines, truncated, matched}; handed out once, then purged.",
|
|
4108
4118
|
{
|
|
4109
4119
|
...NODE_ID_ALIAS_FIELDS,
|
|
4110
4120
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
|
|
4111
4121
|
lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
|
|
4112
4122
|
level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
|
|
4113
4123
|
grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
|
|
4114
|
-
since_ts: z.number().int().min(0).optional().describe("Only lines at
|
|
4124
|
+
since_ts: z.number().int().min(0).optional().describe("Only lines at/after this epoch-ms timestamp (follow mode; client de-dupes by line key)."),
|
|
4115
4125
|
network_id: z.string().max(200).optional(),
|
|
4116
4126
|
},
|
|
4117
4127
|
async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
|
|
@@ -4217,7 +4227,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4217
4227
|
|
|
4218
4228
|
server.tool(
|
|
4219
4229
|
"get_rules_file_result",
|
|
4220
|
-
"Poll
|
|
4230
|
+
"Poll a read_node_rules_file / write_node_rules_file / node-file / node-log request. Returns status pending|in_progress|done|failed|timeout plus the file name and (reads) content. app#225.",
|
|
4221
4231
|
{ request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
4222
4232
|
async ({ request_id: requestId, network_id: clientNetId }) => {
|
|
4223
4233
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
@@ -4298,7 +4308,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4298
4308
|
|
|
4299
4309
|
server.tool(
|
|
4300
4310
|
"restart_node",
|
|
4301
|
-
"
|
|
4311
|
+
"Restart a node without changing config (RFC-024). Network-scoped (SEC-1); member+ suffices — not privilege elevation.",
|
|
4302
4312
|
{
|
|
4303
4313
|
...NODE_ID_ALIAS_FIELDS,
|
|
4304
4314
|
network_id: z.string().max(200).optional(),
|
|
@@ -4930,7 +4940,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4930
4940
|
// the policy and the runtime's session capability (see clone-node.ts).
|
|
4931
4941
|
server.tool(
|
|
4932
4942
|
"clone_node",
|
|
4933
|
-
"Clone a daemon child into a new node on the same daemon
|
|
4943
|
+
"Clone a daemon child into a new node on the same daemon: new name + token. copy_session defaults true; workdir_policy new_empty (default) or share_source. Hub #938.",
|
|
4934
4944
|
{
|
|
4935
4945
|
source_node_id: z.string().min(1).max(200),
|
|
4936
4946
|
name: z.string().min(1).max(256),
|
|
@@ -5720,7 +5730,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5720
5730
|
|
|
5721
5731
|
server.tool(
|
|
5722
5732
|
"delete_node",
|
|
5723
|
-
"Stop child + revoke
|
|
5733
|
+
"Stop child + revoke token + delete hub row + (default) back up config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must match. daemon_node_id auto-resolved. RFC-027 §2.2.",
|
|
5724
5734
|
{
|
|
5725
5735
|
...NODE_ID_ALIAS_FIELDS,
|
|
5726
5736
|
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
@@ -6218,7 +6228,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6218
6228
|
// §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
|
|
6219
6229
|
server.tool(
|
|
6220
6230
|
"upsert_network_secret",
|
|
6221
|
-
"
|
|
6231
|
+
"Create or replace one vault secret by key (value never read back). RFC-028.",
|
|
6222
6232
|
{
|
|
6223
6233
|
key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
|
|
6224
6234
|
value: z.string().min(1).max(16 * 1024),
|
|
@@ -6776,7 +6786,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6776
6786
|
name: z.string().min(1).max(80).optional(),
|
|
6777
6787
|
priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
|
|
6778
6788
|
column: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
|
|
6779
|
-
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601
|
|
6789
|
+
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 (stored as UTC seconds); \"\" clears"),
|
|
6780
6790
|
start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
|
|
6781
6791
|
description: z.string().max(20_000).optional().describe("markdown"),
|
|
6782
6792
|
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
@@ -6888,14 +6898,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6888
6898
|
|
|
6889
6899
|
server.tool(
|
|
6890
6900
|
"requirements_create",
|
|
6891
|
-
"Create a
|
|
6901
|
+
"Create a task; name required. column (alias status) = pool/doing/done/abandoned. Duplicate external_ref -> 409 external_ref_exists with existing_id; use requirements_upsert_by_external_ref to sync.",
|
|
6892
6902
|
{ network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
6893
6903
|
async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
|
|
6894
6904
|
);
|
|
6895
6905
|
|
|
6896
6906
|
server.tool(
|
|
6897
6907
|
"requirements_update",
|
|
6898
|
-
"Patch a
|
|
6908
|
+
"Patch a task (id or \"#N\"); omitted fields keep their value. column (alias status) = pool/doing/done/abandoned. participants REPLACES the whole list (use participants_add / _remove to keep others). archived=true hides it; agents cannot delete.",
|
|
6899
6909
|
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
6900
6910
|
async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
|
|
6901
6911
|
);
|
|
@@ -6909,7 +6919,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6909
6919
|
|
|
6910
6920
|
server.tool(
|
|
6911
6921
|
"requirements_upsert_by_external_ref",
|
|
6912
|
-
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or
|
|
6922
|
+
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or update the one already bound to it. Returns { requirement, created }.",
|
|
6913
6923
|
{ network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, status: reqStatus, archived: z.boolean().optional() },
|
|
6914
6924
|
async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(a, REQ_WRITE_KEYS))),
|
|
6915
6925
|
);
|
|
@@ -6926,7 +6936,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6926
6936
|
// write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
|
|
6927
6937
|
server.tool(
|
|
6928
6938
|
"projects_create",
|
|
6929
|
-
"Create a
|
|
6939
|
+
"Create a project (管理项目 → 新建). name 1-40, unique; color #RRGGBB; sort int; parent_id optional (<=3 levels). Returns { project }; node tokens can only read projects.",
|
|
6930
6940
|
{
|
|
6931
6941
|
network_id: z.string().max(200).optional(),
|
|
6932
6942
|
name: z.string().min(1).max(40),
|
|
@@ -6939,7 +6949,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6939
6949
|
|
|
6940
6950
|
server.tool(
|
|
6941
6951
|
"projects_update",
|
|
6942
|
-
"Rename, recolour, reorder, archive
|
|
6952
|
+
"Rename, recolour, reorder, archive, or move a project under another (parent_id; null = top level; cycles / depth>3 refused). Only fields you pass change; node tokens can only read projects.",
|
|
6943
6953
|
{
|
|
6944
6954
|
network_id: z.string().max(200).optional(),
|
|
6945
6955
|
id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
|
|
@@ -6952,6 +6962,33 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6952
6962
|
async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived", "parent_id"])),
|
|
6953
6963
|
);
|
|
6954
6964
|
|
|
6965
|
+
// #2204 —— 标签管理给 MCP:REST 早就有 rename/merge/delete/color/reparent,但只有人用的 HTTP,
|
|
6966
|
+
// MCP 侧既读不到标签树、也改不了父级。做成**一个**工具(action 分派)而不是 list + op 两个:
|
|
6967
|
+
// tools/list 字节预算是硬约束(all 86,500 / node 64,500 / user 60,500),两个工具会撑破预算,
|
|
6968
|
+
// 而"抬预算"是仓里明令禁止的做法(见 tool-audience-http.test.ts 注释与 #1894 先例)。
|
|
6969
|
+
server.tool(
|
|
6970
|
+
"requirement_tags",
|
|
6971
|
+
"Tags of this network. action=list → name, parent_id (null=root), depth, colour, usage, can_manage. Writes (owner/admin; node tokens 403): rename|merge|delete|color|reparent. reparent moves only the tree edge (cycle / depth>4 refused).",
|
|
6972
|
+
{
|
|
6973
|
+
network_id: z.string().max(200).optional(),
|
|
6974
|
+
action: z.enum(["list", "rename", "merge", "delete", "color", "reparent"]),
|
|
6975
|
+
tag: z.string().min(1).max(20).optional(),
|
|
6976
|
+
from: z.union([z.string().min(1).max(20), z.array(z.string().min(1).max(20)).min(1).max(50)]).optional(),
|
|
6977
|
+
to: z.string().min(1).max(20).optional(),
|
|
6978
|
+
parent: z.string().min(1).max(20).nullable().optional(),
|
|
6979
|
+
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
|
6980
|
+
},
|
|
6981
|
+
async (args) => {
|
|
6982
|
+
if (args.action === "list") return requirementsCall("GET", "/api/requirements/tags", args.network_id);
|
|
6983
|
+
const body: Record<string, unknown> = { op: args.action };
|
|
6984
|
+
if (args.action === "rename" || args.action === "merge") { body.from = args.from; body.to = args.to; }
|
|
6985
|
+
else if (args.action === "delete") body.tag = args.tag;
|
|
6986
|
+
else if (args.action === "color") { body.tag = args.tag; body.color = args.color === undefined ? null : args.color; }
|
|
6987
|
+
else { body.tag = args.tag; if ("parent" in args) body.parent = args.parent; }
|
|
6988
|
+
return requirementsCall("POST", "/api/requirements/tags/ops", args.network_id, body);
|
|
6989
|
+
},
|
|
6990
|
+
);
|
|
6991
|
+
|
|
6955
6992
|
// #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
|
|
6956
6993
|
const REQ_EVENTS_NETWORK_DEFAULT_LIMIT = 50;
|
|
6957
6994
|
const reqEventsShape = {
|
|
@@ -6995,7 +7032,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6995
7032
|
// 两次调用,并发时覆盖别人刚写的描述。这条只追加,进「动态」(requirements_events kind=comment),不改任务本身。
|
|
6996
7033
|
server.tool(
|
|
6997
7034
|
"requirements_comment",
|
|
6998
|
-
"
|
|
7035
|
+
"Append a comment / progress note to a task (**append-only**; never rewrites the description). Shows in requirements_events (kind=comment, text in new.text). text: markdown 1-4000. Anyone who can see the task and write in the network may comment. Returns { event }.",
|
|
6999
7036
|
{
|
|
7000
7037
|
id: z.string().min(1).max(200).describe(REQ_ID_DESC),
|
|
7001
7038
|
text: z.string().min(1).max(4000).describe("the comment (markdown), 1–4000 characters"),
|