@sleep2agi/commhub-server 0.9.0-preview.121 → 0.9.0-preview.123

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.
@@ -0,0 +1,31 @@
1
+ // #906 managed login transport: only explicit actions and secret-free projections.
2
+ export type LoginIntent = { action: 'start' | 'read' | 'cancel' | 'save' | 'accounts'; session_id?: string; label?: string };
3
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
4
+ const object = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
5
+ const keys = (v: any, names: string[]) => Object.keys(v).every(k => names.includes(k));
6
+ export function validLoginIntent(v: any): v is LoginIntent {
7
+ return object(v) && keys(v, ['action','session_id','label'])
8
+ && ['start','read','cancel','save','accounts'].includes(v.action)
9
+ && (v.action === 'accounts' ? v.session_id === undefined : typeof v.session_id === 'string' && uuid.test(v.session_id))
10
+ && (v.action === 'save' ? typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label) : v.label === undefined);
11
+ }
12
+ export function validLoginReceipt(v: any, intent: LoginIntent, network: string, daemon: string): boolean {
13
+ if (!object(v)) return false;
14
+ if (intent.action === 'accounts') return keys(v, ['accounts']) && Array.isArray(v.accounts) && v.accounts.length <= 100
15
+ && v.accounts.every((a: any) => object(a) && keys(a, ['version','network_id','daemon_node_id','account_id','label','created_at','auth_state'])
16
+ && a.version === 1 && a.network_id === network && a.daemon_node_id === daemon
17
+ && typeof a.account_id === 'string' && uuid.test(a.account_id)
18
+ && typeof a.label === 'string' && a.label.length <= 100
19
+ && Number.isSafeInteger(a.created_at) && a.auth_state === 'not_checked');
20
+ if (!keys(v, ['request_id','status','error','challenge','account_id']) || v.request_id !== intent.session_id
21
+ || !['idle','starting','awaiting_user','verifying','authenticated','cancelled','failed'].includes(v.status)) return false;
22
+ if (v.error !== undefined && !['login_start_failed','login_rejected','account_read_failed','login_process_exited','login_expired'].includes(v.error)) return false;
23
+ if (v.account_id !== undefined && (v.status !== 'authenticated' || typeof v.account_id !== 'string' || !uuid.test(v.account_id))) return false;
24
+ if (intent.action === 'cancel' && v.status !== 'cancelled') return false;
25
+ if (intent.action === 'save' && (v.status !== 'authenticated' || !v.account_id)) return false;
26
+ if (v.status === 'awaiting_user') return object(v.challenge) && keys(v.challenge, ['loginId','verificationUrl','userCode'])
27
+ && typeof v.challenge.loginId === 'string' && uuid.test(v.challenge.loginId)
28
+ && v.challenge.verificationUrl === 'https://auth.openai.com/codex/device'
29
+ && typeof v.challenge.userCode === 'string' && /^[A-Z0-9-]{4,32}$/i.test(v.challenge.userCode);
30
+ return v.challenge === undefined;
31
+ }
@@ -28,13 +28,16 @@ export function validateDaemonProviderSnapshot(value: unknown, networkId: string
28
28
  obj(r, ["runtime", "auth"]); if (r.runtime !== "codex-tui") fail();
29
29
  const auths = arr(r.auth, 32); unique(auths, "id");
30
30
  for (const a of auths) {
31
- obj(a, ["id", "kind", "models", "baseUrl", "credential_present", "verification", "application"]);
31
+ obj(a, ["id", "kind", "models", "baseUrl", "account_id", "credential_present", "verification", "application"]);
32
32
  if (!id(a.id) || a.verification !== "not_checked" || a.application !== "not_applied") fail();
33
33
  const models = arr(a.models, 200);
34
34
  if (new Set(models).size !== models.length || models.some(m => typeof m !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(m))) fail();
35
35
  if (a.kind === "chatgpt") {
36
- if (p.id !== "openai" || a.baseUrl !== undefined || a.credential_present !== false) fail();
36
+ if (p.id !== "openai" || a.baseUrl !== undefined || typeof a.credential_present !== 'boolean') fail();
37
+ if (a.account_id !== undefined && (typeof a.account_id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(a.account_id))) fail();
38
+ if (a.credential_present && !a.account_id) fail();
37
39
  } else if (a.kind === "api_key") {
40
+ if (a.account_id !== undefined) fail();
38
41
  if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
39
42
  let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
40
43
  if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail();
@@ -2,23 +2,28 @@
2
2
  export type ProviderApplicationSelection = {
3
3
  node_id: string; node_revision: string; provider_id: string; auth_id: string;
4
4
  model: string; confirm_restart: true;
5
+ account_id?: string;
5
6
  };
6
7
  const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
7
8
  && Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
8
9
  const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
9
10
  const hash = (v: any) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
11
+ const account = (v: any) => typeof v === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v);
10
12
  export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
11
- return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart'])
13
+ return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart',
14
+ ...(v?.account_id !== undefined ? ['account_id'] : [])])
15
+ && (v.account_id === undefined || (v.provider_id === 'openai' && account(v.account_id)))
12
16
  && id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
