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

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.104",
3
+ "version": "0.9.0-preview.106",
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",
@@ -3,7 +3,7 @@ import {
3
3
  ValidationError,
4
4
  validateName, validateRuntime, validateModel, validateFlagValue, RUNTIMES,
5
5
  validateEnvRefs, validateChannelsP1, serializeEnvLocal, buildAnetArgs,
6
- MAX_ENV_KEYS_PER_NODE,
6
+ MAX_ENV_KEYS_PER_NODE, validateFlagsForRuntime, COPRESENCE_FLAG_RUNTIMES,
7
7
  } from "./create-node-validate.js";
8
8
 
9
9
  const okSecret = (k: string, _net: string, key: string) => k === key ? `value-of-${k}` : undefined;
@@ -273,3 +273,38 @@ describe("#1298 RUNTIMES —— 七个都放行,且与 CLI 侧一致", () => {
273
273
  expect(() => validateRuntime("")).toThrow();
274
274
  });
275
275
  });
276
+
277
+ // #584 —— app「Codex(TUI 共存)」经 daemon 建出来的是无头节点:create_node 没有任何字段能表达「要共存」。
278
+ describe("#584 flags.copresence —— 建节点时的共存开关", () => {
279
+ test("is a known flag key and must be boolean", () => {
280
+ expect(() => validateFlagValue("copresence", true)).not.toThrow();
281
+ expect(() => validateFlagValue("copresence", false)).not.toThrow();
282
+ for (const bad of ["true", 1, null, {}]) {
283
+ try { validateFlagValue("copresence", bad); throw new Error("did not throw"); }
284
+ catch (e) { expect((e as ValidationError).code).toBe("flag_value_invalid"); }
285
+ }
286
+ });
287
+ test("only codex-app-server accepts it — elsewhere it would be swallowed silently", () => {
288
+ expect(() => validateFlagsForRuntime("codex-app-server", { copresence: true })).not.toThrow();
289
+ expect(() => validateFlagsForRuntime("claude-agent-sdk", { permissionMode: "default" })).not.toThrow();
290
+ expect(() => validateFlagsForRuntime("claude-agent-sdk", undefined)).not.toThrow();
291
+ for (const rt of ["claude-agent-sdk", "claude-code-cli", "codex-sdk", "grok-build-acp", "grok-build-cli", "opencode-cli"]) {
292
+ try { validateFlagsForRuntime(rt, { copresence: true }); throw new Error(`did not throw for ${rt}`); }
293
+ catch (e) {
294
+ expect((e as ValidationError).code).toBe("flag_not_applicable_to_runtime");
295
+ expect((e as ValidationError).detail).toMatchObject({ field: "copresence", runtime: rt, applicable: [...COPRESENCE_FLAG_RUNTIMES] });
296
+ }
297
+ }
298
+ });
299
+ test("buildAnetArgs emits the CLI's boolean `--copresence` (no value), and nothing for false", () => {
300
+ expect(buildAnetArgs({ name: "cx", runtime: "codex-app-server", flags: { copresence: true } }))
301
+ .toEqual(["node", "create", "cx", "--runtime", "codex-app-server", "--copresence"]);
302
+ expect(buildAnetArgs({ name: "cx", runtime: "codex-app-server", flags: { copresence: false } }))
303
+ .toEqual(["node", "create", "cx", "--runtime", "codex-app-server"]);
304
+ expect(() => buildAnetArgs({ name: "cx", runtime: "codex-sdk", flags: { copresence: true } })).toThrow(ValidationError);
305
+ });
306
+ test("the old request shape (no flag) is unchanged", () => {
307
+ expect(buildAnetArgs({ name: "cx", runtime: "codex-app-server", flags: { permissionMode: "default" } }))
308
+ .toEqual(["node", "create", "cx", "--runtime", "codex-app-server", "--permission-mode", "default"]);
309
+ });
310
+ });
@@ -46,7 +46,16 @@ export const RUNTIMES = [
46
46
  ] as const;
47
47
  export type Runtime = typeof RUNTIMES[number];
