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

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/adoption-candidates-http.test.ts +137 -0
  3. package/src/adoption-candidates.ts +158 -0
  4. package/src/agent-acl-http.test.ts +2 -1
  5. package/src/api-host-supervisors-runtime-readiness.test.ts +13 -0
  6. package/src/auth.ts +8 -7
  7. package/src/clone-node.test.ts +248 -0
  8. package/src/clone-node.ts +289 -0
  9. package/src/codex-config-flags.ts +77 -0
  10. package/src/config-apply-validate.test.ts +33 -1
  11. package/src/config-apply-validate.ts +24 -0
  12. package/src/create-node-validate.test.ts +26 -1
  13. package/src/create-node-validate.ts +45 -1
  14. package/src/create-node-workdir.test.ts +13 -0
  15. package/src/daemon-provider-snapshots.test.ts +359 -0
  16. package/src/daemon-provider-snapshots.ts +269 -0
  17. package/src/daemon-token-owner-http.test.ts +3 -1
  18. package/src/db.ts +45 -0
  19. package/src/mcp-context-cost-http.test.ts +3 -1
  20. package/src/member-presence-http.test.ts +8 -4
  21. package/src/node-lifecycle-read-http.test.ts +13 -1
  22. package/src/node-lifecycle-read.ts +6 -0
  23. package/src/node-permissions.ts +3 -1
  24. package/src/report-status-host-schema.test.ts +24 -0
  25. package/src/runtime-readiness.ts +7 -0
  26. package/src/server.ts +40 -4
  27. package/src/shared/codex-inventory.ts +34 -0
  28. package/src/shared/daemon-provider-login.ts +31 -0
  29. package/src/shared/daemon-provider-snapshot.ts +52 -0
  30. package/src/shared/provider-application.ts +29 -0
  31. package/src/tool-audience-http.test.ts +15 -2
  32. package/src/tool-audience.ts +3 -1
  33. package/src/tools.ts +233 -19
  34. package/src/user-avatar-http.test.ts +74 -0
  35. package/src/user-avatar-migration.test.ts +25 -0
