@sleep2agi/commhub-server 0.9.0-preview.148 → 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.
|
|
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",
|
|
@@ -19,6 +19,11 @@ let server: ReturnType<typeof Bun.serve>;
|
|
|
19
19
|
let base = "";
|
|
20
20
|
const PLAIN = "n_gate_plain";
|
|
21
21
|
const TLS = "n_gate_tls";
|
|
22
|
+
// #1510 acceptance names three transports: loopback passes, https passes, plain
|
|
23
|
+
// public is refused. log/enforce only ever *special-case* `plain`, so loopback
|
|
24
|
+
// could rot without anyone noticing it is still accepted — seed it as its own
|
|
25
|
+
// node rather than folding it in with TLS, so a regression reads by name.
|
|
26
|
+
const LOOPBACK = "n_gate_loopback";
|
|
22
27
|
|
|
23
28
|
const post = async (path: string, token: string, body: unknown) => {
|
|
24
29
|
const r = await fetch(`${base}${path}`, {
|
|
@@ -50,6 +55,7 @@ beforeAll(async () => {
|
|
|
50
55
|
);
|
|
51
56
|
seed(PLAIN, "plain");
|
|
52
57
|
seed(TLS, "https");
|
|
58
|
+
seed(LOOPBACK, "loopback");
|
|
53
59
|
// The transport is reported on the session row (that is where report_status
|
|
54
60
|
// writes it).
|
|
55
61
|
const session = (nodeId: string, transport: string) => db.run(
|
|
@@ -59,6 +65,7 @@ beforeAll(async () => {
|
|
|
59
65
|
);
|
|
60
66
|
session(PLAIN, "plain");
|
|
61
67
|
session(TLS, "https");
|
|
68
|
+
session(LOOPBACK, "loopback");
|
|
62
69
|
|
|
63
70
|
const mod = await import("./server.js");
|
|
64
71
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
@@ -74,7 +81,7 @@ beforeEach(async () => {
|
|
|
74
81
|
// The lane allows one pending request per node; drain whatever the previous
|
|
75
82
|
// case left so the next one starts clean.
|
|
76
83
|
const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
|
|
77
|
-
for (const nodeId of [PLAIN, TLS]) {
|
|
84
|
+
for (const nodeId of [PLAIN, TLS, LOOPBACK]) {
|
|
78
85
|
const daemon = { ok: true as const, networkId: net, daemonNodeId: nodeId, daemonAlias: nodeId };
|
|
79
86
|
const pulled = pullNodeSecrets(daemon) as any;
|
|
80
87
|
for (const r of (pulled?.requests ?? [])) ackNodeSecrets(daemon, r.request_id, null, true);
|
|
@@ -102,6 +109,19 @@ describe("log mode (default)", () => {
|
|
|
102
109
|
expect(after).toBe(before);
|
|
103
110
|
});
|
|
104
111
|
|
|
112
|
+
// #1510 acceptance leg 1: loopback is an allowed transport, so it must neither
|
|
113
|
+
// be refused nor pollute the would-refuse log (the log is the decision input
|
|
114
|
+
// for turning enforce on — a loopback hit in it would read as "something to
|
|
115
|
+
// block" when nothing is).
|
|
116
|
+
test("a put to a loopback node succeeds AND leaves no hit", async () => {
|
|
117
|
+
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
118
|
+
const r = await post(`/api/nodes/${LOOPBACK}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "LB_KEY", value: "v", revision: 1 });
|
|
119
|
+
expect(r.status).toBe(200);
|
|
120
|
+
expect(r.body.ok).toBe(true);
|
|
121
|
+
const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
122
|
+
expect(after).toBe(before);
|
|
123
|
+
});
|
|
124
|
+
|
|
105
125
|
test("list is not observed (it carries no secret)", async () => {
|
|
106
126
|
const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
|
|
107
127
|
await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
|
|
@@ -123,6 +143,17 @@ describe("enforce mode", () => {
|
|
|
123
143
|
const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "D_KEY", value: "v", revision: 1 });
|
|
124
144
|
expect(r.status).toBe(200);
|
|
125
145
|
});
|
|
146
|
+
|
|
147
|
+
// #1510 acceptance: "loopback 通过 / https 通过 / 明文公网被拒". enforce is
|
|
148
|
+
// defined as *refuse plain* — loopback is a local link and must keep working
|
|
149
|
+
// under enforce, otherwise turning the gate on would break the one transport
|
|
150
|
+
// it exists to protect.
|
|
151
|
+
test("a loopback node passes enforce too", async () => {
|
|
152
|
+
process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
|
|
153
|
+
const r = await post(`/api/nodes/${LOOPBACK}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "LB2_KEY", value: "v", revision: 1 });
|
|
154
|
+
expect(r.status).toBe(200);
|
|
155
|
+
expect(r.body.ok).toBe(true);
|
|
156
|
+
});
|
|
126
157
|
});
|
|
127
158
|
|
|
128
159
|
describe("off mode", () => {
|
|
@@ -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
|
+
});
|
|
@@ -297,7 +297,9 @@ describe("#2017 human-to-exact-Bridge HTTP relay", () => {
|
|
|
297
297
|
test("lease expiry refuses new work and Hub restart loses IDs without replay", async () => {
|
|
298
298
|
const made = await enqueue();
|
|
299
299
|
setSystemTime(new Date(Date.now() + 76_000));
|
|
300
|
-
|
|
300
|
+
// #2014: no live bridge is its own code, so a client can tell "this node has no
|
|
301
|
+
// bridge (upgrade / start it)" from a permission refusal.
|
|
302
|
+
expect((await enqueue()).body.error).toBe("session_control_bridge_missing");
|
|
301
303
|
setSystemTime();
|
|
302
304
|
server.stop(true);
|
|
303
305
|
const { bootServer } = await import("./server.js");
|
|
@@ -198,7 +198,11 @@ export function createNodeSessionControlRelay() {
|
|
|
198
198
|
const n = node(v.node_id);
|
|
199
199
|
if (!n || n.network_id !== v.network_id || !human(tokenId, n)) return fail("session_control_unavailable", 403);
|
|
200
200
|
const b = bridges.get(key(n));
|
|
201
|
-
|
|
201
|
+
// A distinct code for "this node has no live bridge" (never registered, or
|
|
202
|
+
// its 75 s lease expired). #2014 needs it: the client can then say "upgrade
|
|
203
|
+
// the node / start it" instead of the generic "refresh later" it shows for
|
|
204
|
+
// `session_control_unavailable` (which also covers permission refusals).
|
|
205
|
+
if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_bridge_missing");
|
|
202
206
|
if (v.expected && !same(v.expected as Identity, b)) return fail("session_control_changed");
|
|
203
207
|
if (entries.size >= CAP || [...entries.values()].some(e => e.nodeId === n.node_id
|
|
204
208
|
&& e.networkId === n.network_id && (e.status === "pending" || e.status === "in_progress"))) {
|
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];
|