a2acalling 0.4.0 → 0.5.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 CHANGED
@@ -32,7 +32,7 @@ a2a create --name "My Agent" --owner "Your Name" --tier friends
32
32
  # Output:
33
33
  # 🤝 Your Name is inviting you to connect agents!
34
34
  # Your agent can reach My Agent for: chat, web, files
35
- # a2a://your-host.com:3001/fed_abc123xyz
35
+ # a2a://random-name.trycloudflare.com:443/fed_abc123xyz
36
36
  ```
37
37
 
38
38
  ### Call someone else's agent
@@ -75,6 +75,7 @@ Setup behavior:
75
75
  - Runtime auto-detects OpenClaw when available and falls back to generic mode if unavailable.
76
76
  - If OpenClaw gateway is detected, dashboard is exposed on gateway at `/a2a` (proxied to A2A backend).
77
77
  - If OpenClaw is not detected, setup bootstraps standalone config + bridge templates and serves dashboard at `/dashboard`.
78
+ - If no public hostname is configured, setup defaults to secure Cloudflare Quick Tunnel for internet-facing invites.
78
79
  - Setup prints the exact dashboard URL at the end.
79
80
 
80
81
  Before the first `a2a call`, the owner must set permissions and disclosure tiers. Run onboarding first:
@@ -199,7 +200,7 @@ Dashboard paths:
199
200
  Tokens use the `a2a://` URI scheme:
200
201
 
201
202
  ```
202
- a2a://<hostname>:<port>/<token>
203
+ a2a://<hostname>[:port]/<token>
203
204
  ```
204
205
 
205
206
  ### API Endpoints
@@ -325,6 +326,7 @@ app.listen(3001);
325
326
  |----------|-------------|
326
327
  | `A2A_HOSTNAME` | Hostname for invite URLs (required for creates) |
327
328
  | `A2A_PORT` | Server port (default: 3001) |
329
+ | `A2A_DISABLE_QUICK_TUNNEL` | Set `true` to disable auto Cloudflare Quick Tunnel host resolution |
328
330
  | `A2A_CONFIG_DIR` | Config directory (default: `~/.config/openclaw`) |
329
331
  | `A2A_WORKSPACE` | Workspace root for context files like `USER.md` (default: current directory) |
