@sleep2agi/commhub-server 0.8.0 → 0.8.1-preview.1

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @sleep2agi/commhub-server
2
2
 
3
- CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config.
3
+ CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config when launched through `anet`.
4
4
 
5
- The supported path is to install the `anet` CLI (`@sleep2agi/agent-network` 2.1.0) and run `anet hub start`, which wires up port, the server token, the default account, and local config for you.
5
+ The supported path is to install the `anet` CLI (`@sleep2agi/agent-network` 2.1.7) and run `anet hub start`, which wires up the port, default admin account, recovery admin `utok_`, and local config for you.
6
6
 
7
7
  ## Quick start (verified)
8
8
 
@@ -15,11 +15,11 @@ anet hub start
15
15
  # • Default admin account auto-created: admin / anethub
16
16
  # • Reset hint printed in the launch banner
17
17
 
18
- # Or directly via bunx (Bun required)
19
- bunx @sleep2agi/commhub-server
18
+ # Or directly via bunx (Bun required). Direct runs need explicit auth or dev-open.
19
+ bunx @sleep2agi/commhub-server --dev-open
20
20
 
21
- # With custom port / auth token
22
- PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
21
+ # With custom port / legacy master token (soft-deprecated; prefer user/ntok auth)
22
+ bunx @sleep2agi/commhub-server --port 9200 --token your-secret
23
23
  ```
24
24
 
25
25
  Once running:
@@ -35,11 +35,11 @@ Once running:
35
35
 
36
36
  | Package | Version |
37
37
  |---|---|
38
- | [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | 2.1.0 |
39
- | [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 0.3.0 |
38
+ | [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | 2.1.7 |
39
+ | [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 0.4.2 |
40
40
  | [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | 2.3.0 |
41
41
 
42
- ## MCP tools (18)
42
+ ## MCP tools (17)
43
43
 
44
44
  ### Agent-side
45
45
  | Tool | Description |
@@ -87,7 +87,7 @@ The server exposes ~33 endpoints across health, auth, networks, and observabilit
87
87
  | GET | `/api/stats` | Aggregate stats |
88
88
  | GET | `/api/audit-log` | Audit trail |
89
89
 
90
- Network-management endpoints (`/api/networks…`) are present and used by the current CLI. `/api/license[…]` is present but remains a placeholder.
90
+ Network-management endpoints (`/api/networks…`) are present and used by the current CLI. `/api/license[…]` is present as an experimental legacy trial/pro-license surface.
91
91
 
92
92
  Auth: `Authorization: Bearer <token>` header, or `?token=<token>` query.
93
93
 
@@ -107,7 +107,7 @@ Auto-created on first run.
107
107
  | `networks` | Workspaces |
108
108
  | `api_tokens` | `utok_` / `ntok_` / `atok_` tokens |
109
109
  | `audit_log` | Operation audit |
110
- | `licenses` | License placeholder |
110
+ | `licenses` | Experimental trial/pro-license state |
111
111
  | `network_members` | Workspace membership |
112
112
  | `network_invites` | Invite codes |
113
113
 
@@ -121,9 +121,11 @@ delivered → expired (5min watchdog)
121
121
  delivered/acked/running → reassign → delivered (new agent)
122
122
  ```
123
123
 
124
- ## PostgreSQL (experimental)
124
+ ## PostgreSQL (community extension point — not on the maintained roadmap)
125
125
 
