a2acalling 0.6.1 → 0.6.3
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/README.md +33 -9
- package/SKILL.md +69 -5
- package/bin/cli.js +539 -162
- package/docs/protocol.md +24 -14
- package/package.json +1 -1
- package/scripts/install-openclaw.js +64 -64
- package/src/dashboard/public/app.js +765 -28
- package/src/dashboard/public/index.html +57 -13
- package/src/dashboard/public/style.css +16 -0
- package/src/lib/callbook.js +358 -0
- package/src/lib/client.js +1 -2
- package/src/lib/config.js +214 -16
- package/src/lib/conversations.js +74 -0
- package/src/lib/disclosure.js +3 -43
- package/src/lib/external-ip.js +18 -7
- package/src/lib/invite-host.js +24 -21
- package/src/lib/logger.js +26 -14
- package/src/lib/tokens.js +314 -113
- package/src/routes/a2a.js +11 -2
- package/src/routes/callbook.js +142 -0
- package/src/routes/dashboard.js +605 -37
- package/src/server.js +6 -0
package/src/routes/dashboard.js
CHANGED
|
@@ -13,9 +13,11 @@ const fs = require('fs');
|
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const { TokenStore } = require('../lib/tokens');
|
|
15
15
|
const { ConversationStore } = require('../lib/conversations');
|
|
16
|
+
const { A2AClient } = require('../lib/client');
|
|
16
17
|
const { A2AConfig } = require('../lib/config');
|
|
17
18
|
const { loadManifest, saveManifest } = require('../lib/disclosure');
|
|
18
19
|
const { resolveInviteHost } = require('../lib/invite-host');
|
|
20
|
+
const { CallbookStore } = require('../lib/callbook');
|
|
19
21
|
const { createLogger } = require('../lib/logger');
|
|
20
22
|
|
|
21
23
|
const DASHBOARD_STATIC_DIR = path.join(__dirname, '..', 'dashboard', 'public');
|
|
@@ -28,6 +30,63 @@ function isLoopbackAddress(ip) {
|
|
|
28
30
|
return ip.startsWith('::ffff:127.');
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function parseCookieHeader(headerValue) {
|
|
34
|
+
const raw = String(headerValue || '').trim();
|
|
35
|
+
if (!raw) return {};
|
|
36
|
+
const cookies = {};
|
|
37
|
+
for (const part of raw.split(';')) {
|
|
38
|
+
const idx = part.indexOf('=');
|
|
39
|
+
if (idx === -1) continue;
|
|
40
|
+
const key = part.slice(0, idx).trim();
|
|
41
|
+
const value = part.slice(idx + 1).trim();
|
|
42
|
+
if (!key) continue;
|
|
43
|
+
cookies[key] = decodeURIComponent(value);
|
|
44
|
+
}
|
|
45
|
+
return cookies;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isDirectLocalRequest(req) {
|
|
49
|
+
const ip = (req && req.socket && req.socket.remoteAddress) ? req.socket.remoteAddress : req.ip;
|
|
50
|
+
if (!isLoopbackAddress(ip)) return false;
|
|
51
|
+
const host = String(req.headers.host || '').toLowerCase();
|
|
52
|
+
const isLocalHost = host.startsWith('localhost') ||
|
|
53
|
+
host.startsWith('127.0.0.1') ||
|
|
54
|
+
host.startsWith('[::1]') ||
|
|
55
|
+
host.startsWith('::1');
|
|
56
|
+
if (!isLocalHost) return false;
|
|
57
|
+
// Avoid treating proxy-forwarded traffic as "local".
|
|
58
|
+
const forwarded = req.headers['x-forwarded-for'] ||
|
|
59
|
+
req.headers['x-forwarded-proto'] ||
|
|
60
|
+
req.headers['x-forwarded-host'] ||
|
|
61
|
+
req.headers['cf-connecting-ip'] ||
|
|
62
|
+
req.headers['x-forwarded-by'];
|
|
63
|
+
if (forwarded) return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isHttpsRequest(req) {
|
|
68
|
+
const proto = String(req.headers['x-forwarded-proto'] || '')
|
|
69
|
+
.split(',')[0]
|
|
70
|
+
.trim()
|
|
71
|
+
.toLowerCase();
|
|
72
|
+
if (proto === 'https') return true;
|
|
73
|
+
if (req && req.socket && req.socket.encrypted) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildSetCookie(name, value, options = {}) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
parts.push(`${name}=${encodeURIComponent(String(value || ''))}`);
|
|
80
|
+
parts.push('Path=/');
|
|
81
|
+
if (options.httpOnly !== false) parts.push('HttpOnly');
|
|
82
|
+
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
|
|
83
|
+
if (options.secure) parts.push('Secure');
|
|
84
|
+
if (Number.isFinite(options.maxAgeSeconds)) {
|
|
85
|
+
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
|
|
86
|
+
}
|
|
87
|
+
return parts.join('; ');
|
|
88
|
+
}
|
|
89
|
+
|
|
31
90
|
function sanitizeString(value, maxLength = 200) {
|
|
32
91
|
return String(value || '')
|
|
33
92
|
.replace(/\s+/g, ' ')
|
|
@@ -35,6 +94,30 @@ function sanitizeString(value, maxLength = 200) {
|
|
|
35
94
|
.slice(0, maxLength);
|
|
36
95
|
}
|
|
37
96
|
|
|
97
|
+
function parseBoolean(value) {
|
|
98
|
+
if (value === null || value === undefined) return false;
|
|
99
|
+
if (typeof value === 'boolean') return value;
|
|
100
|
+
if (typeof value === 'number') return value !== 0;
|
|
101
|
+
const s = String(value).trim().toLowerCase();
|
|
102
|
+
if (['true', '1', 'yes', 'y', 'on'].includes(s)) return true;
|
|
103
|
+
if (['false', '0', 'no', 'n', 'off', ''].includes(s)) return false;
|
|
104
|
+
return Boolean(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveAgentContext(options = {}) {
|
|
108
|
+
const provided = options.agentContext && typeof options.agentContext === 'object' ? options.agentContext : null;
|
|
109
|
+
const name = sanitizeString(
|
|
110
|
+
provided?.name || process.env.A2A_AGENT_NAME || process.env.AGENT_NAME || 'a2a-agent',
|
|
111
|
+
80
|
|
112
|
+
) || 'a2a-agent';
|
|
113
|
+
const owner = sanitizeString(
|
|
114
|
+
provided?.owner || process.env.A2A_OWNER_NAME || process.env.USER || 'Agent Owner',
|
|
115
|
+
120
|
|
116
|
+
) || 'Agent Owner';
|
|
117
|
+
|
|
118
|
+
return { name, owner };
|
|
119
|
+
}
|
|
120
|
+
|
|
38
121
|
function normalizeTierId(value) {
|
|
39
122
|
return sanitizeString(value, 80)
|
|
40
123
|
.toLowerCase()
|
|
@@ -108,6 +191,8 @@ function buildContext(options = {}) {
|
|
|
108
191
|
const tokenStore = options.tokenStore || new TokenStore();
|
|
109
192
|
const config = options.config || new A2AConfig();
|
|
110
193
|
const logger = options.logger || createLogger({ component: 'a2a.dashboard' });
|
|
194
|
+
const callbookStore = options.callbookStore || new CallbookStore(tokenStore.configDir);
|
|
195
|
+
const agentContext = resolveAgentContext(options);
|
|
111
196
|
let convStore = options.convStore || null;
|
|
112
197
|
if (!convStore) {
|
|
113
198
|
try {
|
|
@@ -124,7 +209,9 @@ function buildContext(options = {}) {
|
|
|
124
209
|
tokenStore,
|
|
125
210
|
config,
|
|
126
211
|
convStore,
|
|
212
|
+
callbookStore,
|
|
127
213
|
logger,
|
|
214
|
+
agentContext,
|
|
128
215
|
staticDir: DASHBOARD_STATIC_DIR
|
|
129
216
|
};
|
|
130
217
|
}
|
|
@@ -165,24 +252,95 @@ function resolveConversationContact(conversation, contactIndex) {
|
|
|
165
252
|
return null;
|
|
166
253
|
}
|
|
167
254
|
|
|
168
|
-
function
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
255
|
+
function toDashboardContact(contact) {
|
|
256
|
+
const canCall = Boolean(
|
|
257
|
+
contact &&
|
|
258
|
+
String(contact.host || '').trim() &&
|
|
259
|
+
contact.token_enc &&
|
|
260
|
+
contact.token_hash
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
id: contact.id,
|
|
265
|
+
owner: contact.owner || null,
|
|
266
|
+
name: contact.name || null,
|
|
267
|
+
host: contact.host || null,
|
|
268
|
+
web_address: contact.host || null,
|
|
269
|
+
is_mine: Boolean(contact.is_mine),
|
|
270
|
+
server_name: contact.server_name || null,
|
|
271
|
+
notes: contact.notes || null,
|
|
272
|
+
tags: Array.isArray(contact.tags) ? contact.tags : [],
|
|
273
|
+
fields: (contact.fields && typeof contact.fields === 'object' && !Array.isArray(contact.fields)) ? contact.fields : {},
|
|
274
|
+
linked_token_id: contact.linked_token_id || null,
|
|
275
|
+
status: contact.status || 'unknown',
|
|
276
|
+
last_seen: contact.last_seen || null,
|
|
277
|
+
last_check: contact.last_check || null,
|
|
278
|
+
last_error: contact.last_error || null,
|
|
279
|
+
added_at: contact.added_at || null,
|
|
280
|
+
updated_at: contact.updated_at || null,
|
|
281
|
+
can_call: canCall
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function makeEnsureDashboardAccess(context) {
|
|
286
|
+
return function ensureDashboardAccess(req, res, next) {
|
|
287
|
+
const adminToken = process.env.A2A_ADMIN_TOKEN;
|
|
288
|
+
const headerToken = req.headers['x-admin-token'];
|
|
289
|
+
const queryToken = req.query?.admin_token;
|
|
290
|
+
|
|
291
|
+
if (isDirectLocalRequest(req)) {
|
|
292
|
+
return next();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (adminToken && (headerToken === adminToken || queryToken === adminToken)) {
|
|
296
|
+
return next();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const cookies = parseCookieHeader(req.headers.cookie || '');
|
|
300
|
+
const sessionToken = cookies.a2a_callbook_session || null;
|
|
301
|
+
if (sessionToken && context.callbookStore && context.callbookStore.isAvailable()) {
|
|
302
|
+
const session = context.callbookStore.validateSession(sessionToken);
|
|
303
|
+
if (session && session.valid) {
|
|
304
|
+
req.callbook = session;
|
|
305
|
+
return next();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const wantsHtml = String(req.headers.accept || '').includes('text/html');
|
|
310
|
+
if (wantsHtml) {
|
|
311
|
+
return res.status(401).send(`<!doctype html>
|
|
312
|
+
<html>
|
|
313
|
+
<head>
|
|
314
|
+
<meta charset="utf-8">
|
|
315
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
316
|
+
<title>A2A Dashboard</title>
|
|
317
|
+
</head>
|
|
318
|
+
<body style="font-family: system-ui, -apple-system, Segoe UI, sans-serif; padding: 2rem;">
|
|
319
|
+
<h1>A2A Dashboard Locked</h1>
|
|
320
|
+
<p>This dashboard requires owner access.</p>
|
|
321
|
+
<ul>
|
|
322
|
+
<li>Local access: open <code>http://127.0.0.1:PORT/dashboard/</code> on the server</li>
|
|
323
|
+
<li>Remote access: generate a Callbook Remote install link on the server, then open it on your Mac</li>
|
|
324
|
+
<li>Break-glass: set <code>A2A_ADMIN_TOKEN</code> and send <code>x-admin-token</code> header</li>
|
|
325
|
+
</ul>
|
|
326
|
+
</body>
|
|
327
|
+
</html>`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!adminToken && !(context.callbookStore && context.callbookStore.isAvailable())) {
|
|
331
|
+
return res.status(401).json({
|
|
332
|
+
success: false,
|
|
333
|
+
error: 'admin_token_required',
|
|
334
|
+
message: 'Set A2A_ADMIN_TOKEN (or enable callbook session storage) to access dashboard from non-local addresses'
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
176
338
|
return res.status(401).json({
|
|
177
339
|
success: false,
|
|
178
|
-
error: '
|
|
179
|
-
message: '
|
|
340
|
+
error: 'unauthorized',
|
|
341
|
+
message: 'Admin token or callbook session required'
|
|
180
342
|
});
|
|
181
|
-
}
|
|
182
|
-
if (headerToken === adminToken || queryToken === adminToken) {
|
|
183
|
-
return next();
|
|
184
|
-
}
|
|
185
|
-
return res.status(401).json({ success: false, error: 'unauthorized', message: 'Admin token required' });
|
|
343
|
+
};
|
|
186
344
|
}
|
|
187
345
|
|
|
188
346
|
function summarizeDebugLogs(logs) {
|
|
@@ -252,19 +410,215 @@ function createDashboardApiRouter(options = {}) {
|
|
|
252
410
|
const router = express.Router();
|
|
253
411
|
const context = buildContext(options);
|
|
254
412
|
router.use(express.json());
|
|
413
|
+
const ensureDashboardAccess = makeEnsureDashboardAccess(context);
|
|
414
|
+
|
|
415
|
+
// Callbook Remote: exchange a short-lived provisioning code for a long-lived session cookie.
|
|
416
|
+
// This route must be reachable BEFORE dashboard access is established.
|
|
417
|
+
router.post('/callbook/exchange', (req, res) => {
|
|
418
|
+
const body = req.body || {};
|
|
419
|
+
const code = sanitizeString(body.code || '', 500);
|
|
420
|
+
const label = sanitizeString(body.label || '', 120) || null;
|
|
421
|
+
|
|
422
|
+
if (!context.callbookStore || !context.callbookStore.isAvailable()) {
|
|
423
|
+
return res.status(500).json({
|
|
424
|
+
success: false,
|
|
425
|
+
error: 'callbook_storage_unavailable',
|
|
426
|
+
message: 'Callbook session storage is not available on this server.',
|
|
427
|
+
hint: context.callbookStore ? context.callbookStore.getDbError() : 'missing_callbook_store'
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const result = context.callbookStore.exchangeProvisionCode(code, { label });
|
|
432
|
+
if (!result.success) {
|
|
433
|
+
return res.status(401).json({
|
|
434
|
+
success: false,
|
|
435
|
+
error: result.error || 'invalid_code',
|
|
436
|
+
message: 'Callbook install code is invalid, expired, or already used.'
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// "Never expires" in DB; for browsers we set a very long cookie lifetime.
|
|
441
|
+
// (Browsers may still evict cookies; owner can always re-provision.)
|
|
442
|
+
const maxAgeSeconds = 10 * 365 * 24 * 60 * 60; // ~10 years
|
|
443
|
+
const cookie = buildSetCookie('a2a_callbook_session', result.sessionToken, {
|
|
444
|
+
httpOnly: true,
|
|
445
|
+
sameSite: 'Lax',
|
|
446
|
+
secure: isHttpsRequest(req),
|
|
447
|
+
maxAgeSeconds
|
|
448
|
+
});
|
|
449
|
+
res.setHeader('Set-Cookie', cookie);
|
|
450
|
+
|
|
451
|
+
return res.json({
|
|
452
|
+
success: true,
|
|
453
|
+
device: result.device,
|
|
454
|
+
dashboard_path: '/dashboard/'
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// All other dashboard API routes require owner access.
|
|
255
459
|
router.use(ensureDashboardAccess);
|
|
256
460
|
|
|
257
|
-
router.get('/status', (req, res) => {
|
|
461
|
+
router.get('/status', async (req, res) => {
|
|
258
462
|
context.logger.debug('Dashboard status requested', { event: 'dashboard_status' });
|
|
259
|
-
|
|
463
|
+
const refreshIp = String(req.query.refresh_ip || 'false') === 'true';
|
|
464
|
+
let resolvedHost = null;
|
|
465
|
+
let warnings = [];
|
|
466
|
+
let inviteResolution = null;
|
|
467
|
+
try {
|
|
468
|
+
const resolved = await resolveInviteHost({
|
|
469
|
+
config: context.config,
|
|
470
|
+
fallbackHost: req.headers.host || process.env.HOSTNAME || 'localhost',
|
|
471
|
+
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
|
|
472
|
+
refreshExternalIp: refreshIp,
|
|
473
|
+
forceRefreshExternalIp: refreshIp,
|
|
474
|
+
alwaysLookupExternalIp: true,
|
|
475
|
+
warnOnExternalIpFailure: refreshIp
|
|
476
|
+
});
|
|
477
|
+
inviteResolution = resolved;
|
|
478
|
+
resolvedHost = resolved.host;
|
|
479
|
+
warnings = resolved.warnings || [];
|
|
480
|
+
} catch (err) {
|
|
481
|
+
// Non-fatal: still return base status.
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const schemeOverride = String(process.env.A2A_PUBLIC_SCHEME || '').trim();
|
|
485
|
+
const inferredScheme = schemeOverride || (isHttpsRequest(req) ? 'https' : 'http');
|
|
486
|
+
const publicBaseUrl = resolvedHost ? `${inferredScheme}://${resolvedHost}` : null;
|
|
487
|
+
|
|
488
|
+
const devices = (context.callbookStore && context.callbookStore.isAvailable())
|
|
489
|
+
? context.callbookStore.listDevices({ includeRevoked: true, limit: 200 }).devices
|
|
490
|
+
: [];
|
|
491
|
+
|
|
492
|
+
return res.json({
|
|
260
493
|
success: true,
|
|
261
494
|
dashboard: true,
|
|
262
495
|
conversations_enabled: Boolean(context.convStore),
|
|
496
|
+
agent: {
|
|
497
|
+
name: context.agentContext?.name || null,
|
|
498
|
+
owner_name: context.agentContext?.owner || null,
|
|
499
|
+
server_name: sanitizeString(context.config.getAgent?.().server_name || context.config.getAgent?.().serverName || '', 120) || null
|
|
500
|
+
},
|
|
263
501
|
config_file: require('../lib/config').CONFIG_FILE,
|
|
264
|
-
manifest_file: require('../lib/disclosure').MANIFEST_FILE
|
|
502
|
+
manifest_file: require('../lib/disclosure').MANIFEST_FILE,
|
|
503
|
+
public_base_url: publicBaseUrl,
|
|
504
|
+
public_dashboard_url: publicBaseUrl ? `${publicBaseUrl}/dashboard/` : null,
|
|
505
|
+
callbook_install_base: publicBaseUrl ? `${publicBaseUrl}/callbook/install` : null,
|
|
506
|
+
invite_host: inviteResolution ? {
|
|
507
|
+
host: inviteResolution.host,
|
|
508
|
+
source: inviteResolution.source || null,
|
|
509
|
+
original_host: inviteResolution.originalHost || null
|
|
510
|
+
} : null,
|
|
511
|
+
external_ip: inviteResolution && inviteResolution.externalIpInfo ? {
|
|
512
|
+
ip: inviteResolution.externalIpInfo.ip || null,
|
|
513
|
+
checked_at: inviteResolution.externalIpInfo.checkedAt || null,
|
|
514
|
+
source: inviteResolution.externalIpInfo.source || null,
|
|
515
|
+
from_cache: Boolean(inviteResolution.externalIpInfo.fromCache),
|
|
516
|
+
stale: Boolean(inviteResolution.externalIpInfo.stale),
|
|
517
|
+
error: inviteResolution.externalIpInfo.error || null,
|
|
518
|
+
attempts: inviteResolution.externalIpInfo.attempts || null
|
|
519
|
+
} : null,
|
|
520
|
+
warnings,
|
|
521
|
+
callbook: {
|
|
522
|
+
enabled: Boolean(context.callbookStore && context.callbookStore.isAvailable()),
|
|
523
|
+
device_count: Array.isArray(devices) ? devices.length : 0
|
|
524
|
+
}
|
|
265
525
|
});
|
|
266
526
|
});
|
|
267
527
|
|
|
528
|
+
// Callbook Remote: create a short-lived install link (24h by default).
|
|
529
|
+
router.post('/callbook/provision', async (req, res) => {
|
|
530
|
+
const body = req.body || {};
|
|
531
|
+
const label = sanitizeString(body.label || 'Callbook Remote', 120) || null;
|
|
532
|
+
const ttlHoursRaw = body.ttl_hours !== undefined ? body.ttl_hours : body.ttlHours;
|
|
533
|
+
const ttlHours = Math.max(1, Math.min(168, Number.parseInt(String(ttlHoursRaw || '24'), 10) || 24));
|
|
534
|
+
const ttlMs = ttlHours * 60 * 60 * 1000;
|
|
535
|
+
|
|
536
|
+
if (!context.callbookStore || !context.callbookStore.isAvailable()) {
|
|
537
|
+
return res.status(500).json({
|
|
538
|
+
success: false,
|
|
539
|
+
error: 'callbook_storage_unavailable',
|
|
540
|
+
message: 'Callbook session storage is not available on this server.',
|
|
541
|
+
hint: context.callbookStore ? context.callbookStore.getDbError() : 'missing_callbook_store'
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const created = context.callbookStore.createProvisionCode({ label, ttlMs });
|
|
546
|
+
if (!created.success) {
|
|
547
|
+
return res.status(500).json({
|
|
548
|
+
success: false,
|
|
549
|
+
error: created.error || 'callbook_provision_failed',
|
|
550
|
+
message: created.message || 'Failed to create Callbook Remote install link.'
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let resolvedHost = null;
|
|
555
|
+
let warnings = [];
|
|
556
|
+
try {
|
|
557
|
+
const resolved = await resolveInviteHost({
|
|
558
|
+
config: context.config,
|
|
559
|
+
fallbackHost: req.headers.host || process.env.HOSTNAME || 'localhost',
|
|
560
|
+
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
|
|
561
|
+
refreshExternalIp: true,
|
|
562
|
+
forceRefreshExternalIp: true
|
|
563
|
+
});
|
|
564
|
+
resolvedHost = resolved.host;
|
|
565
|
+
warnings = resolved.warnings || [];
|
|
566
|
+
} catch (err) {
|
|
567
|
+
// Non-fatal: we can still return the code; owner can assemble URL manually.
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const schemeOverride = String(process.env.A2A_PUBLIC_SCHEME || '').trim();
|
|
571
|
+
const scheme = schemeOverride || (isHttpsRequest(req) ? 'https' : 'http');
|
|
572
|
+
const baseUrl = resolvedHost ? `${scheme}://${resolvedHost}` : null;
|
|
573
|
+
const installUrl = baseUrl ? `${baseUrl}/callbook/install#code=${created.code}` : null;
|
|
574
|
+
|
|
575
|
+
return res.json({
|
|
576
|
+
success: true,
|
|
577
|
+
install_url: installUrl,
|
|
578
|
+
expires_at: created.record.expires_at,
|
|
579
|
+
token: {
|
|
580
|
+
id: created.record.id,
|
|
581
|
+
label: created.record.label
|
|
582
|
+
},
|
|
583
|
+
warnings
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
router.get('/callbook/devices', (req, res) => {
|
|
588
|
+
if (!context.callbookStore || !context.callbookStore.isAvailable()) {
|
|
589
|
+
return res.json({ success: true, devices: [], message: 'Callbook storage not available' });
|
|
590
|
+
}
|
|
591
|
+
const includeRevoked = String(req.query.include_revoked || 'false') === 'true';
|
|
592
|
+
const result = context.callbookStore.listDevices({ includeRevoked, limit: req.query.limit || 200 });
|
|
593
|
+
if (!result.success) {
|
|
594
|
+
return res.status(500).json({ success: false, error: result.error || 'callbook_list_failed', message: result.message });
|
|
595
|
+
}
|
|
596
|
+
return res.json({ success: true, devices: result.devices });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
router.post('/callbook/devices/:deviceId/revoke', (req, res) => {
|
|
600
|
+
if (!context.callbookStore || !context.callbookStore.isAvailable()) {
|
|
601
|
+
return res.status(500).json({ success: false, error: 'callbook_storage_unavailable' });
|
|
602
|
+
}
|
|
603
|
+
const deviceId = sanitizeString(req.params.deviceId, 120);
|
|
604
|
+
const result = context.callbookStore.revokeDevice(deviceId);
|
|
605
|
+
if (!result.success) {
|
|
606
|
+
return res.status(404).json({ success: false, error: result.error || 'device_not_found' });
|
|
607
|
+
}
|
|
608
|
+
return res.json({ success: true, device: result.device });
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
router.post('/callbook/logout', (req, res) => {
|
|
612
|
+
const cookie = buildSetCookie('a2a_callbook_session', '', {
|
|
613
|
+
httpOnly: true,
|
|
614
|
+
sameSite: 'Lax',
|
|
615
|
+
secure: isHttpsRequest(req),
|
|
616
|
+
maxAgeSeconds: 0
|
|
617
|
+
});
|
|
618
|
+
res.setHeader('Set-Cookie', cookie);
|
|
619
|
+
return res.json({ success: true });
|
|
620
|
+
});
|
|
621
|
+
|
|
268
622
|
router.get('/logs', (req, res) => {
|
|
269
623
|
const limit = Math.min(1000, Math.max(1, Number.parseInt(req.query.limit || '200', 10) || 200));
|
|
270
624
|
const logs = context.logger.list({
|
|
@@ -362,7 +716,7 @@ function createDashboardApiRouter(options = {}) {
|
|
|
362
716
|
});
|
|
363
717
|
|
|
364
718
|
router.get('/contacts', (req, res) => {
|
|
365
|
-
const contacts = context.tokenStore.
|
|
719
|
+
const contacts = context.tokenStore.listContacts({ includeLinkedToken: true, includeSecrets: true });
|
|
366
720
|
const contactIndex = buildContactIndex(contacts);
|
|
367
721
|
const conversations = context.convStore
|
|
368
722
|
? context.convStore.listConversations({
|
|
@@ -389,10 +743,12 @@ function createDashboardApiRouter(options = {}) {
|
|
|
389
743
|
return String(b.last_message_at || '').localeCompare(String(a.last_message_at || ''));
|
|
390
744
|
});
|
|
391
745
|
const latest = calls[0] || null;
|
|
746
|
+
|
|
392
747
|
return {
|
|
393
|
-
...contact,
|
|
748
|
+
...toDashboardContact(contact),
|
|
394
749
|
call_count: calls.length,
|
|
395
750
|
last_call_at: latest?.last_message_at || null,
|
|
751
|
+
last_call_id: latest?.id || null,
|
|
396
752
|
last_summary: latest?.summary || null,
|
|
397
753
|
last_owner_summary: latest?.owner_summary || null
|
|
398
754
|
};
|
|
@@ -401,13 +757,187 @@ function createDashboardApiRouter(options = {}) {
|
|
|
401
757
|
res.json({ success: true, contacts: result });
|
|
402
758
|
});
|
|
403
759
|
|
|
760
|
+
router.post('/contacts', (req, res) => {
|
|
761
|
+
const body = req.body || {};
|
|
762
|
+
const inviteUrl = sanitizeString(
|
|
763
|
+
body.invite_url || body.inviteUrl || body.web_address || body.webAddress || body.url || '',
|
|
764
|
+
600
|
|
765
|
+
);
|
|
766
|
+
const name = sanitizeString(body.name || '', 120) || null;
|
|
767
|
+
const owner = sanitizeString(body.owner || '', 120) || null;
|
|
768
|
+
const isMine = parseBoolean(body.is_mine !== undefined ? body.is_mine : body.isMine);
|
|
769
|
+
const serverName = sanitizeString(body.server_name || body.serverName || '', 120) || null;
|
|
770
|
+
const notes = sanitizeString(body.notes || '', 800) || null;
|
|
771
|
+
const tags = sanitizeStringArray(body.tags || [], 30, 40);
|
|
772
|
+
const fields = (body.fields && typeof body.fields === 'object' && !Array.isArray(body.fields)) ? body.fields : {};
|
|
773
|
+
|
|
774
|
+
if (!inviteUrl) {
|
|
775
|
+
return res.status(400).json({ success: false, error: 'invite_url_required' });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
const result = context.tokenStore.addContact(inviteUrl, {
|
|
780
|
+
name: name || undefined,
|
|
781
|
+
owner: owner || undefined,
|
|
782
|
+
is_mine: isMine,
|
|
783
|
+
server_name: serverName || undefined,
|
|
784
|
+
notes: notes || undefined,
|
|
785
|
+
tags: tags || undefined,
|
|
786
|
+
fields: fields || undefined
|
|
787
|
+
});
|
|
788
|
+
if (!result.success) {
|
|
789
|
+
return res.status(409).json({ success: false, error: result.error || 'contact_add_failed', contact: result.existing || null });
|
|
790
|
+
}
|
|
791
|
+
const stored = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: true })
|
|
792
|
+
.find(c => c.id === result.contact.id);
|
|
793
|
+
return res.json({ success: true, contact: stored ? toDashboardContact(stored) : toDashboardContact(result.contact) });
|
|
794
|
+
} catch (err) {
|
|
795
|
+
return res.status(400).json({
|
|
796
|
+
success: false,
|
|
797
|
+
error: 'invalid_invite_url',
|
|
798
|
+
message: err.message || 'Invalid invite URL'
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
router.put('/contacts/:contactId', (req, res) => {
|
|
804
|
+
const contactId = sanitizeString(req.params.contactId, 120);
|
|
805
|
+
if (!contactId) {
|
|
806
|
+
return res.status(400).json({ success: false, error: 'contact_id_required' });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const body = req.body || {};
|
|
810
|
+
const updates = {};
|
|
811
|
+
if (body.name !== undefined) updates.name = sanitizeString(body.name, 120);
|
|
812
|
+
if (body.owner !== undefined) updates.owner = sanitizeString(body.owner, 120);
|
|
813
|
+
if (body.is_mine !== undefined) updates.is_mine = parseBoolean(body.is_mine);
|
|
814
|
+
if (body.isMine !== undefined) updates.is_mine = parseBoolean(body.isMine);
|
|
815
|
+
if (body.server_name !== undefined) updates.server_name = sanitizeString(body.server_name, 120);
|
|
816
|
+
if (body.serverName !== undefined) updates.server_name = sanitizeString(body.serverName, 120);
|
|
817
|
+
if (body.notes !== undefined) updates.notes = sanitizeString(body.notes, 800);
|
|
818
|
+
if (body.tags !== undefined) updates.tags = sanitizeStringArray(body.tags, 30, 40);
|
|
819
|
+
if (body.fields !== undefined) updates.fields = body.fields;
|
|
820
|
+
if (body.linked_token_id !== undefined) updates.linked_token_id = sanitizeString(body.linked_token_id, 80);
|
|
821
|
+
if (body.linkedTokenId !== undefined) updates.linked_token_id = sanitizeString(body.linkedTokenId, 80);
|
|
822
|
+
|
|
823
|
+
const result = context.tokenStore.updateContact(contactId, updates);
|
|
824
|
+
if (!result.success) {
|
|
825
|
+
return res.status(404).json({ success: false, error: result.error || 'contact_not_found' });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const stored = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: true })
|
|
829
|
+
.find(c => c.id === contactId);
|
|
830
|
+
return res.json({ success: true, contact: stored ? toDashboardContact(stored) : toDashboardContact(result.contact) });
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
router.delete('/contacts/:contactId', (req, res) => {
|
|
834
|
+
const contactId = sanitizeString(req.params.contactId, 120);
|
|
835
|
+
if (!contactId) {
|
|
836
|
+
return res.status(400).json({ success: false, error: 'contact_id_required' });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const result = context.tokenStore.removeContact(contactId);
|
|
840
|
+
if (!result.success) {
|
|
841
|
+
return res.status(404).json({ success: false, error: result.error || 'contact_not_found' });
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return res.json({ success: true, contact: toDashboardContact(result.contact) });
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
router.post('/contacts/:contactId/call', async (req, res) => {
|
|
848
|
+
const contactId = sanitizeString(req.params.contactId, 120);
|
|
849
|
+
if (!contactId) {
|
|
850
|
+
return res.status(400).json({ success: false, error: 'contact_id_required' });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const body = req.body || {};
|
|
854
|
+
const message = sanitizeString(body.message || body.msg || '', 10000);
|
|
855
|
+
const timeoutSeconds = Math.max(5, Math.min(300, Number.parseInt(String(body.timeout_seconds || body.timeoutSeconds || '60'), 10) || 60));
|
|
856
|
+
if (!message) {
|
|
857
|
+
return res.status(400).json({ success: false, error: 'message_required', message: 'Message is required' });
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const contact = context.tokenStore.getContact(contactId);
|
|
861
|
+
if (!contact) {
|
|
862
|
+
return res.status(404).json({ success: false, error: 'contact_not_found' });
|
|
863
|
+
}
|
|
864
|
+
if (!contact.host || !contact.token) {
|
|
865
|
+
return res.status(400).json({ success: false, error: 'contact_not_callable', message: 'This contact has no callable A2A endpoint stored.' });
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const conversationId = ConversationStore.generateConversationId();
|
|
869
|
+
const client = new A2AClient({
|
|
870
|
+
caller: {
|
|
871
|
+
name: context.agentContext?.name || 'Dashboard',
|
|
872
|
+
owner: context.agentContext?.owner || 'Agent Owner',
|
|
873
|
+
instance: context.config.getAgent?.().hostname || null
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Track in local conversation DB (if enabled).
|
|
878
|
+
if (context.convStore) {
|
|
879
|
+
try {
|
|
880
|
+
context.convStore.startConversation({
|
|
881
|
+
id: conversationId,
|
|
882
|
+
contactId: contact.id,
|
|
883
|
+
contactName: contact.name || contact.host,
|
|
884
|
+
tokenId: null,
|
|
885
|
+
direction: 'outbound'
|
|
886
|
+
});
|
|
887
|
+
context.convStore.addMessage(conversationId, {
|
|
888
|
+
direction: 'outbound',
|
|
889
|
+
role: 'user',
|
|
890
|
+
content: message
|
|
891
|
+
});
|
|
892
|
+
} catch (err) {
|
|
893
|
+
// Best effort; call should still go through.
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const url = `a2a://${contact.host}/${contact.token}`;
|
|
898
|
+
try {
|
|
899
|
+
const result = await client.call(url, message, { conversationId, timeoutSeconds });
|
|
900
|
+
context.tokenStore.updateContactStatus(contact.id, 'online');
|
|
901
|
+
|
|
902
|
+
if (context.convStore) {
|
|
903
|
+
try {
|
|
904
|
+
context.convStore.addMessage(conversationId, {
|
|
905
|
+
direction: 'inbound',
|
|
906
|
+
role: 'assistant',
|
|
907
|
+
content: String(result?.response || '')
|
|
908
|
+
});
|
|
909
|
+
// One-shot outbound dashboard calls should be considered concluded.
|
|
910
|
+
await context.convStore.concludeConversation(conversationId, {});
|
|
911
|
+
} catch (err) {
|
|
912
|
+
// ignore
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return res.json({
|
|
917
|
+
success: true,
|
|
918
|
+
conversation_id: conversationId,
|
|
919
|
+
response: result?.response || '',
|
|
920
|
+
remote_trace_id: result?.trace_id || null,
|
|
921
|
+
remote_request_id: result?.request_id || null,
|
|
922
|
+
can_continue: result?.can_continue !== false
|
|
923
|
+
});
|
|
924
|
+
} catch (err) {
|
|
925
|
+
context.tokenStore.updateContactStatus(contact.id, 'offline', err.message);
|
|
926
|
+
return res.status(502).json({
|
|
927
|
+
success: false,
|
|
928
|
+
error: 'contact_call_failed',
|
|
929
|
+
message: err.message || 'Call failed'
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
404
934
|
router.get('/contacts/:contactId/calls', (req, res) => {
|
|
405
935
|
if (!context.convStore) {
|
|
406
936
|
return res.json({ success: true, calls: [], message: 'Conversation storage not enabled' });
|
|
407
937
|
}
|
|
408
938
|
|
|
409
939
|
const contactId = req.params.contactId;
|
|
410
|
-
const contacts = context.tokenStore.
|
|
940
|
+
const contacts = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: false });
|
|
411
941
|
const contactIndex = buildContactIndex(contacts);
|
|
412
942
|
const contact = contactIndex.byId.get(contactId);
|
|
413
943
|
if (!contact) {
|
|
@@ -446,7 +976,7 @@ function createDashboardApiRouter(options = {}) {
|
|
|
446
976
|
includeMessages: false
|
|
447
977
|
});
|
|
448
978
|
|
|
449
|
-
const contacts = context.tokenStore.
|
|
979
|
+
const contacts = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: false });
|
|
450
980
|
const contactIndex = buildContactIndex(contacts);
|
|
451
981
|
const enriched = calls.map(conv => ({
|
|
452
982
|
...conv,
|
|
@@ -473,7 +1003,7 @@ function createDashboardApiRouter(options = {}) {
|
|
|
473
1003
|
return res.status(404).json({ success: false, error: 'conversation_not_found' });
|
|
474
1004
|
}
|
|
475
1005
|
|
|
476
|
-
const contacts = context.tokenStore.
|
|
1006
|
+
const contacts = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: false });
|
|
477
1007
|
const contactIndex = buildContactIndex(contacts);
|
|
478
1008
|
const contact = resolveConversationContact({
|
|
479
1009
|
contact_name: contextData.contact
|
|
@@ -511,7 +1041,7 @@ function createDashboardApiRouter(options = {}) {
|
|
|
511
1041
|
|
|
512
1042
|
res.json({
|
|
513
1043
|
success: true,
|
|
514
|
-
onboarding_complete:
|
|
1044
|
+
onboarding_complete: context.config.isOnboarded(),
|
|
515
1045
|
defaults: cfg.defaults || {},
|
|
516
1046
|
agent: cfg.agent || {},
|
|
517
1047
|
tiers,
|
|
@@ -539,7 +1069,16 @@ function createDashboardApiRouter(options = {}) {
|
|
|
539
1069
|
if (body.topics !== undefined) update.topics = sanitizeStringArray(body.topics, 200, 160);
|
|
540
1070
|
if (body.goals !== undefined) update.goals = sanitizeStringArray(body.goals, 200, 160);
|
|
541
1071
|
|
|
542
|
-
|
|
1072
|
+
try {
|
|
1073
|
+
context.config.setTier(tierId, update);
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
return res.status(400).json({
|
|
1076
|
+
success: false,
|
|
1077
|
+
error: 'invalid_tier_config',
|
|
1078
|
+
code: err.code || 'A2A_CONFIG_INVALID_TIER_CONFIG',
|
|
1079
|
+
message: err.message
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
543
1082
|
|
|
544
1083
|
if (body.manifest) {
|
|
545
1084
|
const manifest = loadManifest();
|
|
@@ -569,17 +1108,35 @@ function createDashboardApiRouter(options = {}) {
|
|
|
569
1108
|
|
|
570
1109
|
const copyFrom = normalizeTierId(body.copy_from || '');
|
|
571
1110
|
if (copyFrom && cfg.tiers && cfg.tiers[copyFrom]) {
|
|
572
|
-
|
|
1111
|
+
try {
|
|
1112
|
+
context.config.setTier(tierId, { ...cfg.tiers[copyFrom] });
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
return res.status(400).json({
|
|
1115
|
+
success: false,
|
|
1116
|
+
error: 'invalid_tier_config',
|
|
1117
|
+
code: err.code || 'A2A_CONFIG_INVALID_TIER_CONFIG',
|
|
1118
|
+
message: err.message
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
573
1121
|
} else {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1122
|
+
try {
|
|
1123
|
+
context.config.setTier(tierId, {
|
|
1124
|
+
name: sanitizeString(body.name || tierId, 120),
|
|
1125
|
+
description: sanitizeString(body.description || 'Custom tier', 300),
|
|
1126
|
+
capabilities: sanitizeStringArray(body.capabilities || []),
|
|
1127
|
+
topics: sanitizeStringArray(body.topics || []),
|
|
1128
|
+
goals: sanitizeStringArray(body.goals || []),
|
|
1129
|
+
disclosure: sanitizeString(body.disclosure || 'minimal', 40),
|
|
1130
|
+
examples: sanitizeStringArray(body.examples || [], 20, 120)
|
|
1131
|
+
});
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
return res.status(400).json({
|
|
1134
|
+
success: false,
|
|
1135
|
+
error: 'invalid_tier_config',
|
|
1136
|
+
code: err.code || 'A2A_CONFIG_INVALID_TIER_CONFIG',
|
|
1137
|
+
message: err.message
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
583
1140
|
}
|
|
584
1141
|
|
|
585
1142
|
return res.json({ success: true, tier_id: tierId });
|
|
@@ -597,7 +1154,16 @@ function createDashboardApiRouter(options = {}) {
|
|
|
597
1154
|
return res.status(404).json({ success: false, error: 'source_tier_not_found' });
|
|
598
1155
|
}
|
|
599
1156
|
|
|
600
|
-
|
|
1157
|
+
try {
|
|
1158
|
+
context.config.setTier(toTier, { ...cfg.tiers[fromTier] });
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
return res.status(400).json({
|
|
1161
|
+
success: false,
|
|
1162
|
+
error: 'invalid_tier_config',
|
|
1163
|
+
code: err.code || 'A2A_CONFIG_INVALID_TIER_CONFIG',
|
|
1164
|
+
message: err.message
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
601
1167
|
|
|
602
1168
|
const manifest = loadManifest();
|
|
603
1169
|
if (manifest.topics && manifest.topics[fromTier]) {
|
|
@@ -672,8 +1238,9 @@ function createDashboardApiRouter(options = {}) {
|
|
|
672
1238
|
|
|
673
1239
|
const resolvedHost = await resolveInviteHost({
|
|
674
1240
|
config: context.config,
|
|
1241
|
+
fallbackHost: req.headers.host || process.env.HOSTNAME || 'localhost',
|
|
675
1242
|
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
|
|
676
|
-
|
|
1243
|
+
refreshExternalIp: true
|
|
677
1244
|
});
|
|
678
1245
|
const host = resolvedHost.host;
|
|
679
1246
|
const inviteUrl = `a2a://${host}/${token}`;
|
|
@@ -716,6 +1283,7 @@ function createDashboardApiRouter(options = {}) {
|
|
|
716
1283
|
function createDashboardUiRouter(options = {}) {
|
|
717
1284
|
const router = express.Router();
|
|
718
1285
|
const context = buildContext(options);
|
|
1286
|
+
const ensureDashboardAccess = makeEnsureDashboardAccess(context);
|
|
719
1287
|
router.use(ensureDashboardAccess);
|
|
720
1288
|
|
|
721
1289
|
router.use((req, res, next) => {
|