@@ -0,0 +1,34 @@
1
+ // #906 key-free existing Codex inventory. Identical public contract in each package.
2
+ export function validCodexInventory(v: any): boolean {
3
+ const fields = (o: any, allowed: string[]) => o && typeof o === 'object' && !Array.isArray(o)
4
+ && Object.keys(o).every(k => allowed.includes(k));
5
+ const id = (s: any) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
6
+ const nullable = (s: any) => s === null || id(s);
7
+ if (!fields(v, ['observed_at', 'scope', 'rows', 'installation']) || !Number.isSafeInteger(v.observed_at) || v.observed_at <= 0
8
+ || v.scope !== 'hub_bound_nodes' || !Array.isArray(v.rows) || v.rows.length > 200
9
+ || new Set(v.rows.map((r: any) => r?.node_id)).size !== v.rows.length) return false;
10
+ if (v.installation !== undefined && (!fields(v.installation, ['status', 'version'])
11
+ || !['found', 'missing', 'unknown'].includes(v.installation.status)
12
+ || !(v.installation.version === null || (typeof v.installation.version === 'string' && /^\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.installation.version) && v.installation.version.length <= 64)))) return false;
13
+ for (const r of v.rows) {
14
+ if (!r || !id(r.node_id) || typeof r.alias !== 'string' || !/^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,99}$/u.test(r.alias)) return false;
15
+ if (['unavailable', 'not_codex'].includes(r.status)) {
16
+ if (!fields(r, ['node_id', 'alias', 'status'])) return false;
17
+ continue;
18
+ }
19
+ if (r.status !== 'observed' || !fields(r, ['node_id', 'alias', 'status', 'runtime', 'home_ref', 'home_source',
20
+ 'config_status', 'configured_provider', 'configured_model', 'provider_ids', 'auth_kind', 'credential_status',
21
+ 'account_fingerprint', 'verification', 'effective_state', 'node_configured_model', 'config_revision'])) return false;
22
+ if (r.config_revision !== undefined && r.config_revision !== null && (typeof r.config_revision !== 'string' || !/^[a-f0-9]{64}$/.test(r.config_revision))) return false;
23
+ if (!nullable(r.runtime) || !/^[a-f0-9]{16}$/.test(r.home_ref)
24
+ || !['host_default', 'config.codexHome', 'node-codex-home', 'config.env'].includes(r.home_source)
25
+ || !['read', 'missing', 'unreadable'].includes(r.config_status) || !nullable(r.configured_provider)
26
+ || !nullable(r.configured_model) || !nullable(r.node_configured_model)
27
+ || !Array.isArray(r.provider_ids) || r.provider_ids.length > 101 || !r.provider_ids.every(id)
28
+ || !['unknown', 'chatgpt', 'api_key'].includes(r.auth_kind)
29
+ || !['unknown', 'present', 'unreadable'].includes(r.credential_status)
30
+ || !(r.account_fingerprint === null || /^[a-f0-9]{16}$/.test(r.account_fingerprint))
31
+ || r.verification !== 'not_checked' || r.effective_state !== 'not_checked') return false;
32
+ }
33
+ return true;
34
+ }
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ // #906 public Provider snapshot contract. Mirrored across separately published
2
+ // server/agent-node packages; drift is tested. No secret-shaped extension fields.
3
+ import { validCodexInventory } from './codex-inventory.js';
4
+ export function validateDaemonProviderSnapshot(value: unknown, networkId: string, daemonId: string): unknown {
5
+ const fail = (): never => { throw new Error("invalid_provider_snapshot"); };
6
+ const obj = (v: any, fields: string[]) => {
7
+ if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !fields.includes(k))) fail();
8
+ return v;
9
+ };
10
+ const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
11
+ const unique = (xs: any[], key: string) => { if (new Set(xs.map(x => x[key])).size !== xs.length) fail(); };
12
+ const arr = (v: any, max: number, empty = false): any[] => {
13
+ if (!Array.isArray(v) || (!empty && !v.length) || v.length > max) fail();
14
+ return v;
15
+ };
16
+ const s = obj(value, ["network_id", "daemon_node_id", "revision", "source", "providers", "codex_inventory"]);
17
+ if (s.codex_inventory !== undefined && !validCodexInventory(s.codex_inventory)) fail();
18
+ if (s.network_id !== networkId || s.daemon_node_id !== daemonId || s.source !== "daemon"
19
+ || !Number.isSafeInteger(s.revision) || s.revision < 0) fail();
20
+ const providers = arr(s.providers, 100, true);
21
+ unique(providers, "id");
22
+ for (const p of providers) {
23
+ obj(p, ["id", "label", "enabled", "runtimes"]);
24
+ if (!id(p.id) || typeof p.label !== "string" || !p.label.trim() || p.label.length > 100
25
+ || /[\x00-\x1f\x7f]/.test(p.label) || typeof p.enabled !== "boolean") fail();
26
+ const runtimes = arr(p.runtimes, 8); unique(runtimes, "runtime");
27
+ for (const r of runtimes) {
28
+ obj(r, ["runtime", "auth"]); if (r.runtime !== "codex-tui") fail();
29
+ const auths = arr(r.auth, 32); unique(auths, "id");
30
+ for (const a of auths) {
31
+ obj(a, ["id", "kind", "models", "baseUrl", "account_id", "credential_present", "verification", "application"]);
32
+ if (!id(a.id) || a.verification !== "not_checked" || a.application !== "not_applied") fail();
33
+ const models = arr(a.models, 200);
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
+ if (a.kind === "chatgpt") {
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();
39
+ } else if (a.kind === "api_key") {
40
+ if (a.account_id !== undefined) fail();
41
+ if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
42
+ let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
43
+ if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail();
44
+ } else fail();
45
+ }
46
+ }
47
+ }
48
+ // JSON roundtrip detaches the validated object from mutable caller references.
49
+ const encoded = JSON.stringify(s);
50
+ if (new TextEncoder().encode(encoded).byteLength > 1024 * 1024) fail();
51
+ return JSON.parse(encoded);
52
+ }
@@ -0,0 +1,29 @@
1
+ // #906 public application protocol. No paths, endpoints or credentials.
2
+ export type ProviderApplicationSelection = {
3
+ node_id: string; node_revision: string; provider_id: string; auth_id: string;
4
+ model: string; confirm_restart: true;
5
+ account_id?: string;
6
+ };
7
+ const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
8
+ && Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
9
+ const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
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);
12
+ export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
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)))
16
+ && id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
17
+ && v.confirm_restart === true;
18
+ }
19
+ export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
20
+ selection: ProviderApplicationSelection): boolean {
21
+ return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
22
+ 'node_revision', 'provider_revision', 'verification', 'applied_at', ...(selection.account_id ? ['account_id'] : [])])
23
+ && v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
24
+ && v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
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))
27
+ && hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
28
+ && Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
29
+ }
@@ -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";
@@ -197,7 +197,20 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
197
197
  // user 58 / 56,950 B → 57,163 B (+213). start_node adds 213 B, report_status 169 B;
