@sleep2agi/commhub-server 0.9.0-preview.125 → 0.9.0-preview.127

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.125",
3
+ "version": "0.9.0-preview.127",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,79 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import "./require-explicit-test-db.js";
3
+ import { db } from "./db.js";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { registerTools } from "./tools.js";
6
+
7
+ // #1027: get_create_request must return network_id so the daemon can write
8
+ // it into the child config. Without it, child nodes fail the OpenCode
9
+ // runtime binding check and die within 5s.
10
+
11
+ const NET = "net_1027_hub";
12
+ const USER_ID = "u_1027_hub";
13
+ const DAEMON_ID = "node_1027_hub_daemon";
14
+ const DAEMON_ALIAS = "hub1027-daemon";
15
+ const DAEMON_TOK = "tok_1027_hub_daemon";
16
+
17
+ interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
18
+ interface Reply { ok?: boolean; error?: string; request_id?: string; network_id?: string; node_spec?: any; [k: string]: unknown; }
19
+
20
+ function cleanup() {
21
+ for (const t of ["node_create_requests", "audit_log", "nodes", "sessions", "api_tokens", "network_members"]) {
22
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
23
+ }
24
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
25
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
26
+ }
27
+ beforeEach(cleanup);
28
+ afterAll(cleanup);
29
+
30
+ function seed() {
31
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))`, [USER_ID]);
32
+ db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, USER_ID]);
33
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'admin', datetime('now'))`, [USER_ID, NET]);
34
+ db.run(
35
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
36
+ VALUES (?1, ?2, ?2, ?3, ?4, 'host-x', datetime('now'), datetime('now'), 'active')`,
37
+ [DAEMON_ID, DAEMON_ALIAS, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["opencode-cli"] } })],
38
+ );
39
+ db.run(
40
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
41
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
42
+ [DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
43
+ );
44
+ }
45
+
46
+ function tools(callerTokenId: string | null = null): Record<string, ToolHandler> {
47
+ const server = new McpServer({ name: "test", version: "0" }) as any;
48
+ const out: Record<string, ToolHandler> = {};
49
+ const origTool = server.tool.bind(server);
50
+ server.tool = (name: string, d: string, schema: any, handler: ToolHandler) => { out[name] = handler; return origTool(name, d, schema, handler); };
51
+ const origRegisterTool = server.registerTool?.bind(server);
52
+ if (origRegisterTool) {
53
+ server.registerTool = (name: string, cfg: any, handler: ToolHandler) => { out[name] = handler; return origRegisterTool(name, cfg, handler); };
54
+ }
55
+ registerTools(server, undefined, NET, USER_ID, null, true, callerTokenId);
56
+ return out;
57
+ }
58
+
59
+ async function call(h: ToolHandler, args: any): Promise<Reply> {
60
+ return JSON.parse((await h(args)).content[0].text) as Reply;
61
+ }
62
+
63
+ describe("#1027 get_create_request returns network_id", () => {
64
+ test("network_id is included in the response", async () => {
65
+ seed();
66
+ const t = tools(DAEMON_TOK);
67
+ const r = await call(t.create_node, {
68
+ daemon_node_id: DAEMON_ID,
69
+ node_spec: { name: "child-1027", runtime: "opencode-cli", model: null, flags: {} },
70
+ });
71
+ expect(r.ok).toBe(true);
72
+ expect(r.request_id).toMatch(/^cr_/);
73
+
74
+ const g = await call(t.get_create_request, { request_id: r.request_id });
75
+ expect(g.ok).toBe(true);
76
+ expect(g.network_id).toBe(NET);
77
+ expect(g.node_spec?.name).toBe("child-1027");
78
+ });
79
+ });
@@ -7,7 +7,7 @@ import { db } from './db';
7
7
  import { registerTools } from './tools';
8
8
  import { vaultGet } from './vault';
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
- import { reconcileProviderSnapshots } from '../../agent-node/src/runtime/daemon-provider-snapshot';
10
+ import { reconcileProviderSnapshots, secureProviderTransport } from '../../agent-node/src/runtime/daemon-provider-snapshot';
11
11
  import { DaemonProviderStore } from '../../agent-network/src/daemon-provider-store';
12
12
  const net = 'net_hub907', user = 'u_hub907', daemonId = 'n_hub907', alias = 'daemon907', token = 'tok_hub907';
13
13
  const root = mkdtempSync(join(tmpdir(), 'hub907-')), key = 'TEST-ONLY-shared-provider-key';
@@ -39,6 +39,29 @@ function client(kind = 'human') {
39
39
  const provider = () => ({ id: 'shared', label: 'Shared model', enabled: true, protocol: 'openai-responses', baseUrl: 'https://provider.example/v1', models: ['fixture-model'], key });
40
40
  const save = (human: ReturnType<typeof client>, value = provider(), revision = 0) => human('daemon_provider_snapshot', { action: 'hub_put', id: value.id, network_id: net, revision, provider: value });
41
41
  const bind = (human: ReturnType<typeof client>, target = daemonId, revision = 1) => human('daemon_provider_snapshot', { action: 'hub_bind', network_id: net, id: 'shared', revision, provider: { daemon_node_id: target, daemon_revision: 0, confirm: true } });
42
+ test('HTTP Hub and API endpoint support save, explicit bind and exact private-store readback', async () => {
43
+ expect(secureProviderTransport('http://hub907.example.test')).toBe(true);
44
+ expect(secureProviderTransport('ftp://hub907.example.test')).toBe(false);
45
+ expect(secureProviderTransport('http://user:password@hub907.example.test')).toBe(false);
46
+ const human = client(), daemon = client('daemon');
47
+ const config = { ...provider(), baseUrl: 'http://provider.example/v1' };
48
+ const saved = await save(human, config); expect(saved.ok).toBe(true);
49
+ expect(saved.provider.baseUrl).toBe(config.baseUrl);
50
+ const queued = await bind(human); expect(queued.ok).toBe(true);
51
+ await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId,
52
+ serverUrl: 'http://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
53
+ const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
54
+ expect(receipt.status).toBe('succeeded');
55
+ expect(receipt.snapshot.providers[0].runtimes[0].auth[0].baseUrl).toBe(config.baseUrl);
56
+ expect(JSON.stringify([saved, queued, receipt])).not.toContain(key);
57
+ const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
58
+ await store.withApiKey({ revision: 1, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async actual => {
59
+ expect(actual.baseUrl).toBe(config.baseUrl); expect(actual.key).toBe(key);
60
+ }); // Exact synthetic key in local callback, no external model request.
61
+ for (const baseUrl of ['ftp://provider.example/v1', 'http://user:pass@provider.example/v1', 'http://provider.example/v1?key=secret']) {
62
+ expect(await save(human, { ...provider(), id: 'invalid', baseUrl })).toMatchObject({ ok: false, error: 'invalid_hub_provider' });
63
+ }
64
+ });
42
65
  test('existing names-only vault key → Provider reference → actual daemon store, without re-entering key', async () => {
43
66
  const human = client(), daemon = client('daemon');
44
67
  expect(await human('upsert_network_secret', { network_id: net, key: 'SHARED_API_KEY', value: key })).toMatchObject({ ok: true });
@@ -24,7 +24,7 @@ function validConfig(value: any): value is Config {
24
24
  || !Array.isArray(value.models) || !value.models.length || value.models.length > 200
25
25
  || new Set(value.models).size !== value.models.length
26
26
  || value.models.some((m: unknown) => typeof m !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(m))) return false;
27
- try { const u = new URL(value.baseUrl); return u.protocol === 'https:' && !u.username && !u.password && !u.search && !u.hash; }
27
+ try { const u = new URL(value.baseUrl); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password && !u.search && !u.hash; }
28
28
  catch { return false; }
29
29
  }
30
30
  type Row = { revision: number; config_json: string; secret_key_ref: string; updated_at: number };
@@ -0,0 +1,80 @@
1
+ // Short-lived management lane, not task/message text and not a secret archive.
2
+ import { randomUUID } from 'node:crypto';
3
+ import { db } from './db';
4
+ import { getUserNetworkRole } from './auth';
5
+ import { providerScanTargets } from './daemon-provider-snapshots';
6
+ import { validScopedSecretKey } from './shared/scoped-secret-contract';
7
+ type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
8
+ type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
9
+ type Request = { request_id: string; network_id: string; daemon_node_id: string; node_id: string | null;
10
+ action: 'list' | 'put' | 'delete'; key?: string; value?: string; revision?: number;
11
+ status: 'pending' | 'succeeded' | 'failed' | 'expired'; expires_at: number; snapshot?: any; error?: string };
12
+ const requests = new Map<string, Request>();
13
+ const denied = (error: string) => ({ ok: false as const, error });
14
+ function authorize(h: Human) {
15
+ return h.isNode || !h.userId ? denied('user_token_required') : !h.networkId
16
+ || !['owner','admin'].includes(getUserNetworkRole(h.userId, h.networkId) ?? '') ? denied('secret_admin_required') : null;
17
+ }
18
+ function daemonSupported(network: string, daemon: string) {
19
+ const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, daemon);
20
+ try { const s = JSON.parse(row?.config_snapshot ?? '{}'); return s.role === 'host_supervisor' && s.scoped_secrets_capable === true; } catch { return false; }
21
+ }
22
+ function expire() {
23
+ for (const [id,r] of requests) {
24
+ if (r.expires_at < Date.now() && r.status === 'pending') { r.status = 'expired'; delete r.value; }
25
+ if (r.expires_at + 600_000 < Date.now()) requests.delete(id);
26
+ }
27
+ }
28
+ const timer = setInterval(expire, 10_000); timer.unref();
29
+ export function requestScopedSecrets(h: Human, daemon: string, node: string | null, action: Request['action'], key?: string, value?: string, revision?: number) {
30
+ const refusal = authorize(h); if (refusal) return refusal;
31
+ if (!daemonSupported(h.networkId!, daemon)) return denied('scoped_secrets_upgrade_required');
32
+ if (node !== null && !providerScanTargets(h.networkId!, daemon).some(t => t.node_id === node)) return denied('secret_target_not_bound');
33
+ if (action !== 'list' && (!validScopedSecretKey(key)
34
+ || !Number.isSafeInteger(revision) || revision! < 0 || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
35
+ expire();
36
+ if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === daemon && r.status === 'pending')) return denied('secret_store_busy');
37
+ if (requests.size >= 1000) return denied('secret_queue_busy');
38
+ const r: Request = { request_id: `dsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: daemon,
39
+ node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
40
+ expires_at: Date.now() + 120_000, status: 'pending' };
41
+ requests.set(r.request_id, r);
42
+ return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
43
+ }
44
+ export function getScopedSecrets(h: Human, id: string) {
45
+ const refusal = authorize(h); if (refusal) return refusal; expire();
46
+ const r = requests.get(id);
47
+ if (!r || r.network_id !== h.networkId) return denied('request_not_found');
48
+ return { ok: true as const, request_id: id, daemon_node_id: r.daemon_node_id, node_id: r.node_id,
49
+ status: r.status, ...(r.snapshot ? { snapshot: r.snapshot } : {}), ...(r.error ? { error: r.error } : {}) };
50
+ }
51
+ export function pullScopedSecrets(d: Daemon) {
52
+ if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
53
+ const targets = providerScanTargets(d.networkId, d.daemonNodeId);
54
+ return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === d.daemonNodeId && r.status === 'pending')
55
+ .flatMap(r => {
56
+ if (r.node_id !== null && !targets.some(t => t.node_id === r.node_id)) { r.status = 'failed'; r.error = 'secret_target_not_bound'; delete r.value; return []; }
57
+ const { status, snapshot, error, ...input } = r;
58
+ return [{ ...input, targets }];
59
+ }) };
60
+ }
61
+ export function ackScopedSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
62
+ if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
63
+ const r = requests.get(id);
64
+ if (!r || r.network_id !== d.networkId || r.daemon_node_id !== d.daemonNodeId) return denied('request_not_found');
65
+ if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
66
+ if (!failed) {
67
+ if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
68
+ || snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== r.daemon_node_id || snapshot.node_id !== r.node_id
69
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
70
+ || !Array.isArray(snapshot.keys) || snapshot.keys.length > 256 || new Set(snapshot.keys.map((x:any)=>x.key)).size !== snapshot.keys.length
71
+ || snapshot.keys.some((x:any) => !x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key)
72
+ || !['daemon','node'].includes(x.source) || (r.node_id === null && x.source !== 'daemon'))
73
+ || (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
74
+ || (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key && x.source === (r.node_id ? 'node' : 'daemon')))) return denied('invalid_secret_receipt');
75
+ if (r.node_id !== null && !providerScanTargets(r.network_id,r.daemon_node_id).some(t=>t.node_id === r.node_id)) return denied('secret_target_not_bound');
76
+ r.snapshot = snapshot;
77
+ }
78
+ r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
79
+ return { ok: true, status: r.status };
80
+ }
@@ -40,7 +40,7 @@ export function validateDaemonProviderSnapshot(value: unknown, networkId: string
40
40
  if (a.account_id !== undefined) fail();
41
41
  if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
42
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();
43
+ if (!["https:", "http:"].includes(u.protocol) || u.username || u.password || u.search || u.hash) fail();
44
44
  } else fail();
