a2acalling 0.3.5 → 0.4.0

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.
@@ -136,16 +136,17 @@ function writeExecutableFile(filePath, content) {
136
136
  }
137
137
  }
138
138
 
139
- function ensureStandaloneBootstrap(hostname, port) {
139
+ /**
140
+ * Ensure config and disclosure manifest exist.
141
+ * Called from both OpenClaw and standalone install paths.
142
+ */
143
+ function ensureConfigAndManifest(hostname, port) {
140
144
  const configDir = resolveA2AConfigDir();
141
145
  ensureDir(configDir);
142
146
 
143
- const configFile = path.join(configDir, 'a2a-config.json');
144
- const manifestFile = path.join(configDir, 'a2a-disclosure.json');
145
-
146
147
  try {
147
148
  const { A2AConfig } = require('../src/lib/config');
148
- const { loadManifest, saveManifest, generateDefaultManifest } = require('../src/lib/disclosure');
149
+ const { loadManifest, saveManifest, generateDefaultManifest, readContextFiles } = require('../src/lib/disclosure');
149
150
 
150
151
  const config = new A2AConfig();
151
152
  const defaults = config.getDefaults() || {};
@@ -153,25 +154,30 @@ function ensureStandaloneBootstrap(hostname, port) {
153
154
 
154
155
  const agent = config.getAgent() || {};
155
156
  if (!agent.hostname) {
156
- config.setAgent({
157
- hostname: `${hostname}:${port}`
158
- });
157
+ config.setAgent({ hostname: `${hostname}:${port}` });
159
158
  }
160
159
 
161
160
  const manifest = loadManifest();
162
161
  if (!manifest || Object.keys(manifest).length === 0) {
163
- const generated = generateDefaultManifest({
164
- user: safeRead(path.join(process.cwd(), 'USER.md')),
165
- heartbeat: safeRead(path.join(process.cwd(), 'HEARTBEAT.md')),
166
- soul: safeRead(path.join(process.cwd(), 'SOUL.md'))
167
- });
162
+ const contextFiles = readContextFiles(process.cwd());
163
+ const generated = generateDefaultManifest(contextFiles);
168
164
  saveManifest(generated);
165
+ const manifestFile = path.join(configDir, 'a2a-disclosure.json');
169
166
  log(`Generated default disclosure manifest: ${manifestFile}`);
170
167
  }
171
168
  } catch (err) {
172
- warn(`Standalone config bootstrap failed: ${err.message}`);
169
+ warn(`Config/manifest bootstrap failed: ${err.message}`);
173
170
  }
174
171
 
172
+ return configDir;
173
+ }
174
+
175
+ function ensureStandaloneBootstrap(hostname, port) {
176
+ const configDir = ensureConfigAndManifest(hostname, port);
177
+
178
+ const configFile = path.join(configDir, 'a2a-config.json');
179
+ const manifestFile = path.join(configDir, 'a2a-disclosure.json');
180
+
175
181
  const bridgeDir = path.join(configDir, 'runtime-bridge');
176
182
  ensureDir(bridgeDir);
177
183
  const turnScript = path.join(bridgeDir, 'a2a-turn.sh');
@@ -202,6 +208,15 @@ echo "a2a notify: $payload" >&2
202
208
  `);
203
209
  }
204
210
 
211
+ // Install SKILL.md so standalone agents can discover it
212
+ const skillsDir = path.join(configDir, 'skills', SKILL_NAME);
213
+ ensureDir(skillsDir);
214
+ const skillContent = loadSkillMd();
215
+ if (skillContent) {
216
+ fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), skillContent);
217
+ log(`Installed skill to: ${skillsDir}`);
218
+ }
219
+
205
220
  let generatedAdminToken = null;
206
221
  if (!process.env.A2A_ADMIN_TOKEN) {
207
222
  generatedAdminToken = crypto.randomBytes(24).toString('base64url');
@@ -211,6 +226,7 @@ echo "a2a notify: $payload" >&2
211
226
  configDir,
212
227
  configFile,
213
228
  manifestFile,
229
+ skillsDir,
214
230
  bridgeDir,
215
231
  turnScript,
216
232
  summaryScript,
@@ -388,108 +404,44 @@ function installDashboardProxyPlugin(backendUrl) {
388
404
  return pluginDir;
389
405
  }
390
406
 
391
- // Skill content
392
- const SKILL_MD = `---
393
- name: a2a
394
- description: "Agent-to-Agent a2a. Handle /a2a commands to create tokens, manage connections, and call remote agents. Triggers on: /a2a, a2a, agent token, a2a invite."
395
- ---
396
-
397
- # A2A
398
-
399
- Handle agent-to-agent communication with Telegram inline buttons + \`a2a\` CLI.
400
-
401
- ## CRITICAL: Forum Topic Threading
402
-
403
- When sending messages with buttons in Telegram forum groups, **ALWAYS include threadId**:
404
-
405
- 1. Extract topic ID from message header (e.g., \`topic:567\`)
406
- 2. Include \`threadId: "TOPIC_ID"\` in ALL message tool calls
407
-
408
- ## Onboarding (First Run)
409
-
410
- **BEFORE showing tiers, ALWAYS read and analyze user context:**
411
- - HEARTBEAT.md - current tasks/interests
412
- - USER.md - professional context, shareable bio
413
- - SOUL.md - agent personality
414
- - memory/*.md - stored context
415
-
416
- **Extract:** Topics of interest, goals, professional context (job seeking?), sensitive areas.
417
-
418
- **Personalize tiers based on findings** - not generic examples!
407
+ // Skill content — read from the canonical SKILL.md that ships with the package.
408
+ // No embedded copy to drift out of sync.
409
+ const SKILL_MD_PATH = path.resolve(__dirname, '..', 'SKILL.md');
419
410
 
420
- **Step 1:** Show analyzed topics grouped into Public/Friends/Private tiers
421
- **Step 2:** Confirm default settings (expiration, rate limits)
422
- **Step 3:** Confirm agent identity
423
- **Step 4:** Complete - save config, show next steps
424
-
425
- Settings saved to ~/.config/openclaw/a2a-config.json
426
-
427
- ## First-Call Requirement
428
-
429
- Before any agent uses \`/a2a call\`, the human owner must complete onboarding and approve tier permissions.
430
- If onboarding has not been completed yet, route them to \`/a2a quickstart\` first.
431
-
432
- ## Main Menu (Post-Onboarding)
433
-
434
- \`\`\`javascript
435
- message({
436
- action: "send",
437
- channel: "telegram",
438
- target: "CHAT_ID",
439
- threadId: "TOPIC_ID", // REQUIRED for forum topics!
440
- message: "🤝 **A2A**\\n\\nWhat would you like to do?",
441
- buttons: [
442
- [{ text: "📝 Create Invite", callback_data: "/a2a invite" }, { text: "📋 List Tokens", callback_data: "/a2a list" }],
443
- [{ text: "🗑 Revoke Token", callback_data: "/a2a revoke" }, { text: "📡 Add Remote", callback_data: "/a2a add" }]
444
- ]
445
- })
446
- \`\`\`
447
-
448
- ## Commands
449
-
450
- ### /a2a invite
451
- \`\`\`bash
452
- a2a create --name "NAME" --expires "DURATION"
453
- \`\`\`
454
- Reply with full shareable invite block.
455
-
456
- ### /a2a list
457
- \`\`\`bash
458
- a2a list
459
- \`\`\`
460
-
461
- ### /a2a revoke <id>
462
- \`\`\`bash
463
- a2a revoke TOKEN_ID
464
- \`\`\`
465
-
466
- ### /a2a add <url> [name]
467
- \`\`\`bash
468
- a2a add "URL" "NAME"
469
- \`\`\`
470
-
471
- ### /a2a call <url> <msg>
472
- \`\`\`bash
473
- a2a call "URL" "MESSAGE"
474
- \`\`\`
475
-
476
- ## Server
477
-
478
- \`\`\`bash
479
- a2a server --port 3001
480
- \`\`\`
481
-
482
- ## Defaults
483
- - Expiration: 1 day
484
- - Max calls: 100
485
- - Rate limit: 10/min
486
- `;
411
+ function loadSkillMd() {
412
+ if (fs.existsSync(SKILL_MD_PATH)) {
413
+ return fs.readFileSync(SKILL_MD_PATH, 'utf8');
414
+ }
415
+ warn(`SKILL.md not found at ${SKILL_MD_PATH}`);
416
+ warn('The a2acalling package may be incomplete. Run: a2a update');
417
+ return null;
418
+ }
487
419
 
488
- function install() {
420
+ async function install() {
489
421
  log('Installing A2A Calling...\n');
490
422
 
491
423
  const hostname = flags.hostname || process.env.HOSTNAME || 'localhost';
492
- const port = String(flags.port || process.env.A2A_PORT || '3001');
424
+
425
+ // Port scanning: use explicit port or scan for available one
426
+ let port;
427
+ if (flags.port) {
428
+ port = String(flags.port);
429
+ } else if (process.env.A2A_PORT) {
430
+ port = String(process.env.A2A_PORT);
431
+ } else {
432
+ try {
433
+ const { findAvailablePort } = require('../src/lib/port-scanner');
434
+ const available = await findAvailablePort([80, 3001, 8080, 8443, 9001]);
435
+ port = available ? String(available) : '3001';
436
+ if (available === 80) {
437
+ log(`Port 80 is available (recommended for inbound A2A connections)`);
438
+ } else if (available) {
439
+ log(`Auto-detected available port: ${available}`);
440
+ }
441
+ } catch (e) {
442
+ port = '3001';
443
+ }
444
+ }
493
445
  const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${port}`;
494
446
  const forceStandalone = Boolean(flags.standalone) || String(process.env.A2A_FORCE_STANDALONE || '').toLowerCase() === 'true';
495
447
  const hasOpenClawBinary = commandExists('openclaw');
@@ -504,8 +456,16 @@ function install() {
504
456
  // 2. Install skill
505
457
  const skillDir = path.join(OPENCLAW_SKILLS, SKILL_NAME);
506
458
  ensureDir(skillDir);
507
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), SKILL_MD);
459
+ const skillContent = loadSkillMd();
460
+ if (skillContent) {
461
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
462
+ } else {
463
+ warn('Skipped SKILL.md install — source file not found in package.');
464
+ }
508
465
  log(`Installed skill to: ${skillDir}`);
466
+
467
+ // Ensure config and manifest exist even in OpenClaw path
468
+ ensureConfigAndManifest(hostname, port);
509
469
  } else {
510
470
  warn('OpenClaw not detected. Enabling standalone A2A bootstrap.');
511
471
  standaloneBootstrap = ensureStandaloneBootstrap(hostname, port);
@@ -676,7 +636,10 @@ Examples:
676
636
  switch (command) {
677
637
  case 'install':
678
638
  case 'setup':
679
- install();
639
+ install().catch(err => {
640
+ error(`Install failed: ${err.message}`);
641
+ process.exit(1);
642
+ });
680
643
  break;
681
644
  case 'uninstall':
682
645
  uninstall();
@@ -313,7 +313,11 @@ function bindInviteActions() {
313
313
  body: JSON.stringify(body)
314
314
  });
315
315
  document.getElementById('invite-message').value = result.invite_message || result.invite_url;
316
- showNotice('Invite created');
316
+ if (result.warnings && result.warnings.length) {
317
+ showNotice(result.warnings[0]);
318
+ } else {
319
+ showNotice('Invite created');
320
+ }
317
321
  await loadInvites();
318
322
  });
319
323
  }
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
  /**
@@ -130,7 +135,8 @@ function generateDefaultManifest(contextFiles = {}) {
130
135
  const heartbeatContent = contextFiles.heartbeat || '';
131
136
  const soulContent = contextFiles.soul || '';
132
137
 
133
- const hasContent = userContent || heartbeatContent || soulContent;
138
+ const hasContent = userContent || heartbeatContent || soulContent ||
139
+ contextFiles.skill || contextFiles.memory || contextFiles.skills || contextFiles.claude;
134
140
 
135
141
  if (!hasContent) {
136
142
  // Minimal starter manifest
@@ -247,6 +253,52 @@ function generateDefaultManifest(contextFiles = {}) {
247
253
  }
248
254
  }
249
255
 
256
+ // Extract capability keywords from SKILL.md and installed skills
257
+ const skillContent = (contextFiles.skill || '') + '\n' + (contextFiles.skills || '');
258
+ if (skillContent.trim()) {
259
+ const capabilityLines = skillContent
260
+ .split('\n')
261
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
262
+ .map(l => l.replace(/^[\s\-\*]+/, '').trim())
263
+ .filter(l => l.length > 5 && l.length < 120)
264
+ .slice(0, 6);
265
+
266
+ capabilityLines.forEach(cap => {
267
+ manifest.topics.public.discuss_freely.push({ topic: cap.slice(0, 60), detail: cap });
268
+ });
269
+ }
270
+
271
+ // Extract topic keywords from memory files
272
+ if (contextFiles.memory) {
273
+ const memoryLines = contextFiles.memory
274
+ .split('\n')
275
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
276
+ .map(l => l.replace(/^[\s\-\*]+/, '').trim())
277
+ .filter(l => l.length > 5 && l.length < 120)
278
+ .slice(0, 4);
279
+
280
+ memoryLines.forEach(item => {
281
+ manifest.topics.friends.discuss_freely.push({ topic: item.slice(0, 60), detail: item });
282
+ });
283
+ }
284
+
285
+ // Extract project context from CLAUDE.md
286
+ if (contextFiles.claude) {
287
+ const claudeMatch = contextFiles.claude.match(/##\s*(?:What|Quick|About|Context)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
288
+ if (claudeMatch) {
289
+ const contextLines = claudeMatch[1]
290
+ .split('\n')
291
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || (l.trim().length > 10 && !l.startsWith('#')))
292
+ .map(l => l.replace(/^[\s\-\*]+/, '').trim())
293
+ .filter(Boolean)
294
+ .slice(0, 3);
295
+
296
+ contextLines.forEach(item => {
297
+ manifest.topics.public.discuss_freely.push({ topic: item.slice(0, 60), detail: item });
298
+ });
299
+ }
300
+ }
301
+
250
302
  // Ensure at least something in each public category
251
303
  if (manifest.topics.public.lead_with.length === 0) {
252
304
  manifest.topics.public.lead_with.push(
@@ -269,7 +321,7 @@ function generateDefaultManifest(contextFiles = {}) {
269
321
 
270
322
  /**
271
323
  * Read context files from an OpenClaw workspace directory.
272
- * Returns { user, heartbeat, soul } with file contents or empty strings.
324
+ * Returns { user, heartbeat, soul, skill, claude, memory, skills } with file contents or empty strings.
273
325
  */
274
326
  function readContextFiles(workspaceDir) {
275
327
  const read = (filename) => {
@@ -282,11 +334,55 @@ function readContextFiles(workspaceDir) {
282
334
  return '';
283
335
  };
284
336
 
285
- return {
337
+ // Read primary files
338
+ const result = {
286
339
  user: read('USER.md'),
287
340
  heartbeat: read('HEARTBEAT.md'),
288
- soul: read('SOUL.md')
341
+ soul: read('SOUL.md'),
342
+ skill: read('SKILL.md'),
343
+ claude: read('CLAUDE.md')
289
344
  };
345
+
346
+ // Scan memory/*.md
347
+ const memoryDir = path.join(workspaceDir, 'memory');
348
+ result.memory = '';
349
+ if (fs.existsSync(memoryDir)) {
350
+ try {
351
+ const files = fs.readdirSync(memoryDir).filter(f => f.endsWith('.md'));
352
+ result.memory = files.map(f => {
353
+ try { return fs.readFileSync(path.join(memoryDir, f), 'utf8'); }
354
+ catch (e) { return ''; }
355
+ }).filter(Boolean).join('\n---\n');
356
+ } catch (e) {}
357
+ }
358
+
359
+ // Scan installed skills from both OpenClaw and standalone paths
360
+ const homeDir = process.env.HOME || '/tmp';
361
+ const skillsDirs = [
362
+ process.env.OPENCLAW_SKILLS || path.join(homeDir, '.openclaw', 'skills'),
363
+ path.join(CONFIG_DIR, 'skills')
364
+ ];
365
+ const skillFragments = [];
366
+ const seenSkillDirs = new Set();
367
+ for (const skillsDir of skillsDirs) {
368
+ if (!fs.existsSync(skillsDir) || seenSkillDirs.has(skillsDir)) continue;
369
+ seenSkillDirs.add(skillsDir);
370
+ try {
371
+ const dirs = fs.readdirSync(skillsDir).filter(d => {
372
+ try { return fs.statSync(path.join(skillsDir, d)).isDirectory(); }
373
+ catch (e) { return false; }
374
+ });
375
+ for (const d of dirs) {
376
+ try {
377
+ const content = fs.readFileSync(path.join(skillsDir, d, 'SKILL.md'), 'utf8');
378
+ if (content) skillFragments.push(content);
379
+ } catch (e) {}
380
+ }
381
+ } catch (e) {}
382
+ }
383
+ result.skills = skillFragments.join('\n---\n');
384
+
385
+ return result;
290
386
  }
291
387
 
292
388
  module.exports = {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * External IP Resolver (Cached)
3
+ *
4
+ * When generating invites, we want a host that other agents can reach.
5
+ * This module fetches the machine's public (egress) IP from a few
6
+ * "what is my IP" services and caches the result to avoid repeated
7
+ * network calls.
8
+ *
9
+ * Cache file: ~/.config/openclaw/a2a-external-ip.json (or A2A_CONFIG_DIR)
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const http = require('http');
15
+ const https = require('https');
16
+ const net = require('net');
17
+
18
+ const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
19
+ process.env.OPENCLAW_CONFIG_DIR ||
20
+ path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
21
+
22
+ const EXTERNAL_IP_CACHE_FILE = path.join(CONFIG_DIR, 'a2a-external-ip.json');
23
+
24
+ const DEFAULT_SERVICES = [
25
+ 'https://api.ipify.org',
26
+ 'https://checkip.amazonaws.com/',
27
+ 'https://icanhazip.com/'
28
+ ];
29
+
30
+ function ensureDir(dirPath) {
31
+ if (!fs.existsSync(dirPath)) {
32
+ fs.mkdirSync(dirPath, { recursive: true });
33
+ }
34
+ }
35
+
36
+ function atomicWriteJson(filePath, data) {
37
+ ensureDir(path.dirname(filePath));
38
+ const tmpPath = `${filePath}.tmp`;
39
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
40
+ fs.renameSync(tmpPath, filePath);
41
+ try {
42
+ fs.chmodSync(filePath, 0o600);
43
+ } catch (err) {
44
+ // Best effort.
45
+ }
46
+ }
47
+
48
+ function readJson(filePath) {
49
+ try {
50
+ if (!fs.existsSync(filePath)) return null;
51
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
52
+ } catch (err) {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function parseIp(text) {
58
+ const candidate = String(text || '')
59
+ .trim()
60
+ .replace(/^"+|"+$/g, '')
61
+ .split(/\s+/)[0];
62
+ if (!candidate) return null;
63
+ return net.isIP(candidate) ? candidate : null;
64
+ }
65
+
66
+ function requestText(url, timeoutMs) {
67
+ return new Promise((resolve, reject) => {
68
+ let parsed;
69
+ try {
70
+ parsed = new URL(url);
71
+ } catch (err) {
72
+ reject(new Error('invalid_url'));
73
+ return;
74
+ }
75
+
76
+ const client = parsed.protocol === 'https:' ? https : http;
77
+ const req = client.request({
78
+ protocol: parsed.protocol,
79
+ hostname: parsed.hostname,
80
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
81
+ method: 'GET',
82
+ path: parsed.pathname + parsed.search,
83
+ headers: {
84
+ 'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (external-ip)`
85
+ },
86
+ timeout: timeoutMs
87
+ }, (res) => {
88
+ let data = '';
89
+ res.setEncoding('utf8');
90
+
91
+ res.on('data', (chunk) => {
92
+ data += chunk;
93
+ if (data.length > 4096) {
94
+ req.destroy(new Error('response_too_large'));
95
+ }
96
+ });
97
+
98
+ res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: data }));
99
+ });
100
+
101
+ req.on('error', reject);
102
+ req.on('timeout', () => {
103
+ req.destroy(new Error('timeout'));
104
+ });
105
+
106
+ req.end();
107
+ });
108
+ }
109
+
110
+ async function fetchExternalIp(options = {}) {
111
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 2500;
112
+ const services = Array.isArray(options.services) && options.services.length
113
+ ? options.services
114
+ : DEFAULT_SERVICES;
115
+
116
+ let lastError = null;
117
+ for (const serviceUrl of services) {
118
+ try {
119
+ const res = await requestText(serviceUrl, timeoutMs);
120
+ if (res.statusCode < 200 || res.statusCode >= 300) {
121
+ throw new Error(`bad_status_${res.statusCode}`);
122
+ }
123
+ const ip = parseIp(res.body);
124
+ if (!ip) {
125
+ throw new Error('invalid_ip');
126
+ }
127
+ return { ip, source: serviceUrl };
128
+ } catch (err) {
129
+ lastError = err;
130
+ }
131
+ }
132
+
133
+ const msg = lastError ? lastError.message : 'unavailable';
134
+ throw new Error(`external_ip_unavailable:${msg}`);
135
+ }
136
+
137
+ /**
138
+ * Get external IP, using cached result if not stale.
139
+ *
140
+ * Returns:
141
+ * { ip, checkedAt, source, fromCache, stale }
142
+ * or:
143
+ * { ip: null, error }
144
+ */
145
+ async function getExternalIp(options = {}) {
146
+ const ttlMs = Number.isFinite(options.ttlMs)
147
+ ? options.ttlMs
148
+ : Number.parseInt(process.env.A2A_EXTERNAL_IP_TTL_MS || '', 10) || (6 * 60 * 60 * 1000);
149
+ const cacheFile = options.cacheFile || EXTERNAL_IP_CACHE_FILE;
150
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
151
+
152
+ const cached = readJson(cacheFile);
153
+ if (cached && cached.ip && cached.checked_at) {
154
+ const cachedIp = parseIp(cached.ip);
155
+ const checkedAtMs = Date.parse(cached.checked_at);
156
+ if (cachedIp && Number.isFinite(checkedAtMs)) {
157
+ const ageMs = Math.max(0, nowMs - checkedAtMs);
158
+ if (ageMs <= ttlMs && !options.forceRefresh) {
159
+ return {
160
+ ip: cachedIp,
161
+ checkedAt: cached.checked_at,
162
+ source: cached.source || 'cache',
163
+ fromCache: true,
164
+ stale: false
165
+ };
166
+ }
167
+ }
168
+ }
169
+
170
+ try {
171
+ const { ip, source } = await fetchExternalIp({
172
+ timeoutMs: options.timeoutMs,
173
+ services: options.services
174
+ });
175
+ const checkedAt = new Date(nowMs).toISOString();
176
+ atomicWriteJson(cacheFile, { ip, checked_at: checkedAt, source });
177
+ return { ip, checkedAt, source, fromCache: false, stale: false };
178
+ } catch (err) {
179
+ if (cached && cached.ip) {
180
+ const cachedIp = parseIp(cached.ip);
181
+ if (cachedIp) {
182
+ return {
183
+ ip: cachedIp,
184
+ checkedAt: cached.checked_at || null,
185
+ source: cached.source || 'cache',
186
+ fromCache: true,
187
+ stale: true
188
+ };
189
+ }
190
+ }
191
+ return { ip: null, error: err.message };
192
+ }
193
+ }
194
+
195
+ module.exports = {
196
+ EXTERNAL_IP_CACHE_FILE,
197
+ fetchExternalIp,
198
+ getExternalIp
199
+ };
200
+