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