@sleep2agi/commhub-server 0.9.0-preview.51 → 0.9.0-preview.53

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.51",
3
+ "version": "0.9.0-preview.53",
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",
@@ -35,6 +35,15 @@ describe("validateRuntime / validateModel (§4.2.2)", () => {
35
35
  expect(() => validateModel("gpt-4o")).not.toThrow();
36
36
  expect(() => validateModel("bad model")).toThrow(ValidationError);
37
37
  expect(() => validateModel("x;rm")).toThrow(ValidationError);
38
+ // provider/model(OpenCode 共存;桌面向导 0.2.61 起发这种形状,Vincent 2026-09-08 撞到 model_invalid)
39
+ expect(() => validateModel("opencode/mimo-v2.5-free")).not.toThrow();
40
+ expect(() => validateModel("anthropic/claude-sonnet-4")).not.toThrow();
41
+ expect(() => validateModel("a/b/c")).toThrow(ValidationError); // 只许一个斜杠
42
+ expect(() => validateModel("/model")).toThrow(ValidationError); // 不能以斜杠开头
43
+ expect(() => validateModel("provider/")).toThrow(ValidationError); // 不能以斜杠结尾
44
+ expect(() => validateModel("../x")).toThrow(ValidationError); // 纯点段(路径穿越形状)
45
+ expect(() => validateModel("x/.")).toThrow(ValidationError);
46
+ expect(() => validateModel("open code/m")).toThrow(ValidationError);
38
47
  expect(() => validateModel("")).toThrow(ValidationError);
39
48
  });
40
49
  });
@@ -58,7 +58,11 @@ const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
58
58
 
59
59
  // §4.2.2 name + model.
60
60
  const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
61
- const MODEL_RE = /^[a-zA-Z0-9._:\-]+$/;
61
+ // OpenCode 共存的模型带 provider 前缀(`opencode/mimo-v2.5-free`,agent-node parseModelRef 按第一个 `/` 切),
62
+ // 桌面向导 0.2.61 起就发这种形状 —— 允许**恰好一个**斜杠分隔的两段;每段仍是原字符集,且不能是纯点(`..`/`.`)。
63
+ const MODEL_SEGMENT = "[a-zA-Z0-9._:\\-]+";
64
+ const MODEL_RE = new RegExp(`^${MODEL_SEGMENT}(?:/${MODEL_SEGMENT})?$`);
65
+ const MODEL_DOT_ONLY_SEGMENT = /(^|\/)\.+(\/|$)/;
62
66
 
