a2a-exposed 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # a2a-exposed (CLI)
2
2
 
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.
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 plus a wake webhook.
4
4
 
5
5
  ```bash
6
6
  npx -y a2a-exposed@latest --help # no install needed; @latest always runs the newest release
@@ -9,9 +9,9 @@ npx -y a2a-exposed@latest --help # no install needed; @latest always runs t
9
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.
10
10
 
11
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).
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).
13
- - **Inbox:** `inbox`, `show`, `working`, `reply`, `history`, `contexts`
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)
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 A2A endpoint 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).
13
+ - **A2A endpoint:** `inbox`, `show`, `working`, `reply`, `history`, `contexts`
14
+ - **Pairing (OAuth 2.0 device flow, RFC 8628):** `connect <url>` (ask another A2A endpoint for a token; its owner approves), `pair set-password|list|approve|deny` (approve agents that connect to you; human approval by default)
15
15
  - **Peer tokens:** `token issue|list|revoke|rotate` (manual fallback; `list` also shows tokens created by pairing)
16
16
  - **Outbound:** `peers add|list|rm`, `send`, `poll`, `outbound`
17
17
  - **Config:** `~/.config/a2a-exposed/config.env` (chmod 600; override the directory with `A2A_CONFIG_DIR`). Environment variables override file values; a peer token (`PEER_<ALIAS>_TOKEN`) that differs between the two prints a warning on stderr.
package/lib/deploy.mjs CHANGED
@@ -15,7 +15,7 @@ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
15
15
  /** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
16
16
  export const CLAUDE_CODE_NOTE = [
17
17
  "note (claude-code): each fire starts a NEW cloud session, which sees only what the routine provides.",
18
- " Simplest: add this inbox as a connector at claude.ai/settings/connectors (URL: <your Worker URL>/mcp, approve with",
18
+ " Simplest: add this A2A endpoint as a connector at claude.ai/settings/connectors (URL: <your Worker URL>/mcp, approve with",
19
19
  " your approval password) and keep it enabled on the routine; the session then uses its tools, no CLI or token needed.",
20
20
  " Without the connector (CLI fallback), check that",
21
21
  " - the repo with the committed skills (.claude/skills/) is a routine source and the skills are on its default branch,",
@@ -257,7 +257,7 @@ function reportSwitch(sw) {
257
257
  console.error(`\nnote: the public URL moved${sw.from ? ` from ${sw.from}` : ""} to ${to || "workers.dev"}.`);
258
258
  if (sw.fromHost) {
259
259
  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.`);
260
- console.error(` Cloudflare keeps the old custom domain attached to the Worker; to detach it completely, remove ${sw.fromHost} under Workers & Pages -> ${workerName()} -> Settings -> Domains & Routes.`);
260
+ console.error(` Cloudflare keeps the old custom domain attached to the Worker; to detach it completely, remove ${sw.fromHost} from that Worker's custom domains and routes in the dashboard.`);
261
261
  } else console.error(" The workers.dev URL is switched off by this deploy.");
262
262
  console.error(` Peers must update their URL for this agent: ${card} (A2A_BASE_URL in ${C.CONFIG_FILE} is updated).`);
263
263
  if (C.get("A2A_TUNNEL_ID")) console.error(" The wake tunnel is unaffected (it has its own hostname and zone).");
