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

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.127",
3
+ "version": "0.9.0-preview.129",
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,49 @@
1
+ import { db } from "./db.js";
2
+ import { getUserNetworkRole } from "./auth.js";
3
+ import { resolveNodeCaller } from "./create-node.js";
4
+ import { resolveCanonicalAlias } from "./rename.js";
5
+
6
+ type InboxCaller = {
7
+ tokenId?: string | null;
8
+ userId?: string | null;
9
+ networkId?: string | null;
10
+ nodeToken: boolean;
11
+ identityRejected: boolean;
12
+ };
13
+
14
+ /** Inbox reads and ACKs act as the recipient, not as a dispatch target. */
15
+ export function resolveInboxCaller(alias: string, caller: InboxCaller):
16
+ { ok: true; alias: string } | { ok: false; error: string } {
17
+ const denied = { ok: false as const, error: "inbox_identity_mismatch" };
18
+ if (caller.identityRejected) return denied;
19
+ const requested = alias.trim();
20
+ if (caller.nodeToken) {
21
+ const node = resolveNodeCaller(caller.tokenId);
22
+ if (node.ok) {
23
+ if (node.networkId !== caller.networkId) return denied;
24
+ const canonical = resolveCanonicalAlias(node.networkId, requested).alias;
25
+ return canonical === node.alias ? { ok: true, alias: node.alias } : denied;
26
+ }
27
+ // Registration tokens are network-scoped human tokens, not node tokens.
28
+ // Missing/foreign/ambiguous node identities never fall back to the issuer.
29
+ if (node.reason !== "not_a_node_token" || !caller.tokenId) return denied;
30
+ const token = db.get<{ name: string | null }>(
31
+ "SELECT name FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL", caller.tokenId,
32
+ );
33
+ if (!token || token.name?.startsWith("node:")) return denied;
34
+ }
35
+ // Preserve open-development and legacy global-token mode.
36
+ if (!caller.userId) return { ok: true, alias: requested };
37
+ const user = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", caller.userId);
38
+ if (user?.username === requested) return { ok: true, alias: requested };
39
+ if (!caller.networkId) return { ok: false, error: "network_id_required" };
40
+ const role = getUserNetworkRole(caller.userId, caller.networkId);
41
+ if (!role) return denied;
42
+ const canonical = resolveCanonicalAlias(caller.networkId, requested).alias;
43
+ if (role === "owner" || role === "admin") return { ok: true, alias: canonical };
44
+ const owned = db.get<{ node_id: string }>(
45
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2 AND owner_user_id = ?3 LIMIT 1",
46
+ canonical, caller.networkId, caller.userId,
47
+ );
48
+ return owned ? { ok: true, alias: canonical } : denied;
49
+ }
@@ -259,6 +259,9 @@ describe("utok multi-network", () => {
259
259
 
260
260
  test("ack_inbox threads explicit network_id", async () => {
261
261
  const { tools } = buildHandlers({ userId: U_MULTI });
262
+ // This tests network selection with an authorized recipient. Acting as
263
+ // another owner's recipient is covered by the HTTP identity suite.
264
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE alias = ?2 AND network_id = ?3", [U_MULTI, A1, NET_A]);
262
265
  const ib = makeInbox(A1, "x517 g2 ack_inbox");
263
266
  const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib, network_id: NET_A });
264
267
  expect(r.ok).toBe(true);
@@ -269,6 +269,73 @@ async function mcpSend(token: string, task: string, from: string | undefined) {
269
269
  return tool(token, "send_task", args);
270
270
  }
271
271
 
272
+ test("inbox recipient: bound identity wins over the token name", async () => {
273
+ const id = `inbox-bound-${stamp}`;
274
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own inbox', ?3)", [id, otherAlias, NET]);
275
+ const read = await tool(otherTok.token, "get_inbox", { alias: otherAlias });
276
+ expect(read.ok).toBe(true);
277
+ expect(read.messages.some((row: any) => row.id === id)).toBe(true);
278
+ expect((await tool(otherTok.token, "ack_inbox", { alias: otherAlias, message_id: id })).ok).toBe(true);
279
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(1);
280
+ });
281
+
282
+ test("inbox recipient: another node and invalid node identities cannot read or ACK", async () => {
283
+ const id = `inbox-denied-${stamp}`;
284
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private inbox', ?3)", [id, victimAlias, NET]);
285
+ for (const caller of [otherTok, squatter, boundMissing]) {
286
+ const read = await tool(caller.token, "get_inbox", { alias: victimAlias });
287
+ expect(read.ok).toBe(false);
288
+ expect(read.error).toBe("inbox_identity_mismatch");
289
+ expect(read.messages).toBeUndefined();
290
+ const ack = await tool(caller.token, "ack_inbox", { alias: victimAlias, message_id: id });
291
+ expect(ack.ok).toBe(false);
292
+ expect(ack.error).toBe("inbox_identity_mismatch");
293
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
294
+ }
295
+ expect((await tool(victimTok.token, "ack_inbox", { alias: victimAlias, message_id: id })).ok).toBe(true);
296
+ });
297
+
298
+ test("inbox recipient: same alias stays inside the token network", async () => {
299
+ const home = `inbox-net-home-${stamp}`, foreign = `inbox-net-foreign-${stamp}`;
300
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'home', ?3)", [home, sharedAlias, NET2]);
301
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'foreign', ?3)", [foreign, sharedAlias, NET]);
302
+ const read = await tool(sharedHome.token, "get_inbox", { alias: sharedAlias, network_id: NET });
303
+ expect(read.ok).toBe(true);
304
+ expect(read.messages.some((row: any) => row.id === home)).toBe(true);
305
+ expect(read.messages.some((row: any) => row.id === foreign)).toBe(false);
306
+ expect((await tool(sharedHome.token, "ack_inbox", { alias: sharedAlias, message_id: foreign, network_id: NET })).ok).toBe(false);
307
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", foreign)?.acked).toBe(0);
308
+ });
309
+
310
+ test("inbox recipient: legacy ownerless identity and authorized human access remain", async () => {
311
+ const id = `inbox-legacy-${stamp}`;
312
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'legacy', ?3)", [id, ownerlessAlias, NET]);
313
+ expect((await tool(ownerlessTok.token, "get_inbox", { alias: ownerlessAlias })).ok).toBe(true);
314
+ expect((await tool(ownerToken, "get_inbox", { alias: ownerlessAlias, network_id: NET })).ok).toBe(true);
315
+ expect((await tool(ownerToken, "ack_inbox", { alias: ownerlessAlias, message_id: id, network_id: NET })).ok).toBe(true);
316
+ });
317
+
318
+ test("inbox recipient: a full-access member cannot act as another owner's node", async () => {
319
+ const member = register(`inboxmem${stamp}`, "InboxMember123!", undefined, "seed");
320
+ expect(member.ok).toBe(true);
321
+ expect(addNetworkMember(NET, member.user!.user_id, "member", ownerId).ok).toBe(true);
322
+ db.run("UPDATE network_members SET agent_access = 'all' WHERE user_id = ?1 AND network_id = ?2", [member.user!.user_id, NET]);
323
+ const id = `inbox-member-${stamp}`;
324
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private', ?3)", [id, victimAlias, NET]);
325
+ for (const name of ["get_inbox", "ack_inbox"]) {
326
+ const args: Record<string, unknown> = { alias: victimAlias, network_id: NET };
327
+ if (name === "ack_inbox") args.message_id = id;
328
+ const result = await tool(member.token!, name, args);
329
+ expect(result.ok).toBe(false);
330
+ expect(result.error).toBe("inbox_identity_mismatch");
331
+ }
332
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
333
+ const own = `inbox-member-own-${stamp}`;
334
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own', ?3)", [own, member.user!.username, NET]);
335
+ expect((await tool(member.token!, "get_inbox", { alias: member.user!.username, network_id: NET })).ok).toBe(true);
336
+ expect((await tool(member.token!, "ack_inbox", { alias: member.user!.username, message_id: own, network_id: NET })).ok).toBe(true);
337
+ });
338
+
272
339
  test("caller identity boundary: mcp send omits from_session", async () => {
273
340
  const task = `boundary omit ${stamp}`;
274
341
  const sent = await mcpSend(squatter.token, task, undefined);
@@ -6,7 +6,7 @@ import { providerScanTargets } from './daemon-provider-snapshots';
6
6
  import { validScopedSecretKey } from './shared/scoped-secret-contract';
7
7
  type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
8
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;
9
+ type Request = { request_id: string; network_id: string; daemon_node_id: string | null; node_id: string | null;
10
10
  action: 'list' | 'put' | 'delete'; key?: string; value?: string; revision?: number;
11
11
  status: 'pending' | 'succeeded' | 'failed' | 'expired'; expires_at: number; snapshot?: any; error?: string };
12
12
  const requests = new Map<string, Request>();
@@ -26,6 +26,50 @@ function expire() {
26
26
  }
27
27
  }
28
28
  const timer = setInterval(expire, 10_000); timer.unref();
29
+ function nodeSupported(network: string, node: string) {
30
+ const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, node);
31
+ try { return JSON.parse(row?.config_snapshot ?? '{}').node_secrets_capable === true; } catch { return false; }
32
+ }
33
+ // Independent node lane: no supervisor lookup, binding, or Daemon credential.
34
+ export function requestNodeSecrets(h: Human, node: string, action: Request['action'], key?: string, value?: string, revision?: number) {
35
+ const refusal = authorize(h); if (refusal) return refusal;
36
+ if (!nodeSupported(h.networkId!, node)) return denied('node_secrets_upgrade_required');
37
+ if (action !== 'list' && (!validScopedSecretKey(key) || !Number.isSafeInteger(revision) || revision! < 0
38
+ || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
39
+ expire();
40
+ if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === null && r.node_id === node && r.status === 'pending')) return denied('secret_store_busy');
41
+ if (requests.size >= 1000) return denied('secret_queue_busy');
42
+ const r: Request = { request_id: `nsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: null,
43
+ node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
44
+ expires_at: Date.now() + 120_000, status: 'pending' };
45
+ requests.set(r.request_id, r);
46
+ return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
47
+ }
48
+ export function pullNodeSecrets(d: Daemon) {
49
+ if (!d.ok || !nodeSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_secret_node');
50
+ expire();
51
+ return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === null
52
+ && r.node_id === d.daemonNodeId && r.status === 'pending').map(({ status, snapshot, error, ...input }) => input) };
53
+ }
54
+ export function ackNodeSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
55
+ if (!d.ok || !nodeSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_secret_node');
56
+ expire(); const r = requests.get(id);
57
+ if (!r || r.network_id !== d.networkId || r.daemon_node_id !== null || r.node_id !== d.daemonNodeId) return denied('request_not_found');
58
+ if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
59
+ if (!failed) {
60
+ if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
61
+ || snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== null || snapshot.node_id !== r.node_id
62
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
63
+ || !Array.isArray(snapshot.keys) || snapshot.keys.length > 128 || new Set(snapshot.keys.map((x:any)=>x?.key)).size !== snapshot.keys.length
64
+ || snapshot.keys.some((x:any)=>!x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key) || x.source !== 'node')
65
+ || (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
66
+ || (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key))
67
+ || (r.action === 'delete' && snapshot.keys.some((x:any)=>x.key === r.key))) return denied('invalid_secret_receipt');
68
+ r.snapshot = snapshot;
69
+ }
70
+ r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
71
+ return { ok: true, status: r.status };
72
+ }
29
73
  export function requestScopedSecrets(h: Human, daemon: string, node: string | null, action: Request['action'], key?: string, value?: string, revision?: number) {
30
74
  const refusal = authorize(h); if (refusal) return refusal;
31
75
  if (!daemonSupported(h.networkId!, daemon)) return denied('scoped_secrets_upgrade_required');
package/src/tools.ts CHANGED
@@ -1,7 +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
+ import { requestScopedSecrets, getScopedSecrets, pullScopedSecrets, ackScopedSecrets, requestNodeSecrets, pullNodeSecrets, ackNodeSecrets } from './scoped-secrets.js';
5
5
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
6
6
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
7
7
  import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
@@ -22,6 +22,7 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
22
22
  import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
23
23
  import { assertNodeActive } from "./lifecycle-guard.js";
24
24
  import { pendingInboxCount } from "./inbox-count.js";
25
+ import { resolveInboxCaller } from "./inbox-caller.js";
25
26
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
26
27
  import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
27
28
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
@@ -843,6 +844,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
843
844
  // anet daemon. Default-stripping zod would drop this otherwise.
844
845
  role: z.string().max(64).optional().nullable(),
845
846
  scoped_secrets_capable: z.boolean().optional(),
847
+ // Preserve the runtime's independent-key capability through MCP input
848
+ // parsing; unknown z.object fields are otherwise silently stripped.
849
+ node_secrets_capable: z.boolean().optional(),
846
850
  // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
847
851
  // `daemon_capabilities` (canonical shape per existing hub reads
848
852
  // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
@@ -1418,13 +1422,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1418
1422
 
1419
1423
  server.tool(
1420
1424
  "get_inbox",
1421
- "Get pending commands for your session.",
1425
+ "Read your inbox.",
1422
1426
  {
1423
- alias: z.string().min(1).max(200).describe("Session alias"),
1427
+ alias: z.string().min(1).max(200).describe("Alias"),
1424
1428
  limit: z.number().min(1).max(100).optional().default(10),
1429
+ network_id: z.string().max(200).optional(),
1425
1430
  },
1426
- async ({ alias, limit }) => {
1427
- const readScope = resolveReadScope(null);
1431
+ async ({ alias, limit, network_id: netId }) => {
1432
+ const effectiveNetId = getNetworkId(netId);
1433
+ const recipient = resolveInboxCaller(alias, { tokenId: callerTokenId, userId: enforceUserId, networkId: effectiveNetId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected });
1434
+ if (!recipient.ok) return { content: [{ type: "text" as const, text: JSON.stringify(recipient) }] };
1435
+ alias = recipient.alias;
1436
+ const readScope = resolveReadScope(effectiveNetId);
1428
1437
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1429
1438
  const countParams: any[] = [alias];
1430
1439
  let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
@@ -1454,9 +1463,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1454
1463
 
1455
1464
  server.tool(
1456
1465
  "ack_inbox",
1457
- "Acknowledge receipt of a command.",
1466
+ "ACK your inbox.",
1458
1467
  {
1459
- alias: z.string().min(1).max(200).describe("Session alias"),
1468
+ alias: z.string().min(1).max(200).describe("Alias"),
1460
1469
  message_id: z.string().min(1).max(200),
1461
1470
  response: z.string().max(10000).optional(),
1462
1471
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
@@ -1464,6 +1473,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1464
1473
  async ({ alias, message_id, response, network_id: netId }) => {
1465
1474
  const effectiveNetId = getNetworkId(netId);
1466
1475
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1476
+ const recipient = resolveInboxCaller(alias, { tokenId: callerTokenId, userId: enforceUserId, networkId: effectiveNetId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected });
1477
+ if (!recipient.ok) return { content: [{ type: "text" as const, text: JSON.stringify(recipient) }] };
1478
+ alias = recipient.alias;
1467
1479
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
1468
1480
  // New task consumers ACK by the logical tasks.task_id exposed by
1469
1481
  // get_inbox. Keep exact inbox.id support for legacy consumers and for
@@ -4131,13 +4143,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4131
4143
  const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
4132
4144
  const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
4133
4145
  server.tool(
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() },
4146
+ "daemon_provider_snapshot", "Providers, no tokens; hub_*=owner catalog+bind; secret_*=Daemon env; node_secret_*=node env; provider={node_id?,key?,value?}; next full start.",
4147
+ { action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind", "secret_list", "secret_put", "secret_delete", "secret_read", "node_secret_list", "node_secret_put", "node_secret_delete"]), 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() },
4136
4148
  async ({ action, id, network_id, revision, provider, include_discovery }) => {
4137
4149
  const human = providerHuman(network_id);
4138
4150
  // Separate scoped-secret contract using the existing management lane.
4139
4151
  // No additional tools in model context; ordinary Provider actions unchanged.
4140
4152
  if (action === 'secret_read') return providerReply(getScopedSecrets(human, id));
4153
+ if (action === 'node_secret_list' || action === 'node_secret_put' || action === 'node_secret_delete') {
4154
+ const input = provider as { key?: string; value?: string } | undefined;
4155
+ const result = requestNodeSecrets(human, id, action === 'node_secret_list' ? 'list' : action === 'node_secret_put' ? 'put' : 'delete', input?.key, input?.value, revision);
4156
+ if (result.ok) {
4157
+ const target = db.get<{ alias: string }>('SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2', id, human.networkId!);
4158
+ if (target) pushEvent(target.alias, { type: 'node_secrets', request_id: result.request_id }, human.networkId!);
4159
+ }
4160
+ return providerReply(result);
4161
+ }
4141
4162
  if (action === 'secret_list' || action === 'secret_put' || action === 'secret_delete') {
4142
4163
  const input = provider as { node_id?: string | null; key?: string; value?: string } | undefined;
4143
4164
  const result = requestScopedSecrets(human, id, input?.node_id ?? null, action === 'secret_list' ? 'list' : action === 'secret_put' ? 'put' : 'delete', input?.key, input?.value, revision);
@@ -4172,9 +4193,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4172
4193
  },
4173
4194
  );
4174
4195
  server.tool(
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 }) => {
4196
+ "pull_daemon_provider_snapshots", "Provider/secret work; opt into implemented capabilities only.", { 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(), accept_node_secrets: z.boolean().optional() },
4197
+ async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections, accept_secrets, accept_node_secrets }) => {
4177
4198
  const caller = resolveCallerDaemonTokenBound();
4199
+ if (accept_node_secrets) return providerReply(pullNodeSecrets(caller));
4178
4200
  const result = pullProviderSnapshots(caller, accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections);
4179
4201
  return providerReply(result.ok && accept_secrets ? { ...result, secret_requests: pullScopedSecrets(caller) } : result);
4180
4202
  },
@@ -4182,7 +4204,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4182
4204
  server.tool(
4183
4205
  "ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
4184
4206
  { 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() },
4185
- async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(request_id.startsWith('dsk_')
4207
+ async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(request_id.startsWith('nsk_')
4208
+ ? ackNodeSecrets(resolveCallerDaemonTokenBound(), request_id, snapshot, failed)
4209
+ : request_id.startsWith('dsk_')
4186
4210
  ? ackScopedSecrets(resolveCallerDaemonTokenBound(), request_id, snapshot, failed)
4187
4211
  : ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
4188
4212
  );