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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
@@ -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) {
@@ -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 (adminToken !== process.env.A2A_ADMIN_TOKEN && req.ip !== '127.0.0.1') {
404
- return res.status(401).json({ error: 'unauthorized' });
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 (adminToken !== process.env.A2A_ADMIN_TOKEN && req.ip !== '127.0.0.1') {
431
- return res.status(401).json({ error: 'unauthorized' });
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();
@@ -169,7 +169,11 @@ function ensureDashboardAccess(req, res, next) {
169
169
  return next();
170
170
  }
171
171
  if (!adminToken) {
172
- return next();
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 remotes = tokenStore.listRemotes();
403
- const existing = remotes.find(r =>
404
- r.name === caller.name ||
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 add contact:', err.message);
408
+ console.error('[a2a] Failed to ensure contact:', err.message);
436
409
  return null;
437
410
  }
438
411
  }