198
198
  // hidden pull/ack schemas account for the remaining 948 B. Keep strict receipt validation
199
199
  // and unchanged audiences; budget only this measured addition (~130/520 B node/all margin).
200
- const CEILINGS = { node: 62_500, user: 57_500, all: 79_500 } as const;
200
+ // Board #654 adds report_status adoption_candidates (node-only schema, no new tool).
201
+ // Same tree, field removed → restored: all 87 / 79,078 B → 79,591 B (+513);
202
+ // node 61 / 62,433 B → 62,946 B (+513); user 58 / 57,228 B unchanged
203
+ // (report_status is not on the user list). Budget only this measured addition
204
+ // (~154/509 B node/all margin).
205
+ // #906 registers daemon_provider_snapshot (user) plus pull/ack (protocol).
206
+ // Measured there: all 90 / 80,307 B; node 61 / 62,556 B; user 59 / 57,372 B,
207
+ // ceilings 80,800 / 63,100 / 57,500.
208
+ // Hub #938 adds clone_node (audience both, so it lands on the node list and
209
+ // the user list) plus optional expect_clone on get_create_request (protocol,
210
+ // so only the full list). Measured on this combined tree:
211
+ // all 91 / 81,228 B; node 62 / 63,281 B; user 60 / 58,169 B.
212
+ // Ceilings keep about 400 B of margin and nothing else.
213
+ const CEILINGS = { node: 63_700, user: 58_600, all: 81_700 } as const;
201
214
  test("node / user / all", async () => {
202
215
  const node = await listTools(T.node_normal);
203
216
  const user = await listTools(T.owner);
@@ -23,6 +23,8 @@ import { NODE_TOOL_CLASS, nodeIdentity, nodePermissionsFlag, broadcastVerdict, h
23
23
  export type ToolAudience = "both" | "node" | "protocol" | "user";
24
24
 
25
25
  export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
26
+ daemon_provider_snapshot: "user",
27
+ pull_daemon_provider_snapshots: "protocol", ack_daemon_provider_snapshot: "protocol",
26
28
  request_adopt_node: "user", unadopt_node: "user",
27
29
  get_adopt_request: "protocol", ack_adopt_request: "protocol",
28
30
  // ── 节点:用户令牌一律 network_token_required ──
@@ -51,7 +53,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
51
53
  send_desktop_message: "both", broadcast: "both", get_completions: "both",
52
54
  update_node_config: "both", read_node_rules_file: "both", write_node_rules_file: "both",
53
55
  list_node_skills: "both", read_node_skill: "both", get_rules_file_result: "both",
54
- restart_node: "both", list_host_supervisors: "both", create_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
56
+ restart_node: "both", list_host_supervisors: "both", create_node: "both", clone_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
55
57
  upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
56
58
  list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
57
59
  requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
package/src/tools.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
2
3
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
3
4
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
5
+ import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
4
6
  import { forkRecoveryRequestSchema, forkRecoveryResultSchema, publicForkResult } from "./codex-fork-contract.js";
5
7
  import { lockNodeAlias } from "./node-token-ownership.js";
6
8
  import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
@@ -39,6 +41,7 @@ import {
39
41
  FLAG_KEYS,
40
42
  validateFlagValue,
41
43
  validateFlagsForRuntime,
44
+ validateOpenCodeV2ProviderModel,
42
45
  ValidationError,
43
46
  } from "./create-node-validate.js";
44
47
  import {
@@ -52,6 +55,7 @@ import {
52
55
  auditCreateNodeStrict,
53
56
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
54
57
  } from "./create-node.js";
58
+ import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
55
59
  import {
56
60
  vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
57
61
  VaultError,
@@ -843,6 +847,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
843
847
  daemon_capabilities: z.object({
844
848
  codex_fork_recovery: z.object({ protocol: z.literal(1), cli_supported: z.boolean() }).optional().catch(undefined),
845
849
  adopt_capable: z.boolean().optional(),
850
+ adoption_candidates: adoptionCandidatesReportSchema,
846
851
  runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
847
852
  allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
848
853
  max_concurrent_children: z.number().int().min(1).max(1000).optional(),
@@ -3196,7 +3201,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3196
3201
  callerNetworkId: string | null,
3197
3202
  ): { row: any | null; sec1Ok: boolean } => {
3198
3203
  const row = db.get<any>(
3199
- "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
3204
+ "SELECT node_id, alias, network_id, runtime, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
3200
3205
  nodeId,
3201
3206
  );
3202
3207
  if (!row) return { row: null, sec1Ok: false };
@@ -3326,7 +3331,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3326
3331
  };
3327
3332
  }
3328
3333
 
3329
- const validationFail = validatePatch(model, flags, channels);
3334
+ const validationFail = validatePatch(model, flags, channels, node.runtime);
3330
3335
  if (validationFail) {
3331
3336
  return {
3332
3337
  content: [{
@@ -4113,6 +4118,35 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4113
4118
  const resolveCallerDaemonTokenBound = () =>
4114
4119
  _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
4115
4120
 
4121
+ const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
4122
+ const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
4123
+ server.tool(
4124
+ "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.",
4125
+ { 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() },
4126
+ async ({ action, id, network_id, revision, provider }) => {
4127
+ const human = providerHuman(network_id);
4128
+ if (action === "read") return providerReply(getProviderSnapshot(human, id));
4129
+ const daemon_node_id = id;
4130
+ const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
4131
+ : action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
4132
+ : action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
4133
+ if (result.ok) {
4134
+ const target = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", daemon_node_id, human.networkId!);
4135
+ if (target) pushEvent(target.alias, { type: "daemon_provider_snapshot", request_id: result.request_id }, human.networkId!);
4136
+ }
4137
+ return providerReply(result);
4138
+ },
4139
+ );
4140
+ server.tool(
4141
+ "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() },
4142
+ async ({ accept_writes, accept_applications, accept_logins }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins)),
4143
+ );
4144
+ server.tool(
4145
+ "ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
4146
+ { 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() },
4147
+ async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
4148
+ );
4149
+
4116
4150
  // Helper — map a ValidationError thrown from create-node-validate
4117
4151
  // into the MCP-tool-call JSON reply shape.
4118
4152
  const validationFailReply = (e: unknown) => {
@@ -4132,7 +4166,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4132
4166
  // join on api_tokens.revoked_at.
4133
4167
  server.tool(
4134
4168
  "list_host_supervisors",
4135
- "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
4169
+ "List network daemons: status, runtimes, secret-key allowlist and telemetry (member-redacted).",
4136
4170
  {
4137
4171
  network_id: z.string().max(200).optional(),
4138
4172
  },
@@ -4268,7 +4302,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4268
4302
  // row (metadata only — no env_blob in SQL), pushEvent doorbell.
4269
4303
  server.tool(
4270
4304
  "create_node",
4271
- "Create a node on a host-daemon and start it. Daemon forks `anet node create + start` on the target machine; child reports back to hub. RFC-026.",
4305
+ "Create/start a node on the target daemon; the child reports back to Hub.",
4272
4306
  {
4273
4307
  daemon_node_id: z.string().min(1).max(200),
4274
4308
  node_spec: z.object({
@@ -4438,6 +4472,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4438
4472
  validateFlagValue(k, v);
4439
4473
  }
4440
4474
  validateFlagsForRuntime(node_spec.runtime, node_spec.flags);
4475
+ validateOpenCodeV2ProviderModel(node_spec.runtime, node_spec.model, node_spec.flags);
4441
4476
  } catch (e) {
4442
4477
  return validationFailReply(e);
4443
4478
  }
@@ -4577,12 +4612,162 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4577
4612
  },
4578
4613
  );
4579
4614
 
4615
+ // Hub #938 — clone a child that already lives on a daemon into a new
4616
+ // node on THAT daemon. New alias, new ntok_. Session copy is done by
4617
+ // the daemon from the source config on disk; this tool only records
4618
+ // the policy and the runtime's session capability (see clone-node.ts).
4619
+ server.tool(
4620
+ "clone_node",
4621
+ "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.",
4622
+ {
4623
+ source_node_id: z.string().min(1).max(200),
4624
+ name: z.string().min(1).max(256),
4625
+ copy_session: z.boolean().optional(),
4626
+ workdir_policy: z.enum(["new_empty", "share_source"]).optional(),
4627
+ daemon_node_id: z.string().min(1).max(200).optional(),
4628
+ network_id: z.string().max(200).optional(),
4629
+ },
4630
+ async ({ source_node_id, name, copy_session, workdir_policy, daemon_node_id, network_id: clientNetId }) => {
4631
+ const effectiveNetId = getNetworkId(clientNetId);
4632
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4633
+ const callerRole = enforceUserId && effectiveNetId
4634
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4635
+ : null;
4636
+ if (callerRole !== "admin" && callerRole !== "owner") {
4637
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
4638
+ }
4639
+ const { row: source, sec1Ok } = resolveTargetNode(source_node_id, effectiveNetId);
4640
+ if (!source) {
4641
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: source_node_id }) }] };
4642
+ }
4643
+ if (!sec1Ok) {
4644
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
4645
+ }
4646
+ const createRow = db.get<{ daemon_node_id: string; runtime: string; model: string | null; flags_json: string; child_name: string }>(
4647
+ `SELECT daemon_node_id, runtime, model, flags_json, child_name
4648
+ FROM node_create_requests
4649
+ WHERE child_node_id = ?1 AND network_id = ?2 AND status = 'succeeded'
4650
+ ORDER BY created_at DESC LIMIT 1`,
4651
+ source_node_id, source.network_id || effectiveNetId || "default",
4652
+ );
4653
+ const net = source.network_id || effectiveNetId || "default";
4654
+ const nameRows = db.all<{ alias: string | null; node_name: string | null }>(
4655
+ `SELECT alias, node_name FROM nodes WHERE network_id = ?1`,
4656
+ net,
4657
+ );
4658
+ const existingNames = nameRows.flatMap((r) => [r.alias, r.node_name].filter((x): x is string => !!x));
4659
+ const owning = createRow?.daemon_node_id ?? "";
4660
+ const inflight = owning
4661
+ ? db.all<{ child_name: string }>(
4662
+ `SELECT child_name FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered')`,
4663
+ owning,
4664
+ ).map((r) => r.child_name)
4665
+ : [];
4666
+ let policy: ReturnType<typeof parseWorkdirPolicy>;
4667
+ try { policy = parseWorkdirPolicy(workdir_policy); }
4668
+ catch (e) { return validationFailReply(e); }
4669
+ const facts = factsFromRows({
4670
+ node: source,
4671
+ createRow: createRow ?? null,
4672
+ existingNames,
4673
+ inflightNames: inflight,
4674
+ });
4675
+ let decision;
4676
+ try {
4677
+ decision = decideClone(facts, {
4678
+ newName: name,
4679
+ copySession: copy_session !== false,
4680
+ workdirPolicy: policy,
4681
+ requestedDaemonId: daemon_node_id ?? null,
4682
+ });
4683
+ } catch (e) {
4684
+ return validationFailReply(e);
4685
+ }
4686
+ if (!decision.ok) {
4687
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: decision.error, ...decision.detail }) }] };
4688
+ }
4689
+ const { row: daemon, sec1Ok: daemonOk } = resolveTargetNode(decision.daemonNodeId, effectiveNetId);
4690
+ if (!daemon || !daemonOk) {
4691
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id: decision.daemonNodeId }) }] };
4692
+ }
4693
+ if (!enforceUserId) {
4694
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
4695
+ }
4696
+ const childToken = generateNetworkToken();
4697
+ const childTokenId = generateId("tok");
4698
+ const requestId = newRequestId();
4699
+ const networkIdForChild = daemon.network_id || net;
4700
+ const cloneMeta = {
4701
+ source_node_id,
4702
+ source_alias: facts.alias,
4703
+ copy_session: decision.copySession,
4704
+ workdir_policy: decision.workdirPolicy,
4705
+ channels: decision.channels,
4706
+ };
4707
+ db.run(
4708
+ `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)`,
4709
+ [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${decision.name}`, "network", "child", requestId],
4710
+ );
4711
+ putPendingEnvBlob({
4712
+ request_id: requestId,
4713
+ daemon_node_id: decision.daemonNodeId,
4714
+ env_blob: {},
4715
+ child_token: childToken,
4716
+ child_token_id: childTokenId,
4717
+ });
4718
+ db.run(
4719
+ `INSERT INTO node_create_requests
4720
+ (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)
4721
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '[]', 'pending', ?8, ?9, ?10, ?11)`,
4722
+ [
4723
+ requestId, decision.daemonNodeId, decision.name, networkIdForChild,
4724
+ decision.runtime, decision.model, JSON.stringify(decision.flags),
4725
+ childTokenId, Date.now(), callerTokenId || "unknown", JSON.stringify(cloneMeta),
4726
+ ],
4727
+ );
4728
+ pushEvent(daemon.alias, { type: "clone_node", request_id: requestId }, networkIdForChild);
4729
+ auditCreateNode({
4730
+ action: "clone_node_dispatch_attempted",
4731
+ user_id: enforceUserId,
4732
+ network_id: networkIdForChild,
4733
+ target_id: requestId,
4734
+ detail: {
4735
+ daemon_node_id: decision.daemonNodeId,
4736
+ source_node_id,
4737
+ child_name: decision.name,
4738
+ runtime: decision.runtime,
4739
+ copy_session: decision.copySession,
4740
+ workdir_policy: decision.workdirPolicy,
4741
+ session_mode: decision.sessionContract.mode,
4742
+ },
4743
+ });
4744
+ return {
4745
+ content: [{ type: "text" as const, text: JSON.stringify({
4746
+ ok: true,
4747
+ request_id: requestId,
4748
+ daemon_node_id: decision.daemonNodeId,
4749
+ child_name: decision.name,
4750
+ copy_session: decision.copySession,
4751
+ workdir_policy: decision.workdirPolicy,
4752
+ session_contract: decision.sessionContract,
4753
+ contract: {
4754
+ token: "new ntok_ minted for the clone; the source token is not copied",
4755
+ workdir: decision.workdirPolicy === "new_empty"
4756
+ ? "new empty directory under the source workdir's parent (or <daemon cwd>/clone-work when the source has no project workdir)"
4757
+ : "share the source workdir (explicit)",
4758
+ session: decision.sessionContract.detail,
4759
+ },
4760
+ }) }],
4761
+ };
4762
+ },
4763
+ );
4764
+
4580
4765
  // #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
4581
4766
  // intentionally best-effort; after a daemon connects or reconnects, it pulls
4582
4767
  // any still-pending requests bound to its own daemon token.
4583
4768
  server.tool(
4584
4769
  "list_my_pending_create_requests",
4585
- "Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
4770
+ "Daemon pulls its pending creates after reconnect.",
4586
4771
  {},
4587
4772
  async () => {
4588
4773
  const callerDaemon = resolveCallerDaemonTokenBound();
@@ -4595,10 +4780,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4595
4780
  WHERE daemon_node_id = ?1
4596
4781
  AND network_id = ?2
4597
4782
  AND status = 'pending'
4783
+ AND COALESCE(clone_json, '') = ''
4598
4784
  ORDER BY created_at ASC`,
4599
4785
  callerDaemon.daemonNodeId, callerDaemon.networkId,
4600
4786
  );