45
45
  }
46
46
  }
@@ -0,0 +1,5 @@
1
+ import { isReservedEnvKey } from './reserved-env';
2
+ export const validScopedSecretKey = (key: unknown): key is string => typeof key === 'string'
3
+ && /^[A-Z_][A-Z0-9_]{0,127}$/.test(key) && !isReservedEnvKey(key)
4
+ && !['CODEX_HOME','CLAUDE_CONFIG_DIR','XDG_CONFIG_HOME','XDG_DATA_HOME','TMUX','TMUX_PANE','TEMP','TMP'].includes(key)
5
+ && !['COMMHUB_','ANET_','CLAUDE_CODE_'].some(p => key.startsWith(p));
package/src/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
3
3
  import { readHubProviders, putHubProvider, bindHubProvider } from './hub-provider-config.js';
4
+ import { requestScopedSecrets, getScopedSecrets, pullScopedSecrets, ackScopedSecrets } from './scoped-secrets.js';
4
5
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
5
6
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
6
7
  import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
@@ -841,6 +842,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
841
842
  // discovery (#337 extracts this field). "host_supervisor" =
842
843
  // anet daemon. Default-stripping zod would drop this otherwise.
843
844
  role: z.string().max(64).optional().nullable(),
845
+ scoped_secrets_capable: z.boolean().optional(),
844
846
  // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
