@sleep2agi/commhub-server 0.9.0-preview.44 → 0.9.0-preview.46
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/admin-networks-http.test.ts +5 -1
- package/src/api-host-supervisors-fallback.test.ts +5 -1
- package/src/auth-validate.test.ts +5 -1
- package/src/dashboard-slash-routing-http.test.ts +5 -1
- package/src/db.ts +31 -0
- package/src/external-schedule-edits-http.test.ts +5 -1
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +17 -4
- package/src/health-redaction.test.ts +5 -1
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/network-name-http.test.ts +5 -1
- package/src/node-attrs.test.ts +5 -1
- package/src/node-id-alias.test.ts +41 -0
- package/src/observer-avatar-http.test.ts +5 -1
- package/src/rest-explicit-columns-http.test.ts +5 -1
- package/src/rules-file-transport.test.ts +235 -0
- package/src/scheduled-tasks-http.test.ts +5 -1
- package/src/server.ts +8 -3
- package/src/side-thread-http-integration.test.ts +5 -1
- package/src/skillhub-http.test.ts +5 -1
- package/src/task-auth-origin-http.test.ts +5 -1
- package/src/task-consumption.test.ts +1 -1
- package/src/task-diagnostic-http.test.ts +5 -1
- package/src/task-lifecycle-watcher.test.ts +5 -1
- package/src/task-network-resolution-http.test.ts +5 -1
- package/src/tasks-pagination-http.test.ts +5 -1
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +287 -18
- package/src/uploads-http.test.ts +5 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// app#225 — 节点规则文件远程读写:走真 MCP in-process transport 的端到端。
|
|
2
|
+
//
|
|
3
|
+
// 验的是 #225 验收里 hub 能验的那几条:
|
|
4
|
+
// 1. 客户端只用 node_id 就能发起读/写;工具的 inputSchema 里**没有**任何
|
|
5
|
+
// 路径/文件名字段(验收第 5 条「不能借路径参数写任意文件」在 hub 侧的形状)。
|
|
6
|
+
// 2. 节点用网络 token + alias 拉到的请求里也没有路径字段;ack 后客户端轮询拿到内容。
|
|
7
|
+
// 3. SEC-1:跨网络节点发不了、别的节点 ack 不了、用户 token 拉不了。
|
|
8
|
+
// 4. 单飞 + 超时回收。
|
|
9
|
+
//
|
|
10
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules.db bun test src/rules-file-transport.test.ts
|
|
11
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
13
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
import { registerTools } from "./tools.js";
|
|
17
|
+
|
|
18
|
+
const NET = "net_rules_file";
|
|
19
|
+
const OTHER_NET = "net_rules_file_other";
|
|
20
|
+
const USER = "u_rules_file_owner";
|
|
21
|
+
const NODE_ID = "node_rules_file_claude";
|
|
22
|
+
const NODE_ALIAS = "rules-file-claude";
|
|
23
|
+
const OTHER_NODE_ID = "node_rules_file_other";
|
|
24
|
+
const OTHER_NODE_ALIAS = "rules-file-other";
|
|
25
|
+
const NODE_TOKEN = "tok_rules_file_node";
|
|
26
|
+
const OTHER_NODE_TOKEN = "tok_rules_file_other_node";
|
|
27
|
+
|
|
28
|
+
function cleanup() {
|
|
29
|
+
for (const net of [NET, OTHER_NET]) {
|
|
30
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [net]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
36
|
+
}
|
|
37
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function seedWorld() {
|
|
41
|
+
db.run(
|
|
42
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
43
|
+
[USER, USER],
|
|
44
|
+
);
|
|
45
|
+
for (const net of [NET, OTHER_NET]) {
|
|
46
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
47
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
48
|
+
}
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
51
|
+
[NODE_ID, NODE_ALIAS, NODE_ALIAS, NET],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
55
|
+
[OTHER_NODE_ID, OTHER_NODE_ALIAS, OTHER_NODE_ALIAS, OTHER_NET],
|
|
56
|
+
);
|
|
57
|
+
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
|
+
[NODE_TOKEN, USER, NET, `node:${NODE_ALIAS}`, "hash_rules_file_node"],
|
|
60
|
+
);
|
|
61
|
+
db.run(
|
|
62
|
+
`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)`,
|
|
63
|
+
[OTHER_NODE_TOKEN, USER, OTHER_NET, `node:${OTHER_NODE_ALIAS}`, "hash_rules_file_other"],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
68
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_file_user" };
|
|
69
|
+
const asNode: Identity = { net: NET, alias: NODE_ALIAS, isNetworkToken: true, tokenId: NODE_TOKEN };
|
|
70
|
+
const asOtherNode: Identity = { net: OTHER_NET, alias: OTHER_NODE_ALIAS, isNetworkToken: true, tokenId: OTHER_NODE_TOKEN };
|
|
71
|
+
|
|
72
|
+
async function connect(id: Identity) {
|
|
73
|
+
const server = new McpServer({ name: "rules-file-test", version: "1" });
|
|
74
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
75
|
+
const client = new Client({ name: "rules-file-client", version: "1" });
|
|
76
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
77
|
+
await server.connect(st);
|
|
78
|
+
await client.connect(ct);
|
|
79
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
80
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
81
|
+
const first = r.content?.[0];
|
|
82
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
|
|
83
|
+
return JSON.parse(first.text);
|
|
84
|
+
};
|
|
85
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
86
|
+
return { client, server, call, close };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
90
|
+
afterAll(cleanup);
|
|
91
|
+
|
|
92
|
+
describe("app#225 rules file — schema has no path", () => {
|
|
93
|
+
test("read/write tool inputs are node id + content only; no path / file_name / dir field anywhere", async () => {
|
|
94
|
+
// 🔴 不走 client.listTools():main 上 report_status / send_desktop_message /
|
|
95
|
+
// update_node_config / create_node 四个存量工具的 zod 形状让 SDK 的
|
|
96
|
+
// toJSONSchema 抛 `schema._zod`,listTools 整个失败(与本 PR 无关,实验见
|
|
97
|
+
// PR 正文)。这里直接看 McpServer 登记表里的 zod shape —— 它就是 hub 校验
|
|
98
|
+
// 入参用的那份,比 JSON schema 更接近真相。
|
|
99
|
+
const u = await connect(asUser);
|
|
100
|
+
try {
|
|
101
|
+
const reg: Record<string, any> = (u.server as any)._registeredTools;
|
|
102
|
+
const shapeKeys = (name: string): string[] => {
|
|
103
|
+
expect(reg[name], name).toBeDefined();
|
|
104
|
+
const schema = reg[name].inputSchema;
|
|
105
|
+
const shape = schema?.shape ?? schema?._def?.shape?.() ?? schema ?? {};
|
|
106
|
+
return Object.keys(shape).sort();
|
|
107
|
+
};
|
|
108
|
+
expect(shapeKeys("read_node_rules_file")).toEqual(["child_node_id", "network_id", "node_id"]);
|
|
109
|
+
expect(shapeKeys("write_node_rules_file")).toEqual(["child_node_id", "content", "network_id", "node_id"]);
|
|
110
|
+
expect(shapeKeys("get_rules_file_request")).toEqual([]);
|
|
111
|
+
// ack 的 file_name 是节点**回报**它用了哪个文件,不是客户端**指定**用哪个文件。
|
|
112
|
+
expect(shapeKeys("ack_rules_file_request")).toEqual(["content", "error", "exists", "file_name", "request_id", "status"]);
|
|
113
|
+
expect(shapeKeys("get_rules_file_result")).toEqual(["network_id", "request_id"]);
|
|
114
|
+
for (const name of ["read_node_rules_file", "write_node_rules_file", "get_rules_file_request", "get_rules_file_result"]) {
|
|
115
|
+
for (const k of shapeKeys(name)) expect(k, `${name}.${k}`).not.toMatch(/path|dir|file|cwd/i);
|
|
116
|
+
}
|
|
117
|
+
} finally { await u.close(); }
|
|
118
|
+
}, 20_000);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("app#225 rules file — read round trip", () => {
|
|
122
|
+
test("user enqueues read → node pulls (no path in request) → acks content → user polls done", async () => {
|
|
123
|
+
const u = await connect(asUser);
|
|
124
|
+
const n = await connect(asNode);
|
|
125
|
+
try {
|
|
126
|
+
const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
127
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
128
|
+
expect(enq.request_id).toMatch(/^rf_/);
|
|
129
|
+
|
|
130
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending", op: "read" });
|
|
131
|
+
|
|
132
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
133
|
+
expect(pulled.ok).toBe(true);
|
|
134
|
+
expect(Object.keys(pulled.request).sort()).toEqual(["op", "request_id"]);
|
|
135
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "read" });
|
|
136
|
+
// 第二次拉:已经 in_progress,不再重复下发
|
|
137
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
138
|
+
|
|
139
|
+
expect(await n.call("ack_rules_file_request", {
|
|
140
|
+
request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "# 规则\n",
|
|
141
|
+
})).toMatchObject({ ok: true, status: "done" });
|
|
142
|
+
|
|
143
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
144
|
+
expect(res).toMatchObject({ ok: true, status: "done", op: "read", file_name: "CLAUDE.md", exists: true, content: "# 规则\n", error: null });
|
|
145
|
+
} finally { await u.close(); await n.close(); }
|
|
146
|
+
}, 20_000);
|
|
147
|
+
|
|
148
|
+
test("write: content travels to the node verbatim; result carries no content back", async () => {
|
|
149
|
+
const u = await connect(asUser);
|
|
150
|
+
const n = await connect(asNode);
|
|
151
|
+
try {
|
|
152
|
+
const body = "# new rules\n\n- 不碰生产 DB\n";
|
|
153
|
+
const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: body });
|
|
154
|
+
expect(enq).toMatchObject({ ok: true, op: "write" });
|
|
155
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
156
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "write", content: body });
|
|
157
|
+
await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true });
|
|
158
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
159
|
+
expect(res).toMatchObject({ ok: true, status: "done", op: "write", file_name: "CLAUDE.md" });
|
|
160
|
+
expect(res.content).toBeUndefined();
|
|
161
|
+
} finally { await u.close(); await n.close(); }
|
|
162
|
+
}, 20_000);
|
|
163
|
+
|
|
164
|
+
test("node failure is surfaced with its reason", async () => {
|
|
165
|
+
const u = await connect(asUser);
|
|
166
|
+
const n = await connect(asNode);
|
|
167
|
+
try {
|
|
168
|
+
const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: "x" });
|
|
169
|
+
await n.call("get_rules_file_request", {});
|
|
170
|
+
await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "failed", file_name: "CLAUDE.md", error: "EACCES: permission denied" });
|
|
171
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "failed", error: "EACCES: permission denied" });
|
|
172
|
+
} finally { await u.close(); await n.close(); }
|
|
173
|
+
}, 20_000);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("app#225 rules file — SEC-1 and lifecycle", () => {
|
|
177
|
+
test("cross-network node cannot be targeted", async () => {
|
|
178
|
+
const u = await connect(asUser);
|
|
179
|
+
try {
|
|
180
|
+
expect(await u.call("read_node_rules_file", { node_id: OTHER_NODE_ID })).toMatchObject({ ok: false, error: "cross_network_node" });
|
|
181
|
+
expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", OTHER_NODE_ID)?.c).toBe(0);
|
|
182
|
+
} finally { await u.close(); }
|
|
183
|
+
}, 20_000);
|
|
184
|
+
|
|
185
|
+
test("a node in another network can neither pull nor ack this node's request", async () => {
|
|
186
|
+
const u = await connect(asUser);
|
|
187
|
+
const o = await connect(asOtherNode);
|
|
188
|
+
try {
|
|
189
|
+
const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
190
|
+
expect(await o.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
191
|
+
expect(await o.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "stolen" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
192
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.status).toBe("pending");
|
|
193
|
+
} finally { await u.close(); await o.close(); }
|
|
194
|
+
}, 20_000);
|
|
195
|
+
|
|
196
|
+
test("user token (not a network token) cannot use the node-side pull/ack", async () => {
|
|
197
|
+
const u = await connect(asUser);
|
|
198
|
+
try {
|
|
199
|
+
expect(await u.call("get_rules_file_request", {})).toMatchObject({ ok: false, error: "network_token_required" });
|
|
200
|
+
expect(await u.call("ack_rules_file_request", { request_id: "rf_nope", status: "done" })).toMatchObject({ ok: false, error: "network_token_required" });
|
|
201
|
+
} finally { await u.close(); }
|
|
202
|
+
}, 20_000);
|
|
203
|
+
|
|
204
|
+
test("single flight: a second request while one is pending is refused; a stale one is superseded", async () => {
|
|
205
|
+
const u = await connect(asUser);
|
|
206
|
+
try {
|
|
207
|
+
const first = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
208
|
+
expect(await u.call("read_node_rules_file", { node_id: NODE_ID })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
|
|
209
|
+
db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, first.request_id]);
|
|
210
|
+
const second = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
211
|
+
expect(second.ok).toBe(true);
|
|
212
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", first.request_id)?.status).toBe("timeout");
|
|
213
|
+
} finally { await u.close(); }
|
|
214
|
+
}, 20_000);
|
|
215
|
+
|
|
216
|
+
test("polling a request the node never answered flips it to timeout with a readable reason", async () => {
|
|
217
|
+
const u = await connect(asUser);
|
|
218
|
+
try {
|
|
219
|
+
const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
|
|
220
|
+
db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, enq.request_id]);
|
|
221
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
222
|
+
expect(res).toMatchObject({ ok: true, status: "timeout" });
|
|
223
|
+
expect(res.error).toMatch(/did not answer/);
|
|
224
|
+
} finally { await u.close(); }
|
|
225
|
+
}, 20_000);
|
|
226
|
+
|
|
227
|
+
test("oversized content is refused before anything is queued", async () => {
|
|
228
|
+
const u = await connect(asUser);
|
|
229
|
+
try {
|
|
230
|
+
const r: any = await u.client.callTool({ name: "write_node_rules_file", arguments: { node_id: NODE_ID, content: "x".repeat(256 * 1024 + 1) } });
|
|
231
|
+
expect(r.isError).toBe(true);
|
|
232
|
+
expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", NODE_ID)?.c).toBe(0);
|
|
233
|
+
} finally { await u.close(); }
|
|
234
|
+
}, 20_000);
|
|
235
|
+
});
|
|
@@ -26,6 +26,10 @@ async function api(token: string, path: string, init?: RequestInit) {
|
|
|
26
26
|
return { status: res.status, body: await res.json() as any };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
30
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
31
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
32
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
29
33
|
beforeAll(async () => {
|
|
30
34
|
const owner = register(`scheduler_owner_${Date.now()}`, "SchedulerOwner123!", undefined, "seed");
|
|
31
35
|
expect(owner.ok).toBe(true);
|
|
@@ -53,7 +57,7 @@ beforeAll(async () => {
|
|
|
53
57
|
const mod: any = await import("./server.js");
|
|
54
58
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
55
59
|
base = `http://127.0.0.1:${server.port}`;
|
|
56
|
-
});
|
|
60
|
+
}, 30_000);
|
|
57
61
|
|
|
58
62
|
afterAll(() => {
|
|
59
63
|
try { server?.stop?.(true); } catch {}
|
package/src/server.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
|
56
56
|
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
57
57
|
import { handleSideThreadHttpRequest } from "./side-thread-http.js";
|
|
58
58
|
import { createProductionSideThreadTransport } from "./side-thread-production.js";
|
|
59
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
59
60
|
|
|
60
61
|
const PORT = resolvePort(process.env.PORT);
|
|
61
62
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -1002,9 +1003,13 @@ return Bun.serve({
|
|
|
1002
1003
|
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
1003
1004
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
1004
1005
|
const expired = license.expires_at && license.expires_at < now;
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1006
|
+
// #1650 — licenses.expires_at 是 TEXT(datetime('now') 家族),UTC 但不带时区
|
|
1007
|
+
// 标记;`new Date()` 会按本机时区解析,边界上差一天。parse 失败时返回 null
|
|
1008
|
+
// 而不是 NaN —— JSON.stringify(NaN) 本来也序列化成 null,线上字节不变。
|
|
1009
|
+
const expiresMs = parseHubTimestamp(license.expires_at);
|
|
1010
|
+
const daysLeft = expiresMs === null
|
|
1011
|
+
? null
|
|
1012
|
+
: Math.max(0, Math.ceil((expiresMs - Date.now()) / 86400000));
|
|
1008
1013
|
return withCors(req, Response.json({
|
|
1009
1014
|
ok: true,
|
|
1010
1015
|
license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
|
|
@@ -56,6 +56,10 @@ function headers(token = ownerToken) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
60
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
61
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
62
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
59
63
|
beforeAll(async () => {
|
|
60
64
|
process.env.NODE_ENV = "test";
|
|
61
65
|
process.env.COMMHUB_DB = dbPath;
|
|
@@ -100,7 +104,7 @@ beforeAll(async () => {
|
|
|
100
104
|
detachPort = serverModule.installSideThreadExecutionPort(new HttpFakePort());
|
|
101
105
|
hub = serverModule.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
102
106
|
base = `http://127.0.0.1:${hub.port}`;
|
|
103
|
-
});
|
|
107
|
+
}, 30_000);
|
|
104
108
|
|
|
105
109
|
afterAll(() => {
|
|
106
110
|
detachPort();
|
|
@@ -28,6 +28,10 @@ async function tool(token: string, name: string, args: Record<string, unknown>)
|
|
|
28
28
|
return JSON.parse(payload.result.content[0].text);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
32
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
33
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
34
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
31
35
|
beforeAll(async () => {
|
|
32
36
|
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
33
37
|
const owner = register(`skill_owner_${Date.now()}`, "SkillHubOwner123!", undefined, "seed");
|
|
@@ -41,7 +45,7 @@ beforeAll(async () => {
|
|
|
41
45
|
const mod: any = await import("./server.js");
|
|
42
46
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
43
47
|
base = `http://127.0.0.1:${server.port}`;
|
|
44
|
-
});
|
|
48
|
+
}, 30_000);
|
|
45
49
|
|
|
46
50
|
afterAll(() => { try { server?.stop?.(true); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
47
51
|
|
|
@@ -15,6 +15,10 @@ const TARGET = `auth-origin-http-target-${process.pid}`;
|
|
|
15
15
|
const TARGET_NODE_ID = `node_auth_origin_target_${process.pid}`;
|
|
16
16
|
const TARGET_RESUME_ID = `resume_auth_origin_target_${process.pid}`;
|
|
17
17
|
|
|
18
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
19
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
20
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
21
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
18
22
|
beforeAll(async () => {
|
|
19
23
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
20
24
|
const username = `auth_origin_${Date.now()}`;
|
|
@@ -39,7 +43,7 @@ beforeAll(async () => {
|
|
|
39
43
|
const mod: any = await import("./server.js");
|
|
40
44
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
41
45
|
base = `http://127.0.0.1:${server.port}`;
|
|
42
|
-
});
|
|
46
|
+
}, 30_000);
|
|
43
47
|
|
|
44
48
|
afterAll(() => {
|
|
45
49
|
try { server?.stop?.(true); } catch {}
|
|
@@ -166,7 +166,7 @@ describe("task consumed_at identity and lifecycle", () => {
|
|
|
166
166
|
after_terminal_seq: after, limit: 100,
|
|
167
167
|
});
|
|
168
168
|
expect(late.tasks.map((task: any) => task.task_id)).toEqual(["old-open-task"]);
|
|
169
|
-
}, 20_000)
|
|
169
|
+
}, 60_000); // #1627 —— 原 20_000。历史基线 4196ms;2026-08-31 实测 21018ms(5.0×),撞上限。
|
|
170
170
|
test("enqueue and process-level ack keep consumed_at null", async () => {
|
|
171
171
|
const content = "connected process has not started a model turn";
|
|
172
172
|
const before = db.get<{ last_seen_at: string; task: string | null }>(
|
|
@@ -49,6 +49,10 @@ async function openSse(token: string, networkId: string): Promise<ReadableStream
|
|
|
49
49
|
return reader;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
53
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
54
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
55
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
52
56
|
beforeAll(async () => {
|
|
53
57
|
({ db } = await import("./db.js"));
|
|
54
58
|
const { register } = await import("./auth.js");
|
|
@@ -79,7 +83,7 @@ beforeAll(async () => {
|
|
|
79
83
|
const mod = await import("./server.js");
|
|
80
84
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
81
85
|
base = `http://127.0.0.1:${server.port}`;
|
|
82
|
-
});
|
|
86
|
+
}, 30_000);
|
|
83
87
|
|
|
84
88
|
afterAll(() => {
|
|
85
89
|
try { server?.stop(true); } catch {}
|
|
@@ -23,6 +23,10 @@ function sqliteUtcTimestamp(date: Date): string {
|
|
|
23
23
|
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
27
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
28
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
29
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
26
30
|
beforeAll(async () => {
|
|
27
31
|
const { register } = await import("./auth.js");
|
|
28
32
|
const auth = register(`watcher_owner_${Date.now()}`, "WatcherOwner-Strong-1!", undefined, "watcher");
|
|
@@ -39,7 +43,7 @@ beforeAll(async () => {
|
|
|
39
43
|
const mod = await import("./server.js");
|
|
40
44
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
41
45
|
base = `http://127.0.0.1:${server.port}`;
|
|
42
|
-
});
|
|
46
|
+
}, 30_000);
|
|
43
47
|
|
|
44
48
|
afterAll(() => {
|
|
45
49
|
try { server?.stop(true); } catch {}
|
|
@@ -46,6 +46,10 @@ function seedTarget(principal: Principal): void {
|
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
50
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
51
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
52
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
49
53
|
beforeAll(async () => {
|
|
50
54
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
51
55
|
adminSingle = registerPrincipal("admin_single", true);
|
|
@@ -70,7 +74,7 @@ beforeAll(async () => {
|
|
|
70
74
|
const mod: any = await import("./server.js");
|
|
71
75
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
72
76
|
base = `http://127.0.0.1:${server.port}`;
|
|
73
|
-
});
|
|
77
|
+
}, 30_000);
|
|
74
78
|
|
|
75
79
|
afterAll(() => {
|
|
76
80
|
try { server?.stop?.(true); } catch {}
|
|
@@ -28,6 +28,10 @@ function seedTask(
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
32
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
33
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
34
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
31
35
|
beforeAll(async () => {
|
|
32
36
|
({ db } = await import("./db.js"));
|
|
33
37
|
({ register } = await import("./auth.js"));
|
|
@@ -54,7 +58,7 @@ beforeAll(async () => {
|
|
|
54
58
|
const mod = await import("./server.js");
|
|
55
59
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
56
60
|
base = `http://127.0.0.1:${server.port}`;
|
|
57
|
-
});
|
|
61
|
+
}, 30_000);
|
|
58
62
|
|
|
59
63
|
afterAll(() => {
|
|
60
64
|
try { server?.stop(true); } catch {}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// #1756 — 标准 MCP 客户端的 tools/list 必须能成功。
|
|
2
|
+
//
|
|
3
|
+
// 2026-09-02 量到:main 上 client.listTools() 直接抛
|
|
4
|
+
// McpError -32603: undefined is not an object (evaluating 'schema._zod')
|
|
5
|
+
// 逐字段二分,肇事者是四处 `z.record(z.unknown())`(report_status.config_snapshot /
|
|
6
|
+
// send_desktop_message.meta / update_node_config.patch.flags / create_node.node_spec.*):
|
|
7
|
+
// zod v4 的 z.record 要两个参数(key, value),单参数时 value schema 是 undefined,
|
|
8
|
+
// SDK 生成 JSON Schema 时对它取 `_zod` 就炸。修法:z.record(z.string(), z.unknown())。
|
|
9
|
+
//
|
|
10
|
+
// 这条测试守两件事:① listTools 整体成功且条数 == 登记表条数;② 每个工具单独都能
|
|
11
|
+
// toJSONSchema —— 第 ② 条让下一次谁再写单参 record 时,红的是「哪个工具哪个字段」。
|
|
12
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/lt.db bun test src/tools-list-json-schema.test.ts
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { z } from "zod/v4";
|
|
18
|
+
import { registerTools } from "./tools.js";
|
|
19
|
+
|
|
20
|
+
async function connect() {
|
|
21
|
+
const server = new McpServer({ name: "list-tools-test", version: "1" });
|
|
22
|
+
registerTools(server, undefined, "net_list_tools", "u_list_tools", "u_list_tools", false, "tok_list_tools");
|
|
23
|
+
const client = new Client({ name: "list-tools-client", version: "1" });
|
|
24
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
25
|
+
await server.connect(st);
|
|
26
|
+
await client.connect(ct);
|
|
27
|
+
return { server, client, close: async () => { await client.close(); await server.close(); } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("#1756 tools/list", () => {
|
|
31
|
+
test("every registered tool converts to JSON Schema on its own (names the offender)", async () => {
|
|
32
|
+
const { server, close } = await connect();
|
|
33
|
+
try {
|
|
34
|
+
const reg: Record<string, any> = (server as any)._registeredTools;
|
|
35
|
+
const names = Object.keys(reg);
|
|
36
|
+
expect(names.length).toBeGreaterThan(40);
|
|
37
|
+
const bad: string[] = [];
|
|
38
|
+
for (const n of names) {
|
|
39
|
+
const schema = reg[n]?.inputSchema;
|
|
40
|
+
const shape = schema?.shape ?? schema ?? {};
|
|
41
|
+
for (const [k, v] of Object.entries(shape)) {
|
|
42
|
+
try { z.toJSONSchema(z.object({ [k]: v as any })); } catch (e: any) { bad.push(`${n}.${k}: ${String(e?.message).slice(0, 60)}`); }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
expect(bad).toEqual([]);
|
|
46
|
+
} finally { await close(); }
|
|
47
|
+
}, 20_000);
|
|
48
|
+
|
|
49
|
+
test("client.listTools() succeeds and returns every registered tool", async () => {
|
|
50
|
+
const { server, client, close } = await connect();
|
|
51
|
+
try {
|
|
52
|
+
const reg: Record<string, any> = (server as any)._registeredTools;
|
|
53
|
+
const listed = (await client.listTools()).tools;
|
|
54
|
+
expect(listed.length).toBe(Object.keys(reg).length);
|
|
55
|
+
const byName = new Set(listed.map(t => t.name));
|
|
56
|
+
for (const n of ["report_status", "send_desktop_message", "update_node_config", "create_node", "read_node_rules_file"]) {
|
|
57
|
+
expect(byName.has(n), n).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
// 修过的四个字段在 JSON Schema 里必须是 object(不是 undefined / 空)
|
|
60
|
+
const schemaOf = (n: string) => listed.find(t => t.name === n)!.inputSchema as any;
|
|
61
|
+
expect(schemaOf("report_status").properties.config_snapshot).toBeDefined();
|
|
62
|
+
expect(schemaOf("send_desktop_message").properties.meta.type).toBe("object");
|
|
63
|
+
expect(schemaOf("update_node_config").properties.patch.properties.flags.type).toBe("object");
|
|
64
|
+
} finally { await close(); }
|
|
65
|
+
}, 20_000);
|
|
66
|
+
});
|