a2a-exposed 0.8.26 → 0.8.27
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/lib/deploy.mjs +44 -8
- package/lib/layers.mjs +205 -0
- package/lib/link-finish.mjs +97 -0
- package/lib/local-link.mjs +101 -0
- package/lib/main.mjs +26 -0
- package/lib/status.mjs +281 -62
- package/lib/tunnel.mjs +2 -2
- package/package.json +1 -1
- package/worker/src/version.ts +1 -1
package/lib/deploy.mjs
CHANGED
|
@@ -439,15 +439,46 @@ async function deployAndLearn(dir, secrets, o) {
|
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
/**
|
|
442
|
-
* whoami before npm install. A previous install has node_modules/.bin/cf.
|
|
442
|
+
* whoami and accounts list run before npm install. A previous install has node_modules/.bin/cf.
|
|
443
443
|
* A fresh init does not: `npx cf auth login` does not put `cf` on PATH, so this check uses `npx --yes cf`.
|
|
444
|
+
* cwd stays outside the Worker project. Inside it, cf loads cloudflare.config.ts, and that import fails
|
|
445
|
+
* with "Cannot find package 'cf'" until npm install. These commands do not need the project.
|
|
444
446
|
*/
|
|
447
|
+
function cfAccountCwd() {
|
|
448
|
+
return os.tmpdir();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function firstStreamLine(text) {
|
|
452
|
+
return String(text || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || "";
|
|
453
|
+
}
|
|
454
|
+
|
|
445
455
|
function cfSpawn(dir, args, { allowFail = false } = {}) {
|
|
446
456
|
const local = path.join(dir, "node_modules", ".bin", "cf");
|
|
447
457
|
const full = cfArgs(args);
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
const cwd = cfAccountCwd();
|
|
459
|
+
const r = fs.existsSync(local)
|
|
460
|
+
? run(local, full, { cwd, capture: true, captureStderr: true, allowFail: true })
|
|
461
|
+
: run("npx", ["--yes", "cf", ...full], { cwd, capture: true, captureStderr: true, allowFail: true });
|
|
462
|
+
if (r.status !== 0 && !allowFail) die(cfCommandFailure(args, r));
|
|
463
|
+
return r;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function cfCommandFailure(args, r) {
|
|
467
|
+
const line = firstStreamLine(r && (r.stderr || r.stdout));
|
|
468
|
+
const code = r && r.status != null ? r.status : "unknown";
|
|
469
|
+
const shown = args.slice(0, 2).join(" ");
|
|
470
|
+
const prof = cfProfile();
|
|
471
|
+
const profileArg = prof ? ` --profile ${prof}` : "";
|
|
472
|
+
return `cf ${shown} failed (exit ${code})${line ? `: ${line}` : ""}\nCheck: npx --yes cf ${shown}${profileArg} (run it outside the project directory)`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function accountsListFailure(r) {
|
|
476
|
+
const line = firstStreamLine(r && r.stderr) || firstStreamLine(r && r.stdout);
|
|
477
|
+
const code = r && r.status != null ? r.status : "unknown";
|
|
478
|
+
const prof = cfProfile();
|
|
479
|
+
const forProfile = prof ? ` for profile "${prof}"` : "";
|
|
480
|
+
const check = `npx --yes cf accounts list${prof ? ` --profile ${prof}` : ""}`;
|
|
481
|
+
return `could not list Cloudflare accounts${forProfile}${line ? `: ${line}` : ""}\ncf accounts list failed (exit ${code}).\nCheck: ${check} (run it outside the project directory)\nOr pass --account-id <id>.`;
|
|
451
482
|
}
|
|
452
483
|
|
|
453
484
|
function cfLoginJson(dir) {
|
|
@@ -460,13 +491,18 @@ function normalizeAccounts(raw) {
|
|
|
460
491
|
return list.filter((a) => a && typeof a.id === "string" && a.id).map((a) => ({ id: a.id, name: typeof a.name === "string" && a.name ? a.name : a.id }));
|
|
461
492
|
}
|
|
462
493
|
|
|
463
|
-
/** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile.
|
|
464
|
-
|
|
494
|
+
/** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile.
|
|
495
|
+
* A non-zero exit is a failed cf call. It is not an empty account list. When an account id is already
|
|
496
|
+
* set, a failed list is not fatal: init keeps that id. */
|
|
497
|
+
function accountsFromList(dir, { required = true } = {}) {
|
|
465
498
|
const all = [];
|
|
466
499
|
const seen = new Set();
|
|
467
500
|
for (let page = 1; page <= 20; page++) {
|
|
468
501
|
const r = cfSpawn(dir, ["accounts", "list", "--page", String(page), "--per-page", "50"], { allowFail: true });
|
|
469
|
-
if (!r || r.status !== 0)
|
|
502
|
+
if (!r || r.status !== 0) {
|
|
503
|
+
if (!required) return page === 1 ? [] : all;
|
|
504
|
+
die(accountsListFailure(r));
|
|
505
|
+
}
|
|
470
506
|
let data;
|
|
471
507
|
try { data = JSON.parse(r.stdout); } catch { return page === 1 ? [] : all; }
|
|
472
508
|
if (data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.result)) data = data.result;
|
|
@@ -489,7 +525,7 @@ function requireCfLogin(dir) {
|
|
|
489
525
|
? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
|
|
490
526
|
: "not logged in to Cloudflare: run `npx cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
|
|
491
527
|
let accts = normalizeAccounts(who.accounts);
|
|
492
|
-
if (!accts.length && prof) accts = accountsFromList(dir);
|
|
528
|
+
if (!accts.length && prof) accts = accountsFromList(dir, { required: !C.get("CLOUDFLARE_ACCOUNT_ID") });
|
|
493
529
|
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
494
530
|
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
495
531
|
die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
|
package/lib/layers.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Eight read-only checks for `status`. Pure: the caller supplies DNS and HTTP results.
|
|
2
|
+
// Each layer is PASS, FAIL, or SKIP. A failure names the missing or mismatched object and a fix.
|
|
3
|
+
// Nothing here prints a token, a client secret, or a webhook key.
|
|
4
|
+
import { CLI, isPrivateHost } from "./a2a.mjs";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
|
|
7
|
+
export const LAYER_NAMES = [
|
|
8
|
+
"Worker DNS",
|
|
9
|
+
"Worker",
|
|
10
|
+
"Owner API",
|
|
11
|
+
"Wake DNS",
|
|
12
|
+
"Access",
|
|
13
|
+
"Connector",
|
|
14
|
+
"Local webhook",
|
|
15
|
+
"Pairing",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const TLS_RE = /CERT_HAS_EXPIRED|UNABLE_TO_VERIFY_LEAF_SIGNATURE|ERR_TLS_CERT_ALTNAME_INVALID|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_GET_ISSUER_CERT|CERT_UNTRUSTED/;
|
|
19
|
+
|
|
20
|
+
/** Shown when public DNS has the Worker hostname and this machine's resolver does not. */
|
|
21
|
+
export function publicDnsMismatch(host) {
|
|
22
|
+
return `public DNS resolves ${host}; this machine's resolver does not (local cache or resolver). The Worker is reachable through Cloudflare.`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Shown when public DNS has no A record for the Worker hostname. */
|
|
26
|
+
export function noPublicDns(host) {
|
|
27
|
+
return `no public DNS record for ${host} yet: check the Worker custom domain in Cloudflare (Workers → Settings → Domains)`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const MACOS_RESOLVER_FIX = "sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder";
|
|
31
|
+
|
|
32
|
+
/** An address from DNS-over-HTTPS that is safe to connect to (not loopback, LAN, or link-local). */
|
|
33
|
+
export function usablePin(ip) {
|
|
34
|
+
return typeof ip === "string" && isIP(ip) !== 0 && !isPrivateHost(ip);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function layer(name, result, detail, fix = "") {
|
|
38
|
+
return { name, result, detail, fix: result === "FAIL" ? fix : "" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One status row: `PASS|FAIL|SKIP detail` and, on failure, `Fix: …`. */
|
|
42
|
+
export function layerValue(l) {
|
|
43
|
+
if (!l || l.result !== "FAIL" || !l.fix) return `${l.result} ${l.detail}`;
|
|
44
|
+
const gap = /[.]$/.test(l.detail) ? " " : ". ";
|
|
45
|
+
return `${l.result} ${l.detail}${gap}Fix: ${l.fix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function workerDnsLayer(f) {
|
|
49
|
+
if (!f.host) return layer("Worker DNS", "SKIP", "no base URL saved");
|
|
50
|
+
const d = f.workerDns || { kind: "skip" };
|
|
51
|
+
if (d.kind === "ip") return layer("Worker DNS", "PASS", `${f.host} is an address (no DNS name to resolve)`);
|
|
52
|
+
if (d.kind === "system") return layer("Worker DNS", "PASS", `system resolver resolved ${f.host}${d.ip ? ` to ${d.ip}` : ""}`);
|
|
53
|
+
if (d.kind === "public-only") {
|
|
54
|
+
const mac = f.platform === "darwin" ? ` ${MACOS_RESOLVER_FIX}` : "";
|
|
55
|
+
const fix = f.platform === "darwin" ? MACOS_RESOLVER_FIX : "flush this machine's DNS cache";
|
|
56
|
+
return layer("Worker DNS", "FAIL", `${publicDnsMismatch(f.host)}${mac}`, fix);
|
|
57
|
+
}
|
|
58
|
+
if (d.kind === "no-record") return layer("Worker DNS", "FAIL", noPublicDns(f.host));
|
|
59
|
+
if (d.kind === "fail") return layer("Worker DNS", "FAIL", `DNS lookup failed (${d.error || "unknown"}). Likely mismatched: resolver for ${f.host}`, "flush this machine's DNS cache, or check the hostname");
|
|
60
|
+
return layer("Worker DNS", "SKIP", "not checked");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function workerLayer(f) {
|
|
64
|
+
if (!f.baseUrl) return layer("Worker", "SKIP", "not checked (no base URL)");
|
|
65
|
+
const https = /^https:/i.test(f.baseUrl);
|
|
66
|
+
const card = f.card || { ok: false, error: "not checked" };
|
|
67
|
+
if (https && card.error && TLS_RE.test(card.error))
|
|
68
|
+
return layer("Worker", "FAIL", `Likely mismatched: TLS certificate for ${f.host}. ${card.error}`, `check the certificate for ${f.host} in Cloudflare (SSL/TLS)`);
|
|
69
|
+
if (!card.ok)
|
|
70
|
+
return layer("Worker", "FAIL", `Likely missing: deployed Worker. Agent card: ${card.error || "unreachable"}`, `${CLI} deploy`);
|
|
71
|
+
if (card.urlProblem)
|
|
72
|
+
return layer("Worker", "FAIL", `Likely mismatched: agent card URL. ${card.urlProblem}`, `${CLI} deploy`);
|
|
73
|
+
const tls = https ? "TLS ok" : "TLS skipped (not https)";
|
|
74
|
+
const named = card.name ? ` ("${card.name}")` : "";
|
|
75
|
+
const cardBit = `${tls}; agent card HTTP ${card.httpStatus || 200}${named}`;
|
|
76
|
+
const h = f.health;
|
|
77
|
+
if (!h || h.ok !== true) {
|
|
78
|
+
const problem = !h || h.httpStatus == null
|
|
79
|
+
? `/health unreachable${h && h.error ? ` (${h.error})` : ""}`
|
|
80
|
+
: h.httpStatus < 200 || h.httpStatus >= 300
|
|
81
|
+
? `/health HTTP ${h.httpStatus}`
|
|
82
|
+
: `/health HTTP ${h.httpStatus} did not report ok`;
|
|
83
|
+
return layer("Worker", "FAIL", `Likely missing: Worker /health. ${cardBit}; ${problem}`, `${CLI} deploy`);
|
|
84
|
+
}
|
|
85
|
+
return layer("Worker", "PASS", `${cardBit}; /health HTTP ${h.httpStatus || 200}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ownerLayer(f) {
|
|
89
|
+
if (!f.deployed || !f.baseUrl) return layer("Owner API", "SKIP", "not checked (nothing deployed)");
|
|
90
|
+
if (!f.hasOwnerToken) return layer("Owner API", "FAIL", "Likely missing: owner token", `${CLI} init`);
|
|
91
|
+
if (f.ownerApi && f.ownerApi.ok) return layer("Owner API", "PASS", "owner token accepted");
|
|
92
|
+
const err = (f.ownerApi && f.ownerApi.error) || "not checked";
|
|
93
|
+
if (err === "not checked") return layer("Owner API", "SKIP", "not checked");
|
|
94
|
+
if (/HTTP 401/.test(err)) return layer("Owner API", "FAIL", "Likely mismatched: owner token", `${CLI} init --rotate-owner-token`);
|
|
95
|
+
return layer("Owner API", "FAIL", `Likely missing: deployed Worker. ${err}`, `${CLI} deploy`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function wakeDnsLayer(f) {
|
|
99
|
+
if (!f.wakeHost) return layer("Wake DNS", "SKIP", "no wake hostname");
|
|
100
|
+
const d = f.wakeDns || { kind: "skip" };
|
|
101
|
+
if (d.kind === "pass") return layer("Wake DNS", "PASS", `public DNS resolves ${f.wakeHost}${d.ip ? ` to ${d.ip}` : ""}`);
|
|
102
|
+
if (d.kind === "no-record") return layer("Wake DNS", "FAIL", `no public DNS record for ${f.wakeHost} yet. Likely missing: tunnel DNS record for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
103
|
+
if (d.kind === "fail") return layer("Wake DNS", "FAIL", `wake DNS check failed (${d.error || "unknown"}). Likely missing: tunnel DNS record for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
104
|
+
return layer("Wake DNS", "SKIP", "not checked");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function accessProtected(bare) {
|
|
108
|
+
if (!bare || bare.error || bare.status == null) return false;
|
|
109
|
+
if (bare.status === 401 || bare.status === 403) return true;
|
|
110
|
+
return bare.status >= 300 && bare.status < 400 && /cloudflareaccess\.com/i.test(bare.location || "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function accessLayer(f) {
|
|
114
|
+
if (!f.wakeHost) return layer("Access", "SKIP", "no wake hostname");
|
|
115
|
+
const dns = f.wakeDns || {};
|
|
116
|
+
if (dns.kind === "no-record" || dns.kind === "fail") return layer("Access", "SKIP", "not checked (wake DNS failed)");
|
|
117
|
+
const bare = f.bare;
|
|
118
|
+
if (!bare) return layer("Access", "SKIP", "not checked");
|
|
119
|
+
if (bare.error) return layer("Access", "FAIL", `wake endpoint not reachable (${bare.error}). Likely missing: DNS record or Access application for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
120
|
+
if (!accessProtected(bare))
|
|
121
|
+
return layer("Access", "FAIL", `bare request was not blocked (HTTP ${bare.status}). Likely missing: Access application for ${f.wakeHost}`, `${CLI} tunnel status`);
|
|
122
|
+
if (f.serviceToken !== "configured")
|
|
123
|
+
return layer("Access", "FAIL", `protected (HTTP ${bare.status}); Access service token not configured. Likely missing: Access service token`, `${CLI} tunnel create`);
|
|
124
|
+
return layer("Access", "PASS", `protected (HTTP ${bare.status}); Access service token configured`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function connectorLayer(f) {
|
|
128
|
+
const t = f.tunnel;
|
|
129
|
+
if (!t) return layer("Connector", "SKIP", "no tunnel configured");
|
|
130
|
+
const file = t.tokenFile || "tunnel-token";
|
|
131
|
+
if (!t.complete) return layer("Connector", "FAIL", "Likely mismatched: unfinished tunnel", `${CLI} tunnel rm, then ${CLI} tunnel create`);
|
|
132
|
+
if (!t.tokenFileOk) return layer("Connector", "FAIL", `Likely missing: connector token file (${file})`, `${CLI} tunnel create`);
|
|
133
|
+
if (t.grace) return layer("Connector", "SKIP", `waiting for the connector to connect. Start \`cloudflared tunnel run --token-file ${file}\``);
|
|
134
|
+
if (t.down1033 || t.connections === 0)
|
|
135
|
+
return layer("Connector", "FAIL", `Likely missing: cloudflared connector${t.down1033 ? " (Cloudflare error 1033)" : ""}`, `cloudflared tunnel run --token-file ${file}`);
|
|
136
|
+
if (typeof t.connections === "number" && t.connections > 0) return layer("Connector", "PASS", `connected (${t.connections})`);
|
|
137
|
+
return layer("Connector", "SKIP", "connector state unknown");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function webhookLayer(f) {
|
|
141
|
+
if (!f.wakeHost) return layer("Local webhook", "SKIP", "no wake hostname");
|
|
142
|
+
const dns = f.wakeDns || {};
|
|
143
|
+
if (dns.kind === "no-record" || dns.kind === "fail") return layer("Local webhook", "SKIP", "not checked (wake DNS failed)");
|
|
144
|
+
if (f.tunnel && f.tunnel.grace) return layer("Local webhook", "SKIP", "not checked (connector still connecting)");
|
|
145
|
+
if (!f.localServiceToken) {
|
|
146
|
+
const where = f.serviceToken === "configured" ? "the Worker has an Access service token; this machine does not" : "no Access service token in the local config";
|
|
147
|
+
return layer("Local webhook", "SKIP", `not probed (${where})`);
|
|
148
|
+
}
|
|
149
|
+
const p = f.authed;
|
|
150
|
+
if (!p) return layer("Local webhook", "SKIP", "not checked");
|
|
151
|
+
if (p.cf === "1033") return layer("Local webhook", "SKIP", "not reached (Cloudflare error 1033)");
|
|
152
|
+
const origin = f.tunnel && f.tunnel.origin ? ` at ${f.tunnel.origin}` : "";
|
|
153
|
+
if (p.error) return layer("Local webhook", "FAIL", `wake endpoint not reachable (${p.error}). Likely missing: local webhook${origin}`, "start the agent's webhook");
|
|
154
|
+
const refusal = serviceTokenRefusal(p);
|
|
155
|
+
if (refusal) return layer("Local webhook", "FAIL", `Likely mismatched: Access service token or Access policy. The service-token request was refused (${refusal})`, `${CLI} tunnel status`);
|
|
156
|
+
if (p.status === 502 || p.status === 504 || p.status === 530)
|
|
157
|
+
return layer("Local webhook", "FAIL", `wake endpoint HTTP ${p.status}. Likely missing: local webhook${origin}`, "start the agent's webhook");
|
|
158
|
+
return layer("Local webhook", "PASS", `wake endpoint HTTP ${p.status}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Login host, Access path, Access error page, or an Access challenge. "Forbidden" alone is not a marker. */
|
|
162
|
+
const ACCESS_MARK = /cloudflareaccess\.com|\/cdn-cgi\/access\/|cloudflare[- ]access/i;
|
|
163
|
+
|
|
164
|
+
/** True when this response shows Cloudflare Access, from its headers or its body. */
|
|
165
|
+
function accessMarked(p) {
|
|
166
|
+
const headers = Array.isArray(p.accessHeaders) ? p.accessHeaders : [];
|
|
167
|
+
if (headers.some((n) => String(n).toLowerCase().startsWith("cf-access-"))) return true;
|
|
168
|
+
return ACCESS_MARK.test(String(p.location || ""))
|
|
169
|
+
|| ACCESS_MARK.test(String(p.wwwAuthenticate || ""))
|
|
170
|
+
|| ACCESS_MARK.test(String(p.body || ""));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Access rejected the service token: a login redirect, or a 401/403 that carries Access markers.
|
|
174
|
+
* A 401/403 without those markers is not an Access refusal. The location URL is not repeated. */
|
|
175
|
+
function serviceTokenRefusal(p) {
|
|
176
|
+
if (!p || p.error || p.status == null) return "";
|
|
177
|
+
const loc = String(p.location || "");
|
|
178
|
+
const login = /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(loc);
|
|
179
|
+
if (p.status >= 300 && p.status < 400 && login) return `HTTP ${p.status} redirect to the Cloudflare Access login`;
|
|
180
|
+
if ((p.status === 401 || p.status === 403) && accessMarked(p)) return `HTTP ${p.status} from Cloudflare Access`;
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function pairingLayer(f) {
|
|
185
|
+
if (!f.deployed || !f.baseUrl) return layer("Pairing", "SKIP", "not checked (nothing deployed)");
|
|
186
|
+
const p = f.pairing;
|
|
187
|
+
if (!p) return layer("Pairing", "SKIP", "not checked (owner API did not report pairing)");
|
|
188
|
+
if (p.mode === "off") return layer("Pairing", "PASS", "off (token issue only)");
|
|
189
|
+
if (p.mode === "agent") return layer("Pairing", "PASS", "agent approval");
|
|
190
|
+
if (p.mode === "human" && p.passwordSet) return layer("Pairing", "PASS", "approval password set");
|
|
191
|
+
if (p.mode === "human" && p.oidcIssuer) return layer("Pairing", "PASS", `OpenID Connect (${p.oidcIssuer})`);
|
|
192
|
+
if (p.mode === "human") return layer("Pairing", "FAIL", "Likely missing: approval password", `${CLI} pair set-password --web`);
|
|
193
|
+
return layer("Pairing", "SKIP", "not checked");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The eight layers, in order. `f` is the snapshot gathered by `status` (no secrets). */
|
|
197
|
+
export function buildLayers(f) {
|
|
198
|
+
const layers = [
|
|
199
|
+
workerDnsLayer(f), workerLayer(f), ownerLayer(f), wakeDnsLayer(f),
|
|
200
|
+
accessLayer(f), connectorLayer(f), webhookLayer(f), pairingLayer(f),
|
|
201
|
+
];
|
|
202
|
+
if (layers.length !== LAYER_NAMES.length || layers.some((l, i) => l.name !== LAYER_NAMES[i] || !["PASS", "FAIL", "SKIP"].includes(l.result)))
|
|
203
|
+
throw new Error("status layers drifted");
|
|
204
|
+
return layers;
|
|
205
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { CliError } from "./a2a.mjs";
|
|
5
|
+
import { controlOrigin } from "./account.mjs";
|
|
6
|
+
import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector, connectorCommand } from "./local-link.mjs";
|
|
7
|
+
|
|
8
|
+
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
const ERRORS = new Set(["access_denied", "expired_token", "link_not_ready", "invalid_grant", "already_delivered", "connector_not_connected", "not_found", "cloudflare_unavailable"]);
|
|
10
|
+
|
|
11
|
+
// Deliberately never echo server text, bodies, fetch errors, or child output.
|
|
12
|
+
function failure(status, body) {
|
|
13
|
+
const code = ERRORS.has(body?.error) ? body.error : `HTTP ${status}`;
|
|
14
|
+
return new CliError(`link finish failed (${code}); check the link on the website and run finish again for fresh approval`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function finishLink(o, {
|
|
18
|
+
fetchImpl = fetch, sleep = sleepDefault, now = Date.now,
|
|
19
|
+
write = (line) => console.error(line), saveToken = writeTokenFile,
|
|
20
|
+
readSecret = readSigningSecret, runConnector = startConnector,
|
|
21
|
+
} = {}) {
|
|
22
|
+
validateSecretSource(o);
|
|
23
|
+
const agent = o.agent;
|
|
24
|
+
if (typeof agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(agent)) throw new CliError("--agent must be an agent name (letters, digits, underscores or hyphens)");
|
|
25
|
+
const origin = controlOrigin(o["control-url"]);
|
|
26
|
+
if (!origin) throw new CliError("--control-url must be an https origin with no path");
|
|
27
|
+
const request = async (endpoint, body, grant, timeout = 15000) => {
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
response = await fetchImpl(`${origin}/api/link-finish/${endpoint}`, {
|
|
31
|
+
method: body === undefined ? "GET" : "POST", redirect: "error",
|
|
32
|
+
headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(grant ? { authorization: `Grant ${grant}` } : {}) },
|
|
33
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeout),
|
|
34
|
+
});
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
if (text.length > 128 * 1024) throw new Error();
|
|
37
|
+
const parsed = JSON.parse(text);
|
|
38
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
39
|
+
return { response, body: parsed };
|
|
40
|
+
} catch { throw new CliError("link finish request failed; check the connection and run finish again for fresh approval"); }
|
|
41
|
+
};
|
|
42
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
43
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
44
|
+
const started = await request("requests", { agent, challenge });
|
|
45
|
+
if (started.response.status !== 201) throw failure(started.response.status, started.body);
|
|
46
|
+
const setup = started.body;
|
|
47
|
+
let approval;
|
|
48
|
+
try { approval = new URL(setup.approve_url); } catch { throw new CliError("invalid approval response"); }
|
|
49
|
+
if (approval.origin !== origin || approval.username || approval.password || typeof setup.request_id !== "string" || !setup.request_id || typeof setup.user_code !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(setup.user_code))
|
|
50
|
+
throw new CliError("invalid approval response");
|
|
51
|
+
write(`${setup.approve_url}\n${setup.user_code}`);
|
|
52
|
+
const deadline = now() + Math.min(600, Math.max(1, Number(setup.expires_in) || 600)) * 1000;
|
|
53
|
+
let interval = Math.min(60, Math.max(5, Number(setup.interval) || 5)), authorized;
|
|
54
|
+
for (let poll = 0; poll < 120 && now() < deadline; poll++) {
|
|
55
|
+
await sleep(Math.min(interval * 1000, deadline - now()));
|
|
56
|
+
if (now() >= deadline) break;
|
|
57
|
+
const token = await request("token", { request_id: setup.request_id, verifier });
|
|
58
|
+
if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
|
|
59
|
+
if (token.response.status === 429 && token.body.error === "slow_down") { interval += 5; continue; }
|
|
60
|
+
if (!token.response.ok) throw failure(token.response.status, token.body);
|
|
61
|
+
authorized = token.body; break;
|
|
62
|
+
}
|
|
63
|
+
if (!authorized) throw failure(410, { error: "expired_token" });
|
|
64
|
+
const { grant, link } = authorized;
|
|
65
|
+
if (typeof grant !== "string" || !/^[\x21-\x7e]+$/.test(grant) || grant.length > 4096 || link?.agent !== agent || typeof link.path !== "string" || !link.path.startsWith("/") || typeof link.preset !== "string" || !link.preset || typeof link.hostname !== "string" || typeof link.origin !== "string")
|
|
66
|
+
throw new CliError("invalid approved link response");
|
|
67
|
+
// Resolve and validate the source before spending the one-time connector delivery.
|
|
68
|
+
const secret = readSecret(o, link);
|
|
69
|
+
const connector = await request("connector", {}, grant);
|
|
70
|
+
if (!connector.response.ok) throw failure(connector.response.status, connector.body);
|
|
71
|
+
if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw new CliError("invalid connector response; run finish again for fresh approval");
|
|
72
|
+
const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
|
|
73
|
+
saveToken(tokenFile, connector.body.connector_token);
|
|
74
|
+
if (o["no-run"]) {
|
|
75
|
+
write(connectorCommand(tokenFile));
|
|
76
|
+
return { saved: false, connectorStarted: false };
|
|
77
|
+
}
|
|
78
|
+
await runConnector(tokenFile);
|
|
79
|
+
let connected = false;
|
|
80
|
+
const connectDeadline = now() + 60000;
|
|
81
|
+
for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
|
|
82
|
+
const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
|
|
83
|
+
if (now() >= connectDeadline) break;
|
|
84
|
+
if (!status.response.ok) throw failure(status.response.status, status.body);
|
|
85
|
+
if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
|
|
86
|
+
await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
|
|
87
|
+
}
|
|
88
|
+
if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
|
|
89
|
+
const tested = await request("wake-test", { signing_secret: secret, preset: link.preset }, grant);
|
|
90
|
+
if (!tested.response.ok) throw failure(tested.response.status, tested.body);
|
|
91
|
+
if (tested.body.result !== "ok" || tested.body.saved !== true) {
|
|
92
|
+
const result = ["dns-missing", "access-401", "access-403", "origin-4xx/5xx", "timeout"].includes(tested.body.result) ? tested.body.result : "unknown";
|
|
93
|
+
throw new CliError(`wake test did not save settings (${result}); fix the receiver and run finish again for fresh approval`);
|
|
94
|
+
}
|
|
95
|
+
write("Connector connected. Wake test passed and settings saved.");
|
|
96
|
+
return { saved: true, connectorStarted: true };
|
|
97
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Shared local setup operations. No shell, token arguments, or child output.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { CliError } from "./a2a.mjs";
|
|
8
|
+
|
|
9
|
+
export function writeTokenFile(file, token) {
|
|
10
|
+
const dir = path.dirname(file);
|
|
11
|
+
let tmp, fd;
|
|
12
|
+
try {
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
tmp = path.join(dir, `.a2a-token-${crypto.randomBytes(16).toString("hex")}`);
|
|
15
|
+
fd = fs.openSync(tmp, "wx", 0o600);
|
|
16
|
+
fs.fchmodSync(fd, 0o600);
|
|
17
|
+
fs.writeFileSync(fd, token + "\n");
|
|
18
|
+
fs.fsyncSync(fd);
|
|
19
|
+
fs.closeSync(fd); fd = undefined;
|
|
20
|
+
fs.renameSync(tmp, file);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new CliError("could not save the connector token; fix the destination permissions and retry setup; hosted finish needs fresh approval");
|
|
23
|
+
} finally {
|
|
24
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
25
|
+
if (tmp && fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPrivate(file) {
|
|
30
|
+
let fd;
|
|
31
|
+
try {
|
|
32
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
33
|
+
const stat = fs.fstatSync(fd);
|
|
34
|
+
if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.size > 1024 * 1024)
|
|
35
|
+
throw new Error();
|
|
36
|
+
return fs.readFileSync(fd, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
throw new CliError("secret source must be a readable regular file with mode 600 (no symlinks, maximum 1 MiB)");
|
|
39
|
+
} finally {
|
|
40
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usableSecret(value) {
|
|
45
|
+
if (typeof value !== "string" || !value.trim() || value === "INSECURE_NO_AUTH" || Buffer.byteLength(value) > 65536)
|
|
46
|
+
throw new CliError("the selected source has no usable signing secret");
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateSecretSource(o) {
|
|
51
|
+
if (["secret-file", "secret-env", "secret-from"].filter((key) => o[key] !== undefined).length !== 1)
|
|
52
|
+
throw new CliError("choose exactly one of --secret-file, --secret-env, or --secret-from hermes");
|
|
53
|
+
if (o["secret-from"] !== undefined && o["secret-from"] !== "hermes")
|
|
54
|
+
throw new CliError("the only secret adapter is hermes");
|
|
55
|
+
if ((o["hermes-home"] || o.route) && o["secret-from"] !== "hermes")
|
|
56
|
+
throw new CliError("--hermes-home and --route require --secret-from hermes");
|
|
57
|
+
if (o["secret-env"] !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(o["secret-env"]))
|
|
58
|
+
throw new CliError("--secret-env must name an environment variable");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readSigningSecret(o, link, env = process.env) {
|
|
62
|
+
validateSecretSource(o);
|
|
63
|
+
if (o["secret-file"] !== undefined) return usableSecret(readPrivate(o["secret-file"]).replace(/\r?\n$/, ""));
|
|
64
|
+
if (o["secret-env"] !== undefined) return usableSecret(env[o["secret-env"]]);
|
|
65
|
+
if (link.preset !== "hermes") throw new CliError("the Hermes adapter requires the hermes preset");
|
|
66
|
+
const match = /^\/webhooks\/([A-Za-z0-9_-]+)$/.exec(link.path);
|
|
67
|
+
if (!match || (o.route && o.route !== match[1]))
|
|
68
|
+
throw new CliError("Hermes route does not match the approved link path; select the exact route and Hermes home");
|
|
69
|
+
const home = o["hermes-home"] || env.HERMES_HOME || path.join(os.homedir(), ".hermes");
|
|
70
|
+
// Static YAML routes override dynamic subscriptions. Without a YAML dependency,
|
|
71
|
+
// fail closed on any routes key; never guess which secret the gateway uses.
|
|
72
|
+
try {
|
|
73
|
+
const config = fs.readFileSync(path.join(home, "config.yaml"), "utf8");
|
|
74
|
+
if (/\broutes\b/.test(config)) throw new CliError("Hermes static routes may override this subscription; use --secret-file or --secret-env for the exact route secret");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof CliError) throw error;
|
|
77
|
+
if (error.code !== "ENOENT") throw new CliError("could not check Hermes static route configuration");
|
|
78
|
+
}
|
|
79
|
+
let subscriptions;
|
|
80
|
+
try { subscriptions = JSON.parse(readPrivate(path.join(home, "webhook_subscriptions.json")).replace(/^\uFEFF/, "")); }
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof CliError) throw error;
|
|
83
|
+
throw new CliError("could not parse the Hermes subscriptions file");
|
|
84
|
+
}
|
|
85
|
+
const route = Object.hasOwn(subscriptions || {}, match[1]) ? subscriptions[match[1]] : null;
|
|
86
|
+
if (!route || route.enabled === false) throw new CliError("the selected Hermes subscription is missing or disabled");
|
|
87
|
+
return usableSecret(route.secret);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const connectorArgs = (file) => ["tunnel", "run", "--token-file", file];
|
|
91
|
+
export const connectorCommand = (file) => "cloudflared " + connectorArgs(file).map((s) => "'" + s.replaceAll("'", "'\\''") + "'").join(" ");
|
|
92
|
+
|
|
93
|
+
export async function startConnector(file, spawnImpl = spawn) {
|
|
94
|
+
let child;
|
|
95
|
+
try {
|
|
96
|
+
child = spawnImpl("cloudflared", connectorArgs(file), { detached: true, stdio: "ignore", env: Object.fromEntries(Object.entries(process.env).filter(([key]) => ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "WINDIR"].includes(key))) });
|
|
97
|
+
await new Promise((resolve, reject) => { child.once("error", reject); child.once("spawn", resolve); });
|
|
98
|
+
child.unref();
|
|
99
|
+
return child;
|
|
100
|
+
} catch { throw new CliError("could not start cloudflared; install cloudflared and run finish again"); }
|
|
101
|
+
}
|
package/lib/main.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import * as account from "./account.mjs";
|
|
|
12
12
|
import * as upd from "./update.mjs";
|
|
13
13
|
import * as fb from "./feedback.mjs";
|
|
14
14
|
import * as un from "./uninstall.mjs";
|
|
15
|
+
import { finishLink } from "./link-finish.mjs";
|
|
15
16
|
|
|
16
17
|
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
17
18
|
|
|
@@ -22,6 +23,15 @@ Usage: npx -y a2a-exposed@latest <command> [options]
|
|
|
22
23
|
--version, -v print the version
|
|
23
24
|
--help, -h print this help
|
|
24
25
|
|
|
26
|
+
hosted link finish --agent NAME --control-url https://control.example
|
|
27
|
+
(--secret-file FILE | --secret-env VAR | --secret-from hermes)
|
|
28
|
+
[--hermes-home DIR] [--route NAME] [--token-file FILE] [--no-run]
|
|
29
|
+
Human approves the printed URL/code. Saves the connector token privately,
|
|
30
|
+
starts cloudflared, waits up to 60 seconds, then tests and saves the wake.
|
|
31
|
+
--control-url defaults to saved CONTROL_URL. No secrets in arguments/output.
|
|
32
|
+
--no-run saves the token and prints the connector command; wake is not saved.
|
|
33
|
+
Recovery: rerun with fresh approval; the ready link is reused.
|
|
34
|
+
|
|
25
35
|
Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
|
|
26
36
|
npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
|
|
27
37
|
CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
|
|
@@ -44,6 +54,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
44
54
|
--cf-profile cf auth profile for a separate Cloudflare login
|
|
45
55
|
(npx cf auth create NAME --no-browser); saved, used by every cf call.
|
|
46
56
|
When whoami lists no accounts, init reads cf accounts list
|
|
57
|
+
outside the Worker directory. A failed list prints cf's error unless --account-id is set
|
|
47
58
|
--d1-name D1 database to create or reuse (default: worker name)
|
|
48
59
|
--pairing-approval who approves device-flow pairing requests (default human:
|
|
49
60
|
the owner token via \`pair approve\`, or the approval password
|
|
@@ -156,6 +167,16 @@ Status
|
|
|
156
167
|
\`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
|
|
157
168
|
A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
|
|
158
169
|
Read-only; run it after an interruption and continue from "next step". Exit 1 = broken.
|
|
170
|
+
Prints eight layers in order, each PASS, FAIL, or SKIP, and still checks the
|
|
171
|
+
later layers after one fails: Worker DNS (system lookup, then DNS-over-HTTPS
|
|
172
|
+
when that lookup fails), Worker (TLS, the agent card, and /health), Owner API,
|
|
173
|
+
Wake DNS, Access (protected or not, and whether a service token is configured;
|
|
174
|
+
the token is never printed), Connector, Local webhook (HTTP status of the wake
|
|
175
|
+
endpoint), and Pairing. A 401 or 403 fails the local webhook layer only when the
|
|
176
|
+
response shows Cloudflare Access. The webhook's own auth refusal reports the HTTP
|
|
177
|
+
status and passes, as does a 401 or 403 with no Access marker. A failure names the
|
|
178
|
+
missing or mismatched object and a fix. --json includes layers (eight objects).
|
|
179
|
+
Exit 1 if any layer fails.
|
|
159
180
|
For about a minute after tunnel create, while the connector reports 0 connections,
|
|
160
181
|
status prints "waiting for the connector to connect" and exits 0. After that,
|
|
161
182
|
0 connections is broken. A tunnel status of inactive is "never connected".
|
|
@@ -328,6 +349,7 @@ const deployOpts = {
|
|
|
328
349
|
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
|
|
329
350
|
};
|
|
330
351
|
const SPEC = {
|
|
352
|
+
hosted: { agent: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
|
|
331
353
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
332
354
|
inbox: { context: S, from: S, all: B, json: B },
|
|
333
355
|
reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
|
|
@@ -449,6 +471,10 @@ export async function main(argv) {
|
|
|
449
471
|
if (sub === "forward") return address.forward(o);
|
|
450
472
|
throw new CliError(`unknown peer action ${sub} (announce|forward)`);
|
|
451
473
|
}
|
|
474
|
+
case "hosted": {
|
|
475
|
+
if (p.length !== 2 || p[0] !== "link" || p[1] !== "finish") throw new CliError("usage: hosted link finish --agent NAME (--secret-file FILE | --secret-env VAR | --secret-from hermes)");
|
|
476
|
+
return finishLink({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
|
|
477
|
+
}
|
|
452
478
|
case "signup":
|
|
453
479
|
case "login": {
|
|
454
480
|
const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
|
package/lib/status.mjs
CHANGED
|
@@ -1,23 +1,175 @@
|
|
|
1
1
|
// `status`: where a setup stands and what to do next. Safe to run any time (read-only: GET requests and cf list/get
|
|
2
2
|
// calls only), so an agent that was interrupted can resume from the printed next step. The CLI fetches the agent card
|
|
3
3
|
// itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
|
|
4
|
+
import dns from "node:dns";
|
|
4
5
|
import fs from "node:fs";
|
|
6
|
+
import { isIP } from "node:net";
|
|
5
7
|
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
6
9
|
import * as C from "./config.mjs";
|
|
7
|
-
import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
10
|
+
import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson, isPrivateHost } from "./a2a.mjs";
|
|
8
11
|
import { baseUrl, owner, ownerTry } from "./commands.mjs";
|
|
9
12
|
import * as D from "./deploy.mjs";
|
|
13
|
+
import { buildLayers, layerValue, usablePin } from "./layers.mjs";
|
|
10
14
|
import * as T from "./tunnel.mjs";
|
|
11
15
|
import { cacheFile, readCache, newer } from "./update.mjs";
|
|
12
16
|
|
|
17
|
+
const DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
18
|
+
const LOOKUP_MS = 5000;
|
|
19
|
+
|
|
20
|
+
/** ENOTFOUND / EAI_AGAIN, including the text httpJson puts on a failed request. */
|
|
21
|
+
export function isResolverError(e) {
|
|
22
|
+
const code = e?.code || e?.cause?.code || "";
|
|
23
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return true;
|
|
24
|
+
return /\b(ENOTFOUND|EAI_AGAIN)\b/.test(String(e?.message || ""));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** First address from the system resolver, or EAI_AGAIN when it does not answer in time. */
|
|
28
|
+
export async function defaultLookup(host) {
|
|
29
|
+
let timer;
|
|
30
|
+
const timeout = new Promise((_, reject) => {
|
|
31
|
+
timer = setTimeout(() => {
|
|
32
|
+
const err = new Error(`DNS lookup timed out for ${host}`);
|
|
33
|
+
err.code = "EAI_AGAIN";
|
|
34
|
+
reject(err);
|
|
35
|
+
}, LOOKUP_MS);
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
const rows = await Promise.race([dns.promises.lookup(host, { all: true }), timeout]);
|
|
39
|
+
const first = Array.isArray(rows) ? rows[0] : null;
|
|
40
|
+
const address = typeof first === "string" ? first : first?.address;
|
|
41
|
+
if (!address) {
|
|
42
|
+
const err = new Error(`ENOTFOUND ${host}`);
|
|
43
|
+
err.code = "ENOTFOUND";
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return address;
|
|
47
|
+
} finally { clearTimeout(timer); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Connect `host` to `ip` for the duration of `fn` (TLS still checks the hostname). Other names use the previous resolver. */
|
|
51
|
+
export async function withHostPin(host, ip, fn) {
|
|
52
|
+
const prev = dns.lookup;
|
|
53
|
+
const prevPromises = dns.promises.lookup;
|
|
54
|
+
dns.lookup = (hostname, options, callback) => {
|
|
55
|
+
if (typeof options === "function") { callback = options; options = {}; }
|
|
56
|
+
else if (typeof options === "number") options = { family: options };
|
|
57
|
+
else options = options || {};
|
|
58
|
+
if (hostname === host) {
|
|
59
|
+
if (options.all) return callback(null, [{ address: ip, family: 4 }]);
|
|
60
|
+
return callback(null, ip, 4);
|
|
61
|
+
}
|
|
62
|
+
return prev(hostname, options, callback);
|
|
63
|
+
};
|
|
64
|
+
dns.promises.lookup = promisify(dns.lookup);
|
|
65
|
+
try { return await fn(); }
|
|
66
|
+
finally {
|
|
67
|
+
dns.lookup = prev;
|
|
68
|
+
dns.promises.lookup = prevPromises;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** DNS-over-HTTPS A (or the requested type) lookup. `application/dns-json`. No redirects.
|
|
73
|
+
* When the system resolver cannot resolve the DoH host, retry once pinned to 1.1.1.1. */
|
|
74
|
+
export async function queryDoh(name, type = "A") {
|
|
75
|
+
const url = `${DOH_URL}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`;
|
|
76
|
+
const init = {
|
|
77
|
+
method: "GET", redirect: "manual",
|
|
78
|
+
headers: { accept: "application/dns-json", "user-agent": "a2a-exposed-cli" },
|
|
79
|
+
signal: AbortSignal.timeout(5000),
|
|
80
|
+
};
|
|
81
|
+
let res;
|
|
82
|
+
try { res = await fetch(url, init); }
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (!isResolverError(e)) throw e;
|
|
85
|
+
res = await withHostPin("cloudflare-dns.com", "1.1.1.1", () => fetch(url, init));
|
|
86
|
+
}
|
|
87
|
+
if (!res || res.status !== 200) {
|
|
88
|
+
const err = new Error(`HTTP ${res ? res.status : 0}`);
|
|
89
|
+
err.code = `HTTP_${res ? res.status : 0}`;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
const data = await res.json();
|
|
93
|
+
const want = type === "AAAA" ? 28 : 1;
|
|
94
|
+
const answer = Array.isArray(data?.Answer) ? data.Answer : [];
|
|
95
|
+
return answer.filter((a) => a && Number(a.type) === want && typeof a.data === "string").map((a) => a.data);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function firstAddress(raw) {
|
|
99
|
+
if (typeof raw === "string") return raw;
|
|
100
|
+
if (Array.isArray(raw)) {
|
|
101
|
+
const first = raw[0];
|
|
102
|
+
return typeof first === "string" ? first : first?.address || "";
|
|
103
|
+
}
|
|
104
|
+
return raw?.address || "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** System lookup, then DNS-over-HTTPS on ENOTFOUND or EAI_AGAIN. `lookup` and `doh` are injectable. */
|
|
108
|
+
export async function resolveWorkerDns(host, { lookup = defaultLookup, doh = queryDoh } = {}) {
|
|
109
|
+
if (!host) return { kind: "skip" };
|
|
110
|
+
if (isIP(host)) return { kind: "ip", ip: host };
|
|
111
|
+
try {
|
|
112
|
+
const ip = firstAddress(await lookup(host));
|
|
113
|
+
return { kind: "system", ip };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (!isResolverError(e)) return { kind: "fail", error: e?.code || e?.message || "lookup failed" };
|
|
116
|
+
let answers;
|
|
117
|
+
try { answers = await doh(host, "A"); }
|
|
118
|
+
catch (err) { return { kind: "fail", error: err?.code || err?.message || "DNS-over-HTTPS failed" }; }
|
|
119
|
+
const ips = (Array.isArray(answers) ? answers : []).map(String).filter((a) => isIP(a) === 4);
|
|
120
|
+
if (!ips.length) return { kind: "no-record" };
|
|
121
|
+
return { kind: "public-only", ip: ips[0] };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Public DNS for the wake hostname (DNS-over-HTTPS only). */
|
|
126
|
+
export async function resolveWakeDns(host, { doh = queryDoh } = {}) {
|
|
127
|
+
if (!host || isIP(host) || isPrivateHost(host)) return { kind: "skip" };
|
|
128
|
+
try {
|
|
129
|
+
const ips = (await doh(host, "A") || []).map(String).filter((a) => isIP(a) === 4);
|
|
130
|
+
if (!ips.length) return { kind: "no-record" };
|
|
131
|
+
return { kind: "pass", ip: ips[0] };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { kind: "fail", error: e?.code || e?.message || "DNS-over-HTTPS failed" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** GET probe. Request headers are sent and never returned. The result keeps the status, redirect
|
|
138
|
+
* location, Cloudflare error code, Access response-header names (never their values), the
|
|
139
|
+
* WWW-Authenticate challenge, and a body slice, so a 401/403 can be attributed. */
|
|
140
|
+
async function probeGet(url, headers) {
|
|
141
|
+
try {
|
|
142
|
+
const r = await fetch(url, {
|
|
143
|
+
method: "GET", redirect: "manual",
|
|
144
|
+
headers: { "user-agent": "a2a-exposed-cli", ...(headers || {}) },
|
|
145
|
+
signal: AbortSignal.timeout(15000),
|
|
146
|
+
});
|
|
147
|
+
const body = (await r.text()).slice(0, 8192);
|
|
148
|
+
const cf = T.cloudflareErrorCode(body);
|
|
149
|
+
const accessHeaders = [];
|
|
150
|
+
r.headers.forEach((_value, name) => {
|
|
151
|
+
if (name.toLowerCase().startsWith("cf-access-")) accessHeaders.push(name.toLowerCase());
|
|
152
|
+
});
|
|
153
|
+
const wwwAuthenticate = r.headers.get("www-authenticate") || "";
|
|
154
|
+
return {
|
|
155
|
+
status: r.status, location: r.headers.get("location") || "", ...(cf ? { cf } : {}),
|
|
156
|
+
...(accessHeaders.length ? { accessHeaders } : {}),
|
|
157
|
+
...(wwwAuthenticate ? { wwwAuthenticate: wwwAuthenticate.slice(0, 500) } : {}),
|
|
158
|
+
...(body ? { body } : {}),
|
|
159
|
+
};
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { error: e?.cause?.code || e?.cause?.message || e?.message || "request failed" };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
13
165
|
/** Public health probe. Only a valid health answer establishes missing version reporting. */
|
|
14
166
|
export async function checkHealth(base) {
|
|
15
167
|
try {
|
|
16
168
|
const { status, data } = await httpJson(`${base}/health`, { timeout: 15000 });
|
|
17
169
|
if (status === 200 && data?.ok === true)
|
|
18
|
-
return { ok: true, version: typeof data.version === "string" && data.version ? data.version : null };
|
|
19
|
-
|
|
20
|
-
return { ok: false, version: null };
|
|
170
|
+
return { ok: true, version: typeof data.version === "string" && data.version ? data.version : null, httpStatus: status };
|
|
171
|
+
return { ok: false, version: null, httpStatus: status };
|
|
172
|
+
} catch (e) { /* unreachable: version stays unknown */ return { ok: false, version: null, httpStatus: null, error: e.message }; }
|
|
21
173
|
}
|
|
22
174
|
|
|
23
175
|
/** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
|
|
@@ -30,11 +182,11 @@ export async function checkCard(base) {
|
|
|
30
182
|
const { status, data } = await httpJson(`${base}/.well-known/agent-card.json`, { timeout: 15000 });
|
|
31
183
|
if (status !== 200 || !data || typeof data !== "object" || !data.name) {
|
|
32
184
|
const cf = cfErrorCode(typeof data === "string" ? data : "");
|
|
33
|
-
return { ok: false, error: status === 200 ? "HTTP 200 (no agent card)" : describeHttp(status, data), ...(cf ? { cfError: cf } : {}) };
|
|
185
|
+
return { ok: false, httpStatus: status, error: status === 200 ? "HTTP 200 (no agent card)" : describeHttp(status, data), ...(cf ? { cfError: cf } : {}) };
|
|
34
186
|
}
|
|
35
|
-
return { ok: true, name: data.name, versions: (data.supportedInterfaces || []).map((i) => i.protocolVersion).filter(Boolean),
|
|
187
|
+
return { ok: true, httpStatus: status, name: data.name, versions: (data.supportedInterfaces || []).map((i) => i.protocolVersion).filter(Boolean),
|
|
36
188
|
urls: [...new Set((data.supportedInterfaces || []).map((i) => i && i.url).filter(Boolean))], urlProblem: cardUrlProblem(data, base) };
|
|
37
|
-
} catch (e) { return { ok: false, error: e.message }; }
|
|
189
|
+
} catch (e) { return { ok: false, httpStatus: null, error: e.message }; }
|
|
38
190
|
}
|
|
39
191
|
|
|
40
192
|
/** One line describing how wakes reach the agent. */
|
|
@@ -232,8 +384,65 @@ function nextStepCore(s) {
|
|
|
232
384
|
[`peers connect with \`${CLI} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
|
|
233
385
|
}
|
|
234
386
|
|
|
235
|
-
|
|
236
|
-
|
|
387
|
+
function hostOf(url) {
|
|
388
|
+
try { return new URL(url).hostname; } catch { return ""; }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Facts for the eight layers. Tokens and client secrets stay out of this object. */
|
|
392
|
+
function layerFacts(s, extra) {
|
|
393
|
+
const t = s.tunnel;
|
|
394
|
+
return {
|
|
395
|
+
deployed: s.deployed, baseUrl: s.baseUrl || "", host: extra.host || "", platform: process.platform,
|
|
396
|
+
workerDns: extra.workerDns, card: s.card, health: s.health, hasOwnerToken: s.hasOwnerToken, ownerApi: s.ownerApi,
|
|
397
|
+
wakeHost: extra.wakeHost || "", wakeDns: extra.wakeDns, bare: extra.bare, authed: extra.authed,
|
|
398
|
+
serviceToken: extra.serviceToken, localServiceToken: extra.localServiceToken, pairing: s.pairing,
|
|
399
|
+
tunnel: t ? {
|
|
400
|
+
complete: t.complete, tokenFileOk: t.tokenFileOk, tokenFile: t.tokenFile, connections: t.connections,
|
|
401
|
+
grace: connectorConnecting(t), origin: t.origin, down1033: !!extra.down1033,
|
|
402
|
+
} : null,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Wake DNS, the bare Access probe, and the service-token probe. GET only. The Access token is sent
|
|
407
|
+
* and not stored. The webhook's bearer and HMAC are not sent. */
|
|
408
|
+
async function wakeProbes(s, net) {
|
|
409
|
+
const wakeHost = s.tunnel?.hostname || "";
|
|
410
|
+
const empty = { wakeHost, wakeDns: { kind: "skip" }, bare: null, authed: null, down1033: false };
|
|
411
|
+
if (!wakeHost) return empty;
|
|
412
|
+
const wakeDns = await resolveWakeDns(wakeHost, net);
|
|
413
|
+
if (wakeDns.kind !== "pass" || !s.tunnel?.wakeUrl) return { ...empty, wakeDns };
|
|
414
|
+
const localServiceToken = !!(C.get("A2A_TUNNEL_ACCESS_CLIENT_ID") && C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"));
|
|
415
|
+
const run = async () => {
|
|
416
|
+
const bare = await probeGet(s.tunnel.wakeUrl);
|
|
417
|
+
let authed = null;
|
|
418
|
+
if (localServiceToken) {
|
|
419
|
+
authed = await probeGet(s.tunnel.wakeUrl, {
|
|
420
|
+
"CF-Access-Client-Id": C.get("A2A_TUNNEL_ACCESS_CLIENT_ID"),
|
|
421
|
+
"CF-Access-Client-Secret": C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return { bare, authed };
|
|
425
|
+
};
|
|
426
|
+
let probed;
|
|
427
|
+
try {
|
|
428
|
+
probed = wakeDns.ip && usablePin(wakeDns.ip) ? await withHostPin(wakeHost, wakeDns.ip, run) : await run();
|
|
429
|
+
} catch (e) {
|
|
430
|
+
probed = { bare: { error: e?.message || "request failed" }, authed: null };
|
|
431
|
+
}
|
|
432
|
+
const down1033 = [probed.bare, probed.authed].some((p) => p && p.cf === "1033");
|
|
433
|
+
return { wakeHost, wakeDns, bare: probed.bare, authed: probed.authed, down1033 };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function finishLayers(s, extra) {
|
|
437
|
+
const localServiceToken = !!(C.get("A2A_TUNNEL_ACCESS_CLIENT_ID") && C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"));
|
|
438
|
+
const serviceToken = localServiceToken || s.wake?.hasAccessServiceToken ? "configured" : "not-configured";
|
|
439
|
+
s.layers = buildLayers(layerFacts(s, { ...extra, localServiceToken, serviceToken }));
|
|
440
|
+
return s;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Collect the snapshot. Each probe tolerates failure; nothing is created or changed.
|
|
444
|
+
* `net.lookup` and `net.doh` replace the system resolver and DNS-over-HTTPS in tests. */
|
|
445
|
+
export async function collect(net = {}) {
|
|
237
446
|
const s = {
|
|
238
447
|
configFile: C.CONFIG_FILE,
|
|
239
448
|
deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
|
|
@@ -248,61 +457,68 @@ export async function collect() {
|
|
|
248
457
|
};
|
|
249
458
|
const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
|
|
250
459
|
if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
|
|
251
|
-
if (!s.deployed || !s.baseUrl) return s;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
|
|
260
|
-
hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
|
|
261
|
-
} catch (e) { s.ownerApi = { ok: false, error: e.message }; }
|
|
262
|
-
if (s.ownerApi.ok) {
|
|
460
|
+
if (!s.deployed || !s.baseUrl) return finishLayers(s, { host: "", workerDns: { kind: "skip" }, wakeHost: "", wakeDns: { kind: "skip" }, bare: null, authed: null });
|
|
461
|
+
const host = hostOf(s.baseUrl);
|
|
462
|
+
const workerDns = await resolveWorkerDns(host, net);
|
|
463
|
+
const remote = async () => {
|
|
464
|
+
s.card = await checkCard(s.baseUrl);
|
|
465
|
+
s.health = await checkHealth(s.baseUrl);
|
|
466
|
+
s.workerVersion = s.health.version;
|
|
467
|
+
if (s.hasOwnerToken) {
|
|
263
468
|
try {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
catch
|
|
278
|
-
|
|
469
|
+
const p = await owner("GET", "/owner/wake/preview");
|
|
470
|
+
s.ownerApi = { ok: true };
|
|
471
|
+
s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
|
|
472
|
+
hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
|
|
473
|
+
} catch (e) { s.ownerApi = { ok: false, error: e.message }; }
|
|
474
|
+
if (s.ownerApi.ok) {
|
|
475
|
+
try {
|
|
476
|
+
const pr = await owner("GET", "/owner/pairing");
|
|
477
|
+
if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
|
|
478
|
+
s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
|
|
479
|
+
setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
|
|
480
|
+
...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
|
|
481
|
+
...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
|
|
482
|
+
} catch { /* older Worker (no pairing yet): leave unknown */ }
|
|
483
|
+
try {
|
|
484
|
+
const ad = await ownerTry("GET", "/owner/address");
|
|
485
|
+
if (ad.status === 200 && ad.data && typeof ad.data === "object") s.addresses = ad.data;
|
|
486
|
+
} catch { /* older Worker: no address lifecycle */ }
|
|
487
|
+
if (s.upstream) {
|
|
488
|
+
try { s.facade = await owner("GET", "/owner/facade"); }
|
|
489
|
+
catch (e) { s.facade = { mode: "unknown", error: e.message }; }
|
|
490
|
+
if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const m = await ownerTry("GET", "/owner/mcp");
|
|
494
|
+
if (m.status === 200 && m.data && typeof m.data === "object" && typeof m.data.enabled === "boolean")
|
|
495
|
+
s.mcp = m.data.enabled ? "on" : "off";
|
|
496
|
+
} catch { /* probe failed: leave mcp unknown so status omits the add line */ }
|
|
279
497
|
}
|
|
280
|
-
try {
|
|
281
|
-
const m = await ownerTry("GET", "/owner/mcp");
|
|
282
|
-
if (m.status === 200 && m.data && typeof m.data === "object" && typeof m.data.enabled === "boolean")
|
|
283
|
-
s.mcp = m.data.enabled ? "on" : "off";
|
|
284
|
-
} catch { /* probe failed: leave mcp unknown so status omits the add line */ }
|
|
285
498
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
499
|
+
const dir = D.workerDir({});
|
|
500
|
+
const haveCf = fs.existsSync(dir);
|
|
501
|
+
const tunnelHost = C.get("A2A_TUNNEL_HOSTNAME");
|
|
502
|
+
if (tunnelHost || C.get("A2A_TUNNEL_ID")) {
|
|
503
|
+
const url = T.wakeUrl(tunnelHost || "?", C.get("A2A_TUNNEL_PATH") || "/");
|
|
504
|
+
s.tunnel = { hostname: tunnelHost, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
|
|
505
|
+
tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
|
|
506
|
+
secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
|
|
507
|
+
if (haveCf && C.get("A2A_TUNNEL_ID")) {
|
|
508
|
+
try {
|
|
509
|
+
const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
|
|
510
|
+
if (!t) s.tunnel.complete = false;
|
|
511
|
+
else { s.tunnel.state = t.status; s.tunnel.connections = (t.connections || []).length; }
|
|
512
|
+
} catch (e) { s.tunnel.error = e.message; }
|
|
513
|
+
}
|
|
514
|
+
} else if (s.wake && !s.wake.configured && haveCf) {
|
|
515
|
+
try { s.zones = T.listZones(dir).filter((z) => !z.status || z.status === "active").map((z) => z.name); } catch { /* not logged in, offline: leave unknown */ }
|
|
301
516
|
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
517
|
+
};
|
|
518
|
+
if (workerDns.kind === "public-only" && usablePin(workerDns.ip)) await withHostPin(host, workerDns.ip, remote);
|
|
519
|
+
else await remote();
|
|
520
|
+
const wake = await wakeProbes(s, net);
|
|
521
|
+
return finishLayers(s, { host, workerDns, ...wake });
|
|
306
522
|
}
|
|
307
523
|
|
|
308
524
|
const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
|
|
@@ -351,8 +567,10 @@ export async function status(o) {
|
|
|
351
567
|
const s = await collect();
|
|
352
568
|
const next = nextStep(s);
|
|
353
569
|
const mcpAdd = mcpAddCommand(s);
|
|
570
|
+
const layers = Array.isArray(s.layers) ? s.layers : [];
|
|
571
|
+
const ok = next.ok && !layers.some((l) => l.result === "FAIL");
|
|
354
572
|
if (o.json) {
|
|
355
|
-
const out = { ...s, mcpAdd, nextStep: next.text, also: next.also, ok
|
|
573
|
+
const out = { ...s, mcpAdd, nextStep: next.text, also: next.also, ok, layers };
|
|
356
574
|
if (out.tunnel && out.tunnel.state != null) out.tunnel = { ...out.tunnel, state: tunnelStateLabel(out.tunnel.state) };
|
|
357
575
|
console.log(JSON.stringify(out, null, 2));
|
|
358
576
|
}
|
|
@@ -402,10 +620,11 @@ export async function status(o) {
|
|
|
402
620
|
: `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.manageUrl ? `; dashboard ${pg.manageUrl}` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
|
|
403
621
|
}
|
|
404
622
|
if (mcpAdd) rows.push(["mcp", mcpAdd]);
|
|
623
|
+
for (const l of layers) rows.push([l.name, layerValue(l)]);
|
|
405
624
|
rows.push(["next step", next.text]);
|
|
406
625
|
for (const a of next.also || []) rows.push(["also", a]);
|
|
407
626
|
const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
|
|
408
627
|
for (const [k, v] of rows) console.log(`${(k + ":").padEnd(w)} ${v}`);
|
|
409
628
|
}
|
|
410
|
-
if (!
|
|
629
|
+
if (!ok) process.exitCode = 1;
|
|
411
630
|
}
|
package/lib/tunnel.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import * as C from "./config.mjs";
|
|
|
14
14
|
import { CLI, CliError, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
15
15
|
import { owner } from "./commands.mjs";
|
|
16
16
|
import * as D from "./deploy.mjs";
|
|
17
|
+
import { writeTokenFile } from "./local-link.mjs";
|
|
17
18
|
|
|
18
19
|
const step = (s) => console.error(`==> ${s}`);
|
|
19
20
|
|
|
@@ -366,8 +367,7 @@ function fetchTokenFile(dir, tunnelId) {
|
|
|
366
367
|
const token = typeof tok === "string" ? tok.trim() : tok.raw || tok.token || tok.result || "";
|
|
367
368
|
if (!token) die("could not read the tunnel token");
|
|
368
369
|
const tokenFile = path.join(C.CONFIG_DIR, "tunnel-token");
|
|
369
|
-
|
|
370
|
-
fs.chmodSync(tokenFile, 0o600);
|
|
370
|
+
writeTokenFile(tokenFile, token);
|
|
371
371
|
C.saveConfig({ A2A_TUNNEL_TOKEN_FILE: tokenFile });
|
|
372
372
|
return { tokenFile, token };
|
|
373
373
|
}
|
package/package.json
CHANGED
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.27";
|