a2a-exposed 0.8.3 → 0.8.5

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
@@ -103,10 +103,13 @@ function syncTemplate(dir) {
103
103
  fs.cpSync(src, dir, { recursive: true, force: true, filter: (p) => !skip.has(path.basename(p)) });
104
104
  }
105
105
 
106
- function run(cmd, args, { cwd, env, capture = false, allowFail = false } = {}) {
107
- const r = spawnSync(cmd, args, { cwd, env: env || process.env, stdio: ["ignore", capture ? "pipe" : "inherit", "inherit"], encoding: "utf8" });
106
+ function run(cmd, args, { cwd, env, capture = false, captureStderr = false, allowFail = false } = {}) {
107
+ const r = spawnSync(cmd, args, { cwd, env: env || process.env, stdio: ["ignore", capture ? "pipe" : "inherit", capture && captureStderr ? "pipe" : "inherit"], encoding: "utf8" });
108
108
  if (r.error) die(`${cmd}: ${r.error.message}`);
109
- if (r.status !== 0 && !allowFail) die(`${cmd} ${args[0] || ""} ${args[1] || ""} failed (exit ${r.status})`);
109
+ if (r.status !== 0 && !allowFail) {
110
+ if (captureStderr && r.stderr) process.stderr.write(r.stderr.endsWith("\n") ? r.stderr : r.stderr + "\n");
111
+ die(`${cmd} ${args[0] || ""} ${args[1] || ""} failed (exit ${r.status})`);
112
+ }
110
113
  return r;
111
114
  }
112
115
 
@@ -298,26 +301,61 @@ async function verifyDeployedCard(base) {
298
301
  console.error(`warning: agent card not reachable yet at ${base} (${r.lastError || "no answer"}); ${C.get("A2A_HOSTNAME") ? "a new custom domain" : "a new workers.dev deployment or subdomain"} can take a few minutes. Check again with: ${CLI} status`);
299
302
  }
300
303
 
304
+ /** Drop cf's non-interactive confirm line. The migrations still apply; the question is answered yes. */
305
+ export function migrationStderr(text) {
306
+ return String(text || "").split(/\r?\n/).filter((line) => {
307
+ const t = line.trim();
308
+ if (!t) return false;
309
+ if (/Using fallback value in non-interactive context/i.test(t)) return false;
310
+ if (/^\? About to apply \d+ migration/.test(t)) return false;
311
+ if (/may not be available to serve requests during the migration, continue\?/.test(t)) return false;
312
+ return true;
313
+ }).join("\n");
314
+ }
315
+
301
316
  /** Apply D1 migrations quietly: cf prints a bare JSON array (often `[]`) when stdout is not a TTY. */
302
317
  function applyMigrations(dir, env) {
303
318
  step("applying D1 migrations");
304
- const r = runCf(dir, ["d1", "migrations", "apply", C.get("A2A_D1_ID"), "--dir", "migrations"], { env, capture: true });
319
+ const r = runCf(dir, ["d1", "migrations", "apply", C.get("A2A_D1_ID"), "--dir", "migrations"], { env, capture: true, captureStderr: true, allowFail: true });
320
+ const err = migrationStderr(r.stderr);
321
+ if (err) console.error(err);
322
+ if (r.status !== 0) die(`cf d1 migrations failed (exit ${r.status})`);
305
323
  let applied = null;
306
324
  try { applied = JSON.parse(r.stdout); } catch { /* not JSON: show as is */ }
307
325
  if (Array.isArray(applied)) console.error(applied.length ? ` applied: ${applied.map((m) => m.name || m.id || JSON.stringify(m)).join(", ")}` : " (none pending)");
308
326
  else if (r.stdout.trim()) console.error(r.stdout.trim());
309
327
  }
310
328
 
329
+ /** cf deploy sometimes names a Wrangler file. This project configures the Worker in cloudflare.config.ts. */
330
+ export function withoutWranglerConfigMention(text) {
331
+ return String(text || "").split(/\n/).filter((line) => !/Wrangler config file|Wrangler file/i.test(line)).join("\n");
332
+ }
333
+
311
334
  /** Run a command, streaming its output to stderr while collecting it. */
312
335
  function runTee(cmd, args, { cwd, env }) {
313
336
  return new Promise((resolve, reject) => {
314
337
  const ch = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
315
- let out = "";
316
- const on = (d) => { process.stderr.write(d); out += d; };
317
- ch.stdout.on("data", on);
318
- ch.stderr.on("data", on);
338
+ let out = "", pending = "";
339
+ const take = (d) => {
340
+ pending += d;
341
+ const lines = pending.split("\n");
342
+ pending = lines.pop();
343
+ for (const line of lines) {
344
+ if (withoutWranglerConfigMention(line) !== line) continue;
345
+ process.stderr.write(line + "\n");
346
+ out += line + "\n";
347
+ }
348
+ };
349
+ ch.stdout.on("data", take);
350
+ ch.stderr.on("data", take);
319
351
  ch.on("error", reject);
320
- ch.on("close", (code) => resolve({ status: code ?? 1, out }));
352
+ ch.on("close", (code) => {
353
+ if (pending && withoutWranglerConfigMention(pending) === pending) {
354
+ process.stderr.write(pending);
355
+ out += pending;
356
+ }
357
+ resolve({ status: code ?? 1, out });
358
+ });
321
359
  });
322
360
  }
323
361
 
@@ -421,6 +459,7 @@ export async function init(o) {
421
459
  applyMigrations(dir, env);
422
460
 
423
461
  let ownerToken = C.fileConfig().A2A_OWNER_TOKEN;
462
+ const rotated = !!(ownerToken && o["rotate-owner-token"]);
424
463
  if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
425
464
  C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
426
465
 
@@ -432,6 +471,7 @@ export async function init(o) {
432
471
  console.log(base);
433
472
  reportSwitch(switched);
434
473
  proxyNote(secrets);
474
+ if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this inbox is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
435
475
  console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: ${CLI} status (shows the next setup step)`);
436
476
  printStar();
437
477
  }
@@ -594,7 +634,16 @@ export async function wake(sub, o) {
594
634
  if (sub === "unset") {
595
635
  const dir = workerDir(o), name = workerName();
596
636
  const env = cfEnv();
597
- for (const k of WAKE_SECRETS) runCf(dir, ["workers", "secrets", "delete", k, "--worker", name, "--force"], { env, allowFail: true });
637
+ const problems = [];
638
+ for (const k of WAKE_SECRETS) {
639
+ const r = runCf(dir, ["workers", "secrets", "delete", k, "--worker", name, "--force"], { env, allowFail: true, capture: true, captureStderr: true });
640
+ if (r.status === 0) continue;
641
+ const err = `${r.stderr || ""}\n${r.stdout || ""}`;
642
+ if (/404|not found|does not exist|could not be found/i.test(err)) continue; // already unset
643
+ const line = (err.match(/\[\d+\][^\n]*/) || [err.trim().split("\n").filter(Boolean).pop() || "cf failed"])[0];
644
+ problems.push(`${k}: ${line.slice(0, 300)}`);
645
+ }
646
+ if (problems.length) die(`could not remove wake secrets:\n ${problems.join("\n ")}`);
598
647
  return console.error("wake secrets removed (wake is now a no-op; use polling)");
599
648
  }
600
649
  die("usage: wake set|unset|test|preview|fingerprint");
package/lib/main.mjs CHANGED
@@ -10,6 +10,7 @@ import * as pair from "./pair.mjs";
10
10
  import * as account from "./account.mjs";
11
11
  import * as upd from "./update.mjs";
12
12
  import * as fb from "./feedback.mjs";
13
+ import * as un from "./uninstall.mjs";
13
14
 
14
15
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
15
16
 
@@ -53,7 +54,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
53
54
  Plugin manifests leave MCP unset: the URL differs per deploy.
54
55
  When GET /owner/mcp reports enabled and the agent card is reachable,
55
56
  \`status\` prints \`claude mcp add --transport http a2a-exposed <url>/mcp\`
56
- --rotate-owner-token on init, replace the owner token; other settings come from config.env
57
+ --rotate-owner-token on init, replace the owner token; other settings come from config.env.
58
+ Then update A2A_OWNER_TOKEN wherever this inbox is called, and run \`peers sync\`
57
59
  --upstream URL proxy / expose mode: a public façade for an agent that already
58
60
  speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
59
61
  a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
@@ -82,8 +84,12 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
82
84
  which defaults to 25 when this is unset
83
85
  wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
84
86
  + sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
85
- local env values if exported
86
- wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* in the local environment
87
+ local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
88
+ are exported in this shell. match: the exported value is the one on the Worker.
89
+ DIFFERENT: they differ (absent from this shell, or still propagating, about
90
+ 15 to 40 seconds after wake set, wake unset, or tunnel create). Wait about
91
+ 30 seconds and run it again
92
+ wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
87
93
  wake test send a test wake now and print the HTTP status
88
94
  wake unset remove wake secrets (fall back to polling)
89
95
 
@@ -95,10 +101,24 @@ anywhere on the account: the inbox itself can be on workers.dev or a custom host
95
101
  the Worker sends that token (CF-Access-Client-Id/Secret) on every wake.
96
102
  Wake hostname: wake-<random>.<zone>, on the inbox hostname's zone, else the account's
97
103
  only zone; with several zones pass --tunnel-zone. Safe to re-run (no duplicates).
104
+ The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
105
+ The DNS host is wake-<words>-<hex>.<zone> unless --tunnel-hostname is set.
106
+ Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
107
+ The connector machine needs cloudflared installed
108
+ (https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
98
109
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
99
- (pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600)
110
+ (pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600).
111
+ Worker secrets show up in about 15 to 40 seconds
100
112
  tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
101
- tunnel rm delete Worker wake secrets, DNS record, tunnel, Access app + service token
113
+ tunnel rm delete the DNS record, the tunnel, the Access app, and the service token
114
+ (each printed by name), plus Worker secrets WAKE_ACCESS_CLIENT_ID,
115
+ WAKE_ACCESS_CLIENT_SECRET, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET. The wake URL
116
+ goes too when it is this tunnel's. Prints one line per object removed
117
+ uninstall [--yes] remove the wake tunnel (when the config has one), the Worker, and the D1 database.
118
+ One line per step, including when that object is already gone. Asks first;
119
+ off a terminal, pass --yes. The config directory stays (owner token and peer
120
+ tokens): remove that directory to delete them. cf runs from the Worker project
121
+ when it has node_modules, otherwise from a directory with .cloudflare/cache
102
122
  init ... --tunnel init, then tunnel create (also with --workers-dev)
103
123
 
104
124
  Status
@@ -110,7 +130,9 @@ Status
110
130
  When GET /owner/mcp reports enabled and the agent card is reachable, prints
111
131
  \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
112
132
  A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
113
- Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
133
+ Read-only; run it after an interruption and continue from "next step". Exit 1 = broken.
134
+ Right after tunnel create, for about 15 to 40 seconds, while the connector
135
+ reports 0 connections, status prints a wait-about-30-seconds line and exits 0
114
136
  upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
115
137
  (unauthenticated call -> 401), then the Worker calls the upstream with its stored
116
138
  secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
@@ -134,8 +156,9 @@ Inbox (owner side)
134
156
 
135
157
  Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
136
158
  connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
137
- ask another inbox for a token: prints a code + link for your human (who confirms the
138
- code with that inbox's owner), waits for approval, stores the token as outbound peer A
159
+ ask another inbox for a token: prints a code + link for your human (the code lasts
160
+ 10 minutes; they confirm the code with that inbox's owner), waits for approval, stores
161
+ the token as outbound peer A
139
162
  (never printed). --no-wait: print the code and exit; run the same command again to
140
163
  check (same code). An expired code exits 1 (nothing new is requested silently).
141
164
  An alias whose token still works is refused unless --replace (alias --force): the
@@ -148,9 +171,9 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
148
171
  configured agent name, the worker name, or the public host's first label. The
149
172
  other inbox labels the token from that name and adds -2, -3, … when it is taken
150
173
  pair set-password --web [--ttl MIN] [--json]
151
- one-time link (default 15 min, single use) where your human sets or changes the
152
- approval password on a web page. Send it to them privately; never open or fill it
153
- yourself. A new link invalidates the previous one
174
+ one-time link (default 15 min, --ttl 1 to 60, single use) where your human sets or
175
+ changes the approval password on a web page. Send it to them privately; never open
176
+ or fill it yourself. A new link invalidates the previous one
154
177
  pair set-password the same in a terminal, no echo (the human runs it; never via argv/stdin)
155
178
  pair set-oidc --issuer URL --client-id ID --subjects SUB[,SUB...] [--methods password,oidc] [--secret-stdin]
156
179
  optional OpenID Connect approval on /device and the MCP consent page. The client
@@ -238,6 +261,7 @@ const SPEC = {
238
261
  login: { "control-url": S, json: B },
239
262
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
240
263
  feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
264
+ uninstall: { yes: B, dir: S, "worker-dir": S },
241
265
  };
242
266
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
243
267
 
@@ -273,6 +297,7 @@ export async function main(argv) {
273
297
  throw e;
274
298
  }
275
299
  case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
300
+ case "uninstall": return un.uninstall(o);
276
301
  case "deploy": return dep.deploy(o);
277
302
  case "status": return st.status(o);
278
303
  case "upstream": {
package/lib/status.mjs CHANGED
@@ -77,6 +77,17 @@ export function shadowedPeerTokens() {
77
77
  return [...new Set(Object.values(C.loadPeers()).map((p) => p && p.token_env).filter(Boolean))].filter((k) => C.envOverridesFile(k));
78
78
  }
79
79
 
80
+ /** How long after `tunnel create` uploads secrets `status` treats a missing Worker preview as propagation. */
81
+ export const SECRET_PROPAGATION_MS = 60_000;
82
+
83
+ /** True while the tunnel's Worker secrets were uploaded recently and may still be invisible to `wake preview`. */
84
+ export function secretsPropagating(tunnel, now = Date.now()) {
85
+ const at = Number(tunnel && tunnel.secretsAt);
86
+ if (!Number.isFinite(at) || at <= 0) return false;
87
+ const age = now - at;
88
+ return age >= 0 && age < SECRET_PROPAGATION_MS;
89
+ }
90
+
80
91
  /** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
81
92
  * as separate lines (so the next step stays one short instruction). */
82
93
  export function nextStep(s) {
@@ -143,8 +154,12 @@ function nextStepCore(s) {
143
154
  return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
144
155
  const agentSecret = s.wake.preset === "hermes" ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
145
156
  const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
146
- if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches))
157
+ const now = typeof s.now === "number" ? s.now : Date.now();
158
+ if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
159
+ if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
160
+ return done(`Worker wake secrets are still propagating (about 15 to 40 seconds after tunnel create). Wait about 30 seconds and run \`${CLI} status\` again.`);
147
161
  return fail(`the Worker does not have the tunnel's wake secrets: ${needsAgentSecret ? `export ${agentSecret} and ` : ""}run \`${CLI} tunnel create\` again (it re-uploads them; nothing new is created)`);
162
+ }
148
163
  if (t && t.connections === 0)
149
164
  return fail(`the tunnel has no running connector: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
150
165
  if (s.wake.configured && needsAgentSecret)
@@ -223,7 +238,8 @@ export async function collect() {
223
238
  if (host || C.get("A2A_TUNNEL_ID")) {
224
239
  const url = T.wakeUrl(host || "?", C.get("A2A_TUNNEL_PATH") || "/");
225
240
  s.tunnel = { hostname: host, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
226
- tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url), connections: null };
241
+ tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
242
+ secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
227
243
  if (haveCf && C.get("A2A_TUNNEL_ID")) {
228
244
  try {
229
245
  const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
package/lib/tunnel.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // A Cloudflare zone on the account is required on purpose (no quick tunnels, no unprotected hostnames). Any zone
7
7
  // on the account works: the inbox itself can stay on workers.dev or on another hostname.
8
8
  import fs from "node:fs";
9
+ import os from "node:os";
9
10
  import path from "node:path";
10
11
  import crypto from "node:crypto";
11
12
  import { spawnSync } from "node:child_process";
@@ -32,7 +33,7 @@ export const SERVICE_TOKEN_DURATION = "8760h"; // 1 year (Cloudflare's default);
32
33
  export const TUNNEL_KEYS = [
33
34
  "A2A_TUNNEL_HOSTNAME", "A2A_TUNNEL_ORIGIN", "A2A_TUNNEL_PATH", "A2A_TUNNEL_ZONE_ID", "A2A_TUNNEL_ID", "A2A_TUNNEL_DNS_ID",
34
35
  "A2A_TUNNEL_ACCESS_APP_ID", "A2A_TUNNEL_ACCESS_TOKEN_ID", "A2A_TUNNEL_ACCESS_CLIENT_ID", "A2A_TUNNEL_ACCESS_CLIENT_SECRET",
35
- "A2A_TUNNEL_TOKEN_FILE",
36
+ "A2A_TUNNEL_TOKEN_FILE", "A2A_TUNNEL_SECRETS_AT",
36
37
  ];
37
38
 
38
39
  // ------------------------------------------------------------------ pure helpers (unit-tested)
@@ -147,7 +148,8 @@ export function secretsPatch(map) {
147
148
  export function connectorInstructions(tokenFile, token = "") {
148
149
  const t = token || `"$(cat ${tokenFile})"`;
149
150
  return [
150
- "Run the connector on the agent's machine (outbound TCP/UDP 7844 to Cloudflare must be allowed):",
151
+ "Install cloudflared on the agent's machine: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/",
152
+ "Run the connector there (outbound TCP/UDP 7844 to Cloudflare must be allowed):",
151
153
  ` cloudflared tunnel run --token-file ${tokenFile} # cloudflared 2025.4+`,
152
154
  ` cloudflared tunnel run --token ${t}`,
153
155
  ` sudo cloudflared service install ${t} # as a system service (starts on boot)`,
@@ -214,11 +216,28 @@ export function listZones(dir) {
214
216
  }
215
217
 
216
218
  // ------------------------------------------------------------------ commands
219
+ /** A working directory for `cf`. A project with `node_modules` keeps its own cache. Otherwise `.cloudflare/cache`
220
+ * in that directory (or a temp directory, when the project is gone) is where `cf` writes its account cache. */
221
+ export function cfWorkDir(preferred) {
222
+ if (preferred && fs.existsSync(path.join(preferred, "node_modules"))) return preferred;
223
+ const base = preferred && fs.existsSync(preferred) ? preferred : fs.mkdtempSync(path.join(os.tmpdir(), "a2a-cf-"));
224
+ fs.mkdirSync(path.join(base, ".cloudflare", "cache"), { recursive: true });
225
+ return base;
226
+ }
227
+
217
228
  function needDeployment(o) {
218
229
  if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
219
230
  const dir = D.workerDir(o);
220
231
  if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
221
- return dir;
232
+ return cfWorkDir(dir);
233
+ }
234
+
235
+ /** `cf` cwd for `tunnel rm` / `uninstall`. A missing Worker project uses a temp directory with a writable cache. */
236
+ 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`);
238
+ const dir = D.workerDir(o);
239
+ if (!fs.existsSync(dir) && !allowMissingProject) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
240
+ return cfWorkDir(dir);
222
241
  }
223
242
 
224
243
  /** Checks that need no Cloudflare calls (also used by `init --tunnel` before anything is deployed). */
@@ -293,7 +312,7 @@ export async function create(o) {
293
312
  console.log(url);
294
313
  console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
295
314
  console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
296
- console.error(`\nThen (Worker secrets apply within ~15 s): ${CLI} tunnel status and ${CLI} wake test`);
315
+ console.error(`\nThen (Worker secrets apply within about 15 to 40 seconds): ${CLI} tunnel status and ${CLI} wake test`);
297
316
  if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
298
317
  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\`)`);
299
318
  } catch (e) {
@@ -363,8 +382,10 @@ async function resume(dir, o) {
363
382
  console.error(`Then: ${CLI} tunnel status and ${CLI} wake test`);
364
383
  }
365
384
 
366
- function putSecrets(dir, worker, map) {
367
- return D.withSecretsFile(secretsPatch(map), (file) => cfCall(dir, ["workers", "secrets", "bulk", "--worker", worker], { file }));
385
+ async function putSecrets(dir, worker, map) {
386
+ const r = await D.withSecretsFile(secretsPatch(map), (file) => cfCall(dir, ["workers", "secrets", "bulk", "--worker", worker], { file }));
387
+ if (Object.values(map).some((v) => v != null)) C.saveConfig({ A2A_TUNNEL_SECRETS_AT: String(Date.now()) });
388
+ return r;
368
389
  }
369
390
 
370
391
  export async function status(o) {
@@ -406,9 +427,10 @@ export async function status(o) {
406
427
  if (!/^HTTP 40[13]/.test(info.probe.withoutToken)) console.error("WARNING: the bare request was not blocked by Access; check the Access application");
407
428
  }
408
429
 
409
- export async function remove(o, { quiet = false } = {}) {
410
- const dir = needDeployment(o);
430
+ export async function remove(o, { quiet = false, allowMissingProject = false } = {}) {
431
+ const dir = cfDirForRemove(o, { allowMissingProject });
411
432
  const worker = D.workerName();
433
+ const label = `a2a-exposed wake ${worker}`;
412
434
  const failures = [];
413
435
  const attempt = async (what, key, fn) => {
414
436
  if (key && !C.get(key)) return;
@@ -417,22 +439,27 @@ export async function remove(o, { quiet = false } = {}) {
417
439
  };
418
440
  const host = C.get("A2A_TUNNEL_HOSTNAME");
419
441
  // 1. Worker secrets first, so no wake is sent to a half-removed hostname. WAKE_WEBHOOK_URL only if it is ours.
420
- if (host) await attempt("Worker wake secrets (Access token, tunnel URL)", null, async () => {
421
- const patch = { WAKE_ACCESS_CLIENT_ID: null, WAKE_ACCESS_CLIENT_SECRET: null };
442
+ if (host) {
443
+ const names = ["WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"];
422
444
  let ours = false;
423
445
  try { const p = await owner("GET", "/owner/wake/preview"); ours = p.fingerprints?.url === fingerprint(wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/")); }
424
446
  catch { /* Worker unreachable: leave the URL */ }
425
- if (ours) patch.WAKE_WEBHOOK_URL = null;
426
- await putSecrets(dir, worker, patch);
427
- });
447
+ if (ours) names.push("WAKE_WEBHOOK_URL");
448
+ await attempt(`Worker secrets for ${host} (${names.join(", ")})`, null, async () => {
449
+ await putSecrets(dir, worker, Object.fromEntries(names.map((k) => [k, null])));
450
+ });
451
+ }
428
452
  // 2. DNS before Access, so the hostname never resolves without its Access app
429
- await attempt("DNS record", "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 }));
430
- await attempt("tunnel", "A2A_TUNNEL_ID", () => {
431
- cfCall(dir, ["tunnels", "connections", "cleanup", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
432
- cfCall(dir, ["tunnels", "delete", C.get("A2A_TUNNEL_ID"), "--force"], { allowNotFound: true });
453
+ 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 }));
454
+ await attempt(`tunnel "${label}"`, "A2A_TUNNEL_ID", () => {
455
+ const id = C.get("A2A_TUNNEL_ID");
456
+ // Already deleted (a previous rm stopped on the confirm prompt, or it was removed by hand): clear the saved id.
457
+ if (!cfCall(dir, ["tunnels", "get", id], { allowNotFound: true })) return;
458
+ cfCall(dir, ["tunnels", "connections", "cleanup", id, "--force"], { allowNotFound: true });
459
+ cfCall(dir, ["tunnels", "delete", id, "--force"], { allowNotFound: true });
433
460
  });
434
- await attempt("Access application (and its policy)", "A2A_TUNNEL_ACCESS_APP_ID", () => cfCall(dir, ["zero-trust", "access", "applications", "delete", C.get("A2A_TUNNEL_ACCESS_APP_ID"), "--force"], { allowNotFound: true }));
435
- await attempt("Access service token", "A2A_TUNNEL_ACCESS_TOKEN_ID", () => {
461
+ await attempt(`Access application "${label}"`, "A2A_TUNNEL_ACCESS_APP_ID", () => cfCall(dir, ["zero-trust", "access", "applications", "delete", C.get("A2A_TUNNEL_ACCESS_APP_ID"), "--force"], { allowNotFound: true }));
462
+ await attempt(`Access service token "${label}"`, "A2A_TUNNEL_ACCESS_TOKEN_ID", () => {
436
463
  cfCall(dir, ["zero-trust", "access", "service-tokens", "delete", C.get("A2A_TUNNEL_ACCESS_TOKEN_ID"), "--force"], { allowNotFound: true });
437
464
  C.saveConfig({ A2A_TUNNEL_ACCESS_CLIENT_ID: null, A2A_TUNNEL_ACCESS_CLIENT_SECRET: null });
438
465
  });
@@ -0,0 +1,48 @@
1
+ // `uninstall`: remove the wake tunnel (when one is saved), the Worker, and the D1 database for this config.
2
+ import readline from "node:readline/promises";
3
+ import { CliError } from "./a2a.mjs";
4
+ import * as C from "./config.mjs";
5
+ import * as D from "./deploy.mjs";
6
+ import * as T from "./tunnel.mjs";
7
+
8
+ const step = (s) => console.error(`==> ${s}`);
9
+
10
+ /** Ask before deleting. On a terminal, wait for y or yes unless `yes` is set. Off a terminal, `--yes` is required. */
11
+ export async function confirmUninstall(yes, input = process.stdin, output = process.stderr) {
12
+ if (yes) return;
13
+ if (!input.isTTY) throw new CliError("uninstall removes the Worker, the D1 database, and the wake tunnel. Pass --yes.");
14
+ const rl = readline.createInterface({ input, output });
15
+ try {
16
+ const answer = (await rl.question("Remove the Worker, the D1 database, and the wake tunnel? [y/N] ")).trim().toLowerCase();
17
+ if (answer !== "y" && answer !== "yes") throw new CliError("uninstall cancelled");
18
+ } finally {
19
+ rl.close();
20
+ }
21
+ }
22
+
23
+ const kept = () => `config directory kept at ${C.CONFIG_DIR} (owner token and peer tokens). Remove that directory to delete them.`;
24
+
25
+ /** One line per removal. A 404 counts as already gone. The config directory stays. */
26
+ export async function uninstall(o) {
27
+ 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
+
32
+ const name = C.get("A2A_WORKER_NAME");
33
+ const d1 = C.get("A2A_D1_ID");
34
+ const dir = name || d1 ? T.cfWorkDir(D.workerDir(o)) : "";
35
+ 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");
39
+ 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}`);
42
+ } else step("no D1 database id saved");
43
+ C.saveConfig({
44
+ A2A_WORKER_NAME: null, A2A_D1_ID: null, A2A_D1_NAME: null, A2A_BASE_URL: null, A2A_HOSTNAME: null,
45
+ A2A_WORKER_DIR: null, A2A_WORKERS_DEV_SUBDOMAIN: null,
46
+ });
47
+ console.error(kept());
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ const v = (k: string) => (e[k] ?? "").trim();
11
11
  const name = v("A2A_WORKER_NAME") || "a2a-exposed";
12
12
  const hostname = v("A2A_HOSTNAME"); // e.g. agent.example.com (on a zone in your account)
13
13
  // No hostname: serve on <name>.<account subdomain>.workers.dev instead of a custom domain (and the other way round:
14
- // with a hostname, the workers.dev route is switched off).
14
+ // with a hostname, the workers.dev route is switched off). Preview URLs follow that same switch.
15
15
  const workersDev = !hostname;
16
16
  const wdSubdomain = v("A2A_WORKERS_DEV_SUBDOMAIN"); // the account's workers.dev subdomain, once known
17
17
  // Public base URL for the agent card, derived only from where the Worker is served (no override: a local webhook,
@@ -112,6 +112,7 @@ export default defineConfig((ctx) => ({
112
112
  ...(hosted ? { exports: { TenantStore: exports.durableObject({ storage: "sqlite" }) } } : {}),
113
113
  ...(hostname ? { domains: [hostname] } : {}),
114
114
  workersDev,
115
+ previewUrls: workersDev,
115
116
  // Optional persisted Workers Logs (A2A_WORKERS_LOGS=1, `init|deploy --workers-logs`): the JSON event lines plus
116
117
  // invocation logs with CPU time. Query strings are redacted, so one-time setup links never reach the logs.
117
118
  ...(v("A2A_WORKERS_LOGS") === "1" ? { observability: { enabled: true, redactQueryString: true, logs: { enabled: true, invocationLogs: true } } } : {}),
@@ -6,7 +6,7 @@
6
6
  // context is the single self-host tenant and behaviour matches a Worker that read env.* directly.
7
7
  import * as A from "./a2a.ts";
8
8
  import * as F from "./facade.ts";
9
- import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
9
+ import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
10
10
  import { fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
@@ -142,7 +142,9 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
142
142
  r = await doFetch(ctx, ectx, req.url, { method: "POST", headers: req.headers, body: req.body });
143
143
  }
144
144
  // explain Cloudflare edge errors (tunnel connector down, Access block) without echoing the body
145
- const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096));
145
+ const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096), {
146
+ tunnel: !!(ctx.WAKE_ACCESS_CLIENT_ID && ctx.WAKE_ACCESS_CLIENT_SECRET) || wakeUrlIsTunnelHost(req.url),
147
+ });
146
148
  log("wake_sent", { preset: preset(ctx), contextId: ev.contextId, taskId: ev.taskId, status: r.status, ...(hint ? { hint } : {}) });
147
149
  if (r.status === 429 || r.status === 503) await requeueWake(ctx, ectx, payload, r.headers.get("retry-after"));
148
150
  return { status: r.status, info: `HTTP ${r.status}${hint ? ` (${hint})` : ""}` };
@@ -440,7 +442,7 @@ async function fetchUpstreamCard(ctx: TenantContext, fresh = false): Promise<{ c
440
442
  cardFailures.delete(url);
441
443
  return { card, status: 200, url };
442
444
  }
443
- const hint = cloudflareErrorHint(r.status, text.slice(0, 4096));
445
+ const hint = cloudflareErrorHint(r.status, text.slice(0, 4096), { tunnel: true });
444
446
  log("upstream_card_failed", { status: r.status, ...(hint ? { hint } : {}) });
445
447
  return fail(r.status, `HTTP ${r.status}${hint ? ` (${hint})` : ""}`);
446
448
  } catch (e) {
@@ -1385,6 +1387,16 @@ const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, client
1385
1387
  agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
1386
1388
  replacesLabel: r.replaces_label || undefined });
1387
1389
 
1390
+ /** The label approval will assign: the live token being replaced, otherwise the next free label. */
1391
+ async function withTokenLabel(ctx: TenantContext, r: Json): Promise<P.PageRequest> {
1392
+ const base = pageRequest(r);
1393
+ if (r.replaces_label && r.replaces_hash) {
1394
+ const live = await ctx.DB.prepare("SELECT 1 FROM peers WHERE label = ? AND token_hash = ? AND revoked_at IS NULL").bind(r.replaces_label, r.replaces_hash).first();
1395
+ if (live) return { ...base, tokenLabel: String(r.replaces_label) };
1396
+ }
1397
+ return { ...base, tokenLabel: await freeLabel(ctx, P.labelBase(r.client_name || "", r.client_id || "")) };
1398
+ }
1399
+
1388
1400
  /** Optional page fields. Omitted when they would not change today's HTML. */
1389
1401
  function approvalPageBits(ctx: TenantContext): { oidc?: { label: string }; passwordEnabled?: boolean } {
1390
1402
  const p = O.approvalPolicy(ctx.gates.approvalOidc);
@@ -1442,7 +1454,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
1442
1454
  }
1443
1455
  const code = P.normUserCode(raw);
1444
1456
  const r = code ? await pendingRequest(ctx, code) : null;
1445
- if (r && r.status === "pending" && Date.now() < r.expires_ms) model.request = pageRequest(r);
1457
+ if (r && r.status === "pending" && Date.now() < r.expires_ms) model.request = await withTokenLabel(ctx, r);
1446
1458
  else {
1447
1459
  model.askCode = true;
1448
1460
  model.codeValue = raw || "";
@@ -1549,14 +1561,14 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
1549
1561
  if (!seen.ok) {
1550
1562
  if (seen.userCode) {
1551
1563
  const pending = await pendingRequest(ctx, seen.userCode);
1552
- if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = pageRequest(pending);
1564
+ if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = await withTokenLabel(ctx, pending);
1553
1565
  }
1554
1566
  model.notice = { kind: "error", text: seen.error };
1555
1567
  return send(400);
1556
1568
  }
1557
1569
  const pending = seen.userCode ? await pendingRequest(ctx, seen.userCode) : null;
1558
1570
  if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) {
1559
- model.request = pageRequest(pending);
1571
+ model.request = await withTokenLabel(ctx, pending);
1560
1572
  return send(200);
1561
1573
  }
1562
1574
  model.notice = { kind: "error", text: "Nothing was approved." };
@@ -1567,7 +1579,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
1567
1579
  log("oidc_rejected", { kind: "device", reason: done.reason });
1568
1580
  if (done.userCode) {
1569
1581
  const pending = await pendingRequest(ctx, done.userCode);
1570
- if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = pageRequest(pending);
1582
+ if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = await withTokenLabel(ctx, pending);
1571
1583
  }
1572
1584
  model.notice = { kind: "error", text: done.error };
1573
1585
  return send(400);
@@ -173,7 +173,7 @@ export async function verifyPassword(password: string, rec: PasswordRecord): Pro
173
173
  // ------------------------------------------------------------------ /device page (no scripts, no external assets)
174
174
  export const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
175
175
 
176
- export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string };
176
+ export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string };
177
177
  export type PageModel = {
178
178
  agentName: string;
179
179
  mode: Mode;
@@ -197,13 +197,14 @@ export function devicePage(m: PageModel, nonce: string): string {
197
197
  ["Agent (as it calls itself)", r.clientName || r.clientId || "(no name given)"],
198
198
  ...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
199
199
  ["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this inbox can't check it; informational only)" : "") : "(none given)"],
200
- ["Requested from", [r.ip || "unknown address", r.country].filter(Boolean).join(", ")],
200
+ ["Requested from", `${[r.ip || "unknown address", r.country].filter(Boolean).join(", ")} (IP address of the machine that asked to connect)`],
201
201
  ["Expires", `${Math.max(0, Math.round((r.expiresMs - Date.now()) / 60000))} min`],
202
202
  ...(r.replacesLabel ? [["Replaces", `the active token "${r.replacesLabel}" (the requester proved it holds it); approving issues a new token under the same label and the old one stops working`]] : []),
203
203
  ] : [];
204
204
  const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
205
+ const revoke = r?.tokenLabel ? `${m.cli} token revoke ${r.tokenLabel}` : `${m.cli} token revoke`;
205
206
  const details = r ? `<table>${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join("")}</table>
206
- <p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(m.cli)} token revoke &lt;label&gt;</code>.</p>` : "";
207
+ <p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(revoke)}</code>.</p>` : "";
207
208
  const noPassword = `<p class="n info">No approval password is set yet. Easiest: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and send you the one-time link, then set the password on that page. Or run <code>${esc(m.cli)} pair set-password</code> yourself in a terminal on the owner's machine. Then reload this page. You can deny without a password.</p>`;
208
209
  const denyOnly = `<form method="post" action="/device">
209
210
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(r ? formatUserCode(r.userCode) : "")}">
@@ -294,11 +295,11 @@ export function setupPage(m: SetupModel, nonce: string): string {
294
295
  ? `<p>An approval password is already set (since ${esc(m.passwordSetAt)}). Saving here replaces it.</p>`
295
296
  : `<p>Choose the password you will use to approve agents that ask to connect to this inbox.</p>`;
296
297
  return pageShell(`Approval password for ${m.agentName}`, nonce, `${notice}${intro}
297
- <p class="w">Only you should know it: don't share it with your agent or anyone else. At least ${MIN_PASSWORD_LENGTH} characters; a passphrase of a few words works well. This one-time link expires in ${mins} min.</p>
298
+ <p class="w">Only you should know it: don't share it with your agent or anyone else. Use ${MIN_PASSWORD_LENGTH} to 1024 characters. A passphrase of several unrelated words is stronger than one short word. This one-time link expires in ${mins} min.</p>
298
299
  <form method="post" action="/device/setup">
299
300
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="t" value="${esc(m.token)}">
300
301
  <label for="pw1">New approval password</label><input id="pw1" name="password" type="password" autocomplete="new-password" required minlength="${MIN_PASSWORD_LENGTH}" maxlength="1024" autofocus>
301
- <label for="pw2">Repeat it</label><input id="pw2" name="password2" type="password" autocomplete="new-password" required minlength="${MIN_PASSWORD_LENGTH}" maxlength="1024">
302
+ <label for="pw2">Repeat the password</label><input id="pw2" name="password2" type="password" autocomplete="new-password" required minlength="${MIN_PASSWORD_LENGTH}" maxlength="1024">
302
303
  <div class="b"><button type="submit">Set approval password</button></div>
303
304
  </form>`);
304
305
  }
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.3";
2
+ export const VERSION = "0.8.5";
@@ -236,12 +236,20 @@ export function cloudflareErrorCode(body: string): string {
236
236
  return m ? m[1] || m[2] || m[3] || m[4] : "";
237
237
  }
238
238
 
239
- /** Short explanation of a Cloudflare edge error page (e.g. "error code: 1033"), for wake test output. */
240
- export function cloudflareErrorHint(status: number, body: string): string {
239
+ /** True when the wake URL's host is a Cloudflare Tunnel hostname (`*.cfargotunnel.com`). */
240
+ export function wakeUrlIsTunnelHost(url: string): boolean {
241
+ try { return /\.cfargotunnel\.com$/i.test(new URL(url).hostname); } catch { return false; }
242
+ }
243
+
244
+ /** Short explanation of a Cloudflare edge error page (e.g. "error code: 1033"), for wake test output.
245
+ * `tunnel` selects the 1016 wording: a tunnel hostname's DNS record, or a webhook host that failed to resolve. */
246
+ export function cloudflareErrorHint(status: number, body: string, opts?: { tunnel?: boolean }): string {
241
247
  const code = cloudflareErrorCode(body);
242
248
  const known: Record<string, string> = {
243
249
  "1033": "Cloudflare Tunnel has no running connector (start cloudflared on the agent's machine)",
244
- "1016": "origin DNS error (the tunnel hostname has no matching DNS record)",
250
+ "1016": opts?.tunnel
251
+ ? "origin DNS error (the tunnel hostname has no matching DNS record)"
252
+ : "origin DNS error (the webhook host failed to resolve)",
245
253
  "1010": "blocked by a Cloudflare security rule",
246
254
  };
247
255
  if (code) return `Cloudflare error ${code}${known[code] ? ": " + known[code] : ""}`;
@@ -290,6 +290,9 @@ test("human approval: pending, slow_down, page approve with the password, single
290
290
  assert.equal(g.status, 200);
291
291
  assert.ok(!g.text.includes("<script>") && g.text.includes("Barry &lt;script&gt;"));
292
292
  assert.ok(g.text.includes("https://barry.example.org/.well-known/agent-card.json") && g.text.includes("198.51.100.7"));
293
+ assert.match(g.text, /198\.51\.100\.7 \(IP address of the machine that asked to connect\)/);
294
+ assert.match(g.text, /token revoke Barry-script-alert-1-scr/);
295
+ assert.ok(!g.text.includes("&lt;label&gt;"));
293
296
  assert.match(g.headers.get("content-security-policy")!, /^default-src 'none'; style-src 'nonce-[A-Za-z0-9_-]+'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'$/);
294
297
  assert.equal(g.headers.get("cache-control"), "no-store");
295
298
  assert.equal(g.headers.get("x-frame-options"), "DENY");
@@ -660,6 +663,9 @@ test("password setup link: one-time page sets the password (hashed on the Worker
660
663
  assert.ok(!/<script|src=/i.test(get.text), "no scripts or external assets");
661
664
  assert.match(get.text, /name="password" type="password" autocomplete="new-password" required minlength="12"/);
662
665
  assert.match(get.text, /name="password2"/);
666
+ assert.match(get.text, /Repeat the password/);
667
+ assert.match(get.text, /Use 12 to 1024 characters/);
668
+ assert.match(get.text, /passphrase of several unrelated words/);
663
669
  assert.match(get.text, /expires in 15 min/);
664
670
  assert.equal(post.status, 200, post.text);
665
671
  assert.match(post.text, /Approval password set \(\d{4}-\d\d-\d\dT[^)]+\)/);
@@ -173,5 +173,8 @@ test("cloudflareErrorHint explains tunnel / Access failures", () => {
173
173
  assert.match(cloudflareErrorHint(530, '<script>a={event:"feedback clicked",properties:{errorCode: 1033 }}</script>'), /1033/);
174
174
  assert.match(cloudflareErrorHint(403, "Forbidden. You don't have access. cloudflareaccess.com"), /Cloudflare Access/);
175
175
  assert.equal(cloudflareErrorHint(500, "boom"), "");
176
+ assert.match(cloudflareErrorHint(530, "error code: 1016"), /webhook host failed to resolve/);
177
+ assert.doesNotMatch(cloudflareErrorHint(530, "error code: 1016"), /tunnel hostname/);
178
+ assert.match(cloudflareErrorHint(530, "error code: 1016", { tunnel: true }), /tunnel hostname has no matching DNS record/);
176
179
  });
177
180