@sleep2agi/commhub-server 0.9.0-preview.43 → 0.9.0-preview.45

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/admin-networks-http.test.ts +5 -1
  3. package/src/api-host-supervisors-can-create-nodes.test.ts +84 -0
  4. package/src/api-host-supervisors-fallback.test.ts +5 -1
  5. package/src/auth-validate.test.ts +5 -1
  6. package/src/create-node-blocked-gate.test.ts +125 -0
  7. package/src/dashboard-slash-routing-http.test.ts +5 -1
  8. package/src/db.ts +31 -0
  9. package/src/external-schedule-edits-http.test.ts +5 -1
  10. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  11. package/src/external-schedule-edits.ts +17 -4
  12. package/src/health-redaction.test.ts +5 -1
  13. package/src/hub-timestamp.test.ts +38 -0
  14. package/src/hub-timestamp.ts +27 -0
  15. package/src/network-name-http.test.ts +5 -1
  16. package/src/node-attrs.test.ts +5 -1
  17. package/src/node-id-alias.test.ts +41 -0
  18. package/src/observer-avatar-http.test.ts +5 -1
  19. package/src/report-status-host-schema.test.ts +135 -0
  20. package/src/rest-explicit-columns-http.test.ts +5 -1
  21. package/src/rules-file-transport.test.ts +235 -0
  22. package/src/scheduled-tasks-http.test.ts +5 -1
  23. package/src/server.ts +34 -5
  24. package/src/side-thread-http-integration.test.ts +5 -1
  25. package/src/skillhub-http.test.ts +5 -1
  26. package/src/task-auth-origin-http.test.ts +5 -1
  27. package/src/task-consumption.test.ts +1 -1
  28. package/src/task-diagnostic-http.test.ts +5 -1
  29. package/src/task-lifecycle-watcher.test.ts +5 -1
  30. package/src/task-network-resolution-http.test.ts +5 -1
  31. package/src/tasks-pagination-http.test.ts +5 -1
  32. package/src/tools.ts +391 -11
  33. package/src/uploads-http.test.ts +5 -1
@@ -145,3 +145,44 @@ describe("#1281 real handler path — stop_node routes both names to the same pl
145
145
  expect(r.error).toBe("node_id_conflict");
146
146
  });
147
147
  });
148
+
149
+ describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同的话", () => {
150
+ // 🔴 生产实测(2026-08-28):218 个节点里 207 个是 `n_…`(有人直接
151
+ // `anet node start` 起的),它们**根本没有 daemon**。原先这两种情况
152
+ // 打印同一句 `pass daemon_node_id explicitly` —— 对那 207 个来说,
153
+ // **这句建议的前提是假的**,照着做只会走进死路。
154
+ const HAND_STARTED = "n_73687f17"; // 手工起的节点,真实形状
155
+ const DAEMON_CHILD = "node_test_1281"; // daemon 建的子节点
156
+
157
+ test("手工起的节点 → not_daemon_managed,并且**不**建议传 daemon_node_id", async () => {
158
+ const r = await call("stop_node", { node_id: HAND_STARTED });
159
+ expect(r.ok).toBe(false);
160
+ expect(r.error).toBe("not_daemon_managed");
161
+ // 🔴 这一条是本组的核心:旧文案的那句建议**不能**再出现在这条路径上。
162
+ expect(r.message).not.toContain("pass daemon_node_id explicitly");
163
+ // 而且要告诉他真正能走的那条路。
164
+ expect(r.message).toContain("anet node stop");
165
+ });
166
+
167
+ test("daemon 建的子节点(无 create-request 行) → 仍是 daemon_not_resolvable", async () => {
168
+ const r = await call("stop_node", { node_id: DAEMON_CHILD });
169
+ expect(r.ok).toBe(false);
170
+ expect(r.error).toBe("daemon_not_resolvable");
171
+ expect(r.message).toContain("pass daemon_node_id explicitly");
172
+ });
173
+
174
+ test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
175
+ const r = await call("delete_node", { node_id: HAND_STARTED, confirm_alias: "x" });
176
+ expect(r.ok).toBe(false);
177
+ expect(r.error).toBe("not_daemon_managed");
178
+ });
179
+
180
+ // 🔴 反向见证:两种情况必须**真的不同**。若有人把判别改成恒定一支,
181
+ // 上面三条里至少一条会红 —— 但那还不够明显,所以这里直接断言两者不等。
182
+ test("两种情况的 error 与 message 都不相同", async () => {
183
+ const a = await call("stop_node", { node_id: HAND_STARTED });
184
+ const b = await call("stop_node", { node_id: DAEMON_CHILD });
185
+ expect(a.error).not.toBe(b.error);
186
+ expect(a.message).not.toBe(b.message);
187
+ });
188
+ });
@@ -36,6 +36,10 @@ let outsiderUserId = "";
36
36
  const TARGET_ALIAS = "obs-target-agent";
