a2a-exposed 0.4.0 → 0.5.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/lib/a2a.mjs +67 -0
- package/lib/commands.mjs +41 -0
- package/lib/config.mjs +4 -6
- package/lib/deploy.mjs +124 -20
- package/lib/main.mjs +34 -8
- package/lib/pair.mjs +4 -2
- package/lib/status.mjs +121 -5
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -0
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/src/facade.ts +118 -0
- package/worker/src/index.ts +811 -16
- package/worker/src/mcp.ts +318 -0
- package/worker/src/pairing.ts +1 -1
- package/worker/src/wake.ts +12 -1
- package/worker/test/facade.test.ts +162 -1
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +15 -3
- package/worker/test/wake-retry.test.ts +51 -0
- package/worker/test/wake.test.ts +15 -0
package/lib/a2a.mjs
CHANGED
|
@@ -185,3 +185,70 @@ export async function rpc(url, version, token, method03, method1, params, peer =
|
|
|
185
185
|
die(`peer returned ${data && typeof data === "object" && data.error ? `JSON-RPC error ${describeHttp(status, data).replace(/^HTTP \d+: /, "")} (HTTP ${status})` : describeHttp(status, data)}`);
|
|
186
186
|
return data.result;
|
|
187
187
|
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------- proxy mode: what the upstream card asks for
|
|
190
|
+
// Same rules as the Worker's facade.ts (schemeKind / upstreamAuth); keep the two in step.
|
|
191
|
+
|
|
192
|
+
/** "bearer" (HTTP bearer; OAuth 2.0 / OpenID Connect also send `Authorization: Bearer`), "basic", "http", "apiKey",
|
|
193
|
+
* "mtls" or "unknown", for an A2A 1.0 ({ httpAuthSecurityScheme: ... }) or 0.3 / OpenAPI ({ type: "http", ... }) scheme. */
|
|
194
|
+
export function schemeKind(s) {
|
|
195
|
+
if (!s || typeof s !== "object") return "unknown";
|
|
196
|
+
const http = (scheme) => { const v = String(scheme || "").toLowerCase(); return v === "bearer" ? "bearer" : v === "basic" ? "basic" : "http"; };
|
|
197
|
+
if (s.httpAuthSecurityScheme) return http(s.httpAuthSecurityScheme.scheme);
|
|
198
|
+
if (s.oauth2SecurityScheme || s.openIdConnectSecurityScheme) return "bearer";
|
|
199
|
+
if (s.apiKeySecurityScheme) return "apiKey";
|
|
200
|
+
if (s.mtlsSecurityScheme || s.mutualTlsSecurityScheme) return "mtls";
|
|
201
|
+
switch (String(s.type || "").toLowerCase()) {
|
|
202
|
+
case "http": return http(s.scheme);
|
|
203
|
+
case "oauth2": case "openidconnect": return "bearer";
|
|
204
|
+
case "apikey": return "apiKey";
|
|
205
|
+
case "mutualtls": return "mtls";
|
|
206
|
+
}
|
|
207
|
+
return "unknown";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** { bearer: true | false | null, schemes: ["name: kind"], unsupported: [...] } for an upstream card. bearer: true when
|
|
211
|
+
* the card asks for a bearer token (UPSTREAM_TOKEN), false when it declares no bearer / HTTP auth (no schemes,
|
|
212
|
+
* anonymous allowed, or only schemes the façade can't present), null without a card. Requirements come from 1.0
|
|
213
|
+
* `securityRequirements` or 0.3 `security`; without requirements every declared scheme counts. */
|
|
214
|
+
export function upstreamAuth(card) {
|
|
215
|
+
if (!card || typeof card !== "object" || Array.isArray(card)) return { bearer: null, schemes: [], unsupported: [] };
|
|
216
|
+
const defs = card.securitySchemes && typeof card.securitySchemes === "object" && !Array.isArray(card.securitySchemes) ? card.securitySchemes : {};
|
|
217
|
+
const kinds = {};
|
|
218
|
+
for (const [n, s] of Object.entries(defs)) kinds[n] = schemeKind(s);
|
|
219
|
+
const schemes = Object.entries(kinds).map(([n, k]) => `${n}: ${k}`);
|
|
220
|
+
const raw = Array.isArray(card.securityRequirements) ? card.securityRequirements : Array.isArray(card.security) ? card.security : [];
|
|
221
|
+
const alts = raw.filter((r) => r && typeof r === "object" && !Array.isArray(r))
|
|
222
|
+
.map((r) => Object.keys(r.schemes && typeof r.schemes === "object" && !Array.isArray(r.schemes) ? r.schemes : r));
|
|
223
|
+
const kindOf = (n) => kinds[n] || "unknown";
|
|
224
|
+
if (!alts.length) {
|
|
225
|
+
const all = Object.keys(kinds);
|
|
226
|
+
const bearer = all.some((n) => kinds[n] === "bearer");
|
|
227
|
+
return { bearer, schemes, unsupported: bearer ? [] : all.filter((n) => kinds[n] !== "unknown").map((n) => `${n}: ${kinds[n]}`) };
|
|
228
|
+
}
|
|
229
|
+
if (alts.some((a) => a.length === 0)) return { bearer: false, schemes, unsupported: [] };
|
|
230
|
+
const bearer = alts.some((a) => a.some((n) => kindOf(n) === "bearer"));
|
|
231
|
+
return { bearer, schemes, unsupported: bearer ? [] : [...new Set(alts.flat())].map((n) => `${n}: ${kindOf(n)}`) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Fetch the upstream's card from this machine (setup check), with the Access service token from the environment
|
|
235
|
+
* when exported; only ever to the upstream origin (the caller checked it). Never sends UPSTREAM_TOKEN. Returns
|
|
236
|
+
* { card } or { card: null, error }. Redirects are not followed (an Access login redirect is reported as such). */
|
|
237
|
+
export async function fetchUpstreamCard(cardUrl, env = process.env) {
|
|
238
|
+
const headers = { accept: "application/json", "user-agent": "a2a-exposed-cli" };
|
|
239
|
+
if (env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET) {
|
|
240
|
+
headers["cf-access-client-id"] = env.UPSTREAM_ACCESS_CLIENT_ID;
|
|
241
|
+
headers["cf-access-client-secret"] = env.UPSTREAM_ACCESS_CLIENT_SECRET;
|
|
242
|
+
}
|
|
243
|
+
let r;
|
|
244
|
+
try { r = await fetch(cardUrl, { headers, redirect: "manual", signal: AbortSignal.timeout(15000) }); }
|
|
245
|
+
catch (e) { return { card: null, error: `request failed: ${e?.cause?.code || e?.cause?.message || e?.message || e}` }; }
|
|
246
|
+
const text = (await r.text().catch(() => "")).slice(0, 262144);
|
|
247
|
+
const loc = r.headers.get("location") || "";
|
|
248
|
+
if (r.status >= 300 && r.status < 400)
|
|
249
|
+
return { card: null, error: /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(loc) ? `HTTP ${r.status}: redirected to the Cloudflare Access login (export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET so this check can pass Access)` : `HTTP ${r.status} redirect` };
|
|
250
|
+
let data = null;
|
|
251
|
+
try { data = JSON.parse(text); } catch { /* not JSON */ }
|
|
252
|
+
if (r.status === 200 && data && typeof data === "object" && !Array.isArray(data)) return { card: data };
|
|
253
|
+
return { card: null, error: describeHttp(r.status, data ?? text) };
|
|
254
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -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,6 +123,14 @@ 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
135
|
die((r.data && r.data.error) || `no active token with label ${label} (see: a2a-exposed token list)`);
|
|
123
136
|
die(`could not revoke ${label}: ${describeHttp(r.status, r.data)}`);
|
|
@@ -157,7 +170,35 @@ function resolvePeer(to) {
|
|
|
157
170
|
die(`unknown peer alias ${JSON.stringify(to)} (see: a2a-exposed peers list)`);
|
|
158
171
|
}
|
|
159
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 (a2a-exposed 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: a2a-exposed 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 (a2a-exposed 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");
|
|
198
|
+
}
|
|
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;
|
package/lib/config.mjs
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
// Config storage: ~/.config/a2a-exposed/config.env (chmod 600) + peers.json.
|
|
2
|
-
// ~/.config/a2a-over-webhook: when only that one exists it is used as is (nothing is moved or copied).
|
|
1
|
+
// Config storage: ~/.config/a2a-exposed/config.env (chmod 600) + peers.json.
|
|
3
2
|
// Environment variables always override values from config.env.
|
|
4
3
|
import fs from "node:fs";
|
|
5
4
|
import os from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
|
|
8
|
-
/** <base>/a2a-exposed
|
|
9
|
-
export function defaultConfigDir(base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
|
|
10
|
-
|
|
11
|
-
return !exists(dir) && exists(legacy) ? legacy : dir;
|
|
7
|
+
/** <base>/a2a-exposed (base: $XDG_CONFIG_HOME or ~/.config). */
|
|
8
|
+
export function defaultConfigDir(base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")) {
|
|
9
|
+
return path.join(base, "a2a-exposed");
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
export const CONFIG_DIR = process.env.A2A_CONFIG_DIR || defaultConfigDir();
|
package/lib/deploy.mjs
CHANGED
|
@@ -5,11 +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, fingerprint, httpJson, randomToken, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
|
|
9
|
-
import { owner } from "./commands.mjs";
|
|
8
|
+
import { cardUrlProblem, cfErrorCode, die, fetchUpstreamCard, fingerprint, httpJson, randomToken, upstreamAuth, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
|
|
9
|
+
import { owner, ownerTry } from "./commands.mjs";
|
|
10
|
+
import { readHidden } from "./pair.mjs";
|
|
10
11
|
import * as WD from "./workersdev.mjs";
|
|
11
12
|
|
|
12
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
|
+
|
|
13
27
|
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
|
|
14
28
|
// WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
|
|
15
29
|
// by hand for your own Access-protected endpoint)
|
|
@@ -26,7 +40,7 @@ const DEPLOY_KEYS = [
|
|
|
26
40
|
"A2A_PROVIDER_ORGANIZATION", "A2A_PROVIDER_URL", "A2A_DOCUMENTATION_URL",
|
|
27
41
|
"WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
|
|
28
42
|
"WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
|
|
29
|
-
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS",
|
|
43
|
+
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
|
|
30
44
|
"A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
|
|
31
45
|
];
|
|
32
46
|
// init/deploy flag -> config key
|
|
@@ -36,17 +50,15 @@ const FLAG_KEYS = {
|
|
|
36
50
|
"provider-organization": "A2A_PROVIDER_ORGANIZATION", "provider-url": "A2A_PROVIDER_URL",
|
|
37
51
|
preset: "WAKE_PRESET", "agent-id": "WAKE_AGENT_ID", "key-header": "WAKE_KEY_HEADER", "key-prefix": "WAKE_KEY_PREFIX",
|
|
38
52
|
"body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
|
|
39
|
-
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS",
|
|
53
|
+
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS", mcp: "A2A_MCP",
|
|
40
54
|
upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
|
|
41
55
|
};
|
|
42
56
|
export const PAIRING_MODES = ["human", "agent", "off"];
|
|
43
57
|
export const DEPLOY_FLAGS = Object.keys(FLAG_KEYS);
|
|
44
58
|
|
|
45
59
|
const step = (s) => console.error(`==> ${s}`);
|
|
46
|
-
// Worker (and default D1) name
|
|
47
|
-
|
|
48
|
-
export const LEGACY_WORKER_NAME = "a2a-over-webhook";
|
|
49
|
-
export const workerName = () => C.get("A2A_WORKER_NAME") || (C.get("A2A_D1_ID") ? LEGACY_WORKER_NAME : "a2a-exposed");
|
|
60
|
+
// Worker (and default D1) name: A2A_WORKER_NAME (saved by init), else a2a-exposed.
|
|
61
|
+
export const workerName = () => C.get("A2A_WORKER_NAME") || "a2a-exposed";
|
|
50
62
|
/** No custom hostname: the Worker is served on <worker>.<account subdomain>.workers.dev (and only there). */
|
|
51
63
|
const workersDevMode = () => !C.get("A2A_HOSTNAME");
|
|
52
64
|
export const publicBase = () =>
|
|
@@ -63,6 +75,9 @@ export const NODE_HELP = `How to get Node 22 (any one; no root needed):
|
|
|
63
75
|
or the official installer / binaries: https://nodejs.org/en/download
|
|
64
76
|
Then check: node --version (v22.18.0 or newer)`;
|
|
65
77
|
|
|
78
|
+
/** What to do when the Cloudflare login itself is blocked (printed with "not logged in"; the setup skill says the same). */
|
|
79
|
+
export const LOGIN_403_HELP = `If \`cf auth login\` (or \`wrangler login\`) fails with "OAuth error: HTTP 403 Forbidden" or a "Just a moment..." page BEFORE any code is shown, that is Cloudflare's bot mitigation for datacenter / VPS IPs, not a wrong account: don't retry. Use an API token instead: export CLOUDFLARE_API_TOKEN (and CLOUDFLARE_ACCOUNT_ID) in the environment, never argv (setup skill, Troubleshooting: API token).`;
|
|
80
|
+
|
|
66
81
|
/** Dies unless `version` (default: this Node) is 22.18+; the message says how to get it. */
|
|
67
82
|
export function checkNode(version = process.versions.node) {
|
|
68
83
|
const [maj, min] = String(version).replace(/^v/, "").split(".").map(Number);
|
|
@@ -192,6 +207,10 @@ function applyFlags(o) {
|
|
|
192
207
|
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)");
|
|
193
208
|
upd.A2A_PBKDF2_ITERATIONS = String(n);
|
|
194
209
|
}
|
|
210
|
+
if (upd.A2A_MCP !== undefined) {
|
|
211
|
+
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)");
|
|
212
|
+
upd.A2A_MCP = upd.A2A_MCP === "off" ? "off" : null;
|
|
213
|
+
}
|
|
195
214
|
if (upd.A2A_WORKERS_LOGS !== undefined) {
|
|
196
215
|
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)");
|
|
197
216
|
upd.A2A_WORKERS_LOGS = upd.A2A_WORKERS_LOGS === "on" ? "1" : null;
|
|
@@ -199,7 +218,11 @@ function applyFlags(o) {
|
|
|
199
218
|
if (upd.WAKE_PRESET && !PRESETS.includes(upd.WAKE_PRESET)) die(`unknown preset ${upd.WAKE_PRESET} (${PRESETS.join(" | ")})`);
|
|
200
219
|
for (const [flag, key] of [["upstream", "A2A_UPSTREAM_URL"], ["upstream-card-url", "A2A_UPSTREAM_CARD_URL"]]) {
|
|
201
220
|
if (upd[key] === undefined) continue;
|
|
202
|
-
if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) {
|
|
221
|
+
if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) {
|
|
222
|
+
upd[key] = null;
|
|
223
|
+
if (key === "A2A_UPSTREAM_URL") upd.A2A_UPSTREAM_NO_TOKEN = null; // the opt-out belonged to that upstream
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
203
226
|
const why = upstreamUrlProblem(String(upd[key]).trim());
|
|
204
227
|
if (why) die(`--${flag} ${why}`);
|
|
205
228
|
upd[key] = new URL(String(upd[key]).trim()).href;
|
|
@@ -346,7 +369,9 @@ function deployedBase() {
|
|
|
346
369
|
|
|
347
370
|
export async function init(o) {
|
|
348
371
|
checkNode();
|
|
372
|
+
const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
|
|
349
373
|
const switched = applyFlags(o);
|
|
374
|
+
const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
|
|
350
375
|
const dir = workerDir(o);
|
|
351
376
|
C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName() });
|
|
352
377
|
if (workersDevMode()) {
|
|
@@ -361,11 +386,15 @@ export async function init(o) {
|
|
|
361
386
|
const prof = cfProfile();
|
|
362
387
|
step(`checking Cloudflare login (cf auth whoami${prof ? ` --profile ${prof}` : ""})`);
|
|
363
388
|
const who = cfJson(dir, ["auth", "whoami"]);
|
|
364
|
-
if (!who.authenticated) die(
|
|
389
|
+
if (!who.authenticated) die((process.env.CLOUDFLARE_API_TOKEN
|
|
390
|
+
? "CLOUDFLARE_API_TOKEN is set but cf does not accept it (expired, revoked, or IP-restricted: an IP filter must allow both this host's IPv4 /32 and IPv6 /128). Check the token (setup skill, Troubleshooting: API token), then re-run init"
|
|
391
|
+
: prof
|
|
365
392
|
? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
|
|
366
|
-
: "not logged in to Cloudflare: run `cf auth login --no-browser`, open the printed URL, enter the code, then re-run init");
|
|
393
|
+
: "not logged in to Cloudflare: run `cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
|
|
367
394
|
const accts = who.accounts || [];
|
|
368
395
|
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
396
|
+
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
397
|
+
die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
|
|
369
398
|
if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
|
|
370
399
|
C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
|
|
371
400
|
} else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
|
|
@@ -391,30 +420,102 @@ export async function init(o) {
|
|
|
391
420
|
if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
|
|
392
421
|
C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
|
|
393
422
|
|
|
394
|
-
const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv() };
|
|
423
|
+
const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv(), ...upstreamSecrets };
|
|
395
424
|
step(`deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})`);
|
|
396
425
|
await deployAndLearn(dir, secrets, o);
|
|
397
426
|
const base = deployedBase();
|
|
398
427
|
if (base) await verifyDeployedCard(base);
|
|
399
428
|
console.log(base);
|
|
400
429
|
reportSwitch(switched);
|
|
401
|
-
proxyNote();
|
|
430
|
+
proxyNote(secrets);
|
|
402
431
|
console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: a2a-exposed status (shows the next setup step)`);
|
|
403
432
|
}
|
|
404
433
|
|
|
405
|
-
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their
|
|
406
|
-
function proxyNote() {
|
|
434
|
+
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
|
|
435
|
+
function proxyNote(secrets = {}) {
|
|
407
436
|
const up = C.get("A2A_UPSTREAM_URL");
|
|
408
437
|
if (!up) return;
|
|
409
|
-
const sent = UPSTREAM_SECRETS.filter((k) =>
|
|
438
|
+
const sent = UPSTREAM_SECRETS.filter((k) => k in secrets);
|
|
410
439
|
console.error(`proxy mode: authenticated A2A calls are forwarded to ${up}; the public card is the upstream's, rewritten to this deployment's URL.` +
|
|
411
|
-
` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the
|
|
440
|
+
` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with: a2a-exposed status`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** stdin and stderr are a terminal (a human at the keyboard), and not CI: only then may setup prompt. */
|
|
444
|
+
const interactive = () => !!(process.stdin.isTTY && process.stderr.isTTY && !process.env.CI);
|
|
445
|
+
|
|
446
|
+
/** Proxy mode setup check: the façade must hold the bearer token the upstream agent checks (UPSTREAM_TOKEN) in addition
|
|
447
|
+
* to the Cloudflare Access service token, which only gets the Worker through the tunnel. Returns extra secrets to
|
|
448
|
+
* upload ({ UPSTREAM_TOKEN } when read from stdin or a hidden prompt), or dies with what to do. Never reads argv.
|
|
449
|
+
* Order: --upstream-token-stdin / exported UPSTREAM_TOKEN (uploaded) > --no-upstream-token (saved opt-out) > the
|
|
450
|
+
* Worker already holds one for this upstream, or the card it fetched declares no bearer > the upstream card fetched from
|
|
451
|
+
* here (with the exported Access token): no bearer declared -> fine; bearer or unknown -> hidden prompt on a terminal,
|
|
452
|
+
* else exit 1. */
|
|
453
|
+
export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
|
|
454
|
+
const up = C.get("A2A_UPSTREAM_URL");
|
|
455
|
+
if (!up) return {};
|
|
456
|
+
if (o["upstream-token-stdin"] && o["no-upstream-token"]) die("--upstream-token-stdin and --no-upstream-token are mutually exclusive");
|
|
457
|
+
if (o["upstream-token-stdin"]) {
|
|
458
|
+
const t = (process.stdin.isTTY ? await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown): ") : fs.readFileSync(0, "utf8")).trim();
|
|
459
|
+
if (!t) die("--upstream-token-stdin: no token on stdin");
|
|
460
|
+
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null });
|
|
461
|
+
return { UPSTREAM_TOKEN: t };
|
|
462
|
+
}
|
|
463
|
+
if (process.env.UPSTREAM_TOKEN) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return {}; } // uploaded with the other exported secrets
|
|
464
|
+
if (o["no-upstream-token"]) {
|
|
465
|
+
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
|
|
466
|
+
console.error("note: --no-upstream-token: deploying without an upstream bearer token (saved; `status` warns if the upstream card asks for one)");
|
|
467
|
+
return {};
|
|
468
|
+
}
|
|
469
|
+
if (C.get("A2A_UPSTREAM_NO_TOKEN") === "1") return {};
|
|
470
|
+
// an existing façade for this upstream: the Worker's secrets persist across deploys, and it reads the card through Access
|
|
471
|
+
if (!upstreamChanged && C.get("A2A_BASE_URL") && C.get("A2A_OWNER_TOKEN")) {
|
|
472
|
+
try {
|
|
473
|
+
const r = await ownerTry("GET", "/owner/facade");
|
|
474
|
+
if (r.status === 200 && r.data && r.data.mode === "proxy") {
|
|
475
|
+
if (r.data.hasUpstreamToken) return {};
|
|
476
|
+
if (r.data.upstreamAuth && r.data.upstreamAuth.bearer === false) return {};
|
|
477
|
+
}
|
|
478
|
+
} catch { /* not deployed yet / unreachable: check the card from here */ }
|
|
479
|
+
}
|
|
480
|
+
const cardUrl = C.get("A2A_UPSTREAM_CARD_URL") || new URL("/.well-known/agent-card.json", up).href;
|
|
481
|
+
step(`checking which credential the upstream's agent card asks for (${cardUrl})`);
|
|
482
|
+
const { card, error } = await fetchUpstreamCard(cardUrl);
|
|
483
|
+
const auth = upstreamAuth(card);
|
|
484
|
+
if (auth.unsupported.length)
|
|
485
|
+
console.error(`warning: the upstream card requires ${auth.unsupported.join(", ")}; the façade only presents \`Authorization: Bearer <UPSTREAM_TOKEN>\` (plus the Access service token), so the agent may refuse it`);
|
|
486
|
+
if (auth.bearer === false) {
|
|
487
|
+
console.error(`upstream card: no bearer auth declared${auth.schemes.length ? ` (schemes: ${auth.schemes.join(", ")})` : ""}, so no UPSTREAM_TOKEN is needed`);
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
const why = auth.bearer
|
|
491
|
+
? `the upstream's agent card asks for a bearer token (${auth.schemes.join(", ")})`
|
|
492
|
+
: `the upstream's agent card could not be read from here (${error}), so whether the agent needs a bearer token is unknown`;
|
|
493
|
+
const access = "Cloudflare Access (UPSTREAM_ACCESS_CLIENT_ID / _SECRET) only lets the Worker through the tunnel; the agent checks its own token too";
|
|
494
|
+
if (interactive()) {
|
|
495
|
+
console.error(`${why}. ${access}.`);
|
|
496
|
+
const t = (await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown; empty = the agent needs none): ")).trim();
|
|
497
|
+
if (t) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return { UPSTREAM_TOKEN: t }; }
|
|
498
|
+
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
|
+
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
|
|
500
|
+
console.error("note: deploying without an upstream bearer token (saved; check the whole path with `a2a-exposed status`)");
|
|
501
|
+
return {};
|
|
502
|
+
}
|
|
503
|
+
die(`${why}, and no UPSTREAM_TOKEN is set. ${access}, so without it every peer call fails with HTTP 502.\n` +
|
|
504
|
+
" Provide it (never as an argument): `read -rs UPSTREAM_TOKEN && export UPSTREAM_TOKEN`, then re-run; or pipe it: <command that prints the token> | a2a-exposed " +
|
|
505
|
+
`${C.get("A2A_D1_ID") ? "deploy" : "init ..."} --upstream-token-stdin\n` +
|
|
506
|
+
" The agent needs no bearer token? Re-run with --no-upstream-token (saved for later deploys).");
|
|
412
507
|
}
|
|
413
508
|
|
|
414
509
|
export async function deploy(o) {
|
|
415
510
|
checkNode();
|
|
416
511
|
if (!C.get("A2A_D1_ID")) die("no saved deployment; run `a2a-exposed init` first");
|
|
512
|
+
// init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
|
|
513
|
+
// refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
|
|
514
|
+
if (!C.get("A2A_WORKER_NAME") && o["worker-name"] === undefined)
|
|
515
|
+
die(`${C.CONFIG_FILE} has a saved deployment (A2A_D1_ID) but no A2A_WORKER_NAME; rerun with --worker-name <the Worker it deployed> (saved for next time)`);
|
|
516
|
+
const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
|
|
417
517
|
const switched = applyFlags(o);
|
|
518
|
+
const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
|
|
418
519
|
const dir = workerDir(o);
|
|
419
520
|
if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
|
|
420
521
|
guardTunnelUrl();
|
|
@@ -423,14 +524,14 @@ export async function deploy(o) {
|
|
|
423
524
|
if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
|
|
424
525
|
const env = cfEnv();
|
|
425
526
|
applyMigrations(dir, env);
|
|
426
|
-
const secrets = deploySecretsFromEnv();
|
|
527
|
+
const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets };
|
|
427
528
|
if (Object.keys(secrets).length && C.get("A2A_OWNER_TOKEN")) secrets.OWNER_TOKEN = C.get("A2A_OWNER_TOKEN");
|
|
428
529
|
step(Object.keys(secrets).length ? `deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})` : "deploying (existing secrets kept)");
|
|
429
530
|
await deployAndLearn(dir, secrets, o);
|
|
430
531
|
const base = deployedBase();
|
|
431
532
|
if (base) await verifyDeployedCard(base);
|
|
432
533
|
if (switched) { console.log(base); reportSwitch(switched); }
|
|
433
|
-
proxyNote();
|
|
534
|
+
proxyNote(secrets);
|
|
434
535
|
}
|
|
435
536
|
|
|
436
537
|
/** Fingerprints of the wake secrets in the local environment (what `wake set` would upload). */
|
|
@@ -472,6 +573,7 @@ export async function wake(sub, o) {
|
|
|
472
573
|
die("no wake webhook configured on the Worker: export WAKE_WEBHOOK_URL (and WAKE_WEBHOOK_KEY or WAKE_HMAC_SECRET), then run `a2a-exposed wake set` first (or use polling)");
|
|
473
574
|
console.log(JSON.stringify(r, null, 2));
|
|
474
575
|
if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
|
|
576
|
+
else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
|
|
475
577
|
return;
|
|
476
578
|
}
|
|
477
579
|
if (sub === "set") {
|
|
@@ -480,7 +582,9 @@ export async function wake(sub, o) {
|
|
|
480
582
|
if (!Object.keys(secrets).length && !DEPLOY_FLAGS.some((f) => o[f] !== undefined))
|
|
481
583
|
die("nothing to set: export WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET and/or pass --preset etc.");
|
|
482
584
|
await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
|
|
483
|
-
|
|
585
|
+
console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
|
|
586
|
+
if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
|
|
587
|
+
return;
|
|
484
588
|
}
|
|
485
589
|
if (sub === "unset") {
|
|
486
590
|
const dir = workerDir(o), name = workerName();
|
package/lib/main.mjs
CHANGED
|
@@ -16,13 +16,14 @@ const HELP = `a2a-exposed ${VERSION} - public A2A endpoint for any AI agent (Clo
|
|
|
16
16
|
Usage: a2a-exposed <command> [options] (or: npx a2a-exposed <command>)
|
|
17
17
|
|
|
18
18
|
Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx a2a-exposed ...\`, and a Cloudflare login:
|
|
19
|
-
npx cf auth login --no-browser
|
|
19
|
+
npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
|
|
20
|
+
CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
20
21
|
init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
|
|
21
22
|
[--agent-name N] [--agent-description D] [--agent-skills JSON]
|
|
22
23
|
[--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
|
|
23
24
|
[--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
24
|
-
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
|
|
25
|
-
[--upstream URL [--upstream-card-url URL]] [--worker-dir DIR]
|
|
25
|
+
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
|
|
26
|
+
[--upstream URL [--upstream-card-url URL] [--upstream-token-stdin | --no-upstream-token]] [--worker-dir DIR]
|
|
26
27
|
deploy the Worker + D1 to your account; wake secrets are read from
|
|
27
28
|
env WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET (never argv)
|
|
28
29
|
no --hostname: serve on https://<worker>.<account subdomain>.workers.dev
|
|
@@ -38,6 +39,8 @@ npx cf auth login --no-browser)
|
|
|
38
39
|
--pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
|
|
39
40
|
the Workers maximum; lower only if /device hits error 1102)
|
|
40
41
|
--workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
|
|
42
|
+
--mcp on|off remote MCP server at <url>/mcp for Claude and other MCP clients
|
|
43
|
+
(default on; each client needs your approval password once)
|
|
41
44
|
--upstream URL proxy / expose mode: a public façade for an agent that already
|
|
42
45
|
speaks A2A (JSON-RPC) on a private network. URL is its endpoint on
|
|
43
46
|
a Cloudflare Tunnel hostname behind Access (never a Tailnet/LAN
|
|
@@ -48,6 +51,12 @@ npx cf auth login --no-browser)
|
|
|
48
51
|
--upstream-card-url the upstream's card (default <upstream origin>/.well-known/
|
|
49
52
|
agent-card.json); must be on the --upstream origin (it is
|
|
50
53
|
fetched with the upstream credentials)
|
|
54
|
+
Setup reads that card: unless it declares no bearer auth, the
|
|
55
|
+
façade needs UPSTREAM_TOKEN besides the Access token (Access only
|
|
56
|
+
gets the Worker through the tunnel). Without it: a hidden prompt
|
|
57
|
+
on a terminal, else exit 1 with what to do
|
|
58
|
+
--upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
|
|
59
|
+
--no-upstream-token the upstream needs no bearer token: skip that check (saved)
|
|
51
60
|
--cli-command command shown in wake hints (default "npx a2a-exposed";
|
|
52
61
|
e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
|
|
53
62
|
--worker-dir where the Worker project (template copy) lives (default
|
|
@@ -79,9 +88,16 @@ anywhere on the account: the inbox itself can be on workers.dev or a custom host
|
|
|
79
88
|
|
|
80
89
|
Status
|
|
81
90
|
status [--json] deployment, base URL, agent card check (fetched by the CLI: no curl needed), wake
|
|
82
|
-
mode (webhook / tunnel / none = polling), tunnel state, proxy
|
|
83
|
-
card
|
|
91
|
+
mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
|
|
92
|
+
upstream card, Access credential and upstream bearer configured (separately), the
|
|
93
|
+
live upstream check, the last failed peer call, card-leak check; warnings for peer
|
|
94
|
+
token variables shadowing saved tokens; and the next step to run.
|
|
84
95
|
Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
|
|
96
|
+
upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
|
|
97
|
+
(unauthenticated call -> 401), then the Worker calls the upstream with its stored
|
|
98
|
+
secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
|
|
99
|
+
is created): Cloudflare Access, the agent's bearer check, the A2A app. Exit 1 unless
|
|
100
|
+
the app answered. \`status\` runs the same check
|
|
85
101
|
url print the public base URL (exit 1 if none is configured)
|
|
86
102
|
config print config (secrets masked) and its location
|
|
87
103
|
|
|
@@ -119,14 +135,18 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
119
135
|
Inbound peer tokens (one per peer label; paired agents get one too)
|
|
120
136
|
token issue <label> print a new token once (fails if the label is active); manual fallback to \`connect\`
|
|
121
137
|
token rotate <label> replace the label's token
|
|
122
|
-
token list | token revoke <label> list shows how each token was created (pairing: code + date)
|
|
138
|
+
token list | token revoke <label> list shows how each token was created (pairing: code + date)
|
|
139
|
+
and MCP connectors (label mcp-*); revoke works for both;
|
|
123
140
|
revoke of an unknown label exits 1
|
|
124
141
|
|
|
125
142
|
Outbound (agents you call)
|
|
126
143
|
peers add <alias> <url> [--token-env VAR | --token-stdin]
|
|
127
144
|
peers list | peers rm <alias>
|
|
145
|
+
peers sync [alias...] | peers unsync <alias>
|
|
146
|
+
upload peers (URL + token, encrypted on the Worker) so the MCP connector can send to
|
|
147
|
+
them; default: all peers. Re-run after connect, token changes or an owner-token rotation
|
|
128
148
|
a token variable set in the environment overrides config.env; when a peer token
|
|
129
|
-
differs between the two, connect/send/poll/peers warn
|
|
149
|
+
differs between the two, connect/send/poll/peers/status warn (values never shown)
|
|
130
150
|
send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
|
|
131
151
|
poll --to <alias|url> <taskId> [--proto 0.3|1.0]
|
|
132
152
|
outbound <taskId> stored state of a task you sent (incl. pushed updates)
|
|
@@ -139,7 +159,7 @@ const S = { type: "string" }, B = { type: "boolean" };
|
|
|
139
159
|
const tunnelOpts = { "tunnel-hostname": S, "tunnel-zone": S, "tunnel-origin": S, "tunnel-path": S, "show-token": B, "zero-trust-org": S, dir: S, "worker-dir": S };
|
|
140
160
|
const deployOpts = {
|
|
141
161
|
...Object.fromEntries(dep.DEPLOY_FLAGS.map((f) => [f, S])), dir: S, "worker-dir": S, cron: B, "skip-install": B, "rotate-owner-token": B,
|
|
142
|
-
"workers-dev": B, "workers-dev-subdomain": S,
|
|
162
|
+
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B,
|
|
143
163
|
};
|
|
144
164
|
const SPEC = {
|
|
145
165
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
@@ -151,6 +171,7 @@ const SPEC = {
|
|
|
151
171
|
send: { to: S, text: S, context: S, task: S, push: B, proto: S },
|
|
152
172
|
poll: { to: S, proto: S },
|
|
153
173
|
status: { json: B },
|
|
174
|
+
upstream: { json: B },
|
|
154
175
|
connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
|
|
155
176
|
pair: { json: B, web: B, ttl: S },
|
|
156
177
|
};
|
|
@@ -188,6 +209,11 @@ export async function main(argv) {
|
|
|
188
209
|
case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
|
|
189
210
|
case "deploy": return dep.deploy(o);
|
|
190
211
|
case "status": return st.status(o);
|
|
212
|
+
case "upstream": {
|
|
213
|
+
const sub = need(p[0], "upstream verify [--json]");
|
|
214
|
+
if (sub === "verify" || sub === "check") return st.upstreamVerify(o);
|
|
215
|
+
throw new CliError(`unknown upstream action ${sub} (verify)`);
|
|
216
|
+
}
|
|
191
217
|
case "wake": return dep.wake(p[0], o);
|
|
192
218
|
case "url": {
|
|
193
219
|
const u = cmd.baseUrl();
|
package/lib/pair.mjs
CHANGED
|
@@ -181,7 +181,7 @@ export function cardUrlToSend(flag, own, quiet = false) {
|
|
|
181
181
|
if (flag) {
|
|
182
182
|
let u;
|
|
183
183
|
try { u = new URL(flag); } catch { die(`--card-url must be a URL (your agent card, e.g. https://agent.example.com/.well-known/agent-card.json)`); }
|
|
184
|
-
if (u.protocol !== "https:" || u.username || u.password) { note(`note: --card-url ${u.origin} is not a plain https URL, so it is left out of the pairing request (inboxes accept only https cards)`); return ""; }
|
|
184
|
+
if (u.protocol !== "https:" || u.username || u.password) { note(`note: --card-url ${u.origin} is not a plain https URL, so it is left out of the pairing request (inboxes accept only public https cards). Peers can't call an agent there: to be reachable, expose it through a public façade (setup skill: "Already have A2A on a Tailnet or LAN") and pass the façade's card`); return ""; }
|
|
185
185
|
if (u.href.length > 300) { note("note: --card-url is longer than 300 characters, so it is left out of the pairing request"); return ""; }
|
|
186
186
|
if (isPrivateHost(u.hostname)) note(`note: ${u.hostname} is on a private network: the peer's owner sees the card as informational ("not publicly reachable"). Expose your agent through a public façade for others to reach it (setup skill: "Already have A2A on a Tailnet or LAN").`);
|
|
187
187
|
return u.href;
|
|
@@ -278,7 +278,9 @@ A different agent at the same URL? Pass another --alias.`);
|
|
|
278
278
|
const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
|
|
279
279
|
if (r.status !== 200 || !r.data.device_code || !r.data.user_code) {
|
|
280
280
|
const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
|
|
281
|
-
|
|
281
|
+
const privateCard = !!card && (() => { try { return isPrivateHost(new URL(card).hostname); } catch { return false; } })();
|
|
282
|
+
die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token`
|
|
283
|
+
: `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(card).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
|
|
282
284
|
}
|
|
283
285
|
st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
|
|
284
286
|
verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
|