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

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.151",
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
+ });
@@ -0,0 +1,117 @@
1
+ // #993 first slice — network-scoped rules: GET / PUT /api/networks/:id/rules
2
+ //
3
+ // The contract this fixes:
4
+ // * any member may READ (the rules are what nodes obey);
5
+ // * only owner/admin may WRITE, and only with a matching `expected_revision`
6
+ // (blind writes would let two people overwrite the document every node inherits);
7
+ // * node tokens cannot write (user_token_required), and non-members cannot read;
8
+ // * reads before any write report revision 0 with empty content, so a client can
9
+ // create with `expected_revision: 0` and no special "create" path;
10
+ // * size is capped in UTF-8 bytes.
11
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
12
+ import "./require-explicit-test-db.js";
13
+ import { register } from "./auth.js";
14
+ import { db } from "./db.js";
15
+
16
+ let BASE = "";
17
+ let server: any = null;
18
+ let adminToken = "";
19
+ let memberToken = "";
20
+ let netId = "";
21
+ let nodeToken = "";
22
+
23
+ async function send(token: string, method: string, path: string, payload?: unknown) {
24
+ const res = await fetch(`${BASE}${path}`, {
25
+ method,
26
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
27
+ body: payload === undefined ? undefined : JSON.stringify(payload),
28
+ });
29
+ const text = await res.text();
30
+ let body: any = null;
31
+ try { body = JSON.parse(text); } catch {}
32
+ return { status: res.status, body };
33
+ }
34
+
35
+ beforeAll(async () => {
36
+ process.env.HOST = "127.0.0.1";
37
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
38
+ const pw = "NetworkRulesPw123Aa!";
39
+ const a = register(`nr_admin_${suffix}`, pw, undefined, "admin");
40
+ if (!a.ok) throw new Error("admin register failed");
41
+ adminToken = a.token!;
42
+ netId = a.network_id!;
43
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
44
+
45
+ // The server must be listening before any HTTP call below.
46
+ const { bootServer } = await import("./server.js");
47
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
48
+ BASE = `http://127.0.0.1:${server.port}`;
49
+
50
+ const m = await send(adminToken, "POST", "/api/admin/users", { username: `nr_member_${suffix}`, password: pw, network_id: netId, role: "member" });
51
+ expect(m.status).toBe(200);
52
+ const login = await send("", "POST", "/api/auth/login", { username: `nr_member_${suffix}`, password: pw });
53
+ expect(login.status).toBe(200);
54
+ memberToken = login.body.token;
55
+
56
+ const { createNetworkTokenForNode } = await import("./auth.js");
57
+ const nt = createNetworkTokenForNode(a.user!.user_id, netId, "rules-node");
58
+ expect(nt.ok).toBe(true);
59
+ nodeToken = nt.token!;
60
+ }, 30_000);
61
+
62
+ afterAll(() => { try { server?.stop?.(true); } catch {} });
63
+
64
+ describe("#993 — network rules storage + CAS write", () => {
65
+ test("a member reads an empty document as revision 0", async () => {
66
+ const r = await send(memberToken, "GET", `/api/networks/${netId}/rules`);
67
+ expect(r.status).toBe(200);
68
+ expect(r.body.rules).toMatchObject({ network_id: netId, content: "", revision: 0, updated_at: null, updated_by: null });
69
+ });
70
+
71
+ test("a plain member cannot write", async () => {
72
+ const r = await send(memberToken, "PUT", `/api/networks/${netId}/rules`, { content: "nope", expected_revision: 0 });
73
+ expect(r.status).toBe(403);
74
+ expect(r.body.error).toBe("owner/admin required");
75
+ });
76
+
77
+ test("admin writes with expected_revision 0 and reads it back", async () => {
78
+ const w = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, {
79
+ content: "# Team rules\n\n- be precise\n", expected_revision: 0,
80
+ });
81
+ expect(w.status).toBe(200);
82
+ expect(w.body.rules.revision).toBe(1);
83
+ expect(w.body.rules.updated_by.username).toBeTruthy();
84
+ const r = await send(memberToken, "GET", `/api/networks/${netId}/rules`);
85
+ expect(r.body.rules.content).toContain("be precise");
86
+ expect(r.body.rules.revision).toBe(1);
87
+ });
88
+
89
+ test("a stale expected_revision is a 409, and nothing is overwritten", async () => {
90
+ const w = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "clobber", expected_revision: 0 });
91
+ expect(w.status).toBe(409);
92
+ expect(w.body.error).toBe("rules_revision_conflict");
93
+ const r = await send(adminToken, "GET", `/api/networks/${netId}/rules`);
94
+ expect(r.body.rules.content).toContain("be precise");
95
+ expect(r.body.rules.revision).toBe(1);
96
+ });
97
+
98
+ test("a node token cannot write (user_token_required)", async () => {
99
+ const r = await send(nodeToken, "PUT", `/api/networks/${netId}/rules`, { content: "x", expected_revision: 1 });
100
+ expect(r.status).toBe(403);
101
+ expect(r.body.error).toBe("user_token_required");
102
+ });
103
+
104
+ test("oversized content is refused (bytes, not UTF-16 units)", async () => {
105
+ const r = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "a".repeat(200 * 1024 + 1), expected_revision: 1 });
106
+ expect(r.status).toBe(413);
107
+ expect(r.body.error).toBe("rules_too_large");
108
+ });
109
+
110
+ test("a non-member cannot read", async () => {
111
+ const other = register(`nr_other_${Date.now()}`, "OtherPw123Aa!", undefined, "other");
112
+ expect(other.ok).toBe(true);
113
+ const r = await send(other.token!, "GET", `/api/networks/${netId}/rules`);
114
+ expect(r.status).toBe(403);
115
+ expect(r.body.error).toBe("not_a_member");
116
+ });
117
+ });
@@ -0,0 +1,119 @@
1
+ // #993 — network-scoped rules text.
2
+ //
3
+ // First slice: storage + a read/write API with a compare-and-swap revision and an
4
+ // audit entry. Inheritance (Hub → Daemon → node) is NOT implemented here: it needs
5
+ // a precedence and conflict story of its own, and shipping the storage first is
6
+ // what lets that be reviewed against real content.
7
+ //
8
+ // Deliberate choices:
9
+ // * one row per network, text is the whole document (no chunking) — a team rule
10
+ // file is a few kilobytes, and partial reads would make "which revision did I
11
+ // read" ambiguous;
12
+ // * `revision` starts at 1 for the first write; reads before any write report
13
+ // revision 0 and empty content, so a client can PUT with expected_revision 0
14
+ // without a special "create" path;
15
+ // * the size cap is measured in UTF-8 bytes, not UTF-16 units, because the
16
+ // content is a file the node will write to disk.
17
+ import type { DbAdapter } from "./db-adapter.js";
18
+
19
+ /** 200 KiB. A team rule file is small; anything near this is a mistake. */
20
+ export const NETWORK_RULES_MAX_BYTES = 200 * 1024;
21
+
22
+ export type NetworkRules = {
23
+ network_id: string;
24
+ content: string;
25
+ revision: number;
26
+ updated_at: string | null;
27
+ updated_by: { user_id: string; username: string | null } | null;
28
+ };
29
+
30
+ type Row = { content: string; revision: number; updated_at: string; updated_by_json: string | null };
31
+
32
+ export function ensureNetworkRules(database: DbAdapter): void {
33
+ database.exec(`
34
+ CREATE TABLE IF NOT EXISTS network_rules (
35
+ network_id TEXT NOT NULL PRIMARY KEY,
36
+ content TEXT NOT NULL,
37
+ revision INTEGER NOT NULL,
38
+ updated_at TEXT NOT NULL,
39
+ updated_by_json TEXT
40
+ );
41
+ `);
42
+ }
43
+
44
+ let ensured = false;
45
+ /** Idempotent: the table is created on first use, not on every request. */
46
+ export function ensureNetworkRulesOnce(database: DbAdapter): void {
47
+ if (ensured) return;
48
+ ensureNetworkRules(database);
49
+ ensured = true;
50
+ }
51
+
52
+ function current(database: DbAdapter, networkId: string): Row | null {
53
+ return database.get<Row>(
54
+ "SELECT content, revision, updated_at, updated_by_json FROM network_rules WHERE network_id = ?1",
55
+ networkId,
56
+ ) ?? null;
57
+ }
58
+
59
+ export function readNetworkRules(database: DbAdapter, networkId: string): NetworkRules {
60
+ const row = current(database, networkId);
61
+ if (!row) return { network_id: networkId, content: "", revision: 0, updated_at: null, updated_by: null };
62
+ let updatedBy: NetworkRules["updated_by"] = null;
63
+ try {
64
+ const parsed = JSON.parse(row.updated_by_json ?? "null");
65
+ if (parsed && typeof parsed === "object" && typeof parsed.user_id === "string") {
66
+ updatedBy = { user_id: parsed.user_id, username: typeof parsed.username === "string" ? parsed.username : null };
67
+ }
68
+ } catch { /* a corrupt actor blob must not make the rules unreadable */ }
69
+ return {
70
+ network_id: networkId,
71
+ content: row.content,
72
+ revision: Number(row.revision) || 0,
73
+ updated_at: row.updated_at,
74
+ updated_by: updatedBy,
75
+ };
76
+ }
77
+
78
+ export type WriteResult =
79
+ | { ok: true; rules: NetworkRules }
80
+ | { ok: false; status: number; error: "invalid_rules_content" | "rules_too_large" | "rules_revision_conflict" };
81
+
82
+ /**
83
+ * Compare-and-swap write. `expectedRevision` is required: a blind PUT would let two
84
+ * clients overwrite each other, and this is the same document every node in the
85
+ * network would inherit.
86
+ */
87
+ export function writeNetworkRules(
88
+ database: DbAdapter,
89
+ networkId: string,
90
+ content: unknown,
91
+ expectedRevision: unknown,
92
+ actor: { user_id: string; username: string | null },
93
+ now = new Date().toISOString(),
94
+ ): WriteResult {
95
+ if (typeof content !== "string") return { ok: false, status: 400, error: "invalid_rules_content" };
96
+ if (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
97
+ return { ok: false, status: 400, error: "invalid_rules_content" };
98
+ }
99
+ if (Buffer.byteLength(content, "utf8") > NETWORK_RULES_MAX_BYTES) {
100
+ return { ok: false, status: 413, error: "rules_too_large" };
101
+ }
102
+ const row = current(database, networkId);
103
+ const revision = row ? Number(row.revision) || 0 : 0;
104
+ if (revision !== expectedRevision) return { ok: false, status: 409, error: "rules_revision_conflict" };
105
+ const next = revision + 1;
106
+ const actorJson = JSON.stringify({ user_id: actor.user_id, username: actor.username });
107
+ if (row) {
108
+ database.run(
109
+ "UPDATE network_rules SET content = ?1, revision = ?2, updated_at = ?3, updated_by_json = ?4 WHERE network_id = ?5",
110
+ [content, next, now, actorJson, networkId],
111
+ );
112
+ } else {
113
+ database.run(
114
+ "INSERT INTO network_rules (network_id, content, revision, updated_at, updated_by_json) VALUES (?1, ?2, ?3, ?4, ?5)",
115
+ [networkId, content, next, now, actorJson],
116
+ );
117
+ }
118
+ return { ok: true, rules: readNetworkRules(database, networkId) };
119
+ }
package/src/server.ts CHANGED
@@ -24,6 +24,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
24
24
  import { z } from "zod/v4";
