@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 +17 -15
- package/package.json +2 -2
- package/src/auth-tokens.test.ts +132 -0
- package/src/auth-validate.test.ts +145 -0
- package/src/db.ts +25 -1
- package/src/index.ts +79 -5
- package/src/password-dict.test.ts +68 -0
- package/src/rename.ts +131 -0
- package/src/tools.ts +4 -3
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.
|
|
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 /
|
|
22
|
-
|
|
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.
|
|
39
|
-
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 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 (
|
|
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
|
|
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` |
|
|
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 (
|
|
124
|
+
## PostgreSQL (community extension point — not on the maintained roadmap)
|
|
125
125
|
|
|
126
|
-
|
|
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*` —
|
|
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
|
-
|
|
159
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|