a2acalling 0.6.66 → 0.6.68

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.66",
3
- "installed_at": "2026-02-25T09:43:06.371Z",
2
+ "version": "0.6.68",
3
+ "installed_at": "2026-02-25T19:26:27.869Z",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.66",
3
+ "version": "0.6.68",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -348,6 +348,14 @@ class CallbookStore {
348
348
  });
349
349
  return tx();
350
350
  }
351
+
352
+ // A2A-57: Close the SQLite database and flush WAL on shutdown
353
+ close() {
354
+ if (this.db) {
355
+ try { this.db.close(); } catch (_) {}
356
+ this.db = null;
357
+ }
358
+ }
351
359
  }
352
360
 
353
361
  module.exports = {
package/src/lib/client.js CHANGED
@@ -5,6 +5,68 @@
5
5
  const https = require('https');
6
6
  const http = require('http');
7
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
+ }
8
70
 
9
71
  function splitHostPort(rawHost) {
10
72
  const host = String(rawHost || '').trim();
@@ -51,6 +113,41 @@ function resolveProtocolAndPort(host) {
51
113
  return { protocol, hostname, port };
52
114
  }
53
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
+
54
151
  class A2AClient {
55
152
  constructor(options = {}) {
56
153
  this.timeout = options.timeout || 60000;
@@ -58,6 +155,8 @@ class A2AClient {
58
155
  // A2A-52: Ed25519 identity keys for request signing
59
156
  this.privateKey = options.privateKey || null;
60
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;
61
160
  }
62
161
 
63
162
  /**
@@ -88,7 +187,7 @@ class A2AClient {
88
187
 
89
188
  /**
90
189
  * Call a remote agent
91
- *
190
+ *
92
191
  * @param {string|object} endpoint - a2a:// URL or {host, token}
93
192
  * @param {string} message - Message to send
94
193
  * @param {object} options - Additional options
@@ -96,7 +195,7 @@ class A2AClient {
96
195
  */
97
196
  async call(endpoint, message, options = {}) {
98
197
  let host, token;
99
-
198
+
100
199
  if (typeof endpoint === 'string') {
101
200
  ({ host, token } = A2AClient.parseInvite(endpoint));
102
201
  } else {
@@ -117,7 +216,8 @@ class A2AClient {
117
216
  // A2A-52: attach signature headers when keypair available
118
217
  const sigHeaders = this._signHeaders('POST', '/api/a2a/invoke', body);
119
218
 
120
- return new Promise((resolve, reject) => {
219
+ // A2A-54: wrap with retry for transient network failures
220
+ const makeRequest = () => new Promise((resolve, reject) => {
121
221
  const req = protocol.request({
122
222
  hostname,
123
223
  port,
@@ -131,24 +231,23 @@ class A2AClient {
131
231
  },
132
232
  timeout: this.timeout
133
233
  }, (res) => {
134
- let data = '';
135
- res.on('data', chunk => data += chunk);
136
- res.on('end', () => {
234
+ // A2A-54: size-capped response accumulation
235
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
137
236
  try {
138
237
  const json = JSON.parse(data);
139
- if (res.statusCode >= 400) {
140
- 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));
141
240
  } else {
142
241
  resolve(json);
143
242
  }
144
243
  } catch (e) {
145
- reject(new A2AError('parse_error', `Failed to parse response: ${data}`, res.statusCode));
244
+ reject(new A2AError('parse_error', `Failed to parse response: ${data}`, statusCode));
146
245
  }
147
246
  });
148
247
  });
149
248
 
150
249
  req.on('error', (e) => {
151
- reject(new A2AError('network_error', e.message));
250
+ reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
152
251
  });
153
252
 
154
253
  req.on('timeout', () => {
@@ -159,6 +258,8 @@ class A2AClient {
159
258
  req.write(body);
160
259
  req.end();
161
260
  });
261
+
262
+ return withRetry(makeRequest, { delays: this._retryDelays });
162
263
  }
163
264
 
164
265
  /**
@@ -189,7 +290,8 @@ class A2AClient {
189
290
  // A2A-52: attach signature headers when keypair available
190
291
  const sigHeaders = this._signHeaders('POST', '/api/a2a/end', body);
191
292
 
192
- return new Promise((resolve, reject) => {
293
+ // A2A-54: wrap with retry for transient network failures
294
+ const makeRequest = () => new Promise((resolve, reject) => {
193
295
  const req = protocol.request({
194
296
  hostname,
195
297
  port,
@@ -203,24 +305,23 @@ class A2AClient {
203
305
  },
204
306
  timeout: this.timeout
205
307
  }, (res) => {
206
- let data = '';
207
- res.on('data', chunk => data += chunk);
208
- res.on('end', () => {
308
+ // A2A-54: size-capped response accumulation
309
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
209
310
  try {
210
311
  const json = JSON.parse(data);
211
- if (res.statusCode >= 400) {
212
- 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));
213
314
  } else {
214
315
  resolve(json);
215
316
  }
216
317
  } catch (e) {
217
- reject(new A2AError('parse_error', `Failed to parse response: ${data}`, res.statusCode));
318
+ reject(new A2AError('parse_error', `Failed to parse response: ${data}`, statusCode));
218
319
  }
219
320
  });
220
321
  });
221
322
 
222
323
  req.on('error', (e) => {
223
- reject(new A2AError('network_error', e.message));
324
+ reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
224
325
  });
225
326
 
226
327
  req.on('timeout', () => {
@@ -231,6 +332,8 @@ class A2AClient {
231
332
  req.write(body);
232
333
  req.end();
233
334
  });
335
+
336
+ return withRetry(makeRequest, { delays: this._retryDelays });
234
337
  }
235
338
 
236
339
  /**
@@ -238,7 +341,7 @@ class A2AClient {
238
341
  */
239
342
  async ping(endpoint) {
240
343
  let host;
241
-
344
+
242
345
  if (typeof endpoint === 'string') {
243
346
  ({ host } = A2AClient.parseInvite(endpoint));
244
347
  } else {
@@ -247,6 +350,7 @@ class A2AClient {
247
350
 
248
351
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
249
352
 
353
+ // A2A-54: no retry for ping — it's a lightweight probe, not a critical call
250
354
  return new Promise((resolve, reject) => {
251
355
  const req = protocol.request({
252
356
  hostname,
@@ -255,13 +359,12 @@ class A2AClient {
255
359
  method: 'GET',
256
360
  timeout: 5000
257
361
  }, (res) => {
258
- let data = '';
259
- res.on('data', chunk => data += chunk);
260
- res.on('end', () => {
362
+ // A2A-54: size-capped response accumulation
363
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
261
364
  try {
262
365
  resolve(JSON.parse(data));
263
366
  } catch {
264
- resolve({ pong: res.statusCode === 200 });
367
+ resolve({ pong: statusCode === 200 });
265
368
  }
266
369
  });
267
370
  });
@@ -280,7 +383,7 @@ class A2AClient {
280
383
  */
281
384
  async status(endpoint) {
282
385
  let host;
283
-
386
+
284
387
  if (typeof endpoint === 'string') {
285
388
  ({ host } = A2AClient.parseInvite(endpoint));
286
389
  } else {
@@ -289,6 +392,7 @@ class A2AClient {
289
392
 
290
393
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
291
394
 
395
+ // A2A-54: no retry for status — read-only probe, not a stateful operation
292
396
  return new Promise((resolve, reject) => {
293
397
  const req = protocol.request({
294
398
  hostname,
@@ -297,9 +401,8 @@ class A2AClient {
297
401
  method: 'GET',
298
402
  timeout: 5000
299
403
  }, (res) => {
300
- let data = '';
301
- res.on('data', chunk => data += chunk);
302
- res.on('end', () => {
404
+ // A2A-54: size-capped response accumulation
405
+ handleSizeCappedResponse(res, resolve, reject, (data) => {
303
406
  try {
304
407
  resolve(JSON.parse(data));
305
408
  } catch {
@@ -308,7 +411,7 @@ class A2AClient {
308
411
  });
309
412
  });
310
413
 
311
- 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)));
312
415
  req.on('timeout', () => {
313
416
  req.destroy();
314
417
  reject(new A2AError('timeout', 'Request timed out'));
@@ -327,4 +430,13 @@ class A2AError extends Error {
327
430
  }
328
431
  }
329
432
 
330
- 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
+ };
@@ -182,6 +182,14 @@ class DashboardEventStore {
182
182
  };
183
183
  }
184
184
 
185
+ // A2A-57: Close the SQLite database and flush WAL on shutdown
186
+ close() {
187
+ if (this.db) {
188
+ try { this.db.close(); } catch (_) {}
189
+ this.db = null;
190
+ }
191
+ }
192
+
185
193
  _toEvent(row) {
186
194
  let payload = {};
187
195
  try {
package/src/server.js CHANGED
@@ -21,7 +21,7 @@ const {
21
21
  extractCollaborationState
22
22
  } = require('./lib/prompt-template');
23
23
  const { findAvailablePort } = require('./lib/port-scanner');
24
- const { createLogger } = require('./lib/logger');
24
+ const { createLogger, closeAllLoggerStores } = require('./lib/logger');
25
25
  const { writePidFile, removePidFile } = require('./lib/pid-file');
26
26
  const { buildUnifiedSummaryPrompt } = require('./lib/summary-prompt');
27
27
  const { A2AConfig } = require('./lib/config');
@@ -1073,9 +1073,15 @@ async function startServer() {
1073
1073
  throw err;
1074
1074
  });
1075
1075
 
1076
+ // A2A-57: Close all SQLite stores before shutting down the HTTP server
1076
1077
  function shutdown() {
1077
1078
  if (updateManager) updateManager.stop();
1078
1079
  removePidFile();
1080
+ if (serverConvStore && serverConvStore.close) {
1081
+ try { serverConvStore.close(); } catch (_) {}
1082
+ }
1083
+ try { eventStore.close(); } catch (_) {}
1084
+ try { closeAllLoggerStores(); } catch (_) {}
1079
1085
  server.close(() => process.exit(0));
1080
1086
  // Force exit after 5s if connections won't close
1081
1087
  setTimeout(() => process.exit(0), 5000).unref();