37
37
  const AVATAR_NODE_ID = "node_avatar_test_1";
38
38
 
39
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
40
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
41
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
42
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
39
43
  beforeAll(async () => {
40
44
  // Kept for per-file runs launched without an external COMMHUB_DB (the
41
45
  // documented contract still is to set it externally; see db-adapter.ts).
@@ -88,7 +92,7 @@ beforeAll(async () => {
88
92
  const mod: any = await import("./server.js");
89
93
  privateServer = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
90
94
  BASE = `http://127.0.0.1:${privateServer.port}`;
91
- });
95
+ }, 30_000);
92
96
 
93
97
  afterAll(() => {
94
98
  try { privateServer?.stop?.(true); } catch {}
@@ -74,3 +74,138 @@ describe("report_status host telemetry schema", () => {
74
74
  })).not.toThrow();
75
75
  });
76
76
  });
77
+
78
+ /* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
79
+ *
80
+ * 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
81
+ * 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
82
+ * 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
83
+ * 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
84
+ *
85
+ * 消毒在读取处(`/api/host-supervisors`),不在这里。 */
86
+ describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
87
+ const caps = () => schemas.report_status.config_snapshot;
88
+
89
+ test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
90
+ expect(caps()).toBeTruthy();
91
+ const parsed = caps().parse({
92
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
93
+ daemon_capabilities: { can_create_nodes: true },
94
+ });
95
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
96
+ });
97
+
98
+ test("正常值:透传", () => {
99
+ const parsed = caps().parse({
100
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
101
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
102
+ });
103
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
104
+ });
105
+
106
+ test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
107
+ const parsed = caps().parse({
108
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
109
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
110
+ });
111
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
112
+ });
113
+
114
+ /* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
115
+ * 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
116
+ test.each([
117
+ ["负数", -1],
118
+ ["非整数", 1234.5678],
119
+ ["超过一年", 400 * 24 * 60 * 60 * 1000],
120
+ ["NaN", Number.NaN],
121
+ ])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
122
+ expect(() => caps().parse({
123
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
124
+ daemon_capabilities: { can_create_nodes: false,
125
+ create_nodes_blocked_reason: "anet_bin_source",
126
+ create_capability_observed_ms_ago: value },
127
+ })).not.toThrow();
128
+ });
129
+
130
+ /* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
131
+ * 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
132
+ * null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
133
+ test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
134
+ const parsed = caps().parse({
135
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
136
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
137
+ });
138
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
139
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
140
+ });
141
+ });
142
+
143
+ /* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
144
+ *
145
+ * 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
146
+ * hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
147
+ * strict 拒掉整份 report(节点当场失联,#1225 的形状)。
148
+ *
149
+ * 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
150
+ * 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
151
+ * 而不是「必须是某个值」。 */
152
+ describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
153
+ const rs = () => schemas.report_status;
154
+ const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
155
+
156
+ function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
157
+ try {
158
+ const parsed = schema.parse({ ...base, ...UNKNOWN });
159
+ return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
160
+ } catch { return "rejected"; }
161
+ }
162
+
163
+ const SIDE_THREAD = {
164
+ supported: true, runtime: "codex", runtimeVersion: "1.0",
165
+ topology: "native-exact-fork", evidenceRevision: "r1",
166
+ };
167
+ const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
168
+
169
+ test.each([
170
+ ["host", () => rs().host, {}, "dropped"],
171
+ ["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
172
+ ["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
173
+ ["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
174
+ ])("%s → 未知键被 %s", (_name, get, base, expected) => {
175
+ expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
176
+ });
177
+
178
+ test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
179
+ const parsed: any = rs().config_snapshot.parse({
180
+ ...SNAP_BASE,
181
+ daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
182
+ });
183
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
184
+ expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
185
+ });
186
+
187
+ test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
188
+ expect(() => rs().config_snapshot.parse({
189
+ ...SNAP_BASE,
190
+ side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
191
+ })).toThrow();
192
+ // 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
193
+ // 和 strict 无关。
194
+ expect(() => rs().config_snapshot.parse({
195
+ ...SNAP_BASE, side_thread_capability: SIDE_THREAD,
196
+ })).not.toThrow();
197
+ });
198
+
199
+ /* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
200
+ * 那段注释描述的「不对称」就不存在了,而这条会先红。 */
201
+ test("这张表确实是不对称的(两种后果各至少一个)", () => {
202
+ const results = [
203
+ acceptsUnknownKey(rs().host, {}),
204
+ acceptsUnknownKey(rs().process_telemetry, {}),
205
+ acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
206
+ acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
207
+ ];
208
+ expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
209
+ expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
210
+ });
211
+ });
@@ -82,6 +82,10 @@ async function api(path: string): Promise<any> {
82
82
  return response.json();
83
83
  }