126
- Set `DATABASE_URL` to switch to PostgreSQL the SQL layer auto-translates SQLite-isms (datetime, parameter placeholders) so application code is unchanged. Requires `bun add pg`. PostgreSQL remains experimental.
126
+ > v0.8+ product direction is **SQLite only** (see [docs/v3-postgresql-design.md banner](https://github.com/sleep2agi/agent-network/blob/main/docs/v3-postgresql-design.md)). The PostgreSQL adapter interface is preserved as a community extension point no E2E coverage on the current stable line; **not recommended for mainline production**.
127
+
128
+ Set `DATABASE_URL` to switch to PostgreSQL — the SQL layer auto-translates SQLite-isms (datetime, parameter placeholders) so application code is unchanged. Requires `bun add pg`.
127
129
 
128
130
  ```bash
129
131
  DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-server
@@ -148,10 +150,10 @@ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-serv
148
150
 
149
151
  ## Not verified
150
152
 
151
- - `/api/license*` — placeholder for a future paid tier.
153
+ - `/api/license*` — experimental legacy trial/pro-license endpoints.
152
154
  - PostgreSQL backend — translation layer exists, no E2E run.
153
155
  - Telegram / WeChat / Feishu channel endpoints — channel code exists, but only Telegram-oriented agent-node paths are actively exercised.
154
156
 
155
157
  ## License
156
158
 
157
- MIT
159
+ Apache-2.0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.0",
4
- "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
3
+ "version": "0.8.1-preview.1",
4
+ "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "bin": {
@@ -0,0 +1,132 @@
1
+ // UT-01 — server/src/db.ts token generation + hashing pure functions
2
+ // L0 unit test, code view.
3
+ //
4
+ // Importing ./db.js triggers schema bootstrap (db.exec(CREATE TABLE ...) at
5
+ // module load). To avoid touching real state, run with:
6
+ // COMMHUB_DB=/tmp/qa-ut-01.db bun test src/auth-tokens.test.ts
7
+ // (Dockerfile / qa.sh set this env.)
8
+ import { describe, expect, it } from "bun:test";
9
+ import {
10
+ uuidv4,
11
+ generateId,
12
+ generateToken,
13
+ generateUserToken,
14
+ generateNetworkToken,
15
+ hashToken,
16
+ hashPassword,
17
+ } from "./db.js";
18
+
19
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
20
+ const HEX_RE = (n: number) => new RegExp(`^[0-9a-f]{${n}}$`);
21
+
22
+ describe("uuidv4", () => {
23
+ it("returns RFC 4122 v4 UUID", () => {
24
+ for (let i = 0; i < 50; i++) {
25
+ expect(uuidv4()).toMatch(UUID_RE);
26
+ }
27
+ });
28
+ it("returns unique values across 1000 invocations", () => {
29
+ const s = new Set<string>();
30
+ for (let i = 0; i < 1000; i++) s.add(uuidv4());
31
+ expect(s.size).toBe(1000);
32
+ });
33
+ });
34
+
35
+ describe("generateId(prefix)", () => {
36
+ it("returns `<prefix>_<12 hex>`", () => {
37
+ const id = generateId("tok");
38
+ expect(id).toMatch(/^tok_[0-9a-f]{12}$/);
39
+ });
40
+ it("respects arbitrary prefix", () => {
41
+ expect(generateId("u")).toMatch(/^u_[0-9a-f]{12}$/);
42
+ expect(generateId("net")).toMatch(/^net_[0-9a-f]{12}$/);
43
+ });
44
+ });
45
+
46
+ describe("token generators — prefix + length contract", () => {
47
+ // ALL THREE strip UUID dashes → 32 hex chars after prefix.
48
+ // If you ever change crypto.randomUUID() implementation, this asserts the
49
+ // shape stays the same — SDK regexes parse these.
50
+ it("atok_<32hex>", () => {
51
+ expect(generateToken()).toMatch(/^atok_[0-9a-f]{32}$/);
52
+ });
53
+ it("utok_<32hex>", () => {
54
+ expect(generateUserToken()).toMatch(/^utok_[0-9a-f]{32}$/);
55
+ });
56
+ it("ntok_<32hex>", () => {
57
+ expect(generateNetworkToken()).toMatch(/^ntok_[0-9a-f]{32}$/);
58
+ });
59
+ });
60
+
61
+ describe("token generators — prefixes are distinct", () => {
62
+ // Pin the three-way discrimination that resolveToken / SDK clients rely on.
63
+ it("utok / ntok / atok prefixes never collide", () => {
64
+ const u = generateUserToken();
65
+ const n = generateNetworkToken();
66
+ const a = generateToken();
67
+ expect(u.startsWith("utok_")).toBe(true);
68
+ expect(n.startsWith("ntok_")).toBe(true);
69
+ expect(a.startsWith("atok_")).toBe(true);
70
+ expect(u.startsWith("ntok_")).toBe(false);
71
+ expect(n.startsWith("utok_")).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("token uniqueness (no collisions over 1000)", () => {
76
+ for (const [name, gen] of [
77
+ ["generateToken", generateToken],
78
+ ["generateUserToken", generateUserToken],
79
+ ["generateNetworkToken", generateNetworkToken],
80
+ ] as const) {
81
+ it(`${name} — 1000 invocations unique`, () => {
82
+ const s = new Set<string>();
83
+ for (let i = 0; i < 1000; i++) s.add(gen());
84
+ expect(s.size).toBe(1000);
85
+ });
86
+ }
87
+ });
88
+
89
+ describe("hashToken — deterministic sha256", () => {
90
+ it("returns 64-char lowercase hex", () => {
91
+ expect(hashToken("anything")).toMatch(HEX_RE(64));
92
+ });
93
+ it("deterministic — same input same output", () => {
94
+ expect(hashToken("utok_abc")).toBe(hashToken("utok_abc"));
95
+ });
96
+ it("similar inputs produce DIFFERENT hashes (no truncation/collision)", () => {
97
+ expect(hashToken("utok_abc")).not.toBe(hashToken("ntok_abc"));
98
+ expect(hashToken("a")).not.toBe(hashToken("b"));
99
+ });
100
+ it("known fixture — sha256('test') = 9f86...", () => {
101
+ expect(hashToken("test")).toBe(
102
+ "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
103
+ );
104
+ });
105
+ });
106
+
107
+ describe("hashPassword — sha256('anet:' + password)", () => {
108
+ it("returns 64-char lowercase hex", () => {
109
+ expect(hashPassword("StrongPassw0rd")).toMatch(HEX_RE(64));
110
+ });
111
+ it("deterministic", () => {
112
+ expect(hashPassword("foo")).toBe(hashPassword("foo"));
113
+ });
114
+ it("uses 'anet:' salt → different from hashToken on same input", () => {
115
+ // This pins the 'anet:' prefix — if someone drops it, this fails,
116
+ // and existing user password hashes will silently stop matching.
117
+ expect(hashPassword("foo")).not.toBe(hashToken("foo"));
118
+ expect(hashPassword("foo")).toBe(hashToken("anet:foo"));
119
+ });
120
+ });
121
+
122
+ describe("safety — full-token vs prefix-only hash", () => {
123
+ // If somewhere we ever hashed only the prefix or only the body, resolveToken
124
+ // would return the wrong user. Pin "hashToken takes the WHOLE string".
125
+ it("hashToken(prefix) != hashToken(full)", () => {
126
+ const utok = generateUserToken();
127
+ const prefix = "utok_";
128
+ const body = utok.slice(5);
129
+ expect(hashToken(utok)).not.toBe(hashToken(prefix));
130
+ expect(hashToken(utok)).not.toBe(hashToken(body));
131
+ });
132
+ });
@@ -0,0 +1,145 @@
1
+ // UT-03 — server/src/auth.ts password + username validation in register()
2
+ // L0 unit test, code view. Tests validatePasswordStrength indirectly via the
3
+ // user-facing register() contract (it's not exported on its own).
4
+ //
5
+ // Run with COMMHUB_DB=/tmp/qa-l0-auth-validate.db so the schema bootstrap
6
+ // at db.ts load time lands in a throwaway file. Each test uses a unique
7
+ // username, so cross-test DB state doesn't matter.
8
+ import { describe, expect, it, beforeAll } from "bun:test";
9
+ import { register } from "./auth.js";
10
+
11
+ // First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
12
+ // To test full-strength rules, seed a dummy admin first.
13
+ beforeAll(() => {
14
+ // Idempotent: if DB file already has users (from a previous run on the
15
+ // same temp path), this errors with "username already taken" → that's
16
+ // fine, the goal is just "ensure at least one user exists so subsequent
17
+ // registers hit the strict path".
18
+ register("_seed_admin", "BootstrapPw1", undefined, "seed");
19
+ });
20
+
21
+ describe("register — username rules", () => {
22
+ it("rejects empty username", () => {
23
+ const r = register("", "AnyValidPw123");
24
+ expect(r.ok).toBe(false);
25
+ expect(r.error).toContain("username must be at least 2 characters");
26
+ });
27
+
28
+ it("rejects 1-char username", () => {
29
+ expect(register("a", "AnyValidPw123").ok).toBe(false);
30
+ });
31
+
32
+ it("accepts 2-char username", () => {
33
+ const r = register("u2", "StrongPw1234");
34
+ expect(r.ok).toBe(true);
35
+ });
36
+
37
+ it("rejects 51-char username", () => {
38
+ const long = "u".repeat(51);
39
+ const r = register(long, "StrongPw1234");
40
+ expect(r.ok).toBe(false);
41
+ expect(r.error).toContain("too long");
42
+ });
43
+
44
+ it("rejects username with space", () => {
45
+ expect(register("bad name", "StrongPw1234").error).toContain("invalid characters");
46
+ });
47
+
48
+ it("rejects username with '@'", () => {
49
+ expect(register("foo@bar", "StrongPw1234").error).toContain("invalid characters");
50
+ });
51
+
52
+ it("accepts Chinese username (CJK range)", () => {
53
+ // Regex includes 一-鿿 per auth.ts L34
54
+ const r = register("通信测试马", "StrongPw1234");
55
+ expect(r.ok).toBe(true);
56
+ });
57
+
58
+ it("rejects duplicate username", () => {
59
+ register("dup_u", "StrongPw1234");
60
+ const r = register("dup_u", "DifferentPw1");
61
+ expect(r.ok).toBe(false);
62
+ expect(r.error).toContain("already taken");
63
+ });
64
+ });
65
+
66
+ describe("register — password length", () => {
67
+ it("rejects empty password (post-admin)", () => {
68
+ const r = register("pw_empty", "");
69
+ expect(r.ok).toBe(false);
70
+ expect(r.error).toContain("8 characters");
71
+ });
72
+
73
+ it("rejects 7-char password", () => {
74
+ expect(register("pw_7", "Abc1234").error).toContain("8 characters");
75
+ });
76
+
77
+ it("accepts 8-char strong password", () => {
78
+ expect(register("pw_8ok", "StrongP1").ok).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("register — weak-password dictionary (post-admin strict path)", () => {
83
+ // Each of these is exactly 8+ chars but in WEAK_PASSWORDS dict.
84
+ // Pins: validatePasswordStrength rejects by dict AFTER length check.
85
+ const dictMatches = ["password", "passw0rd", "letmein1", "iloveyou"];
86
+ // Note: "letmein1" — is that in dict? letmein is. password{N} family
87
+ // generates "letmein1"? No — that's password{N} only. Let me just use
88
+ // ones I know:
89
+ const reallyInDict = ["password", "passw0rd", "iloveyou", "password1", "qwerty12"];
90
+ // qwerty12 — is it? qwerty{N} family covers 0..999 so qwerty12 is in.
91
+ for (const pw of reallyInDict) {
92
+ it(`rejects "${pw}" as too common`, () => {
93
+ const r = register(`pw_dict_${pw}`, pw);
94
+ expect(r.ok).toBe(false);
95
+ expect(r.error).toContain("too common");
96
+ });
97
+ }
98
+ });
99
+
100
+ describe("register — case-insensitive dict lookup", () => {
101
+ // auth.ts L26 calls WEAK_PASSWORDS.has(password.toLowerCase()).
102
+ // Pins that uppercase weak password is still rejected.
103
+ it("rejects 'PASSWORD' (uppercase)", () => {
104
+ const r = register("pw_uc", "PASSWORD");
105
+ expect(r.ok).toBe(false);
106
+ expect(r.error).toContain("too common");
107
+ });
108
+ it("rejects 'Password1' (mixed)", () => {
109
+ const r = register("pw_mix", "Password1");
110
+ expect(r.ok).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("register — strong passwords accepted", () => {
115
+ // None of these should be in the dict. All ≥ 8 chars.
116
+ const strong = [
117
+ "Tr0ub4dor&3",
118
+ "correct-horse-battery",
119
+ "X9!kLm@PqVx",
120
+ "MyDog'sName2026",
121
+ ];
122
+ for (const pw of strong) {
123
+ it(`accepts strong "${pw}"`, () => {
124
+ // unique username per pw
125
+ const u = "ok_" + pw.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12);
126
+ const r = register(u, pw);
127
+ expect(r.ok).toBe(true);
128
+ });
129
+ }
130
+ });
131
+
132
+ describe("register — admin bootstrap relaxed rules", () => {
133
+ // Test that the FIRST user gets the relaxed validator. Can't directly
134
+ // test this here (the beforeAll already seeded an admin in this DB), but
135
+ // we pin the CONTRACT: if no users exist, password >= 4 chars suffices.
136
+ //
137
+ // This is asserted by checking that AFTER the seed admin exists,
138
+ // a 4-char password is REJECTED for normal users — which proves the
139
+ // strict path is hit.
140
+ it("4-char password rejected for non-first user", () => {
141
+ const r = register("short_pw_user", "abcd");
142
+ expect(r.ok).toBe(false);
143
+ expect(r.error).toContain("8 characters");
144
+ });
145
+ });
package/src/db.ts CHANGED
@@ -55,13 +55,14 @@ db.exec(`
55
55
 
56
56
  // ── V2 schema migration (ALTER TABLE, safe to re-run) ──
57
57
 
58
- // sessions: add node_id, session_id, config_path, channels, last_seen_at
58
+ // sessions: add node_id, session_id, config_path, channels, last_seen_at, model
59
59
  for (const col of [
60
60
  { name: "node_id", def: "TEXT" },
61
61
  { name: "session_id", def: "TEXT" },
62
62
  { name: "config_path", def: "TEXT" },
63
63
  { name: "channels", def: "TEXT" },
64
64
  { name: "last_seen_at", def: "TEXT" },
65
+ { name: "model", def: "TEXT" },
65
66
  ]) {
66
67
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
67
68
  }
@@ -272,6 +273,29 @@ db.exec(`
272
273
  );
273
274
  `);
274
275
 
276
+ // ── #84: node rename — rename_txn table (RFC-010 §4) ──
277
+ // Single isolated table holding the rename 2PC transaction state. It doubles
278
+ // as the alias_rename_log (RFC §4 risk #5): the audit log of completed renames
279
+ // is just `SELECT * FROM rename_txn WHERE status = 'committed'`. Kept out of
280
+ // the sessions table so a prepared (in-flight) new-alias never shows up in
281
+ // get_all_status / node listings. status: prepared → committed | aborted.
282
+ db.exec(`
283
+ CREATE TABLE IF NOT EXISTS rename_txn (
284
+ txn_id TEXT PRIMARY KEY,
285
+ network_id TEXT NOT NULL,
286
+ old_alias TEXT NOT NULL,
287
+ new_alias TEXT NOT NULL,
288
+ status TEXT NOT NULL DEFAULT 'prepared',
289
+ prepared_at TEXT NOT NULL DEFAULT (datetime('now')),
290
+ committed_at TEXT,
291
+ aborted_at TEXT
292
+ );
293
+
294
+ CREATE INDEX IF NOT EXISTS idx_rename_txn_new ON rename_txn(network_id, new_alias);
295
+ CREATE INDEX IF NOT EXISTS idx_rename_txn_old ON rename_txn(network_id, old_alias);
296
+ CREATE INDEX IF NOT EXISTS idx_rename_txn_status ON rename_txn(status);
297
+ `);
298
+
275
299
  // ── V3.13: networks visibility + max_members ──
276
300
  try { db.exec("ALTER TABLE networks ADD COLUMN visibility TEXT DEFAULT 'private'"); } catch {}
277
301
  try { db.exec("ALTER TABLE networks ADD COLUMN max_members INTEGER DEFAULT 50"); } catch {}
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
7
7
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
+ import { prepareRename, commitRename, abortRename } from "./rename.js";
8
9
 
9
10
  const PORT = Number(process.env.PORT) || 9200;
10
11
  const HOST = process.env.HOST || "127.0.0.1";
@@ -131,6 +132,18 @@ function isLocalhostIP(ip: string): boolean {
131
132
  return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
132
133
  }
133
134
 
135
+ // Normalize the raw `agent` field into a canonical runtime identifier for the
136
+ // dashboard's Runtime badge. Returns null for unknown/absent agents so the
137
+ // frontend can fall back to a placeholder.
138
+ function normalizeRuntime(agent: unknown): string | null {
139
+ if (typeof agent !== "string" || agent.length === 0) return null;
140
+ if (agent === "claude-code") return "claude-code-cli";
141
+ if (agent.startsWith("agent-node:codex")) return "codex-sdk";
142
+ if (agent.startsWith("agent-node:claude")) return "claude-agent-sdk";
143
+ if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
144
+ return null;
145
+ }
146
+
134
147
  function isTmuxAllowedIP(ip: string): boolean {
135
148
  return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
136
149
  }
@@ -246,10 +259,11 @@ const CORS_ORIGINS = process.env.COMMHUB_CORS_ORIGINS
246
259
 
247
260
  function corsHeaders(req: Request): Record<string, string> {
248
261
  const origin = req.headers.get("Origin") || "";
249
- const allowed = CORS_ORIGINS.includes(origin)
250
- || origin === "https://agent-network.vansin.me"
251
- || origin === "https://agent-network-dashboard.vercel.app"
252
- ? origin : "";
262
+ // CORS allowlist is driven entirely by COMMHUB_CORS_ORIGINS env (comma-separated).
263
+ // Default (env unset) allows localhost dev origins only — see CORS_ORIGINS above.
264
+ // No author-specific domains are hardcoded; production deployments must set
265
+ // COMMHUB_CORS_ORIGINS explicitly. See docs/concepts/security.md "CORS 配置".
266
+ const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
253
267
  return {
254
268
  "Access-Control-Allow-Origin": allowed,
255
269
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -513,6 +527,59 @@ Bun.serve({
513
527
  }
514
528
  }
515
529
 
530
+ // ── #84: node rename 2PC — Server surface (RFC-010 §4) ──
531
+ // The CLI orchestrates the 2PC; these endpoints are the Server steps:
532
+ // prepare = PHASE 1 P3, commit = PHASE 2 C1, abort = PHASE 1 rollback.
533
+ if (url.pathname === "/api/node-rename/prepare" && req.method === "POST") {
534
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
535
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
536
+ const resolved = resolveToken(token);
537
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
538
+ try {
539
+ const body = await req.json() as any;
540
+ if (!body.network_id || !body.old_alias || !body.new_alias) {
541
+ return withCors(req, Response.json({ ok: false, error: "network_id, old_alias, new_alias required" }, { status: 400 }));
542
+ }
543
+ const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
544
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
545
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
546
+ } catch (e: any) {
547
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
548
+ }
549
+ }
550
+
551
+ if (url.pathname === "/api/node-rename/commit" && req.method === "POST") {
552
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
553
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
554
+ const resolved = resolveToken(token);
555
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
556
+ try {
557
+ const body = await req.json() as any;
558
+ if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
559
+ const result = commitRename(resolved.user.user_id, body.txn_id);
560
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_committed", "node", body.txn_id);
561
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
562
+ } catch (e: any) {
563
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
564
+ }
565
+ }
566
+
567
+ if (url.pathname === "/api/node-rename/abort" && req.method === "POST") {
568
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
569
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
570
+ const resolved = resolveToken(token);
571
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
572
+ try {
573
+ const body = await req.json() as any;
574
+ if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
575
+ const result = abortRename(resolved.user.user_id, body.txn_id);
576
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_aborted", "node", body.txn_id);
577
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
578
+ } catch (e: any) {
579
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
580
+ }
581
+ }
582
+
516
583
  // ── V3: Token management ──
517
584
  if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
518
585
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
@@ -756,7 +823,14 @@ Bun.serve({
756
823
  let sql = "SELECT * FROM sessions WHERE 1=1";
757
824
  sql = addNetworkScope(sql, params, restScope);
758
825
  sql += " ORDER BY updated_at DESC";
759
- const sessions = db.all(sql, ...params);
826
+ // `model` comes straight from the sessions row (SELECT *); `runtime` is
827
+ // derived from the raw `agent` field. Both default to null for old nodes
828
+ // that never reported a model — the dashboard falls back to a placeholder.
829
+ const sessions = db.all(sql, ...params).map((s: any) => ({
830
+ ...s,
831
+ model: s.model ?? null,
832
+ runtime: normalizeRuntime(s.agent),
833
+ }));
760
834
  const summary = sessions.reduce((acc: any, session: any) => {
761
835
  const raw = String(session.status || "").toLowerCase();
762
836
  if (raw === "offline") acc.offline++;
@@ -0,0 +1,68 @@
1
+ // UT-02 — server/src/password-dict.ts
2
+ // L0 unit test, code view. Pure data + computed set; no I/O, no network.
3
+ // Runs via `bun test` (~ms).
4
+ import { describe, expect, it } from "bun:test";
5
+ import { WEAK_PASSWORDS } from "./password-dict.js";
6
+
7
+ const has = (p: string) => WEAK_PASSWORDS.has(p.toLowerCase());
8
+
9
+ describe("WEAK_PASSWORDS — common entries", () => {
10
+ for (const p of ["123456", "password", "qwerty", "admin", "letmein", "iloveyou", "passw0rd"]) {
11
+ it(`contains "${p}"`, () => expect(has(p)).toBe(true));
12
+ }
13
+ });
14
+
15
+ describe("WEAK_PASSWORDS — generated families", () => {
16
+ it("contains 6-digit zero-padded numbers 000000..000999", () => {
17
+ expect(has("000000")).toBe(true);
18
+ expect(has("000042")).toBe(true);
19
+ expect(has("000999")).toBe(true);
20
+ });
21
+
22
+ it("contains passwordN family for N=0..999", () => {
23
+ expect(has("password0")).toBe(true);
24
+ expect(has("password42")).toBe(true);
25
+ expect(has("password999")).toBe(true);
26
+ });
27
+
28
+ it("contains qwertyN family for N=0..999", () => {
29
+ expect(has("qwerty0")).toBe(true);
30
+ expect(has("qwerty999")).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe("WEAK_PASSWORDS — case insensitive contract (storage is lowercase)", () => {
35
+ // The Set itself stores only lowercase. Consumers (auth.ts L26) call
36
+ // `WEAK_PASSWORDS.has(password.toLowerCase())`. This test pins both sides
37
+ // of the contract so a future refactor that changes either side fails fast.
38
+ it("lowercase lookup matches", () => expect(WEAK_PASSWORDS.has("password")).toBe(true));
39
+ it("uppercase lookup misses (must be lowercased by caller)", () => {
40
+ expect(WEAK_PASSWORDS.has("PASSWORD")).toBe(false);
41
+ expect(has("PASSWORD")).toBe(true); // via our wrapper that mirrors auth.ts
42
+ });
43
+ });
44
+
45
+ describe("WEAK_PASSWORDS — strong passwords stay out", () => {
46
+ const strong = [
47
+ "StrongPassw0rd", // mixed case + digit
48
+ "correct horse battery", // multi-word
49
+ "Tr0ub4dor&3", // mixed everything
50
+ "a1b2c3d4e5f6g7", // random-looking
51
+ "j!K8sLm@PqVx", // symbols
52
+ ];
53
+ for (const p of strong) {
54
+ it(`does NOT contain "${p}"`, () => expect(has(p)).toBe(false));
55
+ }
56
+ });
57
+
58
+ describe("WEAK_PASSWORDS — size sanity", () => {
59
+ it("has > 100 base entries plus families (>= 3100 total)", () => {
60
+ // 89 literals (trimmed) + 1000 padded numbers + 1000 password{N} + 1000 qwerty{N}
61
+ // = 3089 base; some may overlap (e.g. "123456" appears in both literals and 6-digit family if range matched)
62
+ expect(WEAK_PASSWORDS.size).toBeGreaterThan(3000);
63
+ });
64
+
65
+ it("contains '000123' (padding implementation correct)", () => {
66
+ expect(WEAK_PASSWORDS.has("000123")).toBe(true);
67
+ });
68
+ });
package/src/rename.ts ADDED
@@ -0,0 +1,131 @@
1
+ // ── #84: node rename — Server surface (RFC-010 §4) ──
2
+ //
3
+ // The server side of the rename 2PC. PHASE 1 (prepare) reserves the new alias
4
+ // in the isolated rename_txn table without touching the sessions registry, so
5
+ // it is fully rollback-safe (abort just marks the row aborted). PHASE 2
6
+ // (commit) atomically switches sessions.alias + nodes.alias old→new and is the
7
+ // non-rollbackable point — past commit, recovery is forward-fix only.
8
+ //
9
+ // Step 1 spec resolution (通信龙-confirmed, #84): the rename_txn row IS the
10
+ // audit log (RFC §4 risk #5 alias_rename_log = `WHERE status='committed'`),
11
+ // and rename touches api_tokens only cosmetically — the token binds
12
+ // user_id+network_id, never the alias, so there is no per-alias binding to
13
+ // migrate. This corrects RFC-010 §4 P3's draft "copy utok/ntok binding" line.
14
+
15
+ import { randomUUID } from "crypto";
16
+ import { db } from "./db";
17
+ import { getUserNetworkRole } from "./auth";
18
+ import { pushEvent } from "./push";
19
+
20
+ export interface RenameResult {
21
+ ok: boolean;
22
+ txn_id?: string;
23
+ error?: string;
24
+ }
25
+
26
+ function hasWriteAccess(userId: string, networkId: string): boolean {
27
+ const role = getUserNetworkRole(userId, networkId);
28
+ return !!role && role !== "viewer";
29
+ }
30
+
31
+ // PHASE 1 P3 — create a prepared rename_txn row reserving new_alias.
32
+ // CAS guard (RFC §4 risk #3 TOCTOU): new_alias must be free both in the
33
+ // sessions registry AND among in-flight prepared rename_txn rows.
34
+ export function prepareRename(
35
+ userId: string, networkId: string, oldAlias: string, newAlias: string,
36
+ ): RenameResult {
37
+ if (!hasWriteAccess(userId, networkId)) return { ok: false, error: "no write access to this network" };
38
+ if (!oldAlias || !newAlias) return { ok: false, error: "old and new alias required" };
39
+ if (oldAlias === newAlias) return { ok: false, error: "old and new alias are identical" };
40
+
41
+ // old-alias must exist as a session in this network
42
+ const oldSession = db.get<any>(
43
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, oldAlias);
44
+ if (!oldSession) return { ok: false, error: `node "${oldAlias}" not found in this network` };
45
+
46
+ // new-alias must not be taken — in sessions OR reserved by another prepared txn
47
+ const newInSessions = db.get<any>(
48
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, newAlias);
49
+ if (newInSessions) return { ok: false, error: `alias "${newAlias}" already in use` };
50
+ const newPrepared = db.get<any>(
51
+ "SELECT txn_id FROM rename_txn WHERE network_id = ?1 AND new_alias = ?2 AND status = 'prepared'",
52
+ networkId, newAlias);
53
+ if (newPrepared) return { ok: false, error: `alias "${newAlias}" already reserved by an in-flight rename` };
54
+
55
+ const txnId = `rtxn_${randomUUID().replace(/-/g, "")}`;
56
+ db.run(
57
+ "INSERT INTO rename_txn (txn_id, network_id, old_alias, new_alias, status) VALUES (?1, ?2, ?3, ?4, 'prepared')",
58
+ [txnId, networkId, oldAlias, newAlias]);
59
+ return { ok: true, txn_id: txnId };
60
+ }
61
+
62
+ // PHASE 2 C1 — atomically switch sessions.alias + nodes.alias old→new, mark
63
+ // the txn committed. Cosmetic: api_tokens.name label node:<old>→node:<new>
64
+ // (idempotent, NOT rollback-critical — the token binds user_id+network_id).
65
+ // inbox/tasks/completions keep the old alias (RFC §4 risk #5 — history is
66
+ // immutable; queries join rename_txn to show "old → now new").
67
+ export function commitRename(userId: string, txnId: string): RenameResult {
68
+ const txn = db.get<any>("SELECT * FROM rename_txn WHERE txn_id = ?1", txnId);
69
+ if (!txn) return { ok: false, error: "rename transaction not found" };
70
+ if (txn.status === "committed") return { ok: true, txn_id: txnId }; // idempotent
71
+ if (txn.status === "aborted") return { ok: false, error: "rename transaction was aborted" };
72
+ if (!hasWriteAccess(userId, txn.network_id)) return { ok: false, error: "no write access to this network" };
73
+
74
+ // Defense-in-depth: new-alias still free in sessions (prepare already CAS'd,
75
+ // but a parallel direct registration could have raced in).
76
+ const conflict = db.get<any>(
77
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
78
+ if (conflict) return { ok: false, error: `alias "${txn.new_alias}" was taken since prepare` };
79
+
80
+ db.run(
81
+ "UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
82
+ [txn.new_alias, txn.network_id, txn.old_alias]);
83
+ db.run(
84
+ "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
85
+ [txn.new_alias, txn.network_id, txn.old_alias]);
86
+ db.run(
87
+ "UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
88
+ [`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
89
+ db.run(
90
+ "UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
91
+ [txnId]);
92
+
93
+ // RFC-010 §4.2.1 C4 — broadcast node.renamed SSE. (#84 实施补漏: the Server
94
+ // surface originally missed C4; N站马's dashboard slice needs this event.)
95
+ // Envelope per RFC §3.4: alias = NEW (the post-event truth); data carries
96
+ // old/new + surfaces + history_policy. `type` is also set for consumers that
97
+ // switch on .type (the existing SSE convention). Pushed to both the old- and
98
+ // new-alias streams since the SSE layer is per-session-name (no network-wide
99
+ // broadcast primitive) — whoever watched either name gets the rename.
100
+ const renamedEvent: Record<string, unknown> = {
101
+ type: "node.renamed",
102
+ event: "node.renamed",
103
+ txn_id: txnId,
104
+ alias: txn.new_alias,
105
+ network_id: txn.network_id,
106
+ data: {
107
+ old_alias: txn.old_alias,
108
+ new_alias: txn.new_alias,
109
+ surfaces_updated: ["config", "tmux", "commhub", "dashboard", "batch_prefix", "session_resume"],
110
+ history_policy: "preserve",
111
+ },
112
+ };
113
+ pushEvent(txn.old_alias, renamedEvent, txn.network_id);
114
+ pushEvent(txn.new_alias, renamedEvent, txn.network_id);
115
+
116
+ return { ok: true, txn_id: txnId };
117
+ }
118
+
119
+ // PHASE 1 rollback — mark a prepared rename_txn aborted, freeing new_alias.
120
+ // Idempotent; refuses to abort an already-committed txn.
121
+ export function abortRename(userId: string, txnId: string): RenameResult {
122
+ const txn = db.get<any>("SELECT * FROM rename_txn WHERE txn_id = ?1", txnId);
123
+ if (!txn) return { ok: false, error: "rename transaction not found" };
124
+ if (txn.status === "committed") return { ok: false, error: "rename already committed, cannot abort" };
125
+ if (txn.status === "aborted") return { ok: true, txn_id: txnId }; // idempotent
126
+ if (!hasWriteAccess(userId, txn.network_id)) return { ok: false, error: "no write access to this network" };
127
+ db.run(
128
+ "UPDATE rename_txn SET status = 'aborted', aborted_at = datetime('now') WHERE txn_id = ?1",
129
+ [txnId]);
130
+ return { ok: true, txn_id: txnId };
131
+ }
package/src/tools.ts CHANGED
@@ -126,8 +126,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
126
126
  // Only delete same-alias sessions within the same network
127
127
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, sessionNetId]);
128
128
  db.run(
129
- `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, last_seen_at, updated_at)
130
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
129
+ `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, last_seen_at, updated_at)
130
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, datetime('now'), datetime('now'))
131
131
  ON CONFLICT(resume_id) DO UPDATE SET
132
132
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
133
133
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -138,8 +138,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
138
138
  score = COALESCE(?14, sessions.score), node_id = COALESCE(?15, sessions.node_id),
139
139
  session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
140
140
  channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
141
+ model = COALESCE(?20, sessions.model),
141
142
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
142
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId]
143
+ [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null]
143
144
  );
144
145
  });
145
146