a2a-exposed 0.8.6 → 0.8.7

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/tunnel.mjs CHANGED
@@ -40,8 +40,11 @@ export const TUNNEL_KEYS = [
40
40
  const WORDS = ["amber", "brisk", "cedar", "delta", "ember", "fable", "gale", "harbor", "iris", "jade", "kelp", "lumen", "maple",
41
41
  "nova", "onyx", "pebble", "quill", "raven", "sable", "tidal", "umber", "velvet", "willow", "zephyr", "otter", "lark", "fjord", "heron"];
42
42
  export function randomLabel(rand = crypto.randomInt) {
43
- const w = () => WORDS[rand(WORDS.length)];
44
- return `wake-${w()}-${w()}-${rand(0x10000).toString(16).padStart(4, "0")}`;
43
+ const a = rand(WORDS.length);
44
+ let b = rand(WORDS.length);
45
+ if (b === a) b = rand(WORDS.length);
46
+ if (b === a) b = (a + 1) % WORDS.length;
47
+ return `wake-${WORDS[a]}-${WORDS[b]}-${rand(0x10000).toString(16).padStart(4, "0")}`;
45
48
  }
46
49
 
47
50
  export const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
@@ -62,8 +65,8 @@ export function noZoneHelp(zones) {
62
65
  inactive.length
63
66
  ? `no active domain (zone) on this Cloudflare account (not active yet: ${inactive.map((z) => `${z.name} [${z.status}]`).join(", ")}).`
64
67
  : "this Cloudflare account has no domain (zone).",
65
- "The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the inbox is on workers.dev or a custom hostname).",
66
- "Without one, use scheduled polling: the inbox works as it is (setup skill: \"Agents without inbound webhooks: polling\").",
68
+ "The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the A2A endpoint is on workers.dev or a custom hostname).",
69
+ "Without one, use scheduled polling. The A2A endpoint works as it is. Agents without inbound webhooks poll.",
67
70
  `To use the tunnel later, add a domain to the account and run \`${CLI} tunnel create\` (no redeploy needed).`,
68
71
  ].join("\n");
69
72
  }
