a2a-exposed 0.4.0 → 0.5.0

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/commands.mjs CHANGED
@@ -101,6 +101,11 @@ export async function token(action, label, o) {
101
101
  const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}` : "";
102
102
  console.log(`${r.label}\tcreated=${r.created_at}\t${r.revoked_at ? "REVOKED " + r.revoked_at : "active"}${via}`);
103
103
  }
104
+ // MCP connectors (Worker 0.5+): their own tokens, listed and revoked by label like peer tokens
105
+ const m = await ownerTry("GET", "/owner/mcp").catch(() => null);
106
+ if (m && m.status === 200 && m.data && Array.isArray(m.data.grants))
107
+ for (const g of m.data.grants)
108
+ console.log(`${g.label}\tcreated=${g.created_at}\t${g.revoked_at ? "REVOKED " + g.revoked_at : "active"}\tMCP connector: ${JSON.stringify(g.client_name || "?")} -> ${g.redirect_host}${g.last_used_at ? `, last used ${g.last_used_at}` : ""}`);
104
109
  return;
105
110
  }
106
111
  if (!label) die("label required");
@@ -118,6 +123,14 @@ export async function token(action, label, o) {
118
123
  if (o.json) return out({ revoked: true, label });
119
124
  return console.log(`revoked ${label}: its token gets HTTP 401 from now on`);
120
125
  }
126
+ if (r.status === 404) {
127
+ // not a peer token: maybe an MCP connector
128
+ const m = await ownerTry("DELETE", `/owner/mcp/${q(label)}`).catch(() => null);
129
+ if (m && m.status === 200 && m.data && m.data.revoked === true) {
130
+ if (o.json) return out({ revoked: true, label, kind: "mcp" });
131
+ return console.log(`revoked ${label}: that MCP connector gets HTTP 401 from now on (remove it in the client, or reconnect to approve it again)`);
132
+ }
133
+ }
121
134
  if (r.status === 404 || (r.status === 200 && r.data && r.data.revoked === false))
122
135
  die((r.data && r.data.error) || `no active token with label ${label} (see: a2a-exposed token list)`);
123
136
  die(`could not revoke ${label}: ${describeHttp(r.status, r.data)}`);
@@ -157,7 +170,35 @@ function resolvePeer(to) {
157
170
  die(`unknown peer alias ${JSON.stringify(to)} (see: a2a-exposed peers list)`);
158
171
  }
159
172
 
173
+ /** peers sync [alias...]: upload outbound peers (URL + token, encrypted on the Worker with a key derived from the owner
174
+ * token) so the MCP connector's send / poll_outbound tools can reach them. peers unsync <alias>: remove one. */
175
+ async function peersRemote(sub, args) {
176
+ if (sub === "unsync") {
177
+ if (!args.length) die("usage: peers unsync <alias>...");
178
+ for (const alias of args) {
179
+ const r = await ownerTry("DELETE", `/owner/outbound-peers/${q(alias)}`);
180
+ if (r.status !== 200) die((r.data && r.data.error) || `could not unsync ${alias}: ${describeHttp(r.status, r.data)}`);
181
+ console.log(`unsynced ${alias}: the connector can no longer send to it`);
182
+ }
183
+ return;
184
+ }
185
+ const all = C.loadPeers();
186
+ const list = args.length ? args : Object.keys(all);
187
+ if (!list.length) die("no peers to sync: add one first (a2a-exposed connect <url> or peers add)");
188
+ for (const alias of list) {
189
+ const pe = all[alias];
190
+ if (!pe) die(`unknown peer alias ${JSON.stringify(alias)} (see: a2a-exposed peers list)`);
191
+ const tok = peerToken(pe.token_env);
192
+ const r = await ownerTry("PUT", `/owner/outbound-peers/${q(alias)}`, { url: pe.url.replace(/\/$/, ""), ...(tok ? { token: tok } : {}) });
193
+ if (r.status === 404) die("this Worker has no MCP support yet: redeploy (a2a-exposed deploy) and try again");
194
+ if (r.status !== 200) die(`could not sync ${alias}: ${(r.data && r.data.error) || describeHttp(r.status, r.data)}`);
195
+ console.log(`synced ${alias}\t${pe.url}\t${tok ? "token stored encrypted on the Worker" : "no token"}`);
196
+ }
197
+ console.error("# the MCP connector's send tool can now reach these peers; re-run after connect / token changes or an owner-token rotation");
198
+ }
199
+
160
200
  export function peers(sub, args, o) {
201
+ if (sub === "sync" || sub === "unsync") return peersRemote(sub, args);
161
202
  const all = C.loadPeers();
162
203
  if (sub === "add") {
163
204
  const [alias, url] = args;
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,11 +5,25 @@ 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)), "..");
14
+ /** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
15
+ export const CLAUDE_CODE_NOTE = [
16
+ "note (claude-code): each fire starts a NEW cloud session, which sees only what the routine provides.",
17
+ " Simplest: add this inbox as a connector at claude.ai/settings/connectors (URL: <your Worker URL>/mcp, approve with",
18
+ " your approval password) and keep it enabled on the routine; the session then uses its tools, no CLI or token needed.",
19
+ " Without the connector (CLI fallback), check that",
20
+ " - the repo with the committed skills (.claude/skills/) is a routine source and the skills are on its default branch,",
21
+ " - A2A_BASE_URL and A2A_OWNER_TOKEN are set on the routine's cloud environment (not only in this session),",
22
+ " - the environment's network access allows this Worker's hostname,",
23
+ " - the routine prompt tells Claude to act on the routine-fire-payload block.",
24
+ " Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
25
+ ].join("\n");
26
+
13
27
  const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
14
28
  // WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
15
29
  // by hand for your own Access-protected endpoint)
@@ -26,7 +40,7 @@ const DEPLOY_KEYS = [
26
40
  "A2A_PROVIDER_ORGANIZATION", "A2A_PROVIDER_URL", "A2A_DOCUMENTATION_URL",
27
41
  "WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
28
42
  "WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
29
- "A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS",
43
+ "A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
30
44
  "A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
31
45
  ];
32
46
  // init/deploy flag -> config key
@@ -36,17 +50,15 @@ const FLAG_KEYS = {
36
50
  "provider-organization": "A2A_PROVIDER_ORGANIZATION", "provider-url": "A2A_PROVIDER_URL",
37
51
  preset: "WAKE_PRESET", "agent-id": "WAKE_AGENT_ID", "key-header": "WAKE_KEY_HEADER", "key-prefix": "WAKE_KEY_PREFIX",
38
52
  "body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
39
- "pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS",
53
+ "pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS", mcp: "A2A_MCP",
40
54
  upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
41
55
  };
42
56
  export const PAIRING_MODES = ["human", "agent", "off"];
43
57
  export const DEPLOY_FLAGS = Object.keys(FLAG_KEYS);
44
58
 
45
59
  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");
60
+ // Worker (and default D1) name: A2A_WORKER_NAME (saved by init), else a2a-exposed.
61
+ export const workerName = () => C.get("A2A_WORKER_NAME") || "a2a-exposed";
50
62
  /** No custom hostname: the Worker is served on <worker>.<account subdomain>.workers.dev (and only there). */
51
63
  const workersDevMode = () => !C.get("A2A_HOSTNAME");
52
64
  export const publicBase = () =>
@@ -63,6 +75,9 @@ export const NODE_HELP = `How to get Node 22 (any one; no root needed):
63
75
  or the official installer / binaries: https://nodejs.org/en/download