84
84
 
85
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
86
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
87
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
88
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
85
89
  beforeAll(async () => {
86
90
  ({ db } = await import("./db.js"));
87
91
  const { register } = await import("./auth.js");
@@ -135,7 +139,7 @@ beforeAll(async () => {
135
139
  const mod = await import("./server.js");
136
140
  server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
137
141
  base = `http://127.0.0.1:${server.port}`;
138
- });
142
+ }, 30_000);
139
143
 
140
144
  afterAll(() => {
141
145
  try { server?.stop(true); } catch {}
@@ -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
- const daysLeft = license.expires_at
1006
- ? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
1007
- : null;
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 },
@@ -3152,6 +3157,12 @@ return Bun.serve({
3152
3157
  // upgraded past preview.55.
3153
3158
  let snapCanCreate: boolean | undefined;
3154
3159
  let snapBlockedReason: string | undefined;
3160
+ // #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
3161
+ // 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
3162
+ // 消毒放在这里。非有限数、负数、超过 1 年的一律当作「没报」——
3163
+ // 🔴 不是 clamp 成边界值:clamp 会把一个坏值变成一个**看起来正常的年龄**,
3164
+ // 而这一格存在的全部意义就是让人分辨新鲜和陈旧。
3165
+ let snapObservedMsAgo: number | undefined;
3155
3166
  if (r.config_snapshot) {
3156
3167
  try {
3157
3168
  const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
@@ -3164,12 +3175,18 @@ return Bun.serve({
3164
3175
  && caps.create_nodes_blocked_reason.length > 0) {
3165
3176
  snapBlockedReason = caps.create_nodes_blocked_reason;
3166
3177
  }
3178
+ const rawAge = caps?.create_capability_observed_ms_ago;
3179
+ if (typeof rawAge === "number" && Number.isFinite(rawAge)
3180
+ && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000) {
3181
+ snapObservedMsAgo = rawAge;
3182
+ }
3167
3183
  } catch { /* malformed */ }
3168
3184
  }
3169
- return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason };
3185
+ return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
3186
+ observedMsAgo: snapObservedMsAgo };
3170
3187
  })
3171
3188
  .filter(({ role }) => role === "host_supervisor")
3172
- .map(({ row: r, canCreate, blockedReason }) => {
3189
+ .map(({ row: r, canCreate, blockedReason, observedMsAgo }) => {
3173
3190
  let online = false;
3174
3191
  let lastSeenAt: string | null = null;
3175
3192
  if (r.session_last_seen) {
@@ -3221,6 +3238,18 @@ return Bun.serve({
3221
3238
  if (canCreate === false && blockedReason) {
3222
3239
  out.create_nodes_blocked_reason = blockedReason;
3223
3240
  }
3241
+ // #1545 —— 只在 daemon 真的报了这一格时才出现。
3242
+ // 🔴 缺席**不能**当成 0:那等于替一个从不重算的旧 daemon 宣称
3243
+ // 「这是刚测的」,正好朝「没问题」方向说谎。缺席就让它缺席,
3244
+ // 读的人据此说「年龄未知」(与 can_create_nodes 本身
3245
+ // undefined-vs-false 的处理同一条规矩,见上面 #1353 Fix ②)。
3246
+ //
3247
+ // 语义:该能力值是在**这份 report 发出前 N 毫秒**测得的。
3248
+ // 绝对年龄 = (now - last_seen_at) + create_capability_observed_ms_ago。
3249
+ // 用节点自己的钟只量了「多久以前」,绝对时间全部由 hub 的钟出。
3250
+ if (typeof observedMsAgo === "number") {
3251
+ out.create_capability_observed_ms_ago = observedMsAgo;
3252
+ }
3224
3253
  }
3225
3254
  return out;
3226
3255
  });
@@ -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 {}