@@ -88,12 +91,12 @@ export function pickTunnelHostname({ tunnelHostname, tunnelZone, inboxHost, zone
88
91
  return { hostname: `${label()}.${zone.name}`, zone, note: `wake hostname on the zone ${zone.name} (--tunnel-zone)` };
89
92
  }
90
93
  const own = inboxHost ? zoneFor(inboxHost, usable) : null;
91
- if (own) return { hostname: `${label()}.${own.name}`, zone: own, note: `wake hostname on ${own.name}, the zone of the inbox hostname` };
94
+ if (own) return { hostname: `${label()}.${own.name}`, zone: own, note: `wake hostname on ${own.name}, the zone of the A2A endpoint hostname` };
92
95
  if (usable.length === 1)
93
- return { hostname: `${label()}.${usable[0].name}`, zone: usable[0], note: `the account has one zone, ${usable[0].name}: the wake hostname goes there (the inbox URL is unchanged)` };
96
+ return { hostname: `${label()}.${usable[0].name}`, zone: usable[0], note: `the account has one zone, ${usable[0].name}: the wake hostname goes there (the A2A endpoint URL is unchanged)` };
94
97
  if (!usable.length) die(noZoneHelp(zones));
95
98
  return die([
96
- `this Cloudflare account has ${usable.length} zones; choose one for the wake hostname (the inbox URL is unchanged):`,
99
+ `this Cloudflare account has ${usable.length} zones; choose one for the wake hostname (the A2A endpoint URL is unchanged):`,
97
100
  zoneList(usable),
98
101
  "Re-run with --tunnel-zone <zone> (or --tunnel-hostname wake-<name>.<zone>).",
99
102
  ].join("\n"));
@@ -164,6 +167,31 @@ export function cloudflareErrorCode(body) {
164
167
  return m ? m[1] || m[2] || m[3] || m[4] : "";
165
168
  }
166
169
 
170
+ /** API token permission named in `[10000]` and `[7403]` errors, from the `cf` command. */
171
+ export function cfPermission(args) {
172
+ const head = args[0];
173
+ if (head === "workers") return "Workers Scripts: Edit";
174
+ if (head === "d1") return "D1: Edit";
175
+ if (head === "dns") return "Zone DNS: Edit";
176
+ if (head === "zero-trust") return "Access: Edit";
177
+ if (head === "tunnels") return "Cloudflare Tunnel: Edit";
178
+ return "";
179
+ }
180
+
181
+ /** Append the token permission when Cloudflare returns code 10000 or 7403. */
182
+ export function withPermissionHint(args, msg) {
183
+ if (!/\[(10000|7403)\]/.test(msg) || /The API token needs/.test(msg)) return msg;
184
+ const perm = cfPermission(args);
185
+ const text = String(msg || "").trim().replace(/[.]+$/, "");
186
+ return perm ? `${text}. The API token needs ${perm}.` : `${text}.`;
187
+ }
188
+
189
+ /** True when `cf` reports that this Worker does not exist (code 10007). */
190
+ export function workerMissingError(err) {
191
+ const msg = err instanceof Error ? err.message : String(err || "");
192
+ return /\[10007\]/.test(msg) || /this worker does not exist/i.test(msg);
193
+ }
194
+
167
195
  // ------------------------------------------------------------------ cf calls (JSON in, JSON out, errors captured)
168
196
  export function cfCall(dir, args, { body, file, allowNotFound = false } = {}) {
169
197
  const full = [...args];
@@ -175,7 +203,7 @@ export function cfCall(dir, args, { body, file, allowNotFound = false } = {}) {
175
203
  const err = (r.stderr || r.stdout || "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
176
204
  if (allowNotFound && /404|not found|does not exist|could not be found/i.test(err)) return null;
177
205
  const msg = (err.match(/\[\d+\][^\n]*/g) || [err.trim().split("\n").slice(-3).join(" ")]).join("; ");
178
- throw new CliError(`cf ${args.slice(0, 3).join(" ")} failed: ${msg.slice(0, 400)}`);
206
+ throw new CliError(`cf ${args.slice(0, 3).join(" ")} failed: ${withPermissionHint(args, msg.slice(0, 400))}`);
179
207
  }
180
208
  const out = (r.stdout || "").trim();
181
209
  if (!out) return {};
@@ -187,7 +215,7 @@ const ACCESS_HELP = (acct) => [
187
215
  " - dashboard: https://one.dash.cloudflare.com/ -> pick a team name and the Free plan (up to 50 users), then re-run",
188
216
  " - or let the CLI create the organization: re-run with --zero-trust-org <team-name> (gives <team-name>.cloudflareaccess.com)",
189
217
  ` account: ${acct || "(see cf auth whoami)"}`,
190
- "No tunnel? Use scheduled polling instead (setup skill: \"Agents without inbound webhooks: polling\").",
218
+ "No tunnel? Use scheduled polling instead. Agents without inbound webhooks poll.",
191
219
  ].join("\n");
192
220
 
193
221
  function accessOrg(dir, o) {
@@ -226,7 +254,7 @@ export function cfWorkDir(preferred) {
226
254
  }
227
255
 
228
256
  function needDeployment(o) {
229
- if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
257
+ if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
230
258
  const dir = D.workerDir(o);
231
259
  if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
232
260
  return cfWorkDir(dir);
@@ -234,7 +262,7 @@ function needDeployment(o) {
234
262
 
235
263
  /** `cf` cwd for `tunnel rm` / `uninstall`. A missing Worker project uses a temp directory with a writable cache. */
236
264
  function cfDirForRemove(o, { allowMissingProject = false } = {}) {
237
- if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
265
+ if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
238
266
  const dir = D.workerDir(o);
239
267
  if (!fs.existsSync(dir) && !allowMissingProject) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
240
268
  return cfWorkDir(dir);
@@ -262,6 +290,11 @@ export async function create(o) {
262
290
  if (tunnelStarted()) return resume(dir, o);
263
291
  const { preset, origin, wpath } = preflight(o);
264
292
  const worker = D.workerName();
293
+ try {
294
+ const preview = await owner("GET", "/owner/wake/preview");
295
+ if (preview && preview.configured)
296
+ console.error(`warning: the Worker already has a wake URL. tunnel create replaces it with the tunnel hostname. \`${CLI} tunnel rm\` removes that tunnel. It does not put the previous wake URL back.`);
297
+ } catch { /* Worker unreachable: the upload below reports it */ }
265
298
 
266
299
  step("finding a zone for the wake hostname");
267
300
  const { hostname, zone, note } = pickTunnelHostname({
@@ -278,6 +311,8 @@ export async function create(o) {
278
311
  const label = `a2a-exposed wake ${worker}`;
279
312
  const save = (u) => C.saveConfig(u);
280
313
  save({ A2A_TUNNEL_HOSTNAME: hostname, A2A_TUNNEL_ORIGIN: origin, A2A_TUNNEL_PATH: wpath, A2A_TUNNEL_ZONE_ID: zone.id });
314
+ /** Secret names this run uploaded. Rollback deletes only these. */
315
+ let createdSecrets = [];
281
316
  try {
282
317
  step(`Access service token "${label}"`);
283
318
  const st = cfCall(dir, ["zero-trust", "access", "service-tokens", "create"], { body: serviceTokenBody(label) });
@@ -308,16 +343,19 @@ export async function create(o) {
308
343
  const secrets = { WAKE_WEBHOOK_URL: url, WAKE_ACCESS_CLIENT_ID: st.client_id, WAKE_ACCESS_CLIENT_SECRET: st.client_secret };
309
344
  for (const k of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[k]) secrets[k] = process.env[k];
310
345
  await putSecrets(dir, worker, secrets);
346
+ createdSecrets = Object.keys(secrets);
311
347
 
312
348
  console.log(url);
313
- console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
349
+ console.error(`\nAccess application "${label}" (id ${app.id}); service token id ${st.id}; tunnel id ${tun.id}.`);
350
+ console.error(`tunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
314
351
  console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
315
- console.error(`\nThen (Worker secrets apply within about 15 to 40 seconds): ${CLI} tunnel status and ${CLI} wake test`);
352
+ console.error(`\nTo undo this, run \`${CLI} tunnel rm\`.`);
353
+ console.error(`Then (Worker secrets apply within about 15 to 40 seconds; wait about 30 seconds): ${CLI} tunnel status and ${CLI} wake test`);
316
354
  if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
317
355
  console.error(`note: no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET in the environment; keep the ${preset} webhook's own auth too (export it and run \`wake set\`)`);
318
356
  } catch (e) {
319
- console.error(`error: ${e.message}\n==> rolling back what was created`);
320
- await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
357
+ console.error("==> rolling back what was created");
358
+ await remove(o, { quiet: true, secretNames: createdSecrets }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
321
359
  throw e;
322
360
  }
323
361
  }
@@ -396,7 +434,7 @@ export async function status(o) {
396
434
  const tid = C.get("A2A_TUNNEL_ID");
397
435
  if (tid) {
398
436
  const t = cfCall(dir, ["tunnels", "get", tid], { allowNotFound: true });
399
- info.tunnel = t ? { id: tid, status: t.status, connections: (t.connections || []).length } : "missing";
437
+ info.tunnel = t ? { id: tid, status: t.status === "inactive" ? "never connected" : t.status, connections: (t.connections || []).length } : "missing";
400
438
  }
401
439
  const app = C.get("A2A_TUNNEL_ACCESS_APP_ID");
402
440
  if (app) {
@@ -424,30 +462,54 @@ export async function status(o) {
424
462
  console.log(JSON.stringify(info, null, 2));
425
463
  const w = info.probe.withServiceToken || "";
426
464
  if (/1033/.test(w)) console.error("connector not running: start cloudflared on the agent's machine (tunnel create printed the command)");
427
- if (!/^HTTP 40[13]/.test(info.probe.withoutToken)) console.error("WARNING: the bare request was not blocked by Access; check the Access application");
465
+ const note = bareProbeNote(info.probe.withoutToken);
466
+ if (note) console.error(note);
467
+ }
468
+
469
+ /** What `tunnel status` prints about the request that carries no service token. A network error is DNS, not a missed Access block. */
470
+ export function bareProbeNote(withoutToken) {
471
+ const bare = withoutToken || "";
472
+ if (/^network error:/.test(bare)) return "DNS may still be propagating; retry in 30 seconds";
473
+ if (!/^HTTP 40[13]/.test(bare)) return "WARNING: the bare request was not blocked by Access; check the Access application";
474
+ return "";
428
475
  }
429
476
 
430
- export async function remove(o, { quiet = false, allowMissingProject = false } = {}) {
477
+ export async function remove(o, { quiet = false, allowMissingProject = false, skipSecrets = false, secretNames = undefined, rerun = "" } = {}) {
478
+ if (!TUNNEL_KEYS.some((k) => C.get(k))) {
479
+ if (!quiet) console.error("nothing to remove");
480
+ return;
481
+ }
431
482
  const dir = cfDirForRemove(o, { allowMissingProject });
432
483
  const worker = D.workerName();
433
484
  const label = `a2a-exposed wake ${worker}`;
434
485
  const failures = [];
486
+ const removedParts = [];
435
487
  const attempt = async (what, key, fn) => {
436
488
  if (key && !C.get(key)) return;
437
- try { await fn(); if (key) C.saveConfig({ [key]: null }); step(`removed ${what}`); }
489
+ try { await fn(); if (key) C.saveConfig({ [key]: null }); step(`removed ${what}`); removedParts.push(what); }
438
490
  catch (e) { failures.push(`${what}: ${e.message}`); }
439
491
  };
440
492
  const host = C.get("A2A_TUNNEL_HOSTNAME");
441
493
  // 1. Worker secrets first, so no wake is sent to a half-removed hostname. WAKE_WEBHOOK_URL only if it is ours.
442
- if (host) {
443
- const names = ["WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"];
444
- let ours = false;
445
- try { const p = await owner("GET", "/owner/wake/preview"); ours = p.fingerprints?.url === fingerprint(wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/")); }
446
- catch { /* Worker unreachable: leave the URL */ }
447
- if (ours) names.push("WAKE_WEBHOOK_URL");
448
- await attempt(`Worker secrets for ${host} (${names.join(", ")})`, null, async () => {
494
+ // secretNames lists what this run created (rollback). Omitted: the usual tunnel-rm set. skipSecrets: the Worker is already gone.
495
+ const explicitSecrets = secretNames !== undefined;
496
+ const names = explicitSecrets ? [...secretNames] : ["WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"];
497
+ if (host && !skipSecrets && names.length) {
498
+ if (!explicitSecrets) {
499
+ let ours = false;
500
+ try { const p = await owner("GET", "/owner/wake/preview"); ours = p.fingerprints?.url === fingerprint(wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/")); }
501
+ catch { /* Worker unreachable: leave the URL */ }
502
+ if (ours && !names.includes("WAKE_WEBHOOK_URL")) names.push("WAKE_WEBHOOK_URL");
503
+ }
504
+ try {
449
505
  await putSecrets(dir, worker, Object.fromEntries(names.map((k) => [k, null])));
450
- });
506
+ const secretsWhat = `Worker secrets for ${host} (${names.join(", ")})`;
507
+ step(`removed ${secretsWhat}`);
508
+ removedParts.push(secretsWhat);
509
+ } catch (e) {
510
+ if (workerMissingError(e)) step(`Worker for ${host} is already gone; its secrets went with it`);
511
+ else failures.push(`Worker secrets for ${host} (${names.join(", ")}): ${e.message}`);
512
+ }
451
513
  }
452
514
  // 2. DNS before Access, so the hostname never resolves without its Access app
453
515
  await attempt(`DNS record ${host || C.get("A2A_TUNNEL_DNS_ID")}`, "A2A_TUNNEL_DNS_ID", () => cfCall(dir, ["dns", "records", "delete", C.get("A2A_TUNNEL_DNS_ID"), "-z", C.get("A2A_TUNNEL_ZONE_ID"), "--force"], { allowNotFound: true }));
@@ -464,7 +526,11 @@ export async function remove(o, { quiet = false, allowMissingProject = false } =
464
526
  C.saveConfig({ A2A_TUNNEL_ACCESS_CLIENT_ID: null, A2A_TUNNEL_ACCESS_CLIENT_SECRET: null });
465
527
  });
466
528
  await attempt("tunnel token file", "A2A_TUNNEL_TOKEN_FILE", () => fs.rmSync(C.get("A2A_TUNNEL_TOKEN_FILE"), { force: true }));
467
- if (failures.length) die(`some parts could not be removed (ids kept in ${C.CONFIG_FILE}; re-run \`tunnel rm\`):\n ${failures.join("\n ")}`);
529
+ if (failures.length) {
530
+ const err = new CliError(`some parts could not be removed (ids kept in ${C.CONFIG_FILE}; re-run ${rerun || "`tunnel rm`"}):\n ${failures.join("\n ")}`);
531
+ err.removed = removedParts;
532
+ throw err;
533
+ }
468
534
  C.saveConfig(Object.fromEntries(TUNNEL_KEYS.map((k) => [k, null])));
469
535
  if (!quiet) console.error("tunnel removed. Stop cloudflared on the agent's machine (and `cloudflared service uninstall` if installed). Wakes are off until you set a new WAKE_WEBHOOK_URL (or use polling).");
470
536
  }
package/lib/uninstall.mjs CHANGED
@@ -1,45 +1,155 @@
1
- // `uninstall`: remove the wake tunnel (when one is saved), the Worker, and the D1 database for this config.
1
+ // `uninstall`: remove the Worker, the wake tunnel (when one is saved), and the D1 database for this config.
2
2
  import readline from "node:readline/promises";
3
- import { CliError } from "./a2a.mjs";
3
+ import { CLI, CliError } from "./a2a.mjs";
4
4
  import * as C from "./config.mjs";
5
5
  import * as D from "./deploy.mjs";
6
6
  import * as T from "./tunnel.mjs";
7
7
 
8
8
  const step = (s) => console.error(`==> ${s}`);
9
9
 
10
+ /** Saved Worker, D1 id, or tunnel: something this config deployed. */
11
+ export function deploymentSaved() {
12
+ return !!(C.get("A2A_WORKER_NAME") || C.get("A2A_D1_ID") || T.TUNNEL_KEYS.some((k) => C.get(k)));
13
+ }
14
+
15
+ /** Lines printed before the prompt and by `--dry-run`. The wake line is present only when a tunnel is saved. */
16
+ export function uninstallPlan() {
17
+ const lines = ["This removes:"];
18
+ lines.push(`Worker: ${C.get("A2A_WORKER_NAME") || "(none saved)"}`);
19
+ const d1 = [C.get("A2A_D1_NAME"), C.get("A2A_D1_ID")].filter(Boolean).join(" ");
20
+ lines.push(`D1: ${d1 || "(none saved)"}`);
21
+ lines.push(`Account: ${C.get("CLOUDFLARE_ACCOUNT_ID") || "(none saved)"}`);
22
+ if (T.TUNNEL_KEYS.some((k) => C.get(k))) {
23
+ const host = C.get("A2A_TUNNEL_HOSTNAME");
24
+ const id = C.get("A2A_TUNNEL_ID");
25
+ const worker = C.get("A2A_WORKER_NAME");
26
+ const label = worker ? `a2a-exposed wake ${worker}` : "(none saved)";
27
+ lines.push(`Wake: ${[host, id && `tunnel ${id}`].filter(Boolean).join(", ") || "(tunnel saved)"}`);
28
+ const app = C.get("A2A_TUNNEL_ACCESS_APP_ID");
29
+ const token = C.get("A2A_TUNNEL_ACCESS_TOKEN_ID");
30
+ const dns = C.get("A2A_TUNNEL_DNS_ID");
31
+ lines.push(`Access application: ${app ? `${label} (${app})` : "(none saved)"}`);
32
+ lines.push(`Service token: ${token ? `${label} (${token})` : "(none saved)"}`);
33
+ lines.push(`DNS record: ${[host, dns && `id ${dns}`].filter(Boolean).join(", ") || "(none saved)"}`);
34
+ }
35
+ lines.push("Removal is permanent. It takes the messages and the paired peers with it.");
36
+ return lines.join("\n");
37
+ }
38
+
10
39
  /** Ask before deleting. On a terminal, wait for y or yes unless `yes` is set. Off a terminal, `--yes` is required. */
11
40
  export async function confirmUninstall(yes, input = process.stdin, output = process.stderr) {
12
41
  if (yes) return;
13
42
  if (!input.isTTY) throw new CliError("uninstall removes the Worker, the D1 database, and the wake tunnel. Pass --yes.");
14
43
  const rl = readline.createInterface({ input, output });
15
44
  try {
16
- const answer = (await rl.question("Remove the Worker, the D1 database, and the wake tunnel? [y/N] ")).trim().toLowerCase();
45
+ const answer = (await rl.question("Remove the Worker, D1 database and wake tunnel listed above? [y/N] ")).trim().toLowerCase();
17
46
  if (answer !== "y" && answer !== "yes") throw new CliError("uninstall cancelled");
18
47
  } finally {
19
48
  rl.close();
20
49
  }
21
50
  }
22
51
 
23
- const kept = () => `config directory kept at ${C.CONFIG_DIR} (owner token and peer tokens). Remove that directory to delete them.`;
52
+ const kept = () => `Config directory kept at ${C.CONFIG_DIR}. The owner token and WAKE_* values stay in config.env. A later init reuses the owner token. Peer tokens stay in this directory too. Remove the directory to delete them.`;
53
+
54
+ /** A tunnel failure names the DNS record when that is the only object left. */
55
+ function tunnelLeft(err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ const names = msg.split("\n").slice(1).map((l) => l.trim().split(":")[0].trim()).filter(Boolean);
58
+ if (names.length && names.every((n) => n.startsWith("DNS record"))) return names.join("; ");
59
+ return "wake tunnel";
60
+ }
61
+
62
+ /** [10000] / [7403], a Worker that is still there, a DNS failure, and a D1 failure after the Worker is gone, say how to continue. */
63
+ function removalError(err, { removed, left, workerGone, workerStill }) {
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ const code = /\[(10000|7403)\]/.test(msg);
66
+ const d1Failed = /cf d1 delete/i.test(msg);
67
+ const dnsFailed = /DNS record/i.test(msg);
68
+ if (!code && !(d1Failed && workerGone) && !dnsFailed && !workerStill) throw err instanceof CliError ? err : new CliError(msg);
69
+ const lines = [msg];
70
+ if (!/uninstall --yes/.test(msg))
71
+ lines.push(`Run \`${CLI} uninstall --yes\` again. A rerun continues from what is left.`);
72
+ lines.push(`Removed: ${removed.length ? removed.join("; ") : "nothing"}.`);
73
+ lines.push(`Not removed: ${left.length ? left.join("; ") : "nothing"}.`);
74
+ if (d1Failed && workerGone) lines.push("The Worker is already gone. A rerun removes the D1 database.");
75
+ if (workerStill) lines.push("The Worker is still there, so the D1 database was left in place. A rerun is safe.");
76
+ throw new CliError(lines.join("\n"));
77
+ }
24
78
 
25
- /** One line per removal. A 404 counts as already gone. The config directory stays. */
79
+ /** One line per removal. A missing Worker or D1 counts as already gone. The config directory stays. */
26
80
  export async function uninstall(o) {
81
+ if (!deploymentSaved())
82
+ throw new CliError(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
83
+ console.error(uninstallPlan());
84
+ if (o["dry-run"]) {
85
+ console.error("Nothing was changed.");
86
+ return;
87
+ }
27
88
  await confirmUninstall(Boolean(o.yes));
28
- const tunnelish = T.TUNNEL_KEYS.some((k) => C.get(k));
29
- if (tunnelish) await T.remove(o, { allowMissingProject: true });
30
- else step("no tunnel to remove");
31
89
 
32
90
  const name = C.get("A2A_WORKER_NAME");
33
91
  const d1 = C.get("A2A_D1_ID");
34
- const dir = name || d1 ? T.cfWorkDir(D.workerDir(o)) : "";
92
+ const d1Label = C.get("A2A_D1_NAME") || d1;
93
+ const tunnelish = T.TUNNEL_KEYS.some((k) => C.get(k));
94
+ const removed = [];
95
+ const dir = name || d1 || tunnelish ? T.cfWorkDir(D.workerDir(o)) : "";
96
+ let workerGone = !name;
97
+ let workerDeletedThisRun = false;
98
+ let workerError = null;
35
99
  if (name) {
36
- const r = T.cfCall(dir, ["workers", "delete", name, "--force"], { allowNotFound: true });
37
- step(r === null ? `Worker ${name} is already gone` : `removed Worker ${name}`);
38
- } else step("no Worker name saved");
100
+ try {
101
+ const r = T.cfCall(dir, ["workers", "delete", name, "--force"], { allowNotFound: true });
102
+ workerGone = true;
103
+ workerDeletedThisRun = r !== null;
104
+ const line = r === null ? `Worker ${name} is already gone` : `Worker ${name}`;
105
+ step(r === null ? line : `removed ${line}`);
106
+ removed.push(line);
107
+ } catch (e) { workerError = e; }
108
+ }
109
+ // A 10000 on the Worker delete is the same missing Workers Scripts permission the secrets
110
+ // upload would hit. Skip that upload so the tunnel can still be removed. The Worker is
111
+ // still there, so the D1 database stays until a later run deletes the Worker.
112
+ const workerAuth = workerError && /\[10000\]/.test(workerError.message || "") && /cf workers delete/i.test(workerError.message || "");
113
+ let tunnelError = null;
114
+ if (tunnelish) {
115
+ try {
116
+ await T.remove(o, { allowMissingProject: true, skipSecrets: workerGone || workerAuth, quiet: !!name, rerun: `${CLI} uninstall --yes` });
117
+ removed.push("wake tunnel");
118
+ } catch (e) {
119
+ tunnelError = e;
120
+ if (Array.isArray(e.removed)) removed.push(...e.removed);
121
+ }
122
+ } else step("no tunnel to remove");
123
+ if (workerError) {
124
+ const left = [d1 && `D1 ${d1Label} (untouched)`, name && `Worker ${name}`, tunnelError && tunnelLeft(tunnelError)].filter(Boolean);
125
+ const err = tunnelError ? new CliError(`${workerError.message}\n${tunnelError.message}`) : workerError;
126
+ removalError(err, { removed, left, workerGone: false, workerStill: true });
127
+ }
128
+ if (tunnelError) {
129
+ // The Worker delete did not happen in this run only when this call did not delete it (already gone, or no Worker name).
130
+ const d1Left = d1 && (workerDeletedThisRun
131
+ ? `D1 ${d1Label}`
132
+ : `D1 ${d1Label} (untouched because the Worker delete did not happen in this run)`);
133
+ const left = [d1Left, tunnelLeft(tunnelError)].filter(Boolean);
134
+ removalError(tunnelError, { removed, left, workerGone });
135
+ }
136
+
39
137
  if (d1) {
40
- const r = T.cfCall(dir, ["d1", "delete", d1, "--force"], { allowNotFound: true });
41
- step(r === null ? `D1 database ${d1} is already gone` : `removed D1 database ${d1}`);
138
+ try {
139
+ const r = T.cfCall(dir, ["d1", "delete", d1, "--force"], { allowNotFound: true });
140
+ const line = r === null ? `D1 database ${d1} is already gone` : `D1 database ${d1}`;
141
+ step(r === null ? line : `removed ${line}`);
142
+ removed.push(line);
143
+ C.saveConfig({ A2A_D1_ID: null, A2A_D1_NAME: null });
144
+ } catch (e) {
145
+ removalError(e, { removed, left: [`D1 ${C.get("A2A_D1_NAME") || d1} (untouched)`, workerError && name && `Worker ${name}`].filter(Boolean), workerGone });
146
+ }
42
147
  } else step("no D1 database id saved");
148
+ if (workerError || tunnelError) {
149
+ const err = workerError && tunnelError ? new CliError(`${workerError.message}\n${tunnelError.message}`) : (workerError || tunnelError);
150
+ const left = [workerError && name && `Worker ${name}`, tunnelError && "wake tunnel"].filter(Boolean);
151
+ removalError(err, { removed, left, workerGone });
152
+ }
43
153
  C.saveConfig({
44
154
  A2A_WORKER_NAME: null, A2A_D1_ID: null, A2A_D1_NAME: null, A2A_BASE_URL: null, A2A_HOSTNAME: null,
45
155
  A2A_WORKER_DIR: null, A2A_WORKERS_DEV_SUBDOMAIN: null,
@@ -31,7 +31,7 @@ export function noSubdomainHelp(accountId) {
31
31
  return [
32
32
  "this Cloudflare account has no workers.dev subdomain yet. Create one (once per account), then re-run:",
33
33
  ` - let the CLI do it: re-run with --workers-dev-subdomain <name> (gives https://<worker>.<name>.workers.dev)`,
34
- ` - or in the dashboard: ${onboardingUrl(accountId)} (the first visit to Workers & Pages asks for it)`,
34
+ ` - or in the dashboard: ${onboardingUrl(accountId)} (the first visit to the Workers section asks for it)`,
35
35
  ` - or the API: PUT /accounts/<account-id>/workers/subdomain {"subdomain":"<name>"}`,
36
36
  ` docs: ${DOCS}`,
37
37
  ].join("\n");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.6",
4
- "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
3
+ "version": "0.8.7",
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": {
7
7
  "a2a-exposed": "bin/a2a-exposed.mjs"
@@ -34,7 +34,7 @@ WAKE_PRESET=generic
34
34
  #
35
35
  # WAKE_TARGET_POLICY=public-https requires a wake to be https to a public address. The Worker
36
36
  # connects to an address from that check. A redirect is followed only on the same origin. Leave it
37
- # unset for a self-hosted inbox.
37
+ # unset for a self-hosted A2A endpoint.
38
38
  # WAKE_TARGET_POLICY=public-https
39
39
  # OpenID Connect approval (optional). All four of issuer, client id, allowlist and the client secret
40
40
  # must be set or the pages stay password-only. APPROVAL_METHODS=oidc hides the password once that
@@ -2,7 +2,7 @@
2
2
  "name": "a2a-exposed-worker",
3
3
  "private": true,
4
4
  "type": "module",
5
- "description": "Cloudflare Worker for a2a-exposed: public A2A endpoint, D1 inbox, wake webhooks, owner API",
5
+ "description": "Cloudflare Worker for a2a-exposed: public A2A endpoint, D1 database, wake webhooks, owner API",
6
6
  "license": "MIT",
7
7
  "scripts": {
8
8
  "build": "cf build",
@@ -41,7 +41,7 @@ export function feedbackToolDescription(repo: string): string {
41
41
  const note = REPO_RE.test(repo) && !repo.includes("..")
42
42
  ? publicIssueNote(repo)
43
43
  : "This report is filed as a public GitHub issue on this deployment's configured feedback repository. The result names that repository. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.";
44
- return `${note} Redact before sending. Report a bug or an idea about this inbox to its developers. A bug requires reproduction steps. Include exact commands, error output with tokens and secrets removed, which agent you are, and where you run (local, cloud VM, CI, cloud agent sandbox, or other). Version, connection (webhook, proxy, or polling), and deployment (hosted or self-hosted) are filled by the inbox when you omit them. When this deployment does not file the report, the result is a link to the bug report form. You do not open a browser.`;
44
+ return `${note} Redact before sending. Report a bug or an idea about this A2A endpoint to its developers. A bug requires reproduction steps. Include exact commands, error output with tokens and secrets removed, which agent you are, and where you run (local, cloud VM, CI, cloud agent sandbox, or other). Version, connection (webhook, proxy, or polling), and deployment (hosted or self-hosted) are filled by the A2A endpoint when you omit them. When this deployment does not file the report, the result is a link to the bug report form. You do not open a browser.`;
45
45
  }
46
46
  /** Inbox build. The same value as `version` in cli/package.json. */
47
47
  export const WORKER_VERSION = VERSION;
@@ -1114,7 +1114,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1114
1114
  publicCardLeaks: F.cardLeaks(card, ctx.PUBLIC_URL, upstreamOrigins(ctx, up.card)) });
1115
1115
  }
1116
1116
  if (seg[0] === "facade" && seg[1] === "verify" && !seg[2] && m === "POST") {
1117
- if (!proxyMode(ctx)) return json({ mode: "inbox", ok: false, reason: "not_proxy_mode", detail: "this Worker is an inbox (no UPSTREAM_URL): nothing to verify" }, 409);
1117
+ if (!proxyMode(ctx)) return json({ mode: "inbox", ok: false, reason: "not_proxy_mode", detail: "this Worker is an A2A endpoint (no UPSTREAM_URL): nothing to verify" }, 409);
1118
1118
  return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
1119
1119
  }
1120
1120
  if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
@@ -1262,7 +1262,7 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1262
1262
  const pending: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(now).first();
1263
1263
  if ((pending?.n ?? 0) >= P.LIMITS.maxPending || (await bump(ctx, "dev:global", 3600)) > P.LIMITS.perHour) {
1264
1264
  log("pairing_rate_limited", { ip, scope: "global" });
1265
- return oauthError("slow_down", "this inbox has too many pairing requests waiting; try again later", 429, retry);
1265
+ return oauthError("slow_down", "this A2A endpoint has too many pairing requests waiting; try again later", 429, retry);
1266
1266
  }
1267
1267
  const deviceCode = P.newDeviceCode();
1268
1268
  let userCode = "";
@@ -1439,7 +1439,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1439
1439
  const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
1440
1440
  if (fresh) csrf = P.randomB64();
1441
1441
  const pw = await approvalPassword(ctx);
1442
- const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
1442
+ const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
1443
1443
  const send = (status = 200) => {
1444
1444
  const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
1445
1445
  h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
@@ -1489,6 +1489,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1489
1489
  if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
1490
1490
  const d0 = await decide(ctx, code, false, "page");
1491
1491
  if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
1492
+ model.settled = "denied";
1492
1493
  model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
1493
1494
  return send();
1494
1495
  }
@@ -1536,6 +1537,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1536
1537
  const d = await decide(ctx, code, true, "human");
1537
1538
  if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
1538
1539
  const who = r.client_name || r.client_id || "the agent";
1540
+ model.settled = "approved";
1539
1541
  model.notice = { kind: "ok", text: `Approved. ${who} can now collect its token (label "${d.label}"${d.replaced ? ", replacing its old token" : ""}). Revoke it any time: ${cliCommand(ctx)} token revoke ${d.label}` };
1540
1542
  return send();
1541
1543
  }
@@ -1544,7 +1546,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1544
1546
  async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
1545
1547
  const nonce = P.randomB64();
1546
1548
  const pw = await approvalPassword(ctx);
1547
- const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
1549
+ const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
1548
1550
  const send = (status = 200) => {
1549
1551
  const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
1550
1552
  h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
@@ -1587,6 +1589,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
1587
1589
  const d = await decide(ctx, done.userCode || "", true, "oidc");
1588
1590
  if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
1589
1591
  const who = d.request.client_name || d.request.client_id || "the agent";
1592
+ model.settled = "approved";
1590
1593
  model.notice = { kind: "ok", text: `Approved. ${who} can now collect its token (label "${d.label}"${d.replaced ? ", replacing its old token" : ""}). Revoke it any time: ${cliCommand(ctx)} token revoke ${d.label}` };
1591
1594
  log("oidc_approved", { kind: "device" });
1592
1595
  return send();
@@ -1617,7 +1620,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
1617
1620
  const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
1618
1621
  if (fresh) csrf = P.randomB64();
1619
1622
  const pw = await approvalPassword(ctx);
1620
- const model: P.SetupModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", cli: cliCommand(ctx), csrf, passwordSetAt: pw?.setAt };
1623
+ const model: P.SetupModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", cli: cliCommand(ctx), csrf, passwordSetAt: pw?.setAt };
1621
1624
  const send = (status = 200) => {
1622
1625
  const h: Record<string, string> = P.pageHeaders(nonce);
1623
1626
  h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
@@ -1772,7 +1775,7 @@ async function landing(req: Request, ctx: TenantContext, url: URL): Promise<Resp
1772
1775
  function mcpResourceMetadata(ctx: TenantContext): Json {
1773
1776
  const u = oauthUrls(ctx);
1774
1777
  return { resource: u.mcp, authorization_servers: [u.issuer], bearer_methods_supported: ["header"], scopes_supported: [M.SCOPE],
1775
- resource_name: `${ctx.AGENT_NAME || "A2A inbox"} (MCP)`, resource_documentation: "https://github.com/telegraphic-dev/a2a-exposed#mcp-connector" };
1778
+ resource_name: `${ctx.AGENT_NAME || "A2A endpoint"} (MCP)`, resource_documentation: "https://github.com/telegraphic-dev/a2a-exposed#mcp-connector" };
1776
1779
  }
1777
1780
 
1778
1781
  /** Why an outbound peer URL is refused (empty: fine). https only, no private / internal hosts. */
@@ -1933,7 +1936,7 @@ async function resolveClient(ctx: TenantContext, id: string, ip?: string, head =
1933
1936
  const c: Json = await ctx.DB.prepare("SELECT * FROM oauth_clients WHERE client_id = ?").bind(id).first();
1934
1937
  return c ? { client: c } : { error: "Unknown client. Remove the connector and add it again (the client registers itself)." };
1935
1938
  }
1936
- const bad = M.cimdUrlProblem(id) || peerUrlProblem(id) || (new URL(id).host === new URL(ctx.PUBLIC_URL).host ? "must not be on this inbox's own host" : "");
1939
+ const bad = M.cimdUrlProblem(id) || peerUrlProblem(id) || (new URL(id).host === new URL(ctx.PUBLIC_URL).host ? "must not be on this A2A endpoint's own host" : "");
1937
1940
  if (bad) return { error: `The client's metadata URL is not acceptable: ${bad}.` };
1938
1941
  const now = Date.now();
1939
1942
  const hit: Json = await ctx.DB.prepare("SELECT * FROM oauth_client_metadata WHERE client_id = ? AND expires_ms > ?").bind(id, now).first();
@@ -2032,7 +2035,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL, head =
2032
2035
  if (c.fatal) return page(400, `<p class="n error">${P.esc(c.fatal)}</p>`);
2033
2036
  if (c.error) return redirectWith(ctx, c.redirect!, { error: c.error, error_description: c.description!, state: c.params.state });
2034
2037
  const origin = new URL(c.redirect!).origin;
2035
- const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A inbox", cli: cliCommand(ctx),
2038
+ const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A endpoint", cli: cliCommand(ctx),
2036
2039
  csrf, clientName: c.client!.client_name || "", clientIdHost: c.client!.cimd ? new URL(c.client!.client_id).host : undefined,
2037
2040
  redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), [origin, ...oidcFormOrigins(ctx)]);
2038
2041
  if (req.method === "GET") return show(200);
@@ -2080,7 +2083,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL, head =
2080
2083
  async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: string, params: Record<string, string>, ip: string, via = ""): Promise<{ ok: true; response: Response } | { ok: false; error: string }> {
2081
2084
  const active: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM mcp_grants WHERE revoked_at IS NULL").first();
2082
2085
  if ((active?.n ?? 0) >= M.LIMITS.maxGrants)
2083
- return { ok: false, error: `This inbox already has ${M.LIMITS.maxGrants} MCP connectors. Revoke one first: ${cliCommand(ctx)} token list, then token revoke <label>.` };
2086
+ return { ok: false, error: M.mcpLimitMessage(M.LIMITS.maxGrants, cliCommand(ctx)) };
2084
2087
  const code = A.randomToken("a2amcpc_"), now = Date.now();
2085
2088
  await ctx.DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
2086
2089
  .bind(await A.sha256(code), params.client_id, redirect, params.code_challenge, params.scope, params.resource || oauthUrls(ctx).mcp, now, now + M.CODE_TTL_S * 1000).run();
@@ -2289,7 +2292,7 @@ const argStr = (a: Json, k: string, required = true): string => {
2289
2292
  /** A synced outbound peer with its decrypted token (null token: the peer takes none, or it can't be decrypted). */
2290
2293
  async function outboundPeer(ctx: TenantContext, alias: string): Promise<{ alias: string; url: string; token: string | null }> {
2291
2294
  const r: Json = await ctx.DB.prepare("SELECT * FROM outbound_peers WHERE alias = ?").bind(alias).first();
2292
- if (!r) throw new ToolError(`no peer "${alias}" synced to this inbox. On the machine with the CLI: ${cliCommand(ctx)} peers sync ${alias}`);
2295
+ if (!r) throw new ToolError(`no peer "${alias}" synced to this A2A endpoint. On the machine with the CLI: ${cliCommand(ctx)} peers sync ${alias}`);
2293
2296
  let token: string | null = null;
2294
2297
  if (r.token_enc) {
2295
2298
  const peerKey = await ctx.peerKey();
@@ -2399,12 +2402,12 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2399
2402
  case "list_peers": {
2400
2403
  const outRows = must(await ownerCall(ctx, ectx, "GET", "/owner/outbound-peers"));
2401
2404
  const inRows = must(await ownerCall(ctx, ectx, "GET", "/owner/peers")).filter((r: Json) => !r.revoked_at);
2402
- return M.toolText(`Outbound peers (send works with these) and inbound labels (agents holding a token for this inbox). Names are claimed by the peers.`,
2405
+ return M.toolText(`Outbound peers (send works with these) and inbound labels (agents holding a token for this A2A endpoint). Names are claimed by the peers.`,
2403
2406
  { outbound: outRows.map((r: Json) => ({ alias: r.alias, url: r.url, hasToken: r.has_token })),
2404
2407
  inbound: inRows.map((r: Json) => ({ label: r.label, source: r.source, clientName: r.client_name, since: r.created_at })) });
2405
2408
  }
2406
2409
  case "pairing_requests": {
2407
- if (!pairingOn(ctx)) return "Device-flow pairing is off on this inbox (PAIRING_APPROVAL=off): there are no pairing requests.";
2410
+ if (!pairingOn(ctx)) return "Device-flow pairing is off on this A2A endpoint (PAIRING_APPROVAL=off): there are no pairing requests.";
2408
2411
  const d = must(await ownerCall(ctx, ectx, "GET", "/owner/pairing"));
2409
2412
  return M.toolText(`${d.pending.length} pending pairing request(s). Show your human who asks, the code and the link. NEVER approve or deny yourself: your human decides on the link with the approval password. Names and card URLs are claimed by the requester (untrusted).`,
2410
2413
  d.pending.map((r: Json) => ({ code: r.userCode, clientName: r.clientName, clientId: r.clientId, agentCardUrl: r.agentCardUrl, country: r.country,
@@ -2440,8 +2443,8 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2440
2443
  const rpcResult = (id: Json, result: Json) => json({ jsonrpc: "2.0", id, result });
2441
2444
  const rpcError = (id: Json, code: number, message: string, status = 200, data?: Json) =>
2442
2445
  json({ jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } }, status);
2443
- const serverInfo = (ctx: TenantContext) => ({ name: "a2a-exposed", title: `${ctx.AGENT_NAME || "A2A inbox"}`, version: "1" });
2444
- const mcpInstructions = `This is your A2A inbox: other agents send you tasks here. Check inbox, read each task, answer with reply. ${M.RULES}`;
2446
+ const serverInfo = (ctx: TenantContext) => ({ name: "a2a-exposed", title: `${ctx.AGENT_NAME || "A2A endpoint"}`, version: "1" });
2447
+ const mcpInstructions = `This is your A2A endpoint: other agents send you tasks here. Check inbox, read each task, answer with reply. ${M.RULES}`;
2445
2448
  const unsupportedVersion = (id: Json, requested: string) =>
2446
2449
  rpcError(id, M.ERR.UNSUPPORTED_VERSION, "Unsupported protocol version", 400, { supported: M.PROTOCOL_VERSIONS, requested });
2447
2450
 
@@ -2582,7 +2585,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
2582
2585
  if (req.method === "GET" && path === "/.well-known/agent.json")
2583
2586
  return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
2584
2587
  if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
2585
- if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this inbox (MCP=off)" }, 404);
2588
+ if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this A2A endpoint (MCP=off)" }, 404);
2586
2589
  if (mcpOn(ctx)) {
2587
2590
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource/mcp") return json(mcpResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2588
2591
  if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(ctx), 200, { "access-control-allow-origin": "*" });
@@ -2594,7 +2597,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
2594
2597
  if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
2595
2598
  }
2596
2599
  if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-protected-resource" || path.startsWith("/oauth/") || path === "/device" || path === "/device/setup" || path === "/device/oidc/callback") {
2597
- if (!pairingOn(ctx)) return json({ error: "not_found", error_description: "device-flow pairing is disabled on this inbox (PAIRING_APPROVAL=off); ask its operator for a token" }, 404);
2600
+ if (!pairingOn(ctx)) return json({ error: "not_found", error_description: "device-flow pairing is disabled on this A2A endpoint (PAIRING_APPROVAL=off); ask its operator for a token" }, 404);
2598
2601
  if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2599
2602
  if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
2600
2603
  if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);