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.
- package/.a2a-manifest.json +2 -2
- package/ARCHITECTURE.md +10 -1
- package/CONVENTIONS.md +18 -0
- package/bin/cli.js +77 -9
- package/package.json +1 -1
- package/src/dashboard/public/app.js +16 -6
- package/src/dashboard/public/index.html +2 -0
- package/src/dashboard/public/style.css +19 -0
- package/src/lib/client.js +170 -33
- package/src/lib/config.js +22 -3
- package/src/lib/conversation-driver.js +7 -1
- package/src/lib/crypto.js +113 -0
- package/src/lib/tokens.js +4 -1
- package/src/routes/a2a.js +78 -4
- package/src/routes/dashboard.js +1 -1
- package/src/server.js +3 -0
package/.a2a-manifest.json
CHANGED
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
|
|
1112
|
-
console.error(' --owner, -o
|
|
1113
|
-
console.error(' --server-name
|
|
1114
|
-
console.error(' --notes
|
|
1115
|
-
console.error(' --tags
|
|
1116
|
-
console.error(' --link
|
|
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
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
1806
|
-
zone.addEventListener('
|
|
1807
|
-
zone.addEventListener('
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
res
|
|
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 (
|
|
118
|
-
reject(new A2AError(json.error || 'request_failed', json.message || data,
|
|
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}`,
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
res
|
|
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 (
|
|
187
|
-
reject(new A2AError(json.error || 'request_failed', json.message || data,
|
|
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}`,
|
|
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
|
-
|
|
234
|
-
res
|
|
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:
|
|
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
|
-
|
|
276
|
-
res
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', {
|
package/src/routes/dashboard.js
CHANGED
|
@@ -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;
|