a2acalling 0.5.4 → 0.6.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/README.md +4 -5
- package/SKILL.md +4 -3
- package/bin/cli.js +2 -4
- package/docs/protocol.md +9 -6
- package/package.json +1 -1
- package/scripts/install-openclaw.js +269 -57
- package/src/lib/client.js +49 -25
- package/src/lib/invite-host.js +13 -35
- package/src/lib/port-scanner.js +51 -6
- package/src/lib/quick-tunnel.js +0 -332
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ a2a create --name "My Agent" --owner "Your Name" --tier friends
|
|
|
33
33
|
# Output:
|
|
34
34
|
# 🤝 Your Name is inviting you to connect agents!
|
|
35
35
|
# Your agent can reach My Agent for: chat, web, files
|
|
36
|
-
# a2a://
|
|
36
|
+
# a2a://your-host.com/fed_abc123xyz
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### Call someone else's agent
|
|
@@ -74,9 +74,9 @@ node scripts/install-openclaw.js setup
|
|
|
74
74
|
|
|
75
75
|
Setup behavior:
|
|
76
76
|
- Runtime auto-detects OpenClaw when available and falls back to generic mode if unavailable.
|
|
77
|
-
- If OpenClaw gateway is detected, dashboard is exposed on gateway at `/a2a` (proxied to A2A backend).
|
|
77
|
+
- If OpenClaw gateway is detected, dashboard is exposed on gateway at `/a2a` and A2A API at `/api/a2a/*` (proxied to A2A backend).
|
|
78
78
|
- If OpenClaw is not detected, setup bootstraps standalone config + bridge templates and serves dashboard at `/dashboard`.
|
|
79
|
-
-
|
|
79
|
+
- Setup inspects port 80 and prints reverse proxy guidance for stable internet-facing ingress.
|
|
80
80
|
- Setup prints the exact dashboard URL at the end.
|
|
81
81
|
|
|
82
82
|
Before the first `a2a call`, the owner must set permissions and disclosure tiers. Run onboarding first:
|
|
@@ -369,9 +369,8 @@ app.listen(3001);
|
|
|
369
369
|
|
|
370
370
|
| Variable | Description |
|
|
371
371
|
|----------|-------------|
|
|
372
|
-
| `A2A_HOSTNAME` | Hostname for invite URLs (required for
|
|
372
|
+
| `A2A_HOSTNAME` | Hostname for invite URLs (required for internet-facing invites) |
|
|
373
373
|
| `A2A_PORT` | Server port (default: 3001) |
|
|
374
|
-
| `A2A_DISABLE_QUICK_TUNNEL` | Set `true` to disable auto Cloudflare Quick Tunnel host resolution |
|
|
375
374
|
| `A2A_CONFIG_DIR` | Config directory (default: `~/.config/openclaw`) |
|
|
376
375
|
| `A2A_WORKSPACE` | Workspace root for context files like `USER.md` (default: current directory) |
|
|
377
376
|
| `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `generic` |
|
package/SKILL.md
CHANGED
|
@@ -53,9 +53,10 @@ Extract: professional context, interests, goals, skills, sensitive areas. Group
|
|
|
53
53
|
|
|
54
54
|
## Network Ingress (Internet-Facing Invites)
|
|
55
55
|
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
56
|
+
- A2A does not bundle an auto-tunneling service for internet-facing ingress.
|
|
57
|
+
- For stable internet-facing invites, set `A2A_HOSTNAME` to your public endpoint (domain or public IP).
|
|
58
|
+
- Recommended: run the A2A backend on an internal port and expose it via a reverse proxy on `:443` (HTTPS) or `:80` (HTTP), routing `/api/a2a/*` to the backend.
|
|
59
|
+
- `npx a2acalling setup` inspects port 80 and prints reverse proxy guidance + an external reachability check.
|
|
59
60
|
|
|
60
61
|
## Commands
|
|
61
62
|
|
package/bin/cli.js
CHANGED
|
@@ -170,15 +170,13 @@ async function resolveInviteHostname() {
|
|
|
170
170
|
const config = new A2AConfig();
|
|
171
171
|
const resolved = await resolveInviteHost({
|
|
172
172
|
config,
|
|
173
|
-
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
174
|
-
preferQuickTunnel: true
|
|
173
|
+
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
175
174
|
});
|
|
176
175
|
return resolved;
|
|
177
176
|
} catch (err) {
|
|
178
177
|
return resolveInviteHost({
|
|
179
178
|
fallbackHost: process.env.OPENCLAW_HOSTNAME || process.env.HOSTNAME || 'localhost',
|
|
180
|
-
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
181
|
-
preferQuickTunnel: true
|
|
179
|
+
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
182
180
|
});
|
|
183
181
|
}
|
|
184
182
|
}
|
package/docs/protocol.md
CHANGED
|
@@ -259,13 +259,16 @@ Owner can reply to inject into the conversation.
|
|
|
259
259
|
|
|
260
260
|
## OpenClaw Integration
|
|
261
261
|
|
|
262
|
-
### Gateway
|
|
262
|
+
### Gateway Proxy (Recommended)
|
|
263
263
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
264
|
+
Run A2A Calling as its own server (separate process), and let the OpenClaw gateway proxy to it:
|
|
265
|
+
|
|
266
|
+
- A2A backend (separate): `a2a server --port 3001`
|
|
267
|
+
- OpenClaw gateway path(s):
|
|
268
|
+
- `/a2a` proxies to the A2A dashboard UI (`/dashboard`)
|
|
269
|
+
- `/api/a2a/*` proxies to the A2A backend API
|
|
270
|
+
|
|
271
|
+
This keeps the A2A server decoupled from OpenClaw's gateway runtime while still allowing the gateway to be the single public entry point.
|
|
269
272
|
|
|
270
273
|
### Agent Context
|
|
271
274
|
|
package/package.json
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Supports automatic setup:
|
|
6
6
|
* - If OpenClaw gateway is detected, install a gateway HTTP proxy plugin
|
|
7
|
-
* so dashboard
|
|
7
|
+
* so dashboard and A2A API are accessible at /a2a and /api/a2a on gateway.
|
|
8
8
|
* - If gateway is not detected, dashboard runs on standalone A2A server.
|
|
9
9
|
* - If OpenClaw is not installed, bootstrap standalone runtime templates.
|
|
10
|
-
* -
|
|
11
|
-
* for internet-facing
|
|
10
|
+
* - Networking-aware: inspects port 80 and prints reverse proxy guidance
|
|
11
|
+
* for stable internet-facing ingress.
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
14
|
* npx a2acalling install
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const crypto = require('crypto');
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const https = require('https');
|
|
23
25
|
const { execSync } = require('child_process');
|
|
24
26
|
|
|
25
27
|
// Paths
|
|
@@ -287,8 +289,8 @@ echo "a2a notify: $payload" >&2
|
|
|
287
289
|
|
|
288
290
|
const DASHBOARD_PLUGIN_MANIFEST = {
|
|
289
291
|
id: DASHBOARD_PLUGIN_ID,
|
|
290
|
-
name: 'A2A
|
|
291
|
-
description: 'Proxy A2A dashboard routes through OpenClaw gateway',
|
|
292
|
+
name: 'A2A Gateway Proxy',
|
|
293
|
+
description: 'Proxy A2A API + dashboard routes through OpenClaw gateway (backend runs separately)',
|
|
292
294
|
version: '1.0.0',
|
|
293
295
|
configSchema: {
|
|
294
296
|
type: 'object',
|
|
@@ -309,7 +311,7 @@ import https from "node:https";
|
|
|
309
311
|
|
|
310
312
|
const PLUGIN_ID = "a2a-dashboard-proxy";
|
|
311
313
|
const UI_PREFIX = "/a2a";
|
|
312
|
-
const API_PREFIX = "/api/a2a
|
|
314
|
+
const API_PREFIX = "/api/a2a";
|
|
313
315
|
|
|
314
316
|
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
315
317
|
res.statusCode = status;
|
|
@@ -364,8 +366,8 @@ function rewriteUiPath(pathname: string): string {
|
|
|
364
366
|
|
|
365
367
|
const plugin = {
|
|
366
368
|
id: PLUGIN_ID,
|
|
367
|
-
name: "A2A
|
|
368
|
-
description: "Proxy A2A dashboard routes through OpenClaw gateway",
|
|
369
|
+
name: "A2A Gateway Proxy",
|
|
370
|
+
description: "Proxy A2A API + dashboard routes through OpenClaw gateway (backend runs separately)",
|
|
369
371
|
configSchema: {
|
|
370
372
|
type: "object" as const,
|
|
371
373
|
additionalProperties: false,
|
|
@@ -423,11 +425,12 @@ const plugin = {
|
|
|
423
425
|
|
|
424
426
|
proxyReq.on("error", (err) => {
|
|
425
427
|
const backend = backendBase.toString();
|
|
428
|
+
const suggestedPort = backendBase.port || (backendBase.protocol === "https:" ? "443" : "80");
|
|
426
429
|
if (isApi) {
|
|
427
430
|
sendJson(res, 502, {
|
|
428
431
|
success: false,
|
|
429
|
-
error: "
|
|
430
|
-
message: \`Could not reach A2A
|
|
432
|
+
error: "a2a_backend_unreachable",
|
|
433
|
+
message: \`Could not reach A2A backend at \${backend}: \${err.message}\`
|
|
431
434
|
});
|
|
432
435
|
return;
|
|
433
436
|
}
|
|
@@ -438,7 +441,7 @@ const plugin = {
|
|
|
438
441
|
<h1>A2A Dashboard Unavailable</h1>
|
|
439
442
|
<p>The gateway proxy is active, but the A2A backend is not reachable.</p>
|
|
440
443
|
<p>Expected backend: <code>\${backend}</code></p>
|
|
441
|
-
<p>Start the backend with: <code>a2a server --port
|
|
444
|
+
<p>Start the backend with: <code>a2a server --port \${suggestedPort}</code></p>
|
|
442
445
|
</body></html>\`);
|
|
443
446
|
});
|
|
444
447
|
|
|
@@ -491,69 +494,221 @@ function readExistingConfiguredInviteHost() {
|
|
|
491
494
|
if (!parsed.hostname || isLocalOrUnroutableHost(parsed.hostname)) {
|
|
492
495
|
return '';
|
|
493
496
|
}
|
|
497
|
+
// Legacy: Cloudflare quick tunnels are ephemeral and no longer supported.
|
|
498
|
+
if (String(parsed.hostname).toLowerCase().endsWith('.trycloudflare.com')) {
|
|
499
|
+
return '';
|
|
500
|
+
}
|
|
494
501
|
return existing;
|
|
495
502
|
} catch (err) {
|
|
496
503
|
return '';
|
|
497
504
|
}
|
|
498
505
|
}
|
|
499
506
|
|
|
500
|
-
|
|
501
|
-
const disabled = Boolean(flags['no-quick-tunnel']) ||
|
|
502
|
-
String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
|
|
503
|
-
if (disabled) return null;
|
|
504
|
-
|
|
507
|
+
function safeJsonParse(text) {
|
|
505
508
|
try {
|
|
506
|
-
|
|
507
|
-
const tunnel = await ensureQuickTunnel({ localPort: Number.parseInt(String(port), 10) || 3001 });
|
|
508
|
-
if (!tunnel || !tunnel.host) return null;
|
|
509
|
-
return {
|
|
510
|
-
...tunnel,
|
|
511
|
-
inviteHost: `${tunnel.host}:443`
|
|
512
|
-
};
|
|
509
|
+
return JSON.parse(String(text || ''));
|
|
513
510
|
} catch (err) {
|
|
514
|
-
warn(`Quick Tunnel setup failed: ${err.message}`);
|
|
515
|
-
warn('Falling back to direct host invites (may require firewall/NAT config).');
|
|
516
511
|
return null;
|
|
517
512
|
}
|
|
518
513
|
}
|
|
519
514
|
|
|
515
|
+
function looksLikePong(body) {
|
|
516
|
+
const parsed = safeJsonParse(body);
|
|
517
|
+
if (parsed && typeof parsed === 'object' && parsed.pong === true) return true;
|
|
518
|
+
return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function fetchUrlText(url, timeoutMs = 5000) {
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = new URL(url);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
reject(new Error('invalid_url'));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
532
|
+
const req = client.request({
|
|
533
|
+
protocol: parsed.protocol,
|
|
534
|
+
hostname: parsed.hostname,
|
|
535
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
536
|
+
method: 'GET',
|
|
537
|
+
path: parsed.pathname + parsed.search,
|
|
538
|
+
headers: {
|
|
539
|
+
'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (setup-check)`
|
|
540
|
+
},
|
|
541
|
+
timeout: timeoutMs
|
|
542
|
+
}, (res) => {
|
|
543
|
+
let data = '';
|
|
544
|
+
res.setEncoding('utf8');
|
|
545
|
+
|
|
546
|
+
res.on('data', (chunk) => {
|
|
547
|
+
data += chunk;
|
|
548
|
+
if (data.length > 1024 * 256) {
|
|
549
|
+
req.destroy(new Error('response_too_large'));
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
res.on('end', () => {
|
|
554
|
+
resolve({
|
|
555
|
+
statusCode: res.statusCode || 0,
|
|
556
|
+
headers: res.headers || {},
|
|
557
|
+
body: data
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
req.on('error', reject);
|
|
563
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
564
|
+
req.end();
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function probeLocalA2APing(port) {
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetchUrlText(`http://127.0.0.1:${port}/api/a2a/ping`, 900);
|
|
571
|
+
return { ok: looksLikePong(res.body), statusCode: res.statusCode, body: res.body };
|
|
572
|
+
} catch (err) {
|
|
573
|
+
return { ok: false, error: err && err.message ? err.message : 'request_failed' };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function scanPort80() {
|
|
578
|
+
const { isPortListening, tryBindPort } = require('../src/lib/port-scanner');
|
|
579
|
+
|
|
580
|
+
const listening = await isPortListening(80, '127.0.0.1', { timeoutMs: 500 });
|
|
581
|
+
const bind = await tryBindPort(80, '0.0.0.0');
|
|
582
|
+
const a2aPing = listening.listening ? await probeLocalA2APing(80) : { ok: false };
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
listening: Boolean(listening.listening),
|
|
586
|
+
listeningCode: listening.code,
|
|
587
|
+
bindOk: Boolean(bind.ok),
|
|
588
|
+
bindCode: bind.code,
|
|
589
|
+
a2aPingOk: Boolean(a2aPing.ok),
|
|
590
|
+
a2aPingStatusCode: a2aPing.statusCode,
|
|
591
|
+
a2aPingError: a2aPing.error
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function externalPingCheck(targetUrl) {
|
|
596
|
+
const providers = [
|
|
597
|
+
{
|
|
598
|
+
name: 'allorigins',
|
|
599
|
+
buildUrl: () => {
|
|
600
|
+
const u = new URL('https://api.allorigins.win/raw');
|
|
601
|
+
u.searchParams.set('url', targetUrl);
|
|
602
|
+
return u.toString();
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
name: 'jina',
|
|
607
|
+
buildUrl: () => `https://r.jina.ai/${targetUrl}`
|
|
608
|
+
}
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
const attempts = [];
|
|
612
|
+
for (const provider of providers) {
|
|
613
|
+
const providerUrl = provider.buildUrl();
|
|
614
|
+
try {
|
|
615
|
+
// eslint-disable-next-line no-await-in-loop
|
|
616
|
+
const res = await fetchUrlText(providerUrl, 8000);
|
|
617
|
+
const ok = looksLikePong(res.body);
|
|
618
|
+
attempts.push({ provider: provider.name, ok, statusCode: res.statusCode });
|
|
619
|
+
if (ok) {
|
|
620
|
+
return { ok: true, provider: provider.name, statusCode: res.statusCode, attempts };
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
attempts.push({ provider: provider.name, ok: false, error: err && err.message ? err.message : 'request_failed' });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return { ok: false, attempts };
|
|
628
|
+
}
|
|
629
|
+
|
|
520
630
|
async function install() {
|
|
521
631
|
log('Installing A2A Calling...\n');
|
|
522
632
|
|
|
523
633
|
const localHostname = process.env.HOSTNAME || 'localhost';
|
|
524
634
|
|
|
525
|
-
//
|
|
526
|
-
|
|
635
|
+
// Networking scan (port 80) + backend port selection.
|
|
636
|
+
const port80 = await scanPort80();
|
|
637
|
+
if (port80.a2aPingOk) {
|
|
638
|
+
log('Port 80 already serves /api/a2a/ping (A2A detected on :80).');
|
|
639
|
+
} else if (port80.listening) {
|
|
640
|
+
warn(`Port 80 is already bound (${port80.listeningCode || 'in_use'}). Setup will use an internal port and recommend reverse proxy routing.`);
|
|
641
|
+
} else if (!port80.bindOk && port80.bindCode === 'EACCES') {
|
|
642
|
+
warn('Port 80 appears free but is not bindable by this user (EACCES). Setup will use an unprivileged port unless you run with elevated privileges.');
|
|
643
|
+
} else if (port80.bindOk) {
|
|
644
|
+
log('Port 80 is bindable by this user (recommended for inbound A2A if you intend to serve directly on :80).');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let backendPort = null;
|
|
527
648
|
if (flags.port) {
|
|
528
|
-
|
|
649
|
+
backendPort = Number.parseInt(String(flags.port), 10);
|
|
529
650
|
} else if (process.env.A2A_PORT) {
|
|
530
|
-
|
|
651
|
+
backendPort = Number.parseInt(String(process.env.A2A_PORT), 10);
|
|
531
652
|
} else {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
653
|
+
if (port80.bindOk && !port80.listening) {
|
|
654
|
+
backendPort = 80;
|
|
655
|
+
} else {
|
|
656
|
+
try {
|
|
657
|
+
const { findAvailablePort } = require('../src/lib/port-scanner');
|
|
658
|
+
backendPort = await findAvailablePort([3001, 8080, 8443, 9001]);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
backendPort = null;
|
|
540
661
|
}
|
|
541
|
-
|
|
542
|
-
|
|
662
|
+
if (!backendPort) {
|
|
663
|
+
backendPort = 3001;
|
|
664
|
+
}
|
|
665
|
+
log(`Auto-detected available internal port: ${backendPort}`);
|
|
543
666
|
}
|
|
544
667
|
}
|
|
545
|
-
|
|
668
|
+
if (!Number.isFinite(backendPort) || backendPort <= 0 || backendPort > 65535) {
|
|
669
|
+
backendPort = 3001;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${backendPort}`;
|
|
673
|
+
|
|
674
|
+
// Invite host selection: explicit > existing configured public host > auto resolve to external IP.
|
|
546
675
|
const explicitInviteHost = flags.hostname ||
|
|
547
676
|
process.env.A2A_HOSTNAME ||
|
|
548
677
|
process.env.OPENCLAW_HOSTNAME ||
|
|
549
678
|
readExistingConfiguredInviteHost();
|
|
550
|
-
|
|
551
|
-
|
|
679
|
+
|
|
680
|
+
let inviteHost = explicitInviteHost;
|
|
681
|
+
let inviteHostWarnings = [];
|
|
682
|
+
|
|
683
|
+
if (!inviteHost) {
|
|
684
|
+
try {
|
|
685
|
+
const { A2AConfig } = require('../src/lib/config');
|
|
686
|
+
const { resolveInviteHost } = require('../src/lib/invite-host');
|
|
687
|
+
const config = new A2AConfig();
|
|
688
|
+
const inviteDefaultPort = port80.listening ? 80 : backendPort;
|
|
689
|
+
const resolved = await resolveInviteHost({
|
|
690
|
+
config,
|
|
691
|
+
fallbackHost: localHostname,
|
|
692
|
+
defaultPort: inviteDefaultPort,
|
|
693
|
+
refreshExternalIp: true
|
|
694
|
+
});
|
|
695
|
+
inviteHost = resolved.host;
|
|
696
|
+
inviteHostWarnings = resolved.warnings || [];
|
|
697
|
+
} catch (err) {
|
|
698
|
+
inviteHost = `${localHostname}:${backendPort}`;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const w of inviteHostWarnings) {
|
|
703
|
+
warn(w);
|
|
704
|
+
}
|
|
705
|
+
|
|
552
706
|
const forceStandalone = Boolean(flags.standalone) || String(process.env.A2A_FORCE_STANDALONE || '').toLowerCase() === 'true';
|
|
553
707
|
const hasOpenClawBinary = commandExists('openclaw');
|
|
554
708
|
const hasOpenClawConfig = fs.existsSync(OPENCLAW_CONFIG);
|
|
555
709
|
const hasOpenClaw = !forceStandalone && (hasOpenClawBinary || hasOpenClawConfig);
|
|
556
710
|
let standaloneBootstrap = null;
|
|
711
|
+
const forceHostname = Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME) || !explicitInviteHost;
|
|
557
712
|
|
|
558
713
|
if (hasOpenClaw) {
|
|
559
714
|
// 1. Create skills directory if needed
|
|
@@ -571,13 +726,13 @@ async function install() {
|
|
|
571
726
|
log(`Installed skill to: ${skillDir}`);
|
|
572
727
|
|
|
573
728
|
// Ensure config and manifest exist even in OpenClaw path
|
|
574
|
-
ensureConfigAndManifest(inviteHost,
|
|
575
|
-
forceHostname
|
|
729
|
+
ensureConfigAndManifest(inviteHost, backendPort, {
|
|
730
|
+
forceHostname
|
|
576
731
|
});
|
|
577
732
|
} else {
|
|
578
733
|
warn('OpenClaw not detected. Enabling standalone A2A bootstrap.');
|
|
579
|
-
standaloneBootstrap = ensureStandaloneBootstrap(inviteHost,
|
|
580
|
-
forceHostname
|
|
734
|
+
standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, backendPort, {
|
|
735
|
+
forceHostname
|
|
581
736
|
});
|
|
582
737
|
}
|
|
583
738
|
|
|
@@ -586,7 +741,7 @@ async function install() {
|
|
|
586
741
|
let configUpdated = false;
|
|
587
742
|
let gatewayDetected = false;
|
|
588
743
|
let dashboardMode = 'standalone';
|
|
589
|
-
let dashboardUrl = `http://${localHostname}:${
|
|
744
|
+
let dashboardUrl = `http://${localHostname}:${backendPort}/dashboard`;
|
|
590
745
|
|
|
591
746
|
if (config) {
|
|
592
747
|
// Add custom command for each enabled channel
|
|
@@ -650,12 +805,76 @@ async function install() {
|
|
|
650
805
|
? 'Runtime auto-selects OpenClaw when available and falls back to generic if needed.'
|
|
651
806
|
: 'Runtime defaults to generic fallback (no OpenClaw dependency required).';
|
|
652
807
|
|
|
808
|
+
const { splitHostPort, isLocalOrUnroutableHost } = require('../src/lib/invite-host');
|
|
809
|
+
const inviteParsed = splitHostPort(inviteHost);
|
|
810
|
+
const invitePort = inviteParsed.port;
|
|
811
|
+
const inviteScheme = (!invitePort || invitePort === 443) ? 'https' : 'http';
|
|
812
|
+
const invitePingUrl = `${inviteScheme}://${inviteHost}/api/a2a/ping`;
|
|
813
|
+
const inviteLooksLocal = isLocalOrUnroutableHost(inviteParsed.hostname);
|
|
814
|
+
const expectsReverseProxy = Boolean(
|
|
815
|
+
(invitePort === 80 && backendPort !== 80) ||
|
|
816
|
+
((!invitePort || invitePort === 443) && backendPort !== 443)
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
let externalPing = null;
|
|
820
|
+
if (!inviteLooksLocal && inviteParsed.hostname) {
|
|
821
|
+
externalPing = await externalPingCheck(invitePingUrl);
|
|
822
|
+
}
|
|
823
|
+
|
|
653
824
|
console.log(`
|
|
654
825
|
${bold('━━━ Server Setup ━━━')}
|
|
655
826
|
|
|
656
827
|
To receive incoming calls and host A2A APIs:
|
|
657
828
|
|
|
658
|
-
${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${
|
|
829
|
+
${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`)}
|
|
830
|
+
|
|
831
|
+
${bold('━━━ Ingress Setup ━━━')}
|
|
832
|
+
|
|
833
|
+
Invite host: ${green(inviteHost)}
|
|
834
|
+
Expected ping URL: ${green(invitePingUrl)}
|
|
835
|
+
|
|
836
|
+
Port 80 scan:
|
|
837
|
+
${port80.a2aPingOk
|
|
838
|
+
? green('Port 80 responds to /api/a2a/ping (A2A ready on :80)')
|
|
839
|
+
: port80.listening
|
|
840
|
+
? yellow(`Port 80 has a listener (${port80.listeningCode || 'in_use'})`)
|
|
841
|
+
: port80.bindOk
|
|
842
|
+
? green('Port 80 is free and bindable by this user')
|
|
843
|
+
: yellow(`Port 80 not bindable (${port80.bindCode || 'unknown'})`)}
|
|
844
|
+
|
|
845
|
+
${expectsReverseProxy
|
|
846
|
+
? `Reverse proxy required:
|
|
847
|
+
Route ${green('/api/a2a/*')} -> ${green(backendUrl)}
|
|
848
|
+
Route ${green('/a2a/*')} -> ${green(backendUrl.replace(/\/$/, ''))}${green('/dashboard/*')} (optional dashboard)
|
|
849
|
+
|
|
850
|
+
Example (Caddy):
|
|
851
|
+
${green(`YOUR_DOMAIN {
|
|
852
|
+
handle /api/a2a/* {
|
|
853
|
+
reverse_proxy 127.0.0.1:${backendPort}
|
|
854
|
+
}
|
|
855
|
+
handle /a2a* {
|
|
856
|
+
uri replace /a2a /dashboard
|
|
857
|
+
reverse_proxy 127.0.0.1:${backendPort}
|
|
858
|
+
}
|
|
859
|
+
}`)}
|
|
860
|
+
|
|
861
|
+
Example (nginx):
|
|
862
|
+
${green(`location /api/a2a/ {
|
|
863
|
+
proxy_pass http://127.0.0.1:${backendPort}/api/a2a/;
|
|
864
|
+
}
|
|
865
|
+
location = /a2a { return 301 /a2a/; }
|
|
866
|
+
location /a2a/ {
|
|
867
|
+
proxy_pass http://127.0.0.1:${backendPort}/dashboard/;
|
|
868
|
+
}`)}`
|
|
869
|
+
: 'Reverse proxy not required for the selected invite host.'}
|
|
870
|
+
|
|
871
|
+
${bold('━━━ External Ping ━━━')}
|
|
872
|
+
|
|
873
|
+
${inviteLooksLocal
|
|
874
|
+
? yellow('Skipped external ping: invite host looks local/unroutable. Set --hostname to a public endpoint to enable this check.')
|
|
875
|
+
: externalPing && externalPing.ok
|
|
876
|
+
? green(`External ping OK via ${externalPing.provider}`)
|
|
877
|
+
: yellow(`External ping FAILED (expected if the server is not running yet, or ingress is not publicly reachable).`)}
|
|
659
878
|
|
|
660
879
|
${bold('━━━ Dashboard Setup ━━━')}
|
|
661
880
|
|
|
@@ -663,18 +882,12 @@ Mode: ${dashboardMode === 'gateway' ? green('gateway') : yellow('standalone')}
|
|
|
663
882
|
Dashboard URL: ${green(dashboardUrl)}
|
|
664
883
|
|
|
665
884
|
${dashboardMode === 'gateway'
|
|
666
|
-
? `Gateway
|
|
885
|
+
? `Gateway paths /a2a and /api/a2a/* are now proxied to ${backendUrl}.`
|
|
667
886
|
: 'No gateway detected. Dashboard is served directly from the A2A server.'}
|
|
668
887
|
|
|
669
888
|
${bold('━━━ Runtime Setup ━━━')}
|
|
670
889
|
|
|
671
890
|
${runtimeLine}
|
|
672
|
-
${quickTunnel
|
|
673
|
-
? `Quick Tunnel enabled:
|
|
674
|
-
${green(quickTunnel.url)}
|
|
675
|
-
Invites will use: ${green(inviteHost)}
|
|
676
|
-
`
|
|
677
|
-
: 'Quick Tunnel not enabled (using configured/direct invite host).'}
|
|
678
891
|
${standaloneBootstrap
|
|
679
892
|
? `Standalone bridge templates:
|
|
680
893
|
${green(standaloneBootstrap.turnScript)}
|
|
@@ -740,12 +953,11 @@ Usage:
|
|
|
740
953
|
npx a2acalling server Start A2A server
|
|
741
954
|
|
|
742
955
|
Install Options:
|
|
743
|
-
--hostname <host>
|
|
956
|
+
--hostname <host> Public hostname for invite URLs (e.g. myserver.com, myserver.com:80)
|
|
744
957
|
--port <port> A2A server port (default: 3001)
|
|
745
958
|
--gateway-url <url> Force gateway base URL for printed dashboard link
|
|
746
959
|
--dashboard-backend <url> Backend URL used by gateway dashboard proxy
|
|
747
960
|
--standalone Force standalone bootstrap (ignore OpenClaw detection)
|
|
748
|
-
--no-quick-tunnel Disable auto quick tunnel for no-DNS environments
|
|
749
961
|
|
|
750
962
|
Examples:
|
|
751
963
|
npx a2acalling install --hostname myserver.com --port 3001
|
package/src/lib/client.js
CHANGED
|
@@ -5,6 +5,51 @@
|
|
|
5
5
|
const https = require('https');
|
|
6
6
|
const http = require('http');
|
|
7
7
|
|
|
8
|
+
function splitHostPort(rawHost) {
|
|
9
|
+
const host = String(rawHost || '').trim();
|
|
10
|
+
if (!host) return { hostname: '', port: null };
|
|
11
|
+
|
|
12
|
+
// IPv6 in brackets: [::1]:3001
|
|
13
|
+
const bracketed = host.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
14
|
+
if (bracketed) {
|
|
15
|
+
return {
|
|
16
|
+
hostname: bracketed[1],
|
|
17
|
+
port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : null
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Only treat the last ":" as a port separator when there's exactly one colon.
|
|
22
|
+
const lastColon = host.lastIndexOf(':');
|
|
23
|
+
if (lastColon !== -1 && host.indexOf(':') === lastColon) {
|
|
24
|
+
const maybePort = host.slice(lastColon + 1);
|
|
25
|
+
if (/^\d+$/.test(maybePort)) {
|
|
26
|
+
return {
|
|
27
|
+
hostname: host.slice(0, lastColon),
|
|
28
|
+
port: Number.parseInt(maybePort, 10)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { hostname: host, port: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveProtocolAndPort(host) {
|
|
37
|
+
const parsed = splitHostPort(host);
|
|
38
|
+
const hostname = parsed.hostname;
|
|
39
|
+
const hasExplicitPort = Number.isFinite(parsed.port);
|
|
40
|
+
const isLocalhost = hostname === 'localhost' ||
|
|
41
|
+
hostname === '127.0.0.1' ||
|
|
42
|
+
hostname === '::1' ||
|
|
43
|
+
hostname.startsWith('127.');
|
|
44
|
+
|
|
45
|
+
const port = hasExplicitPort ? parsed.port : (isLocalhost ? 80 : 443);
|
|
46
|
+
// Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise.
|
|
47
|
+
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
48
|
+
const protocol = useHttp ? http : https;
|
|
49
|
+
|
|
50
|
+
return { protocol, hostname, port };
|
|
51
|
+
}
|
|
52
|
+
|
|
8
53
|
class A2AClient {
|
|
9
54
|
constructor(options = {}) {
|
|
10
55
|
this.timeout = options.timeout || 60000;
|
|
@@ -50,13 +95,7 @@ class A2AClient {
|
|
|
50
95
|
timeout_seconds: timeoutSeconds || 60
|
|
51
96
|
});
|
|
52
97
|
|
|
53
|
-
const
|
|
54
|
-
const hasExplicitPort = host.includes(':');
|
|
55
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
56
|
-
// Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise
|
|
57
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
58
|
-
const protocol = useHttp ? http : https;
|
|
59
|
-
const hostname = host.split(':')[0];
|
|
98
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
60
99
|
|
|
61
100
|
return new Promise((resolve, reject) => {
|
|
62
101
|
const req = protocol.request({
|
|
@@ -125,12 +164,7 @@ class A2AClient {
|
|
|
125
164
|
conversation_id: conversationId
|
|
126
165
|
});
|
|
127
166
|
|
|
128
|
-
const
|
|
129
|
-
const hasExplicitPort = host.includes(':');
|
|
130
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
131
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
132
|
-
const protocol = useHttp ? http : https;
|
|
133
|
-
const hostname = host.split(':')[0];
|
|
167
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
134
168
|
|
|
135
169
|
return new Promise((resolve, reject) => {
|
|
136
170
|
const req = protocol.request({
|
|
@@ -187,12 +221,7 @@ class A2AClient {
|
|
|
187
221
|
({ host } = endpoint);
|
|
188
222
|
}
|
|
189
223
|
|
|
190
|
-
const
|
|
191
|
-
const hasExplicitPort = host.includes(':');
|
|
192
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
193
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
194
|
-
const protocol = useHttp ? http : https;
|
|
195
|
-
const hostname = host.split(':')[0];
|
|
224
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
196
225
|
|
|
197
226
|
return new Promise((resolve, reject) => {
|
|
198
227
|
const req = protocol.request({
|
|
@@ -234,12 +263,7 @@ class A2AClient {
|
|
|
234
263
|
({ host } = endpoint);
|
|
235
264
|
}
|
|
236
265
|
|
|
237
|
-
const
|
|
238
|
-
const hasExplicitPort = host.includes(':');
|
|
239
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
240
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
241
|
-
const protocol = useHttp ? http : https;
|
|
242
|
-
const hostname = host.split(':')[0];
|
|
266
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
243
267
|
|
|
244
268
|
return new Promise((resolve, reject) => {
|
|
245
269
|
const req = protocol.request({
|
package/src/lib/invite-host.js
CHANGED
|
@@ -124,10 +124,10 @@ function isPublicIpHostname(hostname) {
|
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
function
|
|
127
|
+
function isLegacyTunnelHostname(hostname) {
|
|
128
128
|
const host = String(hostname || '').trim().toLowerCase();
|
|
129
129
|
if (!host) return false;
|
|
130
|
-
// Quick
|
|
130
|
+
// Legacy: Cloudflare Quick Tunnel hostnames were ephemeral and are now unsupported.
|
|
131
131
|
if (host.endsWith('.trycloudflare.com')) return true;
|
|
132
132
|
return false;
|
|
133
133
|
}
|
|
@@ -155,7 +155,8 @@ async function resolveInviteHost(options = {}) {
|
|
|
155
155
|
: 'default';
|
|
156
156
|
|
|
157
157
|
const parsed = splitHostPort(candidate);
|
|
158
|
-
const
|
|
158
|
+
const candidateIsLegacyTunnel = candidateSource !== 'env' && isLegacyTunnelHostname(parsed.hostname);
|
|
159
|
+
const desiredPort = (candidateIsLegacyTunnel ? null : parsed.port) ||
|
|
159
160
|
Number.parseInt(String(options.defaultPort || ''), 10) ||
|
|
160
161
|
readIntEnv('PORT') ||
|
|
161
162
|
readIntEnv('A2A_PORT') ||
|
|
@@ -169,18 +170,17 @@ async function resolveInviteHost(options = {}) {
|
|
|
169
170
|
? options.externalIpTtlMs
|
|
170
171
|
: undefined;
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const candidateIsEphemeralTunnel = candidateSource !== 'env' && isEphemeralTunnelHostname(parsed.hostname);
|
|
173
|
+
// If a previous run persisted a legacy Cloudflare Quick Tunnel hostname into config (e.g. trycloudflare),
|
|
174
|
+
// treat it like "unroutable" so we don't emit stale/unsupported invite endpoints.
|
|
175
|
+
if (candidateIsLegacyTunnel) {
|
|
176
|
+
warnings.push(
|
|
177
|
+
`Detected legacy Quick Tunnel hostname "${candidateHostWithPort}". Quick Tunnel support was removed. ` +
|
|
178
|
+
`Set A2A_HOSTNAME="your-public-host:port" (or wire a reverse proxy) to enable internet-facing invites.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
181
|
|
|
182
182
|
const shouldReplaceWithExternalIp = isLocalOrUnroutableHost(parsed.hostname) ||
|
|
183
|
-
|
|
183
|
+
candidateIsLegacyTunnel || (
|
|
184
184
|
options.refreshExternalIp && isPublicIpHostname(parsed.hostname)
|
|
185
185
|
);
|
|
186
186
|
|
|
@@ -193,28 +193,6 @@ async function resolveInviteHost(options = {}) {
|
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
if (preferQuickTunnel && !quickTunnelDisabled) {
|
|
197
|
-
try {
|
|
198
|
-
const { ensureQuickTunnel } = require('./quick-tunnel');
|
|
199
|
-
const tunnel = await ensureQuickTunnel({
|
|
200
|
-
localPort: desiredPort
|
|
201
|
-
});
|
|
202
|
-
if (tunnel && tunnel.host) {
|
|
203
|
-
const tunnelParsed = splitHostPort(tunnel.host);
|
|
204
|
-
const finalHost = formatHostPort(tunnelParsed.hostname, tunnelParsed.port || 443);
|
|
205
|
-
warnings.push(`Using secure Quick Tunnel endpoint "${finalHost}" for internet-facing invites.`);
|
|
206
|
-
return {
|
|
207
|
-
host: finalHost,
|
|
208
|
-
source: 'quick_tunnel',
|
|
209
|
-
originalHost: candidateHostWithPort,
|
|
210
|
-
warnings
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
} catch (err) {
|
|
214
|
-
warnings.push(`Quick Tunnel unavailable (${err.message}). Falling back to external IP host detection.`);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
196
|
const external = await getExternalIp({
|
|
219
197
|
ttlMs,
|
|
220
198
|
timeoutMs: options.externalIpTimeoutMs,
|
package/src/lib/port-scanner.js
CHANGED
|
@@ -8,20 +8,32 @@
|
|
|
8
8
|
const net = require('net');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Attempt to bind a port and return a structured result.
|
|
12
|
+
* Useful for distinguishing "already in use" from "permission denied".
|
|
13
|
+
*
|
|
12
14
|
* @param {number} port
|
|
13
15
|
* @param {string} [host='0.0.0.0']
|
|
14
|
-
* @returns {Promise<boolean>}
|
|
16
|
+
* @returns {Promise<{ ok: boolean, code?: string, message?: string }>}
|
|
15
17
|
*/
|
|
16
|
-
function
|
|
18
|
+
function tryBindPort(port, host = '0.0.0.0') {
|
|
17
19
|
return new Promise((resolve) => {
|
|
18
20
|
const server = net.createServer();
|
|
19
|
-
server.once('error', () => resolve(false));
|
|
20
|
-
server.once('listening', () => server.close(() => resolve(true)));
|
|
21
|
+
server.once('error', (err) => resolve({ ok: false, code: err && err.code ? String(err.code) : 'ERROR', message: err ? String(err.message || '') : '' }));
|
|
22
|
+
server.once('listening', () => server.close(() => resolve({ ok: true })));
|
|
21
23
|
server.listen(port, host);
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Check if a port is available on the given host for binding by the current user.
|
|
29
|
+
* @param {number} port
|
|
30
|
+
* @param {string} [host='0.0.0.0']
|
|
31
|
+
* @returns {Promise<boolean>}
|
|
32
|
+
*/
|
|
33
|
+
function isPortAvailable(port, host = '0.0.0.0') {
|
|
34
|
+
return tryBindPort(port, host).then((res) => Boolean(res && res.ok));
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* Find the first available port from a list of candidates.
|
|
27
39
|
* @param {number[]} candidates - Ports to try in order
|
|
@@ -35,4 +47,37 @@ async function findAvailablePort(candidates, host = '0.0.0.0') {
|
|
|
35
47
|
return null;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Check whether a TCP listener exists on the given host/port.
|
|
52
|
+
* This does NOT require binding privileges; it simply attempts to connect.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} port
|
|
55
|
+
* @param {string} [host='127.0.0.1']
|
|
56
|
+
* @param {object} [options]
|
|
57
|
+
* @param {number} [options.timeoutMs=400]
|
|
58
|
+
* @returns {Promise<{ listening: boolean, code?: string, message?: string }>}
|
|
59
|
+
*/
|
|
60
|
+
function isPortListening(port, host = '127.0.0.1', options = {}) {
|
|
61
|
+
const timeoutMs = Number.parseInt(String(options.timeoutMs || 400), 10) || 400;
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const socket = net.connect({ host, port });
|
|
65
|
+
let settled = false;
|
|
66
|
+
|
|
67
|
+
const finish = (result) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
try { socket.destroy(); } catch (e) {}
|
|
71
|
+
resolve(result);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
socket.setTimeout(timeoutMs, () => finish({ listening: false, code: 'ETIMEDOUT', message: 'connect_timeout' }));
|
|
75
|
+
socket.once('connect', () => finish({ listening: true }));
|
|
76
|
+
socket.once('error', (err) => {
|
|
77
|
+
const code = err && err.code ? String(err.code) : 'ERROR';
|
|
78
|
+
finish({ listening: false, code, message: err ? String(err.message || '') : '' });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { tryBindPort, isPortAvailable, isPortListening, findAvailablePort };
|
package/src/lib/quick-tunnel.js
DELETED
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Quick Tunnel Support (Cloudflare)
|
|
3
|
-
*
|
|
4
|
-
* Default behavior for no-DNS environments:
|
|
5
|
-
* - lazily download cloudflared on first use (smallest viable binary path)
|
|
6
|
-
* - start a quick tunnel to local A2A server
|
|
7
|
-
* - persist tunnel metadata so invite generation can reuse it
|
|
8
|
-
*
|
|
9
|
-
* Intentionally minimal:
|
|
10
|
-
* - no account-required managed tunnel flow yet
|
|
11
|
-
* - no domain routing automation yet
|
|
12
|
-
*
|
|
13
|
-
* If owners need fixed hostnames or custom ingress, they can wire their own
|
|
14
|
-
* proxy/tunnel and set A2A_HOSTNAME to that endpoint.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const http = require('http');
|
|
20
|
-
const https = require('https');
|
|
21
|
-
const { spawn } = require('child_process');
|
|
22
|
-
|
|
23
|
-
const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
24
|
-
process.env.OPENCLAW_CONFIG_DIR ||
|
|
25
|
-
path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
|
|
26
|
-
|
|
27
|
-
const BIN_DIR = path.join(CONFIG_DIR, 'bin');
|
|
28
|
-
const CLOUDFLARED_BIN = path.join(BIN_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
29
|
-
const TUNNEL_STATE_FILE = path.join(CONFIG_DIR, 'a2a-quick-tunnel.json');
|
|
30
|
-
|
|
31
|
-
function ensureDir(dirPath) {
|
|
32
|
-
if (!fs.existsSync(dirPath)) {
|
|
33
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isExecutable(filePath) {
|
|
38
|
-
try {
|
|
39
|
-
fs.accessSync(filePath, fs.constants.X_OK);
|
|
40
|
-
return true;
|
|
41
|
-
} catch (err) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function commandExists(name) {
|
|
47
|
-
const paths = String(process.env.PATH || '').split(path.delimiter);
|
|
48
|
-
for (const p of paths) {
|
|
49
|
-
const full = path.join(p, name);
|
|
50
|
-
if (fs.existsSync(full) && isExecutable(full)) return full;
|
|
51
|
-
if (process.platform === 'win32') {
|
|
52
|
-
const exe = `${full}.exe`;
|
|
53
|
-
if (fs.existsSync(exe)) return exe;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function readJson(filePath) {
|
|
60
|
-
try {
|
|
61
|
-
if (!fs.existsSync(filePath)) return null;
|
|
62
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
63
|
-
} catch (err) {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function writeJson(filePath, value) {
|
|
69
|
-
ensureDir(path.dirname(filePath));
|
|
70
|
-
const tmp = `${filePath}.tmp`;
|
|
71
|
-
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
72
|
-
fs.renameSync(tmp, filePath);
|
|
73
|
-
try {
|
|
74
|
-
fs.chmodSync(filePath, 0o600);
|
|
75
|
-
} catch (err) {
|
|
76
|
-
// Best effort.
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isProcessAlive(pid) {
|
|
81
|
-
if (!pid || !Number.isFinite(pid)) return false;
|
|
82
|
-
try {
|
|
83
|
-
process.kill(pid, 0);
|
|
84
|
-
return true;
|
|
85
|
-
} catch (err) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function resolveCloudflaredAssetName() {
|
|
91
|
-
const arch = process.arch;
|
|
92
|
-
const platform = process.platform;
|
|
93
|
-
|
|
94
|
-
if (platform === 'linux') {
|
|
95
|
-
if (arch === 'x64') return 'cloudflared-linux-amd64';
|
|
96
|
-
if (arch === 'arm64') return 'cloudflared-linux-arm64';
|
|
97
|
-
}
|
|
98
|
-
if (platform === 'darwin') {
|
|
99
|
-
if (arch === 'x64') return 'cloudflared-darwin-amd64.tgz';
|
|
100
|
-
if (arch === 'arm64') return 'cloudflared-darwin-arm64.tgz';
|
|
101
|
-
}
|
|
102
|
-
if (platform === 'win32' && arch === 'x64') {
|
|
103
|
-
return 'cloudflared-windows-amd64.exe';
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
throw new Error(`cloudflared_unsupported_platform:${platform}-${arch}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function downloadFile(url, destination, redirectsLeft = 5) {
|
|
110
|
-
return new Promise((resolve, reject) => {
|
|
111
|
-
let parsed;
|
|
112
|
-
try {
|
|
113
|
-
parsed = new URL(url);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
reject(new Error('invalid_download_url'));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const client = parsed.protocol === 'https:' ? https : http;
|
|
120
|
-
const req = client.get({
|
|
121
|
-
protocol: parsed.protocol,
|
|
122
|
-
hostname: parsed.hostname,
|
|
123
|
-
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
124
|
-
path: parsed.pathname + parsed.search,
|
|
125
|
-
headers: {
|
|
126
|
-
'User-Agent': 'a2acalling/quick-tunnel'
|
|
127
|
-
}
|
|
128
|
-
}, (res) => {
|
|
129
|
-
const status = res.statusCode || 0;
|
|
130
|
-
if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
|
|
131
|
-
res.resume();
|
|
132
|
-
downloadFile(res.headers.location, destination, redirectsLeft - 1).then(resolve).catch(reject);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (status < 200 || status >= 300) {
|
|
136
|
-
res.resume();
|
|
137
|
-
reject(new Error(`download_failed_status_${status}`));
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const tmp = `${destination}.download`;
|
|
142
|
-
const out = fs.createWriteStream(tmp, { mode: 0o755 });
|
|
143
|
-
res.pipe(out);
|
|
144
|
-
out.on('finish', () => {
|
|
145
|
-
out.close(() => {
|
|
146
|
-
fs.renameSync(tmp, destination);
|
|
147
|
-
resolve(destination);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
out.on('error', (err) => {
|
|
151
|
-
reject(err);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
req.on('error', reject);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function extractTarGz(archivePath, outputDir) {
|
|
160
|
-
return new Promise((resolve, reject) => {
|
|
161
|
-
const child = spawn('tar', ['-xzf', archivePath, '-C', outputDir], {
|
|
162
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
163
|
-
});
|
|
164
|
-
let stderr = '';
|
|
165
|
-
child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); });
|
|
166
|
-
child.on('error', reject);
|
|
167
|
-
child.on('close', (code) => {
|
|
168
|
-
if (code === 0) resolve();
|
|
169
|
-
else reject(new Error(`tar_extract_failed:${stderr || code}`));
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function ensureCloudflaredBinary() {
|
|
175
|
-
if (process.env.A2A_CLOUDFLARED_BIN && isExecutable(process.env.A2A_CLOUDFLARED_BIN)) {
|
|
176
|
-
return { path: process.env.A2A_CLOUDFLARED_BIN, source: 'env' };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const systemPath = commandExists(process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
180
|
-
if (systemPath) {
|
|
181
|
-
return { path: systemPath, source: 'system' };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (fs.existsSync(CLOUDFLARED_BIN) && isExecutable(CLOUDFLARED_BIN)) {
|
|
185
|
-
return { path: CLOUDFLARED_BIN, source: 'cached' };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
ensureDir(BIN_DIR);
|
|
189
|
-
const asset = resolveCloudflaredAssetName();
|
|
190
|
-
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
|
|
191
|
-
const downloadedPath = path.join(BIN_DIR, asset);
|
|
192
|
-
|
|
193
|
-
await downloadFile(url, downloadedPath);
|
|
194
|
-
|
|
195
|
-
if (asset.endsWith('.tgz')) {
|
|
196
|
-
await extractTarGz(downloadedPath, BIN_DIR);
|
|
197
|
-
try {
|
|
198
|
-
fs.unlinkSync(downloadedPath);
|
|
199
|
-
} catch (err) {
|
|
200
|
-
// Best effort.
|
|
201
|
-
}
|
|
202
|
-
} else if (downloadedPath !== CLOUDFLARED_BIN) {
|
|
203
|
-
fs.renameSync(downloadedPath, CLOUDFLARED_BIN);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (!fs.existsSync(CLOUDFLARED_BIN)) {
|
|
207
|
-
throw new Error('cloudflared_install_failed');
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
fs.chmodSync(CLOUDFLARED_BIN, 0o755);
|
|
211
|
-
} catch (err) {
|
|
212
|
-
// Best effort.
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return { path: CLOUDFLARED_BIN, source: 'downloaded' };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function parseTryCloudflareUrl(line) {
|
|
219
|
-
const match = String(line || '').match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
220
|
-
return match ? match[0] : null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function readQuickTunnelState() {
|
|
224
|
-
return readJson(TUNNEL_STATE_FILE);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function normalizeHostFromUrl(urlText) {
|
|
228
|
-
try {
|
|
229
|
-
const parsed = new URL(urlText);
|
|
230
|
-
return parsed.host;
|
|
231
|
-
} catch (err) {
|
|
232
|
-
return '';
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function startQuickTunnel(options = {}) {
|
|
237
|
-
const localPort = Number.parseInt(String(options.localPort || 3001), 10) || 3001;
|
|
238
|
-
const targetUrl = options.targetUrl || `http://127.0.0.1:${localPort}`;
|
|
239
|
-
const timeoutMs = Number.parseInt(String(options.timeoutMs || 25000), 10) || 25000;
|
|
240
|
-
const binary = options.binaryPath || (await ensureCloudflaredBinary()).path;
|
|
241
|
-
|
|
242
|
-
const args = [
|
|
243
|
-
'tunnel',
|
|
244
|
-
'--url',
|
|
245
|
-
targetUrl,
|
|
246
|
-
'--no-autoupdate',
|
|
247
|
-
'--protocol',
|
|
248
|
-
'http2'
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
const child = spawn(binary, args, {
|
|
252
|
-
detached: true,
|
|
253
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
return new Promise((resolve, reject) => {
|
|
257
|
-
let finished = false;
|
|
258
|
-
const timer = setTimeout(() => {
|
|
259
|
-
if (finished) return;
|
|
260
|
-
finished = true;
|
|
261
|
-
try { process.kill(child.pid); } catch (err) {}
|
|
262
|
-
reject(new Error('quick_tunnel_timeout'));
|
|
263
|
-
}, timeoutMs);
|
|
264
|
-
|
|
265
|
-
function handleLine(line) {
|
|
266
|
-
const url = parseTryCloudflareUrl(line);
|
|
267
|
-
if (!url || finished) return;
|
|
268
|
-
finished = true;
|
|
269
|
-
clearTimeout(timer);
|
|
270
|
-
|
|
271
|
-
const host = normalizeHostFromUrl(url);
|
|
272
|
-
const state = {
|
|
273
|
-
pid: child.pid,
|
|
274
|
-
url,
|
|
275
|
-
host,
|
|
276
|
-
local_port: localPort,
|
|
277
|
-
target_url: targetUrl,
|
|
278
|
-
started_at: new Date().toISOString(),
|
|
279
|
-
binary
|
|
280
|
-
};
|
|
281
|
-
writeJson(TUNNEL_STATE_FILE, state);
|
|
282
|
-
|
|
283
|
-
// Keep tunnel alive independently from caller process.
|
|
284
|
-
child.unref();
|
|
285
|
-
resolve({
|
|
286
|
-
url,
|
|
287
|
-
host,
|
|
288
|
-
pid: child.pid,
|
|
289
|
-
localPort,
|
|
290
|
-
source: 'started'
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
child.stdout.on('data', (chunk) => handleLine(chunk.toString('utf8')));
|
|
295
|
-
child.stderr.on('data', (chunk) => handleLine(chunk.toString('utf8')));
|
|
296
|
-
child.on('error', (err) => {
|
|
297
|
-
if (finished) return;
|
|
298
|
-
finished = true;
|
|
299
|
-
clearTimeout(timer);
|
|
300
|
-
reject(err);
|
|
301
|
-
});
|
|
302
|
-
child.on('exit', (code) => {
|
|
303
|
-
if (finished) return;
|
|
304
|
-
finished = true;
|
|
305
|
-
clearTimeout(timer);
|
|
306
|
-
reject(new Error(`quick_tunnel_exit_${code}`));
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async function ensureQuickTunnel(options = {}) {
|
|
312
|
-
const localPort = Number.parseInt(String(options.localPort || 3001), 10) || 3001;
|
|
313
|
-
const state = readQuickTunnelState();
|
|
314
|
-
if (state && state.url && state.host && state.local_port === localPort && isProcessAlive(state.pid)) {
|
|
315
|
-
return {
|
|
316
|
-
url: state.url,
|
|
317
|
-
host: state.host,
|
|
318
|
-
pid: state.pid,
|
|
319
|
-
localPort,
|
|
320
|
-
source: 'state'
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return startQuickTunnel({ ...options, localPort });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
module.exports = {
|
|
328
|
-
TUNNEL_STATE_FILE,
|
|
329
|
-
ensureCloudflaredBinary,
|
|
330
|
-
ensureQuickTunnel,
|
|
331
|
-
readQuickTunnelState
|
|
332
|
-
};
|