48
48
 
49
- export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout"] as const;
49
+ // #584 —— `copresence` 是**建节点时**的开关,不是 agent 的运行参数:为 true 时 daemon 把它写成子节点
50
+ // config 的 `codexCopresence: true`,之后一条 `anet node start` 就起共享 TUI(app 向导的「Codex(TUI 共存)」)。
51
+ // 🔴 故意放在 flags 里而不是 node_spec 顶层:老 hub 的 zod 会静默丢弃未知顶层字段、老 daemon 也静默忽略
52
+ // —— 那正是 #584 的形状(用户选了共存,拿到无头节点,没有任何报错)。flags 的未知键在老 hub(这份
53
+ // FLAG_KEYS)和老 daemon(buildAnetArgsDaemon)都**拒绝**,新 app 撞到老组件得到的是一条报错。
54
+ export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence"] as const;
55
+
56
+ /** #584 —— 只有这些 runtime 认 `flags.copresence`。目前只有 codex-app-server 的共存靠 config 字段
57
+ * (`codexCopresence`)决定;grok-build-cli / opencode-cli 各有自己的字段,接进来之前一律拒,不静默吞。 */
58
+ export const COPRESENCE_FLAG_RUNTIMES = ["codex-app-server"] as const;
50
59
  export type FlagKey = typeof FLAG_KEYS[number];
51
60
 
52
61
  export const PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"] as const;
@@ -124,11 +133,25 @@ export function validateFlagValue(k: string, v: unknown): void {
124
133
  throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..86400" });
125
134
  }
126
135
  return;
136
+ case "copresence":
137
+ if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
138
+ return;
127
139
  default:
128
140
  throw new ValidationError("flag_key_unknown", { field: k });
129
141
  }
130
142
  }
131
143
 
144
+ /** #584 —— 逐键校验之外的跨字段一条:`copresence` 只对 COPRESENCE_FLAG_RUNTIMES 有意义。
145
+ * 在别的 runtime 上收下它而什么都不做,就是又一个「选了共存、拿到无头」。 */
146
+ export function validateFlagsForRuntime(runtime: string, flags: Record<string, unknown> | undefined | null): void {
147
+ if (!flags || !Object.prototype.hasOwnProperty.call(flags, "copresence")) return;
148
+ if (!(COPRESENCE_FLAG_RUNTIMES as readonly string[]).includes(runtime)) {
149
+ throw new ValidationError("flag_not_applicable_to_runtime", {
150
+ field: "copresence", runtime, applicable: [...COPRESENCE_FLAG_RUNTIMES],
151
+ });
152
+ }
153
+ }
154
+
132
155
  // §4.4.7 — env_refs 7-step gate. networkSecretsGet is injected so
133
156
  // callers can supply either a real DB lookup (hub) or a fixture (tests).
134
157
  // The 6th step also reports the resolved value size; we return the
@@ -281,8 +304,11 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
281
304
  throw new ValidationError("flag_key_unknown", { field: k });
282
305
  }
283
306
  validateFlagValue(k, v);
307
+ // `anet node create --copresence` 是布尔开关,不吃值。
308
+ if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
284
309
  args.push(`--${kebab(k)}`, String(v));
285
310
  }
311
+ validateFlagsForRuntime(spec.runtime, spec.flags);
286
312
  }
287
313
  return args;
288
314
  }
@@ -435,6 +435,34 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
435
435
  expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A })).ok).toBe(true);
436
436
  });
437
437
 
