a2a-exposed 0.5.0 → 0.6.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.
Files changed (44) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +24 -15
  4. package/lib/deploy.mjs +20 -17
  5. package/lib/main.mjs +24 -9
  6. package/lib/pair.mjs +47 -3
  7. package/lib/status.mjs +4 -4
  8. package/lib/tunnel.mjs +11 -11
  9. package/lib/update.mjs +1 -1
  10. package/package.json +1 -1
  11. package/worker/cloudflare.config.ts +50 -7
  12. package/worker/deploy.env.example +28 -1
  13. package/worker/migrations/0008_oidc_txns.sql +12 -0
  14. package/worker/package.json +6 -2
  15. package/worker/src/approval-oidc.ts +247 -0
  16. package/worker/src/backup.ts +186 -0
  17. package/worker/src/directory.ts +120 -0
  18. package/worker/src/export.ts +156 -0
  19. package/worker/src/hosted.ts +403 -0
  20. package/worker/src/index.ts +737 -584
  21. package/worker/src/mcp.ts +30 -12
  22. package/worker/src/migrations.ts +22 -0
  23. package/worker/src/pairing.ts +17 -4
  24. package/worker/src/sql-raw.d.ts +5 -0
  25. package/worker/src/storage.ts +100 -0
  26. package/worker/src/tenancy.ts +450 -0
  27. package/worker/src/wake.ts +3 -2
  28. package/worker/test/alarms.test.ts +231 -0
  29. package/worker/test/approval-oidc.test.ts +331 -0
  30. package/worker/test/bridge-thread.mjs +40 -0
  31. package/worker/test/d1.ts +146 -2
  32. package/worker/test/directory.test.ts +90 -0
  33. package/worker/test/export.test.ts +544 -0
  34. package/worker/test/facade.test.ts +1 -1
  35. package/worker/test/harness-worker.ts +80 -0
  36. package/worker/test/hosted.test.ts +221 -0
  37. package/worker/test/migrations.test.ts +5 -5
  38. package/worker/test/pairing.test.ts +5 -4
  39. package/worker/test/register-hooks.mjs +4 -0
  40. package/worker/test/sql-raw-hook.mjs +22 -0
  41. package/worker/test/storage.test.ts +74 -0
  42. package/worker/test/tenancy.test.ts +217 -0
  43. package/worker/test/wake-retry.test.ts +24 -1
  44. package/worker/test/wake.test.ts +3 -0
package/README.md CHANGED
@@ -3,14 +3,13 @@
3
3
  Command-line companion for [a2a-exposed](https://github.com/telegraphic-dev/a2a-exposed). It gives any AI agent a public A2A endpoint: a Cloudflare Worker inbox plus a wake webhook.
4
4
 
5
5
  ```bash
6
- npm i -g a2a-exposed # or one-off: npx -y a2a-exposed@latest --help
7
- a2a-exposed --help
6
+ npx -y a2a-exposed@latest --help # no install needed; @latest always runs the newest release
8
7
  ```
9
8
 
10
- The agent skills (`npx --yes skills add telegraphic-dev/a2a-exposed`) document the workflows; they do not install this CLI.
9
+ Optional speed-up on your own machine: `npm i -g a2a-exposed@latest`, then `a2a-exposed <command>` (re-run the install to upgrade; the CLI prints a notice when a newer version is out). Agent sandboxes often block global installs as untrusted code, so docs, skills and wake hints always use the npx form. The agent skills (`npx -y skills add telegraphic-dev/a2a-exposed`, into the current project) document the workflows; they do not install this CLI.
11
10
 
12
11
  - Node 22.18+ and zero dependencies. `init` installs Cloudflare's `cf` CLI into the Worker folder and uses that; you only need a one-time login (`npx cf auth login --no-browser`). No domain? Omit `--hostname` and `init` deploys to `https://<worker>.<account-subdomain>.workers.dev` (`--workers-dev-subdomain <name>` registers the account subdomain if missing).
13
- - **Setup:** `init`, `deploy`, `wake set|unset|test|preview|fingerprint`, `tunnel create|status|rm` (secure Cloudflare Tunnel + Access for local-only webhooks; needs any zone on the account, the inbox can be on workers.dev), `status` (read-only setup check with a next step), `url`, `config`. Use `--cli-command` to set the command shown in wake hints (default `npx a2a-exposed`; e.g. a `node <checkout>/cli/bin/a2a-exposed.mjs` path for development).
12
+ - **Setup:** `init`, `deploy`, `wake set|unset|test|preview|fingerprint`, `tunnel create|status|rm` (secure Cloudflare Tunnel + Access for local-only webhooks; needs any zone on the account, the inbox can be on workers.dev), `status` (read-only setup check with a next step), `url`, `config`. Use `--cli-command` to set the command shown in wake hints (default `npx -y a2a-exposed@latest`; e.g. a `node <checkout>/cli/bin/a2a-exposed.mjs` path for development).
14
13
  - **Inbox:** `inbox`, `show`, `working`, `reply`, `history`, `contexts`
15
14
  - **Pairing (OAuth 2.0 device flow, RFC 8628):** `connect <url>` (ask another inbox for a token; its owner approves), `pair set-password|list|approve|deny` (approve agents that connect to you; human approval by default)
16
15
  - **Peer tokens:** `token issue|list|revoke|rotate` (manual fallback; `list` also shows tokens created by pairing)
package/lib/a2a.mjs CHANGED
@@ -3,6 +3,10 @@ import crypto from "node:crypto";
3
3
 
4
4
  export const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
5
5
 
6
+ /** The one way docs, hints and CLI output tell people (and agents) to run the CLI: npx, always the latest release, no
7
+ * global install (agent sandboxes block global installs as untrusted code). A global install is an optional speed-up. */
8
+ export const CLI = "npx -y a2a-exposed@latest";
9
+
6
10
  export class CliError extends Error {}
7
11
  export const die = (msg) => { throw new CliError(msg); };
8
12
 
@@ -178,8 +182,8 @@ export async function rpc(url, version, token, method03, method1, params, peer =
178
182
  const who = peer && peer.alias ? `peer "${peer.alias}"` : new URL(url).origin;
179
183
  const base = (peer && peer.base) || new URL(url).origin;
180
184
  die(token
181
- ? `${who} rejected our token (HTTP 401: revoked, rotated, or never valid there). Re-pair: a2a-exposed connect ${base}${peer && peer.alias ? ` --alias ${peer.alias}` : ""} (its owner approves), or ask its owner for a new token`
182
- : `${who} needs a token (HTTP 401): a2a-exposed connect ${base}${peer && peer.alias ? ` --alias ${peer.alias}` : ""} (its owner approves)`);
185
+ ? `${who} rejected our token (HTTP 401: revoked, rotated, or never valid there). Re-pair: ${CLI} connect ${base}${peer && peer.alias ? ` --alias ${peer.alias}` : ""} (its owner approves), or ask its owner for a new token`
186
+ : `${who} needs a token (HTTP 401): ${CLI} connect ${base}${peer && peer.alias ? ` --alias ${peer.alias}` : ""} (its owner approves)`);
183
187
  }
