a2acalling 0.6.73 → 0.6.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/.a2a-manifest.json +2 -2
  2. package/.c8rc.json +16 -0
  3. package/.node-version +1 -0
  4. package/.serena/project.yml +126 -0
  5. package/ARCHITECTURE.md +40 -16
  6. package/CONVENTIONS.md +39 -6
  7. package/biome.json +27 -0
  8. package/coverage/base.css +224 -0
  9. package/coverage/block-navigation.js +87 -0
  10. package/coverage/favicon.png +0 -0
  11. package/coverage/index.html +146 -0
  12. package/coverage/prettify.css +1 -0
  13. package/coverage/prettify.js +2 -0
  14. package/coverage/sort-arrow-sprite.png +0 -0
  15. package/coverage/sorter.js +210 -0
  16. package/coverage/src/index.html +131 -0
  17. package/coverage/src/index.js.html +313 -0
  18. package/coverage/src/lib/agent-card.js.html +418 -0
  19. package/coverage/src/lib/call-monitor.js.html +700 -0
  20. package/coverage/src/lib/callbook.js.html +1183 -0
  21. package/coverage/src/lib/claude-subagent.js.html +2173 -0
  22. package/coverage/src/lib/client.js.html +2134 -0
  23. package/coverage/src/lib/config.js.html +1525 -0
  24. package/coverage/src/lib/conversation-driver.js.html +1909 -0
  25. package/coverage/src/lib/conversations.js.html +2575 -0
  26. package/coverage/src/lib/crypto.js.html +424 -0
  27. package/coverage/src/lib/dashboard-events.js.html +724 -0
  28. package/coverage/src/lib/disclosure.js.html +2461 -0
  29. package/coverage/src/lib/external-ip.js.html +718 -0
  30. package/coverage/src/lib/index.html +506 -0
  31. package/coverage/src/lib/invite-host.js.html +754 -0
  32. package/coverage/src/lib/local-request.js.html +292 -0
  33. package/coverage/src/lib/logger.js.html +2116 -0
  34. package/coverage/src/lib/openclaw-integration.js.html +1102 -0
  35. package/coverage/src/lib/pid-file.js.html +394 -0
  36. package/coverage/src/lib/port-scanner.js.html +334 -0
  37. package/coverage/src/lib/prompt-template.js.html +1150 -0
  38. package/coverage/src/lib/runtime-adapter.js.html +2188 -0
  39. package/coverage/src/lib/summarizer.js.html +553 -0
  40. package/coverage/src/lib/summary-formatter.js.html +589 -0
  41. package/coverage/src/lib/summary-prompt.js.html +694 -0
  42. package/coverage/src/lib/tokens.js.html +2689 -0
  43. package/coverage/src/lib/turn-timeout.js.html +241 -0
  44. package/coverage/src/lib/update-checker.js.html +364 -0
  45. package/coverage/src/lib/update-manager.js.html +1024 -0
  46. package/coverage/src/routes/a2a.js.html +3724 -0
  47. package/coverage/src/routes/callbook.js.html +511 -0
  48. package/coverage/src/routes/dashboard.js.html +4819 -0
  49. package/coverage/src/routes/index.html +146 -0
  50. package/coverage/src/server.js.html +3622 -0
  51. package/coverage/tmp/coverage-1605378-1772576706365-0.json +1 -0
  52. package/coverage/tmp/coverage-1605384-1772576607459-0.json +1 -0
  53. package/coverage/tmp/coverage-1605410-1772576631155-0.json +1 -0
  54. package/coverage/tmp/coverage-1606942-1772576636869-0.json +1 -0
  55. package/coverage/tmp/coverage-1607004-1772576637454-0.json +1 -0
  56. package/coverage/tmp/coverage-1607044-1772576637876-0.json +1 -0
  57. package/coverage/tmp/coverage-1607096-1772576638356-0.json +1 -0
  58. package/coverage/tmp/coverage-1607145-1772576638777-0.json +1 -0
  59. package/coverage/tmp/coverage-1607201-1772576639277-0.json +1 -0
  60. package/coverage/tmp/coverage-1607247-1772576639755-0.json +1 -0
  61. package/coverage/tmp/coverage-1607317-1772576640083-0.json +1 -0
  62. package/coverage/tmp/coverage-1607381-1772576640465-0.json +1 -0
  63. package/coverage/tmp/coverage-1607446-1772576640868-0.json +1 -0
  64. package/coverage/tmp/coverage-1607501-1772576641662-0.json +1 -0
  65. package/coverage/tmp/coverage-1607534-1772576641565-0.json +1 -0
  66. package/coverage/tmp/coverage-1607627-1772576641871-0.json +1 -0
  67. package/coverage/tmp/coverage-1607665-1772576642172-0.json +1 -0
  68. package/coverage/tmp/coverage-1607714-1772576642577-0.json +1 -0
  69. package/coverage/tmp/coverage-1607788-1772576643466-0.json +1 -0
  70. package/coverage/tmp/coverage-1607924-1772576644678-0.json +1 -0
  71. package/coverage/tmp/coverage-1607978-1772576645154-0.json +1 -0
  72. package/coverage/tmp/coverage-1608035-1772576645564-0.json +1 -0
  73. package/coverage/tmp/coverage-1608106-1772576645967-0.json +1 -0
  74. package/coverage/tmp/coverage-1608179-1772576648656-0.json +1 -0
  75. package/coverage/tmp/coverage-1608196-1772576647367-0.json +1 -0
  76. package/coverage/tmp/coverage-1608217-1772576648557-0.json +1 -0
  77. package/coverage/tmp/coverage-1608256-1772576651378-0.json +1 -0
  78. package/coverage/tmp/coverage-1608265-1772576650058-0.json +1 -0
  79. package/coverage/tmp/coverage-1608289-1772576651358-0.json +1 -0
  80. package/coverage/tmp/coverage-1608591-1772576660465-0.json +1 -0
  81. package/coverage/tmp/coverage-1608648-1772576659272-0.json +1 -0
  82. package/coverage/tmp/coverage-1608665-1772576660374-0.json +1 -0
  83. package/coverage/tmp/coverage-1608677-1772576661268-0.json +1 -0
  84. package/coverage/tmp/coverage-1608684-1772576663968-0.json +1 -0
  85. package/coverage/tmp/coverage-1608692-1772576662575-0.json +1 -0
  86. package/coverage/tmp/coverage-1608701-1772576663873-0.json +1 -0
  87. package/coverage/tmp/coverage-1608718-1772576666674-0.json +1 -0
  88. package/coverage/tmp/coverage-1608725-1772576665463-0.json +1 -0
  89. package/coverage/tmp/coverage-1608738-1772576666577-0.json +1 -0
  90. package/coverage/tmp/coverage-1608753-1772576669664-0.json +1 -0
  91. package/coverage/tmp/coverage-1608763-1772576668275-0.json +1 -0
  92. package/coverage/tmp/coverage-1608771-1772576669563-0.json +1 -0
  93. package/coverage/tmp/coverage-1608828-1772576676574-0.json +1 -0
  94. package/coverage/tmp/coverage-1609244-1772576675272-0.json +1 -0
  95. package/coverage/tmp/coverage-1609342-1772576676478-0.json +1 -0
  96. package/coverage/tmp/coverage-1609450-1772576686954-0.json +1 -0
  97. package/coverage/tmp/coverage-1609841-1772576685466-0.json +1 -0
  98. package/coverage/tmp/coverage-1609925-1772576686855-0.json +1 -0
  99. package/coverage/tmp/coverage-1610399-1772576692469-0.json +1 -0
  100. package/coverage/tmp/coverage-1611283-1772576703062-0.json +1 -0
  101. package/coverage/tmp/coverage-1611294-1772576703755-0.json +1 -0
  102. package/docs/assessments/2026-02-27-google-a2a-protocol-assessment.md +292 -0
  103. package/docs/plans/2026-03-01-a2a-68-openclaw-integration-tests.md +676 -0
  104. package/docs/plans/2026-03-01-a2a-77-invoke-security-tests.md +661 -0
  105. package/docs/plans/2026-03-03-a2a-91-macos-packaging-plan.md +144 -0
  106. package/docs/signing-setup.md +49 -0
  107. package/eslint.config.js +16 -0
  108. package/knip.json +17 -0
  109. package/native/macos/certs/appldevcert.cer +0 -0
  110. package/native/macos/src-tauri/binaries/.gitkeep +0 -0
  111. package/native/macos/src-tauri/capabilities/default.json +11 -1
  112. package/native/macos/src-tauri/entitlements.plist +14 -0
  113. package/native/macos/src-tauri/src/discovery.rs +14 -3
  114. package/native/macos/src-tauri/src/health.rs +4 -0
  115. package/native/macos/src-tauri/src/lib.rs +52 -11
  116. package/native/macos/src-tauri/src/server.rs +262 -26
  117. package/native/macos/src-tauri/tauri.conf.json +13 -4
  118. package/package.json +16 -2
  119. package/pkg.config.json +14 -0
  120. package/scripts/build-standalone.sh +106 -0
  121. package/scripts/install-openclaw.js +3 -5
  122. package/scripts/smoke-test-standalone.sh +101 -0
  123. package/scripts/sync-version.sh +28 -0
  124. package/scripts/verify-app-bundle.sh +34 -0
  125. package/src/lib/agent-card.js +111 -0
  126. package/src/lib/client.js +290 -49
  127. package/src/lib/conversations.js +2 -0
  128. package/src/lib/local-request.js +69 -0
  129. package/src/lib/logger.js +2 -0
  130. package/src/lib/runtime-adapter.js +41 -1
  131. package/src/routes/a2a.js +393 -66
  132. package/src/routes/dashboard.js +1 -27
  133. package/src/server.js +19 -0
  134. package/.maestro/inbox/release-workflow-spam.md +0 -25