4601
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
4787
+ // Hub #938 — clone rows stay out of `requests` so an older daemon
4788
+ // that replays every pending create does not spawn a blank node.
4789
+ const clone_requests = db.all<{ request_id: string; child_name: string; created_at: number }>(
4790
+ `SELECT request_id, child_name, created_at
4791
+ FROM node_create_requests
4792
+ WHERE daemon_node_id = ?1
4793
+ AND network_id = ?2
4794
+ AND status = 'pending'
4795
+ AND COALESCE(clone_json, '') != ''
4796
+ ORDER BY created_at ASC`,
4797
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4798
+ );
4799
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4800
+ ok: true,
4801
+ count: rows.length,
4802
+ requests: rows,
4803
+ clone_count: clone_requests.length,
4804
+ clone_requests,
4805
+ }) }] };
4602
4806
  },
4603
4807
  );
4604
4808
 
@@ -4614,7 +4818,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4614
4818
  // 只返回绑定到本 daemon + 本网络的 pending 行。
4615
4819
  server.tool(
4616
4820
  "list_my_pending_lifecycle_requests",
4617
- "Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
4821
+ "Daemon pulls its pending stop/delete/start requests after reconnect.",
4618
4822
  {},
4619
4823
  async () => {
4620
4824
  const callerDaemon = resolveCallerDaemonTokenBound();
@@ -4666,21 +4870,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4666
4870
  // isn't a host_supervisor, reject.
4667
4871
  server.tool(
4668
4872
  "get_create_request",
4669
- "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
4873
+ "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026. expect_clone pulls a clone_node row.",
4670
4874
  {
4671
4875
  request_id: z.string().min(1).max(200),
4876
+ expect_clone: z.boolean().optional(),
4672
4877
  },
4673
- async ({ request_id }) => {
4878
+ async ({ request_id, expect_clone }) => {
4674
4879
  const callerDaemon = resolveCallerDaemonTokenBound();
4675
4880
  if (!callerDaemon.ok) {
4676
4881
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4677
4882
  }
4678
4883
 
4679
- const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
4680
- `SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
4884
+ 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 }>(
4885
+ `SELECT request_id, daemon_node_id, status, child_token_id, network_id, clone_json FROM node_create_requests WHERE request_id = ?1`,
4681
4886
  request_id,
4682
4887
  );
4683
4888
  if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4889
+ const isClone = typeof row.clone_json === "string" && row.clone_json.trim().length > 0;
4890
+ // Do not consume the env blob or flip status when the wrong handler asks.
4891
+ if (isClone && expect_clone !== true) {
4892
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "clone_request" }) }] };
4893
+ }
4894
+ if (!isClone && expect_clone === true) {
4895
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_a_clone" }) }] };
4896
+ }
4684
4897
  // §4.1.4 — strict daemon binding by token-derived node_id.
4685
4898
  if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4686
4899
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
@@ -4730,6 +4943,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4730
4943
  },
4731
4944
  child_token: blob.child_token,
4732
4945
  env_blob: blob.env_blob,
4946
+ ...(isClone ? { clone: JSON.parse(row.clone_json as string) } : {}),
4733
4947
  }) }],
4734
4948
  };
4735
4949
  },
@@ -5178,7 +5392,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5178
5392
 
5179
5393
  server.tool(
5180
5394
  "stop_node",
5181
- "Stop the agent-node child process; keep config dir intact. Reversible via restart_node. RFC-027 §2.2. daemon_node_id is auto-resolved from child_node_id when omitted (looked up in node_create_requests).",
5395
+ "Stop child, preserving config. Restart via restart_node. Omitted daemon_node_id resolves from child's create request.",
5182
5396
  {
5183
5397
  ...NODE_ID_ALIAS_FIELDS,
5184
5398
  // PR2 prereq: dashboard rarely knows daemon_node_id directly.
@@ -5407,7 +5621,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5407
5621
  // consumer of its own, so restart_node's self-doorbell cannot revive it.
5408
5622
  server.tool(
5409
5623
  "start_node",
5410
- "Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
5624
+ "Start stopped child; omitted daemon_node_id resolves from its create request.",
5411
5625
  {
5412
5626
  ...NODE_ID_ALIAS_FIELDS,
5413
5627
  daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
@@ -5629,7 +5843,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5629
5843
  // token + network.
5630
5844
  server.tool(
5631
5845
  "list_my_children",
5632
- "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
5846
+ "Daemon lists child alias/node_id pairs to rebuild its map.",
5633
5847
  {},
5634
5848
  async () => {
5635
5849
  const callerDaemon = resolveCallerDaemonTokenBound(); // board #674: token-bound snapshot, not a lease
@@ -5675,7 +5889,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5675
5889
  const adoptionReply = (result: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(result) }] });
5676
5890
  server.tool(
5677
5891
  "request_adopt_node",
5678
- "Human owner/admin requests adoption. Only the target capable daemon may activate the binding after local verification; no process is signalled.",
5892
+ "Owner/admin requests adoption; target daemon verifies locally before binding. No signals.",
5679
5893
  { node_id: z.string().min(1).max(200), daemon_node_id: z.string().min(1).max(200), workdir: z.string().min(1).max(4096) },
5680
5894
  async args => adoptionReply(requestAdopt(adoptionHuman(), args)),
5681
5895
  );
@@ -5984,7 +6198,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5984
6198
  // §2.3.3 — list_providers (viewer+; never returns secret VALUES).
5985
6199
  server.tool(
5986
6200
  "list_providers",
5987
- "List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
6201
+ "List network Provider/model registry, never secrets. Not daemon inventory.",
5988
6202
  { network_id: z.string().max(200).optional() },
5989
6203
  async ({ network_id: clientNetId }) => {
5990
6204
  const effectiveNetId = getNetworkId(clientNetId);
@@ -6011,7 +6225,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6011
6225
  // §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
6012
6226
  server.tool(
6013
6227
  "probe_provider_model",
6014
- "Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
6228
+ "Admin+ dispatches daemon probe; ephemeral secret pulled via get_probe_request.",
6015
6229
  {
6016
6230
  provider_id: z.string().min(1).max(200),
6017
6231
  model_name: z.string().min(1).max(100),
@@ -6077,7 +6291,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6077
6291
  // §2.3.6 — get_probe_results (viewer+; matrix renderer source).
6078
6292
  server.tool(
6079
6293
  "get_probe_results",
6080
- "Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
6294
+ "Query probe history filtered by provider/model/daemon.",
6081
6295
  {
6082
6296
  provider_id: z.string().max(200).optional(),
6083
6297
  model_name: z.string().max(100).optional(),
@@ -6105,7 +6319,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6105
6319
  // §2.3.7 daemon-facing — get_probe_request.
6106
6320
  server.tool(
6107
6321
  "get_probe_request",
6108
- "Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
6322
+ "Daemon pulls pending probe on SSE probe_provider.",
6109
6323
  { probe_id: z.string().min(1).max(200) },
6110
6324
  async ({ probe_id }) => {
6111
6325
  const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });