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 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
- return fs.existsSync(local)
449
- ? run(local, full, { cwd: dir, capture: true, allowFail })
450
- : run("npx", ["--yes", "cf", ...full], { cwd: dir, capture: true, allowFail });
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
- function accountsFromList(dir) {
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) return page === 1 ? [] : all;
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
- } catch { /* unreachable: version stays unknown */ }
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
- /** Collect the snapshot. Each probe tolerates failure; nothing is created or changed. */
236
- export async function collect() {
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
- s.card = await checkCard(s.baseUrl);
253
- s.health = await checkHealth(s.baseUrl);
254
- s.workerVersion = s.health.version;
255
- if (s.hasOwnerToken) {
256
- try {
257
- const p = await owner("GET", "/owner/wake/preview");
258
- s.ownerApi = { ok: true };
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 pr = await owner("GET", "/owner/pairing");
265
- if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
266
- s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
267
- setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
268
- ...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
269
- ...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
270
- } catch { /* older Worker (no pairing yet): leave unknown */ }
271
- try {
272
- const ad = await ownerTry("GET", "/owner/address");
273
- if (ad.status === 200 && ad.data && typeof ad.data === "object") s.addresses = ad.data;
274
- } catch { /* older Worker: no address lifecycle */ }
275
- if (s.upstream) {
276
- try { s.facade = await owner("GET", "/owner/facade"); }
277
- catch (e) { s.facade = { mode: "unknown", error: e.message }; }
278
- if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
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
- const dir = D.workerDir({});
288
- const haveCf = fs.existsSync(dir);
289
- const host = C.get("A2A_TUNNEL_HOSTNAME");
290
- if (host || C.get("A2A_TUNNEL_ID")) {
291
- const url = T.wakeUrl(host || "?", C.get("A2A_TUNNEL_PATH") || "/");
292
- s.tunnel = { hostname: host, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
293
- tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
294
- secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
295
- if (haveCf && C.get("A2A_TUNNEL_ID")) {
296
- try {
297
- const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
298
- if (!t) s.tunnel.complete = false;
299
- else { s.tunnel.state = t.status; s.tunnel.connections = (t.connections || []).length; }
300
- } catch (e) { s.tunnel.error = e.message; }
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
- } else if (s.wake && !s.wake.configured && haveCf) {
303
- try { s.zones = T.listZones(dir).filter((z) => !z.status || z.status === "active").map((z) => z.name); } catch { /* not logged in, offline: leave unknown */ }
304
- }
305
- return s;
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: next.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 (!next.ok) process.exitCode = 1;
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
- fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.26",
3
+ "version": "0.8.27",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.26";
2
+ export const VERSION = "0.8.27";