184
188
  if (status !== 200 || !data || typeof data !== "object" || "error" in data)
185
189
  die(`peer returned ${data && typeof data === "object" && data.error ? `JSON-RPC error ${describeHttp(status, data).replace(/^HTTP \d+: /, "")} (HTTP ${status})` : describeHttp(status, data)}`);
package/lib/commands.mjs CHANGED
@@ -1,25 +1,25 @@
1
1
  // Day-to-day commands: inbox / reply / history (owner API) and outbound send / poll.
2
2
  import fs from "node:fs";
3
3
  import * as C from "./config.mjs";
4
- import { checkId, describeHttp, die, fetchCard, httpJson, newId, pickEndpoint, plainState, randomToken, rpc, textOf } from "./a2a.mjs";
4
+ import { CLI, checkId, describeHttp, die, fetchCard, httpJson, newId, pickEndpoint, plainState, randomToken, rpc, textOf } from "./a2a.mjs";
5
5
 
6
6
  const q = encodeURIComponent;
7
7
  const out = (obj) => console.log(JSON.stringify(obj, null, 2));
8
8
  export const baseUrl = () => C.get("A2A_BASE_URL").replace(/\/$/, "");
9
9
 
10
- export async function owner(method, path, body) {
10
+ export async function owner(method, path, body, timeout) {
11
11
  const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
12
- if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`a2a-exposed init\` or edit ${C.CONFIG_FILE})`);
13
- const { status, data } = await httpJson(base + path, { method, body, headers: { authorization: `Bearer ${tok}` } });
12
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
13
+ const { status, data } = await httpJson(base + path, { method, body, headers: { authorization: `Bearer ${tok}` }, timeout });
14
14
  if (status < 200 || status >= 300) die(`worker ${method} ${path} -> ${describeHttp(status, data)}`);
15
15
  return data;
16
16
  }
17
17
 
18
18
  /** Owner API call that returns { status, data } instead of dying, so callers can explain errors themselves. */
19
- export async function ownerTry(method, path, body) {
19
+ export async function ownerTry(method, path, body, timeout) {
20
20
  const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
21
- if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`a2a-exposed init\` or edit ${C.CONFIG_FILE})`);
22
- return httpJson(base + path, { method, body, headers: { authorization: `Bearer ${tok}` } });
21
+ if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
22
+ return httpJson(base + path, { method, body, headers: { authorization: `Bearer ${tok}` }, ...(timeout ? { timeout } : {}) });
23
23
  }
24
24
 
25
25
  function printPush(res) {
@@ -62,7 +62,7 @@ export async function inbox(o) {
62
62
  }
63
63
  }
64
64
 
65
- const PAIRING_NOTE = "Tell your human who is asking, the code and the link (details: a2a-exposed pair list). Never approve on your own: in human mode they approve on the link with their approval password; in agent mode run `pair approve <code>` only after they say yes.";
65
+ const PAIRING_NOTE = `Tell your human who is asking, the code and the link (details: ${CLI} pair list). Never approve on your own: in human mode they approve on the link with their approval password; in agent mode run \`pair approve <code>\` only after they say yes.`;
66
66
 
67
67
  export const show = async (id) => out(await owner("GET", `/owner/tasks/${q(id)}`));
68
68
 
@@ -132,7 +132,7 @@ export async function token(action, label, o) {
132
132
  }
133
133
  }
134
134
  if (r.status === 404 || (r.status === 200 && r.data && r.data.revoked === false))
135
- die((r.data && r.data.error) || `no active token with label ${label} (see: a2a-exposed token list)`);
135
+ die((r.data && r.data.error) || `no active token with label ${label} (see: ${CLI} token list)`);
136
136
  die(`could not revoke ${label}: ${describeHttp(r.status, r.data)}`);
137
137
  }
138
138
  die(`unknown token action ${action} (issue|list|revoke|rotate)`);
@@ -167,7 +167,7 @@ function resolvePeer(to) {
167
167
  for (const [alias, pe] of Object.entries(peers)) if (pe.url.replace(/\/$/, "") === to.replace(/\/$/, "")) return resolvePeer(alias);
168
168
  return [to, to.replace(/\/$/, ""), peerToken("A2A_PEER_TOKEN")];
169
169
  }
170
- die(`unknown peer alias ${JSON.stringify(to)} (see: a2a-exposed peers list)`);
170
+ die(`unknown peer alias ${JSON.stringify(to)} (see: ${CLI} peers list)`);
171
171
  }
172
172
 
