a2acalling 0.6.65 → 0.6.67

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.6.65",
3
- "installed_at": "2026-02-25T07:27:45.973Z",
2
+ "version": "0.6.67",
3
+ "installed_at": "2026-02-25T14:23:35.176Z",
4
4
  "files": [
5
5
  {
6
6
  "path": "CLAUDE.md",
package/ARCHITECTURE.md CHANGED
@@ -20,7 +20,7 @@ A2A Calling enables agent-to-agent communication across OpenClaw instances. Agen
20
20
  ┌───────────▼──────────────────────────────────────────────────────┐
21
21
  │ Core Libraries (src/lib/) │
22
22
  │ ├─ tokens.js Token CRUD, validation, tiers │
23
- │ ├─ client.js A2AClient for outbound calls
23
+ │ ├─ client.js A2AClient for outbound calls (retry + size cap)
24
24
  │ ├─ conversations.js ConversationStore (SQLite) │
25
25
  │ ├─ conversation-driver.js Multi-turn call orchestration │
26
26
  │ ├─ summarizer.js Call summary generation │
@@ -28,6 +28,7 @@ A2A Calling enables agent-to-agent communication across OpenClaw instances. Agen
28
28
  │ ├─ summary-formatter.js Format summaries for display │
29
29
  │ ├─ disclosure.js Disclosure level enforcement │
30
30
  │ ├─ config.js Config file management │
31
+ │ ├─ crypto.js Ed25519 identity keypair + signing │
31
32
  │ ├─ logger.js Structured logger (SQLite + stdout) │
32
33
  │ ├─ call-monitor.js Active call monitoring │
33
34
  │ ├─ callbook.js Contact/callbook management │
@@ -80,6 +81,10 @@ Single-page app served from `src/dashboard/public/`. Uses Shoelace web component
80
81
 
81
82
  Tauri v2 app at `native/macos/` wrapping the dashboard SPA. Provides native menus, notifications, and server lifecycle management.
82
83
 
84
+ ## Identity Verification
85
+
86
+ Ed25519 cryptographic identity for agents. Each instance generates a keypair on first run (stored in config). Outbound calls sign messages; inbound calls verify signatures. Uses Node.js built-in `crypto.sign`/`crypto.verify` — no external dependencies. See `src/lib/crypto.js`.
87
+
83
88
  ## Testing
84
89
 
85
90
  Zero-dependency test runner at `test/run.js` with custom assert API. Three test tiers:
@@ -90,3 +95,7 @@ Zero-dependency test runner at `test/run.js` with custom assert API. Three test
90
95
  Test profiles at `test/profiles/` represent real personas with distinct permission tiers.
91
96
 
92
97
  E2E test results are persisted to `~/.config/openclaw/a2a-e2e-results.json` via `test/e2e/persist.js` and surfaced in the dashboard Health tab. The `scripts/run-e2e.sh` orchestrator runs E2E suites and stores results.
98
+
99
+ ## Network Resilience
100
+
101
+ The outbound A2A client (`src/lib/client.js`) retries transient network failures (ECONNRESET, ECONNREFUSED, EPIPE, ENOTFOUND, EAI_AGAIN, timeouts) with exponential backoff (0s, 1s, 2s). HTTP 4xx/5xx errors are not retried. All response accumulation is capped at 2MB to prevent OOM from malicious remotes.
package/CONVENTIONS.md CHANGED
@@ -67,6 +67,24 @@ All modules use CommonJS (`require`/`module.exports`). Each lib file exports a f
67
67
  - Sidebar navigation with tab switching (Contacts, Calls, Invites, Logs, Settings, Permissions, Health)
68
68
  - Permissions tab uses tier cards with tool toggles and auto-save
69
69
 
70
+ ## Network Resilience (A2A-54)
71
+
72
+ Outbound client methods (`call()`, `end()`) automatically retry transient network errors with exponential backoff. Pattern:
73
+ - Use `withRetry(fn, { delays })` for retryable operations
74
+ - Only retry on transient errors (ECONNRESET, ECONNREFUSED, EPIPE, ENOTFOUND, EAI_AGAIN, timeout)
75
+ - Never retry HTTP 4xx/5xx — those are explicit server rejections
76
+ - All HTTP responses are size-capped at 2MB via `handleSizeCappedResponse()`
77
+ - Configurable retry delays via `_retryDelays` constructor option (used in tests with `[0,0,0]` for fast execution)
78
+
79
+ ## Dashboard API Testing (A2A-56)
80
+
81
+ Dashboard API integration tests follow the pattern in `test/integration/dashboard-logs.test.js`:
82
+ - Mount `createDashboardApiRouter()` on an Express app
83
+ - Use `helpers.request()` for HTTP assertions (binds to 127.0.0.1 — bypasses auth)
84
+ - Bust module caches for `dashboard`, `logger`, `tokens`, `config`, `disclosure`, `conversations`, `callbook`, `dashboard-events`
85
+ - Call `loggerModule.closeAllLoggerStores()` in teardown to prevent SQLite handle leaks
86
+ - Pass `convStore` directly via `options.convStore` when testing calls endpoints
87
+
70
88
  ## Permission Tiers
71
89
 
72
90
  Tokens have a tier (`public`, `friends`, `family`) and a disclosure level (`public`, `minimal`, `none`). These are enforced at the route level in `src/routes/a2a.js`.
package/bin/cli.js CHANGED
@@ -1103,17 +1103,18 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1103
1103
  console.log('Legend: 🌐 public 🔧 friends ⚡ family');
1104
1104
  },
1105
1105
 
1106
- 'contacts:add': (args) => {
1106
+ 'contacts:add': async (args) => {
1107
1107
  const url = args._[2];
1108
1108
  if (!url) {
1109
1109
  console.error('Usage: a2a contacts add <invite_url> [options]');
1110
1110
  console.error('Options:');
1111
- console.error(' --name, -n Agent name');
1112
- console.error(' --owner, -o Owner name');
1113
- console.error(' --server-name Server label (optional)');
1114
- console.error(' --notes Notes about this contact');
1115
- console.error(' --tags Comma-separated tags');
1116
- console.error(' --link Link to token ID you gave them');
1111
+ console.error(' --name, -n Agent name');
1112
+ console.error(' --owner, -o Owner name');
1113
+ console.error(' --server-name Server label (optional)');
1114
+ console.error(' --notes Notes about this contact');
1115
+ console.error(' --tags Comma-separated tags');
1116
+ console.error(' --link Link to token ID you gave them');
1117
+ console.error(' --public-key Ed25519 public key (base64, or "fetch" to get from /status)');
1117
1118
  process.exit(1);
1118
1119
  }
1119
1120
 
@@ -1126,6 +1127,24 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1126
1127
  linkedTokenId: args.flags.link || null
1127
1128
  };
1128
1129
 
1130
+ // A2A-52: fetch or accept public key for identity verification
1131
+ const pubKeyFlag = args.flags['public-key'] || args.flags.public_key || args.flags.publicKey;
1132
+ if (pubKeyFlag === 'fetch' || pubKeyFlag === true) {
1133
+ try {
1134
+ const client = new A2AClient({});
1135
+ const statusResult = await client.status(url);
1136
+ if (statusResult.public_key) {
1137
+ options.public_key = statusResult.public_key;
1138
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1139
+ console.log(` Fetched public key: ${fpFunc(statusResult.public_key)}`);
1140
+ }
1141
+ } catch (fetchErr) {
1142
+ console.error(` Warning: could not fetch public key from /status: ${fetchErr.message}`);
1143
+ }
1144
+ } else if (pubKeyFlag && typeof pubKeyFlag === 'string') {
1145
+ options.public_key = pubKeyFlag;
1146
+ }
1147
+
1129
1148
  try {
1130
1149
  const result = store.addContact(url, options);
1131
1150
  if (!result.success) {
@@ -1186,13 +1205,22 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1186
1205
  console.log(`🔐 No linked token (you haven't given them access yet)`);
1187
1206
  }
1188
1207
 
1208
+ // A2A-52: show cryptographic identity verification status
1209
+ if (remote.public_key) {
1210
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1211
+ console.log(`🔑 Identity: verified`);
1212
+ console.log(` Fingerprint: ${fpFunc(remote.public_key)}`);
1213
+ } else {
1214
+ console.log(`🔑 Identity: unverified (no public key pinned)`);
1215
+ }
1216
+
1189
1217
  if (remote.tags && remote.tags.length > 0) {
1190
1218
  console.log(`🏷️ Tags: ${remote.tags.join(', ')}`);
1191
1219
  }
1192
1220
  if (remote.notes) {
1193
1221
  console.log(`📝 Notes: ${remote.notes}`);
1194
1222
  }
1195
-
1223
+
1196
1224
  console.log(`\n📅 Added: ${new Date(remote.added_at).toLocaleDateString()}`);
1197
1225
  if (remote.last_seen) {
1198
1226
  console.log(`📍 Last seen: ${formatTimeAgo(new Date(remote.last_seen))}`);
@@ -1300,6 +1328,23 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1300
1328
  console.log(`🟢 ${remote.name} is online`);
1301
1329
  console.log(` Agent: ${result.name}`);
1302
1330
  console.log(` Version: ${result.version}`);
1331
+
1332
+ // A2A-52: also fetch /status to refresh public key
1333
+ try {
1334
+ const statusResult = await client.status(url);
1335
+ if (statusResult.public_key) {
1336
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1337
+ if (remote.public_key && remote.public_key !== statusResult.public_key) {
1338
+ console.log(` ⚠️ Public key changed!`);
1339
+ console.log(` Old: ${fpFunc(remote.public_key)}`);
1340
+ console.log(` New: ${fpFunc(statusResult.public_key)}`);
1341
+ }
1342
+ store.updateContact(name, { public_key: statusResult.public_key });
1343
+ console.log(` 🔑 Fingerprint: ${fpFunc(statusResult.public_key)}`);
1344
+ }
1345
+ } catch (_) {
1346
+ // /status fetch is best-effort during ping
1347
+ }
1303
1348
  } catch (err) {
1304
1349
  store.updateContactStatus(name, 'offline', err.message);
1305
1350
  console.log(`🔴 ${remote.name} is offline`);
@@ -1535,6 +1580,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1535
1580
  // Best effort
1536
1581
  }
1537
1582
 
1583
+ // A2A-52: load keypair for request signing in multi-turn calls
1584
+ const _multiKeypair = config.getKeypair();
1538
1585
  const driver = new ConversationDriver({
1539
1586
  runtime,
1540
1587
  agentContext,
@@ -1546,6 +1593,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1546
1593
  maxTurns,
1547
1594
  configTurnTimeoutMs,
1548
1595
  ownerContext,
1596
+ privateKey: _multiKeypair ? _multiKeypair.privateKey : null,
1597
+ publicKey: _multiKeypair ? _multiKeypair.publicKey : null,
1549
1598
  onTurn: (info) => {
1550
1599
  const preview = info.messagePreview.length >= 80
1551
1600
  ? info.messagePreview + '...'
@@ -1586,8 +1635,12 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1586
1635
  }
1587
1636
 
1588
1637
  // Single-shot call (existing behavior)
1638
+ // A2A-52: load keypair for request signing
1639
+ const _callKeypair = config.getKeypair();
1589
1640
  const client = new A2AClient({
1590
- caller: { name: callerName }
1641
+ caller: { name: callerName },
1642
+ privateKey: _callKeypair ? _callKeypair.privateKey : null,
1643
+ publicKey: _callKeypair ? _callKeypair.publicKey : null
1591
1644
  });
1592
1645
 
1593
1646
  try {
@@ -2292,6 +2345,21 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
2292
2345
  } catch (_) {}
2293
2346
  }
2294
2347
 
2348
+ // A2A-52: Generate Ed25519 keypair for cryptographic identity (skip if already exists)
2349
+ const existingKeypair = config.getKeypair();
2350
+ if (!existingKeypair) {
2351
+ const { generateKeypair, fingerprint: fpFunc } = require('../src/lib/crypto');
2352
+ const keypair = generateKeypair();
2353
+ config.setKeypair(keypair.privateKey, keypair.publicKey);
2354
+ const fp = fpFunc(keypair.publicKey);
2355
+ console.log(`\n 🔑 Ed25519 identity generated`);
2356
+ console.log(` Fingerprint: ${fp}`);
2357
+ } else {
2358
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
2359
+ console.log(`\n 🔑 Ed25519 identity exists (not overwritten)`);
2360
+ console.log(` Fingerprint: ${fpFunc(existingKeypair.publicKey)}`);
2361
+ }
2362
+
2295
2363
  // Save server config and advance onboarding state to awaiting_disclosure.
2296
2364
  config.setAgent({ hostname: publicHost });
2297
2365
  config.setOnboarding({ step: 'awaiting_disclosure' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.65",
3
+ "version": "0.6.67",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1535,7 +1535,12 @@ function handleZoneDrop(zone, e) {
1535
1535
  // since autoSaveTier() may refresh state.settings asynchronously.
1536
1536
  setTimeout(() => {
1537
1537
  const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1538
- if (freshTier) renderSidebarLists(freshTier);
1538
+ if (freshTier) {
1539
+ renderSidebarLists(freshTier);
1540
+ // A2A-51: Re-bind drag listeners after innerHTML replacement in renderSidebarLists().
1541
+ // Without this, sidebar items lose dragstart/dragend handlers after the first drop.
1542
+ bindSidebarDrag();
1543
+ }
1539
1544
  }, 300);
1540
1545
  }
1541
1546
 
@@ -1776,8 +1781,9 @@ function bindPermissionsActions() {
1776
1781
  return;
1777
1782
  }
1778
1783
 
1779
- // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog
1780
- const addBtn = e.target.closest('.sidebar-add-btn[data-add-type]');
1784
+ // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog.
1785
+ // A2A-51: Also matches .col-header-add-btn for narrow viewports where sidebar is hidden.
1786
+ const addBtn = e.target.closest('[data-add-type]');
1781
1787
  if (addBtn) {
1782
1788
  const type = addBtn.dataset.addType;
1783
1789
  const dialog = document.getElementById('create-item-dialog');
@@ -1798,13 +1804,17 @@ function bindPermissionsActions() {
1798
1804
  // (#active-topics-zone, #active-goals-zone) persist across renders. Only
1799
1805
  // their innerHTML is replaced by renderActiveTopics/renderActiveGoals.
1800
1806
  // Binding in bindSidebarDrag() would cause listener accumulation.
1807
+ // A2A-51: Uses dragenter/dragleave counter to prevent flickering when
1808
+ // cursor moves over child elements (cards, placeholder) inside the zone.
1801
1809
  const topicZone = document.getElementById('active-topics-zone');
1802
1810
  const goalZone = document.getElementById('active-goals-zone');
1803
1811
  [topicZone, goalZone].forEach(zone => {
1804
1812
  if (!zone) return;
1805
- zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); });
1806
- zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
1807
- zone.addEventListener('drop', (e) => handleZoneDrop(zone, e));
1813
+ let dragCounter = 0;
1814
+ zone.addEventListener('dragenter', (e) => { e.preventDefault(); dragCounter++; zone.classList.add('drag-over'); });
1815
+ zone.addEventListener('dragover', (e) => { e.preventDefault(); });
1816
+ zone.addEventListener('dragleave', () => { dragCounter--; if (dragCounter === 0) zone.classList.remove('drag-over'); });
1817
+ zone.addEventListener('drop', (e) => { dragCounter = 0; handleZoneDrop(zone, e); });
1808
1818
  });
1809
1819
 
1810
1820
  // A2A-48: Tool toggle change — auto-save and update card styling
@@ -130,6 +130,7 @@
130
130
  <span class="status-dot status-dot--teal"></span>
131
131
  Active Topics
132
132
  <span id="topic-count" class="count-badge count-badge--teal">0</span>
133
+ <button class="col-header-add-btn" data-add-type="topic" title="Add topic"><span class="material-symbols-outlined" style="font-size:16px;">add</span></button>
133
134
  </div>
134
135
  <div id="active-topics-zone" class="perm-drop-zone"></div>
135
136
  </div>
@@ -138,6 +139,7 @@
138
139
  <span class="status-dot status-dot--yellow"></span>
139
140
  Active Goals
140
141
  <span id="goal-count" class="count-badge count-badge--yellow">0</span>
142
+ <button class="col-header-add-btn" data-add-type="goal" title="Add goal"><span class="material-symbols-outlined" style="font-size:16px;">add</span></button>
141
143
  </div>
142
144
  <div id="active-goals-zone" class="perm-drop-zone"></div>
143
145
  </div>
@@ -697,6 +697,25 @@ table tbody tr:hover td {
697
697
  padding: 0 0.25rem;
698
698
  }
699
699
 
700
+ /* A2A-51: "+" button in column headers for adding topics/goals without sidebar */
701
+ .col-header-add-btn {
702
+ margin-left: auto;
703
+ background: none;
704
+ border: 1px solid rgba(255,255,255,0.12);
705
+ border-radius: 4px;
706
+ color: var(--ink-muted);
707
+ cursor: pointer;
708
+ padding: 1px 4px;
709
+ display: flex;
710
+ align-items: center;
711
+ transition: color 0.15s ease, border-color 0.15s ease;
712
+ }
713
+
714
+ .col-header-add-btn:hover {
715
+ color: var(--ink);
716
+ border-color: rgba(255,255,255,0.3);
717
+ }
718
+
700
719
  .config-col-header--teal {
701
720
  color: #2DD4BF;
702
721
  }
package/src/lib/client.js CHANGED
@@ -4,6 +4,69 @@
4
4
 
5
5
  const https = require('https');
6
6
  const http = require('http');
7
+ const { signRequest } = require('./crypto');
8
+ // A2A-54: structured logging for retry warnings and size-cap violations
9
+ const { createLogger } = require('./logger');
10
+
11
+ const logger = createLogger({ component: 'a2a.client' });
12
+
13
+ // A2A-54: response size cap prevents OOM from unbounded accumulation
14
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
15
+
16
+ // A2A-54: only transient network errors are retryable — HTTP 4xx/5xx are not
17
+ const RETRYABLE_CODES = ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENOTFOUND', 'EAI_AGAIN'];
18
+
19
+ // A2A-54: exponential backoff — first retry is immediate, then 1s, then 2s
20
+ const RETRY_DELAYS = [0, 1000, 2000];
21
+
22
+ /**
23
+ * A2A-54: Retry wrapper for transient network failures.
24
+ * Only retries on RETRYABLE_CODES and timeout errors — HTTP status errors
25
+ * bubble up immediately since the remote explicitly rejected the request.
26
+ *
27
+ * @param {Function} fn - async function to retry
28
+ * @param {object} options
29
+ * @param {number[]} options.delays - delay sequence in ms (default: RETRY_DELAYS)
30
+ * @returns {Promise<*>}
31
+ */
32
+ async function withRetry(fn, options = {}) {
33
+ const delays = options.delays || RETRY_DELAYS;
34
+ const maxAttempts = delays.length + 1;
35
+
36
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
37
+ try {
38
+ return await fn();
39
+ } catch (err) {
40
+ // A2A-54: only retry transient network errors and timeouts.
41
+ // HTTP 4xx/5xx errors have err.code set to the server's error code
42
+ // (e.g. 'bad_request'), so they won't match network_error or timeout.
43
+ const isRetryable = err instanceof A2AError && (
44
+ (err.code === 'network_error' && RETRYABLE_CODES.some(c => err.message.includes(c))) ||
45
+ err.code === 'timeout'
46
+ );
47
+
48
+ if (!isRetryable || attempt >= maxAttempts) {
49
+ throw err;
50
+ }
51
+
52
+ // A2A-54: log each retry at warn level for operator visibility
53
+ const delay = delays[attempt - 1];
54
+ logger.warn(`Retrying request (attempt ${attempt + 1}/${maxAttempts})`, {
55
+ event: 'retry',
56
+ data: {
57
+ error_code: err.code,
58
+ error_message: err.message,
59
+ attempt: attempt + 1,
60
+ delay_ms: delay
61
+ }
62
+ });
63
+
64
+ if (delay > 0) {
65
+ await new Promise(resolve => setTimeout(resolve, delay));
66
+ }
67
+ }
68
+ }
69
+ }
7
70
 
8
71
  function splitHostPort(rawHost) {
9
72
  const host = String(rawHost || '').trim();
@@ -50,10 +113,65 @@ function resolveProtocolAndPort(host) {
50
113
  return { protocol, hostname, port };
51
114
  }
52
115
 
116
+ /**
117
+ * A2A-54: Create a size-capped response handler.
118
+ * Tracks accumulated bytes and destroys the socket if the cap is exceeded,
119
+ * preventing OOM from malicious or misconfigured remote agents.
120
+ *
121
+ * @param {http.IncomingMessage} res - the response stream
122
+ * @param {Function} resolve - promise resolve
123
+ * @param {Function} reject - promise reject
124
+ * @param {Function} onComplete - called with (data, statusCode) when response ends within cap
125
+ */
126
+ function handleSizeCappedResponse(res, resolve, reject, onComplete) {
127
+ let data = '';
128
+ let bytes = 0;
129
+ let destroyed = false;
130
+
131
+ res.on('data', (chunk) => {
132
+ bytes += chunk.length;
133
+ if (bytes > MAX_RESPONSE_BYTES) {
134
+ if (!destroyed) {
135
+ destroyed = true;
136
+ res.destroy();
137
+ // A2A-54: reject immediately — the remote sent more data than we allow
138
+ reject(new A2AError('response_too_large', `Response exceeded ${MAX_RESPONSE_BYTES} bytes`));
139
+ }
140
+ return;
141
+ }
142
+ data += chunk;
143
+ });
144
+
145
+ res.on('end', () => {
146
+ if (destroyed) return;
147
+ onComplete(data, res.statusCode);
148
+ });
149
+ }
150
+
53
151
  class A2AClient {
54
152
  constructor(options = {}) {
55
153
  this.timeout = options.timeout || 60000;
56
154
  this.caller = options.caller || {};
155
+ // A2A-52: Ed25519 identity keys for request signing
156
+ this.privateKey = options.privateKey || null;
157
+ this.publicKey = options.publicKey || null;
158
+ // A2A-54: allow configurable retry delays for testing (fast tests use [0,0,0])
159
+ this._retryDelays = options._retryDelays || RETRY_DELAYS;
160
+ }
161
+
162
+ /**
163
+ * A2A-52: Build signature headers if keypair is available.
164
+ * Shared helper used by both call() and end().
165
+ */
166
+ _signHeaders(method, endpoint, body) {
167
+ if (!this.privateKey || !this.publicKey) return {};
168
+ return signRequest({
169
+ privateKey: this.privateKey,
170
+ publicKey: this.publicKey,
171
+ method,
172
+ endpoint,
173
+ body
174
+ });
57
175
  }
58
176
 
59
177
  /**
@@ -69,7 +187,7 @@ class A2AClient {
69
187
 
70
188
  /**
71
189
  * Call a remote agent
72
- *
190
+ *
73
191
  * @param {string|object} endpoint - a2a:// URL or {host, token}
74
192
  * @param {string} message - Message to send
75
193
  * @param {object} options - Additional options
@@ -77,7 +195,7 @@ class A2AClient {
77
195
  */
78
196
  async call(endpoint, message, options = {}) {
79
197
  let host, token;
80
-
198
+
81
199
  if (typeof endpoint === 'string') {
82
200
  ({ host, token } = A2AClient.parseInvite(endpoint));
83
201
  } else {
@@ -95,8 +213,11 @@ class A2AClient {
95
213
  });
96
214
 
97
215
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
216
+ // A2A-52: attach signature headers when keypair available
217
+ const sigHeaders = this._signHeaders('POST', '/api/a2a/invoke', body);
98
218
 
99
- return new Promise((resolve, reject) => {
219
+ // A2A-54: wrap with retry for transient network failures
220
+ const makeRequest = () => new Promise((resolve, reject) => {
100
221
  const req = protocol.request({
101
222
  hostname,
102
223
  port,
@@ -105,28 +226,28 @@ class A2AClient {
105
226
  headers: {
106
227
  'Authorization': `Bearer ${token}`,
107
228
  'Content-Type': 'application/json',
108
- 'Content-Length': Buffer.byteLength(body)
229
+ 'Content-Length': Buffer.byteLength(body),
230
+ ...sigHeaders
109
231
  },
110
232
  timeout: this.timeout
111
233
  }, (res) => {
112
- let data = '';
113
- res.on('data', chunk => data += chunk);
114
- res.on('end', () => {
234
+ // A2A-54: size-capped response accumulation
235
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
115
236
  try {
116
237
  const json = JSON.parse(data);
117
- if (res.statusCode >= 400) {
118
- reject(new A2AError(json.error || 'request_failed', json.message || data, res.statusCode));
238
+ if (statusCode >= 400) {
239
+ reject(new A2AError(json.error || 'request_failed', json.message || data, statusCode));
119
240
  } else {
120
241
  resolve(json);
121
242
  }
122
243
  } catch (e) {
123
- reject(new A2AError('parse_error', `Failed to parse response: ${data}`, res.statusCode));
244
+ reject(new A2AError('parse_error', `Failed to parse response: ${data}`, statusCode));
124
245
  }
125
246
  });
126
247
  });
127
248
 
128
249
  req.on('error', (e) => {
129
- reject(new A2AError('network_error', e.message));
250
+ reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
130
251
  });
131
252
 
132
253
  req.on('timeout', () => {
@@ -137,11 +258,13 @@ class A2AClient {
137
258
  req.write(body);
138
259
  req.end();
139
260
  });
261
+
262
+ return withRetry(makeRequest, { delays: this._retryDelays });
140
263
  }
141
264
 
142
265
  /**
143
266
  * Explicitly end a remote conversation and trigger call conclusion
144
- *
267
+ *
145
268
  * @param {string|object} endpoint - a2a:// URL or {host, token}
146
269
  * @param {string} conversationId - Conversation ID to conclude
147
270
  * @returns {Promise<object>} End response from remote agent
@@ -152,7 +275,7 @@ class A2AClient {
152
275
  }
153
276
 
154
277
  let host, token;
155
-
278
+
156
279
  if (typeof endpoint === 'string') {
157
280
  ({ host, token } = A2AClient.parseInvite(endpoint));
158
281
  } else {
@@ -164,8 +287,11 @@ class A2AClient {
164
287
  });
165
288
 
166
289
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
290
+ // A2A-52: attach signature headers when keypair available
291
+ const sigHeaders = this._signHeaders('POST', '/api/a2a/end', body);
167
292
 
168
- return new Promise((resolve, reject) => {
293
+ // A2A-54: wrap with retry for transient network failures
294
+ const makeRequest = () => new Promise((resolve, reject) => {
169
295
  const req = protocol.request({
170
296
  hostname,
171
297
  port,
@@ -174,28 +300,28 @@ class A2AClient {
174
300
  headers: {
175
301
  'Authorization': `Bearer ${token}`,
176
302
  'Content-Type': 'application/json',
177
- 'Content-Length': Buffer.byteLength(body)
303
+ 'Content-Length': Buffer.byteLength(body),
304
+ ...sigHeaders
178
305
  },
179
306
  timeout: this.timeout
180
307
  }, (res) => {
181
- let data = '';
182
- res.on('data', chunk => data += chunk);
183
- res.on('end', () => {
308
+ // A2A-54: size-capped response accumulation
309
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
184
310
  try {
185
311
  const json = JSON.parse(data);
186
- if (res.statusCode >= 400) {
187
- reject(new A2AError(json.error || 'request_failed', json.message || data, res.statusCode));
312
+ if (statusCode >= 400) {
313
+ reject(new A2AError(json.error || 'request_failed', json.message || data, statusCode));
188
314
  } else {
189
315
  resolve(json);
190
316
  }
191
317
  } catch (e) {
192
- reject(new A2AError('parse_error', `Failed to parse response: ${data}`, res.statusCode));
318
+ reject(new A2AError('parse_error', `Failed to parse response: ${data}`, statusCode));
193
319
  }
194
320
  });
195
321
  });
196
322
 
197
323
  req.on('error', (e) => {
198
- reject(new A2AError('network_error', e.message));
324
+ reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
199
325
  });
200
326
 
201
327
  req.on('timeout', () => {
@@ -206,6 +332,8 @@ class A2AClient {
206
332
  req.write(body);
207
333
  req.end();
208
334
  });
335
+
336
+ return withRetry(makeRequest, { delays: this._retryDelays });
209
337
  }
210
338
 
211
339
  /**
@@ -213,7 +341,7 @@ class A2AClient {
213
341
  */
214
342
  async ping(endpoint) {
215
343
  let host;
216
-
344
+
217
345
  if (typeof endpoint === 'string') {
218
346
  ({ host } = A2AClient.parseInvite(endpoint));
219
347
  } else {
@@ -222,6 +350,7 @@ class A2AClient {
222
350
 
223
351
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
224
352
 
353
+ // A2A-54: no retry for ping — it's a lightweight probe, not a critical call
225
354
  return new Promise((resolve, reject) => {
226
355
  const req = protocol.request({
227
356
  hostname,
@@ -230,13 +359,12 @@ class A2AClient {
230
359
  method: 'GET',
231
360
  timeout: 5000
232
361
  }, (res) => {
233
- let data = '';
234
- res.on('data', chunk => data += chunk);
235
- res.on('end', () => {
362
+ // A2A-54: size-capped response accumulation
363
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
236
364
  try {
237
365
  resolve(JSON.parse(data));
238
366
  } catch {
239
- resolve({ pong: res.statusCode === 200 });
367
+ resolve({ pong: statusCode === 200 });
240
368
  }
241
369
  });
242
370
  });
@@ -255,7 +383,7 @@ class A2AClient {
255
383
  */
256
384
  async status(endpoint) {
257
385
  let host;
258
-
386
+
259
387
  if (typeof endpoint === 'string') {
260
388
  ({ host } = A2AClient.parseInvite(endpoint));
261
389
  } else {
@@ -264,6 +392,7 @@ class A2AClient {
264
392
 
265
393
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
266
394
 
395
+ // A2A-54: no retry for status — read-only probe, not a stateful operation
267
396
  return new Promise((resolve, reject) => {
268
397
  const req = protocol.request({
269
398
  hostname,
@@ -272,9 +401,8 @@ class A2AClient {
272
401
  method: 'GET',
273
402
  timeout: 5000
274
403
  }, (res) => {
275
- let data = '';
276
- res.on('data', chunk => data += chunk);
277
- res.on('end', () => {
404
+ // A2A-54: size-capped response accumulation
405
+ handleSizeCappedResponse(res, resolve, reject, (data) => {
278
406
  try {
279
407
  resolve(JSON.parse(data));
280
408
  } catch {
@@ -283,7 +411,7 @@ class A2AClient {
283
411
  });
284
412
  });
285
413
 
286
- req.on('error', (e) => reject(new A2AError('network_error', e.message)));
414
+ req.on('error', (e) => reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message)));
287
415
  req.on('timeout', () => {
288
416
  req.destroy();
289
417
  reject(new A2AError('timeout', 'Request timed out'));
@@ -302,4 +430,13 @@ class A2AError extends Error {
302
430
  }
303
431
  }
304
432
 
305
- module.exports = { A2AClient, A2AError };
433
+ // A2A-54: export internals for testing (splitHostPort, resolveProtocolAndPort, constants)
434
+ module.exports = {
435
+ A2AClient,
436
+ A2AError,
437
+ _splitHostPort: splitHostPort,
438
+ _resolveProtocolAndPort: resolveProtocolAndPort,
439
+ _MAX_RESPONSE_BYTES: MAX_RESPONSE_BYTES,
440
+ _RETRYABLE_CODES: RETRYABLE_CODES,
441
+ _RETRY_DELAYS: RETRY_DELAYS
442
+ };
package/src/lib/config.js CHANGED
@@ -230,10 +230,13 @@ const DEFAULT_CONFIG = {
230
230
  },
231
231
 
232
232
  // Agent info
233
+ // A2A-52: private_key/public_key store Ed25519 identity (base64 DER)
233
234
  agent: {
234
235
  name: '',
235
236
  description: '',
236
- hostname: ''
237
+ hostname: '',
238
+ private_key: null,
239
+ public_key: null
237
240
  },
238
241
 
239
242
  // Auto-updater
@@ -386,6 +389,21 @@ class A2AConfig {
386
389
  this._save();
387
390
  }
388
391
 
392
+ // A2A-52: Get Ed25519 keypair from agent config (null if not generated)
393
+ getKeypair() {
394
+ const agent = this.config.agent || {};
395
+ if (!agent.private_key || !agent.public_key) return null;
396
+ return { privateKey: agent.private_key, publicKey: agent.public_key };
397
+ }
398
+
399
+ // A2A-52: Store Ed25519 keypair in agent config (already 0o600 via _save)
400
+ setKeypair(privateKey, publicKey) {
401
+ this.config.agent = this.config.agent || {};
402
+ this.config.agent.private_key = privateKey;
403
+ this.config.agent.public_key = publicKey;
404
+ this._save();
405
+ }
406
+
389
407
  // Get full config
390
408
  getAll() {
391
409
  return this.config;
@@ -424,12 +442,13 @@ class A2AConfig {
424
442
  return next;
425
443
  }
426
444
 
427
- // Export for sharing
445
+ // Export for sharing (strips private_key to prevent leakage — A2A-52)
428
446
  export() {
447
+ const { private_key, ...agentPublic } = this.config.agent || {};
429
448
  return {
430
449
  tiers: this.config.tiers,
431
450
  defaults: this.config.defaults,
432
- agent: this.config.agent
451
+ agent: agentPublic
433
452
  };
434
453
  }
435
454
  }
@@ -147,7 +147,13 @@ class ConversationDriver {
147
147
  const clientTimeout = this.claudeMode
148
148
  ? Math.max(this.claudeTimeoutMs + 20000, 200000)
149
149
  : 65000;
150
- this.client = new A2AClient({ caller: this.caller, timeout: clientTimeout });
150
+ // A2A-52: pass Ed25519 keypair for request signing
151
+ this.client = new A2AClient({
152
+ caller: this.caller,
153
+ timeout: clientTimeout,
154
+ privateKey: options.privateKey || null,
155
+ publicKey: options.publicKey || null
156
+ });
151
157
  }
152
158
 
153
159
  /**
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A2A Ed25519 Cryptographic Identity
3
+ *
4
+ * Provides keypair generation, request signing, signature verification,
5
+ * and public key fingerprinting for agent-to-agent identity verification.
6
+ *
7
+ * A2A-52: Zero new dependencies — uses Node.js built-in crypto (Ed25519 since v15).
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ // A2A-52: 5-minute window for replay protection
13
+ const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
14
+
15
+ /**
16
+ * Generate an Ed25519 keypair.
17
+ * Returns { privateKey, publicKey } as base64-encoded DER buffers.
18
+ */
19
+ function generateKeypair() {
20
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
21
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
22
+ publicKeyEncoding: { type: 'spki', format: 'der' }
23
+ });
24
+ return {
25
+ privateKey: privateKey.toString('base64'),
26
+ publicKey: publicKey.toString('base64')
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Compute a SHA-256 fingerprint of a base64-encoded public key.
32
+ * Returns colon-separated hex string (like SSH fingerprints).
33
+ */
34
+ function fingerprint(publicKeyBase64) {
35
+ const hash = crypto.createHash('sha256')
36
+ .update(Buffer.from(publicKeyBase64, 'base64'))
37
+ .digest('hex');
38
+ // A2A-52: colon-separated pairs for readability (SSH-style)
39
+ return hash.match(/.{2}/g).join(':');
40
+ }
41
+
42
+ /**
43
+ * Sign an outbound request.
44
+ *
45
+ * Signing payload: `${timestamp}:${method}:${endpoint}:${bodyHash}`
46
+ * where bodyHash = SHA-256 of the request body string.
47
+ *
48
+ * @param {object} params
49
+ * @param {string} params.privateKey - base64-encoded DER private key
50
+ * @param {string} params.publicKey - base64-encoded DER public key
51
+ * @param {string} params.method - HTTP method (e.g. 'POST')
52
+ * @param {string} params.endpoint - Request path (e.g. '/api/a2a/invoke')
53
+ * @param {string} params.body - Serialized request body
54
+ * @returns {object} Headers to attach: { 'X-A2A-Signature', 'X-A2A-Public-Key', 'X-A2A-Timestamp' }
55
+ */
56
+ function signRequest({ privateKey, publicKey, method, endpoint, body }) {
57
+ const timestamp = new Date().toISOString();
58
+ const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
59
+ const payload = `${timestamp}:${method}:${endpoint}:${bodyHash}`;
60
+
61
+ const keyObject = crypto.createPrivateKey({
62
+ key: Buffer.from(privateKey, 'base64'),
63
+ format: 'der',
64
+ type: 'pkcs8'
65
+ });
66
+
67
+ const signature = crypto.sign(null, Buffer.from(payload), keyObject);
68
+
69
+ return {
70
+ 'X-A2A-Signature': signature.toString('base64'),
71
+ 'X-A2A-Public-Key': publicKey,
72
+ 'X-A2A-Timestamp': timestamp
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Verify an inbound request signature.
78
+ *
79
+ * @param {object} params
80
+ * @param {string} params.signature - base64-encoded Ed25519 signature
81
+ * @param {string} params.publicKey - base64-encoded DER public key
82
+ * @param {string} params.timestamp - ISO 8601 timestamp from header
83
+ * @param {string} params.method - HTTP method
84
+ * @param {string} params.endpoint - Request path
85
+ * @param {string} params.body - Raw request body string
86
+ * @returns {boolean} true if signature is valid
87
+ */
88
+ function verifySignature({ signature, publicKey, timestamp, method, endpoint, body }) {
89
+ const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
90
+ const payload = `${timestamp}:${method}:${endpoint}:${bodyHash}`;
91
+
92
+ const keyObject = crypto.createPublicKey({
93
+ key: Buffer.from(publicKey, 'base64'),
94
+ format: 'der',
95
+ type: 'spki'
96
+ });
97
+
98
+ return crypto.verify(null, Buffer.from(payload), keyObject, Buffer.from(signature, 'base64'));
99
+ }
100
+
101
+ /**
102
+ * Check if a timestamp is within the allowed window (replay protection).
103
+ * @param {string} timestamp - ISO 8601 timestamp
104
+ * @returns {boolean} true if within +-5 minutes of now
105
+ */
106
+ function isTimestampValid(timestamp) {
107
+ const ts = new Date(timestamp).getTime();
108
+ if (Number.isNaN(ts)) return false;
109
+ const diff = Math.abs(Date.now() - ts);
110
+ return diff <= TIMESTAMP_WINDOW_MS;
111
+ }
112
+
113
+ module.exports = { generateKeypair, fingerprint, signRequest, verifySignature, isTimestampValid };
package/src/lib/tokens.js CHANGED
@@ -448,6 +448,7 @@ class TokenStore {
448
448
  tags: Array.isArray(options.tags) ? options.tags : [],
449
449
  fields: sanitizeCustomFields(options.fields || options.custom_fields || options.customFields),
450
450
  linked_token_id: options.linkedTokenId || options.linked_token_id || null, // Token you gave them
451
+ public_key: options.public_key || options.publicKey || null, // A2A-52: Ed25519 public key (base64 DER)
451
452
  status: 'unknown',
452
453
  last_seen: null,
453
454
  added_at: new Date().toISOString(),
@@ -597,7 +598,8 @@ class TokenStore {
597
598
  }
598
599
 
599
600
  // Only allow updating specific fields
600
- const allowed = ['name', 'owner', 'is_mine', 'notes', 'tags', 'linked_token_id', 'server_name', 'fields'];
601
+ // A2A-52: 'public_key' added for Ed25519 identity verification (TOFU pinning)
602
+ const allowed = ['name', 'owner', 'is_mine', 'notes', 'tags', 'linked_token_id', 'server_name', 'fields', 'public_key'];
601
603
  for (const key of allowed) {
602
604
  if (updates[key] !== undefined) {
603
605
  if (key === 'fields') {
@@ -764,6 +766,7 @@ class TokenStore {
764
766
  tags: ['inbound'],
765
767
  fields: {},
766
768
  linked_token_id: tokenId || null,
769
+ public_key: null, // A2A-52: populated via TOFU on first verified call
767
770
  status: 'unknown',
768
771
  last_seen: null,
769
772
  added_at: new Date().toISOString(),
package/src/routes/a2a.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const { TokenStore } = require('../lib/tokens');
12
12
  const crypto = require('crypto');
13
13
  const { createLogger, createTraceId } = require('../lib/logger');
14
+ const { verifySignature, isTimestampValid, fingerprint } = require('../lib/crypto');
14
15
 
15
16
  // Lazy-load conversation store (optional dependency)
16
17
  let ConversationStore = null;
@@ -187,19 +188,75 @@ function createRoutes(options = {}) {
187
188
  } catch (_) {}
188
189
  }
189
190
 
191
+ // A2A-52: shared signature verification helper for /invoke and /end
192
+ function verifySigHeaders(req, validation, endpoint, reqLogger, withTracePayload) {
193
+ const sigHeader = req.headers['x-a2a-signature'];
194
+ const pubKeyHeader = req.headers['x-a2a-public-key'];
195
+ const tsHeader = req.headers['x-a2a-timestamp'];
196
+ const result = { identityVerified: false, publicKeyFingerprint: null, error: null };
197
+
198
+ if (!sigHeader || !pubKeyHeader || !tsHeader) return result;
199
+
200
+ if (!isTimestampValid(tsHeader)) {
201
+ result.error = { status: 403, body: { success: false, error: 'timestamp_expired', message: 'Request timestamp outside allowed window' } };
202
+ reqLogger.warn('Signature timestamp outside window', { tokenId: validation.id, error_code: 'SIGNATURE_TIMESTAMP_EXPIRED', status_code: 403 });
203
+ return result;
204
+ }
205
+
206
+ try {
207
+ crypto.createPublicKey({ key: Buffer.from(pubKeyHeader, 'base64'), format: 'der', type: 'spki' });
208
+ } catch (_) {
209
+ result.error = { status: 400, body: { success: false, error: 'malformed_public_key', message: 'X-A2A-Public-Key is not a valid Ed25519 public key' } };
210
+ reqLogger.warn('Malformed public key', { tokenId: validation.id, error_code: 'MALFORMED_PUBLIC_KEY', status_code: 400 });
211
+ return result;
212
+ }
213
+
214
+ const existingContact = tokenStore.getContact(validation.id) ||
215
+ (tokenStore.listContacts().find(c => c.linked_token_id === validation.id));
216
+ if (existingContact && existingContact.public_key && existingContact.public_key !== pubKeyHeader) {
217
+ result.error = { status: 403, body: { success: false, error: 'public_key_mismatch', message: 'Public key does not match previously pinned key' } };
218
+ reqLogger.warn('Public key mismatch (TOFU violation)', { tokenId: validation.id, error_code: 'PUBLIC_KEY_MISMATCH', status_code: 403 });
219
+ return result;
220
+ }
221
+
222
+ const rawBody = JSON.stringify(req.body);
223
+ try {
224
+ const valid = verifySignature({ signature: sigHeader, publicKey: pubKeyHeader, timestamp: tsHeader, method: 'POST', endpoint, body: rawBody });
225
+ if (valid) {
226
+ result.identityVerified = true;
227
+ result.publicKeyFingerprint = fingerprint(pubKeyHeader);
228
+ if (existingContact && !existingContact.public_key) {
229
+ tokenStore.updateContact(existingContact.name || existingContact.id, { public_key: pubKeyHeader });
230
+ }
231
+ } else {
232
+ result.error = { status: 403, body: { success: false, error: 'invalid_signature', message: 'Ed25519 signature verification failed' } };
233
+ reqLogger.warn('Signature verification failed', { tokenId: validation.id, error_code: 'SIGNATURE_INVALID', status_code: 403 });
234
+ }
235
+ } catch (sigErr) {
236
+ result.error = { status: 403, body: { success: false, error: 'invalid_signature', message: 'Signature verification failed' } };
237
+ reqLogger.warn('Signature verification error', { tokenId: validation.id, error_code: 'SIGNATURE_VERIFY_ERROR', status_code: 403, error: sigErr });
238
+ }
239
+ return result;
240
+ }
241
+
190
242
  /**
191
243
  * GET /status
192
244
  * Check if A2A is enabled
193
245
  */
194
246
  router.get('/status', (req, res) => {
195
247
  const activeCalls = monitor ? monitor.getActiveCount() : 0;
196
- res.json({
248
+ const response = {
197
249
  a2a: true,
198
250
  version: require('../../package.json').version,
199
251
  capabilities: ['invoke', 'multi-turn'],
200
252
  rate_limits: limits,
201
253
  active_calls: activeCalls
202
- });
254
+ };
255
+ // A2A-52: include agent public key so contacts can fetch it
256
+ if (options.publicKey) {
257
+ response.public_key = options.publicKey;
258
+ }
259
+ res.json(response);
203
260
  });
204
261
 
205
262
  /**
@@ -291,6 +348,14 @@ function createRoutes(options = {}) {
291
348
  }));
292
349
  }
293
350
 
351
+ // A2A-52: Ed25519 signature verification (after token auth, before message handling)
352
+ const sigCheck = verifySigHeaders(req, validation, '/api/a2a/invoke', reqLogger, withTracePayload);
353
+ if (sigCheck.error) {
354
+ return res.status(sigCheck.error.status).json(withTracePayload(sigCheck.error.body));
355
+ }
356
+ const identityVerified = sigCheck.identityVerified;
357
+ const publicKeyFingerprint = sigCheck.publicKeyFingerprint;
358
+
294
359
  // Extract and validate request
295
360
  const { message, conversation_id, caller, context, timeout_seconds = 60 } = req.body;
296
361
 
@@ -353,7 +418,10 @@ function createRoutes(options = {}) {
353
418
  caller: sanitizedCaller,
354
419
  conversation_id: conversation_id || `conv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`,
355
420
  trace_id: traceId,
356
- request_id: requestId
421
+ request_id: requestId,
422
+ // A2A-52: cryptographic identity verification status
423
+ identity_verified: identityVerified,
424
+ public_key_fingerprint: publicKeyFingerprint
357
425
  };
358
426
 
359
427
  // Ensure inbound caller exists as a contact (best-effort).
@@ -568,10 +636,16 @@ function createRoutes(options = {}) {
568
636
  return res.status(401).json(withTracePayload({
569
637
  success: false,
570
638
  error: 'unauthorized',
571
- message: 'Invalid or expired token'
639
+ message: 'Invalid or expired token'
572
640
  }));
573
641
  }
574
642
 
643
+ // A2A-52: Ed25519 signature verification for /end (same as /invoke)
644
+ const endSigCheck = verifySigHeaders(req, validation, '/api/a2a/end', reqLogger, withTracePayload);
645
+ if (endSigCheck.error) {
646
+ return res.status(endSigCheck.error.status).json(withTracePayload(endSigCheck.error.body));
647
+ }
648
+
575
649
  const { conversation_id } = req.body;
576
650
  if (!conversation_id) {
577
651
  reqLogger.warn('End request missing conversation_id', {
@@ -1331,7 +1331,7 @@ function createDashboardApiRouter(options = {}) {
1331
1331
  success: true,
1332
1332
  onboarding_complete: context.config.isOnboarded(),
1333
1333
  defaults: cfg.defaults || {},
1334
- agent: cfg.agent || {},
1334
+ agent: (() => { const { private_key, ...pub } = cfg.agent || {}; return pub; })(),
1335
1335
  tiers,
1336
1336
  manifest: {
1337
1337
  never_disclose: manifest.never_disclose || [],
package/src/server.js CHANGED
@@ -901,9 +901,12 @@ app.use('/dashboard', createDashboardUiRouter({
901
901
  }));
902
902
  app.use('/callbook', createCallbookRouter());
903
903
 
904
+ // A2A-52: pass agent's public key so /status can advertise it
905
+ const _a2aKeypair = config.getKeypair();
904
906
  app.use('/api/a2a', createRoutes({
905
907
  tokenStore,
906
908
  eventStore,
909
+ publicKey: _a2aKeypair ? _a2aKeypair.publicKey : null,
907
910
  logger: logger.child({ component: 'a2a.routes' }),
908
911
  onCallMonitor: (monitor) => {
909
912
  activeCallMonitor = monitor;