64
76
  Then check: node --version (v22.18.0 or newer)`;
65
77
 
78
+ /** What to do when the Cloudflare login itself is blocked (printed with "not logged in"; the setup skill says the same). */
79
+ 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).`;
80
+
66
81
  /** Dies unless `version` (default: this Node) is 22.18+; the message says how to get it. */
67
82
  export function checkNode(version = process.versions.node) {
68
83
  const [maj, min] = String(version).replace(/^v/, "").split(".").map(Number);
@@ -192,6 +207,10 @@ function applyFlags(o) {
192
207
  die("--pbkdf2-iterations must be an integer from 50000 to 100000 (default 100000, the Workers maximum; lower it only if /device approvals hit Cloudflare error 1102, the CPU limit)");
193
208
  upd.A2A_PBKDF2_ITERATIONS = String(n);
194
209
  }
210
+ if (upd.A2A_MCP !== undefined) {
211
+ if (!["on", "off"].includes(upd.A2A_MCP)) die("--mcp must be on or off (the remote MCP server at /mcp that Claude and other MCP clients add as a connector; default on)");
212
+ upd.A2A_MCP = upd.A2A_MCP === "off" ? "off" : null;
213
+ }
195
214
  if (upd.A2A_WORKERS_LOGS !== undefined) {
196
215
  if (!["on", "off"].includes(upd.A2A_WORKERS_LOGS)) die("--workers-logs must be on or off (Cloudflare Workers Logs: persisted, searchable Worker logs; query strings redacted)");
197
216
  upd.A2A_WORKERS_LOGS = upd.A2A_WORKERS_LOGS === "on" ? "1" : null;
@@ -199,7 +218,11 @@ function applyFlags(o) {
199
218
  if (upd.WAKE_PRESET && !PRESETS.includes(upd.WAKE_PRESET)) die(`unknown preset ${upd.WAKE_PRESET} (${PRESETS.join(" | ")})`);
200
219
  for (const [flag, key] of [["upstream", "A2A_UPSTREAM_URL"], ["upstream-card-url", "A2A_UPSTREAM_CARD_URL"]]) {
201
220
  if (upd[key] === undefined) continue;
202
- if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) { upd[key] = null; continue; }
221
+ if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) {
222
+ upd[key] = null;
223
+ if (key === "A2A_UPSTREAM_URL") upd.A2A_UPSTREAM_NO_TOKEN = null; // the opt-out belonged to that upstream
224
+ continue;
225
+ }
203
226
  const why = upstreamUrlProblem(String(upd[key]).trim());
204
227
  if (why) die(`--${flag} ${why}`);
205
228
  upd[key] = new URL(String(upd[key]).trim()).href;
@@ -346,7 +369,9 @@ function deployedBase() {
346
369
 
347
370
  export async function init(o) {
348
371
  checkNode();
372
+ const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
349
373
  const switched = applyFlags(o);
374
+ const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
350
375
  const dir = workerDir(o);
351
376
  C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName() });
352
377
  if (workersDevMode()) {
@@ -361,11 +386,15 @@ export async function init(o) {
361
386
  const prof = cfProfile();
362
387
  step(`checking Cloudflare login (cf auth whoami${prof ? ` --profile ${prof}` : ""})`);
363
388
  const who = cfJson(dir, ["auth", "whoami"]);
364
- if (!who.authenticated) die(prof
389
+ if (!who.authenticated) die((process.env.CLOUDFLARE_API_TOKEN
390
+ ? "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"
391
+ : prof
365
392
  ? `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");
393
+ : "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
394
  const accts = who.accounts || [];
368
395
  if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
396
+ if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
397
+ 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
398
  if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
370
399
  C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
371
400
  } else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
@@ -391,30 +420,102 @@ export async function init(o) {
391
420
  if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
392
421
  C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
393
422
 
394
- const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv() };
423
+ const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv(), ...upstreamSecrets };
395
424
  step(`deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})`);
396
425
  await deployAndLearn(dir, secrets, o);
397
426
  const base = deployedBase();
398
427
  if (base) await verifyDeployedCard(base);
399
428
  console.log(base);
400
429
  reportSwitch(switched);
401
- proxyNote();
430
+ proxyNote(secrets);
402
431
  console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: a2a-exposed status (shows the next setup step)`);
403
432
  }
404
433
 
405
- /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their presence is shown). */
406
- function proxyNote() {
434
+ /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
435
+ function proxyNote(secrets = {}) {
407
436
  const up = C.get("A2A_UPSTREAM_URL");
408
437
  if (!up) return;
409
- const sent = UPSTREAM_SECRETS.filter((k) => process.env[k]);
438
+ const sent = UPSTREAM_SECRETS.filter((k) => k in secrets);
410
439
  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`);
440
+ ` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with: a2a-exposed status`);
441
+ }
442
+
443
+ /** stdin and stderr are a terminal (a human at the keyboard), and not CI: only then may setup prompt. */
444
+ const interactive = () => !!(process.stdin.isTTY && process.stderr.isTTY && !process.env.CI);
445
+
446
+ /** Proxy mode setup check: the façade must hold the bearer token the upstream agent checks (UPSTREAM_TOKEN) in addition
447
+ * to the Cloudflare Access service token, which only gets the Worker through the tunnel. Returns extra secrets to
448
+ * upload ({ UPSTREAM_TOKEN } when read from stdin or a hidden prompt), or dies with what to do. Never reads argv.
449
+ * Order: --upstream-token-stdin / exported UPSTREAM_TOKEN (uploaded) > --no-upstream-token (saved opt-out) > the
450
+ * Worker already holds one for this upstream, or the card it fetched declares no bearer > the upstream card fetched from
451
+ * here (with the exported Access token): no bearer declared -> fine; bearer or unknown -> hidden prompt on a terminal,
452
+ * else exit 1. */
453
+ export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
454
+ const up = C.get("A2A_UPSTREAM_URL");
455
+ if (!up) return {};
456
+ if (o["upstream-token-stdin"] && o["no-upstream-token"]) die("--upstream-token-stdin and --no-upstream-token are mutually exclusive");
457
+ if (o["upstream-token-stdin"]) {
458
+ const t = (process.stdin.isTTY ? await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown): ") : fs.readFileSync(0, "utf8")).trim();
459
+ if (!t) die("--upstream-token-stdin: no token on stdin");
460
+ C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null });
461
+ return { UPSTREAM_TOKEN: t };
462
+ }
463
+ if (process.env.UPSTREAM_TOKEN) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return {}; } // uploaded with the other exported secrets
464
+ if (o["no-upstream-token"]) {
465
+ C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
466
+ console.error("note: --no-upstream-token: deploying without an upstream bearer token (saved; `status` warns if the upstream card asks for one)");
467
+ return {};
468
+ }
469
+ if (C.get("A2A_UPSTREAM_NO_TOKEN") === "1") return {};
470
+ // an existing façade for this upstream: the Worker's secrets persist across deploys, and it reads the card through Access
471
+ if (!upstreamChanged && C.get("A2A_BASE_URL") && C.get("A2A_OWNER_TOKEN")) {
472
+ try {
473
+ const r = await ownerTry("GET", "/owner/facade");
474
+ if (r.status === 200 && r.data && r.data.mode === "proxy") {
475
+ if (r.data.hasUpstreamToken) return {};
476
+ if (r.data.upstreamAuth && r.data.upstreamAuth.bearer === false) return {};
477
+ }
478
+ } catch { /* not deployed yet / unreachable: check the card from here */ }
479
+ }
480
+ const cardUrl = C.get("A2A_UPSTREAM_CARD_URL") || new URL("/.well-known/agent-card.json", up).href;
481
+ step(`checking which credential the upstream's agent card asks for (${cardUrl})`);
482
+ const { card, error } = await fetchUpstreamCard(cardUrl);
483
+ const auth = upstreamAuth(card);
484
+ if (auth.unsupported.length)
485
+ 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`);
486
+ if (auth.bearer === false) {
487
+ console.error(`upstream card: no bearer auth declared${auth.schemes.length ? ` (schemes: ${auth.schemes.join(", ")})` : ""}, so no UPSTREAM_TOKEN is needed`);
488
+ return {};
489
+ }
490
+ const why = auth.bearer
491
+ ? `the upstream's agent card asks for a bearer token (${auth.schemes.join(", ")})`
492
+ : `the upstream's agent card could not be read from here (${error}), so whether the agent needs a bearer token is unknown`;
493
+ const access = "Cloudflare Access (UPSTREAM_ACCESS_CLIENT_ID / _SECRET) only lets the Worker through the tunnel; the agent checks its own token too";
494
+ if (interactive()) {
495
+ console.error(`${why}. ${access}.`);
496
+ const t = (await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown; empty = the agent needs none): ")).trim();
497
+ if (t) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return { UPSTREAM_TOKEN: t }; }
498
+ 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");
499
+ C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
500
+ console.error("note: deploying without an upstream bearer token (saved; check the whole path with `a2a-exposed status`)");
501
+ return {};
502
+ }
503
+ die(`${why}, and no UPSTREAM_TOKEN is set. ${access}, so without it every peer call fails with HTTP 502.\n` +
504
+ " 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 " +
505
+ `${C.get("A2A_D1_ID") ? "deploy" : "init ..."} --upstream-token-stdin\n` +
506
+ " The agent needs no bearer token? Re-run with --no-upstream-token (saved for later deploys).");
412
507
  }
413
508
 
414
509
  export async function deploy(o) {
415
510
  checkNode();
416
511
  if (!C.get("A2A_D1_ID")) die("no saved deployment; run `a2a-exposed init` first");
512
+ // init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
513
+ // refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
514
+ if (!C.get("A2A_WORKER_NAME") && o["worker-name"] === undefined)
515
+ 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)`);
516
+ const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
417
517
  const switched = applyFlags(o);
518
+ const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
418
519
  const dir = workerDir(o);
419
520
  if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
420
521
  guardTunnelUrl();
@@ -423,14 +524,14 @@ export async function deploy(o) {
423
524
  if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
424
525
  const env = cfEnv();
425
526
  applyMigrations(dir, env);
426
- const secrets = deploySecretsFromEnv();
527
+ const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets };
427
528
  if (Object.keys(secrets).length && C.get("A2A_OWNER_TOKEN")) secrets.OWNER_TOKEN = C.get("A2A_OWNER_TOKEN");
428
529
  step(Object.keys(secrets).length ? `deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})` : "deploying (existing secrets kept)");
429
530
  await deployAndLearn(dir, secrets, o);
430
531
  const base = deployedBase();
431
532
  if (base) await verifyDeployedCard(base);
432
533
  if (switched) { console.log(base); reportSwitch(switched); }
433
- proxyNote();
534
+ proxyNote(secrets);
434
535
  }
435
536
 
436
537
  /** Fingerprints of the wake secrets in the local environment (what `wake set` would upload). */
@@ -472,6 +573,7 @@ export async function wake(sub, o) {
472
573
  die("no wake webhook configured on the Worker: export WAKE_WEBHOOK_URL (and WAKE_WEBHOOK_KEY or WAKE_HMAC_SECRET), then run `a2a-exposed wake set` first (or use polling)");
473
574
  console.log(JSON.stringify(r, null, 2));
474
575
  if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
576
+ else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
475
577
  return;
476
578
  }
477
579
  if (sub === "set") {
@@ -480,7 +582,9 @@ export async function wake(sub, o) {
480
582
  if (!Object.keys(secrets).length && !DEPLOY_FLAGS.some((f) => o[f] !== undefined))
481
583
  die("nothing to set: export WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET and/or pass --preset etc.");
482
584
  await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
483
- return console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
585
+ console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
586
+ if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
587
+ return;
484
588
  }
485
589
  if (sub === "unset") {
486
590
  const dir = workerDir(o), name = workerName();
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
- [--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
25
- [--upstream URL [--upstream-card-url URL]] [--worker-dir DIR]
25
+ [--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
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
@@ -38,6 +39,8 @@ npx cf auth login --no-browser)
38
39
  --pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
39
40
  the Workers maximum; lower only if /device hits error 1102)
40
41
  --workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
42
+ --mcp on|off remote MCP server at <url>/mcp for Claude and other MCP clients
43
+ (default on; each client needs your approval password once)
41
44
  --upstream URL proxy / expose mode: a public façade for an agent that already
42
45
  speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
43
46
  a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
@@ -48,6 +51,12 @@ npx cf auth login --no-browser)
48
51
  --upstream-card-url the upstream's card (default <upstream origin>/.well-known/
49
52
  agent-card.json); must be on the --upstream origin (it is
50
53
  fetched with the upstream credentials)
54
+ Setup reads that card: unless it declares no bearer auth, the
55
+ façade needs UPSTREAM_TOKEN besides the Access token (Access only
56
+ gets the Worker through the tunnel). Without it: a hidden prompt
57
+ on a terminal, else exit 1 with what to do
58
+ --upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
59
+ --no-upstream-token the upstream needs no bearer token: skip that check (saved)
51
60
  --cli-command command shown in wake hints (default "npx a2a-exposed";
52
61
  e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
53
62
  --worker-dir where the Worker project (template copy) lives (default
@@ -79,9 +88,16 @@ anywhere on the account: the inbox itself can be on workers.dev or a custom host
79
88
 
80
89
  Status
81
90
  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.
91
+ mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
92
+ upstream card, Access credential and upstream bearer configured (separately), the
93
+ live upstream check, the last failed peer call, card-leak check; warnings for peer
94
+ token variables shadowing saved tokens; and the next step to run.
84
95
  Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
96
+ upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
97
+ (unauthenticated call -> 401), then the Worker calls the upstream with its stored
98
+ secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
99
+ is created): Cloudflare Access, the agent's bearer check, the A2A app. Exit 1 unless
100
+ the app answered. \`status\` runs the same check
85
101
  url print the public base URL (exit 1 if none is configured)
86
102
  config print config (secrets masked) and its location
87
103
 
@@ -119,14 +135,18 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
119
135
  Inbound peer tokens (one per peer label; paired agents get one too)
120
136
  token issue <label> print a new token once (fails if the label is active); manual fallback to \`connect\`
121
137
  token rotate <label> replace the label's token
122
- token list | token revoke <label> list shows how each token was created (pairing: code + date);
138
+ token list | token revoke <label> list shows how each token was created (pairing: code + date)
139
+ and MCP connectors (label mcp-*); revoke works for both;
123
140
  revoke of an unknown label exits 1
124
141
 
125
142
  Outbound (agents you call)
126
143
  peers add <alias> <url> [--token-env VAR | --token-stdin]
127
144
  peers list | peers rm <alias>
145
+ peers sync [alias...] | peers unsync <alias>
146
+ upload peers (URL + token, encrypted on the Worker) so the MCP connector can send to
147
+ them; default: all peers. Re-run after connect, token changes or an owner-token rotation
128
148
  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)
149
+ differs between the two, connect/send/poll/peers/status warn (values never shown)
130
150
  send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
131
151
  poll --to <alias|url> <taskId> [--proto 0.3|1.0]
132
152
  outbound <taskId> stored state of a task you sent (incl. pushed updates)
@@ -139,7 +159,7 @@ const S = { type: "string" }, B = { type: "boolean" };
139
159
  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
160
  const deployOpts = {
141
161
  ...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,
162
+ "workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B,
143
163
  };
144
164
  const SPEC = {
145
165
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
@@ -151,6 +171,7 @@ const SPEC = {
151
171
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
152
172
  poll: { to: S, proto: S },
153
173
  status: { json: B },
174
+ upstream: { json: B },
154
175
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
155
176
  pair: { json: B, web: B, ttl: S },
156
177
  };
@@ -188,6 +209,11 @@ export async function main(argv) {
188
209
  case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
189
210
  case "deploy": return dep.deploy(o);
190
211
  case "status": return st.status(o);
212
+ case "upstream": {
213
+ const sub = need(p[0], "upstream verify [--json]");
214
+ if (sub === "verify" || sub === "check") return st.upstreamVerify(o);
215
+ throw new CliError(`unknown upstream action ${sub} (verify)`);
216
+ }
191
217
  case "wake": return dep.wake(p[0], o);
192
218
  case "url": {
193
219
  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 || "",