173
173
  /** peers sync [alias...]: upload outbound peers (URL + token, encrypted on the Worker with a key derived from the owner
@@ -184,13 +184,13 @@ async function peersRemote(sub, args) {
184
184
  }
185
185
  const all = C.loadPeers();
186
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)");
187
+ if (!list.length) die(`no peers to sync: add one first (${CLI} connect <url> or peers add)`);
188
188
  for (const alias of list) {
189
189
  const pe = all[alias];
190
- if (!pe) die(`unknown peer alias ${JSON.stringify(alias)} (see: a2a-exposed peers list)`);
190
+ if (!pe) die(`unknown peer alias ${JSON.stringify(alias)} (see: ${CLI} peers list)`);
191
191
  const tok = peerToken(pe.token_env);
192
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");
193
+ if (r.status === 404) die(`this Worker has no MCP support yet: redeploy (${CLI} deploy) and try again`);
194
194
  if (r.status !== 200) die(`could not sync ${alias}: ${(r.data && r.data.error) || describeHttp(r.status, r.data)}`);
195
195
  console.log(`synced ${alias}\t${pe.url}\t${tok ? "token stored encrypted on the Worker" : "no token"}`);
196
196
  }
@@ -233,7 +233,7 @@ export function peers(sub, args, o) {
233
233
  const inUse = new Set(Object.values(all).map((x) => x.token_env));
234
234
  const reserved = (k) => /^(A2A_|CLOUDFLARE_|WAKE_|CF_PROFILE$)/.test(k);
235
235
  const drop = [...vars].filter((k) => !inUse.has(k) && !reserved(k) && k in C.fileConfig());
236
- if (!pe && !drop.length) die(`unknown peer alias ${JSON.stringify(alias)} (see: a2a-exposed peers list)`);
236
+ if (!pe && !drop.length) die(`unknown peer alias ${JSON.stringify(alias)} (see: ${CLI} peers list)`);
237
237
  if (pe) C.savePeers(all);
238
238
  if (drop.length) C.saveConfig(Object.fromEntries(drop.map((k) => [k, null])));
239
239
  return console.log(`removed ${alias}${drop.length ? ` (and ${drop.join(", ")} from ${C.CONFIG_FILE})` : ""}`);
@@ -278,7 +278,7 @@ export async function send(o) {
278
278
  if (tid) await owner("POST", "/owner/outbound", { taskId: tid, contextId: ctx, peer: alias, endpoint: url, protocol: version, pushToken, task: obj });
279
279
  else if (obj && obj.parts) await logHistory(ctx, { dir: "in", peer: alias, role: "agent", event: "direct_message", text: textOf(obj) });
280
280
  out(obj);
281
- if (tid) console.error(`# task ${tid}: ${plainState(obj)} (check: a2a-exposed poll --to ${alias} ${tid})`);
281
+ if (tid) console.error(`# task ${tid}: ${plainState(obj)} (check: ${CLI} poll --to ${alias} ${tid})`);
282
282
  }
283
283
 
284
284
  export async function poll(taskId, o) {
@@ -301,3 +301,12 @@ export async function poll(taskId, o) {
301
301
  }
302
302
 
303
303
  export const outbound = async (id) => out(await owner("GET", `/owner/outbound/${q(id)}`));
304
+
305
+ // ---------------------------------------------------------------- export
306
+ /** JSON dump on stdout. `--sql` prints the same tables as SQL text. */
307
+ export async function exportInbox(o) {
308
+ const path = o.sql ? "/owner/export?format=sql" : "/owner/export";
309
+ const data = await owner("GET", path, undefined, 120000);
310
+ if (o.sql) return console.log(typeof data === "string" ? data.replace(/\n$/, "") : JSON.stringify(data));
311
+ return out(data);
312
+ }
package/lib/deploy.mjs CHANGED
@@ -5,7 +5,7 @@ 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, fetchUpstreamCard, fingerprint, httpJson, randomToken, upstreamAuth, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
8
+ import { CLI, cardUrlProblem, cfErrorCode, die, fetchUpstreamCard, fingerprint, httpJson, randomToken, upstreamAuth, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
9
9
  import { owner, ownerTry } from "./commands.mjs";
10
10
  import { readHidden } from "./pair.mjs";
11
11
  import * as WD from "./workersdev.mjs";
@@ -31,7 +31,9 @@ export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_
31
31
  // Proxy / expose mode (--upstream): the one credential the façade presents to the private upstream (Bearer), and the
32
32
  // Cloudflare Access service token for the upstream's tunnel hostname. Read from the environment only (never argv).
33
33
  export const UPSTREAM_SECRETS = ["UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
34
- const DEPLOY_SECRETS = [...WAKE_SECRETS, ...UPSTREAM_SECRETS];
34
+ /** OpenID Connect client secret for approval. Environment only; never config.env. */
35
+ export const OIDC_SECRETS = ["APPROVAL_OIDC_CLIENT_SECRET"];
36
+ const DEPLOY_SECRETS = [...WAKE_SECRETS, ...UPSTREAM_SECRETS, ...OIDC_SECRETS];
35
37
 
36
38
  // Non-secret deploy settings persisted in config.env and passed to cloudflare.config.ts as env vars.
37
39
  const DEPLOY_KEYS = [
@@ -42,6 +44,7 @@ const DEPLOY_KEYS = [
42
44
  "WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
43
45
  "A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
44
46
  "A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
47
+ "APPROVAL_OIDC_ISSUER", "APPROVAL_OIDC_CLIENT_ID", "APPROVAL_OIDC_ALLOWED_SUBJECTS", "APPROVAL_METHODS",
45
48
  ];
46
49
  // init/deploy flag -> config key
47
50
  const FLAG_KEYS = {
@@ -68,7 +71,7 @@ export const workerDir = (o) => path.resolve(o.dir || C.get("A2A_WORKER_DIR") ||
68
71
  /** How to get Node 22.18+ (printed by the version check; the setup skill says the same). */
69
72
  export const NODE_HELP = `How to get Node 22 (any one; no root needed):
70
73
  mise (recommended; the agent skill: npx skills add telegraphic-dev/mise-skill):
71
- one command: mise exec node@22 -- npx a2a-exposed <command>
74
+ one command: mise exec node@22 -- npx -y a2a-exposed@latest <command>
72
75
  this project: mise use node@22 (then node/npx are Node 22 in this directory)
73
76
  nvm: nvm install 22 && nvm use 22
74
77
  fnm: fnm install 22 && fnm use 22
@@ -164,7 +167,7 @@ export function wakeSecretsFromEnv() {
164
167
  /** Secrets init/deploy upload when exported: the wake secrets plus, for proxy mode, the upstream credentials. */
165
168
  export function deploySecretsFromEnv() {
166
169
  const s = wakeSecretsFromEnv();
167
- for (const k of UPSTREAM_SECRETS) if (process.env[k]) s[k] = process.env[k];
170
+ for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS]) if (process.env[k]) s[k] = process.env[k];
168
171
  return s;
169
172
  }
170
173
 
@@ -246,7 +249,7 @@ function applyFlags(o) {
246
249
  function reportSwitch(sw) {
247
250
  if (!sw) return;
248
251
  const to = C.fileConfig().A2A_BASE_URL || publicBase();
249
- const card = to ? `${to}/.well-known/agent-card.json` : "(see `a2a-exposed url`)";
252
+ const card = to ? `${to}/.well-known/agent-card.json` : `(see \`${CLI} url\`)`;
250
253
  console.error(`\nnote: the public URL moved${sw.from ? ` from ${sw.from}` : ""} to ${to || "workers.dev"}.`);
251
254
  if (sw.fromHost) {
252
255
  console.error(` ${sw.fromHost} no longer serves this agent: its agent card redirects (301) to the new one and every other request gets 410 Gone.`);
@@ -289,9 +292,9 @@ async function verifyDeployedCard(base) {
289
292
  if (justRegistered) step("waiting for the new workers.dev subdomain to resolve (usually 1-5 minutes)");
290
293
  const r = await verifyCard(base, { tries, intervalMs: Number(process.env.A2A_VERIFY_INTERVAL_MS || 5000),
291
294
  onWait: (why) => step(`agent card not served yet (${why}); a new deployment takes up to ~30 s to propagate, waiting`) });
292
- if (r.ok && r.problem) return console.error(`warning: the agent card at ${base}/.well-known/agent-card.json does not point peers at this deployment: ${r.problem}. Check with: a2a-exposed status`);
295
+ if (r.ok && r.problem) return console.error(`warning: the agent card at ${base}/.well-known/agent-card.json does not point peers at this deployment: ${r.problem}. Check with: ${CLI} status`);
293
296
  if (r.ok) return console.error(`agent card OK: ${r.name} (${base}/.well-known/agent-card.json)`);
294
- console.error(`warning: agent card not reachable yet at ${base} (${r.lastError || "no answer"}); ${C.get("A2A_HOSTNAME") ? "a new custom domain" : "a new workers.dev deployment or subdomain"} can take a few minutes. Check again with: a2a-exposed status`);
297
+ console.error(`warning: agent card not reachable yet at ${base} (${r.lastError || "no answer"}); ${C.get("A2A_HOSTNAME") ? "a new custom domain" : "a new workers.dev deployment or subdomain"} can take a few minutes. Check again with: ${CLI} status`);
295
298
  }
296
299
 
297
300
  /** Apply D1 migrations quietly: cf prints a bare JSON array (often `[]`) when stdout is not a TTY. */
@@ -350,7 +353,7 @@ async function deployAndLearn(dir, secrets, o) {
350
353
  if (sub && o["workers-dev-subdomain"] && sub !== o["workers-dev-subdomain"])
351
354
  console.error(`note: the account already has the workers.dev subdomain "${sub}"; using it (--workers-dev-subdomain ignored)`);
352
355
  if (sub && sub !== baked()) C.saveConfig({ A2A_WORKERS_DEV_SUBDOMAIN: sub });
353
- if (!baked()) return console.error("warning: could not find the workers.dev URL in cf's output; the Worker uses the request origin meanwhile. Check the URL in the dashboard and re-run `a2a-exposed deploy`.");
356
+ if (!baked()) return console.error(`warning: could not find the workers.dev URL in cf's output; the Worker uses the request origin meanwhile. Check the URL in the dashboard and re-run \`${CLI} deploy\`.`);
354
357
  C.saveConfig({ A2A_BASE_URL: publicBase() });
355
358
  if (baked() !== deployedWith) {
356
359
  step(`redeploying once so the agent card advertises ${publicBase()}`);
@@ -428,7 +431,7 @@ export async function init(o) {
428
431
  console.log(base);
429
432
  reportSwitch(switched);
430
433
  proxyNote(secrets);
431
- console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: a2a-exposed status (shows the next setup step)`);
434
+ console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: ${CLI} status (shows the next setup step)`);
432
435
  }
433
436
 
434
437
  /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
@@ -437,7 +440,7 @@ function proxyNote(secrets = {}) {
437
440
  if (!up) return;
438
441
  const sent = UPSTREAM_SECRETS.filter((k) => k in secrets);
439
442
  console.error(`proxy mode: authenticated A2A calls are forwarded to ${up}; the public card is the upstream's, rewritten to this deployment's URL.` +
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`);
443
+ ` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with: ${CLI} status`);
441
444
  }
442
445
 
443
446
  /** stdin and stderr are a terminal (a human at the keyboard), and not CI: only then may setup prompt. */
@@ -497,18 +500,18 @@ export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
497
500
  if (t) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return { UPSTREAM_TOKEN: t }; }
498
501
  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
502
  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`)");
503
+ console.error(`note: deploying without an upstream bearer token (saved; check the whole path with \`${CLI} status\`)`);
501
504
  return {};
502
505
  }
503
506
  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 " +
507
+ ` Provide it (never as an argument): \`read -rs UPSTREAM_TOKEN && export UPSTREAM_TOKEN\`, then re-run; or pipe it: <command that prints the token> | ${CLI} ` +
505
508
  `${C.get("A2A_D1_ID") ? "deploy" : "init ..."} --upstream-token-stdin\n` +
506
509
  " The agent needs no bearer token? Re-run with --no-upstream-token (saved for later deploys).");
507
510
  }
508
511
 
509
512
  export async function deploy(o) {
510
513
  checkNode();
511
- if (!C.get("A2A_D1_ID")) die("no saved deployment; run `a2a-exposed init` first");
514
+ if (!C.get("A2A_D1_ID")) die(`no saved deployment; run \`${CLI} init\` first`);
512
515
  // init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
513
516
  // refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
514
517
  if (!C.get("A2A_WORKER_NAME") && o["worker-name"] === undefined)
@@ -546,7 +549,7 @@ function guardTunnelUrl() {
546
549
  if (!th || !url) return;
547
550
  let host = "";
548
551
  try { host = new URL(url).host; } catch { /* invalid URL: reported elsewhere */ }
549
- if (host !== th) die(`this deployment wakes through the tunnel https://${th} (tunnel create); unset WAKE_WEBHOOK_URL, or run \`a2a-exposed tunnel rm\` first`);
552
+ if (host !== th) die(`this deployment wakes through the tunnel https://${th} (tunnel create); unset WAKE_WEBHOOK_URL, or run \`${CLI} tunnel rm\` first`);
550
553
  }
551
554
 
552
555
  export async function wake(sub, o) {
@@ -562,7 +565,7 @@ export async function wake(sub, o) {
562
565
  const local = localFingerprints(), remote = r.fingerprints || {};
563
566
  for (const [k, v] of Object.entries(local)) {
564
567
  if (!v) continue;
565
- if (!r.fingerprints) { console.error("# this Worker predates fingerprints; redeploy (a2a-exposed deploy) to compare"); break; }
568
+ if (!r.fingerprints) { console.error(`# this Worker predates fingerprints; redeploy (${CLI} deploy) to compare`); break; }
566
569
  console.error(`# ${k}: local ${v} vs uploaded ${remote[k] || "(unset)"} -> ${v === remote[k] ? "match" : "DIFFERENT"}`);
567
570
  }
568
571
  return;
@@ -570,7 +573,7 @@ export async function wake(sub, o) {
570
573
  if (sub === "test") {
571
574
  const r = await owner("POST", "/owner/wake/test", {});
572
575
  if (r.configured === false || r.info === "WAKE_WEBHOOK_URL unset")
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)");
576
+ die(`no wake webhook configured on the Worker: export WAKE_WEBHOOK_URL (and WAKE_WEBHOOK_KEY or WAKE_HMAC_SECRET), then run \`${CLI} wake set\` first (or use polling)`);
574
577
  console.log(JSON.stringify(r, null, 2));
575
578
  if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
576
579
  else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
@@ -582,7 +585,7 @@ export async function wake(sub, o) {
582
585
  if (!Object.keys(secrets).length && !DEPLOY_FLAGS.some((f) => o[f] !== undefined))
583
586
  die("nothing to set: export WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET and/or pass --preset etc.");
584
587
  await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
585
- console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
588
+ console.error(`wake settings deployed. Next: ${CLI} wake preview, then ${CLI} wake test`);
586
589
  if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
587
590
  return;
588
591
  }
package/lib/main.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import fs from "node:fs";
3
- import { CliError } from "./a2a.mjs";
3
+ import { CLI, CliError } from "./a2a.mjs";
4
4
  import * as C from "./config.mjs";
5
5
  import * as cmd from "./commands.mjs";
6
6
  import * as dep from "./deploy.mjs";
@@ -13,9 +13,10 @@ const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.met
13
13
 
14
14
  const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Cloudflare Worker inbox + wake webhook)
15
15
 
16
- Usage: a2a-exposed <command> [options] (or: npx a2a-exposed <command>)
16
+ Usage: npx -y a2a-exposed@latest <command> [options]
17
+ (optional speed-up: npm i -g a2a-exposed, then a2a-exposed <command>)
17
18
 
18
- Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx a2a-exposed ...\`, and a Cloudflare login:
19
+ Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
19
20
  npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
20
21
  CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
21
22
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
@@ -57,7 +58,7 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
57
58
  on a terminal, else exit 1 with what to do
58
59
  --upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
59
60
  --no-upstream-token the upstream needs no bearer token: skip that check (saved)
60
- --cli-command command shown in wake hints (default "npx a2a-exposed";
61
+ --cli-command command shown in wake hints (default "npx -y a2a-exposed@latest";
61
62
  e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
62
63
  --worker-dir where the Worker project (template copy) lives (default
63
64
  <config dir>/worker; --dir is the old name). Not the config dir:
@@ -110,6 +111,9 @@ Inbox (owner side)
110
111
  [--artifact] [--artifact-name N] [--force]
111
112
  history <contextId> [-n 50] [--json] conversation log
112
113
  contexts recent conversations
114
+ export [--sql] JSON dump of this inbox on stdout (peer-token hashes and
115
+ encrypted peer tokens are in the dump; the owner token stays
116
+ a Worker secret). --sql prints the same tables as SQL
113
117
 
114
118
  Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
115
119
  connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
@@ -127,6 +131,12 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
127
131
  approval password on a web page. Send it to them privately; never open or fill it
128
132
  yourself. A new link invalidates the previous one
129
133
  pair set-password the same in a terminal, no echo (the human runs it; never via argv/stdin)
134
+ pair set-oidc --issuer URL --client-id ID --subjects SUB[,SUB...] [--methods password,oidc] [--secret-stdin]
135
+ optional OpenID Connect approval on /device and the MCP consent page. The client
136
+ secret is APPROVAL_OIDC_CLIENT_SECRET in the environment, or one line on stdin with
137
+ --secret-stdin. It is not a flag and it is not saved in config.env. The next
138
+ \`npx -y a2a-exposed@latest deploy\` uploads that environment variable as a Worker
139
+ secret. Leave the issuer unset and approval stays the password page
130
140
  pair list [--json] pending pairing requests (code, claimed name/card, address), the approval mode and
131
141
  when the approval password was set
132
142
  pair approve <code> approve (only with --pairing-approval agent, after your human said yes)
@@ -171,9 +181,10 @@ const SPEC = {
171
181
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
172
182
  poll: { to: S, proto: S },
173
183
  status: { json: B },
184
+ export: { sql: B },
174
185
  upstream: { json: B },
175
186
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
176
- pair: { json: B, web: B, ttl: S },
187
+ pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
177
188
  };
178
189
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
179
190
 
@@ -187,6 +198,8 @@ export async function main(argv) {
187
198
  if (!name || name === "help" || name === "--help" || name === "-h") return console.log(HELP);
188
199
  if (name === "--version" || name === "-v") return console.log(VERSION);
189
200
  if (rest.includes("--help") || rest.includes("-h")) return console.log(HELP);
201
+ if (name === "pair" && rest[0] === "set-oidc" && rest.some((a) => /^--(secret|client-secret)(=|$)/.test(a)))
202
+ throw new CliError("the OpenID Connect client secret is not a flag: export APPROVAL_OIDC_CLIENT_SECRET or pass --secret-stdin (it is not saved in config.env)");
190
203
  if (name === "pair" && rest[0] === "set-password") {
191
204
  // only --web [--ttl M] [--json]: the password itself never comes from argv, stdin or the environment
192
205
  const extra = rest.slice(1);
@@ -203,7 +216,7 @@ export async function main(argv) {
203
216
  await dep.init(o);
204
217
  if (!o.tunnel) return;
205
218
  try { return await tun.create(o); } catch (e) {
206
- if (e instanceof CliError) e.message += "\n(the inbox is deployed and works; fix the above, then run `a2a-exposed tunnel create`, or check `a2a-exposed status`)";
219
+ if (e instanceof CliError) e.message += `\n(the inbox is deployed and works; fix the above, then run \`${CLI} tunnel create\`, or check \`${CLI} status\`)`;
207
220
  throw e;
208
221
  }
209
222
  case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
@@ -217,7 +230,7 @@ export async function main(argv) {
217
230
  case "wake": return dep.wake(p[0], o);
218
231
  case "url": {
219
232
  const u = cmd.baseUrl();
220
- if (!u) throw new CliError(`no base URL configured in ${C.CONFIG_FILE}: run \`a2a-exposed init\` (or pass --config-dir / A2A_CONFIG_DIR for another deployment)`);
233
+ if (!u) throw new CliError(`no base URL configured in ${C.CONFIG_FILE}: run \`${CLI} init\` (or pass --config-dir / A2A_CONFIG_DIR for another deployment)`);
221
234
  return console.log(u);
222
235
  }
223
236
  case "config": {
@@ -234,15 +247,17 @@ export async function main(argv) {
234
247
  return cmd.reply(need(p[0], "reply <taskId> --text ..."), o);
235
248
  case "history": return cmd.history(need(p[0], "history <contextId>"), o);
236
249
  case "contexts": return cmd.contexts();
250
+ case "export": return cmd.exportInbox(o);
237
251
  case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
238
252
  case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
239
253
  case "connect": return pair.connect(need(p[0], "connect <base-or-card-url> [--alias A] [--no-wait] [--json]"), o);
240
254
  case "pair": {
241
- const sub = need(p[0], "pair set-password|list|approve <code>|deny <code>");
255
+ const sub = need(p[0], "pair set-password|set-oidc|list|approve <code>|deny <code>");
242
256
  if (sub === "set-password") return pair.setPassword(o);
257
+ if (sub === "set-oidc") return pair.setOidc(o, p.slice(1));
243
258
  if (sub === "list") return pair.list(o);
244
259
  if (sub === "approve" || sub === "deny") return pair.decide(sub, need(p[1], `pair ${sub} <code>`));
245
- throw new CliError(`unknown pair action ${sub} (set-password|list|approve|deny)`);
260
+ throw new CliError(`unknown pair action ${sub} (set-password|set-oidc|list|approve|deny)`);
246
261
  }
247
262
  case "send": return cmd.send(o);
248
263
  case "poll": return cmd.poll(need(p[0], "poll --to <peer> <taskId>"), o);
package/lib/pair.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  // Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628).
2
- // Owner side (this inbox is the authorization server): pair set-password | list | approve | deny
2
+ // Owner side (this inbox is the authorization server): pair set-password | set-oidc | list | approve | deny
3
3
  // Client side (connect to another inbox): connect <base-or-card-url>
4
4
  import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import * as C from "./config.mjs";
8
- import { describeHttp, die, fetchCard, httpJson, isPrivateHost, pickEndpoint } from "./a2a.mjs";
8
+ import { CLI, describeHttp, die, fetchCard, httpJson, isPrivateHost, pickEndpoint } from "./a2a.mjs";
9
9
  import { baseUrl, peerToken, warnPeerTokenEnv } from "./commands.mjs";
10
10
 
11
- const CLI = "a2a-exposed";
12
11
  export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
13
12
  export const PBKDF2_ITERATIONS = 100000; // default (the Workers runtime's maximum); the Worker advertises its own setting
14
13
  export const MIN_PASSWORD_LENGTH = 12;
@@ -102,6 +101,50 @@ No terminal? \`${CLI} pair set-password --web\` prints a one-time link where you
102
101
  console.error(`Approve pairing requests at ${baseUrl()}/device: a webhook wake carries each request's direct link; polling agents see requests in \`${CLI} inbox\` and \`${CLI} pair list\`.${r.data.mode === "human" ? "" : ` Note: approval mode is ${r.data.mode}.`}`);
103
102
  }
104
103
 
104
+ /** `pair set-oidc`: non-secret settings in config.env. The client secret stays in the environment (or was read from stdin and discarded). */
105
+ export async function setOidc(o, extra = []) {
106
+ if (extra.length) die("pair set-oidc takes no extra arguments. The client secret is not an argument: export APPROVAL_OIDC_CLIENT_SECRET or pass --secret-stdin");
107
+ if (!o.issuer || !o["client-id"] || !o.subjects) die("usage: pair set-oidc --issuer https://idp.example --client-id ID --subjects sub@example.com[,other] [--methods password,oidc] [--secret-stdin]");
108
+ let issuer;
109
+ try { issuer = new URL(String(o.issuer).trim()); } catch { die("--issuer must be an https URL"); }
110
+ if (issuer.protocol !== "https:" || issuer.username || issuer.password) die("--issuer must be an https URL with no user name or password");
111
+ const clientId = String(o["client-id"]).trim();
112
+ if (!clientId || clientId.length > 200 || /\s/.test(clientId)) die("--client-id must be a single token, at most 200 characters");
113
+ const subjects = String(o.subjects).split(",").map((s) => s.trim()).filter(Boolean);
114
+ if (!subjects.length || subjects.some((s) => s.length > 320 || /[\r\n]/.test(s))) die("--subjects is a comma-separated list of allowed sub or email values");
115
+ let methods;
116
+ if (o.methods !== undefined) {
117
+ const parts = String(o.methods).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
118
+ if (!parts.length || parts.some((p) => p !== "password" && p !== "oidc")) die("--methods is a comma-separated list of password and oidc");
119
+ methods = [...new Set(parts)].join(",");
120
+ }
121
+ let sawStdin = false;
122
+ if (o["secret-stdin"]) {
123
+ if (process.stdin.isTTY) die("--secret-stdin reads the client secret from a pipe. Or export APPROVAL_OIDC_CLIENT_SECRET. It is not saved in config.env");
124
+ const chunks = [];
125
+ for await (const c of process.stdin) chunks.push(c);
126
+ const secret = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
127
+ if (!secret || /[\r\n]/.test(secret)) die("the client secret must be a single non-empty line on stdin");
128
+ sawStdin = true;
129
+ const fromEnv = process.env.APPROVAL_OIDC_CLIENT_SECRET || "";
130
+ if (fromEnv && fromEnv !== secret) console.error("The secret on stdin differs from APPROVAL_OIDC_CLIENT_SECRET. Deploy uploads the environment variable. Neither value was printed or saved.");
131
+ } else if (!(process.env.APPROVAL_OIDC_CLIENT_SECRET || "").trim()) {
132
+ die("export APPROVAL_OIDC_CLIENT_SECRET or pass --secret-stdin. The client secret is not a flag and is not saved in config.env");
133
+ }
134
+ C.saveConfig({
135
+ APPROVAL_OIDC_ISSUER: String(o.issuer).trim(),
136
+ APPROVAL_OIDC_CLIENT_ID: clientId,
137
+ APPROVAL_OIDC_ALLOWED_SUBJECTS: subjects.join(","),
138
+ ...(methods !== undefined ? { APPROVAL_METHODS: methods } : {}),
139
+ });
140
+ console.error(`OpenID Connect approval saved in ${C.CONFIG_FILE} (issuer, client id, allowed subjects${methods !== undefined ? ", methods" : ""}).`);
141
+ console.error("The client secret was not written there and was not printed.");
142
+ if (sawStdin && !(process.env.APPROVAL_OIDC_CLIENT_SECRET || "").trim())
143
+ console.error(`Export APPROVAL_OIDC_CLIENT_SECRET in the environment you use for deploy. This command did not store it.`);
144
+ console.error(`The next \`npx -y a2a-exposed@latest deploy\` uploads APPROVAL_OIDC_CLIENT_SECRET from the environment as a Worker secret.`);
145
+ console.error("Password approval stays available unless --methods oidc (and only once the issuer, client id, secret and allowlist are all set).");
146
+ }
147
+
105
148
  // ---------------------------------------------------------------- owner: list / approve / deny
106
149
  export async function list(o) {
107
150
  const r = await ownerCall("GET", "/owner/pairing");
@@ -115,6 +158,7 @@ export async function list(o) {
115
158
  if (d.mode !== "off") {
116
159
  console.log(`approval password: ${d.passwordSet ? `set${d.passwordSetAt ? ` ${d.passwordSetAt}` : ""}${d.passwordSetVia ? ` (via ${d.passwordSetVia})` : ""}`
117
160
  : `NOT SET: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal)`}`);
161
+ if (d.oidcIssuer) console.log(`OpenID Connect: ${(d.approvalMethods || []).join(" and ")} (${d.oidcIssuer})`);
118
162
  if (d.setupLinkExpiresAt) console.log(`setup link: one is open until ${d.setupLinkExpiresAt} (a new \`pair set-password --web\` replaces it)`);
119
163
  }
120
164
  if (!d.pending.length) return console.log("(no pending pairing requests)");
package/lib/status.mjs CHANGED
@@ -3,12 +3,11 @@
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, die, fingerprint, httpJson } from "./a2a.mjs";
6
+ import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
7
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
 
11
- const CLI = "a2a-exposed";
12
11
  /** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
13
12
  export const LOCAL_PRESETS = ["openclaw-wake", "openclaw-agent", "hermes"];
14
13
 
@@ -202,7 +201,8 @@ export async function collect() {
202
201
  const pr = await owner("GET", "/owner/pairing");
203
202
  if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
204
203
  s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
205
- setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length };
204
+ setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
205
+ ...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}) };
206
206
  } catch { /* older Worker (no pairing yet): leave unknown */ }
207
207
  if (s.upstream) {
208
208
  try { s.facade = await owner("GET", "/owner/facade"); }
@@ -303,7 +303,7 @@ export async function status(o) {
303
303
  if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
304
304
  const pg = s.pairing;
305
305
  if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
306
- : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
306
+ : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
307
307
  }
308
308
  rows.push(["next step", next.text]);
309
309
  for (const a of next.also || []) rows.push(["also", a]);
package/lib/tunnel.mjs CHANGED
@@ -10,7 +10,7 @@ import path from "node:path";
10
10
  import crypto from "node:crypto";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import * as C from "./config.mjs";
13
- import { CliError, die, fingerprint, httpJson } from "./a2a.mjs";
13
+ import { CLI, CliError, die, fingerprint, httpJson } from "./a2a.mjs";
14
14
  import { owner } from "./commands.mjs";
15
15
  import * as D from "./deploy.mjs";
16
16
 
@@ -63,7 +63,7 @@ export function noZoneHelp(zones) {
63
63
  : "this Cloudflare account has no domain (zone).",
64
64
  "The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the inbox is on workers.dev or a custom hostname).",
65
65
  "Without one, use scheduled polling: the inbox works as it is (setup skill: \"Agents without inbound webhooks: polling\").",
66
- "To use the tunnel later, add a domain to the account and run `a2a-exposed tunnel create` (no redeploy needed).",
66
+ `To use the tunnel later, add a domain to the account and run \`${CLI} tunnel create\` (no redeploy needed).`,
67
67
  ].join("\n");
68
68
  }
69
69
 
@@ -215,9 +215,9 @@ export function listZones(dir) {
215
215
 
216
216
  // ------------------------------------------------------------------ commands
217
217
  function needDeployment(o) {
218
- if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die("no saved deployment; run `a2a-exposed init` first");
218
+ if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
219
219
  const dir = D.workerDir(o);
220
- if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`a2a-exposed deploy\` once`);
220
+ if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
221
221
  return dir;
222
222
  }
223
223
 
@@ -293,12 +293,12 @@ export async function create(o) {
293
293
  console.log(url);
294
294
  console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
295
295
  console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
296
- console.error(`\nThen (Worker secrets apply within ~15 s): a2a-exposed tunnel status and a2a-exposed wake test`);
296
+ console.error(`\nThen (Worker secrets apply within ~15 s): ${CLI} tunnel status and ${CLI} wake test`);
297
297
  if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
298
298
  console.error(`note: no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET in the environment; keep the ${preset} webhook's own auth too (export it and run \`wake set\`)`);
299
299
  } catch (e) {
300
300
  console.error(`error: ${e.message}\n==> rolling back what was created`);
301
- await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`a2a-exposed tunnel rm\``));
301
+ await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
302
302
  throw e;
303
303
  }
304
304
  }
@@ -335,10 +335,10 @@ export const tunnelComplete = () =>
335
335
  * tunnel must be removed first. */
336
336
  async function resume(dir, o) {
337
337
  if (!tunnelComplete())
338
- die("a previous `tunnel create` did not finish (some ids are saved, see `a2a-exposed status`); run `a2a-exposed tunnel rm`, then `a2a-exposed tunnel create` again");
338
+ die(`a previous \`tunnel create\` did not finish (some ids are saved, see \`${CLI} status\`); run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\` again`);
339
339
  const host = C.get("A2A_TUNNEL_HOSTNAME");
340
340
  if (o["tunnel-hostname"] && o["tunnel-hostname"].toLowerCase() !== host)
341
- die(`a tunnel already exists on ${host}; to use another hostname run \`a2a-exposed tunnel rm\` first`);
341
+ die(`a tunnel already exists on ${host}; to use another hostname run \`${CLI} tunnel rm\` first`);
342
342
  if (!tokenFileOk()) {
343
343
  step(`the connector token file ${C.get("A2A_TUNNEL_TOKEN_FILE")} is missing or empty: downloading the tunnel token again`);
344
344
  fetchTokenFile(dir, C.get("A2A_TUNNEL_ID"));
@@ -356,11 +356,11 @@ async function resume(dir, o) {
356
356
  }
357
357
  const preset = C.get("WAKE_PRESET", "generic");
358
358
  if (p && !p.hasKey && !p.hasHmacSecret && !Object.keys(agentAuth).length && preset !== "generic")
359
- console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`a2a-exposed tunnel create\` again`);
359
+ console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`${CLI} tunnel create\` again`);
360
360
  console.log(url);
361
361
  console.error(`tunnel already set up: wakes go to ${url} -> ${C.get("A2A_TUNNEL_ORIGIN")}${C.get("A2A_TUNNEL_PATH")} (nothing new created; to change it: tunnel rm, then tunnel create)`);
362
362
  console.error(connectorInstructions(C.get("A2A_TUNNEL_TOKEN_FILE")));
363
- console.error("Then: a2a-exposed tunnel status and a2a-exposed wake test");
363
+ console.error(`Then: ${CLI} tunnel status and ${CLI} wake test`);
364
364
  }
365
365
 
366
366
  function putSecrets(dir, worker, map) {
@@ -370,7 +370,7 @@ function putSecrets(dir, worker, map) {
370
370
  export async function status(o) {
371
371
  const dir = needDeployment(o);
372
372
  const host = C.get("A2A_TUNNEL_HOSTNAME");
373
- if (!host) return console.log("no tunnel configured (a2a-exposed tunnel create)");
373
+ if (!host) return console.log(`no tunnel configured (${CLI} tunnel create)`);
374
374
  const info = { hostname: host, wakeUrl: wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/"), origin: C.get("A2A_TUNNEL_ORIGIN") };
375
375
  const tid = C.get("A2A_TUNNEL_ID");
376
376
  if (tid) {
package/lib/update.mjs CHANGED
@@ -31,7 +31,7 @@ export function newer(a, b) {
31
31
  /** The notice for `current`, given the cache contents (or "" when up to date / unknown). */
32
32
  export function notice(current, cache) {
33
33
  if (!cache || !cache.latest || !newer(cache.latest, current)) return "";
34
- return `a2a-exposed ${cache.latest} is available (you have ${current}): npm i -g a2a-exposed@latest, then a2a-exposed deploy (updates the Worker; applies new D1 migrations). Silence: A2A_NO_UPDATE_CHECK=1`;
34
+ return `a2a-exposed ${cache.latest} is available (you have ${current}): run npx -y a2a-exposed@latest deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
35
35
  }
36
36
 
37
37
  export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);