13
17
  && v.confirm_restart === true;
14
18
  }
15
19
  export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
16
20
  selection: ProviderApplicationSelection): boolean {
17
21
  return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
18
- 'node_revision', 'provider_revision', 'verification', 'applied_at'])
22
+ 'node_revision', 'provider_revision', 'verification', 'applied_at', ...(selection.account_id ? ['account_id'] : [])])
19
23
  && v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
20
24
  && v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
21
- && typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider)
25
+ && (selection.account_id ? v.runtime_provider === 'openai' && v.account_id === selection.account_id
26
+ : typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider))
22
27
  && hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
23
28
  && Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
24
29
  }
@@ -419,6 +419,46 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
419
419
  expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
420
420
  });
421
421
 
422
+ // Daemon-mediated delete used to drop only the nodes row. The child's sessions
423
+ // row stayed behind as "offline", so /api/status — and every client list built
424
+ // on it — kept showing the deleted node forever (desktop: the node stays put
425
+ // under 「删除请求已提交」). REST DELETE /api/nodes/:id already drops both.
426
+ test("daemon ack 'stopped' on delete branch → the child's session row is gone too", async () => {
427
+ setupAlphaNetwork();
428
+ db.run(
429
+ "INSERT INTO sessions (resume_id, alias, node_id, network_id, status) VALUES (?1, ?2, ?3, ?4, 'idle')",
430
+ ["resume-sd-child-a", CHILD_A_ALIAS, CHILD_A_ID, NET_A],
431
+ );
432
+ const userTools = buildHandlers(USER_A_ID);
433
+ const dispatch = await call(userTools.delete_node, {
434
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
435
+ });
436
+ expect(dispatch.ok).toBe(true);
437
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
438
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
439
+ const left = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM sessions WHERE node_id = ?1", CHILD_A_ID);
440
+ expect(Number(left?.n ?? 0)).toBe(0);
441
+ });
442
+
443
+ test("delete finalize drops the session by node_id, sparing a same-alias rebuilt node", async () => {
444
+ setupAlphaNetwork();
445
+ const userTools = buildHandlers(USER_A_ID);
446
+ const dispatch = await call(userTools.delete_node, {
447
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
448
+ });
449
+ expect(dispatch.ok).toBe(true);
450
+ // Before the ack lands, a new node takes the same alias and registers.
451
+ const REBUILT_ID = "node_sd_child_a_rebuilt";
452
+ db.run(
453
+ "INSERT INTO sessions (resume_id, alias, node_id, network_id, status) VALUES (?1, ?2, ?3, ?4, 'idle')",
454
+ ["resume-sd-child-a-rebuilt", CHILD_A_ALIAS, REBUILT_ID, NET_A],
455
+ );
456
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
457
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
458
+ const rebuilt = db.get<{ status: string }>("SELECT status FROM sessions WHERE node_id = ?1", REBUILT_ID);
459
+ expect(rebuilt?.status).toBe("idle");
460
+ });
461
+
422
462
  // #1448 finding-4 — stop/delete 必须校验 daemon 是 child 的权威创建者(镜像 start)。
