@dp-pcs/ogp 0.6.0 → 0.7.0-rc.1

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 (87) hide show
  1. package/README.md +47 -11
  2. package/dist/cli/agent-targeting.d.ts +21 -0
  3. package/dist/cli/agent-targeting.d.ts.map +1 -0
  4. package/dist/cli/agent-targeting.js +44 -0
  5. package/dist/cli/agent-targeting.js.map +1 -0
  6. package/dist/cli/config.d.ts +4 -0
  7. package/dist/cli/config.d.ts.map +1 -1
  8. package/dist/cli/config.js +48 -0
  9. package/dist/cli/config.js.map +1 -1
  10. package/dist/cli/federation.d.ts +2 -1
  11. package/dist/cli/federation.d.ts.map +1 -1
  12. package/dist/cli/federation.js +162 -92
  13. package/dist/cli/federation.js.map +1 -1
  14. package/dist/cli/keychain.d.ts +22 -0
  15. package/dist/cli/keychain.d.ts.map +1 -0
  16. package/dist/cli/keychain.js +213 -0
  17. package/dist/cli/keychain.js.map +1 -0
  18. package/dist/cli/project.d.ts +1 -0
  19. package/dist/cli/project.d.ts.map +1 -1
  20. package/dist/cli/project.js +9 -2
  21. package/dist/cli/project.js.map +1 -1
  22. package/dist/cli/setup.d.ts +37 -0
  23. package/dist/cli/setup.d.ts.map +1 -1
  24. package/dist/cli/setup.js +130 -0
  25. package/dist/cli/setup.js.map +1 -1
  26. package/dist/cli.js +40 -6
  27. package/dist/cli.js.map +1 -1
  28. package/dist/daemon/heartbeat.d.ts +37 -0
  29. package/dist/daemon/heartbeat.d.ts.map +1 -1
  30. package/dist/daemon/heartbeat.js +195 -21
  31. package/dist/daemon/heartbeat.js.map +1 -1
  32. package/dist/daemon/keypair.d.ts.map +1 -1
  33. package/dist/daemon/keypair.js +144 -22
  34. package/dist/daemon/keypair.js.map +1 -1
  35. package/dist/daemon/message-handler.d.ts +8 -0
  36. package/dist/daemon/message-handler.d.ts.map +1 -1
  37. package/dist/daemon/message-handler.js +60 -18
  38. package/dist/daemon/message-handler.js.map +1 -1
  39. package/dist/daemon/notify.d.ts +6 -0
  40. package/dist/daemon/notify.d.ts.map +1 -1
  41. package/dist/daemon/notify.js +9 -2
  42. package/dist/daemon/notify.js.map +1 -1
  43. package/dist/daemon/openclaw-bridge.d.ts +6 -0
  44. package/dist/daemon/openclaw-bridge.d.ts.map +1 -1
  45. package/dist/daemon/openclaw-bridge.js +10 -2
  46. package/dist/daemon/openclaw-bridge.js.map +1 -1
  47. package/dist/daemon/peers.d.ts +31 -0
  48. package/dist/daemon/peers.d.ts.map +1 -1
  49. package/dist/daemon/peers.js +66 -4
  50. package/dist/daemon/peers.js.map +1 -1
  51. package/dist/daemon/rendezvous.d.ts.map +1 -1
  52. package/dist/daemon/rendezvous.js +9 -7
  53. package/dist/daemon/rendezvous.js.map +1 -1
  54. package/dist/daemon/reply-handler.d.ts.map +1 -1
  55. package/dist/daemon/reply-handler.js +2 -1
  56. package/dist/daemon/reply-handler.js.map +1 -1
  57. package/dist/daemon/scopes.d.ts +8 -0
  58. package/dist/daemon/scopes.d.ts.map +1 -1
  59. package/dist/daemon/scopes.js.map +1 -1
  60. package/dist/daemon/server.d.ts +128 -1
  61. package/dist/daemon/server.d.ts.map +1 -1
  62. package/dist/daemon/server.js +260 -57
  63. package/dist/daemon/server.js.map +1 -1
  64. package/dist/shared/config.d.ts +93 -0
  65. package/dist/shared/config.d.ts.map +1 -1
  66. package/dist/shared/config.js +111 -0
  67. package/dist/shared/config.js.map +1 -1
  68. package/dist/shared/help.js +1 -0
  69. package/dist/shared/help.js.map +1 -1
  70. package/dist/shared/signing.d.ts +49 -0
  71. package/dist/shared/signing.d.ts.map +1 -1
  72. package/dist/shared/signing.js +68 -0
  73. package/dist/shared/signing.js.map +1 -1
  74. package/dist/shared/tls.d.ts +27 -0
  75. package/dist/shared/tls.d.ts.map +1 -0
  76. package/dist/shared/tls.js +37 -0
  77. package/dist/shared/tls.js.map +1 -0
  78. package/docs/ARCHITECTURE.md +146 -0
  79. package/docs/CLI-REFERENCE.md +170 -2
  80. package/docs/MULTI-AGENT-PERSONAS-DESIGN.md +925 -0
  81. package/package.json +1 -1
  82. package/scripts/completion.bash +25 -1
  83. package/scripts/completion.zsh +9 -4
  84. package/scripts/render-ogp-overview-video.mjs +417 -0
  85. package/skills/ogp/SKILL.md +1 -1
  86. package/skills/ogp-expose/SKILL.md +1 -1
  87. package/skills/ogp-project/SKILL.md +1 -1
