a2a-exposed 0.4.1 → 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.
- package/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +62 -12
- package/lib/deploy.mjs +42 -19
- package/lib/main.mjs +32 -11
- package/lib/pair.mjs +47 -3
- package/lib/status.mjs +4 -4
- package/lib/tunnel.mjs +11 -11
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +51 -7
- package/worker/deploy.env.example +28 -1
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/migrations/0008_oidc_txns.sql +12 -0
- package/worker/package.json +6 -2
- package/worker/src/approval-oidc.ts +247 -0
- package/worker/src/backup.ts +186 -0
- package/worker/src/directory.ts +120 -0
- package/worker/src/export.ts +156 -0
- package/worker/src/hosted.ts +403 -0
- package/worker/src/index.ts +1327 -446
- package/worker/src/mcp.ts +336 -0
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +18 -5
- package/worker/src/sql-raw.d.ts +5 -0
- package/worker/src/storage.ts +100 -0
- package/worker/src/tenancy.ts +450 -0
- package/worker/src/wake.ts +14 -2
- package/worker/test/alarms.test.ts +231 -0
- package/worker/test/approval-oidc.test.ts +331 -0
- package/worker/test/bridge-thread.mjs +40 -0
- package/worker/test/d1.ts +146 -2
- package/worker/test/directory.test.ts +90 -0
- package/worker/test/export.test.ts +544 -0
- package/worker/test/facade.test.ts +1 -1
- package/worker/test/harness-worker.ts +80 -0
- package/worker/test/hosted.test.ts +221 -0
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +20 -7
- package/worker/test/register-hooks.mjs +4 -0
- package/worker/test/sql-raw-hook.mjs +22 -0
- package/worker/test/storage.test.ts +74 -0
- package/worker/test/tenancy.test.ts +217 -0
- package/worker/test/wake-retry.test.ts +74 -0
- package/worker/test/wake.test.ts +18 -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
|
-
|
|
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
|
|
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:
|
|
182
|
-
: `${who} needs a token (HTTP 401):
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
|
@@ -101,6 +101,11 @@ export async function token(action, label, o) {
|
|
|
101
101
|
const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}` : "";
|
|
102
102
|
console.log(`${r.label}\tcreated=${r.created_at}\t${r.revoked_at ? "REVOKED " + r.revoked_at : "active"}${via}`);
|
|
103
103
|
}
|
|
104
|
+
// MCP connectors (Worker 0.5+): their own tokens, listed and revoked by label like peer tokens
|
|
105
|
+
const m = await ownerTry("GET", "/owner/mcp").catch(() => null);
|
|
106
|
+
if (m && m.status === 200 && m.data && Array.isArray(m.data.grants))
|
|
107
|
+
for (const g of m.data.grants)
|
|
108
|
+
console.log(`${g.label}\tcreated=${g.created_at}\t${g.revoked_at ? "REVOKED " + g.revoked_at : "active"}\tMCP connector: ${JSON.stringify(g.client_name || "?")} -> ${g.redirect_host}${g.last_used_at ? `, last used ${g.last_used_at}` : ""}`);
|
|
104
109
|
return;
|
|
105
110
|
}
|
|
106
111
|
if (!label) die("label required");
|
|
@@ -118,8 +123,16 @@ export async function token(action, label, o) {
|
|
|
118
123
|
if (o.json) return out({ revoked: true, label });
|
|
119
124
|
return console.log(`revoked ${label}: its token gets HTTP 401 from now on`);
|
|
120
125
|
}
|
|
126
|
+
if (r.status === 404) {
|
|
127
|
+
// not a peer token: maybe an MCP connector
|
|
128
|
+
const m = await ownerTry("DELETE", `/owner/mcp/${q(label)}`).catch(() => null);
|
|
129
|
+
if (m && m.status === 200 && m.data && m.data.revoked === true) {
|
|
130
|
+
if (o.json) return out({ revoked: true, label, kind: "mcp" });
|
|
131
|
+
return console.log(`revoked ${label}: that MCP connector gets HTTP 401 from now on (remove it in the client, or reconnect to approve it again)`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
121
134
|
if (r.status === 404 || (r.status === 200 && r.data && r.data.revoked === false))
|
|
122
|
-
die((r.data && r.data.error) || `no active token with label ${label} (see:
|
|
135
|
+
die((r.data && r.data.error) || `no active token with label ${label} (see: ${CLI} token list)`);
|
|
123
136
|
die(`could not revoke ${label}: ${describeHttp(r.status, r.data)}`);
|
|
124
137
|
}
|
|
125
138
|
die(`unknown token action ${action} (issue|list|revoke|rotate)`);
|
|
@@ -154,10 +167,38 @@ function resolvePeer(to) {
|
|
|
154
167
|
for (const [alias, pe] of Object.entries(peers)) if (pe.url.replace(/\/$/, "") === to.replace(/\/$/, "")) return resolvePeer(alias);
|
|
155
168
|
return [to, to.replace(/\/$/, ""), peerToken("A2A_PEER_TOKEN")];
|
|
156
169
|
}
|
|
157
|
-
die(`unknown peer alias ${JSON.stringify(to)} (see:
|
|
170
|
+
die(`unknown peer alias ${JSON.stringify(to)} (see: ${CLI} peers list)`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** peers sync [alias...]: upload outbound peers (URL + token, encrypted on the Worker with a key derived from the owner
|
|
174
|
+
* token) so the MCP connector's send / poll_outbound tools can reach them. peers unsync <alias>: remove one. */
|
|
175
|
+
async function peersRemote(sub, args) {
|
|
176
|
+
if (sub === "unsync") {
|
|
177
|
+
if (!args.length) die("usage: peers unsync <alias>...");
|
|
178
|
+
for (const alias of args) {
|
|
179
|
+
const r = await ownerTry("DELETE", `/owner/outbound-peers/${q(alias)}`);
|
|
180
|
+
if (r.status !== 200) die((r.data && r.data.error) || `could not unsync ${alias}: ${describeHttp(r.status, r.data)}`);
|
|
181
|
+
console.log(`unsynced ${alias}: the connector can no longer send to it`);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const all = C.loadPeers();
|
|
186
|
+
const list = args.length ? args : Object.keys(all);
|
|
187
|
+
if (!list.length) die(`no peers to sync: add one first (${CLI} connect <url> or peers add)`);
|
|
188
|
+
for (const alias of list) {
|
|
189
|
+
const pe = all[alias];
|
|
190
|
+
if (!pe) die(`unknown peer alias ${JSON.stringify(alias)} (see: ${CLI} peers list)`);
|
|
191
|
+
const tok = peerToken(pe.token_env);
|
|
192
|
+
const r = await ownerTry("PUT", `/owner/outbound-peers/${q(alias)}`, { url: pe.url.replace(/\/$/, ""), ...(tok ? { token: tok } : {}) });
|
|
193
|
+
if (r.status === 404) die(`this Worker has no MCP support yet: redeploy (${CLI} deploy) and try again`);
|
|
194
|
+
if (r.status !== 200) die(`could not sync ${alias}: ${(r.data && r.data.error) || describeHttp(r.status, r.data)}`);
|
|
195
|
+
console.log(`synced ${alias}\t${pe.url}\t${tok ? "token stored encrypted on the Worker" : "no token"}`);
|
|
196
|
+
}
|
|
197
|
+
console.error("# the MCP connector's send tool can now reach these peers; re-run after connect / token changes or an owner-token rotation");
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
export function peers(sub, args, o) {
|
|
201
|
+
if (sub === "sync" || sub === "unsync") return peersRemote(sub, args);
|
|
161
202
|
const all = C.loadPeers();
|
|
162
203
|
if (sub === "add") {
|
|
163
204
|
const [alias, url] = args;
|
|
@@ -192,7 +233,7 @@ export function peers(sub, args, o) {
|
|
|
192
233
|
const inUse = new Set(Object.values(all).map((x) => x.token_env));
|
|
193
234
|
const reserved = (k) => /^(A2A_|CLOUDFLARE_|WAKE_|CF_PROFILE$)/.test(k);
|
|
194
235
|
const drop = [...vars].filter((k) => !inUse.has(k) && !reserved(k) && k in C.fileConfig());
|
|
195
|
-
if (!pe && !drop.length) die(`unknown peer alias ${JSON.stringify(alias)} (see:
|
|
236
|
+
if (!pe && !drop.length) die(`unknown peer alias ${JSON.stringify(alias)} (see: ${CLI} peers list)`);
|
|
196
237
|
if (pe) C.savePeers(all);
|
|
197
238
|
if (drop.length) C.saveConfig(Object.fromEntries(drop.map((k) => [k, null])));
|
|
198
239
|
return console.log(`removed ${alias}${drop.length ? ` (and ${drop.join(", ")} from ${C.CONFIG_FILE})` : ""}`);
|
|
@@ -237,7 +278,7 @@ export async function send(o) {
|
|
|
237
278
|
if (tid) await owner("POST", "/owner/outbound", { taskId: tid, contextId: ctx, peer: alias, endpoint: url, protocol: version, pushToken, task: obj });
|
|
238
279
|
else if (obj && obj.parts) await logHistory(ctx, { dir: "in", peer: alias, role: "agent", event: "direct_message", text: textOf(obj) });
|
|
239
280
|
out(obj);
|
|
240
|
-
if (tid) console.error(`# task ${tid}: ${plainState(obj)} (check:
|
|
281
|
+
if (tid) console.error(`# task ${tid}: ${plainState(obj)} (check: ${CLI} poll --to ${alias} ${tid})`);
|
|
241
282
|
}
|
|
242
283
|
|
|
243
284
|
export async function poll(taskId, o) {
|
|
@@ -260,3 +301,12 @@ export async function poll(taskId, o) {
|
|
|
260
301
|
}
|
|
261
302
|
|
|
262
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,12 +5,25 @@ import path from "node:path";
|
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import * as C from "./config.mjs";
|
|
8
|
-
import { cardUrlProblem, cfErrorCode, die, 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";
|
|
12
12
|
|
|
13
13
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
/** Each Claude Code routine fire is a new cloud session: a 2xx only means a session started. */
|
|
15
|
+
export const CLAUDE_CODE_NOTE = [
|
|
16
|
+
"note (claude-code): each fire starts a NEW cloud session, which sees only what the routine provides.",
|
|
17
|
+
" Simplest: add this inbox as a connector at claude.ai/settings/connectors (URL: <your Worker URL>/mcp, approve with",
|
|
18
|
+
" your approval password) and keep it enabled on the routine; the session then uses its tools, no CLI or token needed.",
|
|
19
|
+
" Without the connector (CLI fallback), check that",
|
|
20
|
+
" - the repo with the committed skills (.claude/skills/) is a routine source and the skills are on its default branch,",
|
|
21
|
+
" - A2A_BASE_URL and A2A_OWNER_TOKEN are set on the routine's cloud environment (not only in this session),",
|
|
22
|
+
" - the environment's network access allows this Worker's hostname,",
|
|
23
|
+
" - the routine prompt tells Claude to act on the routine-fire-payload block.",
|
|
24
|
+
" Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
14
27
|
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
|
|
15
28
|
// WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
|
|
16
29
|
// by hand for your own Access-protected endpoint)
|
|
@@ -18,7 +31,9 @@ export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_
|
|
|
18
31
|
// Proxy / expose mode (--upstream): the one credential the façade presents to the private upstream (Bearer), and the
|
|
19
32
|
// Cloudflare Access service token for the upstream's tunnel hostname. Read from the environment only (never argv).
|
|
20
33
|
export const UPSTREAM_SECRETS = ["UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
|
|
21
|
-
|
|
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];
|
|
22
37
|
|
|
23
38
|
// Non-secret deploy settings persisted in config.env and passed to cloudflare.config.ts as env vars.
|
|
24
39
|
const DEPLOY_KEYS = [
|
|
@@ -27,8 +42,9 @@ const DEPLOY_KEYS = [
|
|
|
27
42
|
"A2A_PROVIDER_ORGANIZATION", "A2A_PROVIDER_URL", "A2A_DOCUMENTATION_URL",
|
|
28
43
|
"WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
|
|
29
44
|
"WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
|
|
30
|
-
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS",
|
|
45
|
+
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
|
|
31
46
|
"A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
|
|
47
|
+
"APPROVAL_OIDC_ISSUER", "APPROVAL_OIDC_CLIENT_ID", "APPROVAL_OIDC_ALLOWED_SUBJECTS", "APPROVAL_METHODS",
|
|
32
48
|
];
|
|
33
49
|
// init/deploy flag -> config key
|
|
34
50
|
const FLAG_KEYS = {
|
|
@@ -37,7 +53,7 @@ const FLAG_KEYS = {
|
|
|
37
53
|
"provider-organization": "A2A_PROVIDER_ORGANIZATION", "provider-url": "A2A_PROVIDER_URL",
|
|
38
54
|
preset: "WAKE_PRESET", "agent-id": "WAKE_AGENT_ID", "key-header": "WAKE_KEY_HEADER", "key-prefix": "WAKE_KEY_PREFIX",
|
|
39
55
|
"body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
|
|
40
|
-
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS",
|
|
56
|
+
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS", mcp: "A2A_MCP",
|
|
41
57
|
upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
|
|
42
58
|
};
|
|
43
59
|
export const PAIRING_MODES = ["human", "agent", "off"];
|
|
@@ -55,7 +71,7 @@ export const workerDir = (o) => path.resolve(o.dir || C.get("A2A_WORKER_DIR") ||
|
|
|
55
71
|
/** How to get Node 22.18+ (printed by the version check; the setup skill says the same). */
|
|
56
72
|
export const NODE_HELP = `How to get Node 22 (any one; no root needed):
|
|
57
73
|
mise (recommended; the agent skill: npx skills add telegraphic-dev/mise-skill):
|
|
58
|
-
one command: mise exec node@22 -- npx a2a-exposed <command>
|
|
74
|
+
one command: mise exec node@22 -- npx -y a2a-exposed@latest <command>
|
|
59
75
|
this project: mise use node@22 (then node/npx are Node 22 in this directory)
|
|
60
76
|
nvm: nvm install 22 && nvm use 22
|
|
61
77
|
fnm: fnm install 22 && fnm use 22
|
|
@@ -151,7 +167,7 @@ export function wakeSecretsFromEnv() {
|
|
|
151
167
|
/** Secrets init/deploy upload when exported: the wake secrets plus, for proxy mode, the upstream credentials. */
|
|
152
168
|
export function deploySecretsFromEnv() {
|
|
153
169
|
const s = wakeSecretsFromEnv();
|
|
154
|
-
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];
|
|
155
171
|
return s;
|
|
156
172
|
}
|
|
157
173
|
|
|
@@ -194,6 +210,10 @@ function applyFlags(o) {
|
|
|
194
210
|
die("--pbkdf2-iterations must be an integer from 50000 to 100000 (default 100000, the Workers maximum; lower it only if /device approvals hit Cloudflare error 1102, the CPU limit)");
|
|
195
211
|
upd.A2A_PBKDF2_ITERATIONS = String(n);
|
|
196
212
|
}
|
|
213
|
+
if (upd.A2A_MCP !== undefined) {
|
|
214
|
+
if (!["on", "off"].includes(upd.A2A_MCP)) die("--mcp must be on or off (the remote MCP server at /mcp that Claude and other MCP clients add as a connector; default on)");
|
|
215
|
+
upd.A2A_MCP = upd.A2A_MCP === "off" ? "off" : null;
|
|
216
|
+
}
|
|
197
217
|
if (upd.A2A_WORKERS_LOGS !== undefined) {
|
|
198
218
|
if (!["on", "off"].includes(upd.A2A_WORKERS_LOGS)) die("--workers-logs must be on or off (Cloudflare Workers Logs: persisted, searchable Worker logs; query strings redacted)");
|
|
199
219
|
upd.A2A_WORKERS_LOGS = upd.A2A_WORKERS_LOGS === "on" ? "1" : null;
|
|
@@ -229,7 +249,7 @@ function applyFlags(o) {
|
|
|
229
249
|
function reportSwitch(sw) {
|
|
230
250
|
if (!sw) return;
|
|
231
251
|
const to = C.fileConfig().A2A_BASE_URL || publicBase();
|
|
232
|
-
const card = to ? `${to}/.well-known/agent-card.json` :
|
|
252
|
+
const card = to ? `${to}/.well-known/agent-card.json` : `(see \`${CLI} url\`)`;
|
|
233
253
|
console.error(`\nnote: the public URL moved${sw.from ? ` from ${sw.from}` : ""} to ${to || "workers.dev"}.`);
|
|
234
254
|
if (sw.fromHost) {
|
|
235
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.`);
|
|
@@ -272,9 +292,9 @@ async function verifyDeployedCard(base) {
|
|
|
272
292
|
if (justRegistered) step("waiting for the new workers.dev subdomain to resolve (usually 1-5 minutes)");
|
|
273
293
|
const r = await verifyCard(base, { tries, intervalMs: Number(process.env.A2A_VERIFY_INTERVAL_MS || 5000),
|
|
274
294
|
onWait: (why) => step(`agent card not served yet (${why}); a new deployment takes up to ~30 s to propagate, waiting`) });
|
|
275
|
-
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:
|
|
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`);
|
|
276
296
|
if (r.ok) return console.error(`agent card OK: ${r.name} (${base}/.well-known/agent-card.json)`);
|
|
277
|
-
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:
|
|
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`);
|
|
278
298
|
}
|
|
279
299
|
|
|
280
300
|
/** Apply D1 migrations quietly: cf prints a bare JSON array (often `[]`) when stdout is not a TTY. */
|
|
@@ -333,7 +353,7 @@ async function deployAndLearn(dir, secrets, o) {
|
|
|
333
353
|
if (sub && o["workers-dev-subdomain"] && sub !== o["workers-dev-subdomain"])
|
|
334
354
|
console.error(`note: the account already has the workers.dev subdomain "${sub}"; using it (--workers-dev-subdomain ignored)`);
|
|
335
355
|
if (sub && sub !== baked()) C.saveConfig({ A2A_WORKERS_DEV_SUBDOMAIN: sub });
|
|
336
|
-
if (!baked()) return console.error(
|
|
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\`.`);
|
|
337
357
|
C.saveConfig({ A2A_BASE_URL: publicBase() });
|
|
338
358
|
if (baked() !== deployedWith) {
|
|
339
359
|
step(`redeploying once so the agent card advertises ${publicBase()}`);
|
|
@@ -411,7 +431,7 @@ export async function init(o) {
|
|
|
411
431
|
console.log(base);
|
|
412
432
|
reportSwitch(switched);
|
|
413
433
|
proxyNote(secrets);
|
|
414
|
-
console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next:
|
|
434
|
+
console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: ${CLI} status (shows the next setup step)`);
|
|
415
435
|
}
|
|
416
436
|
|
|
417
437
|
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
|
|
@@ -420,7 +440,7 @@ function proxyNote(secrets = {}) {
|
|
|
420
440
|
if (!up) return;
|
|
421
441
|
const sent = UPSTREAM_SECRETS.filter((k) => k in secrets);
|
|
422
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.` +
|
|
423
|
-
` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with:
|
|
443
|
+
` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with: ${CLI} status`);
|
|
424
444
|
}
|
|
425
445
|
|
|
426
446
|
/** stdin and stderr are a terminal (a human at the keyboard), and not CI: only then may setup prompt. */
|
|
@@ -480,18 +500,18 @@ export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
|
|
|
480
500
|
if (t) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return { UPSTREAM_TOKEN: t }; }
|
|
481
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");
|
|
482
502
|
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
|
|
483
|
-
console.error(
|
|
503
|
+
console.error(`note: deploying without an upstream bearer token (saved; check the whole path with \`${CLI} status\`)`);
|
|
484
504
|
return {};
|
|
485
505
|
}
|
|
486
506
|
die(`${why}, and no UPSTREAM_TOKEN is set. ${access}, so without it every peer call fails with HTTP 502.\n` +
|
|
487
|
-
|
|
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} ` +
|
|
488
508
|
`${C.get("A2A_D1_ID") ? "deploy" : "init ..."} --upstream-token-stdin\n` +
|
|
489
509
|
" The agent needs no bearer token? Re-run with --no-upstream-token (saved for later deploys).");
|
|
490
510
|
}
|
|
491
511
|
|
|
492
512
|
export async function deploy(o) {
|
|
493
513
|
checkNode();
|
|
494
|
-
if (!C.get("A2A_D1_ID")) die(
|
|
514
|
+
if (!C.get("A2A_D1_ID")) die(`no saved deployment; run \`${CLI} init\` first`);
|
|
495
515
|
// init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
|
|
496
516
|
// refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
|
|
497
517
|
if (!C.get("A2A_WORKER_NAME") && o["worker-name"] === undefined)
|
|
@@ -529,7 +549,7 @@ function guardTunnelUrl() {
|
|
|
529
549
|
if (!th || !url) return;
|
|
530
550
|
let host = "";
|
|
531
551
|
try { host = new URL(url).host; } catch { /* invalid URL: reported elsewhere */ }
|
|
532
|
-
if (host !== th) die(`this deployment wakes through the tunnel https://${th} (tunnel create); unset WAKE_WEBHOOK_URL, or run
|
|
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`);
|
|
533
553
|
}
|
|
534
554
|
|
|
535
555
|
export async function wake(sub, o) {
|
|
@@ -545,7 +565,7 @@ export async function wake(sub, o) {
|
|
|
545
565
|
const local = localFingerprints(), remote = r.fingerprints || {};
|
|
546
566
|
for (const [k, v] of Object.entries(local)) {
|
|
547
567
|
if (!v) continue;
|
|
548
|
-
if (!r.fingerprints) { console.error(
|
|
568
|
+
if (!r.fingerprints) { console.error(`# this Worker predates fingerprints; redeploy (${CLI} deploy) to compare`); break; }
|
|
549
569
|
console.error(`# ${k}: local ${v} vs uploaded ${remote[k] || "(unset)"} -> ${v === remote[k] ? "match" : "DIFFERENT"}`);
|
|
550
570
|
}
|
|
551
571
|
return;
|
|
@@ -553,9 +573,10 @@ export async function wake(sub, o) {
|
|
|
553
573
|
if (sub === "test") {
|
|
554
574
|
const r = await owner("POST", "/owner/wake/test", {});
|
|
555
575
|
if (r.configured === false || r.info === "WAKE_WEBHOOK_URL unset")
|
|
556
|
-
die(
|
|
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)`);
|
|
557
577
|
console.log(JSON.stringify(r, null, 2));
|
|
558
578
|
if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
|
|
579
|
+
else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
|
|
559
580
|
return;
|
|
560
581
|
}
|
|
561
582
|
if (sub === "set") {
|
|
@@ -564,7 +585,9 @@ export async function wake(sub, o) {
|
|
|
564
585
|
if (!Object.keys(secrets).length && !DEPLOY_FLAGS.some((f) => o[f] !== undefined))
|
|
565
586
|
die("nothing to set: export WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET and/or pass --preset etc.");
|
|
566
587
|
await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
|
|
567
|
-
|
|
588
|
+
console.error(`wake settings deployed. Next: ${CLI} wake preview, then ${CLI} wake test`);
|
|
589
|
+
if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
|
|
590
|
+
return;
|
|
568
591
|
}
|
|
569
592
|
if (sub === "unset") {
|
|
570
593
|
const dir = workerDir(o), name = workerName();
|
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,16 +13,17 @@ 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]
|
|
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]]
|
|
22
23
|
[--agent-name N] [--agent-description D] [--agent-skills JSON]
|
|
23
24
|
[--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
|
|
24
25
|
[--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
25
|
-
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
|
|
26
|
+
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
|
|
26
27
|
[--upstream URL [--upstream-card-url URL] [--upstream-token-stdin | --no-upstream-token]] [--worker-dir DIR]
|
|
27
28
|
deploy the Worker + D1 to your account; wake secrets are read from
|
|
28
29
|
env WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET (never argv)
|
|
@@ -39,6 +40,8 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
|
39
40
|
--pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
|
|
40
41
|
the Workers maximum; lower only if /device hits error 1102)
|
|
41
42
|
--workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
|
|
43
|
+
--mcp on|off remote MCP server at <url>/mcp for Claude and other MCP clients
|
|
44
|
+
(default on; each client needs your approval password once)
|
|
42
45
|
--upstream URL proxy / expose mode: a public façade for an agent that already
|
|
43
46
|
speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
|
|
44
47
|
a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
|
|
@@ -55,7 +58,7 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
|
55
58
|
on a terminal, else exit 1 with what to do
|
|
56
59
|
--upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
|
|
57
60
|
--no-upstream-token the upstream needs no bearer token: skip that check (saved)
|
|
58
|
-
--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";
|
|
59
62
|
e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
|
|
60
63
|
--worker-dir where the Worker project (template copy) lives (default
|
|
61
64
|
<config dir>/worker; --dir is the old name). Not the config dir:
|
|
@@ -108,6 +111,9 @@ Inbox (owner side)
|
|
|
108
111
|
[--artifact] [--artifact-name N] [--force]
|
|
109
112
|
history <contextId> [-n 50] [--json] conversation log
|
|
110
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
|
|
111
117
|
|
|
112
118
|
Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
|
|
113
119
|
connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
|
|
@@ -125,6 +131,12 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
125
131
|
approval password on a web page. Send it to them privately; never open or fill it
|
|
126
132
|
yourself. A new link invalidates the previous one
|
|
127
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
|
|
128
140
|
pair list [--json] pending pairing requests (code, claimed name/card, address), the approval mode and
|
|
129
141
|
when the approval password was set
|
|
130
142
|
pair approve <code> approve (only with --pairing-approval agent, after your human said yes)
|
|
@@ -133,12 +145,16 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
133
145
|
Inbound peer tokens (one per peer label; paired agents get one too)
|
|
134
146
|
token issue <label> print a new token once (fails if the label is active); manual fallback to \`connect\`
|
|
135
147
|
token rotate <label> replace the label's token
|
|
136
|
-
token list | token revoke <label> list shows how each token was created (pairing: code + date)
|
|
148
|
+
token list | token revoke <label> list shows how each token was created (pairing: code + date)
|
|
149
|
+
and MCP connectors (label mcp-*); revoke works for both;
|
|
137
150
|
revoke of an unknown label exits 1
|
|
138
151
|
|
|
139
152
|
Outbound (agents you call)
|
|
140
153
|
peers add <alias> <url> [--token-env VAR | --token-stdin]
|
|
141
154
|
peers list | peers rm <alias>
|
|
155
|
+
peers sync [alias...] | peers unsync <alias>
|
|
156
|
+
upload peers (URL + token, encrypted on the Worker) so the MCP connector can send to
|
|
157
|
+
them; default: all peers. Re-run after connect, token changes or an owner-token rotation
|
|
142
158
|
a token variable set in the environment overrides config.env; when a peer token
|
|
143
159
|
differs between the two, connect/send/poll/peers/status warn (values never shown)
|
|
144
160
|
send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
|
|
@@ -165,9 +181,10 @@ const SPEC = {
|
|
|
165
181
|
send: { to: S, text: S, context: S, task: S, push: B, proto: S },
|
|
166
182
|
poll: { to: S, proto: S },
|
|
167
183
|
status: { json: B },
|
|
184
|
+
export: { sql: B },
|
|
168
185
|
upstream: { json: B },
|
|
169
186
|
connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
|
|
170
|
-
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 },
|
|
171
188
|
};
|
|
172
189
|
const STATES = ["completed", "input-required", "failed", "rejected", "working"];
|
|
173
190
|
|
|
@@ -181,6 +198,8 @@ export async function main(argv) {
|
|
|
181
198
|
if (!name || name === "help" || name === "--help" || name === "-h") return console.log(HELP);
|
|
182
199
|
if (name === "--version" || name === "-v") return console.log(VERSION);
|
|
183
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)");
|
|
184
203
|
if (name === "pair" && rest[0] === "set-password") {
|
|
185
204
|
// only --web [--ttl M] [--json]: the password itself never comes from argv, stdin or the environment
|
|
186
205
|
const extra = rest.slice(1);
|
|
@@ -197,7 +216,7 @@ export async function main(argv) {
|
|
|
197
216
|
await dep.init(o);
|
|
198
217
|
if (!o.tunnel) return;
|
|
199
218
|
try { return await tun.create(o); } catch (e) {
|
|
200
|
-
if (e instanceof CliError) e.message +=
|
|
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\`)`;
|
|
201
220
|
throw e;
|
|
202
221
|
}
|
|
203
222
|
case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
|
|
@@ -211,7 +230,7 @@ export async function main(argv) {
|
|
|
211
230
|
case "wake": return dep.wake(p[0], o);
|
|
212
231
|
case "url": {
|
|
213
232
|
const u = cmd.baseUrl();
|
|
214
|
-
if (!u) throw new CliError(`no base URL configured in ${C.CONFIG_FILE}: run
|
|
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)`);
|
|
215
234
|
return console.log(u);
|
|
216
235
|
}
|
|
217
236
|
case "config": {
|
|
@@ -228,15 +247,17 @@ export async function main(argv) {
|
|
|
228
247
|
return cmd.reply(need(p[0], "reply <taskId> --text ..."), o);
|
|
229
248
|
case "history": return cmd.history(need(p[0], "history <contextId>"), o);
|
|
230
249
|
case "contexts": return cmd.contexts();
|
|
250
|
+
case "export": return cmd.exportInbox(o);
|
|
231
251
|
case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
|
|
232
252
|
case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
|
|
233
253
|
case "connect": return pair.connect(need(p[0], "connect <base-or-card-url> [--alias A] [--no-wait] [--json]"), o);
|
|
234
254
|
case "pair": {
|
|
235
|
-
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>");
|
|
236
256
|
if (sub === "set-password") return pair.setPassword(o);
|
|
257
|
+
if (sub === "set-oidc") return pair.setOidc(o, p.slice(1));
|
|
237
258
|
if (sub === "list") return pair.list(o);
|
|
238
259
|
if (sub === "approve" || sub === "deny") return pair.decide(sub, need(p[1], `pair ${sub} <code>`));
|
|
239
|
-
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)`);
|
|
240
261
|
}
|
|
241
262
|
case "send": return cmd.send(o);
|
|
242
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)");
|