330
332
  | `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `generic` |
package/SKILL.md CHANGED
@@ -51,6 +51,12 @@ cat ~/.config/openclaw/a2a-config.json 2>/dev/null | grep '"onboardingComplete"'
51
51
 
52
52
  Extract: professional context, interests, goals, skills, sensitive areas. Group them into Public/Friends/Family tiers based on sensitivity.
53
53
 
54
+ ## Network Ingress (Internet-Facing Invites)
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.
59
+
54
60
  ## Commands
55
61
 
56
62
  ### Quickstart
package/bin/cli.js CHANGED
@@ -98,13 +98,15 @@ async function resolveInviteHostname() {
98
98
  const config = new A2AConfig();
99
99
  const resolved = await resolveInviteHost({
100
100
  config,
101
- defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
101
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
102
+ preferQuickTunnel: true
102
103
  });
103
104
  return resolved;
104
105
  } catch (err) {
105
106
  return resolveInviteHost({
106
107
  fallbackHost: process.env.OPENCLAW_HOSTNAME || process.env.HOSTNAME || 'localhost',
107
- defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
108
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
109
+ preferQuickTunnel: true
108
110
  });
109
111
  }
110
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -7,6 +7,8 @@
7
7
  * so dashboard is accessible at /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
12
  *
11
13
  * Usage:
12
14
  * npx a2acalling install
@@ -140,7 +142,7 @@ function writeExecutableFile(filePath, content) {
140
142
  * Ensure config and disclosure manifest exist.
141
143
  * Called from both OpenClaw and standalone install paths.
142
144
  */
143
- function ensureConfigAndManifest(hostname, port) {
145
+ function ensureConfigAndManifest(inviteHost, port, options = {}) {
144
146
  const configDir = resolveA2AConfigDir();
145
147
  ensureDir(configDir);
146
148
 
@@ -153,8 +155,9 @@ function ensureConfigAndManifest(hostname, port) {
153
155
  config.setDefaults(defaults);
154
156
 
155
157
  const agent = config.getAgent() || {};
156
- if (!agent.hostname) {
157
- config.setAgent({ hostname: `${hostname}:${port}` });
158
+ const desiredHost = String(inviteHost || '').trim();
159
+ if ((desiredHost && !agent.hostname) || (desiredHost && options.forceHostname)) {
160
+ config.setAgent({ hostname: desiredHost });
158
161
  }
159
162
 
160
163
  const manifest = loadManifest();
@@ -172,8 +175,8 @@ function ensureConfigAndManifest(hostname, port) {
172
175
  return configDir;
173
176
  }
174
177
 
175
- function ensureStandaloneBootstrap(hostname, port) {
176
- const configDir = ensureConfigAndManifest(hostname, port);
178
+ function ensureStandaloneBootstrap(inviteHost, port, options = {}) {
179
+ const configDir = ensureConfigAndManifest(inviteHost, port, options);
177
180
 
178
181
  const configFile = path.join(configDir, 'a2a-config.json');
179
182
  const manifestFile = path.join(configDir, 'a2a-disclosure.json');
@@ -417,10 +420,47 @@ function loadSkillMd() {
417
420
  return null;
418
421
  }
419
422
 
423
+ function readExistingConfiguredInviteHost() {
424
+ try {
425
+ const { A2AConfig } = require('../src/lib/config');
426
+ const { splitHostPort, isLocalOrUnroutableHost } = require('../src/lib/invite-host');
427
+ const config = new A2AConfig();
428
+ const existing = String((config.getAgent() || {}).hostname || '').trim();
429
+ if (!existing) return '';
430
+ const parsed = splitHostPort(existing);
431
+ if (!parsed.hostname || isLocalOrUnroutableHost(parsed.hostname)) {
432
+ return '';
433
+ }
434
+ return existing;
435
+ } catch (err) {
436
+ return '';
437
+ }
438
+ }
439
+
440
+ async function maybeSetupQuickTunnel(port) {
441
+ const disabled = Boolean(flags['no-quick-tunnel']) ||
442
+ String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
443
+ if (disabled) return null;
444
+
445
+ try {
446
+ const { ensureQuickTunnel } = require('../src/lib/quick-tunnel');
447
+ const tunnel = await ensureQuickTunnel({ localPort: Number.parseInt(String(port), 10) || 3001 });
448
+ if (!tunnel || !tunnel.host) return null;
449
+ return {
450
+ ...tunnel,
451
+ inviteHost: `${tunnel.host}:443`
452
+ };
453
+ } catch (err) {
454
+ warn(`Quick Tunnel setup failed: ${err.message}`);
455
+ warn('Falling back to direct host invites (may require firewall/NAT config).');
456
+ return null;
457
+ }
458
+ }
459
+
420
460
  async function install() {
421
461
  log('Installing A2A Calling...\n');
422
462
 
423
- const hostname = flags.hostname || process.env.HOSTNAME || 'localhost';
463
+ const localHostname = process.env.HOSTNAME || 'localhost';
424
464
 
425
465
  // Port scanning: use explicit port or scan for available one
426
466
  let port;
@@ -443,6 +483,12 @@ async function install() {
443
483
  }
444
484
  }
445
485
  const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${port}`;
486
+ const explicitInviteHost = flags.hostname ||
487
+ process.env.A2A_HOSTNAME ||
488
+ process.env.OPENCLAW_HOSTNAME ||
489
+ readExistingConfiguredInviteHost();
490
+ const quickTunnel = explicitInviteHost ? null : await maybeSetupQuickTunnel(port);
491
+ const inviteHost = explicitInviteHost || (quickTunnel && quickTunnel.inviteHost) || `${localHostname}:${port}`;
446
492
  const forceStandalone = Boolean(flags.standalone) || String(process.env.A2A_FORCE_STANDALONE || '').toLowerCase() === 'true';
447
493
  const hasOpenClawBinary = commandExists('openclaw');
448
494
  const hasOpenClawConfig = fs.existsSync(OPENCLAW_CONFIG);
@@ -465,10 +511,14 @@ async function install() {
465
511
  log(`Installed skill to: ${skillDir}`);
466
512
 
467
513
  // Ensure config and manifest exist even in OpenClaw path
468
- ensureConfigAndManifest(hostname, port);
514
+ ensureConfigAndManifest(inviteHost, port, {
515
+ forceHostname: Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME || quickTunnel)
516
+ });
469
517
  } else {
470
518
  warn('OpenClaw not detected. Enabling standalone A2A bootstrap.');
471
- standaloneBootstrap = ensureStandaloneBootstrap(hostname, port);
519
+ standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, port, {
520
+ forceHostname: Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME || quickTunnel)
521
+ });
472
522
  }
473
523
 
474
524
  // 3. Update OpenClaw config + gateway plugin setup (if available)