63
67
  export function validateName(s: unknown): asserts s is string {
64
68
  if (typeof s !== "string" || !NAME_RE.test(s)) {
@@ -90,7 +94,7 @@ export function validateRuntime(s: unknown): asserts s is Runtime {
90
94
 
91
95
  export function validateModel(s: unknown): asserts s is string | undefined | null {
92
96
  if (s === undefined || s === null) return;
93
- if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
97
+ if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s) || MODEL_DOT_ONLY_SEGMENT.test(s)) {
94
98
  throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
95
99
  }
96
100
  }
@@ -0,0 +1,92 @@
1
+ // 桌面向导要看得到 daemon 回的失败原因:GET /api/node-create-requests?request_id=…
2
+ // 承重:① 只在调用者的网络作用域里能读到(别的网络 → 404,与不存在同形);② 无用户上下文 401;③ 缺参 400;
3
+ // ④ 回的是 daemon ack 写进去的 status/error 原文。
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-create-request-status-"));
10
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
11
+
12
+ let server: ReturnType<typeof Bun.serve>;
13
+ let base = "";
14
+ let aToken = "", bToken = "", aNet = "", bNet = "";
15
+ const MASTER_TOKEN = "legacy-master-token-for-test";
16
+
17
+ const get = async (path: string, token: string) => {
18
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
19
+ return { status: r.status, body: await r.json() as any };
20
+ };
21
+
22
+ beforeAll(async () => {
23
+ process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
24
+ const { db } = await import("./db.js");
25
+ const { register } = await import("./auth.js");
26
+ const stamp = Date.now();
27
+ const admin = register(`cr_admin_${stamp}`, "CrAdmin-Strong-1!");
28
+ expect(admin.ok).toBe(true);
29
+ const a = register(`cr_a_${stamp}`, "CrA-Strong-1!");
30
+ const b = register(`cr_b_${stamp}`, "CrB-Strong-1!");
31
+ expect(a.ok && b.ok).toBe(true);
32
+ aToken = a.token!; bToken = b.token!; aNet = a.network_id!; bNet = b.network_id!;
33
+ const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
34
+ for (const id of [uid(`cr_a_${stamp}`), uid(`cr_b_${stamp}`)]) {
35
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
36
+ }
37
+ db.run(
38
+ `INSERT INTO node_create_requests
39
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, acked_at, error)
40
+ VALUES ('cr_a_failed', 'node_daemon_a', 'vansin-go', ?1, 'opencode-cli', NULL, '{}', '[]', 'failed', NULL, ?2, 'tok', ?2,
41
+ 'opencode-cli package entrypoint verification currently requires Linux')`,
42
+ [aNet, Date.now()],
43
+ );
44
+ db.run(
45
+ `INSERT INTO node_create_requests
46
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
47
+ VALUES ('cr_b_pending', 'node_daemon_b', 'other', ?1, 'codex-app-server', NULL, '{}', '[]', 'pending', NULL, ?2, 'tok')`,
48
+ [bNet, Date.now()],
49
+ );
50
+ const mod = await import("./server.js");
51
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
52
+ base = `http://127.0.0.1:${server.port}`;
53
+ });
54
+
55
+ afterAll(() => {
56
+ try { server?.stop(true); } catch {}
57
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
58
+ });
59
+
60
+ describe("GET /api/node-create-requests?request_id=", () => {
61
+ test("A reads her own failed request with the daemon's error verbatim", async () => {
62
+ const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", aToken);
63
+ expect(status).toBe(200);
64
+ expect(body.ok).toBe(true);
65
+ expect(body.request).toMatchObject({
66
+ request_id: "cr_a_failed", child_name: "vansin-go", runtime: "opencode-cli", status: "failed",
67
+ error: "opencode-cli package entrypoint verification currently requires Linux",
68
+ });
69
+ });
70
+ test("🔴 B cannot read A's request — 404, same shape as nonexistent", async () => {
71
+ const other = await get("/api/node-create-requests?request_id=cr_a_failed", bToken);
72
+ const missing = await get("/api/node-create-requests?request_id=cr_nope", bToken);
73
+ expect(other.status).toBe(404);
74
+ expect(missing.status).toBe(404);
75
+ expect(other.body).toEqual(missing.body);
76
+ });
77
+ test("pending request reads as pending with no error", async () => {
78
+ const { body } = await get("/api/node-create-requests?request_id=cr_b_pending", bToken);
79
+ expect(body.request.status).toBe("pending");
80
+ expect(body.request.error).toBeNull();
81
+ });
82
+ test("missing request_id → 400", async () => {
83
+ const { status, body } = await get("/api/node-create-requests", aToken);
84
+ expect(status).toBe(400);
85
+ expect(body.error).toBe("request_id_required");
86
+ });
87
+ test("legacy master token (no user context) → 401", async () => {
88
+ const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", MASTER_TOKEN);
89
+ expect(status).toBe(401);
90
+ expect(body.error).toBe("auth_required");
91
+ });
92
+ });
package/src/server.ts CHANGED
@@ -3120,6 +3120,31 @@ return Bun.serve({
3120
3120
  // Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
3121
3121
  // Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
3122
3122
  // auth pipeline (restScope). Returns {ok, daemons:[...], count}.
3123
+ // ── REST: one node_create_request's outcome (app#… 桌面向导) ──
3124
+ // 向导 POST create_node 后只会轮询「子节点注册了没」,daemon 回的失败原因(ack_create_request 写进
3125
+ // node_create_requests.status/error)此前没有任何读接口 —— 用户只看到「24s 没注册」。
3126
+ // 这里按 request_id 读一行;网络作用域与其它 REST 相同(addNetworkScope),无用户上下文 401。
3127
+ if (url.pathname === "/api/node-create-requests" && req.method === "GET") {
3128
+ if (!restAuth) {
3129
+ return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
3130
+ }
3131
+ const requestId = url.searchParams.get("request_id")?.trim() ?? "";
3132
+ if (!requestId) {
3133
+ return withCors(req, Response.json({ ok: false, error: "request_id_required" }, { status: 400 }));
3134
+ }
3135
+ const params: any[] = [requestId];
3136
+ let sql = `SELECT request_id, daemon_node_id, child_name, network_id, runtime, model, status, error,
3137
+ created_at, delivered_at, acked_at
3138
+ FROM node_create_requests WHERE request_id = ?1`;
3139
+ sql = addNetworkScope(sql, params, restScope);
3140
+ const row = db.get<Record<string, unknown>>(sql, ...params);
3141
+ if (!row) {
3142
+ // 不存在与不在你作用域内长一样:不泄漏别的网络里有没有这个 id。
3143
+ return withCors(req, Response.json({ ok: false, error: "request_not_found" }, { status: 404 }));
3144
+ }
3145
+ return withCors(req, Response.json({ ok: true, request: row }));
3146
+ }
3147
+
3123
3148
  if (url.pathname === "/api/host-supervisors") {
3124
3149
  // #380 — utok callers with exactly one accessible network get a
3125
3150
  // safe fallback so the create-node wizard doesn't have to bake