25
25
  import { registerTools } from "./tools.js";
26
26
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
27
+ import { ensureNetworkRulesOnce, readNetworkRules, writeNetworkRules } from "./network-rules.js";
27
28
  import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
28
29
  import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
29
30
  import { assertNodeActive } from "./lifecycle-guard.js";
@@ -1889,6 +1890,46 @@ return Bun.serve({
1889
1890
 
1890
1891
  // ── Agent 分组(RFC-038 §8):owner/admin/Hub 管理员管理;组授权动态生效 ──
1891
1892
  // GET/POST /api/networks/:id/agent-groups · PATCH/DELETE …/:gid · PUT …/:gid/members {node_ids}
1893
+ // #993 — network-scoped rules text (first slice: storage + CAS write + audit).
1894
+ // Inheritance (Hub → Daemon → node) is deliberately NOT here: it needs its own
1895
+ // precedence/conflict decision, and shipping storage first keeps that reviewable.
1896
+ const networkRulesMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/rules$/);
1897
+ if (networkRulesMatch) {
1898
+ const token = requestToken(req, { allowQueryToken: false });
1899
+ const resolved = token ? resolveToken(token) : null;
1900
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1901
+ const netId = decodeURIComponent(networkRulesMatch[1]);
1902
+ const hubAdmin = resolved.user.role === "admin";
1903
+ const role = getUserNetworkRole(resolved.user.user_id, netId);
1904
+ ensureNetworkRulesOnce(db);
1905
+ if (req.method === "GET") {
1906
+ // Reading is for anyone in the network: the rules are what the nodes obey.
1907
+ if (!hubAdmin && !role) return withCors(req, Response.json({ ok: false, error: "not_a_member" }, { status: 403 }));
1908
+ return withCors(req, Response.json({ ok: true, rules: readNetworkRules(db, netId) }));
1909
+ }
1910
+ if (req.method === "PUT") {
1911
+ // Writing changes what every node in the network would inherit.
1912
+ if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
1913
+ if (!hubAdmin && role !== "owner" && role !== "admin") {
1914
+ return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1915
+ }
1916
+ let body: any = null;
1917
+ try { body = await req.json(); } catch { body = null; }
1918
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1919
+ return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
1920
+ }
1921
+ const result = writeNetworkRules(db, netId, body.content, body.expected_revision, {
1922
+ user_id: resolved.user.user_id,
1923
+ username: resolved.user.username ?? null,
1924
+ });
1925
+ if (!result.ok) return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
1926
+ logAudit(resolved.user.user_id, resolved.user.username ?? null, "network_rules_updated", "network_rules", netId,
1927
+ JSON.stringify({ revision: result.rules.revision, bytes: Buffer.byteLength(result.rules.content, "utf8") }), undefined, netId);
1928
+ return withCors(req, Response.json({ ok: true, rules: result.rules }));
1929
+ }
1930
+ return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1931
+ }
1932
+
1892
1933
  const groupsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/agent-groups(?:\/([^/]+)(\/members)?)?$/);
