a2a-exposed 0.8.25 → 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/index.ts +22 -15
- package/worker/src/mcp.ts +2 -3
- package/worker/src/owner-rpc.ts +16 -5
- package/worker/src/version.ts +1 -1
- package/worker/test/address.test.ts +62 -0
- package/worker/test/mcp.test.ts +21 -1
- package/worker/test/owner-rpc.test.ts +9 -0
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 });
|