423
463
  // 改前只校验 daemon 存在+同网 → 同网调用方传错 daemon_node_id,门铃路由到错 daemon。
424
464
  // f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
@@ -163,7 +163,7 @@ describe("what each caller sees", () => {
163
163
 
164
164
  test("node modes: readonly / restricted drop broadcast + node writes + human-only; enforce drops human-only for normal nodes", async () => {
165
165
  const ro = new Set((await listTools(T.node_readonly)).names);
166
- for (const n of ["broadcast", "update_node_config", "create_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
166
+ for (const n of ["broadcast", "update_node_config", "create_node", "clone_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
167
167
  for (const n of ["send_task", "requirements_update", "report_status", "get_all_status"]) expect([n, ro.has(n)]).toEqual([n, true]); // 看参数才拒的照列
168
168
  expect(new Set((await listTools(T.node_normal)).names).has("upsert_provider")).toBe(true); // log 模式:调得通,照列
169
169
  process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
@@ -202,14 +202,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
202
202
  // node 61 / 62,433 B → 62,946 B (+513); user 58 / 57,228 B unchanged
203
203
  // (report_status is not on the user list). Budget only this measured addition
204
204
  // (~154/509 B node/all margin).
205
- // #906 registers daemon_provider_snapshot (user) plus pull/ack (protocol; omitted
206
- // from the default node and user lists). Measured on this tree after the
207
- // description trims already in the change: all 90 / 80,307 B; node 61 / 62,556 B;
208
- // user 59 / 57,372 B. Node stays under 63,100 (544 B) and user under 57,500 (128 B).
209
- // `all` is 207 B over 80,100. The two protocol descriptions are 179 B together, so
210
- // shortening them cannot pay for their schemas. Raise only `all` to 80,800
211
- // (493 B margin, same order as the previous all margin).
212
- const CEILINGS = { node: 63_100, user: 57_500, all: 80_800 } as const;
205
+ // #906 plus Hub #938 clone_node (all 91 / 81,228 B; node 62 / 63,281 B;
206
+ // user 60 / 58,169 B). OpenCode V2 catalog adds 2 user tools and 2 protocol
207
+ // tools. Measured on this combined tree: all 95 / 85,069 B; node 62 / 63,281 B
208
+ // unchanged (protocol tools stay off the node list); user 62 / 59,409 B.
209
+ // Ceilings keep about 400 B and nothing else.
210
+ const CEILINGS = { node: 63_700, user: 59_810, all: 85_500 } as const;
213
211
  test("node / user / all", async () => {
214
212
  const node = await listTools(T.node_normal);
215
213
  const user = await listTools(T.owner);
@@ -41,8 +41,10 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
41
41
  get_stop_request: "protocol", ack_stop_request: "protocol",
42
42
  get_start_request: "protocol", ack_start_request: "protocol",
43
43
  get_probe_request: "protocol", ack_probe_request: "protocol",
44
+ get_opencode_catalog_request: "protocol", ack_opencode_catalog: "protocol",
44
45
  // ── 人:节点令牌一律 user_token_required(项目管理)/ node_token_cannot_browse_files / node_token_cannot_read_logs ──
45
46
  projects_create: "user", projects_update: "user",
47
+ read_opencode_catalog: "user", get_opencode_catalog: "user",
46
48
  list_node_files: "user", read_node_file: "user", tail_node_logs: "user",
47
49
  // ── 两边都能调 ──
48
50
  submit_skill: "both", list_skills: "both", get_skill: "both", review_skill: "both",
@@ -53,7 +55,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
53
55
  send_desktop_message: "both", broadcast: "both", get_completions: "both",
54
56
  update_node_config: "both", read_node_rules_file: "both", write_node_rules_file: "both",
55
57
  list_node_skills: "both", read_node_skill: "both", get_rules_file_result: "both",
56
- restart_node: "both", list_host_supervisors: "both", create_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
58
+ restart_node: "both", list_host_supervisors: "both", create_node: "both", clone_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
57
59
  upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
58
60
  list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
59
61
  requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
package/src/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
2
+ import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
3
3
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
4
4
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
5
5
  import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
@@ -55,6 +55,7 @@ import {
55
55
  auditCreateNodeStrict,
56
56
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
57
57
  } from "./create-node.js";
58
+ import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
58
59
  import {
59
60
  vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
60
61
  VaultError,
@@ -72,6 +73,13 @@ import {
72
73
  startPendingProbeGcTimer,
73
74
  startProbeSweeperTimer,
74
75
  } from "./probe.js";
76
+ import {
77
+ ackOpenCodeCatalog,
78
+ getOpenCodeCatalogResult,
79
+ opencodeCatalogAckSchema,
80
+ queueOpenCodeCatalogRead,
81
+ takeOpenCodeCatalogRequest,
82
+ } from "./opencode-v2-catalog.js";
75
83
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
76
84
  import {
77
85
  ALLOWED_FLAGS,
@@ -4120,13 +4128,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4120
4128
  const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
4121
4129
  const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
4122
4130
  server.tool(
4123
- "daemon_provider_snapshot", "Admin Provider config. refresh/put/apply: id=daemon; read: id=request. put: revision/provider; apply: revision/provider={node_id,node_revision,provider_id,auth_id,model,confirm_restart:true}.",
4124
- { action: z.enum(["refresh", "read", "put", "apply"]), id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), revision: z.number().int().min(0).optional(), provider: z.unknown().optional() },
4125
- async ({ action, id, network_id, revision, provider }) => {
4131
+ "daemon_provider_snapshot", "Admin Provider: refresh/put/apply/login use daemon id; read uses request id. put: revision+provider; apply: revision+node selection; login: provider={action:start|read|cancel|save|accounts,session_id?,label?}. No login tokens.",
4132
+ { action: z.enum(["refresh", "read", "put", "apply", "login"]), id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), revision: z.number().int().min(0).optional(), provider: z.unknown().optional(), include_discovery: z.boolean().optional() },
4133
+ async ({ action, id, network_id, revision, provider, include_discovery }) => {
4126
4134
  const human = providerHuman(network_id);
4127
- if (action === "read") return providerReply(getProviderSnapshot(human, id));
4135
+ if (action === "read") return providerReply(getProviderSnapshot(human, id, include_discovery));
4128
4136
  const daemon_node_id = id;
4129
- const result = action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
4137
+ const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
4138
+ : action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
4130
4139
  : action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
4131
4140
  if (result.ok) {
4132
4141
  const target = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", daemon_node_id, human.networkId!);
@@ -4136,13 +4145,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4136
4145
  },
4137
4146
  );
4138
4147
  server.tool(
4139
- "pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional() },
4140
- async ({ accept_writes, accept_applications }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications)),
4148
+ "pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional() },
4149
+ async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins, accept_local_discovery)),
4141
4150
  );
4142
4151
  server.tool(
4143
4152
  "ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
4144
- { request_id: z.string().min(1).max(200), snapshot: z.unknown().optional(), failed: z.boolean().optional(), applied_revision: z.number().int().min(0).optional(), application: z.unknown().optional() },
4145
- async ({ request_id, snapshot, failed, applied_revision, application }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application)),
4153
+ { request_id: z.string().min(1).max(200), snapshot: z.unknown().optional(), failed: z.boolean().optional(), applied_revision: z.number().int().min(0).optional(), application: z.unknown().optional(), login: z.unknown().optional() },
4154
+ async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
4146
4155
  );
4147
4156
 
4148
4157
  // Helper — map a ValidationError thrown from create-node-validate
@@ -4610,6 +4619,156 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4610
4619
  },
4611
4620
  );
4612
4621
 
4622
+ // Hub #938 — clone a child that already lives on a daemon into a new
4623
+ // node on THAT daemon. New alias, new ntok_. Session copy is done by
4624
+ // the daemon from the source config on disk; this tool only records
4625
+ // the policy and the runtime's session capability (see clone-node.ts).
4626
+ server.tool(
4627
+ "clone_node",
4628
+ "Clone a daemon child into a new node on the same daemon. New name and new token. copy_session defaults to true. workdir_policy new_empty (default) or share_source. Hub #938.",
4629
+ {
4630
+ source_node_id: z.string().min(1).max(200),
4631
+ name: z.string().min(1).max(256),
4632
+ copy_session: z.boolean().optional(),
4633
+ workdir_policy: z.enum(["new_empty", "share_source"]).optional(),
4634
+ daemon_node_id: z.string().min(1).max(200).optional(),
4635
+ network_id: z.string().max(200).optional(),
4636
+ },
4637
+ async ({ source_node_id, name, copy_session, workdir_policy, daemon_node_id, network_id: clientNetId }) => {
4638
+ const effectiveNetId = getNetworkId(clientNetId);
4639
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4640
+ const callerRole = enforceUserId && effectiveNetId
4641
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4642
+ : null;
4643
+ if (callerRole !== "admin" && callerRole !== "owner") {
4644
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
4645
+ }
4646
+ const { row: source, sec1Ok } = resolveTargetNode(source_node_id, effectiveNetId);
4647
+ if (!source) {
4648
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: source_node_id }) }] };
4649
+ }
4650
+ if (!sec1Ok) {
4651
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
4652
+ }
4653
+ const createRow = db.get<{ daemon_node_id: string; runtime: string; model: string | null; flags_json: string; child_name: string }>(
4654
+ `SELECT daemon_node_id, runtime, model, flags_json, child_name
4655
+ FROM node_create_requests
4656
+ WHERE child_node_id = ?1 AND network_id = ?2 AND status = 'succeeded'
4657
+ ORDER BY created_at DESC LIMIT 1`,
4658
+ source_node_id, source.network_id || effectiveNetId || "default",
4659
+ );
4660
+ const net = source.network_id || effectiveNetId || "default";
4661
+ const nameRows = db.all<{ alias: string | null; node_name: string | null }>(
4662
+ `SELECT alias, node_name FROM nodes WHERE network_id = ?1`,
4663
+ net,
4664
+ );
4665
+ const existingNames = nameRows.flatMap((r) => [r.alias, r.node_name].filter((x): x is string => !!x));
4666
+ const owning = createRow?.daemon_node_id ?? "";
4667
+ const inflight = owning
4668
+ ? db.all<{ child_name: string }>(
4669
+ `SELECT child_name FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered')`,
4670
+ owning,
4671
+ ).map((r) => r.child_name)
4672
+ : [];
4673
+ let policy: ReturnType<typeof parseWorkdirPolicy>;
4674
+ try { policy = parseWorkdirPolicy(workdir_policy); }
4675
+ catch (e) { return validationFailReply(e); }
4676
+ const facts = factsFromRows({
4677
+ node: source,
4678
+ createRow: createRow ?? null,
4679
+ existingNames,
4680
+ inflightNames: inflight,
4681
+ });
4682
+ let decision;
4683
+ try {
4684
+ decision = decideClone(facts, {
4685
+ newName: name,
4686
+ copySession: copy_session !== false,
4687
+ workdirPolicy: policy,
4688
+ requestedDaemonId: daemon_node_id ?? null,
4689
+ });
4690
+ } catch (e) {
4691
+ return validationFailReply(e);
4692
+ }
4693
+ if (!decision.ok) {
4694
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: decision.error, ...decision.detail }) }] };
4695
+ }
4696
+ const { row: daemon, sec1Ok: daemonOk } = resolveTargetNode(decision.daemonNodeId, effectiveNetId);
4697
+ if (!daemon || !daemonOk) {
4698
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id: decision.daemonNodeId }) }] };
4699
+ }
4700
+ if (!enforceUserId) {
4701
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
4702
+ }
4703
+ const childToken = generateNetworkToken();
4704
+ const childTokenId = generateId("tok");
4705
+ const requestId = newRequestId();
4706
+ const networkIdForChild = daemon.network_id || net;
4707
+ const cloneMeta = {
4708
+ source_node_id,
4709
+ source_alias: facts.alias,
4710
+ copy_session: decision.copySession,
4711
+ workdir_policy: decision.workdirPolicy,
4712
+ channels: decision.channels,
4713
+ };
4714
+ db.run(
4715
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2)`,
4716
+ [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${decision.name}`, "network", "child", requestId],
4717
+ );
4718
+ putPendingEnvBlob({
4719
+ request_id: requestId,
4720
+ daemon_node_id: decision.daemonNodeId,
4721
+ env_blob: {},
4722
+ child_token: childToken,
4723
+ child_token_id: childTokenId,
4724
+ });
4725
+ db.run(
4726
+ `INSERT INTO node_create_requests
4727
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, clone_json)
4728
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '[]', 'pending', ?8, ?9, ?10, ?11)`,
4729
+ [
4730
+ requestId, decision.daemonNodeId, decision.name, networkIdForChild,
4731
+ decision.runtime, decision.model, JSON.stringify(decision.flags),
4732
+ childTokenId, Date.now(), callerTokenId || "unknown", JSON.stringify(cloneMeta),
4733
+ ],
4734
+ );
4735
+ pushEvent(daemon.alias, { type: "clone_node", request_id: requestId }, networkIdForChild);
4736
+ auditCreateNode({
4737
+ action: "clone_node_dispatch_attempted",
4738
+ user_id: enforceUserId,
4739
+ network_id: networkIdForChild,
4740
+ target_id: requestId,
4741
+ detail: {
4742
+ daemon_node_id: decision.daemonNodeId,
4743
+ source_node_id,
4744
+ child_name: decision.name,
4745
+ runtime: decision.runtime,
4746
+ copy_session: decision.copySession,
4747
+ workdir_policy: decision.workdirPolicy,
4748
+ session_mode: decision.sessionContract.mode,
4749
+ },
4750
+ });
4751
+ return {
4752
+ content: [{ type: "text" as const, text: JSON.stringify({
4753
+ ok: true,
4754
+ request_id: requestId,
4755
+ daemon_node_id: decision.daemonNodeId,
4756
+ child_name: decision.name,
4757
+ copy_session: decision.copySession,
4758
+ workdir_policy: decision.workdirPolicy,
4759
+ session_contract: decision.sessionContract,
4760
+ contract: {
4761
+ token: "new ntok_ minted for the clone; the source token is not copied",
4762
+ workdir: decision.workdirPolicy === "new_empty"
4763
+ ? "new empty directory under the source workdir's parent (or <daemon cwd>/clone-work when the source has no project workdir)"
4764
+ : "share the source workdir (explicit)",
4765
+ session: decision.sessionContract.detail,
4766
+ },
4767
+ }) }],
4768
+ };
4769
+ },
4770
+ );
4771
+
4613
4772
  // #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