1893
1934
  if (groupsMatch) {
1894
1935
  const token = requestToken(req, { allowQueryToken: false });
@@ -4918,9 +4959,18 @@ return Bun.serve({
4918
4959
  isPrivileged = true;
4919
4960
  }
4920
4961
 
4962
+ // #973 — the daemon's self-reported runtime version. Nodes already send
4963
+ // `version` in report_status (agent-node/src/cli.ts writes AGENT_NODE_VERSION)
4964
+ // and the Hub stores it on the session row — that is why /api/status shows it.
4965
+ // `nodes` has no version column, so read the daemon's newest session row.
4966
+ // Absent stays absent: a daemon that never reported must read as "unknown",
4967
+ // never as a made-up version.
4921
4968
  const sqlRows = hostSupervisorIdentityRows(`SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
4922
4969
  n.runtimes_supported, n.allowed_secret_keys,
4923
- n.created_at, n.updated_at, n.config_snapshot
4970
+ n.created_at, n.updated_at, n.config_snapshot,
4971
+ (SELECT s.version FROM sessions s
4972
+ WHERE s.network_id = n.network_id AND s.node_id = n.node_id
4973
+ ORDER BY s.updated_at DESC LIMIT 1) AS reported_version
4924
4974
  FROM nodes n WHERE n.network_id = ?1 ORDER BY n.updated_at DESC`, [effectiveNetId]);
4925
4975
 
4926
4976
  const nowMs = Date.now();
@@ -5025,6 +5075,9 @@ return Bun.serve({
5025
5075
  allowed_secret_keys: secrets,
5026
5076
  host_telemetry: telemetry,
5027
5077
  };
5078
+ // #973 — only when the daemon actually reported one (see the SQL comment).
5079
+ const reportedVersion = (r as unknown as { reported_version?: string | null }).reported_version;
5080
+ if (typeof reportedVersion === "string" && reportedVersion) out.agent_node_version = reportedVersion;
5028
5081
  if (lifecycleUser && typeof adoptCapable === "boolean") {
5029
5082
  // Do not reveal even the new capability for a node the user cannot read.
5030
5083
  const visibleParams: any[] = [r.node_id];