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/status.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
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, fingerprint, httpJson } from "./a2a.mjs";
|
|
7
|
-
import { baseUrl, owner } from "./commands.mjs";
|
|
6
|
+
import { cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
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
|
|
|
@@ -35,9 +35,58 @@ export function wakeLine(s) {
|
|
|
35
35
|
return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ---------------------------------------------------------------- proxy mode: upstream diagnosis
|
|
39
|
+
/** What to do for each reason code of the Worker's upstream check (`POST /owner/facade/verify`, Worker log
|
|
40
|
+
* `upstream_error`). `d` is the check's detail line (HTTP status, Cloudflare error; never a secret). */
|
|
41
|
+
export const UPSTREAM_FIX = {
|
|
42
|
+
access_credentials_missing: (d) => `Cloudflare Access refused the Worker (${d}). Export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (the service token the upstream's Access app admits) and run \`${CLI} deploy\``,
|
|
43
|
+
access_rejected: (d) => `Cloudflare Access refused the Worker's service token (${d}): the Access app on the upstream hostname needs a Service Auth policy for that token, and UPSTREAM_ACCESS_CLIENT_ID / _SECRET must be its current pair (export them and run \`${CLI} deploy\`)`,
|
|
44
|
+
upstream_auth_missing: (d) => `Access let the Worker through, but the agent refused the call (${d}). Export UPSTREAM_TOKEN (the bearer the agent expects; its card advertises it) and run \`${CLI} deploy\` (or pipe it: \`<print token> | ${CLI} deploy --upstream-token-stdin\`)`,
|
|
45
|
+
upstream_auth_rejected: (d) => `Access let the Worker through, but the agent refused its UPSTREAM_TOKEN (${d}): wrong or rotated. Export the current one and run \`${CLI} deploy\``,
|
|
46
|
+
tunnel_down: (d) => `Cloudflare can't reach the upstream tunnel (${d}): start the connector on the agent's machine (\`cloudflared tunnel run ...\`)`,
|
|
47
|
+
upstream_unavailable: (d) => `the tunnel answers but the agent's A2A server behind it does not (${d}): start the agent, and check the tunnel ingress \`service:\` URL`,
|
|
48
|
+
network: (d) => `the Worker could not connect to the upstream (${d}): check the tunnel hostname (DNS record, certificate)`,
|
|
49
|
+
unexpected_response: (d) => `the upstream answered without JSON-RPC (${d}): --upstream must be the agent's JSON-RPC endpoint (not its web UI or card URL); fix it with \`${CLI} deploy --upstream <URL>\``,
|
|
50
|
+
misconfigured: (d) => `the upstream URL is unusable (${d}): fix it with \`${CLI} deploy --upstream <tunnel URL>\``,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The upstream check as layer-by-layer rows: façade peer auth (public), Cloudflare Access, the agent's bearer check, the
|
|
54
|
+
* A2A app. `v`: the Worker's verify answer; `fa`: the public peer-auth probe ({ status }) or null. Pure; unit-tested. */
|
|
55
|
+
export function verifyLayers(v, fa = null) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
if (fa) rows.push(["façade peer auth", fa.status === 401 ? "enforced (an unauthenticated call gets 401 from the façade)"
|
|
58
|
+
: fa.error ? `not checked (${fa.error})` : `UNEXPECTED: an unauthenticated call got HTTP ${fa.status} (expected 401)`]);
|
|
59
|
+
if (!v) return rows;
|
|
60
|
+
const r = v.reason, sent = v.sent || {};
|
|
61
|
+
const notReached = "not reached";
|
|
62
|
+
const access = r === "access_credentials_missing" ? "FAILED: refused by Cloudflare Access; the Worker has no Access service token"
|
|
63
|
+
: r === "access_rejected" ? "FAILED: Cloudflare Access refused the Worker's service token (policy or wrong token)"
|
|
64
|
+
: ["upstream_auth_missing", "upstream_auth_rejected", "reachable", "upstream_unavailable"].includes(r) ? (sent.accessServiceToken ? "passed" : "no service token sent (the upstream hostname is not behind Access?)")
|
|
65
|
+
: r === "tunnel_down" || r === "network" ? notReached : "?";
|
|
66
|
+
const bearer = r === "upstream_auth_missing" ? "FAILED: the agent wants a credential; the Worker has no UPSTREAM_TOKEN"
|
|
67
|
+
: r === "upstream_auth_rejected" ? "FAILED: the agent refused the Worker's UPSTREAM_TOKEN"
|
|
68
|
+
: r === "reachable" ? (sent.bearer ? "accepted" : "none sent (the agent accepted the call without one)")
|
|
69
|
+
: notReached;
|
|
70
|
+
const app = r === "reachable" ? `reachable (${v.detail}${v.rpcErrorCode === -32601 ? ": method not found, as expected" : ""}; no task created)`
|
|
71
|
+
: ["tunnel_down", "upstream_unavailable", "network", "unexpected_response", "misconfigured"].includes(r) ? `FAILED: ${v.detail}` : notReached;
|
|
72
|
+
rows.push(["Access", access], ["upstream bearer", bearer], ["A2A app", app]);
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Peer token variables set in the environment that shadow a different token saved in config.env (names only). */
|
|
77
|
+
export function shadowedPeerTokens() {
|
|
78
|
+
return [...new Set(Object.values(C.loadPeers()).map((p) => p && p.token_env).filter(Boolean))].filter((k) => C.envOverridesFile(k));
|
|
79
|
+
}
|
|
80
|
+
|
|
38
81
|
/** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
|
|
39
82
|
* as separate lines (so the next step stays one short instruction). */
|
|
40
83
|
export function nextStep(s) {
|
|
84
|
+
const n = nextStepCore(s);
|
|
85
|
+
const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
|
|
86
|
+
return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function nextStepCore(s) {
|
|
41
90
|
const fail = (text) => ({ ok: false, text, also: [] });
|
|
42
91
|
const done = (text, also = []) => ({ ok: true, text, also });
|
|
43
92
|
if (!s.deployed) return fail(`nothing is deployed from ${s.configFile}: run \`${CLI} init ...\` (setup skill, section 2). Another bot's deployment? Set A2A_CONFIG_DIR.`);
|
|
@@ -62,12 +111,28 @@ export function nextStep(s) {
|
|
|
62
111
|
return fail(`${s.configFile} has A2A_UPSTREAM_URL, but the Worker is not in proxy mode (an older template or an interrupted deploy): run \`${CLI} deploy\``);
|
|
63
112
|
if (fc.upstreamProblem) return fail(`the upstream URL is unusable: ${fc.upstreamProblem}. Fix it with \`${CLI} deploy --upstream <tunnel URL>\``);
|
|
64
113
|
if (fc.upstreamCardProblem) return fail(`the upstream card URL is refused: ${fc.upstreamCardProblem}. Fix it with \`${CLI} deploy --upstream-card-url <URL on the upstream's origin>\` (or \`--upstream-card-url none\` for the default)`);
|
|
114
|
+
// the live check (owner-authenticated, run by the Worker with its stored secrets) is the precise diagnosis
|
|
115
|
+
const v = s.upstreamVerify;
|
|
116
|
+
if (v && v.reason && v.reason !== "reachable" && UPSTREAM_FIX[v.reason]) return fail(UPSTREAM_FIX[v.reason](v.detail || v.reason));
|
|
117
|
+
// an Access-only configuration must not look complete when the agent's card asks for a bearer
|
|
118
|
+
const wantsBearer = fc.upstreamAuth && fc.upstreamAuth.bearer === true;
|
|
119
|
+
if (wantsBearer && !fc.hasUpstreamToken && !(v && v.ok) && !s.upstreamNoToken)
|
|
120
|
+
return fail(`the upstream's card asks for a bearer token (${(fc.upstreamAuth.schemes || []).join(", ")}) and the Worker has no UPSTREAM_TOKEN: Cloudflare Access only lets the Worker through the tunnel, so peers' calls would fail with HTTP 502. Export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
|
|
65
121
|
if (fc.upstreamCard !== "ok")
|
|
66
122
|
return fail(`the Worker can't fetch the upstream's agent card (${fc.upstreamCard}): is the tunnel connector running on the agent's machine, and does the Access app admit the service token you exported as UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (re-run \`${CLI} deploy\` with them exported)?`);
|
|
67
123
|
if (fc.publicCardLeaks && fc.publicCardLeaks.length)
|
|
68
124
|
return fail(`the public card still names a private URL (${fc.publicCardLeaks[0]}): report this as a bug; meanwhile override the field with --agent-description / --agent-skills and run \`${CLI} deploy\``);
|
|
69
125
|
const also = [];
|
|
70
126
|
if (!fc.hasUpstreamAccessServiceToken) also.push("no Access service token for the upstream: unless the upstream hostname is protected another way, put it behind Cloudflare Access and export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET, then deploy");
|
|
127
|
+
if (wantsBearer && !fc.hasUpstreamToken)
|
|
128
|
+
also.push(`the upstream's card asks for a bearer token but the Worker has none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}${v && v.ok ? "; the agent accepts calls without it today" : ""}: if peers get HTTP 502, export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
|
|
129
|
+
if (fc.upstreamAuth && fc.upstreamAuth.unsupported && fc.upstreamAuth.unsupported.length)
|
|
130
|
+
also.push(`the upstream's card requires ${fc.upstreamAuth.unsupported.join(", ")}; the façade only presents a bearer token (UPSTREAM_TOKEN) and the Access service token`);
|
|
131
|
+
if (s.localUpstreamToken && fc.fingerprints && fc.fingerprints.upstreamToken !== s.localUpstreamToken)
|
|
132
|
+
also.push(`the UPSTREAM_TOKEN exported here (fingerprint ${s.localUpstreamToken}) differs from the Worker's (${fc.fingerprints.upstreamToken || "none"}): run \`${CLI} deploy\` to upload it`);
|
|
133
|
+
if (!v && s.upstreamVerifyError) also.push(`the live upstream check was not run (${s.upstreamVerifyError || "unavailable"})${/HTTP 404/.test(s.upstreamVerifyError || "") ? `: run \`${CLI} deploy\` for a Worker with \`upstream verify\`` : ""}`);
|
|
134
|
+
if (fc.lastFailure && fc.lastFailure.reason && v && v.ok)
|
|
135
|
+
also.push(`last failed peer call: ${fc.lastFailure.reason}${fc.lastFailure.status ? ` (HTTP ${fc.lastFailure.status})` : ""} at ${fc.lastFailure.at}; the live check passes now`);
|
|
71
136
|
if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
|
|
72
137
|
also.push(`peers can't connect yet: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal); never set it yourself`);
|
|
73
138
|
if (s.pairing && s.pairing.pending) also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
|
|
@@ -117,7 +182,9 @@ export async function collect() {
|
|
|
117
182
|
baseUrl: baseUrl(), urlKind: C.get("A2A_HOSTNAME") ? "custom domain" : "workers.dev", cron: C.get("A2A_ENABLE_CRON") === "1",
|
|
118
183
|
hasOwnerToken: !!C.get("A2A_OWNER_TOKEN"), baseUrlSaved: (C.fileConfig().A2A_BASE_URL || "").replace(/\/$/, ""), baseUrlEnv: "",
|
|
119
184
|
card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
|
|
120
|
-
upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null,
|
|
185
|
+
upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
|
|
186
|
+
upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
|
|
187
|
+
peerTokenShadowed: shadowedPeerTokens(),
|
|
121
188
|
};
|
|
122
189
|
const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
|
|
123
190
|
if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
|
|
@@ -140,6 +207,7 @@ export async function collect() {
|
|
|
140
207
|
if (s.upstream) {
|
|
141
208
|
try { s.facade = await owner("GET", "/owner/facade"); }
|
|
142
209
|
catch (e) { s.facade = { mode: "unknown", error: e.message }; }
|
|
210
|
+
if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
|
|
143
211
|
}
|
|
144
212
|
}
|
|
145
213
|
}
|
|
@@ -163,6 +231,42 @@ export async function collect() {
|
|
|
163
231
|
return s;
|
|
164
232
|
}
|
|
165
233
|
|
|
234
|
+
const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
|
|
235
|
+
|
|
236
|
+
/** The two upstream probes, neither creating anything: (1) public: an unauthenticated JSON-RPC call to the façade, which
|
|
237
|
+
* must answer 401 before looking at the method; (2) owner-authenticated: the Worker sends a JSON-RPC call with an
|
|
238
|
+
* unknown method to the upstream with its stored secrets and classifies the answer (POST /owner/facade/verify). */
|
|
239
|
+
export async function upstreamCheck(base) {
|
|
240
|
+
const out = { facadePeerAuth: null, upstreamVerify: null, upstreamVerifyError: "" };
|
|
241
|
+
try {
|
|
242
|
+
const r = await httpJson(base + "/", { method: "POST", body: { jsonrpc: "2.0", id: "a2a-exposed-verify", method: VERIFY_METHOD, params: {} }, headers: { "a2a-version": "1.0" }, timeout: 15000 });
|
|
243
|
+
out.facadePeerAuth = { status: r.status };
|
|
244
|
+
} catch (e) { out.facadePeerAuth = { status: null, error: e.message }; }
|
|
245
|
+
try {
|
|
246
|
+
const r = await ownerTry("POST", "/owner/facade/verify", {});
|
|
247
|
+
if (r.status === 200 && r.data && typeof r.data === "object" && r.data.reason) out.upstreamVerify = r.data;
|
|
248
|
+
else out.upstreamVerifyError = describeHttp(r.status, r.data);
|
|
249
|
+
} catch (e) { out.upstreamVerifyError = e.message; }
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `upstream verify`: the façade's whole path, layer by layer; exit 1 unless the upstream app answered. */
|
|
254
|
+
export async function upstreamVerify(o) {
|
|
255
|
+
if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an inbox, check wakes with \`${CLI} wake test\``);
|
|
256
|
+
const base = baseUrl();
|
|
257
|
+
if (!base || !C.get("A2A_OWNER_TOKEN")) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
|
|
258
|
+
const r = await upstreamCheck(base);
|
|
259
|
+
const v = r.upstreamVerify;
|
|
260
|
+
if (o.json) console.log(JSON.stringify({ ...r, ok: !!(v && v.ok) }, null, 2));
|
|
261
|
+
else {
|
|
262
|
+
const rows = [["upstream", C.get("A2A_UPSTREAM_URL")], ...verifyLayers(v, r.facadePeerAuth)];
|
|
263
|
+
if (!v) rows.push(["check", `FAILED: ${r.upstreamVerifyError}${/HTTP 404/.test(r.upstreamVerifyError) ? ` (this Worker predates \`upstream verify\`: run \`${CLI} deploy\`)` : ""}`]);
|
|
264
|
+
else rows.push(["result", v.ok ? "OK: the façade reaches the agent with its stored credentials" : `${v.reason}: ${UPSTREAM_FIX[v.reason] ? UPSTREAM_FIX[v.reason](v.detail || v.reason) : v.detail}`]);
|
|
265
|
+
for (const [k, val] of rows) console.log(`${(k + ":").padEnd(18)} ${val}`);
|
|
266
|
+
}
|
|
267
|
+
if (!(v && v.ok) || (r.facadePeerAuth && r.facadePeerAuth.status !== null && r.facadePeerAuth.status !== 401)) process.exitCode = 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
166
270
|
export async function status(o) {
|
|
167
271
|
const s = await collect();
|
|
168
272
|
const next = nextStep(s);
|
|
@@ -179,7 +283,18 @@ export async function status(o) {
|
|
|
179
283
|
rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
|
|
180
284
|
if (s.upstream) {
|
|
181
285
|
const f = s.facade || {};
|
|
182
|
-
rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""};
|
|
286
|
+
rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
|
|
287
|
+
if (f.mode === "proxy") {
|
|
288
|
+
const ua = f.upstreamAuth || {};
|
|
289
|
+
rows.push(["upstream Access", f.hasUpstreamAccessServiceToken ? `credential configured (UPSTREAM_ACCESS_CLIENT_ID fingerprint ${f.fingerprints?.upstreamAccessClientId || "?"})` : "NONE (UPSTREAM_ACCESS_CLIENT_ID / _SECRET not on the Worker)"]);
|
|
290
|
+
rows.push(["upstream bearer", f.hasUpstreamToken ? `bearer configured (UPSTREAM_TOKEN fingerprint ${f.fingerprints?.upstreamToken || "?"})`
|
|
291
|
+
: ua.bearer === true ? `NONE, but the upstream card asks for one (${(ua.schemes || []).join(", ")})${s.upstreamNoToken ? "; --no-upstream-token" : ""}`
|
|
292
|
+
: ua.bearer === false ? "none (the upstream card declares no bearer auth)" : `none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}`]);
|
|
293
|
+
const v = s.upstreamVerify;
|
|
294
|
+
rows.push(["upstream check", !v ? `not run (${s.upstreamVerifyError || "?"})` : v.ok ? `OK: ${verifyLayers(v).map(([k, x]) => `${k} ${x.replace(/ \(.*$/, "")}`).join("; ")}` : `FAILED (${v.reason}): ${v.detail}`]);
|
|
295
|
+
if (s.facadePeerAuth) rows.push(["peer auth", verifyLayers(null, s.facadePeerAuth)[0][1]]);
|
|
296
|
+
if (f.lastFailure && f.lastFailure.reason) rows.push(["last failure", `${f.lastFailure.reason}${f.lastFailure.status ? ` (HTTP ${f.lastFailure.status})` : ""} on ${f.lastFailure.method || "?"} from peer ${f.lastFailure.peer || "?"} at ${f.lastFailure.at}`]);
|
|
297
|
+
}
|
|
183
298
|
}
|
|
184
299
|
rows.push(["wake", wakeLine(s)]);
|
|
185
300
|
const t = s.tunnel;
|
|
@@ -192,7 +307,8 @@ export async function status(o) {
|
|
|
192
307
|
}
|
|
193
308
|
rows.push(["next step", next.text]);
|
|
194
309
|
for (const a of next.also || []) rows.push(["also", a]);
|
|
195
|
-
|
|
310
|
+
const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
|
|
311
|
+
for (const [k, v] of rows) console.log(`${(k + ":").padEnd(w)} ${v}`);
|
|
196
312
|
}
|
|
197
313
|
if (!next.ok) process.exitCode = 1;
|
|
198
314
|
}
|
package/package.json
CHANGED
|
@@ -39,6 +39,7 @@ const plain: Record<string, string> = {
|
|
|
39
39
|
RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
|
|
40
40
|
RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
|
|
41
41
|
PAIRING_APPROVAL: v("PAIRING_APPROVAL") || "human", // device-flow pairing: human (approval password on /device) | agent | off
|
|
42
|
+
MCP: v("A2A_MCP"), // remote MCP server at /mcp (connector for Claude etc.): on unless "off"; never in proxy mode
|
|
42
43
|
PBKDF2_ITERATIONS: v("A2A_PBKDF2_ITERATIONS"), // approval password hashing; default 100000 (the Workers maximum), at least 50000
|
|
43
44
|
// proxy / expose mode: forward A2A to an agent that already speaks it, published through a Tunnel hostname behind
|
|
44
45
|
// Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Remote MCP server (/mcp): OAuth 2.1 clients (dynamic registration), one-time authorization codes, and the grants
|
|
2
|
+
-- (owner-approved, inbox-scoped tokens) that `token list` shows and `token revoke` ends. Peer tokens stay in `peers`
|
|
3
|
+
-- and are never accepted on /mcp; MCP tokens are never accepted on the A2A endpoint or /owner/*.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT,
|
|
7
|
+
redirect_uris_json TEXT NOT NULL,
|
|
8
|
+
created_ms INTEGER NOT NULL,
|
|
9
|
+
ip TEXT
|
|
10
|
+
);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
12
|
+
code_hash TEXT PRIMARY KEY,
|
|
13
|
+
client_id TEXT NOT NULL,
|
|
14
|
+
redirect_uri TEXT NOT NULL,
|
|
15
|
+
code_challenge TEXT NOT NULL,
|
|
16
|
+
scope TEXT NOT NULL,
|
|
17
|
+
resource TEXT NOT NULL,
|
|
18
|
+
created_ms INTEGER NOT NULL,
|
|
19
|
+
expires_ms INTEGER NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS mcp_grants (
|
|
22
|
+
label TEXT PRIMARY KEY,
|
|
23
|
+
client_id TEXT NOT NULL,
|
|
24
|
+
client_name TEXT,
|
|
25
|
+
redirect_host TEXT,
|
|
26
|
+
scope TEXT NOT NULL,
|
|
27
|
+
access_hash TEXT UNIQUE,
|
|
28
|
+
access_expires_ms INTEGER,
|
|
29
|
+
refresh_hash TEXT UNIQUE,
|
|
30
|
+
refresh_expires_ms INTEGER,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
last_used_at TEXT,
|
|
33
|
+
revoked_at TEXT
|
|
34
|
+
);
|
|
35
|
+
-- Peers this inbox sends to from the Worker (MCP `send` / `poll_outbound`), uploaded by `peers sync`. The token is
|
|
36
|
+
-- AES-GCM ciphertext under a key derived from the Worker's OWNER_TOKEN secret, so a D1 export alone doesn't reveal it.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS outbound_peers (
|
|
38
|
+
alias TEXT PRIMARY KEY,
|
|
39
|
+
url TEXT NOT NULL,
|
|
40
|
+
token_enc TEXT,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
updated_at TEXT NOT NULL
|
|
43
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- MCP authorization (2026-07-28): OAuth Client ID Metadata Documents. A client_id that is an https URL names a JSON
|
|
2
|
+
-- document the Worker fetches (SSRF-guarded, 5 KB, 5 s) and validates; valid documents are cached here for their
|
|
3
|
+
-- Cache-Control max-age (60 s - 24 h, default 1 h). Errors and invalid documents are never cached.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS oauth_client_metadata (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT,
|
|
7
|
+
redirect_uris_json TEXT NOT NULL,
|
|
8
|
+
fetched_ms INTEGER NOT NULL,
|
|
9
|
+
expires_ms INTEGER NOT NULL
|
|
10
|
+
);
|
package/worker/src/facade.ts
CHANGED
|
@@ -252,3 +252,121 @@ export function wantsPush(op: string, p: Json): boolean {
|
|
|
252
252
|
if (op !== "send" || !p || typeof p !== "object" || !p.configuration || typeof p.configuration !== "object") return false;
|
|
253
253
|
return Object.entries(p.configuration).some(([k, v]) => /push/i.test(k) && v !== undefined && v !== null);
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
// ------------------------------------------------------------------ upstream auth + failure diagnosis (proxy mode)
|
|
257
|
+
|
|
258
|
+
/** How an upstream security scheme authenticates: "bearer" (HTTP bearer; OAuth 2.0 and OpenID Connect also send
|
|
259
|
+
* `Authorization: Bearer`), "basic" / "http" (other HTTP schemes), "apiKey", "mtls", or "unknown". Accepts the A2A 1.0
|
|
260
|
+
* shape ({ httpAuthSecurityScheme: { scheme } }, { oauth2SecurityScheme }, ...) and the 0.3 / OpenAPI shape
|
|
261
|
+
* ({ type: "http", scheme: "bearer" }, { type: "oauth2" }, ...). */
|
|
262
|
+
export function schemeKind(s: Json): string {
|
|
263
|
+
if (!s || typeof s !== "object") return "unknown";
|
|
264
|
+
const http = (scheme: unknown) => { const v = String(scheme || "").toLowerCase(); return v === "bearer" ? "bearer" : v === "basic" ? "basic" : "http"; };
|
|
265
|
+
if (s.httpAuthSecurityScheme) return http(s.httpAuthSecurityScheme.scheme);
|
|
266
|
+
if (s.oauth2SecurityScheme || s.openIdConnectSecurityScheme) return "bearer";
|
|
267
|
+
if (s.apiKeySecurityScheme) return "apiKey";
|
|
268
|
+
if (s.mtlsSecurityScheme || s.mutualTlsSecurityScheme) return "mtls";
|
|
269
|
+
switch (String(s.type || "").toLowerCase()) {
|
|
270
|
+
case "http": return http(s.scheme);
|
|
271
|
+
case "oauth2": case "openidconnect": return "bearer";
|
|
272
|
+
case "apikey": return "apiKey";
|
|
273
|
+
case "mutualtls": return "mtls";
|
|
274
|
+
}
|
|
275
|
+
return "unknown";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type UpstreamAuth = {
|
|
279
|
+
/** true: the card asks for a bearer token (UPSTREAM_TOKEN); false: it declares no bearer / HTTP auth (no schemes, or
|
|
280
|
+
* anonymous access allowed, or only schemes the façade can't present); null: no card to tell. */
|
|
281
|
+
bearer: boolean | null;
|
|
282
|
+
/** Declared schemes as "name: kind" (names and kinds only, never values). */
|
|
283
|
+
schemes: string[];
|
|
284
|
+
/** Required schemes the façade can't present (it only sends `Authorization: Bearer <UPSTREAM_TOKEN>`). */
|
|
285
|
+
unsupported: string[];
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/** What the upstream card says about authenticating to it. Security requirements are read from A2A 1.0
|
|
289
|
+
* `securityRequirements` ([{ schemes: { name: { list: [] } } }]) or 0.3 `security` ([{ name: [] }]); without
|
|
290
|
+
* requirements every declared scheme counts. An empty requirement ({}) means anonymous access is allowed. */
|
|
291
|
+
export function upstreamAuth(card: Json): UpstreamAuth {
|
|
292
|
+
if (!card || typeof card !== "object" || Array.isArray(card)) return { bearer: null, schemes: [], unsupported: [] };
|
|
293
|
+
const defs: Record<string, Json> = card.securitySchemes && typeof card.securitySchemes === "object" && !Array.isArray(card.securitySchemes) ? card.securitySchemes : {};
|
|
294
|
+
const kinds: Record<string, string> = {};
|
|
295
|
+
for (const [n, s] of Object.entries(defs)) kinds[n] = schemeKind(s);
|
|
296
|
+
const schemes = Object.entries(kinds).map(([n, k]) => `${n}: ${k}`);
|
|
297
|
+
const raw = Array.isArray(card.securityRequirements) ? card.securityRequirements : Array.isArray(card.security) ? card.security : [];
|
|
298
|
+
const alts: string[][] = raw.filter((r: Json) => r && typeof r === "object" && !Array.isArray(r))
|
|
299
|
+
.map((r: Json) => Object.keys(r.schemes && typeof r.schemes === "object" && !Array.isArray(r.schemes) ? r.schemes : r));
|
|
300
|
+
const kindOf = (n: string) => kinds[n] || "unknown";
|
|
301
|
+
if (!alts.length) {
|
|
302
|
+
const all = Object.keys(kinds);
|
|
303
|
+
return { bearer: all.some((n) => kinds[n] === "bearer"), schemes, unsupported: all.some((n) => kinds[n] === "bearer") ? [] : all.filter((n) => kinds[n] !== "unknown").map((n) => `${n}: ${kinds[n]}`) };
|
|
304
|
+
}
|
|
305
|
+
if (alts.some((a) => a.length === 0)) return { bearer: false, schemes, unsupported: [] }; // anonymous allowed
|
|
306
|
+
const bearer = alts.some((a) => a.some((n) => kindOf(n) === "bearer"));
|
|
307
|
+
const unsupported = bearer ? [] : [...new Set(alts.flat())].map((n) => `${n}: ${kindOf(n)}`);
|
|
308
|
+
return { bearer, schemes, unsupported };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Why a call to the upstream did not produce a JSON-RPC answer, as a stable reason code (Worker logs, the owner
|
|
312
|
+
* diagnostics, `status`). Never shown to peers: their 502 stays generic. */
|
|
313
|
+
export type UpstreamReason =
|
|
314
|
+
| "reachable" // a JSON-RPC response came back (any result or error): Access, the tunnel and the bearer all passed
|
|
315
|
+
| "access_credentials_missing" // Cloudflare Access refused the request and the façade has no Access service token
|
|
316
|
+
| "access_rejected" // Cloudflare Access refused the façade's service token (wrong token, or not admitted by the app's policy)
|
|
317
|
+
| "upstream_auth_missing" // the agent answered 401/403 and the façade has no UPSTREAM_TOKEN
|
|
318
|
+
| "upstream_auth_rejected" // the agent answered 401/403 to the façade's UPSTREAM_TOKEN
|
|
319
|
+
| "tunnel_down" // Cloudflare could not reach the tunnel / origin (error 1033, 1016, 530, ...)
|
|
320
|
+
| "upstream_unavailable" // a 5xx without JSON-RPC (cloudflared up, the agent's server not answering)
|
|
321
|
+
| "network" // the fetch itself failed (DNS, TLS, timeout)
|
|
322
|
+
| "unexpected_response" // anything else that is not JSON-RPC (a 404 page, HTML, a redirect elsewhere)
|
|
323
|
+
| "misconfigured"; // UPSTREAM_URL unusable
|
|
324
|
+
|
|
325
|
+
export type UpstreamVerdict = { reason: UpstreamReason; status: number | null; cloudflareError?: string; rpcErrorCode?: number; detail: string };
|
|
326
|
+
|
|
327
|
+
/** Classify one upstream HTTP answer (or a fetch failure: status null + error). Pure; `bearerSent` says whether the
|
|
328
|
+
* façade sent UPSTREAM_TOKEN, `accessSent` whether it sent an Access service token (false turns an Access refusal into
|
|
329
|
+
* access_credentials_missing). Access is recognised by its login redirect (<team>.cloudflareaccess.com,
|
|
330
|
+
* /cdn-cgi/access/), its CF-Access-* response headers, or its error page. */
|
|
331
|
+
export function classifyUpstream(i: { status: number | null; headers?: Headers | Record<string, string>; body?: string; error?: string; bearerSent: boolean; accessSent?: boolean }): UpstreamVerdict {
|
|
332
|
+
if (i.status === null) return { reason: "network", status: null, detail: `request failed: ${String(i.error || "unknown error").slice(0, 200)}` };
|
|
333
|
+
const status = i.status;
|
|
334
|
+
const body = String(i.body || "");
|
|
335
|
+
const get = (k: string): string => {
|
|
336
|
+
const h: any = i.headers;
|
|
337
|
+
if (!h) return "";
|
|
338
|
+
if (typeof h.get === "function") return h.get(k) || "";
|
|
339
|
+
for (const [n, v] of Object.entries(h)) if (n.toLowerCase() === k) return String(v);
|
|
340
|
+
return "";
|
|
341
|
+
};
|
|
342
|
+
const names: string[] = [];
|
|
343
|
+
const h: any = i.headers;
|
|
344
|
+
if (h && typeof h.forEach === "function" && typeof h.get === "function") h.forEach((_v: string, k: string) => names.push(k.toLowerCase()));
|
|
345
|
+
else if (h) names.push(...Object.keys(h).map((k) => k.toLowerCase()));
|
|
346
|
+
let rpc: Json = null;
|
|
347
|
+
try { rpc = JSON.parse(body); } catch { /* not JSON */ }
|
|
348
|
+
if (rpc && typeof rpc === "object" && !Array.isArray(rpc) && rpc.jsonrpc === "2.0" && status !== 401 && status !== 403) {
|
|
349
|
+
const code = rpc.error && typeof rpc.error === "object" && typeof rpc.error.code === "number" ? rpc.error.code : undefined;
|
|
350
|
+
return { reason: "reachable", status, ...(code !== undefined ? { rpcErrorCode: code } : {}),
|
|
351
|
+
detail: code !== undefined ? `JSON-RPC error ${code} (HTTP ${status})` : `JSON-RPC result (HTTP ${status})` };
|
|
352
|
+
}
|
|
353
|
+
const location = get("location");
|
|
354
|
+
const accessPage = /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(location) || /cloudflareaccess\.com|\/cdn-cgi\/access\/|Cloudflare Access/i.test(body.slice(0, 8192));
|
|
355
|
+
const accessHeader = names.some((n) => n.startsWith("cf-access-"));
|
|
356
|
+
if ((status >= 300 && status < 400 && /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(location)) || ((status === 401 || status === 403) && (accessPage || accessHeader)))
|
|
357
|
+
return { reason: i.accessSent === false ? "access_credentials_missing" : "access_rejected", status,
|
|
358
|
+
detail: `${status >= 300 && status < 400 ? `HTTP ${status} redirect to the Cloudflare Access login` : `HTTP ${status} from Cloudflare Access`}: ${i.accessSent === false
|
|
359
|
+
? "the façade has no Access service token (UPSTREAM_ACCESS_CLIENT_ID / _SECRET)" : "Access refused the façade's service token (wrong token, or the app's policy doesn't admit it)"}` };
|
|
360
|
+
if (status === 401 || status === 403)
|
|
361
|
+
return i.bearerSent
|
|
362
|
+
? { reason: "upstream_auth_rejected", status, detail: `HTTP ${status} from the agent: it refused the façade's UPSTREAM_TOKEN` }
|
|
363
|
+
: { reason: "upstream_auth_missing", status, detail: `HTTP ${status} from the agent: it wants a credential and the façade has no UPSTREAM_TOKEN` };
|
|
364
|
+
const m = /error code:?\s*(\d{4})|\bError\s+(\d{4})\b|errorCode:?\s*(\d{4})|cf-error-code[^>]*>\s*(\d{4})/i.exec(body.slice(0, 8192));
|
|
365
|
+
const cf = m ? m[1] || m[2] || m[3] || m[4] : "";
|
|
366
|
+
if (cf === "1033" || cf === "1016" || status === 530)
|
|
367
|
+
return { reason: "tunnel_down", status, ...(cf ? { cloudflareError: cf } : {}), detail: `HTTP ${status}${cf ? `, Cloudflare error ${cf}` : ""}: Cloudflare can't reach the tunnel (is cloudflared running?)` };
|
|
368
|
+
if (status >= 500)
|
|
369
|
+
return { reason: "upstream_unavailable", status, ...(cf ? { cloudflareError: cf } : {}), detail: `HTTP ${status}${cf ? `, Cloudflare error ${cf}` : ""} without a JSON-RPC answer: the agent's server behind the tunnel is not answering` };
|
|
370
|
+
return { reason: "unexpected_response", status, ...(cf ? { cloudflareError: cf } : {}),
|
|
371
|
+
detail: `HTTP ${status} without a JSON-RPC answer${status >= 300 && status < 400 && location ? " (a redirect)" : ""}: is UPSTREAM_URL the agent's JSON-RPC endpoint?` };
|
|
372
|
+
}
|