4614
4773
  // intentionally best-effort; after a daemon connects or reconnects, it pulls
4615
4774
  // any still-pending requests bound to its own daemon token.
@@ -4628,10 +4787,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4628
4787
  WHERE daemon_node_id = ?1
4629
4788
  AND network_id = ?2
4630
4789
  AND status = 'pending'
4790
+ AND COALESCE(clone_json, '') = ''
4791
+ ORDER BY created_at ASC`,
4792
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4793
+ );
4794
+ // Hub #938 — clone rows stay out of `requests` so an older daemon
4795
+ // that replays every pending create does not spawn a blank node.
4796
+ const clone_requests = db.all<{ request_id: string; child_name: string; created_at: number }>(
4797
+ `SELECT request_id, child_name, created_at
4798
+ FROM node_create_requests
4799
+ WHERE daemon_node_id = ?1
4800
+ AND network_id = ?2
4801
+ AND status = 'pending'
4802
+ AND COALESCE(clone_json, '') != ''
4631
4803
  ORDER BY created_at ASC`,
4632
4804
  callerDaemon.daemonNodeId, callerDaemon.networkId,
4633
4805
  );
4634
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
4806
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4807
+ ok: true,
4808
+ count: rows.length,
4809
+ requests: rows,
4810
+ clone_count: clone_requests.length,
4811
+ clone_requests,
4812
+ }) }] };
4635
4813
  },
4636
4814
  );
4637
4815
 
@@ -4699,21 +4877,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4699
4877
  // isn't a host_supervisor, reject.
4700
4878
  server.tool(
4701
4879
  "get_create_request",
4702
- "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
4880
+ "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026. expect_clone pulls a clone_node row.",
4703
4881
  {
4704
4882
  request_id: z.string().min(1).max(200),
4883
+ expect_clone: z.boolean().optional(),
4705
4884
  },
4706
- async ({ request_id }) => {
4885
+ async ({ request_id, expect_clone }) => {
4707
4886
  const callerDaemon = resolveCallerDaemonTokenBound();
4708
4887
  if (!callerDaemon.ok) {
4709
4888
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4710
4889
  }
4711
4890
 
4712
- const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
4713
- `SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
4891
+ const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string; clone_json: string | null }>(
4892
+ `SELECT request_id, daemon_node_id, status, child_token_id, network_id, clone_json FROM node_create_requests WHERE request_id = ?1`,
4714
4893
  request_id,
4715
4894
  );