@@ -471,7 +471,7 @@ export async function init(o) {
471
471
  console.log(base);
472
472
  reportSwitch(switched);
473
473
  proxyNote(secrets);
474
- if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this inbox is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
474
+ if (rotated) console.error(`owner token replaced. Update A2A_OWNER_TOKEN wherever this A2A endpoint is called, then run \`${CLI} peers sync\` so uploaded peers stay readable.`);
475
475
  console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: ${CLI} status (shows the next setup step)`);
476
476
  printStar();
477
477
  }
package/lib/feedback.mjs CHANGED
@@ -233,7 +233,7 @@ export async function feedbackPublicRepo(input) {
233
233
  }
234
234
  const body = named && named.ok ? await readJson(named) : null;
235
235
  const repo = namedRepo(body?.repo);
236
- if (!repo) throw new CliError("the inbox did not name the public repository; feedback was not sent");
236
+ if (!repo) throw new CliError("the A2A endpoint did not name the public repository; feedback was not sent");
237
237
  return repo;
238
238
  }
239
239
 
package/lib/main.mjs CHANGED
@@ -14,7 +14,7 @@ import * as un from "./uninstall.mjs";
14
14
 
15
15
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
16
 
17
- const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Cloudflare Worker inbox + wake webhook)
17
+ const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Cloudflare Worker + wake webhook)
18
18
 
19
19
  Usage: npx -y a2a-exposed@latest <command> [options]
20
20
  (optional speed-up: npm i -g a2a-exposed, then a2a-exposed <command>)
@@ -23,7 +23,7 @@ Usage: npx -y a2a-exposed@latest <command> [options]
23
23
 
24
24
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
25
25
  npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
26
- CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the inbox and is open to everyone
26
+ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
27
27
  (https://a2a.exposed/pricing). When to use it, and the sentence to say:
28
28
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#hosted-inbox)
29
29
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
@@ -55,14 +55,14 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
55
55
  When GET /owner/mcp reports enabled and the agent card is reachable,
56
56
  \`status\` prints \`claude mcp add --transport http a2a-exposed <url>/mcp\`
57
57
  --rotate-owner-token on init, replace the owner token; other settings come from config.env.
58
- Then update A2A_OWNER_TOKEN wherever this inbox is called, and run \`peers sync\`
58
+ Then update A2A_OWNER_TOKEN wherever this A2A endpoint is called, and run \`peers sync\`
59
59
  --upstream URL proxy / expose mode: a public façade for an agent that already
60
60
  speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
61
61
  a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
62
62
  address). The card is the upstream's, rewritten to this public
63
63
  URL; paired peers' calls are forwarded with env UPSTREAM_TOKEN and
64
64
  UPSTREAM_ACCESS_CLIENT_ID / _SECRET (never argv). --upstream none
65
- switches back to the inbox
65
+ switches back to the A2A endpoint
66
66
  --upstream-card-url the upstream's card (default <upstream origin>/.well-known/
67
67
  agent-card.json); must be on the --upstream origin (it is
68
68
  fetched with the upstream credentials)
@@ -94,33 +94,41 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
94
94
  wake unset remove wake secrets (fall back to polling)
95
95
 
96
96
  Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
97
- anywhere on the account: the inbox itself can be on workers.dev or a custom hostname)
97
+ anywhere on the account: the A2A endpoint itself can be on workers.dev or a custom hostname)
98
98
  tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p] [--show-token]
99
99
  [--zero-trust-org TEAM]
100
100
  named Cloudflare Tunnel + proxied DNS + Access app that admits ONE service token;
101
101
  the Worker sends that token (CF-Access-Client-Id/Secret) on every wake.
102
- Wake hostname: wake-<random>.<zone>, on the inbox hostname's zone, else the account's
103
- only zone; with several zones pass --tunnel-zone. Safe to re-run (no duplicates).
102
+ Wake hostname: wake-<word>-<word>-<4 hex>.<zone>, on the A2A endpoint hostname's zone, else the
103
+ account's only zone; with several zones pass --tunnel-zone. A finished tunnel
104
+ creates nothing when you run it again. A partial tunnel stops and asks for tunnel rm.
104
105
  The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
105
- The DNS host is wake-<words>-<hex>.<zone> unless --tunnel-hostname is set.
106
+ The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
106
107
  Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
107
108
  The connector machine needs cloudflared installed
108
109
  (https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
109
110
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
110
111
  (pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600).
111
- Worker secrets show up in about 15 to 40 seconds
112
+ Worker secrets show up in about 15 to 40 seconds. Wait about 30 seconds and check again
112
113
  tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
114
+ A network error says DNS may still be propagating; retry in 30 seconds
113
115
  tunnel rm delete the DNS record, the tunnel, the Access app, and the service token
114
116
  (each printed by name), plus Worker secrets WAKE_ACCESS_CLIENT_ID,
115
117
  WAKE_ACCESS_CLIENT_SECRET, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET. The wake URL
116
- goes too when it is this tunnel's. Prints one line per object removed
117
- uninstall [--yes] remove the wake tunnel (when the config has one), the Worker, and the D1 database.
118
- One line per step, including when that object is already gone. Asks first;
119
- off a terminal, pass --yes. The config directory stays (owner token and peer
120
- tokens): remove that directory to delete them. cf runs from the Worker project
121
- when it has node_modules, otherwise from a directory with .cloudflare/cache
118
+ goes too when it is this tunnel's. Prints one line per object removed.
119
+ With nothing saved, prints "nothing to remove"
122
120
  init ... --tunnel init, then tunnel create (also with --workers-dev)
123
121
 
122
+ Remove
123
+ uninstall [--yes] [--dry-run] [--dir DIR | --worker-dir DIR]
124
+ remove the Worker, the wake tunnel (when the config has one), and the D1 database.
125
+ Prints the plan first (Worker, D1, account, and the wake hostname when a tunnel
126
+ is saved). Removal is permanent and takes the messages and paired peers with it.
127
+ --dry-run prints the plan and changes nothing. Asks first; off a terminal, pass --yes.
128
+ An empty config exits 1. The config directory stays: the owner token and WAKE_* values
129
+ stay, and a later init reuses the owner token. Remove that directory to delete them.
130
+ --dir and --worker-dir are the Worker project directory (default <config dir>/worker)
131
+
124
132
  Status
125
133
  status [--json] deployment, base URL, agent card check (fetched by the CLI: no curl needed), wake
126
134
  mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
@@ -131,8 +139,11 @@ Status
131
139
  \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
132
140
  A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
133
141
  Read-only; run it after an interruption and continue from "next step". Exit 1 = broken.
134
- Right after tunnel create, for about 15 to 40 seconds, while the connector
135
- reports 0 connections, status prints a wait-about-30-seconds line and exits 0
142
+ For about a minute after tunnel create, while the connector reports 0 connections,
143
+ status prints "waiting for the connector to connect" and exits 0. After that,
144
+ 0 connections is broken. A tunnel status of inactive is "never connected".
145
+ Worker secrets from tunnel create, wake set, and wake unset show up in about
146
+ 15 to 40 seconds. Wait about 30 seconds and check again
136
147
  upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
137
148
  (unauthenticated call -> 401), then the Worker calls the upstream with its stored
138
149
  secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
@@ -150,14 +161,14 @@ Inbox (owner side)
150
161
  [--artifact] [--artifact-name N] [--force]
151
162
  history <contextId> [-n 50] [--json] conversation log
152
163
  contexts recent conversations
153
- export [--sql] JSON dump of this inbox on stdout (peer-token hashes and
164
+ export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
154
165
  encrypted peer tokens are in the dump; the owner token stays
155
166
  a Worker secret). --sql prints the same tables as SQL
156
167
 
157
168
  Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
158
169
  connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
159
- ask another inbox for a token: prints a code + link for your human (the code lasts
160
- 10 minutes; they confirm the code with that inbox's owner), waits for approval, stores
170
+ ask another A2A endpoint for a token: prints a code + link for your human (the code lasts
171
+ 10 minutes; they confirm the code with that A2A endpoint's owner), waits for approval, stores
161
172
  the token as outbound peer A
162
173
  (never printed). --no-wait: print the code and exit; run the same command again to
163
174
  check (same code). An expired code exits 1 (nothing new is requested silently).
@@ -169,7 +180,7 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
169
180
  The name sent is the card named in the request (--card-url, otherwise this
170
181
  deployment's). --name overrides it. If that card can't be read, the name is the
171
182
  configured agent name, the worker name, or the public host's first label. The
172
- other inbox labels the token from that name and adds -2, -3, … when it is taken
183
+ other A2A endpoint labels the token from that name and adds -2, -3, … when it is taken
173
184
  pair set-password --web [--ttl MIN] [--json]
174
185
  one-time link (default 15 min, --ttl 1 to 60, single use) where your human sets or
175
186
  changes the approval password on a web page. Send it to them privately; never open
@@ -207,7 +218,7 @@ Outbound (agents you call)
207
218
  outbound <taskId> stored state of a task you sent (incl. pushed updates)
208
219
 
209
220
  Owner
210
- One inbox. The owner token from init authenticates inbox, reply, export, and the other owner
221
+ One A2A endpoint. The owner token from init authenticates inbox, reply, export, and the other owner
211
222
  commands. GET / in a browser shows the agent card. /device approves or denies a pairing request.
212
223
  pair set-password sets the approval password. pair set-oidc adds OpenID Connect on /device and
213
224
  the MCP consent page when the issuer, client id, client secret, and allowlist are all set.
@@ -219,25 +230,61 @@ Feedback
219
230
  [--connection webhook|proxy|polling] [--yes]
220
231
  report a bug or an idea. A bug needs --steps.
221
232
  The report is a public GitHub issue. The warning names
222
- the repository before it sends. A hosted inbox names
233
+ the repository before it sends. A hosted A2A endpoint names
223
234
  that repository. Otherwise the link opens
224
235
  the form on telegraphic-dev/a2a-exposed. Do not include
225
236
  confidential information: secrets, tokens, owner or pairing
226
237
  tokens, private URLs or hostnames, message contents, or
227
238
  personal data. Prints that warning and asks before sending,
228
239
  unless --yes is set. Off a terminal, --yes is required.
229
- Posts when this inbox names a feedback URL.
240
+ Posts when this A2A endpoint names a feedback URL.
230
241
  Otherwise prints a link to the bug
231
242
  report form and does not open a browser. The CLI adds its
232
- version, the inbox Worker version when the inbox reports it,
243
+ version, the A2A endpoint Worker version when the A2A endpoint reports it,
233
244
  the OS, the Node version, the connection (webhook, proxy, or
234
245
  polling), and hosted or self-hosted. --connection overrides
235
246
  that detection. Remove tokens from the message and from --logs.
236
247
 
237
248
  Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
249
+ Which clients are tested end to end is in the client table:
250
+ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
238
251
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
239
252
  Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
240
253
 
254
+ const HELP_TUNNEL = `tunnel create|status|rm
255
+
256
+ tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p]
257
+ [--show-token] [--zero-trust-org TEAM] [--dir DIR | --worker-dir DIR]
258
+ Named Cloudflare Tunnel, proxied DNS, and an Access app that admits one service token.
259
+ The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
260
+ The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
261
+ A finished tunnel creates nothing when you run it again. A partial tunnel stops and
262
+ asks for tunnel rm. Prints the Access application name and ids, then how to undo it
263
+ with tunnel rm. Worker secrets show up in about 15 to 40 seconds. Wait about 30 seconds.
264
+ tunnel status Tunnel, Access, and Worker state, plus two probes.
265
+ A network error says DNS may still be propagating; retry in 30 seconds.
266
+ Warns about Access only when the bare request returns an HTTP status other than 401 or 403.
267
+ tunnel rm Delete the DNS record, the tunnel, the Access app, and the service token, plus the
268
+ Worker secrets this tunnel uploaded. A missing Worker (Cloudflare 10007) is already gone.
269
+ With nothing saved, prints "nothing to remove".
270
+
271
+ --dir and --worker-dir are the Worker project directory (default <config dir>/worker).`;
272
+
273
+ const HELP_UNINSTALL = `uninstall [--yes] [--dry-run] [--dir DIR | --worker-dir DIR]
274
+
275
+ Remove the Worker, the wake tunnel when the config has one, and the D1 database.
276
+ Prints the plan first: Worker name, D1 name and id, account, and the wake hostname
277
+ when a tunnel is saved. Removal is permanent and takes the messages and paired peers with it.
278
+
279
+ --yes skip the prompt (required off a terminal)
280
+ --dry-run print the plan and change nothing
281
+ --dir Worker project directory (same as --worker-dir)
282
+ --worker-dir Worker project directory (default <config dir>/worker)
283
+
284
+ An empty config exits 1: nothing is deployed from that directory. Set A2A_CONFIG_DIR for another deployment.
285
+ The config directory stays. The owner token and WAKE_* values stay in config.env. A later init reuses the owner token.
286
+ Takes no other arguments.`;
287
+
241
288
  const S = { type: "string" }, B = { type: "boolean" };
242
289
  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 };
243
290
  const deployOpts = {
@@ -261,7 +308,7 @@ const SPEC = {
261
308
  login: { "control-url": S, json: B },
262
309
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
263
310
  feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
264
- uninstall: { yes: B, dir: S, "worker-dir": S },
311
+ uninstall: { yes: B, "dry-run": B, dir: S, "worker-dir": S },
265
312
  };
266
313
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
267
314
 
@@ -274,18 +321,40 @@ export async function main(argv) {
274
321
  const [name, ...rest] = argv;
275
322
  if (!name || name === "help" || name === "--help" || name === "-h") return console.log(HELP);
276
323
  if (name === "--version" || name === "-v") return console.log(VERSION);
277
- if (rest.includes("--help") || rest.includes("-h")) return console.log(HELP);
324
+ if (rest.includes("--help") || rest.includes("-h")) {
325
+ if (name === "uninstall") return console.log(HELP_UNINSTALL);
326
+ if (name === "tunnel") return console.log(HELP_TUNNEL);
327
+ return console.log(HELP);
328
+ }
278
329
  if (name === "pair" && rest[0] === "set-oidc" && rest.some((a) => /^--(secret|client-secret)(=|$)/.test(a)))
279
330
  throw new CliError("export APPROVAL_OIDC_CLIENT_SECRET or pass --secret-stdin. --secret-stdin checks the line and discards it. Deploy uploads only the environment variable");
280
331
  if (name === "pair" && rest[0] === "set-password") {
281
332
  // only --web [--ttl M] [--json]: the password itself never comes from argv, stdin or the environment
282
333
  const extra = rest.slice(1);
334
+ const ttlAt = extra.findIndex((a) => a === "--ttl" || a.startsWith("--ttl="));
335
+ if (ttlAt >= 0) {
336
+ const raw = extra[ttlAt].startsWith("--ttl=") ? extra[ttlAt].slice("--ttl=".length) : extra[ttlAt + 1];
337
+ if (raw === undefined || String(raw).startsWith("--") || !/^\d+$/.test(String(raw)))
338
+ throw new CliError("--ttl is the link lifetime in minutes, 1 to 60 (default 15)");
339
+ }
283
340
  const ok = extra.includes("--web") && extra.every((a, i) => ["--web", "--json", "--ttl"].includes(a) || /^--ttl=\d+$/.test(a) || (extra[i - 1] === "--ttl" && /^\d+$/.test(a)));
284
341
  if (extra.length && !ok)
285
342
  throw new CliError("`pair set-password` takes no password argument: it reads the password from the terminal, without echo (never from argv, stdin or the environment). For a one-time web link instead: `pair set-password --web [--ttl MIN] [--json]`");
286
343
  }
287
344
  upd.check(VERSION);
288
- const { values: o, positionals: p } = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
345
+ let parsed;
346
+ try {
347
+ parsed = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
348
+ } catch (e) {
349
+ if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
350
+ const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
351
+ if (flag === "-y") throw new CliError("unknown flag -y. To skip a prompt, pass --yes.");
352
+ throw new CliError(`unknown flag ${flag}. Run \`${CLI} ${name} --help\` for this command's flags.`);
353
+ }
354
+ if (e && String(e.code || "").startsWith("ERR_PARSE_ARGS")) throw new CliError(String(e.message));
355
+ throw e;
356
+ }
357
+ const { values: o, positionals: p } = parsed;
289
358
  if (o["worker-dir"] !== undefined) o.dir = o["worker-dir"];
