@sleep2agi/commhub-server 0.9.0-preview.149 → 0.9.0-preview.150

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.149",
3
+ "version": "0.9.0-preview.150",
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",
@@ -0,0 +1,137 @@
1
+ // #973 — /api/host-supervisors surfaces the daemon's self-reported runtime
2
+ // version (`agent_node_version`).
3
+ //
4
+ // Why it needs a session join: nodes already send `version` in report_status
5
+ // (agent-node/src/cli.ts writes AGENT_NODE_VERSION) and the Hub stores it on the
6
+ // session row, which is why /api/status can show it. `nodes` has no version
7
+ // column, so the daemon list reads the newest session row for the same
8
+ // (network_id, node_id).
9
+ //
10
+ // 🔴 Absent stays absent: a daemon that never reported a version must NOT get a
11
+ // default, an empty string or the Hub's own version — "unknown" and "old" are
12
+ // different facts, and inventing one would make the client show a stale daemon
13
+ // as up to date.
14
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
15
+ import "./require-explicit-test-db.js";
16
+ import { register, login } from "./auth.js";
17
+ import { db } from "./db.js";
18
+
19
+ let BASE = "";
20
+ let server: any = null;
21
+ let userToken = "";
22
+ let userNetworkId = "";
23
+ let userId = "";
24
+
25
+ beforeAll(async () => {
26
+ process.env.HOST = "127.0.0.1";
27
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
28
+ const password = "DaemonVersionPw123Aa!";
29
+ const seed = register(`dver_seed_${suffix}`, password, undefined, "seed");
30
+ if (!seed.ok) throw new Error("seed failed");
31
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`dver_seed_${suffix}`]);
32
+ const u = register(`dver_u_${suffix}`, password, undefined, "seed");
33
+ if (!u.ok || !u.token) throw new Error("user register failed");
34
+ userToken = u.token;
35
+ userNetworkId = u.network_id ?? "";
36
+ userId = login(`dver_u_${suffix}`, password).user!.user_id;
37
+
38
+ const { bootServer } = await import("./server.js");
39
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
40
+ BASE = `http://127.0.0.1:${server.port}`;
41
+ await new Promise((r) => setTimeout(r, 100));
42
+ });
43
+
44
+ afterAll(() => {
45
+ try { server?.stop?.(true); } catch {}
46
+ });
47
+
48
+ function seedDaemon(alias: string): void {
49
+ db.run(
50
+ `INSERT OR REPLACE INTO nodes (
51
+ node_id, node_name, alias, runtime, model, config_path,
52
+ channels, server, hostname, network_id,
53
+ config_revision, config_snapshot
54
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
55
+ [`dver_n_${alias}`, alias, alias, "claude-agent-sdk", "claude-sonnet-4-5",
56
+ "/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
57
+ 0, JSON.stringify({ role: "host_supervisor" })],
58
+ );
59
+ db.run(
60
+ `INSERT OR REPLACE INTO api_tokens (
61
+ token_id, token_hash, user_id, network_id, name, scope
62
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
63
+ [`dver_t_${alias}`, `hash_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
64
+ );
65
+ }
66
+
67
+ function seedSession(alias: string, version: string | null): void {
68
+ const now = new Date().toISOString();
69
+ db.run(
70
+ `INSERT OR REPLACE INTO sessions (
71
+ node_id, alias, network_id, registered_at, updated_at,
72
+ peer_reply_inbox_capable, rules_file_capable, skills_capable,
73
+ files_capable, logs_capable, version
74
+ ) VALUES (?1, ?2, ?3, ?4, ?4, 1, 1, 1, 1, 1, ?5)`,
75
+ [`dver_n_${alias}`, alias, userNetworkId, now, version],
76
+ );
77
+ }
78
+
79
+ function clearAll(): void {
80
+ db.run("DELETE FROM sessions WHERE network_id = ?1", [userNetworkId]);
81
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
82
+ db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [userNetworkId]);
83
+ }
84
+
85
+ async function listDaemons(): Promise<any[]> {
86
+ const res = await fetch(`${BASE}/api/host-supervisors`, {
87
+ headers: { "Authorization": `Bearer ${userToken}` },
88
+ });
89
+ const body = await res.json();
90
+ if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
91
+ return body.daemons;
92
+ }
93
+
94
+ const find = (rows: any[], alias: string) => rows.find((r) => r.alias === alias)!;
95
+
96
+ describe("#973 — daemon list carries the self-reported runtime version", () => {
97
+ test("a daemon whose newest session reported a version exposes agent_node_version", async () => {
98
+ clearAll();
99
+ seedDaemon("versioned-a");
100
+ seedSession("versioned-a", "2.5.0-preview.155");
101
+ const d = find(await listDaemons(), "versioned-a");
102
+ expect(d.agent_node_version).toBe("2.5.0-preview.155");
103
+ });
104
+
105
+ test("🔴 no session / no version → the field is ABSENT (never a default)", async () => {
106
+ clearAll();
107
+ seedDaemon("silent-a");
108
+ const d = find(await listDaemons(), "silent-a");
109
+ expect("agent_node_version" in d).toBe(false);
110
+ });
111
+
112
+ test("an older session's version does not win over the newest one", async () => {
113
+ clearAll();
114
+ seedDaemon("newest-a");
115
+ const old = new Date(Date.now() - 60_000).toISOString();
116
+ const now = new Date().toISOString();
117
+ // Same (network_id, node_id) with two rows: the newest updated_at must win.
118
+ db.run(
119
+ `INSERT OR REPLACE INTO sessions (
120
+ node_id, alias, network_id, registered_at, updated_at,
121
+ peer_reply_inbox_capable, rules_file_capable, skills_capable,
122
+ files_capable, logs_capable, version
123
+ ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 1, 1, 1, ?6)`,
124
+ ["dver_n_newest-a", "newest-a", userNetworkId, old, old, "2.5.0-preview.146"],
125
+ );
126
+ db.run(
127
+ `INSERT OR REPLACE INTO sessions (
128
+ node_id, alias, network_id, registered_at, updated_at,
129
+ peer_reply_inbox_capable, rules_file_capable, skills_capable,
130
+ files_capable, logs_capable, version
131
+ ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 1, 1, 1, ?6)`,
132
+ ["dver_n_newest-a", "newest-a", userNetworkId, now, now, "2.5.0-preview.155"],
133
+ );
134
+ const d = find(await listDaemons(), "newest-a");
135
+ expect(d.agent_node_version).toBe("2.5.0-preview.155");
136
+ });
137
+ });
package/src/server.ts CHANGED
@@ -4918,9 +4918,18 @@ return Bun.serve({
4918
4918
  isPrivileged = true;
4919
4919
  }
4920
4920
 
4921
+ // #973 — the daemon's self-reported runtime version. Nodes already send
4922
+ // `version` in report_status (agent-node/src/cli.ts writes AGENT_NODE_VERSION)
4923
+ // and the Hub stores it on the session row — that is why /api/status shows it.
4924
+ // `nodes` has no version column, so read the daemon's newest session row.
4925
+ // Absent stays absent: a daemon that never reported must read as "unknown",
4926
+ // never as a made-up version.
4921
4927
  const sqlRows = hostSupervisorIdentityRows(`SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
4922
4928
  n.runtimes_supported, n.allowed_secret_keys,
4923
- n.created_at, n.updated_at, n.config_snapshot
4929
+ n.created_at, n.updated_at, n.config_snapshot,
4930
+ (SELECT s.version FROM sessions s
4931
+ WHERE s.network_id = n.network_id AND s.node_id = n.node_id
4932
+ ORDER BY s.updated_at DESC LIMIT 1) AS reported_version
4924
4933
  FROM nodes n WHERE n.network_id = ?1 ORDER BY n.updated_at DESC`, [effectiveNetId]);
4925
4934
 
4926
4935
  const nowMs = Date.now();
@@ -5025,6 +5034,9 @@ return Bun.serve({
5025
5034
  allowed_secret_keys: secrets,
5026
5035
  host_telemetry: telemetry,
5027
5036
  };
5037
+ // #973 — only when the daemon actually reported one (see the SQL comment).
5038
+ const reportedVersion = (r as unknown as { reported_version?: string | null }).reported_version;
5039
+ if (typeof reportedVersion === "string" && reportedVersion) out.agent_node_version = reportedVersion;
5028
5040
  if (lifecycleUser && typeof adoptCapable === "boolean") {
5029
5041
  // Do not reveal even the new capability for a node the user cannot read.
5030
5042
  const visibleParams: any[] = [r.node_id];