@dp-pcs/ogp 0.5.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 (91) 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 +6 -1
  11. package/dist/cli/federation.d.ts.map +1 -1
  12. package/dist/cli/federation.js +222 -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 +87 -7
  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 +48 -7
  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 +77 -20
  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/projects.d.ts +9 -1
  52. package/dist/daemon/projects.d.ts.map +1 -1
  53. package/dist/daemon/projects.js +2 -1
  54. package/dist/daemon/projects.js.map +1 -1
  55. package/dist/daemon/rendezvous.d.ts.map +1 -1
  56. package/dist/daemon/rendezvous.js +9 -7
  57. package/dist/daemon/rendezvous.js.map +1 -1
  58. package/dist/daemon/reply-handler.d.ts.map +1 -1
  59. package/dist/daemon/reply-handler.js +2 -1
  60. package/dist/daemon/reply-handler.js.map +1 -1
  61. package/dist/daemon/scopes.d.ts +8 -0
  62. package/dist/daemon/scopes.d.ts.map +1 -1
  63. package/dist/daemon/scopes.js.map +1 -1
  64. package/dist/daemon/server.d.ts +128 -1
  65. package/dist/daemon/server.d.ts.map +1 -1
  66. package/dist/daemon/server.js +304 -57
  67. package/dist/daemon/server.js.map +1 -1
  68. package/dist/shared/config.d.ts +93 -0
  69. package/dist/shared/config.d.ts.map +1 -1
  70. package/dist/shared/config.js +111 -0
  71. package/dist/shared/config.js.map +1 -1
  72. package/dist/shared/help.js +2 -0
  73. package/dist/shared/help.js.map +1 -1
  74. package/dist/shared/signing.d.ts +49 -0
  75. package/dist/shared/signing.d.ts.map +1 -1
  76. package/dist/shared/signing.js +68 -0
  77. package/dist/shared/signing.js.map +1 -1
  78. package/dist/shared/tls.d.ts +27 -0
  79. package/dist/shared/tls.d.ts.map +1 -0
  80. package/dist/shared/tls.js +37 -0
  81. package/dist/shared/tls.js.map +1 -0
  82. package/docs/ARCHITECTURE.md +146 -0
  83. package/docs/CLI-REFERENCE.md +170 -2
  84. package/docs/MULTI-AGENT-PERSONAS-DESIGN.md +925 -0
  85. package/package.json +1 -1
  86. package/scripts/completion.bash +26 -2
  87. package/scripts/completion.zsh +10 -4
  88. package/scripts/render-ogp-overview-video.mjs +417 -0
  89. package/skills/ogp/SKILL.md +1 -1
  90. package/skills/ogp-expose/SKILL.md +1 -1
  91. 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
@@ -1041,6 +1099,66 @@ export async function federationUntagPeer(peerId, tags) {
1041
1099
  console.log('No tags removed (tags not found)');
1042
1100
  }
1043
1101
  }
1102
+ /**
1103
+ * Update identity information with an existing peer
1104
+ */
1105
+ export async function federationUpdateIdentity(peerId) {
1106
+ const config = requireConfig();
1107
+ const keypair = loadOrGenerateKeyPair();
1108
+ // Resolve peer identifier
1109
+ const resolvedId = resolvePeerId(peerId);
1110
+ if (!resolvedId) {
1111
+ console.error(`Peer not found: ${peerId}`);
1112
+ return;
1113
+ }
1114
+ peerId = resolvedId;
1115
+ const peer = getPeer(peerId);
1116
+ if (!peer) {
1117
+ console.error(`Peer not found: ${peerId}`);
1118
+ return;
1119
+ }
1120
+ if (peer.status !== 'approved') {
1121
+ console.error(`Cannot update identity - peer ${peerId} is not approved (status: ${peer.status})`);
1122
+ return;
1123
+ }
1124
+ console.log(`Sending identity update to ${peer.displayName}...`);
1125
+ // Build identity payload
1126
+ const ourPeerId = keypair.publicKey.substring(0, 32);
1127
+ const identityUpdate = {
1128
+ id: ourPeerId,
1129
+ displayName: config.displayName,
1130
+ humanName: config.humanName,
1131
+ agentName: config.agentName,
1132
+ organization: config.organization,
1133
+ email: config.email,
1134
+ gatewayUrl: config.gatewayUrl,
1135
+ publicKey: keypair.publicKey,
1136
+ };
1137
+ const { sign } = await import('../shared/signing.js');
1138
+ const signature = sign(JSON.stringify(identityUpdate), keypair.privateKey);
1139
+ try {
1140
+ const response = await fetch(`${peer.gatewayUrl}/federation/update-identity`, {
1141
+ method: 'POST',
1142
+ headers: { 'Content-Type': 'application/json' },
1143
+ body: JSON.stringify({ identity: identityUpdate, signature })
1144
+ });
1145
+ if (!response.ok) {
1146
+ console.error(`Identity update failed: ${response.status} ${response.statusText}`);
1147
+ return;
1148
+ }
1149
+ const result = await response.json();
1150
+ if (result.updated) {
1151
+ console.log('✓ Identity update sent successfully');
1152
+ console.log(` Peer ${peer.displayName} has been notified of your updated identity`);
1153
+ }
1154
+ else {
1155
+ console.log(`Note: ${result.message || 'Update acknowledged but peer may not support identity updates'}`);
1156
+ }
1157
+ }
1158
+ catch (error) {
1159
+ console.error('Failed to send identity update:', error instanceof Error ? error.message : error);
1160
+ }
1161
+ }
1044
1162
  /**
1045
1163
  * Send an agent-comms message to a peer
1046
1164
  */
@@ -1062,6 +1180,14 @@ export async function federationSendAgentComms(peerId, topic, messageText, optio
1062
1180
  console.error(`Peer ${peerId} is not approved`);
1063
1181
  return;
1064
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
+ }
1065
1191
  const keypair = loadOrGenerateKeyPair();
1066
1192
  // Use 32-char public key prefix as our ID (avoids Ed25519 DER header collision with 16-char)
1067
1193
  const ourId = keypair.publicKey.substring(0, 32);
@@ -1075,6 +1201,7 @@ export async function federationSendAgentComms(peerId, topic, messageText, optio
1075
1201
  intent: 'agent-comms',
1076
1202
  from: ourId,
1077
1203
  to: peerId,
1204
+ ...(options.toAgent ? { toAgent: options.toAgent } : {}),
1078
1205
  nonce,
1079
1206
  timestamp: new Date().toISOString(),
1080
1207
  replyTo,
@@ -1172,10 +1299,13 @@ export async function federationInvite() {
1172
1299
  const pubkey = getPublicKey();
1173
1300
  const port = config.daemonPort ?? 18790;
1174
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());
1175
1305
  const res = await fetch(`${config.rendezvous.url}/invite`, {
1176
1306
  method: 'POST',
1177
1307
  headers: { 'Content-Type': 'application/json' },
1178
- body: JSON.stringify({ pubkey, port }),
1308
+ body: JSON.stringify({ payloadStr, signature }),
1179
1309
  signal: AbortSignal.timeout(10000),
1180
1310
  });
1181
1311
  if (!res.ok) {