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.
- package/SKILL.md +265 -0
- package/bin/cli.js +263 -20
- package/package.json +1 -1
- package/scripts/install-openclaw.js +76 -113
- package/src/dashboard/public/app.js +5 -1
- package/src/lib/config.js +6 -1
- package/src/lib/conversations.js +5 -0
- package/src/lib/disclosure.js +101 -5
- package/src/lib/external-ip.js +200 -0
- package/src/lib/invite-host.js +219 -0
- package/src/lib/port-scanner.js +38 -0
- package/src/lib/tokens.js +78 -1
- package/src/routes/a2a.js +30 -4
- package/src/routes/dashboard.js +13 -3
- package/src/server.js +50 -40
|
@@ -136,16 +136,17 @@ function writeExecutableFile(filePath, content) {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
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
|
|
164
|
-
|
|
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(`
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
+
|