@@ -476,7 +526,7 @@ async function install() {
476
526
  let configUpdated = false;
477
527
  let gatewayDetected = false;
478
528
  let dashboardMode = 'standalone';
479
- let dashboardUrl = `http://${hostname}:${port}/dashboard`;
529
+ let dashboardUrl = `http://${localHostname}:${port}/dashboard`;
480
530
 
481
531
  if (config) {
482
532
  // Add custom command for each enabled channel
@@ -539,7 +589,7 @@ ${bold('━━━ Server Setup ━━━')}
539
589
 
540
590
  To receive incoming calls and host A2A APIs:
541
591
 
542
- ${green(`A2A_HOSTNAME="${hostname}:${port}" a2a server --port ${port}`)}
592
+ ${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${port}`)}
543
593
 
544
594
  ${bold('━━━ Dashboard Setup ━━━')}
545
595
 
@@ -553,6 +603,12 @@ ${dashboardMode === 'gateway'
553
603
  ${bold('━━━ Runtime Setup ━━━')}
554
604
 
555
605
  ${runtimeLine}
606
+ ${quickTunnel
607
+ ? `Quick Tunnel enabled:
608
+ ${green(quickTunnel.url)}
609
+ Invites will use: ${green(inviteHost)}
610
+ `
611
+ : 'Quick Tunnel not enabled (using configured/direct invite host).'}
556
612
  ${standaloneBootstrap
557
613
  ? `Standalone bridge templates:
558
614
  ${green(standaloneBootstrap.turnScript)}
@@ -618,11 +674,12 @@ Usage:
618
674
  npx a2acalling server Start A2A server
619
675
 
620
676
  Install Options:
621
- --hostname <host> Hostname for invite URLs (default: system hostname)
677
+ --hostname <host> Hostname for invite URLs (skip quick tunnel when set)
622
678
  --port <port> A2A server port (default: 3001)
623
679
  --gateway-url <url> Force gateway base URL for printed dashboard link
624
680
  --dashboard-backend <url> Backend URL used by gateway dashboard proxy
625
681
  --standalone Force standalone bootstrap (ignore OpenClaw detection)
682
+ --no-quick-tunnel Disable auto quick tunnel for no-DNS environments
626
683
 
627
684
  Examples:
628
685
  npx a2acalling install --hostname myserver.com --port 3001
@@ -161,6 +161,11 @@ async function resolveInviteHost(options = {}) {
161
161
  ? options.externalIpTtlMs
162
162
  : undefined;
163
163
 
164
+ const preferQuickTunnel = Boolean(options.preferQuickTunnel) ||
165
+ String(process.env.A2A_PREFER_QUICK_TUNNEL || '').toLowerCase() === 'true';
166
+ const quickTunnelDisabled = Boolean(options.disableQuickTunnel) ||
167
+ String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
168
+
164
169
  const shouldReplaceWithExternalIp = isLocalOrUnroutableHost(parsed.hostname) || (
165
170
  options.refreshExternalIp && isPublicIpHostname(parsed.hostname)
166
171
  );
@@ -174,6 +179,32 @@ async function resolveInviteHost(options = {}) {
174
179
  };
175
180
  }
176
181
 
182
+ if (preferQuickTunnel && !quickTunnelDisabled) {
183
+ try {
184
+ const { ensureQuickTunnel } = require('./quick-tunnel');
185
+ const tunnel = await ensureQuickTunnel({
186
+ localPort: desiredPort
187
+ });
188
+ if (tunnel && tunnel.host) {
189
+ const tunnelParsed = splitHostPort(tunnel.host);
190
+ const finalHost = formatHostPort(tunnelParsed.hostname, tunnelParsed.port || 443);
191
+ if (candidateSource !== 'env' && config && typeof config.setAgent === 'function') {
192
+ // Persist the secure public hostname for future invite generation.
193
+ config.setAgent({ hostname: finalHost });
194
+ }
195
+ warnings.push(`Using secure Quick Tunnel endpoint "${finalHost}" for internet-facing invites.`);
196
+ return {
197
+ host: finalHost,
198
+ source: 'quick_tunnel',
199
+ originalHost: candidateHostWithPort,
200
+ warnings
201
+ };
202
+ }
203
+ } catch (err) {
204
+ warnings.push(`Quick Tunnel unavailable (${err.message}). Falling back to external IP host detection.`);
205
+ }
206
+ }
207
+
177
208
  const external = await getExternalIp({
178
209
  ttlMs,
179
210
  timeoutMs: options.externalIpTimeoutMs,
@@ -216,4 +247,3 @@ module.exports = {
216
247
  isLocalOrUnroutableHost,
217
248
  resolveInviteHost
218
249
  };
219
-
@@ -0,0 +1,332 @@
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
+ };
@@ -509,7 +509,8 @@ function createDashboardApiRouter(options = {}) {
509
509
 
510
510
  const resolvedHost = await resolveInviteHost({
511
511
  config: context.config,
512
- defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
512
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
513
+ preferQuickTunnel: true
513
514
  });
514
515
  const host = resolvedHost.host;
515
516
  const inviteUrl = `a2a://${host}/${token}`;