a2acalling 0.6.73 → 0.6.74

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.73",
3
+ "version": "0.6.74",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -10,7 +10,10 @@
10
10
  "scripts": {
11
11
  "postinstall": "node scripts/postinstall.js",
12
12
  "start": "node src/server.js",
13
- "test": "node test/run.js"
13
+ "test": "node test/run.js",
14
+ "lint": "biome lint src/",
15
+ "lint:check": "biome check src/",
16
+ "knip": "knip"
14
17
  },
15
18
  "keywords": [
16
19
  "openclaw",
@@ -36,5 +39,11 @@
36
39
  "dependencies": {
37
40
  "better-sqlite3": "^11.10.0",
38
41
  "express": "^4.21.0"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.4.4",
45
+ "eslint": "^10.0.2",
46
+ "eslint-plugin-sonarjs": "^4.0.0",
47
+ "knip": "^5.85.0"
39
48
  }
40
49
  }
@@ -770,8 +770,8 @@ async function install() {
770
770
  const runtimeLine = forceStandalone
771
771
  ? 'Runtime forced to standalone mode for this setup run.'
772
772
  : hasOpenClawBinary
773
- ? 'Runtime auto-selects OpenClaw when available and falls back to generic if needed.'
774
- : 'Runtime defaults to generic fallback (no OpenClaw dependency required).';
773
+ ? 'Runtime auto-selects OpenClaw when available and falls back to test mode if needed.'
774
+ : 'Runtime defaults to test mode (no OpenClaw dependency required).';
775
775
 
776
776
  const { splitHostPort, isLocalOrUnroutableHost } = require('../src/lib/invite-host');
777
777
  const inviteParsed = splitHostPort(inviteHost);
@@ -890,10 +890,8 @@ ${standaloneBootstrap
890
890
  ${green(standaloneBootstrap.notifyScript)}
891
891
 
892
892
  Optional bridge wiring:
893
- export A2A_RUNTIME=generic
893
+ export A2A_RUNTIME=test
894
894
  export A2A_AGENT_COMMAND="${standaloneBootstrap.turnScript}"
895
- export A2A_SUMMARY_COMMAND="${standaloneBootstrap.summaryScript}"
896
- export A2A_NOTIFY_COMMAND="${standaloneBootstrap.notifyScript}"
897
895
  ${standaloneBootstrap.generatedAdminToken ? `
898
896
  Suggested dashboard admin token (set in env, do not commit):
899
897
  export A2A_ADMIN_TOKEN="${standaloneBootstrap.generatedAdminToken}"` : ''}`
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Google A2A Agent Card Builder
3
+ *
4
+ * Assembles a standards-compliant Agent Card from existing config,
5
+ * disclosure manifest, and crypto identity. The card is served at
6
+ * GET /.well-known/a2a-agent-card and mirrored at GET /api/a2a/agent-card.
7
+ *
8
+ * Reference: A2A-75 assessment, A2A-76 implementation ticket.
9
+ */
10
+
11
+ const crypto = require('node:crypto');
12
+ const { fingerprint } = require('./crypto');
13
+
14
+ /**
15
+ * Build a Google A2A-compliant Agent Card.
16
+ *
17
+ * @param {object} opts
18
+ * @param {object} opts.config - Result of A2AConfig.getAgent()
19
+ * @param {object} opts.manifest - Result of getTopicsForTier('public')
20
+ * @param {string|null} opts.publicKey - Base64-encoded Ed25519 public key (or null)
21
+ * @param {string} opts.serverUrl - Externally-reachable base URL (e.g. "https://host.com")
22
+ * @param {string} opts.version - Package version string
23
+ * @returns {object} Agent Card JSON
24
+ */
25
+ function buildAgentCard({ config, manifest, publicKey, serverUrl, version }) {
26
+ const agentName = config?.name || 'a2a-agent';
27
+ const agentDescription = config?.description || '';
28
+ const ownerName = config?.owner || '';
29
+
30
+ // Agent ID: Ed25519 fingerprint if available, else deterministic hash of name + hostname
31
+ const id = publicKey
32
+ ? fingerprint(publicKey)
33
+ : crypto.createHash('sha256')
34
+ .update(`${agentName}:${config?.hostname || 'localhost'}`)
35
+ .digest('hex')
36
+ .match(/.{2}/g)
37
+ .join(':');
38
+
39
+ // Map public-tier disclosure topics → Agent Card skills
40
+ const topics = (manifest && Array.isArray(manifest.topics)) ? manifest.topics : [];
41
+ const skills = topics
42
+ .filter(t => t?.topic)
43
+ .map(t => ({
44
+ id: t.topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
45
+ name: t.topic,
46
+ description: t.description || ''
47
+ }));
48
+
49
+ // Normalize server URL (strip trailing slash)
50
+ const baseUrl = (serverUrl || '').replace(/\/+$/, '');
51
+
52
+ const card = {
53
+ id,
54
+ name: agentName,
55
+ version: version || '0.0.0',
56
+ provider: ownerName ? { name: ownerName } : undefined,
57
+ description: agentDescription || undefined,
58
+ capabilities: {
59
+ streaming: false,
60
+ pushNotifications: false,
61
+ extendedAgentCard: false
62
+ },
63
+ skills,
64
+ interfaces: [
65
+ {
66
+ type: 'rest',
67
+ url: `${baseUrl}/api/a2a/`,
68
+ version: '0.3'
69
+ }
70
+ ],
71
+ securitySchemes: {
72
+ bearerAuth: {
73
+ type: 'http',
74
+ scheme: 'bearer',
75
+ description: 'A2A federation token (fed_xxx)'
76
+ }
77
+ },
78
+ security: [{ bearerAuth: [] }],
79
+ extensions: [
80
+ {
81
+ uri: 'https://openclaw.dev/a2a/extensions/trust-tiers',
82
+ version: '1.0.0',
83
+ required: false,
84
+ data: {
85
+ tiers: ['public', 'friends', 'family'],
86
+ default_tier: 'public',
87
+ disclosure_levels: ['public', 'minimal', 'none'],
88
+ default_disclosure: 'minimal',
89
+ supports_topics: true,
90
+ supports_goals: true,
91
+ owner_notifications: true,
92
+ max_calls_enforced: true
93
+ }
94
+ }
95
+ ]
96
+ };
97
+
98
+ // Include signature identity only when a keypair exists
99
+ if (publicKey) {
100
+ card.signature = {
101
+ algorithm: 'ed25519',
102
+ publicKey,
103
+ fingerprint: fingerprint(publicKey)
104
+ };
105
+ }
106
+
107
+ // Strip undefined values for clean JSON
108
+ return JSON.parse(JSON.stringify(card));
109
+ }
110
+
111
+ module.exports = { buildAgentCard };
package/src/lib/client.js CHANGED
@@ -19,16 +19,198 @@ const RETRYABLE_CODES = ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENOTFOUND', 'EA
19
19
  // A2A-54: exponential backoff — first retry is immediate, then 1s, then 2s
20
20
  const RETRY_DELAYS = [0, 1000, 2000];
21
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
- */
22
+ // A2A-80: Agent Card cache — module-level Map with TTL and prune-on-access
23
+ // Each entry: { card: object|null, cachedAt: number }
24
+ // null card = negative cache (failed fetch)
25
+ const _agentCardCache = new Map();
26
+
27
+ function _readPositiveIntEnv(name, defaultVal) {
28
+ const raw = process.env[name];
29
+ if (!raw) return defaultVal;
30
+ const n = Number.parseInt(raw, 10);
31
+ return Number.isFinite(n) && n > 0 ? n : defaultVal;
32
+ }
33
+
34
+ const AGENT_CARD_TTL_MS = _readPositiveIntEnv('A2A_AGENT_CARD_TTL_MS', 300000);
35
+ const AGENT_CARD_MAX_ENTRIES = _readPositiveIntEnv('A2A_AGENT_CARD_MAX_ENTRIES', 200);
36
+ const AGENT_CARD_FETCH_TIMEOUT_MS = 3000;
37
+
38
+ // A2A-80: Prune-on-access eviction (pattern from A2A-69, NOT imported)
39
+ function _pruneAgentCardCache() {
40
+ const now = Date.now();
41
+ for (const [key, entry] of _agentCardCache.entries()) {
42
+ if (now - entry.cachedAt > AGENT_CARD_TTL_MS) {
43
+ _agentCardCache.delete(key);
44
+ }
45
+ }
46
+
47
+ if (_agentCardCache.size <= AGENT_CARD_MAX_ENTRIES) {
48
+ return;
49
+ }
50
+
51
+ const oldest = Array.from(_agentCardCache.entries())
52
+ .sort((a, b) => a[1].cachedAt - b[1].cachedAt);
53
+ const toDelete = _agentCardCache.size - AGENT_CARD_MAX_ENTRIES;
54
+ for (let i = 0; i < toDelete; i++) {
55
+ _agentCardCache.delete(oldest[i][0]);
56
+ }
57
+ }
58
+
59
+ // A2A-80: Cache key is always hostname:port
60
+ function _agentCardCacheKey(host) {
61
+ const parsed = splitHostPort(host);
62
+ const hostname = parsed.hostname;
63
+ const port = Number.isFinite(parsed.port) ? parsed.port : 80;
64
+ return `${hostname}:${port}`;
65
+ }
66
+
67
+ // A2A-80: Validate Agent Card — needs non-empty interfaces[] with { type: 'rest' }
68
+ function _parseAgentCard(json) {
69
+ if (!json || typeof json !== 'object') return null;
70
+ if (!Array.isArray(json.interfaces) || json.interfaces.length === 0) return null;
71
+
72
+ const restInterface = json.interfaces.find(
73
+ iface => iface && iface.type === 'rest'
74
+ );
75
+ if (!restInterface) return null;
76
+
77
+ return json;
78
+ }
79
+
80
+ // A2A-80: Fetch Agent Card (GET /.well-known/a2a-agent-card, 3s timeout, cached with TTL).
81
+ // TODO: Concurrent call() to same uncached host may duplicate Agent Card fetches.
82
+ function fetchRemoteAgentCard(host) {
83
+ _pruneAgentCardCache();
84
+
85
+ const cacheKey = _agentCardCacheKey(host);
86
+ const cached = _agentCardCache.get(cacheKey);
87
+ if (cached) {
88
+ return Promise.resolve(cached.card);
89
+ }
90
+
91
+ const { protocol, hostname, port } = resolveProtocolAndPort(host);
92
+
93
+ return new Promise((resolve) => {
94
+ const req = protocol.request({
95
+ hostname,
96
+ port,
97
+ path: '/.well-known/a2a-agent-card',
98
+ method: 'GET',
99
+ timeout: AGENT_CARD_FETCH_TIMEOUT_MS
100
+ }, (res) => {
101
+ let data = '';
102
+ let bytes = 0;
103
+ res.on('data', (chunk) => {
104
+ bytes += chunk.length;
105
+ if (bytes > MAX_RESPONSE_BYTES) {
106
+ res.destroy();
107
+ _agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
108
+ resolve(null);
109
+ return;
110
+ }
111
+ data += chunk;
112
+ });
113
+ res.on('end', () => {
114
+ if (res.statusCode !== 200) {
115
+ _agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
116
+ resolve(null);
117
+ return;
118
+ }
119
+ try {
120
+ const json = JSON.parse(data);
121
+ const card = _parseAgentCard(json);
122
+ _agentCardCache.set(cacheKey, { card, cachedAt: Date.now() });
123
+ resolve(card);
124
+ } catch {
125
+ _agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
126
+ resolve(null);
127
+ }
128
+ });
129
+ });
130
+
131
+ req.on('error', () => {
132
+ _agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
133
+ resolve(null);
134
+ });
135
+ req.on('timeout', () => {
136
+ req.destroy();
137
+ _agentCardCache.set(cacheKey, { card: null, cachedAt: Date.now() });
138
+ resolve(null);
139
+ });
140
+ req.end();
141
+ });
142
+ }
143
+
144
+ // A2A-80: Build Google A2A message/send body (ref: a2a.js translateInternalToGoogle)
145
+ function _translateToGoogleRequest(message, conversationId, options = {}, caller = {}) {
146
+ return {
147
+ message: {
148
+ role: 'user',
149
+ parts: [{ content: { text: message } }],
150
+ ...(conversationId ? { context_id: conversationId } : {})
151
+ },
152
+ metadata: {
153
+ caller_name: String(caller.name || '').slice(0, 100),
154
+ caller_owner: String(caller.owner || '').slice(0, 100),
155
+ caller_instance: String(caller.instance || '').slice(0, 200)
156
+ },
157
+ configuration: {
158
+ timeout_seconds: options.timeoutSeconds || 60,
159
+ blocking: true
160
+ }
161
+ };
162
+ }
163
+
164
+ // A2A-80: Translate Google A2A Task response to internal format (ref: a2a.js translateGoogleToInternal)
165
+ function _translateGoogleResponse(taskResponse) {
166
+ const task = taskResponse?.task;
167
+ if (!task || !task.status) {
168
+ throw new A2AError('google_a2a_error', 'Invalid Google A2A response: missing task or status');
169
+ }
170
+
171
+ const parts = task.status.message?.parts || [];
172
+ const textParts = [];
173
+ for (const part of parts) {
174
+ if (part?.content && typeof part.content.text === 'string') {
175
+ textParts.push(part.content.text);
176
+ }
177
+ }
178
+
179
+ const response = textParts.join('\n');
180
+ const state = task.status.state;
181
+ const canContinue = state === 'input-required';
182
+
183
+ return {
184
+ response,
185
+ conversation_id: task.context_id || null,
186
+ can_continue: canContinue
187
+ };
188
+ }
189
+
190
+ // A2A-80: Resolve message:send URL from Agent Card REST interface (trailing slash stripped)
191
+ function _resolveGoogleA2AUrl(agentCard, host) {
192
+ const restInterface = agentCard.interfaces.find(
193
+ iface => iface && iface.type === 'rest'
194
+ );
195
+
196
+ if (restInterface && restInterface.url) {
197
+ const baseUrl = restInterface.url.replace(/\/+$/, '');
198
+ return `${baseUrl}/message:send`;
199
+ }
200
+
201
+ // Fallback: build from host
202
+ const { hostname, port } = splitHostPort(host);
203
+ const effectivePort = Number.isFinite(port) ? port : 80;
204
+ const isLocalhost = hostname === 'localhost' ||
205
+ hostname === '127.0.0.1' ||
206
+ hostname === '::1' ||
207
+ hostname.startsWith('127.');
208
+ const scheme = (isLocalhost || effectivePort === 80 || (Number.isFinite(port) && port !== 443))
209
+ ? 'http' : 'https';
210
+ return `${scheme}://${hostname}:${effectivePort}/message:send`;
211
+ }
212
+
213
+ // A2A-54: Retry on transient network errors only (not HTTP 4xx/5xx)
32
214
  async function withRetry(fn, options = {}) {
33
215
  const delays = options.delays || RETRY_DELAYS;
34
216
  const maxAttempts = delays.length + 1;
@@ -113,16 +295,7 @@ function resolveProtocolAndPort(host) {
113
295
  return { protocol, hostname, port };
114
296
  }
115
297
 
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
- */
298
+ // A2A-54: Size-capped response handler — destroys socket if cap exceeded
126
299
  function handleSizeCappedResponse(res, resolve, reject, onComplete) {
127
300
  let data = '';
128
301
  let bytes = 0;
@@ -159,10 +332,7 @@ class A2AClient {
159
332
  this._retryDelays = options._retryDelays || RETRY_DELAYS;
160
333
  }
161
334
 
162
- /**
163
- * A2A-52: Build signature headers if keypair is available.
164
- * Shared helper used by both call() and end().
165
- */
335
+ // A2A-52: Build signature headers if keypair available
166
336
  _signHeaders(method, endpoint, body) {
167
337
  if (!this.privateKey || !this.publicKey) return {};
168
338
  return signRequest({
@@ -174,9 +344,6 @@ class A2AClient {
174
344
  });
175
345
  }
176
346
 
177
- /**
178
- * Parse an a2a:// URL
179
- */
180
347
  static parseInvite(inviteUrl) {
181
348
  const match = inviteUrl.match(/^a2a:\/\/([^/]+)\/(.+)$/);
182
349
  if (!match) {
@@ -185,14 +352,70 @@ class A2AClient {
185
352
  return { host: match[1], token: match[2] };
186
353
  }
187
354
 
188
- /**
189
- * Call a remote agent
190
- *
191
- * @param {string|object} endpoint - a2a:// URL or {host, token}
192
- * @param {string} message - Message to send
193
- * @param {object} options - Additional options
194
- * @returns {Promise<object>} Response from remote agent
195
- */
355
+ // A2A-80: Send message via Google A2A protocol (message/send format)
356
+ _callGoogleA2A(host, token, body, agentCard) {
357
+ const url = _resolveGoogleA2AUrl(agentCard, host);
358
+ // Parse the URL to extract components for http/https request
359
+ const parsed = new URL(url);
360
+ const proto = parsed.protocol === 'https:' ? https : http;
361
+ const path = parsed.pathname;
362
+
363
+ // A2A-52: attach signature headers when keypair available
364
+ const sigHeaders = this._signHeaders('POST', path, body);
365
+
366
+ const makeRequest = () => new Promise((resolve, reject) => {
367
+ const req = proto.request({
368
+ hostname: parsed.hostname,
369
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
370
+ path,
371
+ method: 'POST',
372
+ headers: {
373
+ 'Authorization': `Bearer ${token}`,
374
+ 'Content-Type': 'application/json',
375
+ 'Content-Length': Buffer.byteLength(body),
376
+ ...sigHeaders
377
+ },
378
+ timeout: this.timeout
379
+ }, (res) => {
380
+ handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
381
+ try {
382
+ const json = JSON.parse(data);
383
+ if (statusCode >= 400) {
384
+ // A2A-80: map Google A2A error format to A2AError
385
+ const errObj = json.error || {};
386
+ const code = errObj.code || json.error || 'google_a2a_error';
387
+ const message = errObj.message || json.message || data;
388
+ reject(new A2AError(String(code), message, statusCode));
389
+ } else {
390
+ resolve(_translateGoogleResponse(json));
391
+ }
392
+ } catch (e) {
393
+ if (e instanceof A2AError) {
394
+ reject(e);
395
+ } else {
396
+ reject(new A2AError('parse_error', `Failed to parse Google A2A response: ${data}`, statusCode));
397
+ }
398
+ }
399
+ });
400
+ });
401
+
402
+ req.on('error', (e) => {
403
+ reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
404
+ });
405
+
406
+ req.on('timeout', () => {
407
+ req.destroy();
408
+ reject(new A2AError('timeout', 'Request timed out'));
409
+ });
410
+
411
+ req.write(body);
412
+ req.end();
413
+ });
414
+
415
+ return withRetry(makeRequest, { delays: this._retryDelays });
416
+ }
417
+
418
+ // Call a remote agent — auto-detects Google A2A via Agent Card
196
419
  async call(endpoint, message, options = {}) {
197
420
  let host, token;
198
421
 
@@ -204,6 +427,18 @@ class A2AClient {
204
427
 
205
428
  const { conversationId, context, timeoutSeconds } = options;
206
429
 
430
+ // A2A-80: check Agent Card to decide Google A2A vs proprietary format
431
+ const agentCard = await fetchRemoteAgentCard(host);
432
+
433
+ if (agentCard) {
434
+ // Google A2A format path
435
+ const googleBody = JSON.stringify(
436
+ _translateToGoogleRequest(message, conversationId, { timeoutSeconds }, this.caller)
437
+ );
438
+ return this._callGoogleA2A(host, token, googleBody, agentCard);
439
+ }
440
+
441
+ // Proprietary format path (unchanged)
207
442
  const body = JSON.stringify({
208
443
  message,
209
444
  conversation_id: conversationId,
@@ -262,13 +497,7 @@ class A2AClient {
262
497
  return withRetry(makeRequest, { delays: this._retryDelays });
263
498
  }
264
499
 
265
- /**
266
- * Explicitly end a remote conversation and trigger call conclusion
267
- *
268
- * @param {string|object} endpoint - a2a:// URL or {host, token}
269
- * @param {string} conversationId - Conversation ID to conclude
270
- * @returns {Promise<object>} End response from remote agent
271
- */
500
+ // End a remote conversation — no-op for Google A2A remotes
272
501
  async end(endpoint, conversationId) {
273
502
  if (!conversationId) {
274
503
  throw new A2AError('missing_conversation_id', 'conversationId is required');
@@ -282,6 +511,17 @@ class A2AClient {
282
511
  ({ host, token } = endpoint);
283
512
  }
284
513
 
514
+ // A2A-80: Google A2A remotes don't have an end endpoint — return synthetic response
515
+ const agentCard = await fetchRemoteAgentCard(host);
516
+ if (agentCard) {
517
+ logger.info('Skipping end() for Google A2A remote', {
518
+ event: 'google_a2a_end_skipped',
519
+ data: { conversationId, host }
520
+ });
521
+ return { ended: true, summary: null };
522
+ }
523
+
524
+ // Proprietary format path (unchanged)
285
525
  const body = JSON.stringify({
286
526
  conversation_id: conversationId
287
527
  });
@@ -336,9 +576,6 @@ class A2AClient {
336
576
  return withRetry(makeRequest, { delays: this._retryDelays });
337
577
  }
338
578
 
339
- /**
340
- * Check if a remote agent is available
341
- */
342
579
  async ping(endpoint) {
343
580
  let host;
344
581
 
@@ -378,9 +615,6 @@ class A2AClient {
378
615
  });
379
616
  }
380
617
 
381
- /**
382
- * Get A2A status of a remote
383
- */
384
618
  async status(endpoint) {
385
619
  let host;
386
620
 
@@ -431,6 +665,7 @@ class A2AError extends Error {
431
665
  }
432
666
 
433
667
  // A2A-54: export internals for testing (splitHostPort, resolveProtocolAndPort, constants)
668
+ // A2A-80: export Agent Card cache and helpers for testing
434
669
  module.exports = {
435
670
  A2AClient,
436
671
  A2AError,
@@ -438,5 +673,11 @@ module.exports = {
438
673
  _resolveProtocolAndPort: resolveProtocolAndPort,
439
674
  _MAX_RESPONSE_BYTES: MAX_RESPONSE_BYTES,
440
675
  _RETRYABLE_CODES: RETRYABLE_CODES,
441
- _RETRY_DELAYS: RETRY_DELAYS
676
+ _RETRY_DELAYS: RETRY_DELAYS,
677
+ _agentCardCache,
678
+ _parseAgentCard,
679
+ _translateToGoogleRequest,
680
+ _translateGoogleResponse,
681
+ _resolveGoogleA2AUrl,
682
+ fetchRemoteAgentCard
442
683
  };
@@ -82,6 +82,8 @@ class ConversationStore {
82
82
  */
83
83
  _migrate() {
84
84
  this.db.exec(`
85
+ PRAGMA journal_mode = WAL;
86
+
85
87
  -- Conversations with remote agents
86
88
  CREATE TABLE IF NOT EXISTS conversations (
87
89
  id TEXT PRIMARY KEY,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Local Request Detection Utilities
3
+ *
4
+ * A2A-73: Extracted from dashboard.js and a2a.js to provide a single,
5
+ * proxy-aware implementation of local request detection. The previous
6
+ * isLoopbackAddress(req.ip) check was insufficient behind reverse proxies
7
+ * because Express (without trust proxy) reports the proxy's IP, not the
8
+ * real client IP.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ /**
14
+ * Check if an IP address is a loopback address.
15
+ * Handles IPv4, IPv6, and IPv4-mapped IPv6 formats.
16
+ */
17
+ function isLoopbackAddress(ip) {
18
+ if (!ip) return false;
19
+ if (ip === '::1' || ip === '127.0.0.1' || ip === '::ffff:127.0.0.1') {
20
+ return true;
21
+ }
22
+ // Full 127.0.0.0/8 range is loopback in IPv4
23
+ if (ip.startsWith('127.')) return true;
24
+ return ip.startsWith('::ffff:127.');
25
+ }
26
+
27
+ /**
28
+ * Determine if a request is a direct local connection (not proxied).
29
+ *
30
+ * A2A-73: This is the security-critical check. A request is only considered
31
+ * "direct local" if ALL of these conditions hold:
32
+ * 1. Socket remote address is loopback (the TCP connection is local)
33
+ * 2. Host header targets localhost (not a public hostname)
34
+ * 3. No proxy-forwarding headers are present (rules out nginx/CDN traffic)
35
+ *
36
+ * Without condition 3, any request through a reverse proxy would pass
37
+ * because the proxy connects from 127.0.0.1 to the backend.
38
+ */
39
+ function isDirectLocalRequest(req) {
40
+ const ip = (req && req.socket && req.socket.remoteAddress) ? req.socket.remoteAddress : req.ip;
41
+ if (!isLoopbackAddress(ip)) return false;
42
+
43
+ const rawHost = String(req.headers.host || '').toLowerCase();
44
+ // Strip port suffix to get the bare hostname for exact matching.
45
+ // This prevents DNS rebinding via e.g. localhost.evil.com or 127.0.0.1.nip.io.
46
+ // Negative lookbehind avoids stripping `:1` from bare IPv6 `::1`.
47
+ const hostname = rawHost.replace(/(?<!:):\d+$/, '');
48
+ const isLocalHost = hostname === 'localhost' ||
49
+ hostname === '127.0.0.1' ||
50
+ hostname === '[::1]' ||
51
+ hostname === '::1';
52
+ if (!isLocalHost) return false;
53
+
54
+ // A2A-73: Reject requests with any proxy-forwarding header. These indicate
55
+ // the request was relayed by nginx, a CDN, or another reverse proxy —
56
+ // even though the socket address is loopback (proxy → backend is local).
57
+ const forwarded = req.headers['x-forwarded-for'] ||
58
+ req.headers['x-forwarded-proto'] ||
59
+ req.headers['x-forwarded-host'] ||
60
+ req.headers['cf-connecting-ip'] ||
61
+ req.headers['x-forwarded-by'] ||
62
+ req.headers['x-real-ip'] ||
63
+ req.headers['forwarded'];
64
+ if (forwarded) return false;
65
+
66
+ return true;
67
+ }
68
+
69
+ module.exports = { isLoopbackAddress, isDirectLocalRequest };
package/src/lib/logger.js CHANGED
@@ -161,6 +161,8 @@ class LogStore {
161
161
 
162
162
  _ensureSchema() {
163
163
  this.db.exec(`
164
+ PRAGMA journal_mode = WAL;
165
+
164
166
  CREATE TABLE IF NOT EXISTS logs (
165
167
  id INTEGER PRIMARY KEY AUTOINCREMENT,
166
168
  timestamp TEXT NOT NULL,