a2a-exposed 0.8.26 → 0.8.28

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/config.mjs CHANGED
@@ -54,6 +54,14 @@ export function get(key, dflt = "") {
54
54
  return f !== undefined && f !== "" ? f : dflt;
55
55
  }
56
56
 
57
+ /** Resolve a control credential with the origin from the same source.
58
+ * A partial environment override must never borrow saved credentials.
59
+ */
60
+ export function controlSession(env = process.env, saved = fileConfig()) {
61
+ const source = env.CONTROL_URL || env.CONTROL_TOKEN ? env : saved;
62
+ return { origin: source.CONTROL_URL || "", token: source.CONTROL_TOKEN || "" };
63
+ }
64
+
57
65
  function ensureDir() {
58
66
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
59
67
  }
package/lib/deploy.mjs CHANGED
@@ -124,6 +124,21 @@ function run(cmd, args, { cwd, env, capture = false, captureStderr = false, allo
124
124
  return r;
125
125
  }
126
126
 
127
+ /** Keep dependency installer output in a private local log. */
128
+ export function installDependencies(dir, { configDir = C.CONFIG_DIR, env = process.env } = {}) {
129
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
130
+ const log = path.join(configDir, "npm-install.log");
131
+ const fd = fs.openSync(log, "w", 0o600);
132
+ fs.fchmodSync(fd, 0o600);
133
+ let result;
134
+ try {
135
+ result = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, env, stdio: ["ignore", fd, fd] });
136
+ } finally {
137
+ fs.closeSync(fd);
138
+ }
139
+ if (result.error || result.status !== 0) die(`npm install failed; inspect the private log: ${log}`);
140
+ }
141
+
127
142
  export function cfBin(dir) {
128
143
  const local = path.join(dir, "node_modules", ".bin", "cf");
129
144
  return fs.existsSync(local) ? local : "cf";
@@ -439,15 +454,46 @@ async function deployAndLearn(dir, secrets, o) {
439
454
  }
440
455
 
441
456
  /**
442
- * whoami before npm install. A previous install has node_modules/.bin/cf.
457
+ * whoami and accounts list run before npm install. A previous install has node_modules/.bin/cf.
443
458
  * A fresh init does not: `npx cf auth login` does not put `cf` on PATH, so this check uses `npx --yes cf`.
459
+ * cwd stays outside the Worker project. Inside it, cf loads cloudflare.config.ts, and that import fails
460
+ * with "Cannot find package 'cf'" until npm install. These commands do not need the project.
444
461
  */
462
+ function cfAccountCwd() {
463
+ return os.tmpdir();
464
+ }
465
+
466
+ function firstStreamLine(text) {
467
+ return String(text || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || "";
468
+ }
469
+
445
470
  function cfSpawn(dir, args, { allowFail = false } = {}) {
446
471
  const local = path.join(dir, "node_modules", ".bin", "cf");
447
472
  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 });
473
+ const cwd = cfAccountCwd();
474
+ const r = fs.existsSync(local)
475
+ ? run(local, full, { cwd, capture: true, captureStderr: true, allowFail: true })
476
+ : run("npx", ["--yes", "cf", ...full], { cwd, capture: true, captureStderr: true, allowFail: true });
477
+ if (r.status !== 0 && !allowFail) die(cfCommandFailure(args, r));
478
+ return r;
479
+ }
480
+
481
+ function cfCommandFailure(args, r) {
482
+ const line = firstStreamLine(r && (r.stderr || r.stdout));
483
+ const code = r && r.status != null ? r.status : "unknown";
484
+ const shown = args.slice(0, 2).join(" ");
485
+ const prof = cfProfile();
486
+ const profileArg = prof ? ` --profile ${prof}` : "";
487
+ return `cf ${shown} failed (exit ${code})${line ? `: ${line}` : ""}\nCheck: npx --yes cf ${shown}${profileArg} (run it outside the project directory)`;
488
+ }
489
+
490
+ function accountsListFailure(r) {
491
+ const line = firstStreamLine(r && r.stderr) || firstStreamLine(r && r.stdout);
492
+ const code = r && r.status != null ? r.status : "unknown";
493
+ const prof = cfProfile();
494
+ const forProfile = prof ? ` for profile "${prof}"` : "";
495
+ const check = `npx --yes cf accounts list${prof ? ` --profile ${prof}` : ""}`;
496
+ 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
497
  }