4716
4895
  if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4896
+ const isClone = typeof row.clone_json === "string" && row.clone_json.trim().length > 0;
4897
+ // Do not consume the env blob or flip status when the wrong handler asks.
4898
+ if (isClone && expect_clone !== true) {
4899
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "clone_request" }) }] };
4900
+ }
4901
+ if (!isClone && expect_clone === true) {
4902
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_a_clone" }) }] };
4903
+ }
4717
4904
  // §4.1.4 — strict daemon binding by token-derived node_id.
4718
4905
  if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4719
4906
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
@@ -4763,6 +4950,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4763
4950
  },
4764
4951
  child_token: blob.child_token,
4765
4952
  env_blob: blob.env_blob,
4953
+ ...(isClone ? { clone: JSON.parse(row.clone_json as string) } : {}),
4766
4954
  }) }],
4767
4955
  };
4768
4956
  },
@@ -5400,6 +5588,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5400
5588
  );
5401
5589
  }
5402
5590
  db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
5591
+ // Same as REST DELETE /api/nodes/:id: without this the sessions row
5592
+ // stays "offline" and /api/status lists the deleted node forever.
5593
+ // By node_id, not alias — a same-alias rebuild keeps its session.
5594
+ db.run(`DELETE FROM sessions WHERE node_id = ?1`, [row.child_node_id]);
5403
5595
  clearNodeFromAgentTeams(row.child_node_id); // #764
