a2acalling 0.5.5 → 0.6.1

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 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://random-name.trycloudflare.com:443/fed_abc123xyz
36
+ # a2a://your-host.com/fed_abc123xyz
37
37
  ```
38
38
 
39
39
  ### Call someone else's agent
@@ -76,7 +76,7 @@ Setup behavior:
76
76
  - Runtime auto-detects OpenClaw when available and falls back to generic mode if unavailable.
77
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
- - If no public hostname is configured, setup defaults to secure Cloudflare Quick Tunnel for internet-facing invites.
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 creates) |
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
- - By default, A2A now prefers a secure Quick Tunnel endpoint for invite URLs when no public hostname is configured.
57
- - If the owner has custom infrastructure (domain, reverse proxy, managed tunnel), they can set `A2A_HOSTNAME` to their own public endpoint and bypass Quick Tunnel.
58
- - Managed/domain-specific ingress automation is intentionally not bundled yet. Advanced users can implement it based on their own network and security requirements.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.5.5",
3
+ "version": "0.6.1",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -7,8 +7,8 @@
7
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
- * - If no public hostname is configured, default to secure Quick Tunnel
11
- * for internet-facing invite URLs (lazy cloudflared download).
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
@@ -423,6 +425,7 @@ 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,
@@ -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 3001</code></p>
444
+ <p>Start the backend with: <code>a2a server --port \${suggestedPort}</code></p>
442
445
  </body></html>\`);
443
446
  });
444
447
 
@@ -497,63 +500,211 @@ function readExistingConfiguredInviteHost() {
497
500
  }
498
501
  }
499
502
 
500
- async function maybeSetupQuickTunnel(port) {
501
- const disabled = Boolean(flags['no-quick-tunnel']) ||
502
- String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
503
- if (disabled) return null;
504
-
503
+ function safeJsonParse(text) {
505
504
  try {
506
- const { ensureQuickTunnel } = require('../src/lib/quick-tunnel');
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
- };
505
+ return JSON.parse(String(text || ''));
513
506
  } catch (err) {
514
- warn(`Quick Tunnel setup failed: ${err.message}`);
515
- warn('Falling back to direct host invites (may require firewall/NAT config).');
516
507
  return null;
517
508
  }
518
509
  }
519
510
 
511
+ function looksLikePong(body) {
512
+ const parsed = safeJsonParse(body);
513
+ if (parsed && typeof parsed === 'object' && parsed.pong === true) return true;
514
+ return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
515
+ }
516
+
517
+ function fetchUrlText(url, timeoutMs = 5000) {
518
+ return new Promise((resolve, reject) => {
519
+ let parsed;
520
+ try {
521
+ parsed = new URL(url);
522
+ } catch (err) {
523
+ reject(new Error('invalid_url'));
524
+ return;
525
+ }
526
+
527
+ const client = parsed.protocol === 'https:' ? https : http;
528
+ const req = client.request({
529
+ protocol: parsed.protocol,
530
+ hostname: parsed.hostname,
531
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
532
+ method: 'GET',
533
+ path: parsed.pathname + parsed.search,
534
+ headers: {
535
+ 'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (setup-check)`
536
+ },
537
+ timeout: timeoutMs
538
+ }, (res) => {
539
+ let data = '';
540
+ res.setEncoding('utf8');
541
+
542
+ res.on('data', (chunk) => {
543
+ data += chunk;
544
+ if (data.length > 1024 * 256) {
545
+ req.destroy(new Error('response_too_large'));
546
+ }
547
+ });
548
+
549
+ res.on('end', () => {
550
+ resolve({
551
+ statusCode: res.statusCode || 0,
552
+ headers: res.headers || {},
553
+ body: data
554
+ });
555
+ });
556
+ });
557
+
558
+ req.on('error', reject);
559
+ req.on('timeout', () => req.destroy(new Error('timeout')));
560
+ req.end();
561
+ });
562
+ }
563
+
564
+ async function probeLocalA2APing(port) {
565
+ try {
566
+ const res = await fetchUrlText(`http://127.0.0.1:${port}/api/a2a/ping`, 900);
567
+ return { ok: looksLikePong(res.body), statusCode: res.statusCode, body: res.body };
568
+ } catch (err) {
569
+ return { ok: false, error: err && err.message ? err.message : 'request_failed' };
570
+ }
571
+ }
572
+
573
+ async function scanPort80() {
574
+ const { isPortListening, tryBindPort } = require('../src/lib/port-scanner');
575
+
576
+ const listening = await isPortListening(80, '127.0.0.1', { timeoutMs: 500 });
577
+ const bind = await tryBindPort(80, '0.0.0.0');
578
+ const a2aPing = listening.listening ? await probeLocalA2APing(80) : { ok: false };
579
+
580
+ return {
581
+ listening: Boolean(listening.listening),
582
+ listeningCode: listening.code,
583
+ bindOk: Boolean(bind.ok),
584
+ bindCode: bind.code,
585
+ a2aPingOk: Boolean(a2aPing.ok),
586
+ a2aPingStatusCode: a2aPing.statusCode,
587
+ a2aPingError: a2aPing.error
588
+ };
589
+ }
590
+
591
+ async function externalPingCheck(targetUrl) {
592
+ const providers = [
593
+ {
594
+ name: 'allorigins',
595
+ buildUrl: () => {
596
+ const u = new URL('https://api.allorigins.win/raw');
597
+ u.searchParams.set('url', targetUrl);
598
+ return u.toString();
599
+ }
600
+ },
601
+ {
602
+ name: 'jina',
603
+ buildUrl: () => `https://r.jina.ai/${targetUrl}`
604
+ }
605
+ ];
606
+
607
+ const attempts = [];
608
+ for (const provider of providers) {
609
+ const providerUrl = provider.buildUrl();
610
+ try {
611
+ // eslint-disable-next-line no-await-in-loop
612
+ const res = await fetchUrlText(providerUrl, 8000);
613
+ const ok = looksLikePong(res.body);
614
+ attempts.push({ provider: provider.name, ok, statusCode: res.statusCode });
615
+ if (ok) {
616
+ return { ok: true, provider: provider.name, statusCode: res.statusCode, attempts };
617
+ }
618
+ } catch (err) {
619
+ attempts.push({ provider: provider.name, ok: false, error: err && err.message ? err.message : 'request_failed' });
620
+ }
621
+ }
622
+
623
+ return { ok: false, attempts };
624
+ }
625
+
520
626
  async function install() {
521
627
  log('Installing A2A Calling...\n');
522
628
 
523
629
  const localHostname = process.env.HOSTNAME || 'localhost';
524
630
 
525
- // Port scanning: use explicit port or scan for available one
526
- let port;
631
+ // Networking scan (port 80) + backend port selection.
632
+ const port80 = await scanPort80();
633
+ if (port80.a2aPingOk) {
634
+ log('Port 80 already serves /api/a2a/ping (A2A detected on :80).');
635
+ } else if (port80.listening) {
636
+ warn(`Port 80 is already bound (${port80.listeningCode || 'in_use'}). Setup will use an internal port and recommend reverse proxy routing.`);
637
+ } else if (!port80.bindOk && port80.bindCode === 'EACCES') {
638
+ 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.');
639
+ } else if (port80.bindOk) {
640
+ log('Port 80 is bindable by this user (recommended for inbound A2A if you intend to serve directly on :80).');
641
+ }
642
+
643
+ let backendPort = null;
527
644
  if (flags.port) {
528
- port = String(flags.port);
645
+ backendPort = Number.parseInt(String(flags.port), 10);
529
646
  } else if (process.env.A2A_PORT) {
530
- port = String(process.env.A2A_PORT);
647
+ backendPort = Number.parseInt(String(process.env.A2A_PORT), 10);
531
648
  } else {
532
- try {
533
- const { findAvailablePort } = require('../src/lib/port-scanner');
534
- const available = await findAvailablePort([80, 3001, 8080, 8443, 9001]);
535
- port = available ? String(available) : '3001';
536
- if (available === 80) {
537
- log(`Port 80 is available (recommended for inbound A2A connections)`);
538
- } else if (available) {
539
- log(`Auto-detected available port: ${available}`);
649
+ if (port80.bindOk && !port80.listening) {
650
+ backendPort = 80;
651
+ } else {
652
+ try {
653
+ const { findAvailablePort } = require('../src/lib/port-scanner');
654
+ backendPort = await findAvailablePort([3001, 8080, 8443, 9001]);
655
+ } catch (e) {
656
+ backendPort = null;
657
+ }
658
+ if (!backendPort) {
659
+ backendPort = 3001;
540
660
  }
541
- } catch (e) {
542
- port = '3001';
661
+ log(`Auto-detected available internal port: ${backendPort}`);
543
662
  }
544
663
  }
545
- const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${port}`;
664
+ if (!Number.isFinite(backendPort) || backendPort <= 0 || backendPort > 65535) {
665
+ backendPort = 3001;
666
+ }
667
+
668
+ const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${backendPort}`;
669
+
670
+ // Invite host selection: explicit > existing configured public host > auto resolve to external IP.
546
671
  const explicitInviteHost = flags.hostname ||
547
672
  process.env.A2A_HOSTNAME ||
548
673
  process.env.OPENCLAW_HOSTNAME ||
549
674
  readExistingConfiguredInviteHost();
550
- const quickTunnel = explicitInviteHost ? null : await maybeSetupQuickTunnel(port);
551
- const inviteHost = explicitInviteHost || (quickTunnel && quickTunnel.inviteHost) || `${localHostname}:${port}`;
675
+
676
+ let inviteHost = explicitInviteHost;
677
+ let inviteHostWarnings = [];
678
+
679
+ if (!inviteHost) {
680
+ try {
681
+ const { A2AConfig } = require('../src/lib/config');
682
+ const { resolveInviteHost } = require('../src/lib/invite-host');
683
+ const config = new A2AConfig();
684
+ const inviteDefaultPort = port80.listening ? 80 : backendPort;
685
+ const resolved = await resolveInviteHost({
686
+ config,
687
+ fallbackHost: localHostname,
688
+ defaultPort: inviteDefaultPort,
689
+ refreshExternalIp: true
690
+ });
691
+ inviteHost = resolved.host;
692
+ inviteHostWarnings = resolved.warnings || [];
693
+ } catch (err) {
694
+ inviteHost = `${localHostname}:${backendPort}`;
695
+ }
696
+ }
697
+
698
+ for (const w of inviteHostWarnings) {
699
+ warn(w);
700
+ }
701
+
552
702
  const forceStandalone = Boolean(flags.standalone) || String(process.env.A2A_FORCE_STANDALONE || '').toLowerCase() === 'true';
553
703
  const hasOpenClawBinary = commandExists('openclaw');
554
704
  const hasOpenClawConfig = fs.existsSync(OPENCLAW_CONFIG);
555
705
  const hasOpenClaw = !forceStandalone && (hasOpenClawBinary || hasOpenClawConfig);
556
706
  let standaloneBootstrap = null;
707
+ const forceHostname = Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME) || !explicitInviteHost;
557
708
 
558
709
  if (hasOpenClaw) {
559
710
  // 1. Create skills directory if needed
@@ -571,13 +722,13 @@ async function install() {
571
722
  log(`Installed skill to: ${skillDir}`);
572
723
 
573
724
  // Ensure config and manifest exist even in OpenClaw path
574
- ensureConfigAndManifest(inviteHost, port, {
575
- forceHostname: Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME || quickTunnel)
725
+ ensureConfigAndManifest(inviteHost, backendPort, {
726
+ forceHostname
576
727
  });
577
728
  } else {
578
729
  warn('OpenClaw not detected. Enabling standalone A2A bootstrap.');
579
- standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, port, {
580
- forceHostname: Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME || quickTunnel)
730
+ standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, backendPort, {
731
+ forceHostname
581
732
  });
582
733
  }
583
734
 
@@ -586,7 +737,7 @@ async function install() {
586
737
  let configUpdated = false;
587
738
  let gatewayDetected = false;
588
739
  let dashboardMode = 'standalone';
589
- let dashboardUrl = `http://${localHostname}:${port}/dashboard`;
740
+ let dashboardUrl = `http://${localHostname}:${backendPort}/dashboard`;
590
741
 
591
742
  if (config) {
592
743
  // Add custom command for each enabled channel
@@ -650,12 +801,76 @@ async function install() {
650
801
  ? 'Runtime auto-selects OpenClaw when available and falls back to generic if needed.'
651
802
  : 'Runtime defaults to generic fallback (no OpenClaw dependency required).';
652
803
 
804
+ const { splitHostPort, isLocalOrUnroutableHost } = require('../src/lib/invite-host');
805
+ const inviteParsed = splitHostPort(inviteHost);
806
+ const invitePort = inviteParsed.port;
807
+ const inviteScheme = (!invitePort || invitePort === 443) ? 'https' : 'http';
808
+ const invitePingUrl = `${inviteScheme}://${inviteHost}/api/a2a/ping`;
809
+ const inviteLooksLocal = isLocalOrUnroutableHost(inviteParsed.hostname);
810
+ const expectsReverseProxy = Boolean(
811
+ (invitePort === 80 && backendPort !== 80) ||
812
+ ((!invitePort || invitePort === 443) && backendPort !== 443)
813
+ );
814
+
815
+ let externalPing = null;
816
+ if (!inviteLooksLocal && inviteParsed.hostname) {
817
+ externalPing = await externalPingCheck(invitePingUrl);
818
+ }
819
+
653
820
  console.log(`
654
821
  ${bold('━━━ Server Setup ━━━')}
655
822
 
656
823
  To receive incoming calls and host A2A APIs:
657
824
 
658
- ${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${port}`)}
825
+ ${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`)}
826
+
827
+ ${bold('━━━ Ingress Setup ━━━')}
828
+
829
+ Invite host: ${green(inviteHost)}
830
+ Expected ping URL: ${green(invitePingUrl)}
831
+
832
+ Port 80 scan:
833
+ ${port80.a2aPingOk
834
+ ? green('Port 80 responds to /api/a2a/ping (A2A ready on :80)')
835
+ : port80.listening
836
+ ? yellow(`Port 80 has a listener (${port80.listeningCode || 'in_use'})`)
837
+ : port80.bindOk
838
+ ? green('Port 80 is free and bindable by this user')
839
+ : yellow(`Port 80 not bindable (${port80.bindCode || 'unknown'})`)}
840
+
841
+ ${expectsReverseProxy
842
+ ? `Reverse proxy required:
843
+ Route ${green('/api/a2a/*')} -> ${green(backendUrl)}
844
+ Route ${green('/a2a/*')} -> ${green(backendUrl.replace(/\/$/, ''))}${green('/dashboard/*')} (optional dashboard)
845
+
846
+ Example (Caddy):
847
+ ${green(`YOUR_DOMAIN {
848
+ handle /api/a2a/* {
849
+ reverse_proxy 127.0.0.1:${backendPort}
850
+ }
851
+ handle /a2a* {
852
+ uri replace /a2a /dashboard
853
+ reverse_proxy 127.0.0.1:${backendPort}
854
+ }
855
+ }`)}
856
+
857
+ Example (nginx):
858
+ ${green(`location /api/a2a/ {
859
+ proxy_pass http://127.0.0.1:${backendPort}/api/a2a/;
860
+ }
861
+ location = /a2a { return 301 /a2a/; }
862
+ location /a2a/ {
863
+ proxy_pass http://127.0.0.1:${backendPort}/dashboard/;
864
+ }`)}`
865
+ : 'Reverse proxy not required for the selected invite host.'}
866
+
867
+ ${bold('━━━ External Ping ━━━')}
868
+
869
+ ${inviteLooksLocal
870
+ ? yellow('Skipped external ping: invite host looks local/unroutable. Set --hostname to a public endpoint to enable this check.')
871
+ : externalPing && externalPing.ok
872
+ ? green(`External ping OK via ${externalPing.provider}`)
873
+ : yellow(`External ping FAILED (expected if the server is not running yet, or ingress is not publicly reachable).`)}
659
874
 
660
875
  ${bold('━━━ Dashboard Setup ━━━')}
661
876
 
@@ -669,12 +884,6 @@ ${dashboardMode === 'gateway'
669
884
  ${bold('━━━ Runtime Setup ━━━')}
670
885
 
671
886
  ${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
887
  ${standaloneBootstrap
679
888
  ? `Standalone bridge templates:
680
889
  ${green(standaloneBootstrap.turnScript)}
@@ -740,12 +949,11 @@ Usage:
740
949
  npx a2acalling server Start A2A server
741
950
 
742
951
  Install Options:
743
- --hostname <host> Hostname for invite URLs (skip quick tunnel when set)
952
+ --hostname <host> Public hostname for invite URLs (e.g. myserver.com, myserver.com:80)
744
953
  --port <port> A2A server port (default: 3001)
745
954
  --gateway-url <url> Force gateway base URL for printed dashboard link
746
955
  --dashboard-backend <url> Backend URL used by gateway dashboard proxy
747
956
  --standalone Force standalone bootstrap (ignore OpenClaw detection)
748
- --no-quick-tunnel Disable auto quick tunnel for no-DNS environments
749
957
 
750
958
  Examples:
751
959
  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 isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
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 isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
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 isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
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 isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
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({
@@ -124,14 +124,6 @@ function isPublicIpHostname(hostname) {
124
124
  return false;
125
125
  }
126
126
 
127
- function isEphemeralTunnelHostname(hostname) {
128
- const host = String(hostname || '').trim().toLowerCase();
129
- if (!host) return false;
130
- // Quick tunnels (cloudflared) are not stable across restarts.
131
- if (host.endsWith('.trycloudflare.com')) return true;
132
- return false;
133
- }
134
-
135
127
  async function resolveInviteHost(options = {}) {
136
128
  const config = options.config || null;
137
129
 
@@ -169,18 +161,8 @@ async function resolveInviteHost(options = {}) {
169
161
  ? options.externalIpTtlMs
170
162
  : undefined;
171
163
 
172
- const preferQuickTunnel = Boolean(options.preferQuickTunnel) ||
173
- String(process.env.A2A_PREFER_QUICK_TUNNEL || '').toLowerCase() === 'true';
174
- const quickTunnelDisabled = Boolean(options.disableQuickTunnel) ||
175
- String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
176
-
177
- // If a previous run persisted an ephemeral tunnel hostname into config (e.g. trycloudflare),
178
- // treat it like "unroutable" so we always refresh via tunnel/external IP instead of returning
179
- // a stale endpoint.
180
- const candidateIsEphemeralTunnel = candidateSource !== 'env' && isEphemeralTunnelHostname(parsed.hostname);
181
-
182
164
  const shouldReplaceWithExternalIp = isLocalOrUnroutableHost(parsed.hostname) ||
183
- candidateIsEphemeralTunnel || (
165
+ (
184
166
  options.refreshExternalIp && isPublicIpHostname(parsed.hostname)
185
167
  );
186
168
 
@@ -193,28 +175,6 @@ async function resolveInviteHost(options = {}) {
193
175
  };
194
176
  }
195
177
 
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
178
  const external = await getExternalIp({
219
179
  ttlMs,
220
180
  timeoutMs: options.externalIpTimeoutMs,
@@ -8,20 +8,32 @@
8
8
  const net = require('net');
9
9
 
10
10
  /**
11
- * Check if a port is available on the given host.
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 isPortAvailable(port, host = '0.0.0.0') {
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
- module.exports = { isPortAvailable, findAvailablePort };
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 };
@@ -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
- };