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 +4 -4
- package/lib/deploy.mjs +3 -3
- package/lib/feedback.mjs +1 -1
- package/lib/main.mjs +108 -32
- package/lib/pair.mjs +4 -4
- package/lib/status.mjs +35 -10
- package/lib/tunnel.mjs +94 -28
- package/lib/uninstall.mjs +124 -14
- package/lib/workersdev.mjs +1 -1
- package/package.json +2 -2
- package/worker/deploy.env.example +1 -1
- package/worker/package.json +1 -1
- package/worker/src/feedback.ts +1 -1
- package/worker/src/index.ts +19 -16
- package/worker/src/mcp.ts +18 -13
- package/worker/src/pairing.ts +23 -8
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +5 -4
- package/worker/test/mcp.test.ts +7 -1
- package/worker/test/pairing.test.ts +46 -1
- package/worker/test/wake.test.ts +2 -0
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
|
|
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
|
|
13
|
-
- **
|
|
14
|
-
- **Pairing (OAuth 2.0 device flow, RFC 8628):** `connect <url>` (ask another
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-<
|
|
103
|
-
only zone; with several zones pass --tunnel-zone.
|
|
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-<
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
160
|
-
10 minutes; they confirm the code with that
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"))
|
|
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
|
-
|
|
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
|
|
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":
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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)"
|