438
+ // #571 — 手工起的节点(id `n_…`)没有任何 daemon 链接。改前:authority==null 时
439
+ // 放行调用方点名的任意同网 daemon → 门铃发过去,那台 daemon 本地无记录,按 alias
440
+ // 全机 pgrep+SIGTERM,误杀同 alias 的别的 agent-node。改后:不论传没传 daemon 一律拒。
441
+ test("#571: stop/delete of a hand-started node refuse even with an explicit same-network daemon", async () => {
442
+ setupAlphaNetwork();
443
+ const HAND_ID = "n_571abcde";
444
+ const HAND_ALIAS = "sd-hand-571";
445
+ seedChild(NET_A, HAND_ID, HAND_ALIAS, USER_A_ID, "tok_sd_hand_571");
446
+ const u = buildHandlers(USER_A_ID);
447
+ const s = await call(u.stop_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
448
+ expect(s.ok).toBe(false);
449
+ expect(s.error).toBe("not_daemon_managed");
450
+ expect(String(s.message)).toContain("anet node stop");
451
+ const d = await call(u.delete_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, confirm_alias: HAND_ALIAS, network_id: NET_A });
452
+ expect(d.error).toBe("not_daemon_managed");
453
+ // 没有任何副作用:状态没动、没写 stop 请求行
454
+ expect(readNode(HAND_ID)?.lifecycle_state).toBe("active");
455
+ const rows = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM node_stop_requests WHERE child_node_id = ?1`, HAND_ID);
456
+ expect(Number(rows?.n ?? 0)).toBe(0);
457
+ // 回归钉:daemon 建的 child(有 create 记录)照常可停
458
+ db.run(
459
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
460
+ VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
461
+ [`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
462
+ );
463
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, network_id: NET_A })).ok).toBe(true);
464
+ });
465
+
438
466
  // #1448 finding-5 — delete 完成时按派发时定住的 token_id 精确撤 ntok,而非按
439
467
  // name='node:<alias>' 广撤。改前:同 alias 在 delete 未收敛窗口内被重建、拿到同名新
440
468
  // token,ack 会把新 token 一并误撤(下面 REBUILT_TOK 断言 not revoked 会红)。
package/src/tools.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  validateEnvRefs,
31
31
  FLAG_KEYS,
32
32
  validateFlagValue,
33
+ validateFlagsForRuntime,
33
34
  ValidationError,
34
35
  } from "./create-node-validate.js";
35
36
  import {
@@ -4252,6 +4253,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4252
4253
  if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
4253
4254
  validateFlagValue(k, v);
4254
4255
  }
4256
+ validateFlagsForRuntime(node_spec.runtime, node_spec.flags);
4255
4257
  } catch (e) {
4256
4258
  return validationFailReply(e);
4257
4259
  }
@@ -4698,7 +4700,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4698
4700
  ok: false, error: "not_daemon_managed",
4699
4701
  message:
4700
4702
  `node ${child_node_id} was not created by a daemon (it was started by hand with ` +
4701
- `\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
4703
+ `\`anet node start\` on some machine), so no daemon on the Hub can stop it ` +
4704
+ `(naming a daemon_node_id does not change that). ` +
4702
4705
  `Run \`anet node stop <alias>\` on that machine instead. ` +
4703
4706
  `Daemon-created children have ids beginning with \`node_\`; this one does not.`,
4704
4707
  };
@@ -4826,6 +4829,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4826
4829
  // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
4827
4830
  // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
4828
4831
  const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
4832
+ // #571 — 手工起的节点(id 不以 `node_` 开头)**没有**任何 daemon 链接。此前
4833
+ // authority==null 时放行调用方点名的任意同网 daemon → 门铃发给那台 daemon,
4834
+ // 它本地没这个 child 的记录,会走「按 alias 全机 pgrep + SIGTERM」的收敛分支,
4835
+ // 杀掉那台机器上所有同 alias 的 agent-node(不分工作目录/HOME/网络)。
4836
+ // 对 hand-started 节点这条路在概念上就不成立(见 explainUnresolvableDaemon ①),
4837
+ // 所以不管调用方传没传 daemon_node_id 一律拒。`node_` 开头但缺 create 记录的
4838
+ // (②,显式传 daemon 是文档化的出路)保持原行为。
4839
+ if (!authoritativeDaemonId && !args.child_node_id.startsWith("node_")) {
4840
+ return explainUnresolvableDaemon(args.child_node_id);
4841
+ }
4829
4842
  if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
4830
4843
  return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
4831
4844
  }