290
359
  switch (name) {
291
360
  case "init":
@@ -293,11 +362,17 @@ export async function main(argv) {
293
362
  await dep.init(o);
294
363
  if (!o.tunnel) return;
295
364
  try { return await tun.create(o); } catch (e) {
296
- 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\`)`;
365
+ if (e instanceof CliError) e.message += `\n(the A2A endpoint is deployed and works; fix the above, then run \`${CLI} tunnel create\`, or check \`${CLI} status\`)`;
297
366
  throw e;
298
367
  }
299
- case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
300
- case "uninstall": return un.uninstall(o);
368
+ case "tunnel": {
369
+ const sub = need(p[0], "tunnel create|status|rm");
370
+ if (p.length > 1) throw new CliError(`tunnel ${sub} takes no arguments (${p.slice(1).join(" ")}). Run \`${CLI} tunnel --help\`.`);
371
+ return tun.tunnel(sub, o);
372
+ }
373
+ case "uninstall":
374
+ if (p.length) throw new CliError(`uninstall takes no arguments (${p.join(" ")}). Run \`${CLI} uninstall --help\`.`);
375
+ return un.uninstall(o);
301
376
  case "deploy": return dep.deploy(o);
302
377
  case "status": return st.status(o);
303
378
  case "upstream": {
@@ -314,7 +389,8 @@ export async function main(argv) {
314
389
  case "config": {
315
390
  const f = C.fileConfig();
316
391
  console.log(`# ${C.CONFIG_FILE}`);
317
- for (const [k, v] of Object.entries(f)) console.log(`${k}=${/TOKEN|KEY|SECRET/.test(k) ? "(set)" : v}`);
392
+ const showValue = (k) => k === "A2A_TUNNEL_TOKEN_FILE" || k === "A2A_TUNNEL_SECRETS_AT";
393
+ for (const [k, v] of Object.entries(f)) console.log(`${k}=${/TOKEN|KEY|SECRET/.test(k) && !showValue(k) ? "(set)" : v}`);
318
394
  return;
319
395
  }
320
396
  case "inbox": return cmd.inbox(o);
package/lib/pair.mjs CHANGED
@@ -11,7 +11,7 @@ import { baseUrl, peerToken, warnPeerTokenEnv } from "./commands.mjs";
11
11
  export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
12
12
  export const PBKDF2_ITERATIONS = 100000; // default (the Workers runtime's maximum); the Worker advertises its own setting
13
13
  export const MIN_PASSWORD_LENGTH = 12;
14
- const MIGRATION_HINT = `(if this inbox was deployed before device-flow pairing existed, run \`${CLI} deploy\` first: it applies the new D1 migration)`;
14
+ const MIGRATION_HINT = `(if this A2A endpoint was deployed before device-flow pairing existed, run \`${CLI} deploy\` first: it applies the new D1 migration)`;
15
15
 
16
16
  /** Owner API call that returns { status, data } instead of dying, so errors can be explained. */
17
17
  async function ownerCall(method, p, body) {
@@ -70,7 +70,7 @@ async function passwordLink(o) {
70
70
  }
71
71
  const r = await ownerCall("POST", "/owner/pairing/password-link", ttlSeconds ? { ttlSeconds } : {});
72
72
  if (r.status === 404)
73
- die(`this inbox's Worker predates \`pair set-password --web\`: run \`${CLI} deploy\` first, or let your human run \`${CLI} pair set-password\` in a terminal`);
73
+ die(`this A2A endpoint's Worker predates \`pair set-password --web\`: run \`${CLI} deploy\` first, or let your human run \`${CLI} pair set-password\` in a terminal`);
74
74
  if (r.status !== 200) die(`could not create a setup link: ${ownerError(r)}`);
75
75
  const d = r.data, mins = Math.round(d.expiresIn / 60);
76
76
  const tell = `Send this link to your human, privately (it lets whoever opens it ${d.passwordSet ? "change" : "set"} the approval password). They open it and type the password themselves. Never open the link, fill in the page, or ask for the password yourself. Creating a new link invalidates this one.`;
@@ -258,7 +258,7 @@ export function cardUrlToSend(flag, own, quiet = false) {
258
258
  if (flag) {
259
259
  let u;
260
260
  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)`); }
261
- 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 ""; }
261
+ 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 (A2A endpoints 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 ""; }
262
262
  if (u.href.length > 300) { note("note: --card-url is longer than 300 characters, so it is left out of the pairing request"); return ""; }
263
263
  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").`);
264
264
  return u.href;
@@ -360,7 +360,7 @@ A different agent at the same URL? Pass another --alias.`);
360
360
  const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
361
361
  const privateCard = !!sentCard && (() => { try { return isPrivateHost(new URL(sentCard).hostname); } catch { return false; } })();
362
362
  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`
363
- : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(sentCard).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")` : ""}`);
363
+ : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(sentCard).host}) is not publicly reachable, and A2A endpoints 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")` : ""}`);
364
364
  }
365
365
  st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
366
366
  verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
package/lib/status.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // calls only), so an agent that was interrupted can resume from the printed next step. The CLI fetches the agent card
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
+ import path from "node:path";
5
6
  import * as C from "./config.mjs";
6
7
  import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
7
8
  import { baseUrl, owner, ownerTry } from "./commands.mjs";
@@ -29,7 +30,7 @@ export async function checkCard(base) {
29
30
  export function wakeLine(s) {
30
31
  if (!s.wake) return "unknown (owner API not reachable)";
31
32
  const w = s.wake;
32
- if (!w.configured) return "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the inbox";
33
+ if (!w.configured) return "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the A2A endpoint";
33
34
  const auth = [w.hasKey && "bearer/API key", w.hasHmacSecret && "HMAC signature", w.hasAccessServiceToken && "Access service token"].filter(Boolean);
34
35
  return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
35
36
  }
@@ -80,6 +81,24 @@ export function shadowedPeerTokens() {
80
81
  /** How long after `tunnel create` uploads secrets `status` treats a missing Worker preview as propagation. */
81
82
  export const SECRET_PROPAGATION_MS = 60_000;
82
83
 
84
+ /** How long after `tunnel create` a connector with 0 connections is still "connecting", not broken. */
85
+ export const CONNECTOR_WAIT_MS = 60_000;
86
+
87
+ /** Cloudflare tunnel status `inactive` means the connector has never connected. */
88
+ export function tunnelStateLabel(state) {
89
+ if (state === "inactive") return "never connected";
90
+ return state || "?";
91
+ }
92
+
93
+ /** True for about a minute after `tunnel create` while the connector reports 0 connections. */
94
+ export function connectorConnecting(tunnel, now = Date.now()) {
95
+ if (!tunnel || tunnel.connections !== 0) return false;
96
+ const at = Number(tunnel.secretsAt);
97
+ if (!Number.isFinite(at) || at <= 0) return false;
98
+ const age = now - at;
99
+ return age >= 0 && age < CONNECTOR_WAIT_MS;
100
+ }
101
+
83
102
  /** True while the tunnel's Worker secrets were uploaded recently and may still be invisible to `wake preview`. */
84
103
  export function secretsPropagating(tunnel, now = Date.now()) {
85
104
  const at = Number(tunnel && tunnel.secretsAt);
@@ -99,13 +118,13 @@ export function nextStep(s) {
99
118
  function nextStepCore(s) {
100
119
  const fail = (text) => ({ ok: false, text, also: [] });
101
120
  const done = (text, also = []) => ({ ok: true, text, also });
102
- if (!s.deployed) return fail(`nothing is deployed from ${s.configFile}: run \`${CLI} init ...\` (setup skill, section 2). Another bot's deployment? Set A2A_CONFIG_DIR.`);
121
+ if (!s.deployed) return fail(`nothing is deployed from ${path.dirname(s.configFile)}. Another deployment? Set A2A_CONFIG_DIR.`);
103
122
  if (s.baseUrlEnv)
104
- return fail(`A2A_BASE_URL is exported as ${s.baseUrlEnv}, but ${s.configFile} has ${s.baseUrlSaved || "(none)"}: CLI commands (and push URLs sent to peers) use the exported value. Unset A2A_BASE_URL (the inbox URL is always the deployment's own)`);
123
+ return fail(`A2A_BASE_URL is exported as ${s.baseUrlEnv}, but ${s.configFile} has ${s.baseUrlSaved || "(none)"}: CLI commands (and push URLs sent to peers) use the exported value. Unset A2A_BASE_URL (the A2A endpoint URL is always the deployment's own)`);
105
124
  if (!s.baseUrl || !s.hasOwnerToken)
106
125
  return fail(`the deployment is incomplete (no ${!s.baseUrl ? "base URL" : "owner token"} saved): re-run \`${CLI} init\` with the same flags (safe: it reuses the D1 database and the owner token)`);
107
126
  if (!s.card.ok && s.card.cfError === 1042)
108
- return fail(`the Worker is still propagating (Cloudflare error 1042, normal for up to ~30 s after a first deploy): run \`${CLI} status\` again in 30 seconds. Still 1042 after a few minutes? Run \`${CLI} deploy\``);
127
+ return fail(`the Worker is still propagating (Cloudflare error 1042, normal for up to 30 seconds after a first deploy): run \`${CLI} status\` again in 30 seconds. Still 1042 after a few minutes? Run \`${CLI} deploy\``);
109
128
  if (!s.card.ok)
110
129
  return fail(`the agent card is not reachable (${s.card.error}). A new workers.dev subdomain or custom domain can take a few minutes: run \`${CLI} status\` again. Still failing? Run \`${CLI} deploy\``);
111
130
  if (s.card.urlProblem)
@@ -157,11 +176,13 @@ function nextStepCore(s) {
157
176
  const now = typeof s.now === "number" ? s.now : Date.now();
158
177
  if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
159
178
  if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
160
- return done(`Worker wake secrets are still propagating (about 15 to 40 seconds after tunnel create). Wait about 30 seconds and run \`${CLI} status\` again.`);
179
+ return done(`Worker wake secrets are still propagating (about 15 to 40 seconds after tunnel create). Wait about 30 seconds. Start \`cloudflared tunnel run --token-file ${t.tokenFile}\` on the agent's machine now, then run \`${CLI} status\` again.`);
161
180
  return fail(`the Worker does not have the tunnel's wake secrets: ${needsAgentSecret ? `export ${agentSecret} and ` : ""}run \`${CLI} tunnel create\` again (it re-uploads them; nothing new is created)`);
162
181
  }
182
+ if (t && connectorConnecting(t, now))
183
+ return done(`waiting for the connector to connect. Start \`cloudflared tunnel run --token-file ${t.tokenFile}\` on the agent's machine now, then run \`${CLI} status\` again.`);
163
184
  if (t && t.connections === 0)
164
- return fail(`the tunnel has no running connector: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
185
+ return fail(`the tunnel has ${t.state === "inactive" ? "never connected" : "no running connector"}: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
165
186
  if (s.wake.configured && needsAgentSecret)
166
187
  return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${s.wake.preset === "hermes" ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
167
188
  // not broken, but peers can't pair until the human sets the approval password
@@ -172,7 +193,7 @@ function nextStepCore(s) {
172
193
  also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
173
194
  const done2 = (text, extra = []) => done(text, [...extra, ...also]);
174
195
  if (!s.wake.configured) {
175
- const poll = "the agent must check the inbox on a schedule (setup skill: polling; Hermes and OpenClaw have copy-paste commands there)";
196
+ const poll = "the agent must check the A2A endpoint on a schedule (Hermes and OpenClaw have copy-paste commands in the setup references)";
176
197
  if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
177
198
  return done2(`no wake webhook: export WAKE_WEBHOOK_URL and WAKE_WEBHOOK_KEY, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
178
199
  const z = s.zones;
@@ -280,7 +301,7 @@ export function mcpAddCommand(s) {
280
301
 
281
302
  /** `upstream verify`: the façade's whole path, layer by layer; exit 1 unless the upstream app answered. */
282
303
  export async function upstreamVerify(o) {
283
- if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an inbox, check wakes with \`${CLI} wake test\``);
304
+ if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an A2A endpoint, check wakes with \`${CLI} wake test\``);
284
305
  const base = baseUrl();
285
306
  if (!base || !C.get("A2A_OWNER_TOKEN")) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
286
307
  const r = await upstreamCheck(base);
@@ -299,7 +320,11 @@ export async function status(o) {
299
320
  const s = await collect();
300
321
  const next = nextStep(s);
301
322
  const mcpAdd = mcpAddCommand(s);
302
- if (o.json) console.log(JSON.stringify({ ...s, mcpAdd, nextStep: next.text, also: next.also, ok: next.ok }, null, 2));
323
+ if (o.json) {
324
+ const out = { ...s, mcpAdd, nextStep: next.text, also: next.also, ok: next.ok };
325
+ if (out.tunnel && out.tunnel.state != null) out.tunnel = { ...out.tunnel, state: tunnelStateLabel(out.tunnel.state) };
326
+ console.log(JSON.stringify(out, null, 2));
327
+ }
303
328
  else {
304
329
  const rows = [
305
330
  ["config", s.configFile],
@@ -328,7 +353,7 @@ export async function status(o) {
328
353
  rows.push(["wake", wakeLine(s)]);
329
354
  const t = s.tunnel;
330
355
  rows.push(["tunnel", !t ? "none" : `${t.wakeUrl} -> ${t.origin || "?"}; ${!t.complete ? "INCOMPLETE" : t.tokenFileOk ? "set up" : "set up, but the connector token file is MISSING"}` +
331
- (t.connections != null ? `; ${t.state || "?"}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
356
+ (t.connections != null ? `; ${tunnelStateLabel(t.state)}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
332
357
  if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
333
358
  const pg = s.pairing;
334
359
  if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"