@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.
- package/package.json +1 -1
- package/src/admin-networks-http.test.ts +5 -1
- package/src/api-host-supervisors-can-create-nodes.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +5 -1
- package/src/auth-validate.test.ts +5 -1
- package/src/create-node-blocked-gate.test.ts +125 -0
- 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/report-status-host-schema.test.ts +135 -0
- 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 +34 -5
- 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.ts +391 -11
- package/src/uploads-http.test.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.45",
|
|
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",
|
|
@@ -25,6 +25,10 @@ async function listNetworks(token: string): Promise<any[]> {
|
|
|
25
25
|
return body.networks;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
29
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
30
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
31
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
28
32
|
beforeAll(async () => {
|
|
29
33
|
const { db } = await import("./db.js");
|
|
30
34
|
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
@@ -51,7 +55,7 @@ beforeAll(async () => {
|
|
|
51
55
|
const mod = await import("./server.js");
|
|
52
56
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
53
57
|
base = `http://127.0.0.1:${server.port}`;
|
|
54
|
-
});
|
|
58
|
+
}, 30_000);
|
|
55
59
|
|
|
56
60
|
afterAll(() => {
|
|
57
61
|
try { server?.stop(true); } catch {}
|
|
@@ -264,3 +264,87 @@ describe("#1353 Fix ② — /api/host-supervisors surfaces can_create_nodes + bl
|
|
|
264
264
|
expect(rows.find((r: any) => r.alias === "malformed")).toBeUndefined();
|
|
265
265
|
});
|
|
266
266
|
});
|
|
267
|
+
|
|
268
|
+
/* #1545 —— `can_create_nodes` 说的是「能不能」,这一组说的是「**那是什么时候测的**」。
|
|
269
|
+
*
|
|
270
|
+
* 为什么非要分开:agent-node ≤ 2.5.0-preview.54 用一个进程级缓存只算一次
|
|
271
|
+
* (`cli.ts` 的 `_createCapCache`)。开机时二进制好、之后被 chmod 掉 ⇒ 它会
|
|
272
|
+
* **永远上报 ready**。hub 这边,那条记录和一个 3 秒前刚测出来的 ready
|
|
273
|
+
* 在 `last_seen_at` 上一模一样 —— 心跳是新的,那一格不是。
|
|
274
|
+
*
|
|
275
|
+
* 🔴 缺席**不得**当成 0。把「没报」渲染成「刚测的」,正好朝「没问题」方向说谎,
|
|
276
|
+
* 而这正是 #1545 里比沉默更糟的那一半。
|
|
277
|
+
*/
|
|
278
|
+
describe("#1545 /api/host-supervisors —— can_create_nodes 的年龄", () => {
|
|
279
|
+
test("daemon 报了年龄 → 原样带出", async () => {
|
|
280
|
+
clearDaemons();
|
|
281
|
+
seedDaemon("age-fresh", {
|
|
282
|
+
role: "host_supervisor",
|
|
283
|
+
daemon_capabilities: {
|
|
284
|
+
can_create_nodes: false,
|
|
285
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
286
|
+
create_capability_observed_ms_ago: 12,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const d = findDaemon(await listDaemons(), "age-fresh");
|
|
290
|
+
expect(d.create_capability_observed_ms_ago).toBe(12);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("🔴 daemon 没报年龄 → 这一格必须**缺席**,不能是 0/null", async () => {
|
|
294
|
+
clearDaemons();
|
|
295
|
+
seedDaemon("age-absent", {
|
|
296
|
+
role: "host_supervisor",
|
|
297
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
298
|
+
});
|
|
299
|
+
const d = findDaemon(await listDaemons(), "age-absent");
|
|
300
|
+
// 能力本身照常带出 —— 加年龄这一格不许把旧 daemon 的既有信息弄丢。
|
|
301
|
+
expect(d.can_create_nodes).toBe(true);
|
|
302
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/* 🔴 消毒**不是 clamp**:clamp 会把一个坏值变成一个看起来正常的年龄,
|
|
306
|
+
* 而这一格存在的全部意义就是让人分辨新鲜和陈旧。坏值一律当作「没报」。 */
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["null(schema 的 .catch 兜底值)", null],
|
|
311
|
+
["字符串(直接写库,绕过 schema)", "12"],
|
|
312
|
+
])("坏值当作没报,而不是 clamp 成边界:%s", async (label, value) => {
|
|
313
|
+
clearDaemons();
|
|
314
|
+
const alias = `age-bad-${String(label).slice(0, 4)}`;
|
|
315
|
+
seedDaemon(alias, {
|
|
316
|
+
role: "host_supervisor",
|
|
317
|
+
daemon_capabilities: {
|
|
318
|
+
can_create_nodes: true,
|
|
319
|
+
create_capability_observed_ms_ago: value,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const d = findDaemon(await listDaemons(), alias);
|
|
323
|
+
expect(d.can_create_nodes).toBe(true); // 能力仍在
|
|
324
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false); // 年龄不在
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/* 边界校准:上面用 400 天证明「太旧被丢」,这里用**刚好一年**证明门不是恒丢。
|
|
328
|
+
* 只验被丢那一侧的话,一个恒丢的实现能全绿通过。 */
|
|
329
|
+
test("边界:恰好一年被接受 ⇒ 上面那条不是因为恒丢才绿", async () => {
|
|
330
|
+
clearDaemons();
|
|
331
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
332
|
+
seedDaemon("age-boundary", {
|
|
333
|
+
role: "host_supervisor",
|
|
334
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: oneYear },
|
|
335
|
+
});
|
|
336
|
+
const d = findDaemon(await listDaemons(), "age-boundary");
|
|
337
|
+
expect(d.create_capability_observed_ms_ago).toBe(oneYear);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("年龄只在 can_create_nodes 存在时出现(旧 daemon 两格都没有)", async () => {
|
|
341
|
+
clearDaemons();
|
|
342
|
+
seedDaemon("age-legacy", {
|
|
343
|
+
role: "host_supervisor",
|
|
344
|
+
daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] },
|
|
345
|
+
});
|
|
346
|
+
const d = findDaemon(await listDaemons(), "age-legacy");
|
|
347
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
348
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -38,6 +38,10 @@ let soloNetworkId = "";
|
|
|
38
38
|
let multiNetworkA = "", multiNetworkB = "";
|
|
39
39
|
let soloDaemonAlias = "";
|
|
40
40
|
|
|
41
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
42
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
43
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
44
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
41
45
|
beforeAll(async () => {
|
|
42
46
|
process.env.HOST = "127.0.0.1";
|
|
43
47
|
|
|
@@ -116,7 +120,7 @@ beforeAll(async () => {
|
|
|
116
120
|
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
117
121
|
BASE = `http://127.0.0.1:${server.port}`;
|
|
118
122
|
await new Promise((r) => setTimeout(r, 100));
|
|
119
|
-
});
|
|
123
|
+
}, 30_000);
|
|
120
124
|
|
|
121
125
|
// Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up
|
|
122
126
|
// in /api/host-supervisors' JOIN-then-role-filter query (index.ts
|
|
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
|
|
|
10
10
|
|
|
11
11
|
// First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
|
|
12
12
|
// To test full-strength rules, seed a dummy admin first.
|
|
13
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
14
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
15
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
16
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
13
17
|
beforeAll(() => {
|
|
14
18
|
// Idempotent: if DB file already has users (from a previous run on the
|
|
15
19
|
// same temp path), this errors with "username already taken" → that's
|
|
16
20
|
// fine, the goal is just "ensure at least one user exists so subsequent
|
|
17
21
|
// registers hit the strict path".
|
|
18
22
|
register("_seed_admin", "BootstrapPw1", undefined, "seed");
|
|
19
|
-
});
|
|
23
|
+
}, 30_000);
|
|
20
24
|
|
|
21
25
|
describe("register — username rules", () => {
|
|
22
26
|
it("rejects empty username", () => {
|
|
@@ -31,6 +31,7 @@ function cleanup() {
|
|
|
31
31
|
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
32
32
|
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
33
|
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
34
35
|
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
35
36
|
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
36
37
|
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
@@ -39,6 +40,18 @@ function cleanup() {
|
|
|
39
40
|
beforeEach(cleanup);
|
|
40
41
|
afterAll(cleanup);
|
|
41
42
|
|
|
43
|
+
/** #1545 —— 可选:写一条 sessions 心跳行。
|
|
44
|
+
* 默认**不写**,这样既有那 9 条测试的行为逐字不变;
|
|
45
|
+
* 只有要断言绝对年龄的用例才显式给 `heartbeatAgoMs`。 */
|
|
46
|
+
function seedHeartbeat(agoMs: number) {
|
|
47
|
+
const iso = new Date(Date.now() - agoMs).toISOString();
|
|
48
|
+
db.run(
|
|
49
|
+
`INSERT INTO sessions (alias, node_id, network_id, status, last_seen_at, updated_at)
|
|
50
|
+
VALUES (?1, ?2, ?3, 'idle', ?4, ?4)`,
|
|
51
|
+
[DAEMON_ALIAS, DAEMON_ID, NET, iso],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
function seed(snapshot: object | string | null) {
|
|
43
56
|
db.run(
|
|
44
57
|
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
@@ -229,3 +242,115 @@ describe("#1353 Fix ① — hub gates create_node on daemon-reported can_create_
|
|
|
229
242
|
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
230
243
|
});
|
|
231
244
|
});
|
|
245
|
+
|
|
246
|
+
/* #1545 —— 拒绝的时候还要说**那个判断是什么时候做出来的**。
|
|
247
|
+
*
|
|
248
|
+
* 为什么这一格落在拒绝路径上最要紧:用户被挡住的这一刻,正好在决定"去修哪台机器"。
|
|
249
|
+
* 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
250
|
+
* 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
251
|
+
* (agent-node ≤ 2.5.0-preview.54),它可能早就好了 —— 先重启/升级它
|
|
252
|
+
* 只给 `blocked_reason` 的话,这两种情况给用户的字**一模一样**。 */
|
|
253
|
+
describe("#1545 拒绝文案带上「这个判断是多久以前做的」", () => {
|
|
254
|
+
const blockedSnap = (extra: object) => ({
|
|
255
|
+
role: "host_supervisor",
|
|
256
|
+
daemon_capabilities: {
|
|
257
|
+
can_create_nodes: false,
|
|
258
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
259
|
+
...extra,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/* 🔴 报出去的是**到现在为止**的绝对年龄,不是 daemon 报的那个原始时长。
|
|
264
|
+
* daemon 报的是「测完到**这份 report 发出**」;直接透传会漏掉心跳那一段,
|
|
265
|
+
* **朝更新鲜的方向低报** —— 正是这一格要防的方向。
|
|
266
|
+
* 绝对年龄 = (now − last_seen_at) + daemon 报的时长 */
|
|
267
|
+
test("daemon 报了年龄 + 有心跳时间 → 补上心跳那一段,标记 known", async () => {
|
|
268
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
269
|
+
seedHeartbeat(60_000); // 上次心跳在 60s 前
|
|
270
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
271
|
+
expect(r.ok).toBe(false);
|
|
272
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
273
|
+
expect(r.capability_age).toBe("known");
|
|
274
|
+
// 允许 ±5s 的执行抖动;关键是它**明显大于** 12,345,即心跳那一段确实被加上了
|
|
275
|
+
const got = r.capability_observed_ms_ago as number;
|
|
276
|
+
expect(got).toBeGreaterThanOrEqual(12_345 + 60_000 - 5_000);
|
|
277
|
+
expect(got).toBeLessThanOrEqual(12_345 + 60_000 + 5_000);
|
|
278
|
+
// 🔴 反向锚:如果实现是"原样透传",这个数会恰好等于 12,345
|
|
279
|
+
expect(got).not.toBe(12_345);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* 🔴 第三种 unknown,不能和「旧 daemon 没报」塌成同一个:
|
|
283
|
+
* 它报了,但 hub 这边没有可用的心跳时间 ⇒ **补不出绝对年龄**,而不是"年龄是 0"。 */
|
|
284
|
+
test("报了年龄但 hub 没有心跳时间 → unknown_no_heartbeat_time(不是 legacy,也不是 0)", async () => {
|
|
285
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
286
|
+
// 故意不 seedHeartbeat
|
|
287
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
288
|
+
expect(r.capability_age).toBe("unknown_no_heartbeat_time");
|
|
289
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/* 🔴 本组的重点:年龄未知时给的是**显式 null + 说明为什么的枚举**,
|
|
293
|
+
* 而不是省略这两个键。省略读起来像"没有这个问题",而调用方(常常是个 agent)
|
|
294
|
+
* 会默认它是刚测的 —— 那是朝「更可信」方向撒谎,比不给更糟。 */
|
|
295
|
+
test("🔴 daemon 没报年龄 → 两个键都在:null + unknown_legacy_daemon(不是省略)", async () => {
|
|
296
|
+
seed(blockedSnap({}));
|
|
297
|
+
seedHeartbeat(1_000); // 心跳齐全 ⇒ 唯一缺的就是 daemon 没报那一格
|
|
298
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
299
|
+
expect(r.ok).toBe(false);
|
|
300
|
+
expect("capability_observed_ms_ago" in r).toBe(true);
|
|
301
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
302
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
303
|
+
// 🔴 绝不能渲染成"刚测的"
|
|
304
|
+
expect(r.capability_observed_ms_ago).not.toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["非数字", "12"],
|
|
311
|
+
["null", null],
|
|
312
|
+
])("坏值当作没报(不 clamp 成一个看起来正常的年龄):%s", async (_l, v) => {
|
|
313
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: v }));
|
|
314
|
+
// 🔴 必须给心跳:否则这几条会因为"没有心跳时间"而变成
|
|
315
|
+
// unknown_no_heartbeat_time,**坏值那一维根本没被测到**
|
|
316
|
+
// (夹具只该在被测的那一维上不合法)。
|
|
317
|
+
seedHeartbeat(1_000);
|
|
318
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
319
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
320
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
321
|
+
// 分母自证:拒绝本身仍然发生了,不是因为整条路径没走到才"没有年龄"
|
|
322
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("边界:恰好一年被接受 ⇒ 上面那些不是因为恒判 unknown 才绿", async () => {
|
|
326
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
327
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: oneYear }));
|
|
328
|
+
seedHeartbeat(1_000);
|
|
329
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
330
|
+
expect(r.capability_age).toBe("known");
|
|
331
|
+
expect(r.capability_observed_ms_ago as number).toBeGreaterThanOrEqual(oneYear);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/* 🔴 hub 出**代码**,不出渲染好的修法命令 —— 那张 code → 命令 的表只有一个作者
|
|
335
|
+
* (@sleep2agi/agent-network 的 daemon-capability-display)。抄一份到 hub,
|
|
336
|
+
* 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时没有任何东西会红。 */
|
|
337
|
+
test("🔴 hub 不返回具体修法命令,只把人指到那唯一一份", async () => {
|
|
338
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 0 }));
|
|
339
|
+
seedHeartbeat(1_000);
|
|
340
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
341
|
+
const blob = JSON.stringify(r);
|
|
342
|
+
for (const leaked of ["chmod go-w", "npm i -g", "readlink -f", "/etc/anet-daemon"]) {
|
|
343
|
+
expect(blob).not.toContain(leaked);
|
|
344
|
+
}
|
|
345
|
+
expect(String(r.remediation_hint)).toContain("anet daemon list");
|
|
346
|
+
// 正控:这四条不是恒真 —— 它们确实是那份表里的真实内容
|
|
347
|
+
expect("该二进制 group/other 可写。一行修:chmod go-w \"$(command -v anet)\"").toContain("chmod go-w");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("健康 daemon 不受影响:正常派发,不带这两个键", async () => {
|
|
351
|
+
seed({ role: "host_supervisor", daemon_capabilities: { can_create_nodes: true } });
|
|
352
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
353
|
+
expect(r.ok).not.toBe(false);
|
|
354
|
+
expect("capability_age" in r).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -18,6 +18,10 @@ let nodeToken = "";
|
|
|
18
18
|
let networkId = "";
|
|
19
19
|
let requestSequence = 0;
|
|
20
20
|
|
|
21
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
22
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
23
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
24
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
21
25
|
beforeAll(async () => {
|
|
22
26
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
23
27
|
const username = `dashboard_slash_${Date.now()}`;
|
|
@@ -42,7 +46,7 @@ beforeAll(async () => {
|
|
|
42
46
|
const mod: any = await import("./server.js");
|
|
43
47
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
44
48
|
base = `http://127.0.0.1:${server.port}`;
|
|
45
|
-
});
|
|
49
|
+
}, 30_000);
|
|
46
50
|
|
|
47
51
|
afterAll(() => {
|
|
48
52
|
try { server?.stop?.(true); } catch {}
|
package/src/db.ts
CHANGED
|
@@ -540,6 +540,37 @@ try {
|
|
|
540
540
|
console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
// ── app#225: 节点规则文件(AGENTS.md / CLAUDE.md)远程读写请求 ──
|
|
544
|
+
//
|
|
545
|
+
// 形状照抄 node_config_updates(上面):客户端落一行 + 门铃,节点拉走做完回报,
|
|
546
|
+
// 客户端轮询结果。network_id 同样在插入时从 nodes 反规范化。
|
|
547
|
+
//
|
|
548
|
+
// 🔴 这张表**故意没有路径列**:文件名由节点按自己的 RUNTIME 决定
|
|
549
|
+
// (claude → CLAUDE.md,其余 → AGENTS.md),目录固定是节点进程的 cwd。
|
|
550
|
+
// hub 侧只存 op + 内容,客户端连文件名都指定不了(#225 验收第 5 条)。
|
|
551
|
+
// file_name 是节点**回报**的实际文件名,只用于显示。
|
|
552
|
+
db.exec(`
|
|
553
|
+
CREATE TABLE IF NOT EXISTS node_rules_requests (
|
|
554
|
+
request_id TEXT PRIMARY KEY,
|
|
555
|
+
node_id TEXT NOT NULL,
|
|
556
|
+
network_id TEXT NOT NULL,
|
|
557
|
+
op TEXT NOT NULL,
|
|
558
|
+
content TEXT,
|
|
559
|
+
status TEXT NOT NULL,
|
|
560
|
+
file_name TEXT,
|
|
561
|
+
file_exists INTEGER,
|
|
562
|
+
result_content TEXT,
|
|
563
|
+
error TEXT,
|
|
564
|
+
created_at INTEGER NOT NULL,
|
|
565
|
+
created_by_token TEXT NOT NULL,
|
|
566
|
+
pulled_at INTEGER,
|
|
567
|
+
acked_at INTEGER
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_node_status ON node_rules_requests(node_id, status);
|
|
571
|
+
CREATE INDEX IF NOT EXISTS idx_nrr_network ON node_rules_requests(network_id);
|
|
572
|
+
`);
|
|
573
|
+
|
|
543
574
|
// ── V3: networks table ──
|
|
544
575
|
db.exec(`
|
|
545
576
|
CREATE TABLE IF NOT EXISTS networks (
|
|
@@ -51,6 +51,10 @@ function snapshot(revision: number, extra: Record<string, unknown> = {}) {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
55
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
56
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
57
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
54
58
|
beforeAll(async () => {
|
|
55
59
|
const admin = register(`b4_admin_${stamp}`, "B4Admin123!", undefined, "B4 Admin");
|
|
56
60
|
const owner = register(`b4_owner_${stamp}`, "B4Owner123!", undefined, "B4 Owner");
|
|
@@ -83,7 +87,7 @@ beforeAll(async () => {
|
|
|
83
87
|
const mod: any = await import("./server.js");
|
|
84
88
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
85
89
|
base = `http://127.0.0.1:${server.port}`;
|
|
86
|
-
});
|
|
90
|
+
}, 30_000);
|
|
87
91
|
|
|
88
92
|
afterAll(() => {
|
|
89
93
|
try { server?.stop?.(true); } catch {}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
// #1650 —— external-schedule-edits 的 publicEdit() 原先对四个时间列直接
|
|
5
|
+
// `new Date(row.x).toISOString()`。这些列是 SQLite `datetime('now')` 家族:
|
|
6
|
+
// **UTC 但不带时区标记**,而 JS 对「有时间、无偏移」的串按**本机时区**解析,
|
|
7
|
+
// 且不报错 ⇒ 发给 API 消费方的是一个错的时刻。
|
|
8
|
+
|
|
9
|
+
function hubIso(raw: unknown): string | null {
|
|
10
|
+
const ms = parseHubTimestamp(raw);
|
|
11
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("#1650 hub 时间戳按 UTC 解析", () => {
|
|
15
|
+
it("🔴 无时区标记的串按 UTC 解析,不按本机时区", () => {
|
|
16
|
+
expect(hubIso("2026-08-30 21:04:11")).toBe("2026-08-30T21:04:11.000Z");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("🔴 反向见证:旧写法在非 UTC 机器上会给出不同的结果", () => {
|
|
20
|
+
const old = new Date("2026-08-30 21:04:11").toISOString();
|
|
21
|
+
const now = hubIso("2026-08-30 21:04:11")!;
|
|
22
|
+
// 只有当本机恰好是 UTC 时两者才相同;此处断言的是「新写法等于 UTC 语义」,
|
|
23
|
+
// 而 old 的值取决于运行机器 —— 所以只在两者不同时才有对比意义。
|
|
24
|
+
const offsetMin = new Date("2026-08-30T21:04:11Z").getTimezoneOffset();
|
|
25
|
+
if (offsetMin !== 0) expect(old).not.toBe(now);
|
|
26
|
+
expect(now).toBe("2026-08-30T21:04:11.000Z");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("带 Z 的串保持原意", () => {
|
|
30
|
+
expect(hubIso("2026-08-30T21:04:11Z")).toBe("2026-08-30T21:04:11.000Z");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("🔴 垃圾串返回 null,而不是抛 RangeError(旧写法会把接口打成 500)", () => {
|
|
34
|
+
expect(hubIso("not-a-date")).toBeNull();
|
|
35
|
+
expect(hubIso(null)).toBeNull();
|
|
36
|
+
expect(hubIso(undefined)).toBeNull();
|
|
37
|
+
expect(() => new Date("not-a-date").toISOString()).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 🔴 上面四条测的是 helper 的语义。真正要钉住的是**那个文件用了它**——
|
|
42
|
+
// 否则 helper 再对,publicEdit 里改回 `new Date(row.x)` 也照样全绿。
|
|
43
|
+
import { readFileSync } from "node:fs";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
|
|
46
|
+
describe("#1650 publicEdit 必须走 hubIso,不能有裸 new Date(row.*)", () => {
|
|
47
|
+
const SRC = readFileSync(join(import.meta.dir, "external-schedule-edits.ts"), "utf-8");
|
|
48
|
+
const CODE = SRC.split("\n").filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
|
|
49
|
+
|
|
50
|
+
it("🔴 源码里不再有裸 new Date(row.…)", () => {
|
|
51
|
+
expect(/new Date\(row\./.test(CODE)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("四个时间列都走 hubIso", () => {
|
|
55
|
+
for (const f of ["expires_at", "created_at", "delivered_at", "acked_at"]) {
|
|
56
|
+
expect(CODE).toContain(`${f}: hubIso(row.${f})`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("hubIso 复用 parseHubTimestamp,没有第二套判据", () => {
|
|
61
|
+
expect(CODE).toContain('from "./hub-timestamp"');
|
|
62
|
+
expect(CODE).toContain("parseHubTimestamp(raw)");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -2,6 +2,7 @@ import { db } from "./db.js";
|
|
|
2
2
|
import { getUserNetworkRole } from "./auth.js";
|
|
3
3
|
import { pushEvent } from "./push.js";
|
|
4
4
|
import { parseExternalSchedulePatch, type ExternalSchedulePatch } from "./shared/external-schedule-contract.js";
|
|
5
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
5
6
|
|
|
6
7
|
type Auth = {
|
|
7
8
|
userId: string;
|
|
@@ -109,6 +110,18 @@ function reportedSchedule(node: NodeRow, scheduleId: string): Record<string, unk
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// 🔴 #1650 —— 这些列是 SQLite `datetime('now')` 家族:**UTC 但不带时区标记**。
|
|
114
|
+
// `new Date("2026-08-30 21:04:11")` 会按**本机时区**解析(ES 规范:带时间且无偏移
|
|
115
|
+
// 的形式视为本地时间),而且不报错 —— 于是发给 API 消费方的是一个错的时刻,
|
|
116
|
+
// 误差就是这台机器的 offset。
|
|
117
|
+
// 另外原写法在字段是垃圾串时 `new Date(x).toISOString()` 会**抛 RangeError**,
|
|
118
|
+
// 足以把这个接口打成 500;`parseHubTimestamp` 解析失败返回 null,这里也返回 null。
|
|
119
|
+
// 复用 server.ts:1010 已在用的那个 helper,不自造第二套判据。
|
|
120
|
+
function hubIso(raw: unknown): string | null {
|
|
121
|
+
const ms = parseHubTimestamp(raw);
|
|
122
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
function publicEdit(row: EditRow): Record<string, unknown> {
|
|
113
126
|
let patch: ExternalSchedulePatch = {};
|
|
114
127
|
try { patch = JSON.parse(row.patch_json); } catch {}
|
|
@@ -119,10 +132,10 @@ function publicEdit(row: EditRow): Record<string, unknown> {
|
|
|
119
132
|
base_revision: row.base_revision,
|
|
120
133
|
patch,
|
|
121
134
|
status: row.status,
|
|
122
|
-
expires_at:
|
|
123
|
-
created_at:
|
|
124
|
-
delivered_at:
|
|
125
|
-
acked_at:
|
|
135
|
+
expires_at: hubIso(row.expires_at),
|
|
136
|
+
created_at: hubIso(row.created_at),
|
|
137
|
+
delivered_at: hubIso(row.delivered_at),
|
|
138
|
+
acked_at: hubIso(row.acked_at),
|
|
126
139
|
result_revision: row.result_revision,
|
|
127
140
|
error_code: row.error_code,
|
|
128
141
|
};
|
|
@@ -30,6 +30,10 @@ let userNetworkId = "";
|
|
|
30
30
|
let userName = "";
|
|
31
31
|
let adminToken = "";
|
|
32
32
|
|
|
33
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
34
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
35
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
36
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
33
37
|
beforeAll(async () => {
|
|
34
38
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
|
|
35
39
|
process.env.HOST = "127.0.0.1";
|
|
@@ -72,7 +76,7 @@ beforeAll(async () => {
|
|
|
72
76
|
if (res.status !== 200) throw new Error("SSE subscribe failed: " + res.status);
|
|
73
77
|
sseReader = res.body!.getReader();
|
|
74
78
|
await sseReader.read(); // consume the connected frame → registration done
|
|
75
|
-
});
|
|
79
|
+
}, 30_000);
|
|
76
80
|
|
|
77
81
|
afterAll(() => {
|
|
78
82
|
try { sseReader?.cancel(); } catch {}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
const RAW = "2026-08-30 21:11:24"; // sessions.last_seen_at 的真实取值
|
|
5
|
+
const TRUE_UTC = Date.UTC(2026, 7, 30, 21, 11, 24);
|
|
6
|
+
|
|
7
|
+
describe("parseHubTimestamp (#1650)", () => {
|
|
8
|
+
test("🔴 非 UTC 时区下也必须解出同一个 UTC 时刻", () => {
|
|
9
|
+
// `bun test` 把测试进程时区固定成 UTC(getTimezoneOffset() === 0)。
|
|
10
|
+
// 在 UTC 下,「补 Z」和「不补 Z」解析出**同一个数** —— 这个函数存在的那个
|
|
11
|
+
// 缺陷在测试进程里**结构上不可见**。所以这一条必须开 TZ≠UTC 的子进程。
|
|
12
|
+
// 实测:去掉补 Z,本文件其余各条**全绿**。
|
|
13
|
+
const child = `import { parseHubTimestamp } from ${JSON.stringify(import.meta.dir + "/hub-timestamp.ts")};
|
|
14
|
+
process.stdout.write(new Date().getTimezoneOffset() + "," + parseHubTimestamp(${JSON.stringify(RAW)}));`;
|
|
15
|
+
const r = Bun.spawnSync(["bun", "-e", child], { env: { ...process.env, TZ: "Asia/Shanghai" } });
|
|
16
|
+
const [offset, parsed] = r.stdout.toString().trim().split(",");
|
|
17
|
+
// 🔴 先断言控制生效:子进程真的不在 UTC。否则 TZ 被忽略时这条又变成空测试。
|
|
18
|
+
expect(Number(offset)).not.toBe(0);
|
|
19
|
+
expect(parsed).toBe(String(TRUE_UTC));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("已带 Z / ±HH:MM 的串不再追加时区", () => {
|
|
23
|
+
expect(parseHubTimestamp("2026-08-30T21:11:24Z")).toBe(TRUE_UTC);
|
|
24
|
+
expect(parseHubTimestamp("2026-08-30T22:11:24+01:00")).toBe(TRUE_UTC);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("拿不到就返回 null,不返回一个看起来合理的数", () => {
|
|
28
|
+
for (const bad of [undefined, null, "", " ", 0, 1788124284000, {}, [], "2026-08-30", "垃圾"]) {
|
|
29
|
+
expect(parseHubTimestamp(bad as unknown)).toBeNull();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("🔴 INTEGER 列(epoch 毫秒)不归它管 —— 传数字必须返回 null,而不是悄悄接受", () => {
|
|
34
|
+
// external_schedule_edits.* 是 INTEGER。如果有人把本函数用在那儿,
|
|
35
|
+
// 应当立刻拿到 null 而不是一个错的时刻。
|
|
36
|
+
expect(parseHubTimestamp(TRUE_UTC)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1650 — hub 的 TEXT 时间戳列是 SQLite `datetime('now')` 写的,
|
|
3
|
+
* 形如 `2026-08-30 21:11:24`:**UTC,但不带任何时区标记**。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 `new Date(raw)` / `Date.parse(raw)` 对这种串按**本地时间**解析(ES 规范:
|
|
6
|
+
* 带时间的形式无偏移时视为本地时间),误差恰好等于主机时区偏移,**而且不报错**:
|
|
7
|
+
*
|
|
8
|
+
* TZ=UTC 误差 0h
|
|
9
|
+
* TZ=Asia/Shanghai 误差 -8h (看起来更陈旧)
|
|
10
|
+
* TZ=America/Los_Angeles 误差 +7h (看起来更新鲜 ← 朝好的一侧错)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只对 TEXT 列用它。** 同一个字段名在不同表里可能是不同类型:
|
|
13
|
+
* sessions.last_seen_at / licenses.expires_at → TEXT(要用本函数)
|
|
14
|
+
* external_schedule_edits.expires_at 等 4 列 → INTEGER(epoch 毫秒,直接 new Date 就对)
|
|
15
|
+
* 按字段名判会判错 —— 见 #1650 里的更正。
|
|
16
|
+
*
|
|
17
|
+
* 与 agent-network 侧的同名函数是**有意的重复**:server 包不依赖 agent-network,
|
|
18
|
+
* 仓里也没有共享 lib。两边各自带测试。
|
|
19
|
+
*/
|
|
20
|
+
export function parseHubTimestamp(raw: unknown): number | null {
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
const t = raw.trim();
|
|
23
|
+
if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(t)) return null;
|
|
24
|
+
const hasZone = /([Zz]|[+-]\d{2}:?\d{2})$/.test(t);
|
|
25
|
+
const ms = Date.parse(t.replace(" ", "T") + (hasZone ? "" : "Z"));
|
|
26
|
+
return Number.isFinite(ms) ? ms : null;
|
|
27
|
+
}
|
|
@@ -13,6 +13,10 @@ let nodeToken = "";
|
|
|
13
13
|
let networkId = "";
|
|
14
14
|
let username = "";
|
|
15
15
|
|
|
16
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
17
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
18
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
19
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
16
20
|
beforeAll(async () => {
|
|
17
21
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
18
22
|
username = `network_name_${Date.now()}_${process.pid}`;
|
|
@@ -30,7 +34,7 @@ beforeAll(async () => {
|
|
|
30
34
|
const mod: any = await import("./server.js");
|
|
31
35
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
32
36
|
base = `http://127.0.0.1:${server.port}`;
|
|
33
|
-
});
|
|
37
|
+
}, 30_000);
|
|
34
38
|
|
|
35
39
|
afterAll(() => {
|
|
36
40
|
try { server?.stop?.(true); } catch {}
|
package/src/node-attrs.test.ts
CHANGED
|
@@ -45,6 +45,10 @@ const LEGACY_ALIAS = "attrs-legacy-node";
|
|
|
45
45
|
let token = "";
|
|
46
46
|
let server: { stop: (force?: boolean) => void } | null = null;
|
|
47
47
|
|
|
48
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
49
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
50
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
51
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
48
52
|
beforeAll(async () => {
|
|
49
53
|
db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
|
|
50
54
|
db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
|
|
@@ -58,7 +62,7 @@ beforeAll(async () => {
|
|
|
58
62
|
token = issueUserToken(USER, "attrs-test").token;
|
|
59
63
|
server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
|
|
60
64
|
await new Promise((r) => setTimeout(r, 120));
|
|
61
|
-
});
|
|
65
|
+
}, 30_000);
|
|
62
66
|
|
|
63
67
|
afterAll(() => {
|
|
64
68
|
try { server?.stop(true); } catch { /* already down */ }
|