@sleep2agi/commhub-server 0.9.0-preview.119 → 0.9.0-preview.120

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.119",
3
+ "version": "0.9.0-preview.120",
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",
@@ -98,6 +98,29 @@ beforeEach(() => {
98
98
  afterAll(cleanup);
99
99
 
100
100
  describe("#344 ack_create_request real in-process MCP transport", () => {
101
+ test("explicit launch proof survives transport while malformed booleans are rejected", async () => {
102
+ const requestId = 'cr_transport_verified';
103
+ seedRequest(requestId);
104
+ const { client, server } = await connectClient();
105
+ try {
106
+ const read = () => db.get<{ launch_verified_at: number | null }>(
107
+ 'SELECT launch_verified_at FROM node_create_requests WHERE request_id=?1', requestId);
108
+ const bad = await client.callTool({ name: 'ack_create_request', arguments: {
109
+ request_id: requestId, status: 'started', child_pid: 7777, launch_verified: 'true',
110
+ } });
111
+ expectTransportValidationError(bad);
112
+ expect(read()?.launch_verified_at).toBeNull();
113
+ const good = await client.callTool({ name: 'ack_create_request', arguments: {
114
+ request_id: requestId, status: 'started', child_pid: 7777, launch_verified: true,
115
+ } });
116
+ expect(responseJson(good).ok).toBe(true);
117
+ expect(read()?.launch_verified_at).toBeGreaterThan(0);
118
+ } finally {
119
+ await client.close();
120
+ await server.close();
121
+ }
122
+ });
123
+
101
124
  test("accepts runtime_capability_check_failed plus string runtime through the SDK zod gate", async () => {
102
125
  const requestId = "cr_transport_accept";
103
126
  seedRequest(requestId);
@@ -138,8 +138,8 @@ async function callAck(handler: ToolHandler, args: any): Promise<AckReply> {
138
138
  }
139
139
 
140
140
  function readRequest(request_id: string) {
141
- return db.get<{ status: string; error: string | null; acked_at: number | null }>(
142
- `SELECT status, error, acked_at FROM node_create_requests WHERE request_id = ?1`,
141
+ return db.get<{ status: string; error: string | null; acked_at: number | null; launch_verified_at: number | null }>(
142
+ `SELECT status, error, acked_at, launch_verified_at FROM node_create_requests WHERE request_id = ?1`,
143
143
  request_id,
144
144
  );
145
145
  }
@@ -297,6 +297,29 @@ describe("ack_create_request HANDLER — B2 audit_log daemon_capability_lied", (
297
297
  });
298
298
 
299
299
  describe("ack_create_request HANDLER — pre-existing happy-path stays green (regression)", () => {
300
+ for (const status of ['failed', 'rejected', 'runtime_capability_check_failed']) {
301
+ test(`late ${status} after registration cannot leave succeeded with a revoked token`, async () => {
302
+ seedDaemonWorld();
303
+ const request_id = `cr_late_${status}`;
304
+ const child_token_id = `tok_late_${status}`;
305
+ seedCreateRequest({ request_id, child_token_id, status: 'succeeded' });
306
+ const handler = buildAckHandler();
307
+ await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
308
+ expect(readRequest(request_id)?.launch_verified_at).toBeGreaterThan(0);
309
+ const reply = await callAck(handler, { request_id, status, error: 'post-register launch verdict' });
310
+ expect(reply).toMatchObject({ ok: true, status });
311
+ expect(readRequest(request_id)?.status).toBe(status);
312
+ expect(readRequest(request_id)?.error).toBe('post-register launch verdict');
313
+ expect(readRequest(request_id)?.acked_at).toBeGreaterThan(0);
314
+ expect(readToken(child_token_id)?.revoked_at).not.toBeNull();
315
+ // Delayed success must not resurrect either the request or its token.
316
+ await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
317
+ expect(readRequest(request_id)?.status).toBe(status);
318
+ expect(readRequest(request_id)?.launch_verified_at).toBeNull();
319
+ expect(readToken(child_token_id)?.revoked_at).not.toBeNull();
320
+ });
321
+ }
322
+
300
323
  test("status='started' still flips acked_at without terminal teardown", async () => {
301
324
  seedDaemonWorld();
302
325
  const reqId = "cr_ack_started_regression";
@@ -310,10 +333,30 @@ describe("ack_create_request HANDLER — pre-existing happy-path stays green (re
310
333
  // 'started' doesn't flip to terminal; status stays 'delivered'.
311
334
  expect(row?.status).toBe("delivered");
312
335
  expect(row?.acked_at).toBeGreaterThan(0);
336
+ expect(row?.launch_verified_at).toBeNull();
313
337
  // child ntok is NOT revoked on 'started'.
314
338
  expect(readToken(childTok)?.revoked_at).toBeNull();
315
339
  });
316
340
 
341
+ for (const status of ['pending', 'delivered', 'succeeded'] as const) {
342
+ test(`verified launch persists independently of registration: ${status}`, async () => {
343
+ seedDaemonWorld();
344
+ const request_id = `cr_verified_${status}`;
345
+ seedCreateRequest({ request_id, child_token_id: `tok_verified_${status}`, status });
346
+ const handler = buildAckHandler();
347
+ expect(readRequest(request_id)?.launch_verified_at).toBeNull();
348
+ const bad = await callAck(handler, { request_id, status: 'started', launch_verified: true });
349
+ expect(bad.ok).toBe(false);
350
+ expect(readRequest(request_id)?.launch_verified_at).toBeNull();
351
+ await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
352
+ const first = readRequest(request_id)!;
353
+ expect(first.status).toBe(status);
354
+ expect(first.launch_verified_at).toBeGreaterThan(0);
355
+ await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
356
+ expect(readRequest(request_id)?.launch_verified_at).toBe(first.launch_verified_at);
357
+ });
358
+ }
359
+
317
360
  test("status='failed' still revokes child + flips to failed (no audit_log daemon_capability_lied)", async () => {
318
361
  seedDaemonWorld();
319
362
  const reqId = "cr_ack_failed_regression";
@@ -17,6 +17,15 @@ function captureSchemas(): Record<string, Record<string, any>> {
17
17
  }
18
18
 
19
19
  describe("create_node MCP schema", () => {
20
+ test("#829 rejects misplaced V2 controls instead of stripping them", () => {
21
+ const nodeSpec = captureSchemas().create_node.node_spec;
22
+ const base = { name: "v2-node", runtime: "opencode-cli" };
23
+ expect(nodeSpec.safeParse({ ...base, opencodeGeneration: "v2" }).success).toBe(false);
24
+ expect(nodeSpec.safeParse({ ...base, opencodeUnsafeTools: true }).success).toBe(false);
25
+ const flags = { opencodeGeneration: "v2", opencodeUnsafeTools: true };
26
+ expect(nodeSpec.parse({ ...base, flags }).flags).toEqual(flags);
27
+ expect(nodeSpec.parse(base)).toEqual(base);
28
+ });
20
29
  test("model is optional on daemon create specs but empty strings stay invalid", () => {
21
30
  const schemas = captureSchemas();
22
31
  const nodeSpec = schemas.create_node.node_spec;
@@ -52,7 +52,7 @@ export type Runtime = typeof RUNTIMES[number];
52
52
  // 🔴 故意放在 flags 里而不是 node_spec 顶层:老 hub 的 zod 会静默丢弃未知顶层字段、老 daemon 也静默忽略
53
53
  // —— 那正是 #584 的形状(用户选了共存,拿到无头节点,没有任何报错)。flags 的未知键在老 hub(这份
54
54
  // FLAG_KEYS)和老 daemon(buildAnetArgsDaemon)都**拒绝**,新 app 撞到老组件得到的是一条报错。
55
- export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence"] as const;
55
+ export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence", "opencodeGeneration", "opencodeUnsafeTools"] as const;
56
56
 
57
57
  /** #584 —— 只有这些 runtime 认 `flags.copresence`。目前只有 codex-app-server 的共存靠 config 字段
58
58
  * (`codexCopresence`)决定;grok-build-cli / opencode-cli 各有自己的字段,接进来之前一律拒,不静默吞。 */
@@ -137,6 +137,12 @@ export function isValidTimeoutMs(v: unknown): v is number {
137
137
 
138
138
  export function validateFlagValue(k: string, v: unknown): void {
139
139
  switch (k) {
140
+ case "opencodeGeneration":
141
+ if (v !== "v1" && v !== "v2") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be v1 or v2" });
142
+ return;
143
+ case "opencodeUnsafeTools":
144
+ if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
145
+ return;
140
146
  case "permissionMode":
141
147
  if (typeof v !== "string" || !(PERMISSION_MODES as readonly string[]).includes(v)) {
142
148
  throw new ValidationError("flag_value_invalid", { field: k, reason: "must be one of default/acceptEdits/plan/bypassPermissions" });
@@ -171,8 +177,21 @@ export function validateFlagValue(k: string, v: unknown): void {
171
177
  /** #584 —— 逐键校验之外的跨字段一条:`copresence` 只对 COPRESENCE_FLAG_RUNTIMES 有意义。
172
178
  * 在别的 runtime 上收下它而什么都不做,就是又一个「选了共存、拿到无头」。 */
173
179
  export function validateFlagsForRuntime(runtime: string, flags: Record<string, unknown> | undefined | null): void {
174
- if (!flags || !Object.prototype.hasOwnProperty.call(flags, "copresence")) return;
175
- if (!(COPRESENCE_FLAG_RUNTIMES as readonly string[]).includes(runtime)) {
180
+ if (!flags) return;
181
+ // Strict flags envelope: old Hub/daemon reject these keys rather than dropping
182
+ // unknown node_spec fields. Opt-in must be explicit on this create request.
183
+ for (const field of ["opencodeGeneration", "opencodeUnsafeTools"]) {
184
+ if (!Object.prototype.hasOwnProperty.call(flags, field)) continue;
185
+ validateFlagValue(field, flags[field]);
186
+ if (runtime !== "opencode-cli") throw new ValidationError("flag_not_applicable_to_runtime", { field, runtime, applicable: ["opencode-cli"] });
187
+ }
188
+ if (Object.prototype.hasOwnProperty.call(flags, "opencodeUnsafeTools") && flags.opencodeGeneration !== "v2") {
189
+ throw new ValidationError("opencode_unsafe_requires_v2", { reason: "opencodeUnsafeTools requires explicit opencodeGeneration=v2" });
190
+ }
191
+ if (flags.opencodeGeneration === "v2" && flags.opencodeUnsafeTools !== true) {
192
+ throw new ValidationError("opencode_v2_requires_unsafe_opt_in", { reason: "V2 preview enables every local tool; trusted tasks only. Explicit opencodeUnsafeTools=true required." });
193
+ }
194
+ if (Object.prototype.hasOwnProperty.call(flags, "copresence") && !(COPRESENCE_FLAG_RUNTIMES as readonly string[]).includes(runtime)) {
176
195
  throw new ValidationError("flag_not_applicable_to_runtime", {
177
196
  field: "copresence", runtime, applicable: [...COPRESENCE_FLAG_RUNTIMES],
178
197
  });
@@ -318,6 +337,9 @@ function kebab(k: string): string {
318
337
  // shell, no string concat). Each value has already passed type/enum
319
338
  // validation, so String() coercion is safe.
320
339
  export function buildAnetArgs(spec: NodeSpec): string[] {
340
+ for (const field of ["opencodeGeneration", "opencodeUnsafeTools"]) {
341
+ if (Object.prototype.hasOwnProperty.call(spec, field)) throw new ValidationError("flag_location_invalid", { field, reason: "use node_spec.flags" });
342
+ }
321
343
  validateName(spec.name);
322
344
  validateRuntime(spec.runtime);
323
345
  validateModel(spec.model);
@@ -333,6 +355,7 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
333
355
  validateFlagValue(k, v);
334
356
  // `anet node create --copresence` 是布尔开关,不吃值。
335
357
  if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
358
+ if (k === "opencodeUnsafeTools") { if (v === true) args.push("--opencode-unsafe-tools"); continue; }
336
359
  args.push(`--${kebab(k)}`, String(v));
337
360
  }
338
361
  validateFlagsForRuntime(spec.runtime, spec.flags);
@@ -107,6 +107,27 @@ describe("daemonDefaultWorkdirRoot — sanitize the self-report", () => {
107
107
  });
108
108
 
109
109
  describe("create_node + workdir", () => {
110
+ test("#829 V2 flags survive Hub storage and daemon pull", async () => {
111
+ seed({ runtimes_supported: ["opencode-cli"] });
112
+ const flags = { opencodeGeneration: "v2", opencodeUnsafeTools: true, timeout: 600000 };
113
+ const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { name: "v2-child", runtime: "opencode-cli", model: "stub/model", flags } });
114
+ expect(r.ok).toBe(true);
115
+ const row = db.get<{ flags_json: string }>("SELECT flags_json FROM node_create_requests WHERE request_id = ?1", r.request_id);
116
+ expect(JSON.parse(row!.flags_json)).toEqual(flags);
117
+ const pulled = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
118
+ expect(pulled.ok).toBe(true);
119
+ expect(pulled.node_spec.flags).toEqual(flags);
120
+ });
121
+
122
+ test("#829 missing opt-in rejected before request or child token mint", async () => {
123
+ seed({ runtimes_supported: ["opencode-cli"] });
124
+ const tokenCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM api_tokens WHERE network_id = ?1", NET)!.n;
125
+ const before = tokenCount();
126
+ const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { name: "v2-child", runtime: "opencode-cli", flags: { opencodeGeneration: "v2" } } });
127
+ expect(r.error).toBe("opencode_v2_requires_unsafe_opt_in");
128
+ expect(rowCount()).toBe(0);
129
+ expect(tokenCount()).toBe(before);
130
+ });
110
131
  test("🔴 old daemon (no default_workdir_root) + workdir → workdir_not_supported_by_daemon, NO row", async () => {
111
132
  seed({});
112
133
  const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: `${ROOT}/wd-child` } });
package/src/db.ts CHANGED
@@ -771,6 +771,14 @@ try {
771
771
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
772
772
  }
773
773
 
774
+ // Registration also stamps acked_at, so it cannot prove daemon launch checks.
775
+ // Keep old rows NULL; run after the legacy model-nullability table rebuild.
776
+ try {
777
+ db.exec(`ALTER TABLE node_create_requests ADD COLUMN launch_verified_at BIGINT`);
778
+ } catch (e: any) {
779
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
780
+ }
781
+
774
782
  // #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
775
783
  // 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
776
784
  // getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
@@ -78,6 +78,19 @@ describe("GET /api/node-create-requests?request_id=", () => {
78
78
  const { body } = await get("/api/node-create-requests?request_id=cr_b_pending", bToken);
79
79
  expect(body.request.status).toBe("pending");
80
80
  expect(body.request.error).toBeNull();
81
+ expect(body.request.child_node_id).toBeNull();
82
+ expect(body.request.launch_verified_at).toBeNull();
83
+ });
84
+ test("registration timestamp is not launch proof; explicit proof and identity round-trip", async () => {
85
+ const { db } = await import('./db.js');
86
+ db.run(`UPDATE node_create_requests SET status='succeeded', child_node_id='node_verified', acked_at=1234 WHERE request_id='cr_b_pending'`);
87
+ const registered = await get('/api/node-create-requests?request_id=cr_b_pending', bToken);
88
+ expect(registered.body.request.acked_at).toBe(1234);
89
+ expect(registered.body.request.launch_verified_at).toBeNull();
90
+ db.run(`UPDATE node_create_requests SET launch_verified_at=5678 WHERE request_id='cr_b_pending'`);
91
+ const verified = await get('/api/node-create-requests?request_id=cr_b_pending', bToken);
92
+ expect(verified.body.request).toMatchObject({ child_node_id: 'node_verified', launch_verified_at: 5678 });
93
+ expect((await get('/api/node-create-requests?request_id=cr_b_pending', aToken)).status).toBe(404);
81
94
  });
82
95
  test("missing request_id → 400", async () => {
83
96
  const { status, body } = await get("/api/node-create-requests", aToken);
package/src/server.ts CHANGED
@@ -4489,7 +4489,7 @@ return Bun.serve({
4489
4489
  }
4490
4490
  const params: any[] = [requestId];
4491
4491
  let sql = `SELECT request_id, daemon_node_id, child_name, network_id, runtime, model, status, error,
4492
- created_at, delivered_at, acked_at
4492
+ created_at, delivered_at, acked_at, child_node_id, launch_verified_at
4493
4493
  FROM node_create_requests WHERE request_id = ?1`;
4494
4494
  sql = addNetworkScope(sql, params, restScope);
4495
4495
  const row = db.get<Record<string, unknown>>(sql, ...params);
package/src/tools.ts CHANGED
@@ -4279,6 +4279,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4279
4279
  model: z.string().min(1).max(100).optional().nullable(),
4280
4280
  flags: z.record(z.string(), z.unknown()).optional(),
4281
4281
  env_refs: z.array(z.string().max(64)).optional(),
4282
+ // Wrong placement must fail, not silently create a legacy V1 node.
4283
+ // These options belong under the strict flags envelope.
4284
+ opencodeGeneration: z.never().optional(),
4285
+ opencodeUnsafeTools: z.never().optional(),
4282
4286
  channels: z.array(z.unknown()).optional(),
4283
4287
  // app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
4284
4288
  // 缺席 = 老行为:落 daemon 的 cwd。
@@ -4753,8 +4757,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4753
4757
  error: z.string().max(1000).optional(),
4754
4758
  child_pid: z.number().int().optional(),
4755
4759
  runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
4760
+ launch_verified: z.boolean().optional(), // finite launcher exit + live generation proof, not mere registration/spawn
4756
4761
  },
4757
- async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
4762
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime, launch_verified }) => {
4758
4763
  const callerDaemon = resolveCallerDaemonTokenBound();
4759
4764
  if (!callerDaemon.ok) {
4760
4765
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -4772,6 +4777,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4772
4777
  }
4773
4778
  const ackedAt = Date.now();
4774
4779
  if (status === "started") {
4780
+ if (launch_verified === true && (!Number.isInteger(_pid) || _pid! <= 0)) {
4781
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "verified_child_pid_required" }) }] };
4782
+ }
4783
+ if (launch_verified === true) {
4784
+ // A child can register before this acknowledgement arrives. Preserve
4785
+ // both event orders, but a late success must never revive a failure.
4786
+ db.run(`UPDATE node_create_requests SET launch_verified_at = COALESCE(launch_verified_at, ?1)
4787
+ WHERE request_id = ?2 AND status IN ('pending', 'delivered', 'succeeded')`, [ackedAt, request_id]);
4788
+ }
4775
4789
  // Don't flip to 'succeeded' here — that happens via content-
4776
4790
  // match when the child actually registers. We just stamp ack.
4777
4791
  db.run(
@@ -4781,12 +4795,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4781
4795
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
4782
4796
  }
4783
4797
  // failed / rejected / runtime_capability_check_failed — revoke
4784
- // child-ntok + mark request terminal.
4798
+ // child-ntok + mark request terminal. Registration can precede the
4799
+ // daemon's final capability verdict; don't leave succeeded visible
4800
+ // while this same failure acknowledgement revokes its child token.
4785
4801
  if (row.child_token_id) {
4786
4802
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
4787
4803
  }
4788
4804
  db.run(
4789
- `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
4805
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3, launch_verified_at = NULL WHERE request_id = ?4 AND status IN ('pending', 'delivered', 'succeeded')`,
4790
4806
  [status, ackError || null, ackedAt, request_id],
4791
4807
  );
4792
4808
  // RFC-026 §9.3 D2 — surface declaration↔reality gap on a