a2a-exposed 0.4.0 → 0.4.1

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/a2a.mjs CHANGED
@@ -185,3 +185,70 @@ export async function rpc(url, version, token, method03, method1, params, peer =
185
185
  die(`peer returned ${data && typeof data === "object" && data.error ? `JSON-RPC error ${describeHttp(status, data).replace(/^HTTP \d+: /, "")} (HTTP ${status})` : describeHttp(status, data)}`);
186
186
  return data.result;
187
187
  }
188
+
189
+ // ---------------------------------------------------------------- proxy mode: what the upstream card asks for
190
+ // Same rules as the Worker's facade.ts (schemeKind / upstreamAuth); keep the two in step.
191
+
192
+ /** "bearer" (HTTP bearer; OAuth 2.0 / OpenID Connect also send `Authorization: Bearer`), "basic", "http", "apiKey",
193
+ * "mtls" or "unknown", for an A2A 1.0 ({ httpAuthSecurityScheme: ... }) or 0.3 / OpenAPI ({ type: "http", ... }) scheme. */
194
+ export function schemeKind(s) {
195
+ if (!s || typeof s !== "object") return "unknown";
196
+ const http = (scheme) => { const v = String(scheme || "").toLowerCase(); return v === "bearer" ? "bearer" : v === "basic" ? "basic" : "http"; };
197
+ if (s.httpAuthSecurityScheme) return http(s.httpAuthSecurityScheme.scheme);
198
+ if (s.oauth2SecurityScheme || s.openIdConnectSecurityScheme) return "bearer";
199
+ if (s.apiKeySecurityScheme) return "apiKey";
200
+ if (s.mtlsSecurityScheme || s.mutualTlsSecurityScheme) return "mtls";
201
+ switch (String(s.type || "").toLowerCase()) {
202
+ case "http": return http(s.scheme);
203
+ case "oauth2": case "openidconnect": return "bearer";
204
+ case "apikey": return "apiKey";
205
+ case "mutualtls": return "mtls";
206
+ }
207
+ return "unknown";
208
+ }
209
+
210
+ /** { bearer: true | false | null, schemes: ["name: kind"], unsupported: [...] } for an upstream card. bearer: true when
211
+ * the card asks for a bearer token (UPSTREAM_TOKEN), false when it declares no bearer / HTTP auth (no schemes,
212
+ * anonymous allowed, or only schemes the façade can't present), null without a card. Requirements come from 1.0
213
+ * `securityRequirements` or 0.3 `security`; without requirements every declared scheme counts. */
214
+ export function upstreamAuth(card) {
215
+ if (!card || typeof card !== "object" || Array.isArray(card)) return { bearer: null, schemes: [], unsupported: [] };
216
+ const defs = card.securitySchemes && typeof card.securitySchemes === "object" && !Array.isArray(card.securitySchemes) ? card.securitySchemes : {};
217
+ const kinds = {};
218
+ for (const [n, s] of Object.entries(defs)) kinds[n] = schemeKind(s);
219
+ const schemes = Object.entries(kinds).map(([n, k]) => `${n}: ${k}`);
220
+ const raw = Array.isArray(card.securityRequirements) ? card.securityRequirements : Array.isArray(card.security) ? card.security : [];
221
+ const alts = raw.filter((r) => r && typeof r === "object" && !Array.isArray(r))
222
+ .map((r) => Object.keys(r.schemes && typeof r.schemes === "object" && !Array.isArray(r.schemes) ? r.schemes : r));
223
+ const kindOf = (n) => kinds[n] || "unknown";
224
+ if (!alts.length) {
225
+ const all = Object.keys(kinds);
226
+ const bearer = all.some((n) => kinds[n] === "bearer");
227
+ return { bearer, schemes, unsupported: bearer ? [] : all.filter((n) => kinds[n] !== "unknown").map((n) => `${n}: ${kinds[n]}`) };
228
+ }
229
+ if (alts.some((a) => a.length === 0)) return { bearer: false, schemes, unsupported: [] };
230
+ const bearer = alts.some((a) => a.some((n) => kindOf(n) === "bearer"));
231
+ return { bearer, schemes, unsupported: bearer ? [] : [...new Set(alts.flat())].map((n) => `${n}: ${kindOf(n)}`) };
232
+ }
233
+
234
+ /** Fetch the upstream's card from this machine (setup check), with the Access service token from the environment
235
+ * when exported; only ever to the upstream origin (the caller checked it). Never sends UPSTREAM_TOKEN. Returns
236
+ * { card } or { card: null, error }. Redirects are not followed (an Access login redirect is reported as such). */
237
+ export async function fetchUpstreamCard(cardUrl, env = process.env) {
238
+ const headers = { accept: "application/json", "user-agent": "a2a-exposed-cli" };
239
+ if (env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET) {
240
+ headers["cf-access-client-id"] = env.UPSTREAM_ACCESS_CLIENT_ID;
241
+ headers["cf-access-client-secret"] = env.UPSTREAM_ACCESS_CLIENT_SECRET;
242
+ }
243
+ let r;
244
+ try { r = await fetch(cardUrl, { headers, redirect: "manual", signal: AbortSignal.timeout(15000) }); }
245
+ catch (e) { return { card: null, error: `request failed: ${e?.cause?.code || e?.cause?.message || e?.message || e}` }; }
246
+ const text = (await r.text().catch(() => "")).slice(0, 262144);
247
+ const loc = r.headers.get("location") || "";
248
+ if (r.status >= 300 && r.status < 400)
249
+ return { card: null, error: /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(loc) ? `HTTP ${r.status}: redirected to the Cloudflare Access login (export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET so this check can pass Access)` : `HTTP ${r.status} redirect` };
250
+ let data = null;
251
+ try { data = JSON.parse(text); } catch { /* not JSON */ }
252
+ if (r.status === 200 && data && typeof data === "object" && !Array.isArray(data)) return { card: data };
253
+ return { card: null, error: describeHttp(r.status, data ?? text) };
254
+ }
package/lib/config.mjs CHANGED
@@ -1,14 +1,12 @@
1
- // Config storage: ~/.config/a2a-exposed/config.env (chmod 600) + peers.json. Before the rename the directory was
2
- // ~/.config/a2a-over-webhook: when only that one exists it is used as is (nothing is moved or copied).
1
+ // Config storage: ~/.config/a2a-exposed/config.env (chmod 600) + peers.json.
3
2
  // Environment variables always override values from config.env.
4
3
  import fs from "node:fs";
5
4
  import os from "node:os";
6
5
  import path from "node:path";
7
6
 