@@ -152,6 +152,11 @@ function normalizeOpenClawOutput(raw) {
152
152
  }
153
153
 
154
154
 
155
+ function readPositiveIntEnv(name, fallback) {
156
+ const parsed = Number.parseInt(process.env[name] || '', 10);
157
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
158
+ }
159
+
155
160
  function createRuntimeAdapter(options = {}) {
156
161
  const workspaceDir = options.workspaceDir || process.cwd();
157
162
  const modeInfo = resolveRuntimeMode();
@@ -172,6 +177,33 @@ function createRuntimeAdapter(options = {}) {
172
177
  // Design decision (A2A-29): we keep per-conversation state for prompt/metadata
173
178
  // continuity, but Claude execution itself is stateless (no `--resume`).
174
179
  const claudeSessions = new Map();
180
+ const CLAUDE_SESSION_TTL_MS = readPositiveIntEnv('A2A_CLAUDE_SESSION_TTL_MS', 6 * 60 * 60 * 1000);
181
+ const MAX_CLAUDE_SESSIONS = readPositiveIntEnv('A2A_CLAUDE_MAX_SESSIONS', 500);
182
+
183
+ // A2A-69: TTL-based pruning for Claude session state.
184
+ // Follows the same pattern as pruneCollaborationSessions() in server.js:
185
+ // 1. Evict entries older than TTL
186
+ // 2. If still over max, evict oldest-first
187
+ function pruneClaudeSessions() {
188
+ const now = Date.now();
189
+ for (const [id, session] of claudeSessions.entries()) {
190
+ const updatedAt = Number(session?.updatedAt || 0);
191
+ if (!updatedAt || now - updatedAt > CLAUDE_SESSION_TTL_MS) {
192
+ claudeSessions.delete(id);
193
+ }
194
+ }
195
+
196
+ if (claudeSessions.size <= MAX_CLAUDE_SESSIONS) {
197
+ return;
198
+ }
199
+
200
+ const oldest = Array.from(claudeSessions.entries())
201
+ .sort((a, b) => (a[1]?.updatedAt || 0) - (b[1]?.updatedAt || 0));
202
+ const toDelete = claudeSessions.size - MAX_CLAUDE_SESSIONS;
203
+ for (let i = 0; i < toDelete; i++) {
204
+ claudeSessions.delete(oldest[i][0]);
205
+ }
206
+ }
175
207
 
176
208
  async function runClaudeTurnAdapter({ sessionId, message, caller, context, timeoutMs }) {
177
209
  const traceId = context?.traceId || context?.trace_id;
@@ -179,6 +211,9 @@ function createRuntimeAdapter(options = {}) {
179
211
  const conversationId = context?.conversationId || context?.conversation_id;
180
212
  const startAt = Date.now();
181
213
 
214
+ // A2A-69: prune stale sessions before accessing/creating state
215
+ pruneClaudeSessions();
216
+
182
217
  // Get or create session state
183
218
  let session = claudeSessions.get(sessionId);
184
219
  if (!session) {
@@ -191,6 +226,7 @@ function createRuntimeAdapter(options = {}) {
191
226
  systemPrompt: '',
192
227
  turnCount: 0,
193
228
  lastMeta: null,
229
+ updatedAt: Date.now(),
194
230
  // Keep a permission snapshot so summary runs with the same policy envelope.
195
231
  permissionSnapshot: {
196
232
  capabilities: Array.isArray(context?.capabilities) ? context.capabilities : [],
@@ -227,6 +263,7 @@ function createRuntimeAdapter(options = {}) {
227
263
  claudeSessions.set(sessionId, session);
228
264
  }
229
265
 
266
+ session.updatedAt = Date.now();
230
267
  session.turnCount++;
231
268
 
232
269
  logger.debug('Invoking Claude subagent turn', {
@@ -651,7 +688,10 @@ function createRuntimeAdapter(options = {}) {
651
688
  runTurn,
652
689
  summarize,
653
690
  notify,
654
- getLastTurnMeta
691
+ getLastTurnMeta,
692
+ // A2A-69: exposed for testing
693
+ _claudeSessions: claudeSessions,
694
+ _pruneClaudeSessions: pruneClaudeSessions
655
695
  };
656
696
  }
657
697
 
package/src/routes/a2a.js CHANGED
@@ -12,6 +12,8 @@ const { TokenStore } = require('../lib/tokens');
12
12
  const crypto = require('crypto');
13
13
  const { createLogger, createTraceId } = require('../lib/logger');
14
14
  const { verifySignature, isTimestampValid, fingerprint } = require('../lib/crypto');
15
+ const { buildAgentCard } = require('../lib/agent-card');
16
+ const { isDirectLocalRequest } = require('../lib/local-request');
15
17
 
16
18
  // Lazy-load conversation store (optional dependency)
17
19
  let ConversationStore = null;
@@ -66,14 +68,6 @@ const MAX_MESSAGE_LENGTH = 10000; // 10KB max message
66
68
  const MAX_TIMEOUT_SECONDS = 300; // 5 min max timeout
67
69
  const MIN_TIMEOUT_SECONDS = 5; // 5 sec min timeout
68
70
 
69
- function isLoopbackAddress(ip) {
70
- if (!ip) return false;
71
- if (ip === '::1' || ip === '127.0.0.1' || ip === '::ffff:127.0.0.1') {
72
- return true;
73
- }
74
- return ip.startsWith('::ffff:127.');
75
- }
76
-
77
71
  function resolveTraceId(req) {
78
72
  const headerTrace = req.headers['x-trace-id'];
79
73
  if (typeof headerTrace === 'string' && headerTrace.trim()) {
@@ -234,6 +228,73 @@ function createRoutes(options = {}) {
234
228
  } catch (_) {}
235
229
  }
236
230
 
231
+ // A2A-78: shared inbound auth pipeline for /invoke, /message:send, /end
232
+ // Returns { validation, identityVerified, publicKeyFingerprint } or sends error response and returns null
233
+ async function validateInboundAuth(req, res, reqLogger, endpoint) {
234
+ const withTracePayload = (payload) => ({ ...payload, trace_id: res.getHeader('x-trace-id'), request_id: res.getHeader('x-request-id') });
235
+
236
+ const authHeader = req.headers.authorization;
237
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
238
+ reqLogger.warn('Request missing bearer token', {
239
+ error_code: 'AUTH_MISSING_BEARER',
240
+ status_code: 401,
241
+ hint: 'Send Authorization: Bearer <a2a_token>.'
242
+ });
243
+ res.status(401).json(withTracePayload({
244
+ success: false,
245
+ error: 'missing_token',
246
+ message: 'Authorization header required'
247
+ }));
248
+ return null;
249
+ }
250
+
251
+ const token = authHeader.slice(7);
252
+ const validation = tokenStore.validate(token);
253
+ if (!validation.valid) {
254
+ reqLogger.warn('Token validation failed', {
255
+ error_code: 'TOKEN_INVALID_OR_EXPIRED',
256
+ status_code: 401,
257
+ hint: 'Create a fresh invite token and retry with the new bearer token.'
258
+ });
259
+ res.status(401).json(withTracePayload({
260
+ success: false,
261
+ error: 'unauthorized',
262
+ message: 'Invalid or expired token'
263
+ }));
264
+ return null;
265
+ }
266
+
267
+ const rateCheck = checkRateLimit(validation.id, limits);
268
+ if (rateCheck.limited) {
269
+ reqLogger.warn('Request rate limited', {
270
+ tokenId: validation.id,
271
+ error_code: 'TOKEN_RATE_LIMITED',
272
+ status_code: 429,
273
+ hint: 'Respect Retry-After and reduce invoke frequency for this token.',
274
+ data: { retry_after: rateCheck.retryAfter }
275
+ });
276
+ res.set('Retry-After', rateCheck.retryAfter);
277
+ res.status(429).json(withTracePayload({
278
+ success: false,
279
+ error: rateCheck.error,
280
+ message: rateCheck.message
281
+ }));
282
+ return null;
283
+ }
284
+
285
+ const sigCheck = verifySigHeaders(req, validation, endpoint, reqLogger);
286
+ if (sigCheck.error) {
287
+ res.status(sigCheck.error.status).json(withTracePayload(sigCheck.error.body));
288
+ return null;
289
+ }
290
+
291
+ return {
292
+ validation,
293
+ identityVerified: sigCheck.identityVerified,
294
+ publicKeyFingerprint: sigCheck.publicKeyFingerprint
295
+ };
296
+ }
297
+
237
298
  // A2A-52: shared signature verification helper for /invoke and /end
238
299
  function verifySigHeaders(req, validation, endpoint, reqLogger, withTracePayload) {
239
300
  const sigHeader = req.headers['x-a2a-signature'];
@@ -285,6 +346,73 @@ function createRoutes(options = {}) {
285
346
  return result;
286
347
  }
287
348
 
349
+ // A2A-78: Google A2A ↔ internal format translation
350
+ function translateGoogleToInternal(body) {
351
+ const msg = body?.message;
352
+ if (!msg || !Array.isArray(msg.parts) || msg.parts.length === 0) {
353
+ return { error: { status: 400, code: 'invalid_message', message: 'message.parts array is required and must not be empty' } };
354
+ }
355
+
356
+ const textParts = [];
357
+ let skippedNonText = 0;
358
+ for (const part of msg.parts) {
359
+ if (part?.content && typeof part.content.text === 'string') {
360
+ textParts.push(part.content.text);
361
+ } else {
362
+ skippedNonText++;
363
+ }
364
+ }
365
+
366
+ const message = textParts.join('\n');
367
+ if (!message) {
368
+ return { error: { status: 400, code: 'no_text_content', message: 'No text content in message parts' } };
369
+ }
370
+
371
+ const config = body.configuration || {};
372
+ const metadata = body.metadata || {};
373
+
374
+ return {
375
+ message,
376
+ conversation_id: msg.context_id || null,
377
+ caller: {
378
+ name: String(metadata.caller_name || '').slice(0, 100),
379
+ owner: String(metadata.caller_owner || '').slice(0, 100),
380
+ instance: String(metadata.caller_instance || '').slice(0, 200),
381
+ context: ''
382
+ },
383
+ timeout_seconds: Number(config.timeout_seconds) || 60,
384
+ blocking: config.blocking !== false,
385
+ skippedNonText
386
+ };
387
+ }
388
+
389
+ function translateInternalToGoogle(response, conversationId, tier, disclosure, callsRemaining) {
390
+ const canContinue = response.canContinue !== false;
391
+ const taskId = `task_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
392
+ const messageId = `resp_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
393
+
394
+ return {
395
+ task: {
396
+ id: taskId,
397
+ context_id: conversationId,
398
+ status: {
399
+ state: canContinue ? 'input-required' : 'completed',
400
+ message: {
401
+ message_id: messageId,
402
+ role: 'agent',
403
+ parts: [{ content: { text: response.text } }]
404
+ },
405
+ timestamp: new Date().toISOString()
406
+ },
407
+ metadata: {
408
+ 'openclaw:tier': tier,
409
+ 'openclaw:disclosure': disclosure,
410
+ 'openclaw:calls_remaining': callsRemaining
411
+ }
412
+ }
413
+ };
414
+ }
415
+
288
416
  /**
289
417
  * GET /status
290
418
  * Check if A2A is enabled
@@ -305,6 +433,30 @@ function createRoutes(options = {}) {
305
433
  res.json(response);
306
434
  });
307
435
 
436
+ /**
437
+ * GET /agent-card
438
+ * Convenience mirror of /.well-known/a2a-agent-card (A2A-76)
439
+ */
440
+ router.get('/agent-card', (req, res) => {
441
+ const { A2AConfig } = require('../lib/config');
442
+ const { getTopicsForTier } = require('../lib/disclosure');
443
+ const cfg = new A2AConfig();
444
+ const agent = cfg.getAgent();
445
+ const manifest = getTopicsForTier('public');
446
+ const keypair = cfg.getKeypair();
447
+ const hostname = (agent && agent.hostname) || process.env.A2A_HOSTNAME || req.headers.host || 'localhost';
448
+ const protocol = req.protocol || 'https';
449
+ const card = buildAgentCard({
450
+ config: agent,
451
+ manifest,
452
+ publicKey: keypair ? keypair.publicKey : null,
453
+ serverUrl: `${protocol}://${hostname}`,
454
+ version: require('../../package.json').version
455
+ });
456
+ res.set('Cache-Control', 'public, max-age=3600');
457
+ res.json(card);
458
+ });
459
+
308
460
  /**
309
461
  * GET /ping
310
462
  * Simple health check
@@ -314,93 +466,268 @@ function createRoutes(options = {}) {
314
466
  });
315
467
 
316
468
  /**
317
- * POST /invoke
318
- * Call the agent
469
+ * POST /message:send
470
+ * Google A2A protocol inbound endpoint (A2A-78)
471
+ * Accepts standard A2A wire format and translates to internal invoke.
472
+ * Registered on both escaped-colon and percent-encoded paths for compatibility.
319
473
  */
320
- router.post('/invoke', async (req, res) => {
474
+ async function handleMessageSend(req, res) {
321
475
  const startedAt = Date.now();
322
476
  const traceId = resolveTraceId(req);
323
477
  const requestId = resolveRequestId(req);
324
- const reqLogger = logger.child({ traceId, requestId, event: 'invoke' });
478
+ const reqLogger = logger.child({ traceId, requestId, event: 'message_send' });
325
479
  const withTracePayload = (payload) => ({ ...payload, trace_id: traceId, request_id: requestId });
326
480
  res.set('x-trace-id', traceId);
327
481
  res.set('x-request-id', requestId);
328
- reqLogger.info('Received invoke request', {
482
+ reqLogger.info('Received Google A2A message:send request', {
329
483
  data: {
330
484
  ip: req.ip,
331
485
  request_id: requestId,
332
486
  client_host: extractClientHost(req),
333
- forwarded_for: req.headers['x-forwarded-for'] || null,
334
- user_agent: req.headers['user-agent'] || null,
335
487
  has_auth_header: Boolean(req.headers.authorization)
336
488
  }
337
489
  });
338
- reqLogger.debug('Invoke request metadata', {
339
- event: 'invoke_request_metadata',
340
- data: normalizeRequestMetadata(req)
341
- });
342
490
 
343
- // Extract token
344
- const authHeader = req.headers.authorization;
345
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
346
- reqLogger.warn('Invoke request missing bearer token', {
347
- error_code: 'AUTH_MISSING_BEARER',
348
- status_code: 401,
349
- hint: 'Send Authorization: Bearer <a2a_token>.'
491
+ // Warn if non-blocking mode requested (not yet supported)
492
+ const bodyConfig = req.body?.configuration;
493
+ if (bodyConfig?.blocking === false) {
494
+ reqLogger.warn('Non-blocking mode requested but not supported', {
495
+ hint: 'Async task polling is not yet implemented. Request will be processed synchronously.'
350
496
  });
351
- return res.status(401).json(withTracePayload({
352
- success: false,
353
- error: 'missing_token',
354
- message: 'Authorization header required'
497
+ }
498
+
499
+ // Auth pipeline (shared with /invoke)
500
+ const auth = await validateInboundAuth(req, res, reqLogger, '/api/a2a/message:send');
501
+ if (!auth) return;
502
+ const { validation, identityVerified, publicKeyFingerprint } = auth;
503
+
504
+ // Translate Google A2A format to internal
505
+ const translated = translateGoogleToInternal(req.body);
506
+ if (translated.error) {
507
+ reqLogger.warn('Google A2A message validation failed', {
508
+ tokenId: validation.id,
509
+ error_code: translated.error.code,
510
+ status_code: translated.error.status
511
+ });
512
+ return res.status(translated.error.status).json(withTracePayload({
513
+ success: false,
514
+ error: translated.error.code,
515
+ message: translated.error.message
355
516
  }));
356
517
  }
357
518
 
358
- const token = authHeader.slice(7);
519
+ if (translated.skippedNonText > 0) {
520
+ reqLogger.debug('Skipped non-text parts in Google A2A message', {
521
+ tokenId: validation.id,
522
+ data: { skipped_count: translated.skippedNonText }
523
+ });
524
+ }
359
525
 
360
- // Validate token
361
- const validation = tokenStore.validate(token);
362
- if (!validation.valid) {
363
- // Use generic error to prevent token enumeration
364
- // All invalid token states return same response
365
- reqLogger.warn('Invoke token validation failed', {
366
- error_code: 'TOKEN_INVALID_OR_EXPIRED',
367
- status_code: 401,
368
- hint: 'Create a fresh invite token and retry with the new bearer token.'
526
+ // Validate message length
527
+ if (translated.message.length > MAX_MESSAGE_LENGTH) {
528
+ reqLogger.warn('Google A2A message too long', {
529
+ tokenId: validation.id,
530
+ error_code: 'REQUEST_INVALID_MESSAGE',
531
+ status_code: 400,
532
+ data: { message_length: translated.message.length }
369
533
  });
370
- return res.status(401).json(withTracePayload({
371
- success: false,
372
- error: 'unauthorized',
373
- message: 'Invalid or expired token'
534
+ return res.status(400).json(withTracePayload({
535
+ success: false,
536
+ error: 'invalid_message',
537
+ message: `Message must be under ${MAX_MESSAGE_LENGTH} characters`
374
538
  }));
375
539
  }
376
540
 
377
- // Check rate limit
378
- const rateCheck = checkRateLimit(validation.id, limits);
379
- if (rateCheck.limited) {
380
- reqLogger.warn('Invoke request rate limited', {
541
+ const boundedTimeout = Math.max(MIN_TIMEOUT_SECONDS, Math.min(MAX_TIMEOUT_SECONDS, translated.timeout_seconds));
542
+
543
+ // Build a2a context (same as /invoke)
544
+ const isNewConversation = !translated.conversation_id;
545
+ const conversationId = translated.conversation_id || `conv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
546
+ const sanitizedCaller = translated.caller;
547
+
548
+ const a2aContext = {
549
+ mode: 'a2a',
550
+ token_id: validation.id,
551
+ token_name: validation.name,
552
+ tier: validation.tier,
553
+ capabilities: validation.capabilities,
554
+ allowed_topics: validation.allowed_topics,
555
+ allowed_goals: validation.allowed_goals,
556
+ allowed_tools: validation.allowed_tools,
557
+ timeout_ms: validation.timeout_ms,
558
+ caller: sanitizedCaller,
559
+ conversation_id: conversationId,
560
+ trace_id: traceId,
561
+ request_id: requestId,
562
+ identity_verified: identityVerified,
563
+ public_key_fingerprint: publicKeyFingerprint
564
+ };
565
+
566
+ // Ensure inbound caller exists as a contact
567
+ let ensuredContact = null;
568
+ try {
569
+ ensuredContact = tokenStore.ensureInboundContact(sanitizedCaller, validation.id);
570
+ } catch (_) {
571
+ ensuredContact = null;
572
+ }
573
+
574
+ // Track conversation if store available
575
+ if (convStore) {
576
+ try {
577
+ convStore.startConversation({
578
+ id: conversationId,
579
+ contactId: ensuredContact?.id || validation.id,
580
+ contactName: ensuredContact?.name || sanitizedCaller.name || validation.name,
581
+ tokenId: validation.id,
582
+ direction: 'inbound'
583
+ });
584
+ if (isNewConversation && eventStore && eventStore.isAvailable && eventStore.isAvailable()) {
585
+ eventStore.emitEvent('call.inbound', {
586
+ conversation_id: conversationId,
587
+ token_id: validation.id,
588
+ caller_name: sanitizedCaller.name || validation.name || null,
589
+ caller_owner: sanitizedCaller.owner || null
590
+ }, {
591
+ conversationId,
592
+ contactId: ensuredContact?.id || validation.id
593
+ });
594
+ }
595
+ if (monitor) {
596
+ monitor.trackActivity(conversationId, {
597
+ ...sanitizedCaller,
598
+ tier: validation.tier,
599
+ capabilities: validation.capabilities,
600
+ allowed_topics: validation.allowed_topics,
601
+ allowed_goals: validation.allowed_goals,
602
+ allowed_tools: validation.allowed_tools,
603
+ trace_id: traceId,
604
+ request_id: requestId
605
+ });
606
+ }
607
+ convStore.addMessage(conversationId, {
608
+ direction: 'inbound',
609
+ role: 'user',
610
+ content: translated.message
611
+ });
612
+ } catch (err) {
613
+ reqLogger.error('Conversation tracking error', {
614
+ conversationId,
615
+ tokenId: validation.id,
616
+ error_code: 'CONVERSATION_TRACKING_FAILED',
617
+ error: err
618
+ });
619
+ }
620
+ }
621
+
622
+ try {
623
+ const response = await handleMessage(translated.message, a2aContext, { timeout: boundedTimeout * 1000 });
624
+
625
+ // Store outgoing response
626
+ if (convStore) {
627
+ try {
628
+ convStore.addMessage(conversationId, {
629
+ direction: 'outbound',
630
+ role: 'assistant',
631
+ content: response.text
632
+ });
633
+ } catch (err) {
634
+ reqLogger.error('Message storage error', {
635
+ conversationId,
636
+ tokenId: validation.id,
637
+ error_code: 'CONVERSATION_MESSAGE_STORE_FAILED',
638
+ error: err
639
+ });
640
+ }
641
+ }
642
+
643
+ // Notify owner if configured
644
+ if (validation.notify !== 'none') {
645
+ notifyOwner({
646
+ level: validation.notify,
647
+ token: validation,
648
+ caller: sanitizedCaller,
649
+ message: translated.message,
650
+ response: response.text,
651
+ conversation_id: conversationId,
652
+ trace_id: traceId,
653
+ request_id: requestId
654
+ }).catch(err => {
655
+ reqLogger.error('Failed to notify owner', {
656
+ conversationId,
657
+ tokenId: validation.id,
658
+ error_code: 'OWNER_NOTIFY_FAILED',
659
+ error: err
660
+ });
661
+ });
662
+ }
663
+
664
+ reqLogger.info('Google A2A message:send completed', {
665
+ conversationId,
381
666
  tokenId: validation.id,
382
- error_code: 'TOKEN_RATE_LIMITED',
383
- status_code: 429,
384
- hint: 'Respect Retry-After and reduce invoke frequency for this token.',
385
667
  data: {
386
- retry_after: rateCheck.retryAfter
668
+ duration_ms: Date.now() - startedAt,
669
+ message_length: translated.message.length,
670
+ is_new_conversation: isNewConversation
387
671
  }
388
672
  });
389
- res.set('Retry-After', rateCheck.retryAfter);
390
- return res.status(429).json(withTracePayload({
391
- success: false,
392
- error: rateCheck.error,
393
- message: rateCheck.message
673
+
674
+ // Translate response to Google A2A Task format
675
+ const disclosure = validation.disclosure || 'minimal';
676
+ const googleResponse = translateInternalToGoogle(
677
+ response, conversationId, validation.tier, disclosure, validation.calls_remaining
678
+ );
679
+ res.json(googleResponse);
680
+
681
+ } catch (err) {
682
+ reqLogger.error('Message handling error', {
683
+ conversationId,
684
+ tokenId: validation.id,
685
+ error_code: 'MESSAGE_SEND_HANDLER_FAILED',
686
+ status_code: 500,
687
+ error: err,
688
+ data: { duration_ms: Date.now() - startedAt }
689
+ });
690
+ res.status(500).json(withTracePayload({
691
+ success: false,
692
+ error: 'internal_error',
693
+ message: 'Failed to process message'
394
694
  }));
395
695
  }
696
+ }
697
+ router.post('/message\\:send', handleMessageSend);
698
+ router.post('/message%3Asend', handleMessageSend);
396
699
 
397
- // A2A-52: Ed25519 signature verification (after token auth, before message handling)
398
- const sigCheck = verifySigHeaders(req, validation, '/api/a2a/invoke', reqLogger, withTracePayload);
399
- if (sigCheck.error) {
400
- return res.status(sigCheck.error.status).json(withTracePayload(sigCheck.error.body));
401
- }
402
- const identityVerified = sigCheck.identityVerified;
403
- const publicKeyFingerprint = sigCheck.publicKeyFingerprint;
700
+ /**
701
+ * POST /invoke
702
+ * Call the agent
703
+ */
704
+ router.post('/invoke', async (req, res) => {
705
+ const startedAt = Date.now();
706
+ const traceId = resolveTraceId(req);
707
+ const requestId = resolveRequestId(req);
708
+ const reqLogger = logger.child({ traceId, requestId, event: 'invoke' });
709
+ const withTracePayload = (payload) => ({ ...payload, trace_id: traceId, request_id: requestId });
710
+ res.set('x-trace-id', traceId);
711
+ res.set('x-request-id', requestId);
712
+ reqLogger.info('Received invoke request', {
713
+ data: {
714
+ ip: req.ip,
715
+ request_id: requestId,
716
+ client_host: extractClientHost(req),
717
+ forwarded_for: req.headers['x-forwarded-for'] || null,
718
+ user_agent: req.headers['user-agent'] || null,
719
+ has_auth_header: Boolean(req.headers.authorization)
720
+ }
721
+ });
722
+ reqLogger.debug('Invoke request metadata', {
723
+ event: 'invoke_request_metadata',
724
+ data: normalizeRequestMetadata(req)
725
+ });
726
+
727
+ // Auth pipeline (shared with /message:send)
728
+ const auth = await validateInboundAuth(req, res, reqLogger, '/api/a2a/invoke');
729
+ if (!auth) return;
730
+ const { validation, identityVerified, publicKeyFingerprint } = auth;
404
731
 
405
732
  // Extract and validate request
406
733
  const { message, conversation_id, caller, context, timeout_seconds = 60 } = req.body;
@@ -797,7 +1124,7 @@ function createRoutes(options = {}) {
797
1124
  // For now, require an admin token or local access
798
1125
  const expected = process.env.A2A_ADMIN_TOKEN;
799
1126
  const adminToken = req.headers['x-admin-token'];
800
- if (!isLoopbackAddress(req.ip)) {
1127
+ if (!isDirectLocalRequest(req)) {
801
1128
  if (!expected) {
802
1129
  return res.status(401).json({
803
1130
  error: 'admin_token_required',
@@ -833,7 +1160,7 @@ function createRoutes(options = {}) {
833
1160
  router.get('/conversations/:id', (req, res) => {
834
1161
  const expected = process.env.A2A_ADMIN_TOKEN;
835
1162
  const adminToken = req.headers['x-admin-token'];
836
- if (!isLoopbackAddress(req.ip)) {
1163
+ if (!isDirectLocalRequest(req)) {
837
1164
  if (!expected) {
838
1165
  return res.status(401).json({
839
1166
  error: 'admin_token_required',
@@ -20,17 +20,10 @@ const { resolveInviteHost } = require('../lib/invite-host');
20
20
  const { CallbookStore } = require('../lib/callbook');
21
21
  const { DashboardEventStore } = require('../lib/dashboard-events');
22
22
  const { createLogger } = require('../lib/logger');
23
+ const { isDirectLocalRequest } = require('../lib/local-request');
23
24
 
24
25
  const DASHBOARD_STATIC_DIR = path.join(__dirname, '..', 'dashboard', 'public');
25
26
 
26
- function isLoopbackAddress(ip) {
27
- if (!ip) return false;
28
- if (ip === '::1' || ip === '127.0.0.1' || ip === '::ffff:127.0.0.1') {
29
- return true;
30
- }
31
- return ip.startsWith('::ffff:127.');
32
- }
33
-
34
27
  function parseCookieHeader(headerValue) {
35
28
  const raw = String(headerValue || '').trim();
36
29
  if (!raw) return {};
@@ -46,25 +39,6 @@ function parseCookieHeader(headerValue) {
46
39
  return cookies;
47
40
  }
48
41
 
49
- function isDirectLocalRequest(req) {
50
- const ip = (req && req.socket && req.socket.remoteAddress) ? req.socket.remoteAddress : req.ip;
51
- if (!isLoopbackAddress(ip)) return false;
52
- const host = String(req.headers.host || '').toLowerCase();
53
- const isLocalHost = host.startsWith('localhost') ||
54
- host.startsWith('127.0.0.1') ||
55
- host.startsWith('[::1]') ||
56
- host.startsWith('::1');
57
- if (!isLocalHost) return false;
58
- // Avoid treating proxy-forwarded traffic as "local".
59
- const forwarded = req.headers['x-forwarded-for'] ||
60
- req.headers['x-forwarded-proto'] ||
61
- req.headers['x-forwarded-host'] ||
62
- req.headers['cf-connecting-ip'] ||
63
- req.headers['x-forwarded-by'];
64
- if (forwarded) return false;
65
- return true;
66
- }
67
-
68
42
  function isHttpsRequest(req) {
69
43
  const proto = String(req.headers['x-forwarded-proto'] || '')
70
44
  .split(',')[0]