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.
- package/.a2a-manifest.json +2 -2
- package/.c8rc.json +16 -0
- package/.node-version +1 -0
- package/.serena/project.yml +126 -0
- package/ARCHITECTURE.md +40 -16
- package/CONVENTIONS.md +39 -6
- package/biome.json +27 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/index.html +131 -0
- package/coverage/src/index.js.html +313 -0
- package/coverage/src/lib/agent-card.js.html +418 -0
- package/coverage/src/lib/call-monitor.js.html +700 -0
- package/coverage/src/lib/callbook.js.html +1183 -0
- package/coverage/src/lib/claude-subagent.js.html +2173 -0
- package/coverage/src/lib/client.js.html +2134 -0
- package/coverage/src/lib/config.js.html +1525 -0
- package/coverage/src/lib/conversation-driver.js.html +1909 -0
- package/coverage/src/lib/conversations.js.html +2575 -0
- package/coverage/src/lib/crypto.js.html +424 -0
- package/coverage/src/lib/dashboard-events.js.html +724 -0
- package/coverage/src/lib/disclosure.js.html +2461 -0
- package/coverage/src/lib/external-ip.js.html +718 -0
- package/coverage/src/lib/index.html +506 -0
- package/coverage/src/lib/invite-host.js.html +754 -0
- package/coverage/src/lib/local-request.js.html +292 -0
- package/coverage/src/lib/logger.js.html +2116 -0
- package/coverage/src/lib/openclaw-integration.js.html +1102 -0
- package/coverage/src/lib/pid-file.js.html +394 -0
- package/coverage/src/lib/port-scanner.js.html +334 -0
- package/coverage/src/lib/prompt-template.js.html +1150 -0
- package/coverage/src/lib/runtime-adapter.js.html +2188 -0
- package/coverage/src/lib/summarizer.js.html +553 -0
- package/coverage/src/lib/summary-formatter.js.html +589 -0
- package/coverage/src/lib/summary-prompt.js.html +694 -0
- package/coverage/src/lib/tokens.js.html +2689 -0
- package/coverage/src/lib/turn-timeout.js.html +241 -0
- package/coverage/src/lib/update-checker.js.html +364 -0
- package/coverage/src/lib/update-manager.js.html +1024 -0
- package/coverage/src/routes/a2a.js.html +3724 -0
- package/coverage/src/routes/callbook.js.html +511 -0
- package/coverage/src/routes/dashboard.js.html +4819 -0
- package/coverage/src/routes/index.html +146 -0
- package/coverage/src/server.js.html +3622 -0
- package/coverage/tmp/coverage-1605378-1772576706365-0.json +1 -0
- package/coverage/tmp/coverage-1605384-1772576607459-0.json +1 -0
- package/coverage/tmp/coverage-1605410-1772576631155-0.json +1 -0
- package/coverage/tmp/coverage-1606942-1772576636869-0.json +1 -0
- package/coverage/tmp/coverage-1607004-1772576637454-0.json +1 -0
- package/coverage/tmp/coverage-1607044-1772576637876-0.json +1 -0
- package/coverage/tmp/coverage-1607096-1772576638356-0.json +1 -0
- package/coverage/tmp/coverage-1607145-1772576638777-0.json +1 -0
- package/coverage/tmp/coverage-1607201-1772576639277-0.json +1 -0
- package/coverage/tmp/coverage-1607247-1772576639755-0.json +1 -0
- package/coverage/tmp/coverage-1607317-1772576640083-0.json +1 -0
- package/coverage/tmp/coverage-1607381-1772576640465-0.json +1 -0
- package/coverage/tmp/coverage-1607446-1772576640868-0.json +1 -0
- package/coverage/tmp/coverage-1607501-1772576641662-0.json +1 -0
- package/coverage/tmp/coverage-1607534-1772576641565-0.json +1 -0
- package/coverage/tmp/coverage-1607627-1772576641871-0.json +1 -0
- package/coverage/tmp/coverage-1607665-1772576642172-0.json +1 -0
- package/coverage/tmp/coverage-1607714-1772576642577-0.json +1 -0
- package/coverage/tmp/coverage-1607788-1772576643466-0.json +1 -0
- package/coverage/tmp/coverage-1607924-1772576644678-0.json +1 -0
- package/coverage/tmp/coverage-1607978-1772576645154-0.json +1 -0
- package/coverage/tmp/coverage-1608035-1772576645564-0.json +1 -0
- package/coverage/tmp/coverage-1608106-1772576645967-0.json +1 -0
- package/coverage/tmp/coverage-1608179-1772576648656-0.json +1 -0
- package/coverage/tmp/coverage-1608196-1772576647367-0.json +1 -0
- package/coverage/tmp/coverage-1608217-1772576648557-0.json +1 -0
- package/coverage/tmp/coverage-1608256-1772576651378-0.json +1 -0
- package/coverage/tmp/coverage-1608265-1772576650058-0.json +1 -0
- package/coverage/tmp/coverage-1608289-1772576651358-0.json +1 -0
- package/coverage/tmp/coverage-1608591-1772576660465-0.json +1 -0
- package/coverage/tmp/coverage-1608648-1772576659272-0.json +1 -0
- package/coverage/tmp/coverage-1608665-1772576660374-0.json +1 -0
- package/coverage/tmp/coverage-1608677-1772576661268-0.json +1 -0
- package/coverage/tmp/coverage-1608684-1772576663968-0.json +1 -0
- package/coverage/tmp/coverage-1608692-1772576662575-0.json +1 -0
- package/coverage/tmp/coverage-1608701-1772576663873-0.json +1 -0
- package/coverage/tmp/coverage-1608718-1772576666674-0.json +1 -0
- package/coverage/tmp/coverage-1608725-1772576665463-0.json +1 -0
- package/coverage/tmp/coverage-1608738-1772576666577-0.json +1 -0
- package/coverage/tmp/coverage-1608753-1772576669664-0.json +1 -0
- package/coverage/tmp/coverage-1608763-1772576668275-0.json +1 -0
- package/coverage/tmp/coverage-1608771-1772576669563-0.json +1 -0
- package/coverage/tmp/coverage-1608828-1772576676574-0.json +1 -0
- package/coverage/tmp/coverage-1609244-1772576675272-0.json +1 -0
- package/coverage/tmp/coverage-1609342-1772576676478-0.json +1 -0
- package/coverage/tmp/coverage-1609450-1772576686954-0.json +1 -0
- package/coverage/tmp/coverage-1609841-1772576685466-0.json +1 -0
- package/coverage/tmp/coverage-1609925-1772576686855-0.json +1 -0
- package/coverage/tmp/coverage-1610399-1772576692469-0.json +1 -0
- package/coverage/tmp/coverage-1611283-1772576703062-0.json +1 -0
- package/coverage/tmp/coverage-1611294-1772576703755-0.json +1 -0
- package/docs/assessments/2026-02-27-google-a2a-protocol-assessment.md +292 -0
- package/docs/plans/2026-03-01-a2a-68-openclaw-integration-tests.md +676 -0
- package/docs/plans/2026-03-01-a2a-77-invoke-security-tests.md +661 -0
- package/docs/plans/2026-03-03-a2a-91-macos-packaging-plan.md +144 -0
- package/docs/signing-setup.md +49 -0
- package/eslint.config.js +16 -0
- package/knip.json +17 -0
- package/native/macos/certs/appldevcert.cer +0 -0
- package/native/macos/src-tauri/binaries/.gitkeep +0 -0
- package/native/macos/src-tauri/capabilities/default.json +11 -1
- package/native/macos/src-tauri/entitlements.plist +14 -0
- package/native/macos/src-tauri/src/discovery.rs +14 -3
- package/native/macos/src-tauri/src/health.rs +4 -0
- package/native/macos/src-tauri/src/lib.rs +52 -11
- package/native/macos/src-tauri/src/server.rs +262 -26
- package/native/macos/src-tauri/tauri.conf.json +13 -4
- package/package.json +16 -2
- package/pkg.config.json +14 -0
- package/scripts/build-standalone.sh +106 -0
- package/scripts/install-openclaw.js +3 -5
- package/scripts/smoke-test-standalone.sh +101 -0
- package/scripts/sync-version.sh +28 -0
- package/scripts/verify-app-bundle.sh +34 -0
- package/src/lib/agent-card.js +111 -0
- package/src/lib/client.js +290 -49
- package/src/lib/conversations.js +2 -0
- package/src/lib/local-request.js +69 -0
- package/src/lib/logger.js +2 -0
- package/src/lib/runtime-adapter.js +41 -1
- package/src/routes/a2a.js +393 -66
- package/src/routes/dashboard.js +1 -27
- package/src/server.js +19 -0
- 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 /
|
|
318
|
-
*
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
-
//
|
|
344
|
-
const
|
|
345
|
-
if (
|
|
346
|
-
reqLogger.warn('
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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(
|
|
371
|
-
success: false,
|
|
372
|
-
error: '
|
|
373
|
-
message:
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
668
|
+
duration_ms: Date.now() - startedAt,
|
|
669
|
+
message_length: translated.message.length,
|
|
670
|
+
is_new_conversation: isNewConversation
|
|
387
671
|
}
|
|
388
672
|
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const
|
|
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 (!
|
|
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 (!
|
|
1163
|
+
if (!isDirectLocalRequest(req)) {
|
|
837
1164
|
if (!expected) {
|
|
838
1165
|
return res.status(401).json({
|
|
839
1166
|
error: 'admin_token_required',
|
package/src/routes/dashboard.js
CHANGED
|
@@ -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]
|