a2acalling 0.4.1 → 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 +4 -2
- package/SKILL.md +6 -0
- package/bin/cli.js +4 -2
- package/package.json +1 -1
- package/scripts/install-openclaw.js +68 -11
- package/src/lib/invite-host.js +31 -1
- package/src/lib/quick-tunnel.js +332 -0
- package/src/routes/dashboard.js +2 -1
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://
|
|
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
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
157
|
-
|
|
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(
|
|
176
|
-
const configDir = ensureConfigAndManifest(
|
|
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
|
|
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(
|
|
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(
|
|
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://${
|
|
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="${
|
|
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 (
|
|
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
|
package/src/lib/invite-host.js
CHANGED
|
@@ -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
|
+
};
|
package/src/routes/dashboard.js
CHANGED
|
@@ -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}`;
|