452
498
 
453
499
  function cfLoginJson(dir) {
@@ -460,13 +506,18 @@ function normalizeAccounts(raw) {
460
506
  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
507
  }
462
508
 
463
- /** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile. */
464
- function accountsFromList(dir) {
509
+ /** `cf accounts list` (GET /accounts). whoami intersects memberships and can return [] for a logged-in profile.
510
+ * A non-zero exit is a failed cf call. It is not an empty account list. When an account id is already
511
+ * set, a failed list is not fatal: init keeps that id. */
512
+ function accountsFromList(dir, { required = true } = {}) {
465
513
  const all = [];
466
514
  const seen = new Set();
467
515
  for (let page = 1; page <= 20; page++) {
468
516
  const r = cfSpawn(dir, ["accounts", "list", "--page", String(page), "--per-page", "50"], { allowFail: true });
469
- if (!r || r.status !== 0) return page === 1 ? [] : all;
517
+ if (!r || r.status !== 0) {
518
+ if (!required) return page === 1 ? [] : all;
519
+ die(accountsListFailure(r));
520
+ }
470
521
  let data;
471
522
  try { data = JSON.parse(r.stdout); } catch { return page === 1 ? [] : all; }
472
523
  if (data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.result)) data = data.result;
@@ -489,7 +540,7 @@ function requireCfLogin(dir) {
489
540
  ? `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
541
  : "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
542
  let accts = normalizeAccounts(who.accounts);
492
- if (!accts.length && prof) accts = accountsFromList(dir);
543
+ if (!accts.length && prof) accts = accountsFromList(dir, { required: !C.get("CLOUDFLARE_ACCOUNT_ID") });
493
544
  if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
494
545
  if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
495
546
  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)");
@@ -525,7 +576,7 @@ export async function init(o) {
525
576
  step(`worker project -> ${dir}`);
526
577
  syncTemplate(dir);
527
578
  requireCfLogin(dir);
528
- if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
579
+ if (!o["skip-install"]) installDependencies(dir);
529
580
  const env = cfEnv();
530
581
 
531
582
  const dbName = C.get("A2A_D1_NAME") || C.get("A2A_WORKER_NAME");
@@ -552,6 +603,7 @@ export async function init(o) {
552
603
  const base = deployedBase();
553
604
  if (base) await verifyDeployedCard(base);
554
605
  console.log(base);
606
+ await reportApprovalReadiness(base, ownerToken);
555
607
  reportSwitch(switched);
556
608
  proxyNote(secrets);
557
609
  if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this A2A endpoint is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
@@ -559,6 +611,21 @@ export async function init(o) {
559
611
  printStar();
560
612
  }
561
613
 
614
+ /** Read the deployed inbox, using its saved owner token rather than an environment override. */
615
+ async function reportApprovalReadiness(base, ownerToken) {
616
+ try {
617
+ if (!base) throw new Error();
618
+ const { status, data } = await httpJson(`${base}/owner/pairing`, { headers: { authorization: `Bearer ${ownerToken}` }, timeout: 3000 });
619
+ if (status !== 200 || typeof data?.passwordSet !== "boolean" || !["human", "agent", "off"].includes(data.mode)) throw new Error();
620
+ if (data.mode === "off" || (Array.isArray(data.approvalMethods) && !data.approvalMethods.includes("password"))) return;
621
+ console.error(data.passwordSet ? "Approval password: set."
622
+ : `Approval password: not set. To approve pairing on /device with a password, run: ${CLI} pair set-password --web`);
623
+ if (!data.passwordSet) console.error("Send the one-time setup link privately to your human. They set the password themselves; never open the page or ask for the password.");
624
+ } catch {
625
+ console.error(`Approval password: could not check. Run: ${CLI} pair list --json`);
626
+ }
627
+ }
628
+
562
629
  /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
563
630
  function proxyNote(secrets = {}) {
564
631
  const up = C.get("A2A_UPSTREAM_URL");
@@ -649,7 +716,7 @@ export async function deploy(o) {
649
716
  guardTunnelUrl();
650
717
  step(`worker project -> ${dir}${cfProfile() ? ` (cf profile ${cfProfile()})` : ""}`);
651
718
  syncTemplate(dir);
652
- if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
719
+ if (!o["skip-install"]) installDependencies(dir);
653
720
  const env = cfEnv();
654
721
  applyMigrations(dir, env);
655
722
  const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets, ...await eventSecretFromStdin(o) };
package/lib/feedback.mjs CHANGED
@@ -32,7 +32,7 @@ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
32
32
  /** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the name that URL returns. */
33
33
  export function publicIssueWarning(repo = ISSUE_REPO) {
34
34
  const name = typeof repo === "string" && REPO_RE.test(repo) && repo.length <= 100 && !repo.includes("..") ? repo : ISSUE_REPO;
35
- return `This report is filed as a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
35
+ return `Posting this report creates a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
36
36
  }
37
37
 
38
38
  export const PUBLIC_ISSUE_WARNING = publicIssueWarning(ISSUE_REPO);
@@ -316,6 +316,7 @@ export async function runFeedback(input) {
316
316
  }),
317
317
  });
318
318
  if (!endpoint) return fallback();
319
+ await input.beforePost?.();
319
320
  let response;
320
321
  try {
321
322
  response = await call(endpoint, {
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,178 @@
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 * as C from "./config.mjs";
7
+ import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector, connectorCommand } from "./local-link.mjs";
8
+
9
+ const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+ const ERRORS = {
11
+ unauthorized: "Run a2a-exposed login, then run finish again.",
12
+ invalid_request: "Check the agent name, hostname and link options before running the command again.",
13
+ link_exists: "The link request was refused; review the approval page before changing or removing an existing link.",
14
+ apex_hostname: "The domain itself cannot be a secure link. Choose a hostname below it, such as wake.example.com, then run create again.",
15
+ outside_zone: "The hostname is not on a domain in your Cloudflare account. Choose a hostname on your domain, then run create again.",
16
+ existing_dns: "The hostname already has a DNS record. Choose another hostname, then run create again.",
17
+ existing_tunnel: "The hostname is already routed through another tunnel. Choose another hostname, then run create again.",
18
+ provision_failed: "Check the approval page for the failing provisioning phase and cleanup state before running the command again.",
19
+ preset: "Select a valid wake preset with --preset <id>, then run finish again.",
20
+ signing_secret: "Check the local signing-secret source and receiver configuration before running the command again.",
21
+ unreadable: "Reconnect Cloudflare on the website, then run finish again.",
22
+ unavailable: "The service is temporarily unavailable; retry later with fresh approval.",
23
+ };
24
+ const APPROVAL_ERRORS = new Set(["access_denied", "expired_token", "link_not_ready", "invalid_grant", "already_delivered", "connector_not_connected", "not_found", "cloudflare_unavailable"]);
25
+
26
+ // Only these public validation templates may cross the server-text boundary.
27
+ // Never echo arbitrary bodies, fetch errors, child output, or credential-bearing fields.
28
+ const VALIDATION_MESSAGES = new Set([
29
+ "Choose a wake preset.", "Choose a wake preset and try again.",
30
+ "Use a hostname on the selected domain.",
31
+ "Use a full hostname such as wake.example.com.",
32
+ "Remove the scheme (https://), port and path. Use a full hostname such as wake.example.com.",
33
+ "Enter the local origin URL, such as http://127.0.0.1:8644.",
34
+ "Use an http or https origin with no path or credentials.",
35
+ "Use a path beginning with /, with no query or fragment.",
36
+ ]);
37
+ function validationDetail(body, hostname) {
38
+ if (typeof body?.message !== "string") return "";
39
+ if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
40
+ const apex = `Use a hostname under ${hostname}, such as wake.${hostname}. The domain itself is not a secure link.`;
41
+ return hostname && body.message === apex ? apex : "";
42
+ }
43
+
44
+ function failure(status, body, { command, hostname }) {
45
+ const code = Object.hasOwn(ERRORS, body?.error) || APPROVAL_ERRORS.has(body?.error) ? body.error : `HTTP ${status}`;
46
+ const detail = ["invalid_request", "apex_hostname"].includes(code) ? validationDetail(body, hostname) : "";
47
+ const message = detail || (Object.hasOwn(ERRORS, code) ? ERRORS[code].replace(/run finish/g, `run ${command}`) : "check the link on the website and rerun the command for fresh approval");
48
+ return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
49
+ }
50
+
51
+ export function savedLinkSession(origin, session = C.controlSession()) {
52
+ if (controlOrigin(session.origin) !== origin || !session.token)
53
+ throw new CliError("sign in to this service first: run login --control-url <service-origin>, then rerun link setup; environment sessions require both CONTROL_URL and CONTROL_TOKEN");
54
+ return session.token;
55
+ }
56
+
57
+ const CREATE_FIELDS = ["hostname", "origin", "path", "preset"];
58
+
59
+ function createPlan(o) {
60
+ for (const key of CREATE_FIELDS) {
61
+ if (typeof o[key] !== "string" || !o[key]) throw new CliError(`hosted link create requires --${key}`);
62
+ }
63
+ const hostname = o.hostname.trim().toLowerCase();
64
+ if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname))
65
+ throw new CliError("--hostname must be a DNS hostname on your Cloudflare domain");
66
+ let origin;
67
+ try { origin = new URL(o.origin); } catch { throw new CliError("--origin must be an http or https origin"); }
68
+ if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
69
+ throw new CliError("--origin must be an http or https origin with no path or credentials");
70
+ if (!o.path.startsWith("/") || o.path.startsWith("//") || /[\s?#\\\x00-\x1f\x7f]/.test(o.path) || o.path.length > 1000)
71
+ throw new CliError("--path must begin with / and have no query, fragment or whitespace");
72
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw new CliError("--preset must be a wake preset id");
73
+ return { hostname, origin: origin.origin, path: o.path, preset: o.preset };
74
+ }
75
+
76
+ export function createLink(o, deps) {
77
+ const create = createPlan(o);
78
+ // Catch a wrong local route before asking the owner to create any resources.
79
+ validateSecretSource(o);
80
+ if (o["secret-from"] === "hermes" && (create.preset !== "hermes" || !/^\/webhooks\/[A-Za-z0-9_-]+$/.test(create.path) || (o.route && create.path !== `/webhooks/${o.route}`)))
81
+ throw new CliError("Hermes route does not match the create path and preset");
82
+ return setupLink(o, deps, create);
83
+ }
84
+
85
+ export function finishLink(o, deps) {
86
+ if (CREATE_FIELDS.filter((key) => key !== "preset").some((key) => o[key] !== undefined)) throw new CliError("creation options require hosted link create; finish only reuses an existing link");
87
+ return setupLink(o, deps);
88
+ }
89
+
90
+ async function setupLink(o, {
91
+ fetchImpl = fetch, sleep = sleepDefault, now = Date.now,
92
+ write = (line) => console.error(line), saveToken = writeTokenFile,
93
+ readSecret = readSigningSecret, runConnector = startConnector,
94
+ sessionToken = savedLinkSession,
95
+ } = {}, create) {
96
+ validateSecretSource(o);
97
+ const context = { command: create ? "create" : "finish", hostname: create?.hostname };
98
+ const agent = o.agent;
99
+ 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)");
100
+ if (o.preset !== undefined && (typeof o.preset !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(o.preset))) throw new CliError("--preset must be a wake preset id");
101
+ const origin = controlOrigin(o["control-url"]);
102
+ if (!origin) throw new CliError("--control-url must be an https origin with no path");
103
+ const session = sessionToken(origin);
104
+ const request = async (endpoint, body, grant, timeout = 15000) => {
105
+ let response;
106
+ try {
107
+ response = await fetchImpl(`${origin}/api/link-finish/${endpoint}`, {
108
+ method: body === undefined ? "GET" : "POST", redirect: "error",
109
+ headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...(endpoint === "requests" ? { authorization: `Bearer ${session}` } : grant ? { authorization: `Grant ${grant}` } : {}) },
110
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeout),
111
+ });
112
+ const text = await response.text();
113
+ if (text.length > 128 * 1024) throw new Error();
114
+ const parsed = JSON.parse(text);
115
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
116
+ return { response, body: parsed };
117
+ } catch { throw new CliError(`link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`); }
118
+ };
119
+ const verifier = crypto.randomBytes(32).toString("base64url");
120
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
121
+ const started = await request("requests", { agent, challenge, ...(create ? { create } : {}) });
122
+ if (started.response.status !== 201) throw failure(started.response.status, started.body, context);
123
+ const setup = started.body;
124
+ let approval;
125
+ try { approval = new URL(setup.approve_url); } catch { throw new CliError("invalid approval response"); }
126
+ 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))
127
+ throw new CliError("invalid approval response");
128
+ write(`${setup.approve_url}\n${setup.user_code}`);
129
+ const deadline = now() + Math.min(600, Math.max(1, Number(setup.expires_in) || 600)) * 1000;
130
+ let interval = Math.min(60, Math.max(5, Number(setup.interval) || 5)), authorized;
131
+ for (let poll = 0; poll < 120 && now() < deadline; poll++) {
132
+ await sleep(Math.min(interval * 1000, deadline - now()));
133
+ if (now() >= deadline) break;
134
+ const token = await request("token", { request_id: setup.request_id, verifier });
135
+ if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
136
+ if (token.response.status === 429 && token.body.error === "slow_down") { interval += 5; continue; }
137
+ if (!token.response.ok) throw failure(token.response.status, token.body, context);
138
+ authorized = token.body; break;
139
+ }
140
+ if (!authorized) throw failure(410, { error: "expired_token" }, context);
141
+ const { grant, link } = authorized;
142
+ 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" || typeof link.hostname !== "string" || typeof link.origin !== "string")
143
+ throw new CliError("invalid approved link response");
144
+ if (create && CREATE_FIELDS.some((key) => link[key] !== create[key]))
145
+ throw new CliError("approved link differs from the requested creation plan; no connector or signing secret was transferred");
146
+ const preset = o.preset ?? link.preset;
147
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(preset)) throw new CliError("approved link has no valid preset; rerun finish with --preset <id> before requesting fresh approval");
148
+ // Resolve and validate the source before spending the one-time connector delivery.
149
+ const secret = readSecret(o, { ...link, preset });
150
+ const connector = await request("connector", {}, grant);
151
+ if (!connector.response.ok) throw failure(connector.response.status, connector.body, context);
152
+ 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");
153
+ const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
154
+ saveToken(tokenFile, connector.body.connector_token);
155
+ if (o["no-run"]) {
156
+ write(connectorCommand(tokenFile));
157
+ return { saved: false, connectorStarted: false };
158
+ }
159
+ await runConnector(tokenFile);
160
+ let connected = false;
161
+ const connectDeadline = now() + 60000;
162
+ for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
163
+ const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
164
+ if (now() >= connectDeadline) break;
165
+ if (!status.response.ok) throw failure(status.response.status, status.body, context);
166
+ if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
167
+ await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
168
+ }
169
+ if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
170
+ const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
171
+ if (!tested.response.ok) throw failure(tested.response.status, tested.body, context);
172
+ if (tested.body.result !== "ok" || tested.body.saved !== true) {
173
+ const result = (["dns-missing", "access-401", "access-403", "timeout"].includes(tested.body.result) || /^origin-\d{3}$/.test(tested.body.result)) ? tested.body.result : "unknown";
174
+ throw new CliError(`wake test did not save settings (${result}); fix the receiver and run finish again for fresh approval`);
175
+ }
176
+ write("Connector connected. Wake test passed and settings saved.");
177
+ return { saved: true, connectorStarted: true };
178
+ }