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

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.148",
3
+ "version": "0.9.0-preview.149",
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", () => {
@@ -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
- expect((await enqueue()).body.error).toBe("session_control_unavailable");
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
- if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_unavailable");
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"))) {