@sleep2agi/commhub-server 0.9.0-preview.106 → 0.9.0-preview.108

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.106",
3
+ "version": "0.9.0-preview.108",
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",
@@ -0,0 +1,182 @@
1
+ // #622 —— daemon 逐 runtime 自检(runtime_readiness)从 report_status 一路走到
2
+ // /api/host-supervisors。三件事:
3
+ // 1. report_status 的 zod 形状收得宽:坏的 runtime_readiness 只丢它自己,
4
+ // 绝不拒掉整份 report(否则一台 daemon 发了个奇怪的诊断字段就在 hub 上失联);
5
+ // 2. 新字段会被**保留**(daemon_capabilities 不是 strict,schema 里没声明的键会被
6
+ // 静默剥掉 —— 不加 schema 就永远到不了 REST);
7
+ // 3. REST 只在 daemon 报了时输出,旧 daemon 的行里**没有**这个键;
8
+ // can_create_nodes 语义不变。
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import "./require-explicit-test-db.js";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { register, login } from "./auth.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
17
+
18
+ function captureToolSchemas(): Record<string, any> {
19
+ const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
20
+ const schemas: Record<string, any> = {};
21
+ const origTool = mcp.tool.bind(mcp);
22
+ mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
23
+ schemas[name] = schema;
24
+ return origTool(name, desc, schema, handler);
25
+ };
26
+ registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
27
+ return schemas;
28
+ }
29
+ const schemas = captureToolSchemas();
30
+ const snapSchema = () => schemas.report_status.config_snapshot;
31
+ const base = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
32
+
33
+ const READY = {
34
+ ok: true, state: "ready", reason: "可以创建", version: "2.1.290",
35
+ checked_at: "2026-10-06T03:00:00.000Z", cli: "found", auth: "present", network: "reachable",
36
+ };
37
+ const MISSING = {
38
+ ok: false, state: "missing_cli", reason: "新节点的 PATH 上找不到可用的 grok 命令", checked_at: "2026-10-06T03:00:00.000Z",
39
+ cli: "missing", auth: "present", network: "skipped",
40
+ };
41
+
42
+ describe("#622 report_status schema —— runtime_readiness", () => {
43
+ test("合法值透传(没被 daemon_capabilities 的非 strict 剥掉)", () => {
44
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: {
45
+ can_create_nodes: true,
46
+ runtime_readiness: { "claude-code-cli": READY, "grok-build-acp": MISSING, "codex-sdk": { ...READY, shared_login_count: 27 } },
47
+ } });
48
+ expect(parsed.daemon_capabilities.runtime_readiness["claude-code-cli"].state).toBe("ready");
49
+ expect(parsed.daemon_capabilities.runtime_readiness["grok-build-acp"].state).toBe("missing_cli");
50
+ expect(parsed.daemon_capabilities.runtime_readiness["codex-sdk"].shared_login_count).toBe(27);
51
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
52
+ });
53
+
54
+ test("🔴 坏形状绝不拒整份 report —— 只丢这一格", () => {
55
+ for (const bad of [
56
+ "string", 42, null, [], { x: "notobj" },
57
+ Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`rt${i}`, READY])),
58
+ ]) {
59
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: { can_create_nodes: true, runtime_readiness: bad } });
60
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
61
+ expect(parsed.daemon_capabilities.runtime_readiness).toBeUndefined();
62
+ }
63
+ });
64
+
65
+ test("单个字段坏:schema 不拒整份;读取侧消毒把它兜成 unknown", () => {
66
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: {
67
+ can_create_nodes: true,
68
+ runtime_readiness: { a: { ok: "yes", state: 7, reason: "x".repeat(5000), shared_login_count: -3 } },
69
+ } });
70
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
71
+ const a = sanitizeRuntimeReadiness(parsed.daemon_capabilities.runtime_readiness)!.a;
72
+ expect(a.ok).toBe(false);
73
+ expect(a.state).toBe("unknown");
74
+ expect(a.reason.length).toBe(600);
75
+ expect(a.shared_login_count).toBeUndefined();
76
+ });
77
+
78
+ test("总字节超 16 KB:整格丢掉(异常 daemon 不能往库里塞大对象)", () => {
79
+ const big = { a: { ...READY, reason: "x".repeat(20_000) } };
80
+ const parsed = snapSchema().parse({ ...base, daemon_capabilities: { can_create_nodes: true, runtime_readiness: big } });
81
+ expect(parsed.daemon_capabilities.runtime_readiness).toBeUndefined();
82
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("#622 sanitizeRuntimeReadiness(读取侧)", () => {
87
+ test("不认识的 state → unknown;ok 只有 ready 时才可能为真", () => {
88
+ const s = sanitizeRuntimeReadiness({
89
+ a: { ok: true, state: "something_new", reason: "r" },
90
+ b: { ok: true, state: "no_network", reason: "r" },
91
+ })!;
92
+ expect(s.a.state).toBe("unknown");
93
+ expect(s.a.ok).toBe(false);
94
+ expect(s.b.ok).toBe(false);
95
+ });
96
+ test("没报 / 空 → undefined(调用方据此不输出键)", () => {
97
+ expect(sanitizeRuntimeReadiness(undefined)).toBeUndefined();
98
+ expect(sanitizeRuntimeReadiness({})).toBeUndefined();
99
+ expect(sanitizeRuntimeReadiness([])).toBeUndefined();
100
+ });
101
+ test("未知枚举值的 cli/auth/network 被丢掉", () => {
102
+ const s = sanitizeRuntimeReadiness({ a: { ...READY, cli: "/home/x/bin/claude", network: "maybe" } })!;
103
+ expect(s.a.cli).toBeUndefined();
104
+ expect(s.a.network).toBeUndefined();
105
+ expect(s.a.auth).toBe("present");
106
+ });
107
+ });
108
+
109
+ let BASE = "";
110
+ let server: any = null;
111
+ let userToken = "";
112
+ let userNetworkId = "";
113
+ let userId = "";
114
+
115
+ beforeAll(async () => {
116
+ process.env.HOST = "127.0.0.1";
117
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
118
+ const password = "BootstrapPw123Aa!";
119
+ const seed = register(`rr_seed_${suffix}`, password, undefined, "seed");
120
+ if (!seed.ok) throw new Error("seed failed");
121
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`rr_seed_${suffix}`]);
122
+ const u = register(`rr_u_${suffix}`, password, undefined, "seed");
123
+ if (!u.ok || !u.token) throw new Error("user register failed");
124
+ userToken = u.token;
125
+ userNetworkId = u.network_id ?? "";
126
+ userId = login(`rr_u_${suffix}`, password).user!.user_id;
127
+ const { bootServer } = await import("./server.js");
128
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
129
+ BASE = `http://127.0.0.1:${server.port}`;
130
+ await new Promise((r) => setTimeout(r, 100));
131
+ });
132
+
133
+ afterAll(() => {
134
+ try { server?.stop?.(true); } catch {}
135
+ });
136
+
137
+ function seedDaemon(alias: string, snapshot: object): void {
138
+ db.run(
139
+ `INSERT OR REPLACE INTO nodes (
140
+ node_id, node_name, alias, runtime, model, config_path,
141
+ channels, server, hostname, network_id, config_revision, config_snapshot
142
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
143
+ [`rr_n_${alias}`, alias, alias, "claude-agent-sdk", null, "/tmp/cfg.json", "[]", "test-host", "test-host",
144
+ userNetworkId, 0, JSON.stringify(snapshot)],
145
+ );
146
+ db.run(
147
+ `INSERT OR REPLACE INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
148
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
149
+ [`rr_t_${alias}`, `hash_rr_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
150
+ );
151
+ }
152
+
153
+ async function listDaemons(): Promise<any[]> {
154
+ const res = await fetch(`${BASE}/api/host-supervisors`, { headers: { Authorization: `Bearer ${userToken}` } });
155
+ const body = await res.json();
156
+ if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
157
+ return body.daemons;
158
+ }
159
+
160
+ describe("#622 /api/host-supervisors 输出 runtime_readiness", () => {
161
+ test("新 daemon:逐 runtime 透传;can_create_nodes 不受影响", async () => {
162
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
163
+ seedDaemon("rr-new", { role: "host_supervisor", daemon_capabilities: {
164
+ runtimes_supported: ["claude-code-cli", "grok-build-acp"],
165
+ can_create_nodes: true,
166
+ runtime_readiness: { "claude-code-cli": READY, "grok-build-acp": MISSING },
167
+ } });
168
+ const d = (await listDaemons()).find((x) => x.alias === "rr-new");
169
+ expect(d.can_create_nodes).toBe(true);
170
+ expect(d.runtime_readiness["claude-code-cli"]).toEqual(READY);
171
+ expect(d.runtime_readiness["grok-build-acp"].state).toBe("missing_cli");
172
+ expect(d.runtime_readiness["grok-build-acp"].ok).toBe(false);
173
+ });
174
+
175
+ test("🔴 旧 daemon(不报):响应里没有这个键 —— 不是 {}、不是 null", async () => {
176
+ db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
177
+ seedDaemon("rr-old", { role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"], can_create_nodes: true } });
178
+ const d = (await listDaemons()).find((x) => x.alias === "rr-old");
179
+ expect(d.can_create_nodes).toBe(true);
180
+ expect("runtime_readiness" in d).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,176 @@
1
+ // #649: a successful login with a weak password flags the account.
2
+ //
3
+ // Accounts created before #261 (2026-06-28) added `must_change_password` sit at 0 even when they
4
+ // still use the bootstrap default. Login is the one place the plaintext is in hand, so a login that
5
+ // succeeds with a password failing validatePasswordStrength (too short / in WEAK_PASSWORDS) sets the
6
+ // flag and returns `must_change_password: true`. Changing the password clears it.
7
+ //
8
+ // Every case goes through the real HTTP route (bootServer + fetch POST /api/auth/login), not the
9
+ // module function, so the response shape is the one clients see. The aggregate runner gives this
10
+ // file its own COMMHUB_DB / HOME / TMPDIR; the hub listens on port 0.
11
+ // Mutation: drop the new block in login() and the weak cases go red (flag stays 0, field absent).
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { createHash } from "crypto";
18
+ import { register } from "./auth.js";
19
+ import { db, hashPassword } from "./db.js";
20
+ import { WEAK_PASSWORDS } from "./password-dict.js";
21
+
22
+ const DIR = mkdtempSync(join(tmpdir(), "anet-weakpw-"));
23
+ let BASE = "";
24
+ let hub: any = null;
25
+ const STRONG = "Str0ng-Fixture-Passw0rd!";
26
+ // Fixture plaintexts only (not anyone's real password).
27
+ const TOO_SHORT = "abc1234"; // 7 chars: fails the length rule (same shape as an old bootstrap default)
28
+ const IN_LIST = "password1"; // ≥ 8 chars but in WEAK_PASSWORDS
29
+
30
+ async function post(path: string, body: unknown, token = "") {
31
+ const res = await fetch(`${BASE}${path}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
34
+ body: JSON.stringify(body),
35
+ });
36
+ return { status: res.status, body: await res.json().catch(() => null) as any };
37
+ }
38
+ const login = (username: string, password: string) => post("/api/auth/login", { username, password });
39
+ const flag = (userId: string) => db.get<any>("SELECT must_change_password AS f FROM users WHERE user_id = ?1", userId)?.f;
40
+
41
+ let seq = 0;
42
+ /** A user created the normal way (strong password), then rewound to an "old account" state:
43
+ * its stored hash replaced by `password` and the flag forced to 0. */
44
+ function oldAccount(password: string, opts: { legacySha256?: boolean } = {}) {
45
+ const username = `weakpw_${Date.now()}_${seq++}`;
46
+ const reg = register(username, STRONG, undefined, undefined, { issueTokens: false });
47
+ expect(reg.ok).toBe(true);
48
+ const userId = reg.user!.user_id;
49
+ const hash = opts.legacySha256 ? createHash("sha256").update(`anet:${password}`).digest("hex") : hashPassword(password); // legacy = pre-A1 `anet:`-prefixed sha256 (auth-kdf-migration.test.ts)
50
+ db.run("UPDATE users SET password_hash = ?1, must_change_password = 0 WHERE user_id = ?2", [hash, userId]);
51
+ expect(flag(userId)).toBe(0);
52
+ return { username, userId };
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
57
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
58
+ process.env.HOST = "127.0.0.1";
59
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
60
+ const mod: any = await import("./server.js");
61
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
62
+ BASE = `http://127.0.0.1:${hub.port}`;
63
+ }, 30_000);
64
+
65
+ afterAll(() => {
66
+ try { hub?.stop?.(true); } catch {}
67
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
68
+ });
69
+
70
+ describe("fixtures are what they claim", () => {
71
+ test("TOO_SHORT is < 8 chars and IN_LIST is >= 8 chars and in WEAK_PASSWORDS; STRONG is neither", () => {
72
+ expect(TOO_SHORT.length).toBeLessThan(8);
73
+ expect(IN_LIST.length).toBeGreaterThanOrEqual(8);
74
+ expect(WEAK_PASSWORDS.has(IN_LIST)).toBe(true);
75
+ expect(STRONG.length).toBeGreaterThanOrEqual(8);
76
+ expect(WEAK_PASSWORDS.has(STRONG.toLowerCase())).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("successful login with a weak password sets must_change_password", () => {
81
+ test("too-short password, flag forced to 0 → login → flag 1 and response says must_change_password: true", async () => {
82
+ const u = oldAccount(TOO_SHORT);
83
+ const r = await login(u.username, TOO_SHORT);
84
+ expect(r.status).toBe(200);
85
+ expect(r.body.ok).toBe(true);
86
+ expect(r.body.must_change_password).toBe(true);
87
+ expect(flag(u.userId)).toBe(1);
88
+ });
89
+
90
+ test("password in WEAK_PASSWORDS (length OK) → flagged", async () => {
91
+ const u = oldAccount(IN_LIST);
92
+ const r = await login(u.username, IN_LIST);
93
+ expect(r.status).toBe(200);
94
+ expect(r.body.must_change_password).toBe(true);
95
+ expect(flag(u.userId)).toBe(1);
96
+ });
97
+
98
+ test("weak-list match is case-insensitive like validatePasswordStrength", async () => {
99
+ const upper = IN_LIST.toUpperCase();
100
+ const u = oldAccount(upper);
101
+ const r = await login(u.username, upper);
102
+ expect(r.status).toBe(200);
103
+ expect(r.body.must_change_password).toBe(true);
104
+ expect(flag(u.userId)).toBe(1);
105
+ });
106
+
107
+ test("legacy sha256 hash + weak password → login succeeds, hash upgraded, flag set", async () => {
108
+ const u = oldAccount(TOO_SHORT, { legacySha256: true });
109
+ const r = await login(u.username, TOO_SHORT);
110
+ expect(r.status).toBe(200);
111
+ expect(r.body.must_change_password).toBe(true);
112
+ expect(flag(u.userId)).toBe(1);
113
+ const stored = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", u.userId).password_hash;
114
+ expect(stored.startsWith("scrypt$")).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe("no flag where it does not belong", () => {
119
+ test("strong password → flag stays 0 and the field is absent", async () => {
120
+ const u = oldAccount(STRONG);
121
+ const r = await login(u.username, STRONG);
122
+ expect(r.status).toBe(200);
123
+ expect(r.body.ok).toBe(true);
124
+ expect("must_change_password" in r.body).toBe(false);
125
+ expect(flag(u.userId)).toBe(0);
126
+ });
127
+
128
+ test("failed login against a weak-password account does not touch the flag", async () => {
129
+ const u = oldAccount(TOO_SHORT);
130
+ const r = await login(u.username, "wrong-password-xyz");
131
+ expect(r.status).toBe(401);
132
+ expect(r.body.must_change_password).toBeUndefined();
133
+ expect(flag(u.userId)).toBe(0);
134
+ });
135
+
136
+ test("an already-flagged account with a strong password still reports the flag (#261 behaviour kept)", async () => {
137
+ const u = oldAccount(STRONG);
138
+ db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [u.userId]);
139
+ const r = await login(u.username, STRONG);
140
+ expect(r.body.must_change_password).toBe(true);
141
+ expect(flag(u.userId)).toBe(1);
142
+ });
143
+ });
144
+
145
+ describe("changing the password clears the flag", () => {
146
+ test("weak login flags → POST /api/auth/password to a strong one → flag 0 → next login has no field", async () => {
147
+ const u = oldAccount(TOO_SHORT);
148
+ const first = await login(u.username, TOO_SHORT);
149
+ expect(first.body.must_change_password).toBe(true);
150
+ const changed = await post("/api/auth/password", { old_password: TOO_SHORT, new_password: STRONG }, first.body.token);
151
+ expect(changed.status).toBe(200);
152
+ expect(changed.body.ok).toBe(true);
153
+ expect(flag(u.userId)).toBe(0);
154
+ const again = await login(u.username, STRONG);
155
+ expect(again.status).toBe(200);
156
+ expect("must_change_password" in again.body).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("the plaintext is never logged", () => {
161
+ test("console output during a weak-password login does not contain the password", async () => {
162
+ const u = oldAccount(IN_LIST);
163
+ const seen: string[] = [];
164
+ const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
165
+ for (const k of Object.keys(orig) as (keyof typeof orig)[]) {
166
+ (console as any)[k] = (...a: unknown[]) => { seen.push(a.map(String).join(" ")); };
167
+ }
168
+ try {
169
+ const r = await login(u.username, IN_LIST);
170
+ expect(r.body.must_change_password).toBe(true);
171
+ } finally {
172
+ Object.assign(console, orig);
173
+ }
174
+ expect(seen.some(l => l.includes(IN_LIST))).toBe(false);
175
+ });
176
+ });
package/src/auth.ts CHANGED
@@ -196,6 +196,20 @@ export function login(username: string, password: string, client: SessionClientI
196
196
  }
197
197
  }
198
198
 
199
+ // #649: a successful login whose plaintext fails the same strength rule as
200
+ // register/changePassword (too short, or in WEAK_PASSWORDS) flags the account.
201
+ // Catches accounts created before #261 added must_change_password (they sit at
202
+ // 0 with the bootstrap default). changePassword clears it. Never log the password.
203
+ let mustChangePassword = user.must_change_password === 1;
204
+ if (!mustChangePassword && validatePasswordStrength(password) !== null) {
205
+ mustChangePassword = true;
206
+ try {
207
+ db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [user.user_id]);
208
+ } catch (e: any) {
209
+ console.log(`[commhub auth] weak-password flag write failed for user ${user.user_id}: ${e?.message ?? e}`);
210
+ }
211
+ }
212
+
199
213
  // Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
200
214
  // login (cli, dashboard, second machine) gets its own row so they don't
201
215
  // kick each other out of session. Tokens can be revoked via /api/auth/tokens.
@@ -223,7 +237,7 @@ export function login(username: string, password: string, client: SessionClientI
223
237
  network_id: networkId,
224
238
  // #261 P0-2 — only include field when truthy (back-compat; old clients
225
239
  // don't see this field at all unless their account is flagged).
226
- ...(user.must_change_password === 1 ? { must_change_password: true } : {}),
240
+ ...(mustChangePassword ? { must_change_password: true } : {}),
227
241
  };
228
242
  }
229
243
 
@@ -249,6 +249,15 @@ describe("validatePatch — per-flag enum / range", () => {
249
249
  expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
250
250
  expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
251
251
  });
252
+ test("timeout: 0 = no limit; 1..999 is seconds-shaped and rejected (#605)", () => {
253
+ expect(validatePatch(undefined, { timeout: 0 })).toBeNull();
254
+ expect(validatePatch(undefined, { timeout: 1000 })).toBeNull();
255
+ for (const v of [1, 600, 999]) {
256
+ const r = validatePatch(undefined, { timeout: v });
257
+ expect(r?.field).toBe("flags.timeout");
258
+ expect(r?.reason).toContain("milliseconds");
259
+ }
260
+ });
252
261
  });
253
262
 
254
263
  describe("validatePatch — combinations", () => {
@@ -5,6 +5,8 @@
5
5
  // tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
6
6
  // caller identity, role gates).
7
7
 
8
+ import { isValidTimeoutMs, TIMEOUT_MS_REASON } from "./create-node-validate.js";
9
+
8
10
  /**
9
11
  * Fields the dashboard may change. Anything not in this list is rejected
10
12
  * by hub-side validation regardless of role — the UI cannot smuggle
@@ -239,8 +241,10 @@ export function validatePatch(
239
241
  }
240
242
  break;
241
243
  case "timeout":
242
- if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 3_600_000) {
243
- return { field: "flags.timeout", reason: "must be an integer ms in [0, 3_600_000]" };
244
+ // #605 —— 与 create_node 同一把尺子(create-node-validate.ts isValidTimeoutMs):
245
+ // 毫秒,0 或 1000..3_600_000;1..999 是「按秒填了」的形状,拒绝而不是给一个亚秒超时。
246
+ if (!isValidTimeoutMs(val)) {
247
+ return { field: "flags.timeout", reason: TIMEOUT_MS_REASON };
244
248
  }
245
249
  break;
246
250
  }
@@ -69,6 +69,30 @@ describe("validateFlagValue (§4.2.2)", () => {
69
69
  expect(() => validateFlagValue("permissionMode", "plan")).not.toThrow();
70
70
  expect(() => validateFlagValue("permissionMode", "anything-else")).toThrow(ValidationError);
71
71
  });
72
+ // #605 —— agent-node 按毫秒读 flags.timeout(默认 300000)。这里曾收 1..86400(秒的形状):
73
+ // 600 原样到子节点 = 0.6 秒超时;600000(10 分钟)反被拒。现在与 update_node_config 同一把尺子。
74
+ test("timeout is milliseconds: 0 or 1000..3_600_000 (#605)", () => {
75
+ for (const v of [0, 1000, 300_000, 600_000, 3_600_000]) {
76
+ expect(() => validateFlagValue("timeout", v)).not.toThrow();
77
+ }
78
+ for (const v of [1, 600, 999, 86_400 * 1000 + 1, 3_600_001, -1, 1500.5, "600000", null]) {
79
+ expect(() => validateFlagValue("timeout", v)).toThrow(ValidationError);
80
+ }
81
+ try { validateFlagValue("timeout", 600); throw new Error("unreachable"); }
82
+ catch (e) {
83
+ expect(e).toBeInstanceOf(ValidationError);
84
+ expect((e as ValidationError).code).toBe("flag_value_invalid");
85
+ expect(String((e as ValidationError).detail?.reason)).toContain("milliseconds");
86
+ }
87
+ });
88
+ test("timeout range matches update_node_config's (#605)", async () => {
89
+ const { validatePatch } = await import("./config-apply-validate.js");
90
+ for (const v of [0, 1, 600, 999, 1000, 600_000, 3_600_000, 3_600_001, -1, 1.5]) {
91
+ let createOk = true;
92
+ try { validateFlagValue("timeout", v); } catch { createOk = false; }
93
+ expect({ v, ok: createOk }).toEqual({ v, ok: validatePatch(undefined, { timeout: v }) === null });
94
+ }
95
+ });
72
96
  });
73
97
 
74
98
  describe("validateEnvRefs (§4.4.7 + B1 G7/G8 sub-cases)", () => {
@@ -108,6 +108,23 @@ export function validateModel(s: unknown): asserts s is string | undefined | nul
108
108
  }
109
109
  }
110
110
 
111
+ /**
112
+ * #605 —— `flags.timeout` 的单位是**毫秒**:agent-node 就是这么读的(cli.ts currentClaudeTimeoutMs /
113
+ * currentCodexTimeoutMs → util/timeout.ts resolveTimeoutMs、runtime/opencode-timeout.ts,默认 300000)。
114
+ * 这里曾按「秒」收 1..86400:填 600 想要 10 分钟的人拿到 0.6 秒、每个任务都超时;真正的毫秒值被拒。
115
+ *
116
+ * 范围与 update_node_config(config-apply-validate.ts)/ agent-node config-apply 同一把尺子:
117
+ * 0(不设上限)或 TIMEOUT_MS_MIN..TIMEOUT_MS_MAX。1..999 拒绝 —— 旧客户端按秒发的小值分辨不出意图,
118
+ * 不偷偷乘 1000,但也不再让任何人悄悄拿到一个亚秒超时。
119
+ */
120
+ export const TIMEOUT_MS_MIN = 1000;
121
+ export const TIMEOUT_MS_MAX = 3_600_000;
122
+ export const TIMEOUT_MS_REASON =
123
+ `timeout is in milliseconds: 0 (no limit) or an integer ${TIMEOUT_MS_MIN}..${TIMEOUT_MS_MAX} (e.g. 600000 = 10 min)`;
124
+ export function isValidTimeoutMs(v: unknown): v is number {
125
+ return typeof v === "number" && Number.isInteger(v) && (v === 0 || (v >= TIMEOUT_MS_MIN && v <= TIMEOUT_MS_MAX));
126
+ }
127
+
111
128
  export function validateFlagValue(k: string, v: unknown): void {
112
129
  switch (k) {
113
130
  case "permissionMode":
@@ -129,8 +146,8 @@ export function validateFlagValue(k: string, v: unknown): void {
129
146
  }
130
147
  return;
131
148
  case "timeout":
132
- if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 86400) {
133
- throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..86400" });
149
+ if (!isValidTimeoutMs(v)) {
150
+ throw new ValidationError("flag_value_invalid", { field: k, reason: TIMEOUT_MS_REASON });
134
151
  }
135
152
  return;
136
153
  case "copresence":
@@ -214,7 +231,7 @@ export function validateEnvRefs(
214
231
  return out;
215
232
  }
216
233
 
217
- // §4.4.7 — safe serializer for .env.local. Dotenv-quoted with escape
234
+ // §4.4.7 — safe serializer for the node secrets.env file. Dotenv-quoted with escape
218
235
  // for backslash / double-quote / newline / carriage-return so that a
219
236
  // value containing `\n"evil=KEY2"` cannot pollute the next line.
220
237
  export function serializeEnvLocal(env: Record<string, string>): string {
package/src/db.ts CHANGED
@@ -2104,3 +2104,23 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
2104
2104
  if (db.dialect === "postgres") {
2105
2105
  for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
2106
2106
  }
2107
+
2108
+ // Board #625: common SQLite/PostgreSQL DDL. Adoption records authority, not processes.
2109
+ db.exec(`
2110
+ CREATE TABLE IF NOT EXISTS node_daemon_bindings (
2111
+ request_id TEXT PRIMARY KEY,
2112
+ network_id TEXT NOT NULL,
2113
+ node_id TEXT NOT NULL,
2114
+ daemon_node_id TEXT NOT NULL,
2115
+ workdir TEXT NOT NULL,
2116
+ requested_by TEXT NOT NULL,
2117
+ status TEXT NOT NULL CHECK (status IN ('pending','active','refused','revoked')),
2118
+ error TEXT,
2119
+ created_at BIGINT NOT NULL,
2120
+ updated_at BIGINT NOT NULL
2121
+ );
2122
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ndb_live_node
2123
+ ON node_daemon_bindings(node_id) WHERE status IN ('pending','active');
2124
+ CREATE INDEX IF NOT EXISTS idx_ndb_daemon_network
2125
+ ON node_daemon_bindings(daemon_node_id,network_id,status);
2126
+ `);
@@ -25,7 +25,8 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
25
25
  * 上限跟着收到用户这一档(~1.7 KB 余量);节点 / 全部工具的上限在 tool-audience-http.test.ts。
26
26
  * 加工具或加说明超过它,就在这里改数字并在 PR 里说为什么。
27
27
  */
28
- const TOOLS_LIST_MAX_BYTES = 56_500;
28
+ // Board #625: two human adoption tools; measured 58 tools / 56,615 B.
29
+ const TOOLS_LIST_MAX_BYTES = 57_500;
29
30
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
30
31
  const TOOL_MAX_BYTES = 9_000;
31
32
  /** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */