a2acalling 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/config.js +6 -1
- package/src/lib/conversations.js +5 -0
- package/src/lib/disclosure.js +6 -1
- package/src/lib/tokens.js +78 -1
- package/src/routes/a2a.js +30 -4
- package/src/routes/dashboard.js +5 -1
- package/src/server.js +5 -32
package/package.json
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -112,8 +112,13 @@ class A2AConfig {
|
|
|
112
112
|
this.config.createdAt = this.config.updatedAt;
|
|
113
113
|
}
|
|
114
114
|
const tmpPath = `${CONFIG_FILE}.tmp`;
|
|
115
|
-
fs.writeFileSync(tmpPath, JSON.stringify(this.config, null, 2));
|
|
115
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.config, null, 2), { mode: 0o600 });
|
|
116
116
|
fs.renameSync(tmpPath, CONFIG_FILE);
|
|
117
|
+
try {
|
|
118
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Best effort - ignore on platforms without chmod support.
|
|
121
|
+
}
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
// Check if onboarding is complete
|
package/src/lib/conversations.js
CHANGED
|
@@ -39,6 +39,11 @@ class ConversationStore {
|
|
|
39
39
|
try {
|
|
40
40
|
const Database = require('better-sqlite3');
|
|
41
41
|
this.db = new Database(this.dbPath);
|
|
42
|
+
try {
|
|
43
|
+
fs.chmodSync(this.dbPath, 0o600);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Best effort - ignore on platforms without chmod support.
|
|
46
|
+
}
|
|
42
47
|
this._migrate();
|
|
43
48
|
return this.db;
|
|
44
49
|
} catch (err) {
|
package/src/lib/disclosure.js
CHANGED
|
@@ -39,8 +39,13 @@ function saveManifest(manifest) {
|
|
|
39
39
|
}
|
|
40
40
|
manifest.updated_at = new Date().toISOString();
|
|
41
41
|
const tmpPath = `${MANIFEST_FILE}.tmp`;
|
|
42
|
-
fs.writeFileSync(tmpPath, JSON.stringify(manifest, null, 2));
|
|
42
|
+
fs.writeFileSync(tmpPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
|
43
43
|
fs.renameSync(tmpPath, MANIFEST_FILE);
|
|
44
|
+
try {
|
|
45
|
+
fs.chmodSync(MANIFEST_FILE, 0o600);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Best effort - ignore on platforms without chmod support.
|
|
48
|
+
}
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/**
|
package/src/lib/tokens.js
CHANGED
|
@@ -44,8 +44,13 @@ class TokenStore {
|
|
|
44
44
|
_save(db) {
|
|
45
45
|
// Atomic write: write to temp file, then rename
|
|
46
46
|
const tmpPath = `${this.dbPath}.tmp.${process.pid}`;
|
|
47
|
-
fs.writeFileSync(tmpPath, JSON.stringify(db, null, 2));
|
|
47
|
+
fs.writeFileSync(tmpPath, JSON.stringify(db, null, 2), { mode: 0o600 });
|
|
48
48
|
fs.renameSync(tmpPath, this.dbPath);
|
|
49
|
+
try {
|
|
50
|
+
fs.chmodSync(this.dbPath, 0o600);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Best effort - ignore on platforms without chmod support.
|
|
53
|
+
}
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
@@ -460,6 +465,78 @@ class TokenStore {
|
|
|
460
465
|
this._save(db);
|
|
461
466
|
return { success: true, remote: removed };
|
|
462
467
|
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Ensure an inbound caller is present in contacts.
|
|
471
|
+
*
|
|
472
|
+
* Inbound callers authenticate with a token we issued; we usually don't
|
|
473
|
+
* have their endpoint URL, so these records are "placeholders" with:
|
|
474
|
+
* - host: "inbound"
|
|
475
|
+
* - no token stored
|
|
476
|
+
* - linked_token_id: token id used to call us (tok_...)
|
|
477
|
+
*
|
|
478
|
+
* This allows mapping call history (SQLite contact_id=tok_...) to a contact
|
|
479
|
+
* row in the dashboard via linked_token_id.
|
|
480
|
+
*/
|
|
481
|
+
ensureInboundContact(caller, tokenId) {
|
|
482
|
+
if (!caller || !caller.name) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const name = String(caller.name || '').trim().slice(0, 120);
|
|
487
|
+
const owner = caller.owner ? String(caller.owner).trim().slice(0, 120) : null;
|
|
488
|
+
|
|
489
|
+
const db = this._load();
|
|
490
|
+
db.remotes = db.remotes || [];
|
|
491
|
+
|
|
492
|
+
// Prefer stable linking by the token used for inbound auth.
|
|
493
|
+
let remote = tokenId
|
|
494
|
+
? db.remotes.find(r => r.linked_token_id === tokenId)
|
|
495
|
+
: null;
|
|
496
|
+
|
|
497
|
+
// Fallback match by agent name/owner (less reliable, but helpful).
|
|
498
|
+
if (!remote) {
|
|
499
|
+
remote = db.remotes.find(r => r.name === name || (owner && r.owner === owner));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (remote) {
|
|
503
|
+
remote.name = remote.name || name;
|
|
504
|
+
if (owner && !remote.owner) {
|
|
505
|
+
remote.owner = owner;
|
|
506
|
+
}
|
|
507
|
+
if (tokenId && !remote.linked_token_id) {
|
|
508
|
+
remote.linked_token_id = tokenId;
|
|
509
|
+
}
|
|
510
|
+
remote.host = remote.host || 'inbound';
|
|
511
|
+
remote.tags = Array.isArray(remote.tags) ? remote.tags : [];
|
|
512
|
+
if (!remote.tags.includes('inbound')) {
|
|
513
|
+
remote.tags.push('inbound');
|
|
514
|
+
}
|
|
515
|
+
remote.status = remote.status || 'unknown';
|
|
516
|
+
remote.updated_at = new Date().toISOString();
|
|
517
|
+
this._save(db);
|
|
518
|
+
return remote;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const contact = {
|
|
522
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
523
|
+
name,
|
|
524
|
+
owner,
|
|
525
|
+
host: 'inbound',
|
|
526
|
+
token_hash: null,
|
|
527
|
+
token_enc: null,
|
|
528
|
+
notes: tokenId ? `Inbound caller via token ${tokenId}` : 'Inbound caller',
|
|
529
|
+
tags: ['inbound'],
|
|
530
|
+
linked_token_id: tokenId || null,
|
|
531
|
+
status: 'unknown',
|
|
532
|
+
last_seen: null,
|
|
533
|
+
added_at: new Date().toISOString()
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
db.remotes.push(contact);
|
|
537
|
+
this._save(db);
|
|
538
|
+
return contact;
|
|
539
|
+
}
|
|
463
540
|
}
|
|
464
541
|
|
|
465
542
|
// Legacy tier values from old records → label mapping
|
package/src/routes/a2a.js
CHANGED
|
@@ -57,6 +57,14 @@ const MAX_MESSAGE_LENGTH = 10000; // 10KB max message
|
|
|
57
57
|
const MAX_TIMEOUT_SECONDS = 300; // 5 min max timeout
|
|
58
58
|
const MIN_TIMEOUT_SECONDS = 5; // 5 sec min timeout
|
|
59
59
|
|
|
60
|
+
function isLoopbackAddress(ip) {
|
|
61
|
+
if (!ip) return false;
|
|
62
|
+
if (ip === '::1' || ip === '127.0.0.1' || ip === '::ffff:127.0.0.1') {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return ip.startsWith('::ffff:127.');
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
function checkRateLimit(tokenId, limits = { minute: 10, hour: 100, day: 1000 }) {
|
|
61
69
|
const now = Date.now();
|
|
62
70
|
const minute = Math.floor(now / 60000);
|
|
@@ -399,9 +407,18 @@ function createRoutes(options = {}) {
|
|
|
399
407
|
router.get('/conversations', (req, res) => {
|
|
400
408
|
// This endpoint should be protected by local auth, not A2A tokens
|
|
401
409
|
// For now, require an admin token or local access
|
|
410
|
+
const expected = process.env.A2A_ADMIN_TOKEN;
|
|
402
411
|
const adminToken = req.headers['x-admin-token'];
|
|
403
|
-
if (
|
|
404
|
-
|
|
412
|
+
if (!isLoopbackAddress(req.ip)) {
|
|
413
|
+
if (!expected) {
|
|
414
|
+
return res.status(401).json({
|
|
415
|
+
error: 'admin_token_required',
|
|
416
|
+
message: 'Set A2A_ADMIN_TOKEN to access conversation admin routes from non-local addresses'
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (adminToken !== expected) {
|
|
420
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
421
|
+
}
|
|
405
422
|
}
|
|
406
423
|
|
|
407
424
|
const convStore = getConversationStore();
|
|
@@ -426,9 +443,18 @@ function createRoutes(options = {}) {
|
|
|
426
443
|
* Get conversation details with context
|
|
427
444
|
*/
|
|
428
445
|
router.get('/conversations/:id', (req, res) => {
|
|
446
|
+
const expected = process.env.A2A_ADMIN_TOKEN;
|
|
429
447
|
const adminToken = req.headers['x-admin-token'];
|
|
430
|
-
if (
|
|
431
|
-
|
|
448
|
+
if (!isLoopbackAddress(req.ip)) {
|
|
449
|
+
if (!expected) {
|
|
450
|
+
return res.status(401).json({
|
|
451
|
+
error: 'admin_token_required',
|
|
452
|
+
message: 'Set A2A_ADMIN_TOKEN to access conversation admin routes from non-local addresses'
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (adminToken !== expected) {
|
|
456
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
457
|
+
}
|
|
432
458
|
}
|
|
433
459
|
|
|
434
460
|
const convStore = getConversationStore();
|
package/src/routes/dashboard.js
CHANGED
|
@@ -169,7 +169,11 @@ function ensureDashboardAccess(req, res, next) {
|
|
|
169
169
|
return next();
|
|
170
170
|
}
|
|
171
171
|
if (!adminToken) {
|
|
172
|
-
return
|
|
172
|
+
return res.status(401).json({
|
|
173
|
+
success: false,
|
|
174
|
+
error: 'admin_token_required',
|
|
175
|
+
message: 'Set A2A_ADMIN_TOKEN to access dashboard from non-local addresses'
|
|
176
|
+
});
|
|
173
177
|
}
|
|
174
178
|
if (headerToken === adminToken || queryToken === adminToken) {
|
|
175
179
|
return next();
|
package/src/server.js
CHANGED
|
@@ -397,42 +397,15 @@ function fallbackCollaborationUpdate(state, inboundMessage, responseText, tierTo
|
|
|
397
397
|
*/
|
|
398
398
|
function ensureContact(caller, tokenId) {
|
|
399
399
|
if (!caller?.name) return null;
|
|
400
|
-
|
|
400
|
+
|
|
401
401
|
try {
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
(caller.owner && r.owner === caller.owner)
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
if (existing) {
|
|
409
|
-
return existing;
|
|
402
|
+
const contact = tokenStore.ensureInboundContact(caller, tokenId);
|
|
403
|
+
if (contact) {
|
|
404
|
+
console.log(`[a2a] 📇 Contact ensured: ${caller.name}${caller.owner ? ` (${caller.owner})` : ''}`);
|
|
410
405
|
}
|
|
411
|
-
|
|
412
|
-
// Create a placeholder contact for the caller
|
|
413
|
-
const contact = {
|
|
414
|
-
id: `contact_${Date.now()}`,
|
|
415
|
-
name: caller.name,
|
|
416
|
-
owner: caller.owner || null,
|
|
417
|
-
host: 'inbound', // They called us, we don't have their URL
|
|
418
|
-
added_at: new Date().toISOString(),
|
|
419
|
-
notes: `Inbound caller via token ${tokenId}`,
|
|
420
|
-
tags: ['inbound'],
|
|
421
|
-
status: 'unknown',
|
|
422
|
-
linkedTokenId: tokenId
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
// Save to remotes
|
|
426
|
-
const db = JSON.parse(fs.readFileSync(tokenStore.dbPath, 'utf8'));
|
|
427
|
-
db.remotes = db.remotes || [];
|
|
428
|
-
db.remotes.push(contact);
|
|
429
|
-
fs.writeFileSync(tokenStore.dbPath, JSON.stringify(db, null, 2));
|
|
430
|
-
|
|
431
|
-
console.log(`[a2a] 📇 New contact added: ${caller.name}${caller.owner ? ` (${caller.owner})` : ''}`);
|
|
432
406
|
return contact;
|
|
433
|
-
|
|
434
407
|
} catch (err) {
|
|
435
|
-
console.error('[a2a] Failed to
|
|
408
|
+
console.error('[a2a] Failed to ensure contact:', err.message);
|
|
436
409
|
return null;
|
|
437
410
|
}
|
|
438
411
|
}
|