845
847
  // `daemon_capabilities` (canonical shape per existing hub reads
846
848
  // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
@@ -4129,10 +4131,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4129
4131
  const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
4130
4132
  const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
4131
4133
  server.tool(
4132
- "daemon_provider_snapshot", "Provider: daemon refresh/put/collect/apply/login; read=request id. login provider={action:start|read|cancel|save|accounts,session_id?,label?}. hub_read=catalog; hub_put=owner config; hub_bind=id+revision,provider={daemon_node_id,daemon_revision,confirm:true}. No login tokens.",
4133
- { action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind"]), 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() },
4134
+ "daemon_provider_snapshot", "Daemon Provider actions; read=id. Login provider={action,session_id?,label?}, no tokens. hub_*=owner catalog/distribute. secret_*=env persistence, provider={node_id?,key?,value?}; next full start.",
4135
+ { action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind", "secret_list", "secret_put", "secret_delete", "secret_read"]), 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() },
4134
4136
  async ({ action, id, network_id, revision, provider, include_discovery }) => {
4135
4137
  const human = providerHuman(network_id);
4138
+ // Separate scoped-secret contract using the existing management lane.
4139
+ // No additional tools in model context; ordinary Provider actions unchanged.
4140
+ if (action === 'secret_read') return providerReply(getScopedSecrets(human, id));
4141
+ if (action === 'secret_list' || action === 'secret_put' || action === 'secret_delete') {
4142
+ const input = provider as { node_id?: string | null; key?: string; value?: string } | undefined;
4143
+ const result = requestScopedSecrets(human, id, input?.node_id ?? null, action === 'secret_list' ? 'list' : action === 'secret_put' ? 'put' : 'delete', input?.key, input?.value, revision);
4144
+ if (result.ok) {
4145
+ const target = db.get<{ alias: string }>('SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2', id, human.networkId!);
4146
+ if (target) pushEvent(target.alias, { type: 'scoped_secrets', request_id: result.request_id }, human.networkId!);
4147
+ }
4148
+ return providerReply(result);
4149
+ }
4136
4150
  if (action === 'hub_read') return providerReply(readHubProviders(human, (provider as any)?.include_secret_refs === true));
4137
4151
  if (action === 'hub_put') return providerReply(putHubProvider(human, revision!, provider));
4138
4152
  if (action === 'hub_bind') {
@@ -4158,13 +4172,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4158
4172
  },
4159
4173
  );
4160
4174
  server.tool(
4161
- "pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/collect/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional(), accept_collections: z.boolean().optional() },
4162
- async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections)),
4175
+ "pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/collect/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional(), accept_collections: z.boolean().optional(), accept_secrets: z.boolean().optional() },
4176
+ async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections, accept_secrets }) => {
4177
+ const caller = resolveCallerDaemonTokenBound();
4178
+ const result = pullProviderSnapshots(caller, accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections);
4179
+ return providerReply(result.ok && accept_secrets ? { ...result, secret_requests: pullScopedSecrets(caller) } : result);
4180
+ },
4163
4181
  );
4164
4182
  server.tool(
4165
4183
  "ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
4166
4184
  { 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() },
4167
- async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
4185
+ async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(request_id.startsWith('dsk_')
4186
+ ? ackScopedSecrets(resolveCallerDaemonTokenBound(), request_id, snapshot, failed)
4187
+ : ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
4168
4188
  );
4169
4189
 
4170
4190
  // Helper — map a ValidationError thrown from create-node-validate
@@ -4952,6 +4972,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4952
4972
  content: [{ type: "text" as const, text: JSON.stringify({
4953
4973
  ok: true,
4954
4974
  request_id,
4975
+ // #1027: daemon needs network_id to write into child config.
4976
+ network_id: row.network_id,
4955
4977
  node_spec: {
4956
4978
  name: specRow.child_name,
4957
4979
  runtime: specRow.runtime,