8
- /** <base>/a2a-exposed, or the pre-rename <base>/a2a-over-webhook when only that exists (an existing deployment). */
9
- export function defaultConfigDir(base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), exists = fs.existsSync) {
10
- const dir = path.join(base, "a2a-exposed"), legacy = path.join(base, "a2a-over-webhook");
11
- return !exists(dir) && exists(legacy) ? legacy : dir;
7
+ /** <base>/a2a-exposed (base: $XDG_CONFIG_HOME or ~/.config). */
8
+ export function defaultConfigDir(base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")) {
9
+ return path.join(base, "a2a-exposed");
12
10
  }
13
11
 
14
12
  export const CONFIG_DIR = process.env.A2A_CONFIG_DIR || defaultConfigDir();
package/lib/deploy.mjs CHANGED
@@ -5,8 +5,9 @@ import path from "node:path";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import * as C from "./config.mjs";
8
- import { cardUrlProblem, cfErrorCode, die, fingerprint, httpJson, randomToken, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
9
- import { owner } from "./commands.mjs";
8
+ import { cardUrlProblem, cfErrorCode, die, fetchUpstreamCard, fingerprint, httpJson, randomToken, upstreamAuth, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
9
+ import { owner, ownerTry } from "./commands.mjs";
10
+ import { readHidden } from "./pair.mjs";
10
11
  import * as WD from "./workersdev.mjs";
11
12
 
12
13
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -43,10 +44,8 @@ export const PAIRING_MODES = ["human", "agent", "off"];
43
44
  export const DEPLOY_FLAGS = Object.keys(FLAG_KEYS);
44
45
 
45
46
  const step = (s) => console.error(`==> ${s}`);
46
- // Worker (and default D1) name for new deployments. A deployment made before the rename that has no saved
47
- // A2A_WORKER_NAME (a hand-written config.env) keeps the old default, so `deploy` never creates a second Worker.
48
- export const LEGACY_WORKER_NAME = "a2a-over-webhook";
49
- export const workerName = () => C.get("A2A_WORKER_NAME") || (C.get("A2A_D1_ID") ? LEGACY_WORKER_NAME : "a2a-exposed");
47
+ // Worker (and default D1) name: A2A_WORKER_NAME (saved by init), else a2a-exposed.
48
+ export const workerName = () => C.get("A2A_WORKER_NAME") || "a2a-exposed";
50
49
  /** No custom hostname: the Worker is served on <worker>.<account subdomain>.workers.dev (and only there). */
51
50
  const workersDevMode = () => !C.get("A2A_HOSTNAME");
52
51
  export const publicBase = () =>
@@ -63,6 +62,9 @@ export const NODE_HELP = `How to get Node 22 (any one; no root needed):
63
62
  or the official installer / binaries: https://nodejs.org/en/download
64
63
  Then check: node --version (v22.18.0 or newer)`;
65
64
 
65
+ /** What to do when the Cloudflare login itself is blocked (printed with "not logged in"; the setup skill says the same). */
66
+ export const LOGIN_403_HELP = `If \`cf auth login\` (or \`wrangler login\`) fails with "OAuth error: HTTP 403 Forbidden" or a "Just a moment..." page BEFORE any code is shown, that is Cloudflare's bot mitigation for datacenter / VPS IPs, not a wrong account: don't retry. Use an API token instead: export CLOUDFLARE_API_TOKEN (and CLOUDFLARE_ACCOUNT_ID) in the environment, never argv (setup skill, Troubleshooting: API token).`;
67
+
66
68
  /** Dies unless `version` (default: this Node) is 22.18+; the message says how to get it. */
67
69
  export function checkNode(version = process.versions.node) {
68
70
  const [maj, min] = String(version).replace(/^v/, "").split(".").map(Number);
@@ -199,7 +201,11 @@ function applyFlags(o) {
199
201
  if (upd.WAKE_PRESET && !PRESETS.includes(upd.WAKE_PRESET)) die(`unknown preset ${upd.WAKE_PRESET} (${PRESETS.join(" | ")})`);
200
202
  for (const [flag, key] of [["upstream", "A2A_UPSTREAM_URL"], ["upstream-card-url", "A2A_UPSTREAM_CARD_URL"]]) {
201
203
  if (upd[key] === undefined) continue;
202
- if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) { upd[key] = null; continue; }
204
+ if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) {
205
+ upd[key] = null;
206
+ if (key === "A2A_UPSTREAM_URL") upd.A2A_UPSTREAM_NO_TOKEN = null; // the opt-out belonged to that upstream
207
+ continue;
208
+ }
203
209
  const why = upstreamUrlProblem(String(upd[key]).trim());
204
210
  if (why) die(`--${flag} ${why}`);
205
211
  upd[key] = new URL(String(upd[key]).trim()).href;
@@ -346,7 +352,9 @@ function deployedBase() {
346
352
 
347
353
  export async function init(o) {
348
354
  checkNode();
355
+ const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
349
356
  const switched = applyFlags(o);
357
+ const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
350
358
  const dir = workerDir(o);
351
359
  C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName() });
352
360
  if (workersDevMode()) {
@@ -361,11 +369,15 @@ export async function init(o) {
361
369
  const prof = cfProfile();
362
370
  step(`checking Cloudflare login (cf auth whoami${prof ? ` --profile ${prof}` : ""})`);
363
371
  const who = cfJson(dir, ["auth", "whoami"]);
364
- if (!who.authenticated) die(prof
372
+ if (!who.authenticated) die((process.env.CLOUDFLARE_API_TOKEN
373
+ ? "CLOUDFLARE_API_TOKEN is set but cf does not accept it (expired, revoked, or IP-restricted: an IP filter must allow both this host's IPv4 /32 and IPv6 /128). Check the token (setup skill, Troubleshooting: API token), then re-run init"
374
+ : prof
365
375
  ? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
366
- : "not logged in to Cloudflare: run `cf auth login --no-browser`, open the printed URL, enter the code, then re-run init");
376
+ : "not logged in to Cloudflare: run `cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
367
377
  const accts = who.accounts || [];
368
378
  if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
379
+ if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
380
+ die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
369
381
  if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
370
382
  C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
371
383
  } else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
@@ -391,30 +403,102 @@ export async function init(o) {
391
403
  if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
392
404
  C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
393
405
 
394
- const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv() };
406
+ const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv(), ...upstreamSecrets };
395
407
  step(`deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})`);
396
408
  await deployAndLearn(dir, secrets, o);
397
409
  const base = deployedBase();
398
410
  if (base) await verifyDeployedCard(base);
399
411
  console.log(base);
400
412
  reportSwitch(switched);
401
- proxyNote();
413
+ proxyNote(secrets);
402
414
  console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: a2a-exposed status (shows the next setup step)`);
403
415
  }
404
416
 
405
- /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their presence is shown). */
406
- function proxyNote() {
417
+ /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
418
+ function proxyNote(secrets = {}) {
407
419
  const up = C.get("A2A_UPSTREAM_URL");
408
420
  if (!up) return;
409
- const sent = UPSTREAM_SECRETS.filter((k) => process.env[k]);
421
+ const sent = UPSTREAM_SECRETS.filter((k) => k in secrets);
410
422
  console.error(`proxy mode: authenticated A2A calls are forwarded to ${up}; the public card is the upstream's, rewritten to this deployment's URL.` +
411
- ` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the upstream and the card with: a2a-exposed status`);
423
+ ` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with: a2a-exposed status`);
424
+ }
425
+
426
+ /** stdin and stderr are a terminal (a human at the keyboard), and not CI: only then may setup prompt. */
427
+ const interactive = () => !!(process.stdin.isTTY && process.stderr.isTTY && !process.env.CI);
428
+
429
+ /** Proxy mode setup check: the façade must hold the bearer token the upstream agent checks (UPSTREAM_TOKEN) in addition
430
+ * to the Cloudflare Access service token, which only gets the Worker through the tunnel. Returns extra secrets to
431
+ * upload ({ UPSTREAM_TOKEN } when read from stdin or a hidden prompt), or dies with what to do. Never reads argv.
432
+ * Order: --upstream-token-stdin / exported UPSTREAM_TOKEN (uploaded) > --no-upstream-token (saved opt-out) > the
433
+ * Worker already holds one for this upstream, or the card it fetched declares no bearer > the upstream card fetched from
434
+ * here (with the exported Access token): no bearer declared -> fine; bearer or unknown -> hidden prompt on a terminal,
435
+ * else exit 1. */
436
+ export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
437
+ const up = C.get("A2A_UPSTREAM_URL");
438
+ if (!up) return {};
439
+ if (o["upstream-token-stdin"] && o["no-upstream-token"]) die("--upstream-token-stdin and --no-upstream-token are mutually exclusive");
440
+ if (o["upstream-token-stdin"]) {
441
+ const t = (process.stdin.isTTY ? await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown): ") : fs.readFileSync(0, "utf8")).trim();
442
+ if (!t) die("--upstream-token-stdin: no token on stdin");
443
+ C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null });
444
+ return { UPSTREAM_TOKEN: t };
445
+ }
446
+ if (process.env.UPSTREAM_TOKEN) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return {}; } // uploaded with the other exported secrets
447
+ if (o["no-upstream-token"]) {
448
+ C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
449
+ console.error("note: --no-upstream-token: deploying without an upstream bearer token (saved; `status` warns if the upstream card asks for one)");
450
+ return {};
451
+ }
452
+ if (C.get("A2A_UPSTREAM_NO_TOKEN") === "1") return {};
453
+ // an existing façade for this upstream: the Worker's secrets persist across deploys, and it reads the card through Access
454
+ if (!upstreamChanged && C.get("A2A_BASE_URL") && C.get("A2A_OWNER_TOKEN")) {
455
+ try {
456
+ const r = await ownerTry("GET", "/owner/facade");
457
+ if (r.status === 200 && r.data && r.data.mode === "proxy") {
458
+ if (r.data.hasUpstreamToken) return {};
459
+ if (r.data.upstreamAuth && r.data.upstreamAuth.bearer === false) return {};
460
+ }
461
+ } catch { /* not deployed yet / unreachable: check the card from here */ }
462
+ }
463
+ const cardUrl = C.get("A2A_UPSTREAM_CARD_URL") || new URL("/.well-known/agent-card.json", up).href;
464
+ step(`checking which credential the upstream's agent card asks for (${cardUrl})`);
465
+ const { card, error } = await fetchUpstreamCard(cardUrl);
466
+ const auth = upstreamAuth(card);
467
+ if (auth.unsupported.length)
468
+ console.error(`warning: the upstream card requires ${auth.unsupported.join(", ")}; the façade only presents \`Authorization: Bearer <UPSTREAM_TOKEN>\` (plus the Access service token), so the agent may refuse it`);
469
+ if (auth.bearer === false) {
470
+ console.error(`upstream card: no bearer auth declared${auth.schemes.length ? ` (schemes: ${auth.schemes.join(", ")})` : ""}, so no UPSTREAM_TOKEN is needed`);
471
+ return {};
472
+ }
473
+ const why = auth.bearer
474
+ ? `the upstream's agent card asks for a bearer token (${auth.schemes.join(", ")})`
475
+ : `the upstream's agent card could not be read from here (${error}), so whether the agent needs a bearer token is unknown`;
476
+ const access = "Cloudflare Access (UPSTREAM_ACCESS_CLIENT_ID / _SECRET) only lets the Worker through the tunnel; the agent checks its own token too";
477
+ if (interactive()) {
478
+ console.error(`${why}. ${access}.`);
479
+ const t = (await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown; empty = the agent needs none): ")).trim();
480
+ if (t) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return { UPSTREAM_TOKEN: t }; }
481
+ if (auth.bearer) die("no UPSTREAM_TOKEN given, but the upstream card asks for one: peers' calls would fail with HTTP 502. Re-run and enter it, or pass --no-upstream-token if the agent really accepts calls without it");
482
+ C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
483
+ console.error("note: deploying without an upstream bearer token (saved; check the whole path with `a2a-exposed status`)");
484
+ return {};
485
+ }
486
+ die(`${why}, and no UPSTREAM_TOKEN is set. ${access}, so without it every peer call fails with HTTP 502.\n` +
487
+ " Provide it (never as an argument): `read -rs UPSTREAM_TOKEN && export UPSTREAM_TOKEN`, then re-run; or pipe it: <command that prints the token> | a2a-exposed " +
488
+ `${C.get("A2A_D1_ID") ? "deploy" : "init ..."} --upstream-token-stdin\n` +
489
+ " The agent needs no bearer token? Re-run with --no-upstream-token (saved for later deploys).");
412
490
  }
413
491
 
414
492
  export async function deploy(o) {
415
493
  checkNode();
416
494
  if (!C.get("A2A_D1_ID")) die("no saved deployment; run `a2a-exposed init` first");
495
+ // init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
496
+ // refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
497
+ if (!C.get("A2A_WORKER_NAME") && o["worker-name"] === undefined)
498
+ die(`${C.CONFIG_FILE} has a saved deployment (A2A_D1_ID) but no A2A_WORKER_NAME; rerun with --worker-name <the Worker it deployed> (saved for next time)`);
499
+ const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
417
500
  const switched = applyFlags(o);
501
+ const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
418
502
  const dir = workerDir(o);
419
503
  if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
420
504
  guardTunnelUrl();
@@ -423,14 +507,14 @@ export async function deploy(o) {
423
507
  if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
424
508
  const env = cfEnv();
425
509
  applyMigrations(dir, env);
426
- const secrets = deploySecretsFromEnv();
510
+ const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets };
427
511
  if (Object.keys(secrets).length && C.get("A2A_OWNER_TOKEN")) secrets.OWNER_TOKEN = C.get("A2A_OWNER_TOKEN");
428
512
  step(Object.keys(secrets).length ? `deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})` : "deploying (existing secrets kept)");
429
513
  await deployAndLearn(dir, secrets, o);
430
514
  const base = deployedBase();
431
515
  if (base) await verifyDeployedCard(base);
432
516
  if (switched) { console.log(base); reportSwitch(switched); }
433
- proxyNote();
517
+ proxyNote(secrets);
434
518
  }
435
519
 
436
520
  /** Fingerprints of the wake secrets in the local environment (what `wake set` would upload). */
package/lib/main.mjs CHANGED
@@ -16,13 +16,14 @@ const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Clo
16
16
  Usage: a2a-exposed <command> [options] (or: npx a2a-exposed <command>)
17
17
 
18
18
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx a2a-exposed ...\`, and a Cloudflare login:
19
- npx cf auth login --no-browser)
19
+ npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
20
+ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
20
21
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
21
22
  [--agent-name N] [--agent-description D] [--agent-skills JSON]
22
23
  [--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
23
24
  [--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
24
25
  [--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
25
- [--upstream URL [--upstream-card-url URL]] [--worker-dir DIR]
26
+ [--upstream URL [--upstream-card-url URL] [--upstream-token-stdin | --no-upstream-token]] [--worker-dir DIR]
26
27
  deploy the Worker + D1 to your account; wake secrets are read from
27
28
  env WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET (never argv)
28
29
  no --hostname: serve on https://<worker>.<account subdomain>.workers.dev
@@ -48,6 +49,12 @@ npx cf auth login --no-browser)
48
49
  --upstream-card-url the upstream's card (default <upstream origin>/.well-known/
49
50
  agent-card.json); must be on the --upstream origin (it is
50
51
  fetched with the upstream credentials)
52
+ Setup reads that card: unless it declares no bearer auth, the
53
+ façade needs UPSTREAM_TOKEN besides the Access token (Access only
54
+ gets the Worker through the tunnel). Without it: a hidden prompt
55
+ on a terminal, else exit 1 with what to do
56
+ --upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
57
+ --no-upstream-token the upstream needs no bearer token: skip that check (saved)
51
58
  --cli-command command shown in wake hints (default "npx a2a-exposed";
52
59
  e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
53
60
  --worker-dir where the Worker project (template copy) lives (default
@@ -79,9 +86,16 @@ anywhere on the account: the inbox itself can be on workers.dev or a custom host
79
86
 
80
87
  Status
81
88
  status [--json] deployment, base URL, agent card check (fetched by the CLI: no curl needed), wake
82
- mode (webhook / tunnel / none = polling), tunnel state, proxy-mode upstream and
83
- card-leak check (--upstream), and the next step to run.
89
+ mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
90
+ upstream card, Access credential and upstream bearer configured (separately), the
91
+ live upstream check, the last failed peer call, card-leak check; warnings for peer
92
+ token variables shadowing saved tokens; and the next step to run.
84
93
  Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
94
+ upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
95
+ (unauthenticated call -> 401), then the Worker calls the upstream with its stored
96
+ secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
97
+ is created): Cloudflare Access, the agent's bearer check, the A2A app. Exit 1 unless
98
+ the app answered. \`status\` runs the same check
85
99
  url print the public base URL (exit 1 if none is configured)
86
100
  config print config (secrets masked) and its location
87
101
 
@@ -126,7 +140,7 @@ Outbound (agents you call)
126
140
  peers add <alias> <url> [--token-env VAR | --token-stdin]
127
141
  peers list | peers rm <alias>
128
142
  a token variable set in the environment overrides config.env; when a peer token
129
- differs between the two, connect/send/poll/peers warn on stderr (values never shown)
143
+ differs between the two, connect/send/poll/peers/status warn (values never shown)
130
144
  send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
131
145
  poll --to <alias|url> <taskId> [--proto 0.3|1.0]
132
146
  outbound <taskId> stored state of a task you sent (incl. pushed updates)
@@ -139,7 +153,7 @@ const S = { type: "string" }, B = { type: "boolean" };
139
153
  const tunnelOpts = { "tunnel-hostname": S, "tunnel-zone": S, "tunnel-origin": S, "tunnel-path": S, "show-token": B, "zero-trust-org": S, dir: S, "worker-dir": S };
140
154
  const deployOpts = {
141
155
  ...Object.fromEntries(dep.DEPLOY_FLAGS.map((f) => [f, S])), dir: S, "worker-dir": S, cron: B, "skip-install": B, "rotate-owner-token": B,
142
- "workers-dev": B, "workers-dev-subdomain": S,
156
+ "workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B,
143
157
  };
144
158
  const SPEC = {
145
159
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
@@ -151,6 +165,7 @@ const SPEC = {
151
165
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
152
166
  poll: { to: S, proto: S },
153
167
  status: { json: B },
168
+ upstream: { json: B },
154
169
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
155
170
  pair: { json: B, web: B, ttl: S },
156
171
  };
@@ -188,6 +203,11 @@ export async function main(argv) {
188
203
  case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
189
204
  case "deploy": return dep.deploy(o);
190
205
  case "status": return st.status(o);
206
+ case "upstream": {
207
+ const sub = need(p[0], "upstream verify [--json]");
208
+ if (sub === "verify" || sub === "check") return st.upstreamVerify(o);
209
+ throw new CliError(`unknown upstream action ${sub} (verify)`);
210
+ }
191
211
  case "wake": return dep.wake(p[0], o);
192
212
  case "url": {
193
213
  const u = cmd.baseUrl();
package/lib/pair.mjs CHANGED
@@ -181,7 +181,7 @@ export function cardUrlToSend(flag, own, quiet = false) {
181
181
  if (flag) {
182
182
  let u;
183
183
  try { u = new URL(flag); } catch { die(`--card-url must be a URL (your agent card, e.g. https://agent.example.com/.well-known/agent-card.json)`); }
184
- if (u.protocol !== "https:" || u.username || u.password) { note(`note: --card-url ${u.origin} is not a plain https URL, so it is left out of the pairing request (inboxes accept only https cards)`); return ""; }
184
+ if (u.protocol !== "https:" || u.username || u.password) { note(`note: --card-url ${u.origin} is not a plain https URL, so it is left out of the pairing request (inboxes accept only public https cards). Peers can't call an agent there: to be reachable, expose it through a public façade (setup skill: "Already have A2A on a Tailnet or LAN") and pass the façade's card`); return ""; }
185
185
  if (u.href.length > 300) { note("note: --card-url is longer than 300 characters, so it is left out of the pairing request"); return ""; }
186
186
  if (isPrivateHost(u.hostname)) note(`note: ${u.hostname} is on a private network: the peer's owner sees the card as informational ("not publicly reachable"). Expose your agent through a public façade for others to reach it (setup skill: "Already have A2A on a Tailnet or LAN").`);
187
187
  return u.href;
@@ -278,7 +278,9 @@ A different agent at the same URL? Pass another --alias.`);
278
278
  const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
279
279
  if (r.status !== 200 || !r.data.device_code || !r.data.user_code) {
280
280
  const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
281
- die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token` : `pairing request to ${peerName} failed: ${why}`);
281
+ const privateCard = !!card && (() => { try { return isPrivateHost(new URL(card).hostname); } catch { return false; } })();
282
+ die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token`
283
+ : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(card).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
282
284
  }
283
285
  st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
284
286
  verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
package/lib/status.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  // itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
4
4
  import fs from "node:fs";
5
5
  import * as C from "./config.mjs";
6
- import { cardUrlProblem, cfErrorCode, describeHttp, fingerprint, httpJson } from "./a2a.mjs";
7
- import { baseUrl, owner } from "./commands.mjs";
6
+ import { cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
7
+ import { baseUrl, owner, ownerTry } from "./commands.mjs";
8
8
  import * as D from "./deploy.mjs";
9
9
  import * as T from "./tunnel.mjs";
10
10
 
@@ -35,9 +35,58 @@ export function wakeLine(s) {
35
35
  return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
36
36
  }
37
37
 
38
+ // ---------------------------------------------------------------- proxy mode: upstream diagnosis
39
+ /** What to do for each reason code of the Worker's upstream check (`POST /owner/facade/verify`, Worker log
40
+ * `upstream_error`). `d` is the check's detail line (HTTP status, Cloudflare error; never a secret). */
41
+ export const UPSTREAM_FIX = {
42
+ access_credentials_missing: (d) => `Cloudflare Access refused the Worker (${d}). Export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (the service token the upstream's Access app admits) and run \`${CLI} deploy\``,
43
+ access_rejected: (d) => `Cloudflare Access refused the Worker's service token (${d}): the Access app on the upstream hostname needs a Service Auth policy for that token, and UPSTREAM_ACCESS_CLIENT_ID / _SECRET must be its current pair (export them and run \`${CLI} deploy\`)`,
44
+ upstream_auth_missing: (d) => `Access let the Worker through, but the agent refused the call (${d}). Export UPSTREAM_TOKEN (the bearer the agent expects; its card advertises it) and run \`${CLI} deploy\` (or pipe it: \`<print token> | ${CLI} deploy --upstream-token-stdin\`)`,
45
+ upstream_auth_rejected: (d) => `Access let the Worker through, but the agent refused its UPSTREAM_TOKEN (${d}): wrong or rotated. Export the current one and run \`${CLI} deploy\``,
46
+ tunnel_down: (d) => `Cloudflare can't reach the upstream tunnel (${d}): start the connector on the agent's machine (\`cloudflared tunnel run ...\`)`,
47
+ upstream_unavailable: (d) => `the tunnel answers but the agent's A2A server behind it does not (${d}): start the agent, and check the tunnel ingress \`service:\` URL`,
48
+ network: (d) => `the Worker could not connect to the upstream (${d}): check the tunnel hostname (DNS record, certificate)`,
49
+ unexpected_response: (d) => `the upstream answered without JSON-RPC (${d}): --upstream must be the agent's JSON-RPC endpoint (not its web UI or card URL); fix it with \`${CLI} deploy --upstream <URL>\``,
50
+ misconfigured: (d) => `the upstream URL is unusable (${d}): fix it with \`${CLI} deploy --upstream <tunnel URL>\``,
51
+ };
52
+
53
+ /** The upstream check as layer-by-layer rows: façade peer auth (public), Cloudflare Access, the agent's bearer check, the
54
+ * A2A app. `v`: the Worker's verify answer; `fa`: the public peer-auth probe ({ status }) or null. Pure; unit-tested. */
55
+ export function verifyLayers(v, fa = null) {
56
+ const rows = [];
57
+ if (fa) rows.push(["façade peer auth", fa.status === 401 ? "enforced (an unauthenticated call gets 401 from the façade)"
58
+ : fa.error ? `not checked (${fa.error})` : `UNEXPECTED: an unauthenticated call got HTTP ${fa.status} (expected 401)`]);
59
+ if (!v) return rows;
60
+ const r = v.reason, sent = v.sent || {};
61
+ const notReached = "not reached";
62
+ const access = r === "access_credentials_missing" ? "FAILED: refused by Cloudflare Access; the Worker has no Access service token"
63
+ : r === "access_rejected" ? "FAILED: Cloudflare Access refused the Worker's service token (policy or wrong token)"
64
+ : ["upstream_auth_missing", "upstream_auth_rejected", "reachable", "upstream_unavailable"].includes(r) ? (sent.accessServiceToken ? "passed" : "no service token sent (the upstream hostname is not behind Access?)")
65
+ : r === "tunnel_down" || r === "network" ? notReached : "?";
66
+ const bearer = r === "upstream_auth_missing" ? "FAILED: the agent wants a credential; the Worker has no UPSTREAM_TOKEN"
67
+ : r === "upstream_auth_rejected" ? "FAILED: the agent refused the Worker's UPSTREAM_TOKEN"
68
+ : r === "reachable" ? (sent.bearer ? "accepted" : "none sent (the agent accepted the call without one)")
69
+ : notReached;
70
+ const app = r === "reachable" ? `reachable (${v.detail}${v.rpcErrorCode === -32601 ? ": method not found, as expected" : ""}; no task created)`
71
+ : ["tunnel_down", "upstream_unavailable", "network", "unexpected_response", "misconfigured"].includes(r) ? `FAILED: ${v.detail}` : notReached;
72
+ rows.push(["Access", access], ["upstream bearer", bearer], ["A2A app", app]);
73
+ return rows;
74
+ }
75
+
76
+ /** Peer token variables set in the environment that shadow a different token saved in config.env (names only). */
77
+ export function shadowedPeerTokens() {
78
+ return [...new Set(Object.values(C.loadPeers()).map((p) => p && p.token_env).filter(Boolean))].filter((k) => C.envOverridesFile(k));
79
+ }
80
+
38
81
  /** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
39
82
  * as separate lines (so the next step stays one short instruction). */
40
83
  export function nextStep(s) {
84
+ const n = nextStepCore(s);
85
+ const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
86
+ return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
87
+ }
88
+
89
+ function nextStepCore(s) {
41
90
  const fail = (text) => ({ ok: false, text, also: [] });
42
91
  const done = (text, also = []) => ({ ok: true, text, also });
43
92
  if (!s.deployed) return fail(`nothing is deployed from ${s.configFile}: run \`${CLI} init ...\` (setup skill, section 2). Another bot's deployment? Set A2A_CONFIG_DIR.`);
@@ -62,12 +111,28 @@ export function nextStep(s) {
62
111
  return fail(`${s.configFile} has A2A_UPSTREAM_URL, but the Worker is not in proxy mode (an older template or an interrupted deploy): run \`${CLI} deploy\``);
63
112
  if (fc.upstreamProblem) return fail(`the upstream URL is unusable: ${fc.upstreamProblem}. Fix it with \`${CLI} deploy --upstream <tunnel URL>\``);
64
113
  if (fc.upstreamCardProblem) return fail(`the upstream card URL is refused: ${fc.upstreamCardProblem}. Fix it with \`${CLI} deploy --upstream-card-url <URL on the upstream's origin>\` (or \`--upstream-card-url none\` for the default)`);
114
+ // the live check (owner-authenticated, run by the Worker with its stored secrets) is the precise diagnosis
115
+ const v = s.upstreamVerify;
116
+ if (v && v.reason && v.reason !== "reachable" && UPSTREAM_FIX[v.reason]) return fail(UPSTREAM_FIX[v.reason](v.detail || v.reason));
117
+ // an Access-only configuration must not look complete when the agent's card asks for a bearer
118
+ const wantsBearer = fc.upstreamAuth && fc.upstreamAuth.bearer === true;
119
+ if (wantsBearer && !fc.hasUpstreamToken && !(v && v.ok) && !s.upstreamNoToken)
120
+ return fail(`the upstream's card asks for a bearer token (${(fc.upstreamAuth.schemes || []).join(", ")}) and the Worker has no UPSTREAM_TOKEN: Cloudflare Access only lets the Worker through the tunnel, so peers' calls would fail with HTTP 502. Export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
65
121
  if (fc.upstreamCard !== "ok")
66
122
  return fail(`the Worker can't fetch the upstream's agent card (${fc.upstreamCard}): is the tunnel connector running on the agent's machine, and does the Access app admit the service token you exported as UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (re-run \`${CLI} deploy\` with them exported)?`);
67
123
  if (fc.publicCardLeaks && fc.publicCardLeaks.length)
68
124
  return fail(`the public card still names a private URL (${fc.publicCardLeaks[0]}): report this as a bug; meanwhile override the field with --agent-description / --agent-skills and run \`${CLI} deploy\``);
69
125
  const also = [];
70
126
  if (!fc.hasUpstreamAccessServiceToken) also.push("no Access service token for the upstream: unless the upstream hostname is protected another way, put it behind Cloudflare Access and export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET, then deploy");
127
+ if (wantsBearer && !fc.hasUpstreamToken)
128
+ also.push(`the upstream's card asks for a bearer token but the Worker has none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}${v && v.ok ? "; the agent accepts calls without it today" : ""}: if peers get HTTP 502, export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
129
+ if (fc.upstreamAuth && fc.upstreamAuth.unsupported && fc.upstreamAuth.unsupported.length)
130
+ also.push(`the upstream's card requires ${fc.upstreamAuth.unsupported.join(", ")}; the façade only presents a bearer token (UPSTREAM_TOKEN) and the Access service token`);
131
+ if (s.localUpstreamToken && fc.fingerprints && fc.fingerprints.upstreamToken !== s.localUpstreamToken)
132
+ also.push(`the UPSTREAM_TOKEN exported here (fingerprint ${s.localUpstreamToken}) differs from the Worker's (${fc.fingerprints.upstreamToken || "none"}): run \`${CLI} deploy\` to upload it`);
133
+ if (!v && s.upstreamVerifyError) also.push(`the live upstream check was not run (${s.upstreamVerifyError || "unavailable"})${/HTTP 404/.test(s.upstreamVerifyError || "") ? `: run \`${CLI} deploy\` for a Worker with \`upstream verify\`` : ""}`);
134
+ if (fc.lastFailure && fc.lastFailure.reason && v && v.ok)
135
+ also.push(`last failed peer call: ${fc.lastFailure.reason}${fc.lastFailure.status ? ` (HTTP ${fc.lastFailure.status})` : ""} at ${fc.lastFailure.at}; the live check passes now`);
71
136
  if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
72
137
  also.push(`peers can't connect yet: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal); never set it yourself`);
73
138
  if (s.pairing && s.pairing.pending) also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
@@ -117,7 +182,9 @@ export async function collect() {
117
182
  baseUrl: baseUrl(), urlKind: C.get("A2A_HOSTNAME") ? "custom domain" : "workers.dev", cron: C.get("A2A_ENABLE_CRON") === "1",
118
183
  hasOwnerToken: !!C.get("A2A_OWNER_TOKEN"), baseUrlSaved: (C.fileConfig().A2A_BASE_URL || "").replace(/\/$/, ""), baseUrlEnv: "",
119
184
  card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
120
- upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null,
185
+ upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
186
+ upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
187
+ peerTokenShadowed: shadowedPeerTokens(),
121
188
  };
122
189
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
123
190
  if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
@@ -140,6 +207,7 @@ export async function collect() {
140
207
  if (s.upstream) {
141
208
  try { s.facade = await owner("GET", "/owner/facade"); }
142
209
  catch (e) { s.facade = { mode: "unknown", error: e.message }; }
210
+ if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
143
211
  }
144
212
  }
145
213
  }
@@ -163,6 +231,42 @@ export async function collect() {
163
231
  return s;
164
232
  }
165
233
 
234
+ const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
235
+
236
+ /** The two upstream probes, neither creating anything: (1) public: an unauthenticated JSON-RPC call to the façade, which
237
+ * must answer 401 before looking at the method; (2) owner-authenticated: the Worker sends a JSON-RPC call with an
238
+ * unknown method to the upstream with its stored secrets and classifies the answer (POST /owner/facade/verify). */
239
+ export async function upstreamCheck(base) {
240
+ const out = { facadePeerAuth: null, upstreamVerify: null, upstreamVerifyError: "" };
241
+ try {
242
+ const r = await httpJson(base + "/", { method: "POST", body: { jsonrpc: "2.0", id: "a2a-exposed-verify", method: VERIFY_METHOD, params: {} }, headers: { "a2a-version": "1.0" }, timeout: 15000 });
243
+ out.facadePeerAuth = { status: r.status };
244
+ } catch (e) { out.facadePeerAuth = { status: null, error: e.message }; }
245
+ try {
246
+ const r = await ownerTry("POST", "/owner/facade/verify", {});
247
+ if (r.status === 200 && r.data && typeof r.data === "object" && r.data.reason) out.upstreamVerify = r.data;
248
+ else out.upstreamVerifyError = describeHttp(r.status, r.data);
249
+ } catch (e) { out.upstreamVerifyError = e.message; }
250
+ return out;
251
+ }
252
+
253
+ /** `upstream verify`: the façade's whole path, layer by layer; exit 1 unless the upstream app answered. */
254
+ export async function upstreamVerify(o) {
255
+ if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an inbox, check wakes with \`${CLI} wake test\``);
256
+ const base = baseUrl();
257
+ if (!base || !C.get("A2A_OWNER_TOKEN")) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
258
+ const r = await upstreamCheck(base);
259
+ const v = r.upstreamVerify;
260
+ if (o.json) console.log(JSON.stringify({ ...r, ok: !!(v && v.ok) }, null, 2));
261
+ else {
262
+ const rows = [["upstream", C.get("A2A_UPSTREAM_URL")], ...verifyLayers(v, r.facadePeerAuth)];
263
+ if (!v) rows.push(["check", `FAILED: ${r.upstreamVerifyError}${/HTTP 404/.test(r.upstreamVerifyError) ? ` (this Worker predates \`upstream verify\`: run \`${CLI} deploy\`)` : ""}`]);
264
+ else rows.push(["result", v.ok ? "OK: the façade reaches the agent with its stored credentials" : `${v.reason}: ${UPSTREAM_FIX[v.reason] ? UPSTREAM_FIX[v.reason](v.detail || v.reason) : v.detail}`]);
265
+ for (const [k, val] of rows) console.log(`${(k + ":").padEnd(18)} ${val}`);
266
+ }
267
+ if (!(v && v.ok) || (r.facadePeerAuth && r.facadePeerAuth.status !== null && r.facadePeerAuth.status !== 401)) process.exitCode = 1;
268
+ }
269
+
166
270
  export async function status(o) {
167
271
  const s = await collect();
168
272
  const next = nextStep(s);
@@ -179,7 +283,18 @@ export async function status(o) {
179
283
  rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
180
284
  if (s.upstream) {
181
285
  const f = s.facade || {};
182
- rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; credential ${f.hasUpstreamToken ? "set" : "none"}; Access service token ${f.hasUpstreamAccessServiceToken ? "set" : "NONE"}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
286
+ rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
287
+ if (f.mode === "proxy") {
288
+ const ua = f.upstreamAuth || {};
289
+ rows.push(["upstream Access", f.hasUpstreamAccessServiceToken ? `credential configured (UPSTREAM_ACCESS_CLIENT_ID fingerprint ${f.fingerprints?.upstreamAccessClientId || "?"})` : "NONE (UPSTREAM_ACCESS_CLIENT_ID / _SECRET not on the Worker)"]);
290
+ rows.push(["upstream bearer", f.hasUpstreamToken ? `bearer configured (UPSTREAM_TOKEN fingerprint ${f.fingerprints?.upstreamToken || "?"})`
291
+ : ua.bearer === true ? `NONE, but the upstream card asks for one (${(ua.schemes || []).join(", ")})${s.upstreamNoToken ? "; --no-upstream-token" : ""}`
292
+ : ua.bearer === false ? "none (the upstream card declares no bearer auth)" : `none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}`]);
293
+ const v = s.upstreamVerify;
294
+ rows.push(["upstream check", !v ? `not run (${s.upstreamVerifyError || "?"})` : v.ok ? `OK: ${verifyLayers(v).map(([k, x]) => `${k} ${x.replace(/ \(.*$/, "")}`).join("; ")}` : `FAILED (${v.reason}): ${v.detail}`]);
295
+ if (s.facadePeerAuth) rows.push(["peer auth", verifyLayers(null, s.facadePeerAuth)[0][1]]);
296
+ if (f.lastFailure && f.lastFailure.reason) rows.push(["last failure", `${f.lastFailure.reason}${f.lastFailure.status ? ` (HTTP ${f.lastFailure.status})` : ""} on ${f.lastFailure.method || "?"} from peer ${f.lastFailure.peer || "?"} at ${f.lastFailure.at}`]);
297
+ }
183
298
  }
184
299
  rows.push(["wake", wakeLine(s)]);
185
300
  const t = s.tunnel;
@@ -192,7 +307,8 @@ export async function status(o) {
192
307
  }
193
308
  rows.push(["next step", next.text]);
194
309
  for (const a of next.also || []) rows.push(["also", a]);
195
- for (const [k, v] of rows) console.log(`${(k + ":").padEnd(12)} ${v}`);
310
+ const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
311
+ for (const [k, v] of rows) console.log(`${(k + ":").padEnd(w)} ${v}`);
196
312
  }
197
313
  if (!next.ok) process.exitCode = 1;
198
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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": {
@@ -252,3 +252,121 @@ export function wantsPush(op: string, p: Json): boolean {
252
252
  if (op !== "send" || !p || typeof p !== "object" || !p.configuration || typeof p.configuration !== "object") return false;
253
253
  return Object.entries(p.configuration).some(([k, v]) => /push/i.test(k) && v !== undefined && v !== null);
254
254
  }
255
+
256
+ // ------------------------------------------------------------------ upstream auth + failure diagnosis (proxy mode)
257
+
258
+ /** How an upstream security scheme authenticates: "bearer" (HTTP bearer; OAuth 2.0 and OpenID Connect also send
259
+ * `Authorization: Bearer`), "basic" / "http" (other HTTP schemes), "apiKey", "mtls", or "unknown". Accepts the A2A 1.0
260
+ * shape ({ httpAuthSecurityScheme: { scheme } }, { oauth2SecurityScheme }, ...) and the 0.3 / OpenAPI shape
261
+ * ({ type: "http", scheme: "bearer" }, { type: "oauth2" }, ...). */
262
+ export function schemeKind(s: Json): string {
263
+ if (!s || typeof s !== "object") return "unknown";
264
+ const http = (scheme: unknown) => { const v = String(scheme || "").toLowerCase(); return v === "bearer" ? "bearer" : v === "basic" ? "basic" : "http"; };
265
+ if (s.httpAuthSecurityScheme) return http(s.httpAuthSecurityScheme.scheme);
266
+ if (s.oauth2SecurityScheme || s.openIdConnectSecurityScheme) return "bearer";
267
+ if (s.apiKeySecurityScheme) return "apiKey";
268
+ if (s.mtlsSecurityScheme || s.mutualTlsSecurityScheme) return "mtls";
269
+ switch (String(s.type || "").toLowerCase()) {
270
+ case "http": return http(s.scheme);
271
+ case "oauth2": case "openidconnect": return "bearer";
272
+ case "apikey": return "apiKey";
273
+ case "mutualtls": return "mtls";
274
+ }
275
+ return "unknown";
276
+ }
277
+
278
+ export type UpstreamAuth = {
279
+ /** true: the card asks for a bearer token (UPSTREAM_TOKEN); false: it declares no bearer / HTTP auth (no schemes, or
280
+ * anonymous access allowed, or only schemes the façade can't present); null: no card to tell. */
281
+ bearer: boolean | null;
282
+ /** Declared schemes as "name: kind" (names and kinds only, never values). */
283
+ schemes: string[];
284
+ /** Required schemes the façade can't present (it only sends `Authorization: Bearer <UPSTREAM_TOKEN>`). */
285
+ unsupported: string[];
286
+ };
287
+
288
+ /** What the upstream card says about authenticating to it. Security requirements are read from A2A 1.0
289
+ * `securityRequirements` ([{ schemes: { name: { list: [] } } }]) or 0.3 `security` ([{ name: [] }]); without
290
+ * requirements every declared scheme counts. An empty requirement ({}) means anonymous access is allowed. */
291
+ export function upstreamAuth(card: Json): UpstreamAuth {
292
+ if (!card || typeof card !== "object" || Array.isArray(card)) return { bearer: null, schemes: [], unsupported: [] };
293
+ const defs: Record<string, Json> = card.securitySchemes && typeof card.securitySchemes === "object" && !Array.isArray(card.securitySchemes) ? card.securitySchemes : {};
294
+ const kinds: Record<string, string> = {};
295
+ for (const [n, s] of Object.entries(defs)) kinds[n] = schemeKind(s);
296
+ const schemes = Object.entries(kinds).map(([n, k]) => `${n}: ${k}`);
297
+ const raw = Array.isArray(card.securityRequirements) ? card.securityRequirements : Array.isArray(card.security) ? card.security : [];
298
+ const alts: string[][] = raw.filter((r: Json) => r && typeof r === "object" && !Array.isArray(r))
299
+ .map((r: Json) => Object.keys(r.schemes && typeof r.schemes === "object" && !Array.isArray(r.schemes) ? r.schemes : r));
300
+ const kindOf = (n: string) => kinds[n] || "unknown";
301
+ if (!alts.length) {
302
+ const all = Object.keys(kinds);
303
+ return { bearer: all.some((n) => kinds[n] === "bearer"), schemes, unsupported: all.some((n) => kinds[n] === "bearer") ? [] : all.filter((n) => kinds[n] !== "unknown").map((n) => `${n}: ${kinds[n]}`) };
304
+ }
305
+ if (alts.some((a) => a.length === 0)) return { bearer: false, schemes, unsupported: [] }; // anonymous allowed
306
+ const bearer = alts.some((a) => a.some((n) => kindOf(n) === "bearer"));
307
+ const unsupported = bearer ? [] : [...new Set(alts.flat())].map((n) => `${n}: ${kindOf(n)}`);
308
+ return { bearer, schemes, unsupported };
309
+ }
310
+
311
+ /** Why a call to the upstream did not produce a JSON-RPC answer, as a stable reason code (Worker logs, the owner
312
+ * diagnostics, `status`). Never shown to peers: their 502 stays generic. */
313
+ export type UpstreamReason =
314
+ | "reachable" // a JSON-RPC response came back (any result or error): Access, the tunnel and the bearer all passed
315
+ | "access_credentials_missing" // Cloudflare Access refused the request and the façade has no Access service token
316
+ | "access_rejected" // Cloudflare Access refused the façade's service token (wrong token, or not admitted by the app's policy)
317
+ | "upstream_auth_missing" // the agent answered 401/403 and the façade has no UPSTREAM_TOKEN
318
+ | "upstream_auth_rejected" // the agent answered 401/403 to the façade's UPSTREAM_TOKEN
319
+ | "tunnel_down" // Cloudflare could not reach the tunnel / origin (error 1033, 1016, 530, ...)
320
+ | "upstream_unavailable" // a 5xx without JSON-RPC (cloudflared up, the agent's server not answering)
321
+ | "network" // the fetch itself failed (DNS, TLS, timeout)
322
+ | "unexpected_response" // anything else that is not JSON-RPC (a 404 page, HTML, a redirect elsewhere)
323
+ | "misconfigured"; // UPSTREAM_URL unusable
324
+
325
+ export type UpstreamVerdict = { reason: UpstreamReason; status: number | null; cloudflareError?: string; rpcErrorCode?: number; detail: string };
326
+
327
+ /** Classify one upstream HTTP answer (or a fetch failure: status null + error). Pure; `bearerSent` says whether the
328
+ * façade sent UPSTREAM_TOKEN, `accessSent` whether it sent an Access service token (false turns an Access refusal into
329
+ * access_credentials_missing). Access is recognised by its login redirect (<team>.cloudflareaccess.com,
330
+ * /cdn-cgi/access/), its CF-Access-* response headers, or its error page. */
331
+ export function classifyUpstream(i: { status: number | null; headers?: Headers | Record<string, string>; body?: string; error?: string; bearerSent: boolean; accessSent?: boolean }): UpstreamVerdict {
332
+ if (i.status === null) return { reason: "network", status: null, detail: `request failed: ${String(i.error || "unknown error").slice(0, 200)}` };
333
+ const status = i.status;
334
+ const body = String(i.body || "");
335
+ const get = (k: string): string => {
336
+ const h: any = i.headers;
337
+ if (!h) return "";
338
+ if (typeof h.get === "function") return h.get(k) || "";
339
+ for (const [n, v] of Object.entries(h)) if (n.toLowerCase() === k) return String(v);
340
+ return "";
341
+ };
342
+ const names: string[] = [];
343
+ const h: any = i.headers;
344
+ if (h && typeof h.forEach === "function" && typeof h.get === "function") h.forEach((_v: string, k: string) => names.push(k.toLowerCase()));
345
+ else if (h) names.push(...Object.keys(h).map((k) => k.toLowerCase()));
346
+ let rpc: Json = null;
347
+ try { rpc = JSON.parse(body); } catch { /* not JSON */ }
348
+ if (rpc && typeof rpc === "object" && !Array.isArray(rpc) && rpc.jsonrpc === "2.0" && status !== 401 && status !== 403) {
349
+ const code = rpc.error && typeof rpc.error === "object" && typeof rpc.error.code === "number" ? rpc.error.code : undefined;
350
+ return { reason: "reachable", status, ...(code !== undefined ? { rpcErrorCode: code } : {}),
351
+ detail: code !== undefined ? `JSON-RPC error ${code} (HTTP ${status})` : `JSON-RPC result (HTTP ${status})` };
352
+ }
353
+ const location = get("location");
354
+ const accessPage = /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(location) || /cloudflareaccess\.com|\/cdn-cgi\/access\/|Cloudflare Access/i.test(body.slice(0, 8192));
355
+ const accessHeader = names.some((n) => n.startsWith("cf-access-"));
356
+ if ((status >= 300 && status < 400 && /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(location)) || ((status === 401 || status === 403) && (accessPage || accessHeader)))
357
+ return { reason: i.accessSent === false ? "access_credentials_missing" : "access_rejected", status,
358
+ detail: `${status >= 300 && status < 400 ? `HTTP ${status} redirect to the Cloudflare Access login` : `HTTP ${status} from Cloudflare Access`}: ${i.accessSent === false
359
+ ? "the façade has no Access service token (UPSTREAM_ACCESS_CLIENT_ID / _SECRET)" : "Access refused the façade's service token (wrong token, or the app's policy doesn't admit it)"}` };
360
+ if (status === 401 || status === 403)
361
+ return i.bearerSent
362
+ ? { reason: "upstream_auth_rejected", status, detail: `HTTP ${status} from the agent: it refused the façade's UPSTREAM_TOKEN` }
363
+ : { reason: "upstream_auth_missing", status, detail: `HTTP ${status} from the agent: it wants a credential and the façade has no UPSTREAM_TOKEN` };
364
+ const m = /error code:?\s*(\d{4})|\bError\s+(\d{4})\b|errorCode:?\s*(\d{4})|cf-error-code[^>]*>\s*(\d{4})/i.exec(body.slice(0, 8192));
365
+ const cf = m ? m[1] || m[2] || m[3] || m[4] : "";
366
+ if (cf === "1033" || cf === "1016" || status === 530)
367
+ return { reason: "tunnel_down", status, ...(cf ? { cloudflareError: cf } : {}), detail: `HTTP ${status}${cf ? `, Cloudflare error ${cf}` : ""}: Cloudflare can't reach the tunnel (is cloudflared running?)` };
368
+ if (status >= 500)
369
+ return { reason: "upstream_unavailable", status, ...(cf ? { cloudflareError: cf } : {}), detail: `HTTP ${status}${cf ? `, Cloudflare error ${cf}` : ""} without a JSON-RPC answer: the agent's server behind the tunnel is not answering` };
370
+ return { reason: "unexpected_response", status, ...(cf ? { cloudflareError: cf } : {}),
371
+ detail: `HTTP ${status} without a JSON-RPC answer${status >= 300 && status < 400 && location ? " (a redirect)" : ""}: is UPSTREAM_URL the agent's JSON-RPC endpoint?` };
372
+ }
@@ -602,6 +602,63 @@ async function handleRpc(req: Request, env: Env, ectx: ExecutionContext): Promis
602
602
  }
603
603
 
604
604
  // ------------------------------------------------------------------ proxy mode: forward JSON-RPC to the private upstream
605
+ /** What a peer sees when the upstream hop fails: generic on purpose (no secret names, no upstream host, no hint which
606
+ * lock refused). The operator's diagnosis is the reason code in the Worker log, `status` and `upstream verify`. */
607
+ const PUBLIC_UPSTREAM_ERROR: Record<string, string> = {
608
+ access_credentials_missing: "This agent is unavailable: its façade is misconfigured; tell its operator",
609
+ access_rejected: "This agent is unavailable: its façade is misconfigured; tell its operator",
610
+ upstream_auth_missing: "This agent is unavailable: its façade is misconfigured; tell its operator",
611
+ upstream_auth_rejected: "This agent is unavailable: its façade is misconfigured; tell its operator",
612
+ tunnel_down: "This agent is not reachable right now; try again later",
613
+ upstream_unavailable: "This agent is not reachable right now; try again later",
614
+ network: "This agent is not reachable right now; try again later",
615
+ unexpected_response: "This agent gave an invalid answer; try again later or tell its operator",
616
+ };
617
+
618
+ /** Last failed upstream hop of a peer call (settings row `upstream_last_failure`): reason code, HTTP status, when, which
619
+ * method and peer. Shown to the owner by `GET /owner/facade` (and so `status`); never to peers. Best effort. */
620
+ const hasAccess = (env: Env) => !!(env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET);
621
+
622
+ async function recordUpstreamFailure(env: Env, v: F.UpstreamVerdict, c: { method: string; label: string }) {
623
+ const rec = { at: A.nowIso(), reason: v.reason, status: v.status, ...(v.cloudflareError ? { cloudflareError: v.cloudflareError } : {}), method: c.method, peer: c.label };
624
+ try {
625
+ await env.DB.prepare("INSERT INTO settings (key, value) VALUES ('upstream_last_failure', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
626
+ .bind(JSON.stringify(rec)).run();
627
+ } catch (e) { log("upstream_failure_not_recorded", { error: String(e).slice(0, 200) }); }
628
+ }
629
+
630
+ async function lastUpstreamFailure(env: Env): Promise<Json | null> {
631
+ try {
632
+ const r: Json = await env.DB.prepare("SELECT value FROM settings WHERE key = 'upstream_last_failure'").first();
633
+ return r ? JSON.parse(r.value) : null;
634
+ } catch { return null; }
635
+ }
636
+
637
+ /** Owner-only upstream check (`POST /owner/facade/verify`): one JSON-RPC call with a method no A2A server implements,
638
+ * sent from the Worker with the stored upstream credentials over the same path as peer traffic (Access, tunnel, the
639
+ * agent's bearer check), then classified. An unknown method can't create or touch a task, and the secrets never leave
640
+ * the Worker: the answer carries reason codes and HTTP status only. */
641
+ const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
642
+ async function verifyUpstream(env: Env): Promise<Json> {
643
+ const sent = { bearer: !!env.UPSTREAM_TOKEN, accessServiceToken: hasAccess(env) };
644
+ const [ep, why] = F.upstreamEndpoint(env.UPSTREAM_URL);
645
+ if (!ep) return { ok: false, reason: "misconfigured", status: null, detail: why, sent, method: VERIFY_METHOD };
646
+ const up = await fetchUpstreamCard(env);
647
+ const versions = F.upstreamJsonRpcVersions(up.card);
648
+ const v1 = !versions.length || versions.includes("1.0");
649
+ const headers = upstreamHeaders(env, ep, { "content-type": "application/json", accept: "application/json", "a2a-version": v1 ? "1.0" : "0.3" });
650
+ const rid = `a2a-exposed-verify-${A.newId()}`;
651
+ let verdict: F.UpstreamVerdict;
652
+ try {
653
+ const r = await fetch(ep, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: rid, method: VERIFY_METHOD, params: {} }), redirect: "manual", signal: AbortSignal.timeout(20000) });
654
+ verdict = F.classifyUpstream({ status: r.status, headers: r.headers, body: (await r.text()).slice(0, 8192), bearerSent: sent.bearer, accessSent: sent.accessServiceToken });
655
+ } catch (e) {
656
+ verdict = F.classifyUpstream({ status: null, error: String(e), bearerSent: sent.bearer, accessSent: sent.accessServiceToken });
657
+ }
658
+ log("upstream_verify", { reason: verdict.reason, status: verdict.status, ...(verdict.rpcErrorCode !== undefined ? { code: verdict.rpcErrorCode } : {}) });
659
+ return { ok: verdict.reason === "reachable", ...verdict, sent, method: VERIFY_METHOD, upstreamWantsBearer: F.upstreamAuth(up.card).bearer };
660
+ }
661
+
605
662
  // Methods only the façade forwards (the inbox has one push config per task and no delete).
606
663
  const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
607
664
  const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
@@ -659,8 +716,10 @@ async function proxyRpc(req: Request, env: Env, c: { raw: string; rid: Json; met
659
716
  try {
660
717
  r = await fetch(ep, { method: "POST", headers, body: c.raw, redirect: "manual", signal: AbortSignal.timeout(90000) });
661
718
  } catch (e) {
662
- log("upstream_failed", { peer: c.label, method: c.method, error: String(e).slice(0, 200) });
663
- return err(-32603, "This agent is not reachable right now (upstream unavailable); try again later", 502);
719
+ const v = F.classifyUpstream({ status: null, error: String(e), bearerSent: !!env.UPSTREAM_TOKEN, accessSent: hasAccess(env) });
720
+ log("upstream_error", { reason: v.reason, peer: c.label, method: c.method, detail: v.detail });
721
+ await recordUpstreamFailure(env, v, c);
722
+ return err(-32603, PUBLIC_UPSTREAM_ERROR[v.reason] || PUBLIC_UPSTREAM_ERROR.network, 502);
664
723
  }
665
724
  const text = await r.text();
666
725
  if (text.length > 8 * 1048576) { log("upstream_too_large", { peer: c.label, method: c.method, bytes: text.length }); return err(-32603, "Upstream response too large", 502); }
@@ -668,11 +727,12 @@ async function proxyRpc(req: Request, env: Env, c: { raw: string; rid: Json; met
668
727
  try { body = JSON.parse(text); } catch { /* not JSON */ }
669
728
  const isRpc = body && typeof body === "object" && !Array.isArray(body) && body.jsonrpc === "2.0";
670
729
  if (r.status === 401 || r.status === 403 || !isRpc) {
671
- const hint = cloudflareErrorHint(r.status, text.slice(0, 4096));
672
- log("upstream_error", { peer: c.label, method: c.method, status: r.status, ...(hint ? { hint } : {}) });
673
- return err(-32603, r.status === 401 || r.status === 403
674
- ? "This agent's façade was refused by its upstream (credential or Access policy); tell its operator"
675
- : `This agent's upstream answered HTTP ${r.status} without a JSON-RPC response; try again later`, 502);
730
+ // the operator gets the precise reason (Worker log line + `status` / `upstream verify`); the peer a generic 502
731
+ // that names no secret, header or upstream detail
732
+ const v = F.classifyUpstream({ status: r.status, headers: r.headers, body: text.slice(0, 8192), bearerSent: !!env.UPSTREAM_TOKEN, accessSent: hasAccess(env) });
733
+ log("upstream_error", { reason: v.reason, peer: c.label, method: c.method, status: r.status, ...(v.cloudflareError ? { cloudflareError: v.cloudflareError } : {}) });
734
+ await recordUpstreamFailure(env, v, c);
735
+ return err(-32603, PUBLIC_UPSTREAM_ERROR[v.reason] || PUBLIC_UPSTREAM_ERROR.unexpected_response, 502);
676
736
  }
677
737
  if (c.op === "send" && body.result) {
678
738
  const { taskId, contextId } = F.idsFromResult(body.result);
@@ -893,10 +953,17 @@ async function handleOwner(req: Request, env: Env, ectx: ExecutionContext, path:
893
953
  const [, cardWhy] = ep ? F.upstreamCardUrl(ep, env.UPSTREAM_CARD_URL) : ["", ""];
894
954
  return json({ mode: "proxy", upstreamValid: !!ep, ...(why ? { upstreamProblem: why } : {}), ...(cardWhy ? { upstreamCardProblem: cardWhy } : {}), upstreamCardUrl: up.url || null,
895
955
  upstreamCard: up.status === 200 ? "ok" : up.error || "unavailable", upstreamVersions: F.upstreamJsonRpcVersions(up.card),
956
+ // what the upstream card asks for (scheme names and kinds only), from the copy the Worker fetched through Access
957
+ upstreamAuth: F.upstreamAuth(up.status === 200 ? up.card : null),
896
958
  hasUpstreamToken: !!env.UPSTREAM_TOKEN, hasUpstreamAccessServiceToken: !!(env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET),
897
959
  fingerprints: { upstreamToken: await A.fingerprint(env.UPSTREAM_TOKEN), upstreamAccessClientId: await A.fingerprint(env.UPSTREAM_ACCESS_CLIENT_ID) },
960
+ lastFailure: await lastUpstreamFailure(env),
898
961
  publicCardLeaks: F.cardLeaks(card, env.PUBLIC_URL, upstreamOrigins(env, up.card)) });
899
962
  }
963
+ if (seg[0] === "facade" && seg[1] === "verify" && !seg[2] && m === "POST") {
964
+ if (!proxyMode(env)) return json({ mode: "inbox", ok: false, reason: "not_proxy_mode", detail: "this Worker is an inbox (no UPSTREAM_URL): nothing to verify" }, 409);
965
+ return json({ mode: "proxy", ...(await verifyUpstream(env)) });
966
+ }
900
967
  if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
901
968
  // rendered wake request for a sample event, partially masked, plus short sha256 fingerprints of the
902
969
  // uploaded secrets so the owner can compare them with local values without revealing them
@@ -360,7 +360,8 @@ test("proxy mode: upstream failures are the façade's errors, never a 401 to the
360
360
  const send = () => s.rpc(a, "SendMessage", { message: { role: "ROLE_USER", parts: [{ text: "x" }] } });
361
361
  let r = await send();
362
362
  assert.equal(r.status, 502);
363
- assert.match(r.data.error.message, /refused by its upstream/);
363
+ assert.match(r.data.error.message, /façade is misconfigured; tell its operator/);
364
+ assert.doesNotMatch(r.text, /UPSTREAM|Access|token|bearer|agent-upstream|401/i, "the public 502 names no secret, lock or upstream detail");
364
365
  mode = "html";
365
366
  r = await send();
366
367
  assert.equal(r.status, 502);
@@ -419,3 +420,163 @@ test("proxy mode: the landing page shows the rewritten card (upstream name and s
419
420
  assert.equal(j.data.name, "Jean");
420
421
  assert.ok(!JSON.stringify(j.data).includes("ts.net"));
421
422
  });
423
+
424
+ // ------------------------------------------------------------------ upstream auth, failure reasons, owner verify
425
+
426
+ test("upstreamAuth: bearer wanted or not, from A2A 1.0 and 0.3 card shapes", () => {
427
+ assert.deepEqual(F.upstreamAuth(null), { bearer: null, schemes: [], unsupported: [] });
428
+ assert.equal(F.upstreamAuth({ name: "x" }).bearer, false, "no schemes: no auth declared");
429
+ // 1.0 (ProtoJSON oneof wrappers + securityRequirements)
430
+ const v1 = { securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: "Bearer" } } }, securityRequirements: [{ schemes: { bearer: { list: [] } } }] };
431
+ assert.deepEqual(F.upstreamAuth(v1), { bearer: true, schemes: ["bearer: bearer"], unsupported: [] });
432
+ assert.equal(F.upstreamAuth({ securitySchemes: { o: { oauth2SecurityScheme: { flows: {} } } }, securityRequirements: [{ schemes: { o: { list: ["a2a"] } } }] }).bearer, true, "OAuth 2.0 sends a bearer");
433
+ assert.equal(F.upstreamAuth({ securitySchemes: { o: { openIdConnectSecurityScheme: { openIdConnectUrl: "https://id.example.com" } } } }).bearer, true);
434
+ // 0.3 (OpenAPI style + security)
435
+ const v03 = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } }, security: [{ bearerAuth: [] }] };
436
+ assert.deepEqual(F.upstreamAuth(v03), { bearer: true, schemes: ["bearerAuth: bearer"], unsupported: [] });
437
+ assert.equal(F.upstreamAuth({ securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } }).bearer, true, "declared without requirements still counts");
438
+ // anonymous allowed as an alternative
439
+ assert.equal(F.upstreamAuth({ ...v03, security: [{ bearerAuth: [] }, {}] }).bearer, false);
440
+ // only schemes the façade can't present
441
+ const key = F.upstreamAuth({ securitySchemes: { k: { apiKeySecurityScheme: { location: "header", name: "x-api-key" } } }, securityRequirements: [{ schemes: { k: { list: [] } } }] });
442
+ assert.deepEqual(key, { bearer: false, schemes: ["k: apiKey"], unsupported: ["k: apiKey"] });
443
+ assert.deepEqual(F.upstreamAuth({ securitySchemes: { b: { type: "http", scheme: "basic" } }, security: [{ b: [] }] }).unsupported, ["b: basic"]);
444
+ assert.equal(F.upstreamAuth(tailnetCard()).bearer, false, "the test card wants an API key header");
445
+ });
446
+
447
+ test("classifyUpstream: Access, the agent's bearer check, tunnel, network and a reachable app are told apart", () => {
448
+ const c = (status: number | null, body = "", headers: Record<string, string> = {}, bearerSent = false, error = "") => F.classifyUpstream({ status, body, headers, bearerSent, error });
449
+ const rpc = JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "Method not found" } });
450
+ assert.deepEqual(c(200, rpc), { reason: "reachable", status: 200, rpcErrorCode: -32601, detail: "JSON-RPC error -32601 (HTTP 200)" });
451
+ assert.equal(c(404, rpc).reason, "reachable", "any JSON-RPC answer means the app was reached");
452
+ assert.equal(c(200, JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} })).reason, "reachable");
453
+ assert.equal(c(302, "", { Location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/up.example.com?kid=x" }).reason, "access_rejected");
454
+ assert.equal(c(403, "<html>Forbidden. You don't have permission ... Cloudflare Access</html>").reason, "access_rejected");
455
+ assert.equal(c(401, "", { "cf-access-domain": "up.example.com" }, true).reason, "access_rejected");
456
+ assert.equal(F.classifyUpstream({ status: 302, headers: { location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/x" }, bearerSent: true, accessSent: false }).reason, "access_credentials_missing");
457
+ assert.equal(F.classifyUpstream({ status: 403, body: "Forbidden (Cloudflare Access)", bearerSent: true, accessSent: true }).reason, "access_rejected", "a configured token Access refuses: policy / wrong token");
458
+ assert.equal(c(401, "unauthorized", {}, false).reason, "upstream_auth_missing");
459
+ // Hermes, verbatim from Jean's probe: Access headers only -> 401 + JSON-RPC -32050; Access + Hermes bearer -> 200 + -32601
460
+ const hermes401 = JSON.stringify({ jsonrpc: "2.0", id: "probe", error: { code: -32050, message: "unauthorized" } });
461
+ assert.equal(c(401, hermes401, { "content-type": "application/json" }, false).reason, "upstream_auth_missing");
462
+ assert.equal(c(401, hermes401, { "content-type": "application/json" }, true).reason, "upstream_auth_rejected");
463
+ const hermes200 = JSON.stringify({ jsonrpc: "2.0", id: "probe", error: { code: -32601, message: "Method not found" } });
464
+ assert.deepEqual(c(200, hermes200, { "content-type": "application/json" }, true), { reason: "reachable", status: 200, rpcErrorCode: -32601, detail: "JSON-RPC error -32601 (HTTP 200)" });
465
+ assert.equal(c(403, JSON.stringify({ error: "bad token" }), {}, true).reason, "upstream_auth_rejected");
466
+ assert.equal(c(401, JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32000, message: "unauthorized" } }), {}, true).reason, "upstream_auth_rejected", "a JSON-RPC body on 401 is still an auth failure");
467
+ assert.deepEqual(c(530, "error code: 1033"), { reason: "tunnel_down", status: 530, cloudflareError: "1033", detail: "HTTP 530, Cloudflare error 1033: Cloudflare can't reach the tunnel (is cloudflared running?)" });
468
+ assert.equal(c(502, "<html>Bad gateway</html>").reason, "upstream_unavailable");
469
+ assert.equal(c(null, "", {}, false, "TypeError: fetch failed").reason, "network");
470
+ assert.equal(c(404, "<html>Not found</html>").reason, "unexpected_response");
471
+ assert.equal(c(301, "", { location: "https://elsewhere.example.com/" }).reason, "unexpected_response");
472
+ const h = new Headers({ location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/x" });
473
+ assert.equal(F.classifyUpstream({ status: 302, headers: h, body: "", bearerSent: false }).reason, "access_rejected", "Headers objects work too");
474
+ });
475
+
476
+ /** Jean's façade: Access admits the Worker's service token, but the agent (Hermes) also checks its own bearer. */
477
+ function bearerCheckingUpstream(want = "hermes-bearer") {
478
+ const seen: any[] = [];
479
+ const card = { ...tailnetCard(), securitySchemes: { bearer: { type: "http", scheme: "bearer" } }, securityRequirements: undefined, security: [{ bearer: [] }] }; // 0.3-style auth, as Hermes advertises
480
+ const fn = async (url: string, init: any): Promise<Response> => {
481
+ const h = init.headers || {};
482
+ if (h["cf-access-client-id"] !== "cid.access" || h["cf-access-client-secret"] !== "csecret")
483
+ return new Response(null, { status: 302, headers: { location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/agent-upstream.example.net" } });
484
+ if (url.endsWith(".json")) return Response.json(card); // the card itself is public behind Access
485
+ const rq = JSON.parse(init.body);
486
+ seen.push(rq);
487
+ if (h.authorization !== `Bearer ${want}`) return Response.json({ jsonrpc: "2.0", id: rq.id, error: { code: -32050, message: "unauthorized" } }, { status: 401 }); // Hermes, as Jean probed it
488
+ if (rq.method !== "SendMessage" && rq.method !== "GetTask") return Response.json({ jsonrpc: "2.0", id: rq.id, error: { code: -32601, message: "Method not found" } });
489
+ return Response.json({ jsonrpc: "2.0", id: rq.id, result: { task: { id: "t-1", contextId: "c-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
490
+ };
491
+ return { fn, seen };
492
+ }
493
+
494
+ test("proxy mode: missing UPSTREAM_TOKEN (Jean's case): peers get a generic 502, the owner gets the precise reason", async (t) => {
495
+ const up = bearerCheckingUpstream();
496
+ const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-jean.json", UPSTREAM_ACCESS_CLIENT_ID: "cid.access", UPSTREAM_ACCESS_CLIENT_SECRET: "csecret" }, up.fn);
497
+ t.after(s.restore);
498
+ const a = await s.issue("alice");
499
+ const r = await s.rpc(a, "SendMessage", { message: { role: "ROLE_USER", parts: [{ text: "hi" }] } });
500
+ assert.equal(r.status, 502);
501
+ assert.equal(r.data.error.code, -32603);
502
+ assert.doesNotMatch(r.text, /UPSTREAM|Access|bearer|token|agent-upstream|401/i);
503
+ const diag = (await s.call("GET", "/owner/facade", { token: "owner-secret" })).data;
504
+ assert.equal(diag.upstreamCard, "ok", "the card passes (Access only), the calls don't");
505
+ assert.deepEqual(diag.upstreamAuth, { bearer: true, schemes: ["bearer: bearer"], unsupported: [] });
506
+ assert.equal(diag.hasUpstreamToken, false);
507
+ assert.equal(diag.hasUpstreamAccessServiceToken, true);
508
+ assert.equal(diag.lastFailure.reason, "upstream_auth_missing");
509
+ assert.equal(diag.lastFailure.status, 401);
510
+ assert.equal(diag.lastFailure.method, "SendMessage");
511
+ assert.equal(diag.lastFailure.peer, "alice");
512
+ assert.match(diag.lastFailure.at, /^\d{4}-/);
513
+
514
+ const v = await s.call("POST", "/owner/facade/verify", { token: "owner-secret" });
515
+ assert.equal(v.status, 200);
516
+ assert.equal(v.data.ok, false);
517
+ assert.equal(v.data.reason, "upstream_auth_missing");
518
+ assert.equal(v.data.status, 401);
519
+ assert.deepEqual(v.data.sent, { bearer: false, accessServiceToken: true });
520
+ assert.equal(v.data.upstreamWantsBearer, true);
521
+ assert.equal(up.seen.at(-1).method, "a2a-exposed/verify-unknown-method");
522
+ });
523
+
524
+ test("owner verify: classifies Access, bearer, tunnel and network failures; a reachable app answers -32601; never creates a task", async (t) => {
525
+ const up = bearerCheckingUpstream();
526
+ const env = { UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-v.json", UPSTREAM_ACCESS_CLIENT_ID: "cid.access", UPSTREAM_ACCESS_CLIENT_SECRET: "csecret", UPSTREAM_TOKEN: "hermes-bearer" };
527
+ const s = setup(env, up.fn);
528
+ t.after(s.restore);
529
+ const verify = async (st = s) => (await st.call("POST", "/owner/facade/verify", { token: "owner-secret" })).data;
530
+ let v = await verify();
531
+ assert.equal(v.ok, true, JSON.stringify(v));
532
+ assert.equal(v.reason, "reachable");
533
+ assert.equal(v.rpcErrorCode, -32601);
534
+ assert.deepEqual(v.sent, { bearer: true, accessServiceToken: true });
535
+ assert.ok(!JSON.stringify(v).includes("hermes-bearer") && !JSON.stringify(v).includes("csecret"), "no secret values in the answer");
536
+ const probe = s.calls.filter((c) => c.url === UPSTREAM).at(-1);
537
+ assert.equal(probe.headers.authorization, "Bearer hermes-bearer");
538
+ assert.equal(probe.headers["cf-access-client-id"], "cid.access");
539
+ assert.equal(JSON.parse(probe.body).method, "a2a-exposed/verify-unknown-method");
540
+ assert.equal((s.DB.db.prepare("SELECT COUNT(*) AS n FROM facade_owners").get() as any).n, 0, "nothing recorded");
541
+ assert.equal((s.DB.db.prepare("SELECT COUNT(*) AS n FROM tasks").get() as any).n, 0, "no task");
542
+
543
+ const wrong = setup({ ...env, UPSTREAM_TOKEN: "stale" }, up.fn);
544
+ t.after(wrong.restore);
545
+ assert.equal((await verify(wrong)).reason, "upstream_auth_rejected");
546
+ const wrongAccess = setup({ ...env, UPSTREAM_ACCESS_CLIENT_SECRET: "wrong" }, up.fn);
547
+ t.after(wrongAccess.restore);
548
+ v = await verify(wrongAccess);
549
+ assert.equal(v.reason, "access_rejected");
550
+ assert.equal(v.status, 302);
551
+ const { UPSTREAM_ACCESS_CLIENT_ID: _i, UPSTREAM_ACCESS_CLIENT_SECRET: _s, ...noAccessEnv } = env;
552
+ const noAccess = setup(noAccessEnv, up.fn);
553
+ t.after(noAccess.restore);
554
+ assert.equal((await verify(noAccess)).reason, "access_credentials_missing");
555
+ const down = setup(env, async () => new Response("error code: 1033", { status: 530 }));
556
+ t.after(down.restore);
557
+ assert.equal((await verify(down)).reason, "tunnel_down");
558
+ const net = setup(env, async () => { throw new TypeError("fetch failed"); });
559
+ t.after(net.restore);
560
+ assert.equal((await verify(net)).reason, "network");
561
+ const bad = setup({ UPSTREAM_URL: "https://jean.tail1234.ts.net/a2a" });
562
+ t.after(bad.restore);
563
+ v = await verify(bad);
564
+ assert.equal(v.reason, "misconfigured");
565
+ assert.equal(bad.calls.length, 0);
566
+ });
567
+
568
+ test("owner verify is owner-only and proxy-only (no public or peer-reachable probe)", async (t) => {
569
+ const up = bearerCheckingUpstream();
570
+ const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-o.json", UPSTREAM_ACCESS_CLIENT_ID: "cid.access", UPSTREAM_ACCESS_CLIENT_SECRET: "csecret", UPSTREAM_TOKEN: "hermes-bearer" }, up.fn);
571
+ t.after(s.restore);
572
+ const peer = await s.issue("mallory");
573
+ const n0 = s.calls.filter((c) => c.url === UPSTREAM).length;
574
+ assert.equal((await s.call("POST", "/owner/facade/verify")).status, 401);
575
+ assert.equal((await s.call("POST", "/owner/facade/verify", { token: peer })).status, 401, "a peer token is not the owner token");
576
+ assert.equal(s.calls.filter((c) => c.url === UPSTREAM).length, n0, "unauthenticated calls never reach the upstream");
577
+ const inbox = setup();
578
+ t.after(inbox.restore);
579
+ const r = await inbox.call("POST", "/owner/facade/verify", { token: "owner-secret" });
580
+ assert.equal(r.status, 409);
581
+ assert.equal(r.data.reason, "not_proxy_mode");
582
+ });