5404
5596
  auditCreateNodeStrict({
5405
5597
  action: "delete_node_completed",
@@ -5425,7 +5617,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5425
5617
  // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
5426
5618
  // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
5427
5619
  const did = status === "stopped" && row.action === "delete"
5428
- ? "revoked ntok + DELETE FROM nodes"
5620
+ ? "revoked ntok + DELETE FROM nodes + sessions"
5429
5621
  : status === "stopped" && row.action === "stop"
5430
5622
  ? "lifecycle_state=stopped"
5431
5623
  : status === "stop_failed"
@@ -6199,6 +6391,80 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6199
6391
  },
6200
6392
  );
6201
6393
 
6394
+ // OpenCode V2 catalog. Human tokens queue; the owning daemon pulls scope
6395
+ // only and acks a whitelist snapshot. pending is stored authorization, not
6396
+ // a snapshot and not proof the doorbell was received.
6397
+ server.registerTool(
6398
+ "read_opencode_catalog",
6399
+ {
6400
+ description: "Queue a native OpenCode V2 catalog read for one owned child. ok with status pending means authorized and stored in memory, not a snapshot and not proof a daemon received the doorbell. Does not start a server. url, authorization, directory, settings, headers, and body are rejected.",
6401
+ inputSchema: z.object({
6402
+ daemon_node_id: z.string().min(1).max(200),
6403
+ node_id: z.string().min(1).max(200),
6404
+ network_id: z.string().max(200).optional(),
6405
+ }).strict() as any,
6406
+ },
6407
+ async (args: { daemon_node_id: string; node_id: string; network_id?: string }) => {
6408
+ const networkId = getNetworkId(args.network_id);
6409
+ const queued = queueOpenCodeCatalogRead({
6410
+ callerUserId: enforceUserId ?? null,
6411
+ callerIsNode: callerTokenIsNetwork,
6412
+ networkId,
6413
+ daemonNodeId: args.daemon_node_id,
6414
+ nodeId: args.node_id,
6415
+ });
6416
+ if (!queued.ok) return { content: [{ type: "text" as const, text: JSON.stringify(queued) }] };
6417
+ pushEvent(queued.daemon_alias, { type: "opencode_catalog", request_id: queued.request_id }, queued.network_id);
6418
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "pending", request_id: queued.request_id }) }] };
6419
+ },
6420
+ );
6421
+
6422
+ server.registerTool(
6423
+ "get_opencode_catalog",
6424
+ {
6425
+ description: "Poll one queued OpenCode V2 catalog read. status pending is not a snapshot. Only status ready returns a whitelist snapshot, still completeness unknown and invocationVerified false.",
6426
+ inputSchema: z.object({
6427
+ request_id: z.string().min(1).max(200),
6428
+ network_id: z.string().max(200).optional(),
6429
+ }).strict() as any,
6430
+ },
6431
+ async (args: { request_id: string; network_id?: string }) => {
6432
+ const result = getOpenCodeCatalogResult({
6433
+ callerUserId: enforceUserId ?? null,
6434
+ callerIsNode: callerTokenIsNetwork,
6435
+ networkId: getNetworkId(args.network_id),
6436
+ requestId: args.request_id,
6437
+ });
6438
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
6439
+ },
6440
+ );
6441
+
6442
+ server.registerTool(
6443
+ "get_opencode_catalog_request",
6444
+ {
6445
+ description: "Daemon pulls one authorized OpenCode V2 catalog read. Scope only: no URL, authorization, settings, headers, or body.",
6446
+ inputSchema: z.object({ request_id: z.string().min(1).max(200) }).strict() as any,
6447
+ },
6448
+ async (args: { request_id: string }) => {
6449
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
6450
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
6451
+ return { content: [{ type: "text" as const, text: JSON.stringify(takeOpenCodeCatalogRequest(args.request_id, callerDaemon.daemonNodeId)) }] };
6452
+ },
6453
+ );
6454
+
6455
+ server.registerTool(
6456
+ "ack_opencode_catalog",
6457
+ {
6458
+ description: "Daemon stores a whitelist OpenCode V2 catalog snapshot or a closed error code. Extra fields are rejected. A stored failure is status failed, not a transport error.",
6459
+ inputSchema: opencodeCatalogAckSchema as any,
6460
+ },
6461
+ async (args: unknown) => {
6462
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
6463
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
6464
+ return { content: [{ type: "text" as const, text: JSON.stringify(ackOpenCodeCatalog(callerDaemon.daemonNodeId, args)) }] };
6465
+ },
6466
+ );
6467
+
6202
6468
  // ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
6203
6469
  // 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
6204
6470
  // 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。