@@ -11,6 +11,7 @@ import { loadIntents } from '../daemon/intent-registry.js';
11
11
  import { loadMetaConfig } from '../shared/meta-config.js';
12
12
  import { logActivity } from '../daemon/agent-comms.js';
13
13
  import { deliverLocalSessionText } from '../daemon/notify.js';
14
+ import { validateTargetAgent } from './agent-targeting.js';
14
15
  /**
15
16
  * Expand tilde in paths
16
17
  */
@@ -20,6 +21,42 @@ function expandTilde(filePath) {
20
21
  }
21
22
  return filePath;
22
23
  }
24
+ function formatRelative(iso) {
25
+ if (!iso)
26
+ return 'never';
27
+ const ms = Date.now() - new Date(iso).getTime();
28
+ if (ms < 0)
29
+ return 'in the future';
30
+ const m = Math.floor(ms / 60000);
31
+ if (m < 1)
32
+ return '<1m';
33
+ if (m < 60)
34
+ return `${m}m ago`;
35
+ const h = Math.floor(m / 60);
36
+ if (h < 24)
37
+ return `${h}h ago`;
38
+ return `${Math.floor(h / 24)}d ago`;
39
+ }
40
+ function healthStateLabel(state) {
41
+ switch (state) {
42
+ case 'established': return { icon: '✓', label: 'established' };
43
+ case 'degraded-outbound': return { icon: '⚠', label: 'degraded-out' };
44
+ case 'degraded-inbound': return { icon: '⚠', label: 'degraded-in' };
45
+ case 'down': return { icon: '✗', label: 'down' };
46
+ default: return { icon: '?', label: 'unknown' };
47
+ }
48
+ }
49
+ function federationStateLabel(state) {
50
+ switch (state) {
51
+ case 'init': return { icon: '◐', label: 'INIT' };
52
+ case 'twoWay': return { icon: '◑', label: 'TWO-WAY' };
53
+ case 'established': return { icon: '✓', label: 'ESTABLISHED' };
54
+ case 'degraded': return { icon: '⚠', label: 'DEGRADED' };
55
+ case 'down': return { icon: '✗', label: 'DOWN' };
56
+ case 'tombstoned': return { icon: '☠', label: 'TOMBSTONED' };
57
+ default: return { icon: '?', label: 'UNKNOWN' };
58
+ }
59
+ }
23
60
  function normalizeGatewayUrl(url) {
24
61
  const trimmed = url.trim();
25
62
  if (!trimmed) {
@@ -148,25 +185,22 @@ export async function federationList(status, filterTag) {
148
185
  peers.forEach(peer => {
149
186
  const aliasDisplay = peer.alias || peer.displayName || '-';
150
187
  const displayName = peer.alias ? peer.displayName : '';
151
- const keyShort = peer.publicKey?.substring(0, 16) || '-';
152
188
  // Status and health icons
153
189
  let statusIcon = peer.status === 'approved' ? '✓' : peer.status === 'pending' ? '?' : '✗';
154
190
  if (peer.status === 'approved' && peer.healthy === false) {
155
191
  statusIcon = '✗'; // Show unhealthy status
156
192
  }
157
- // Health details for approved peers
193
+ // Issue #3 + #5: directional health summary for approved peers
158
194
  let healthInfo = '';
159
195
  if (peer.status === 'approved') {
160
- if (peer.lastSeenAt) {
161
- const lastSeen = new Date(peer.lastSeenAt);
162
- const now = new Date();
163
- const minutesAgo = Math.floor((now.getTime() - lastSeen.getTime()) / 60000);
164
- const timeStr = minutesAgo < 60 ? `${minutesAgo}m` : `${Math.floor(minutesAgo / 60)}h`;
165
- healthInfo = ` [last seen: ${timeStr}]`;
166
- }
167
- else {
168
- healthInfo = ' [never seen]';
169
- }
196
+ const { icon, label } = healthStateLabel(peer.healthState);
197
+ const out = peer.lastOutboundCheckFailedAt && (!peer.lastOutboundCheckAt || peer.lastOutboundCheckFailedAt > peer.lastOutboundCheckAt)
198
+ ? `out: FAIL ${formatRelative(peer.lastOutboundCheckFailedAt)}`
199
+ : `out: ${formatRelative(peer.lastOutboundCheckAt)}`;
200
+ const inboundLabel = peer.inboundHealthReport
201
+ ? `in (reported): ${peer.inboundHealthReport.healthy ? 'OK' : 'FAIL'} ${formatRelative(peer.inboundHealthReport.receivedAt)}`
202
+ : `in: ${formatRelative(peer.lastInboundContactAt)}`;
203
+ healthInfo = ` [${icon} ${label} ${out} ${inboundLabel}]`;
170
204
  if (peer.healthCheckFailures && peer.healthCheckFailures > 0) {
171
205
  healthInfo += ` (${peer.healthCheckFailures} failures)`;
172
206
  }
@@ -219,19 +253,10 @@ export async function federationList(status, filterTag) {
219
253
  const displayCol = (peer.displayName || '-').slice(0, 20).padEnd(20);
220
254
  const keyCol = (peer.publicKey?.substring(0, 16) || '-') + '...';
221
255
  const statusCol = peer.status;
222
- // Health status indicator
223
- let healthIcon = '';
224
- if (peer.status === 'approved') {
225
- if (peer.healthy === true) {
226
- healthIcon = '✓';
227
- }
228
- else if (peer.healthy === false) {
229
- healthIcon = '✗';
230
- }
231
- else {
232
- healthIcon = '?'; // Unknown health status
233
- }
234
- }
256
+ // Health status indicator (Issue #3: directional)
257
+ const { icon: healthIcon } = peer.status === 'approved'
258
+ ? healthStateLabel(peer.healthState)
259
+ : { icon: '' };
235
260
  console.log(` ${healthIcon ? healthIcon + ' ' : ''}${aliasCol} ${displayCol} ${keyCol.padEnd(20)} ${statusCol}`);
236
261
  console.log(` Gateway: ${peer.gatewayUrl}`);
237
262
  console.log(` ID: ${peer.id}`);
@@ -250,13 +275,23 @@ export async function federationList(status, filterTag) {
250
275
  if (peer.tags && peer.tags.length > 0) {
251
276
  console.log(` Tags: ${peer.tags.join(', ')}`);
252
277
  }
253
- // Show health details for approved peers
278
+ // Issue #4: federation lifecycle line for any non-tombstoned peer.
279
+ if (peer.status !== 'rejected' && peer.status !== 'removed') {
280
+ const { label: fedLabel } = federationStateLabel(peer.federationState);
281
+ const since = peer.federationStateChangedAt ? ` since ${formatRelative(peer.federationStateChangedAt)}` : '';
282
+ const reason = peer.federationStateReason ? ` — ${peer.federationStateReason}` : '';
283
+ console.log(` Federation: ${fedLabel}${since}${reason}`);
284
+ }
285
+ // Show health details for approved peers (Issue #3: directional, Issue #5: authoritative inbound)
254
286
  if (peer.status === 'approved') {
255
- if (peer.lastSeenAt) {
256
- const lastSeen = new Date(peer.lastSeenAt);
257
- const now = new Date();
258
- const minutesAgo = Math.floor((now.getTime() - lastSeen.getTime()) / 60000);
259
- console.log(` Last seen: ${minutesAgo < 60 ? minutesAgo + 'm ago' : Math.floor(minutesAgo / 60) + 'h ago'}`);
287
+ const { label } = healthStateLabel(peer.healthState);
288
+ console.log(` Health state: ${label}${peer.healthStateChangedAt ? ` (since ${formatRelative(peer.healthStateChangedAt)})` : ''}`);
289
+ console.log(` Outbound: last ok ${formatRelative(peer.lastOutboundCheckAt)}, last fail ${formatRelative(peer.lastOutboundCheckFailedAt)}`);
290
+ console.log(` Inbound: last contact ${formatRelative(peer.lastInboundContactAt)}`);
291
+ if (peer.inboundHealthReport) {
292
+ const r = peer.inboundHealthReport;
293
+ console.log(` Inbound (reported): ${r.healthy ? 'healthy' : 'unhealthy'} as of ${formatRelative(r.receivedAt)}` +
294
+ (r.healthCheckFailures && r.healthCheckFailures > 0 ? ` (${r.healthCheckFailures} failures from peer)` : ''));
260
295
  }
261
296
  if (peer.healthCheckFailures && peer.healthCheckFailures > 0) {
262
297
  console.log(` Health check failures: ${peer.healthCheckFailures}`);
@@ -319,42 +354,17 @@ export async function federationStatus() {
319
354
  console.log('\n Approved peers:');
320
355
  for (const peer of approvedPeers) {
321
356
  const aliasDisplay = peer.alias || peer.displayName || 'no alias';
322
- // Health status indicator
323
- let healthIcon = '';
324
- if (peer.healthy === true) {
325
- healthIcon = '✓';
326
- }
327
- else if (peer.healthy === false) {
328
- healthIcon = '✗';
329
- }
330
- else {
331
- healthIcon = '?';
332
- }
333
- // Format last seen time
334
- let lastSeenStr = '';
335
- if (peer.lastSeenAt) {
336
- const lastSeen = new Date(peer.lastSeenAt);
337
- const now = new Date();
338
- const minutesAgo = Math.floor((now.getTime() - lastSeen.getTime()) / 60000);
339
- if (minutesAgo < 60) {
340
- lastSeenStr = `${minutesAgo}m ago`;
341
- }
342
- else if (minutesAgo < 1440) {
343
- lastSeenStr = `${Math.floor(minutesAgo / 60)}h ago`;
344
- }
345
- else {
346
- lastSeenStr = `${Math.floor(minutesAgo / 1440)}d ago`;
347
- }
348
- }
349
- else {
350
- lastSeenStr = 'never';
351
- }
352
- // Show health failures if any
357
+ // Issue #3: directional health
358
+ const { icon: healthIcon, label } = healthStateLabel(peer.healthState);
359
+ const out = peer.lastOutboundCheckFailedAt && (!peer.lastOutboundCheckAt || peer.lastOutboundCheckFailedAt > peer.lastOutboundCheckAt)
360
+ ? `out FAIL ${formatRelative(peer.lastOutboundCheckFailedAt)}`
361
+ : `out ${formatRelative(peer.lastOutboundCheckAt)}`;
362
+ const inb = `in ${formatRelative(peer.lastInboundContactAt)}`;
353
363
  const failuresStr = peer.healthCheckFailures && peer.healthCheckFailures > 0
354
364
  ? ` (${peer.healthCheckFailures} failures)`
355
365
  : '';
356
366
  const scopes = peer.grantedScopes?.scopes.map(s => s.intent).join(', ') || 'none';
357
- console.log(` ${healthIcon} ${aliasDisplay.padEnd(20)} [${lastSeenStr.padEnd(8)}${failuresStr}] ${scopes}`);
367
+ console.log(` ${healthIcon} ${aliasDisplay.padEnd(20)} ${label.padEnd(13)} ${out.padEnd(20)} ${inb.padEnd(15)}${failuresStr} ${scopes}`);
358
368
  }
359
369
  }
360
370
  }
@@ -394,10 +404,18 @@ export async function federationStatus() {
394
404
  const pendingPeers = peers.filter(p => p.status === 'pending');
395
405
  const rejectedPeers = peers.filter(p => p.status === 'rejected');
396
406
  const removedPeers = peers.filter(p => p.status === 'removed');
397
- // Health statistics for approved peers
398
- const healthyPeers = approvedPeers.filter(p => p.healthy === true);
399
- const unhealthyPeers = approvedPeers.filter(p => p.healthy === false);
400
- const unknownHealthPeers = approvedPeers.filter(p => p.healthy === undefined);
407
+ // Health statistics for approved peers (Issue #3: directional)
408
+ const stateCounts = {
409
+ established: 0,
410
+ 'degraded-outbound': 0,
411
+ 'degraded-inbound': 0,
412
+ down: 0,
413
+ unknown: 0
414
+ };
415
+ for (const p of approvedPeers) {
416
+ const state = p.healthState ?? 'unknown';
417
+ stateCounts[state] = (stateCounts[state] ?? 0) + 1;
418
+ }
401
419
  console.log('\n📊 FEDERATION STATUS\n');
402
420
  // Summary counts
403
421
  console.log(`Total peers: ${peers.length}`);
@@ -406,12 +424,38 @@ export async function federationStatus() {
406
424
  console.log(` Rejected: ${rejectedPeers.length}`);
407
425
  console.log(` Removed: ${removedPeers.length}`);
408
426
  console.log('');
409
- // Health summary for approved peers
427
+ // Health summary for approved peers (Issue #3: directional state breakdown)
410
428
  if (approvedPeers.length > 0) {
411
429
  console.log('🏥 PEER HEALTH:\n');
412
- console.log(` Healthy: ${healthyPeers.length} (✓)`);
413
- console.log(` Unhealthy: ${unhealthyPeers.length} ()`);
414
- console.log(` Unknown: ${unknownHealthPeers.length} (?)`);
430
+ console.log(` Established: ${stateCounts.established} (✓)`);
431
+ console.log(` Degraded out: ${stateCounts['degraded-outbound']} (⚠ I can't reach them)`);
432
+ console.log(` Degraded in: ${stateCounts['degraded-inbound']} (⚠ they haven't reached me)`);
433
+ console.log(` Down: ${stateCounts.down} (✗)`);
434
+ console.log(` Unknown: ${stateCounts.unknown} (?)`);
435
+ console.log('');
436
+ }
437
+ // Issue #4: federation lifecycle state breakdown across all non-tombstoned peers.
438
+ const lifecycleCounts = {
439
+ init: 0, twoWay: 0, established: 0, degraded: 0, down: 0, unknown: 0
440
+ };
441
+ for (const p of peers) {
442
+ if (p.status === 'rejected' || p.status === 'removed')
443
+ continue;
444
+ const s = p.federationState ?? 'unknown';
445
+ if (s === 'tombstoned')
446
+ continue;
447
+ lifecycleCounts[s] = (lifecycleCounts[s] ?? 0) + 1;
448
+ }
449
+ if (peers.some(p => p.status !== 'rejected' && p.status !== 'removed')) {
450
+ console.log('🔗 FEDERATION LIFECYCLE:\n');
451
+ console.log(` Init: ${lifecycleCounts.init}`);
452
+ console.log(` Two-way: ${lifecycleCounts.twoWay}`);
453
+ console.log(` Established: ${lifecycleCounts.established}`);
454
+ console.log(` Degraded: ${lifecycleCounts.degraded}`);
455
+ console.log(` Down: ${lifecycleCounts.down}`);
456
+ if (lifecycleCounts.unknown > 0) {
457
+ console.log(` Unknown: ${lifecycleCounts.unknown}`);
458
+ }
415
459
  console.log('');
416
460
  }
417
461
  // Alias → Public Key mapping section
@@ -472,15 +516,18 @@ export async function federationRequest(peerUrl, peerId, alias) {
472
516
  ...(config.agentName ? { agentName: config.agentName } : {}),
473
517
  ...(config.organization ? { organization: config.organization } : {}),
474
518
  };
475
- const { sign } = await import('../shared/signing.js');
476
- const signature = sign(JSON.stringify(peer), keypair.privateKey);
477
519
  // Fetch our capabilities to include in the request (BUILD-110: intent negotiation)
478
520
  let ourIntents = loadIntents().map((i) => i.name);
479
521
  if (ourIntents.length === 0) {
480
522
  // Fallback to default intents if none registered
481
523
  ourIntents = ['message', 'agent-comms', 'project.join', 'project.contribute', 'project.query', 'project.status'];
482
524
  }
483
- const requestBody = { peer, signature, offeredIntents: ourIntents };
525
+ // SECURITY (F-04): Send a signed canonical envelope. The receiver verifies
526
+ // the signature against peer.publicKey from the payload — proves we hold
527
+ // the private key for the publicKey we're announcing.
528
+ const { signCanonical } = await import('../shared/signing.js');
529
+ const { payloadStr, signature } = signCanonical({ peer, offeredIntents: ourIntents }, keypair.privateKey);
530
+ const requestBody = { payloadStr, signature };
484
531
  // Send request
485
532
  try {
486
533
  const response = await fetch(`${resolvedPeerUrl}/federation/request`, {
@@ -628,25 +675,27 @@ export async function federationApprove(peerId, options = {}) {
628
675
  const ourConfig = requireConfig();
629
676
  try {
630
677
  const nonce = crypto.randomUUID();
678
+ // SECURITY (F-01): Approval is a canonical signed envelope.
679
+ const { signCanonical } = await import('../shared/signing.js');
680
+ const { payloadStr, signature } = signCanonical({
681
+ // Package format
682
+ peerId: peer.id,
683
+ approved: true,
684
+ // Fork format (for interoperability)
685
+ fromGatewayId: `${new URL(ourConfig.gatewayUrl).hostname}:${ourConfig.daemonPort}`,
686
+ fromDisplayName: ourConfig.displayName,
687
+ fromGatewayUrl: ourConfig.gatewayUrl,
688
+ fromPublicKey: keypair.publicKey,
689
+ fromEmail: ourConfig.email,
690
+ nonce,
691
+ // v0.2.0: Include scope grants
692
+ protocolVersion: '0.2.0',
693
+ scopeGrants
694
+ }, keypair.privateKey);
631
695
  await fetch(`${peerGatewayUrl}/federation/approve`, {
632
696
  method: 'POST',
633
697
  headers: { 'Content-Type': 'application/json' },
634
- body: JSON.stringify({
635
- // Package format
636
- peerId: peer.id,
637
- approved: true,
638
- // Fork format (for interoperability)
639
- fromGatewayId: `${new URL(ourConfig.gatewayUrl).hostname}:${ourConfig.daemonPort}`,
640
- fromDisplayName: ourConfig.displayName,
641
- fromGatewayUrl: ourConfig.gatewayUrl,
642
- fromPublicKey: keypair.publicKey,
643
- fromEmail: ourConfig.email,
644
- timestamp: new Date().toISOString(),
645
- nonce,
646
- // v0.2.0: Include scope grants
647
- protocolVersion: '0.2.0',
648
- scopeGrants
649
- })
698
+ body: JSON.stringify({ payloadStr, signature })
650
699
  });
651
700
  console.log('✓ Notified peer of approval');
652
701
  }
@@ -782,7 +831,7 @@ export async function federationRemove(peerId) {
782
831
  removePeer(peerId);
783
832
  console.log(`✓ Removed peer: ${peerId} (${peer.displayName})`);
784
833
  }
785
- export async function federationSend(peerId, intent, payloadJson, timeoutMs) {
834
+ export async function federationSend(peerId, intent, payloadJson, timeoutMs, toAgent) {
786
835
  const config = requireConfig();
787
836
  // Resolve peer identifier (alias, ID, or public key)
788
837
  const resolvedId = resolvePeerId(peerId);
@@ -800,6 +849,14 @@ export async function federationSend(peerId, intent, payloadJson, timeoutMs) {
800
849
  console.error(`Peer ${peerId} is not approved`);
801
850
  return null;
802
851
  }
852
+ // B0032 P4: capability check before honoring --to-agent.
853
+ if (toAgent) {
854
+ const targeting = await validateTargetAgent({ id: peer.id, gatewayUrl: peer.gatewayUrl, displayName: peer.displayName }, toAgent);
855
+ if (!targeting.ok) {
856
+ console.error(targeting.reason);
857
+ return null;
858
+ }
859
+ }
803
860
  const payload = JSON.parse(payloadJson);
804
861
  const keypair = loadOrGenerateKeyPair();
805
862
  // BUILD-111: Use public key prefix as our ID (not hostname:port)
@@ -808,6 +865,7 @@ export async function federationSend(peerId, intent, payloadJson, timeoutMs) {
808
865
  intent,
809
866
  from: ourId,
810
867
  to: peerId,
868
+ ...(toAgent ? { toAgent } : {}),
811
869
  nonce: crypto.randomUUID(),
812
870
  timestamp: new Date().toISOString(),
813
871
  payload
@@ -1122,6 +1180,14 @@ export async function federationSendAgentComms(peerId, topic, messageText, optio
1122
1180
  console.error(`Peer ${peerId} is not approved`);
1123
1181
  return;
1124
1182
  }
1183
+ // B0032 P4: capability check before honoring --to-agent.
1184
+ if (options.toAgent) {
1185
+ const targeting = await validateTargetAgent({ id: peer.id, gatewayUrl: peer.gatewayUrl, displayName: peer.displayName }, options.toAgent);
1186
+ if (!targeting.ok) {
1187
+ console.error(targeting.reason);
1188
+ return;
1189
+ }
1190
+ }
1125
1191
  const keypair = loadOrGenerateKeyPair();
1126
1192
  // Use 32-char public key prefix as our ID (avoids Ed25519 DER header collision with 16-char)
1127
1193
  const ourId = keypair.publicKey.substring(0, 32);
@@ -1135,6 +1201,7 @@ export async function federationSendAgentComms(peerId, topic, messageText, optio
1135
1201
  intent: 'agent-comms',
1136
1202
  from: ourId,
1137
1203
  to: peerId,
1204
+ ...(options.toAgent ? { toAgent: options.toAgent } : {}),
1138
1205
  nonce,
1139
1206
  timestamp: new Date().toISOString(),
1140
1207
  replyTo,
@@ -1232,10 +1299,13 @@ export async function federationInvite() {
1232
1299
  const pubkey = getPublicKey();
1233
1300
  const port = config.daemonPort ?? 18790;
1234
1301
  try {
1302
+ // SECURITY (F-02): /invite now requires a signed envelope, same as /register.
1303
+ const { signCanonical } = await import('../shared/signing.js');
1304
+ const { payloadStr, signature } = signCanonical({ pubkey, port }, getPrivateKey());
1235
1305
  const res = await fetch(`${config.rendezvous.url}/invite`, {
1236
1306
  method: 'POST',
1237
1307
  headers: { 'Content-Type': 'application/json' },
1238
- body: JSON.stringify({ pubkey, port }),
1308
+ body: JSON.stringify({ payloadStr, signature }),
1239
1309
  signal: AbortSignal.timeout(10000),
1240
1310
  });
1241
1311
  if (!res.ok) {