a2a-exposed 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +19 -2
- package/bin/a2a-exposed.mjs +21 -0
- package/lib/a2a.mjs +254 -0
- package/lib/commands.mjs +262 -0
- package/lib/config.mjs +95 -0
- package/lib/deploy.mjs +576 -0
- package/lib/main.mjs +246 -0
- package/lib/pair.mjs +351 -0
- package/lib/status.mjs +314 -0
- package/lib/tunnel.mjs +450 -0
- package/lib/update.mjs +71 -0
- package/lib/workersdev.mjs +97 -0
- package/package.json +46 -5
- package/worker/cloudflare.config.ts +75 -0
- package/worker/deploy.env.example +33 -0
- package/worker/migrations/0001_init.sql +74 -0
- package/worker/migrations/0002_wake_budget.sql +8 -0
- package/worker/migrations/0003_device_pairing.sql +39 -0
- package/worker/migrations/0004_pairing_replace.sql +6 -0
- package/worker/migrations/0005_facade_owners.sql +9 -0
- package/worker/package.json +20 -0
- package/worker/src/a2a.ts +216 -0
- package/worker/src/facade.ts +372 -0
- package/worker/src/index.ts +1512 -0
- package/worker/src/pairing.ts +313 -0
- package/worker/src/wake.ts +242 -0
- package/worker/test/a2a.test.ts +102 -0
- package/worker/test/d1.ts +38 -0
- package/worker/test/facade.test.ts +582 -0
- package/worker/test/migrations.test.ts +124 -0
- package/worker/test/pairing.test.ts +651 -0
- package/worker/test/wake.test.ts +141 -0
- package/worker/tsconfig.json +22 -0
- package/worker/vite.config.ts +6 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Public façade helpers: agent card rewriting (the card a public caller sees must only name the façade's public
|
|
2
|
+
// https URL) and the proxy-mode plumbing for an agent that already speaks A2A on a private network (Tailnet, LAN,
|
|
3
|
+
// localhost) behind a Cloudflare Tunnel + Access. Pure functions (no Worker globals), so Node's test runner covers them.
|
|
4
|
+
import { isPrivateHost } from "./a2a.ts";
|
|
5
|
+
|
|
6
|
+
export type Json = any;
|
|
7
|
+
|
|
8
|
+
export type RewriteOptions = {
|
|
9
|
+
/** The façade's public https origin (PUBLIC_URL), without a trailing slash. */
|
|
10
|
+
publicBase: string;
|
|
11
|
+
/** Origins of the private upstream (UPSTREAM_URL, UPSTREAM_CARD_URL and every interface URL the upstream card names). */
|
|
12
|
+
upstreamOrigins?: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const REMOVED_URL = "[private URL removed]";
|
|
16
|
+
export const REMOVED_HOST = "[private host removed]";
|
|
17
|
+
|
|
18
|
+
/** True when a URL must never appear in a public card: not https, credentials in it, a private-network host
|
|
19
|
+
* (localhost, RFC 1918, CGNAT/Tailscale 100.64/10, private IPv6 incl. IPv4-mapped, *.ts.net, *.local, single-label
|
|
20
|
+
* names, ...), the upstream, or an http(s)/ws(s) URL that doesn't parse. */
|
|
21
|
+
export function isHiddenUrl(raw: string, o: RewriteOptions): boolean {
|
|
22
|
+
let u: URL;
|
|
23
|
+
// an http(s)/ws(s) URL that doesn't parse (e.g. an IPv6 zone id, http://[fe80::1%eth0]/) is hidden: fail closed
|
|
24
|
+
try { u = new URL(raw); } catch { return /^(?:https?|wss?):\/\//i.test(raw); }
|
|
25
|
+
if (!/^(https?|wss?):$/.test(u.protocol)) return false; // urn:, mailto:, ... are not network endpoints
|
|
26
|
+
// the façade's own origin is what the card is about (always public on Cloudflare; http://127.0.0.1 only in local dev)
|
|
27
|
+
if (o.publicBase && sameOrigin(o.publicBase, u.origin)) return false;
|
|
28
|
+
if (u.protocol !== "https:" && u.protocol !== "wss:") return true;
|
|
29
|
+
if (u.username || u.password) return true;
|
|
30
|
+
if (isPrivateHost(u.hostname)) return true;
|
|
31
|
+
return (o.upstreamOrigins || []).some((x) => sameOrigin(x, u.origin));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Same origin (scheme, host and port; default ports and host case normalised). False when either is not a URL. */
|
|
35
|
+
export function sameOrigin(a: string, b: string): boolean {
|
|
36
|
+
try { return new URL(a).origin.toLowerCase() === new URL(b).origin.toLowerCase(); } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
|
|
40
|
+
|
|
41
|
+
// a bracketed IPv6 literal (http://[::1]:8080/x) counts as part of the URL; other brackets end it
|
|
42
|
+
const URL_IN_TEXT = /\b(?:https?|wss?):\/\/(?:\[[0-9A-Za-z:.%_-]*\]|[^\s"'<>()\[\]{}])+/gi;
|
|
43
|
+
|
|
44
|
+
/** Free text (descriptions, examples, ...): private / upstream URLs are replaced by a marker, and bare upstream or
|
|
45
|
+
* *.ts.net host names too. Public URLs stay as they are. */
|
|
46
|
+
export function scrubText(s: string, o: RewriteOptions): string {
|
|
47
|
+
let out = s.replace(URL_IN_TEXT, (m) => {
|
|
48
|
+
const trimmed = m.replace(/[.,;:!?]+$/, ""); // sentence punctuation is not part of the URL
|
|
49
|
+
return isHiddenUrl(trimmed, o) ? REMOVED_URL + m.slice(trimmed.length) : m;
|
|
50
|
+
});
|
|
51
|
+
const hosts = new Set<string>();
|
|
52
|
+
for (const x of o.upstreamOrigins || []) try { const h = new URL(x).hostname; if (h) hosts.add(h.toLowerCase()); } catch { /* skip */ }
|
|
53
|
+
for (const h of hosts) out = out.replace(new RegExp("(^|[^A-Za-z0-9.-])" + escapeRe(h) + "(?::\\d+)?(?![A-Za-z0-9-])", "gi"), `$1${REMOVED_HOST}`);
|
|
54
|
+
return out.replace(/(^|[^A-Za-z0-9.-])[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.ts\.net(?::\d+)?(?![A-Za-z0-9-])/gi, `$1${REMOVED_HOST}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every string inside a JSON value scrubbed with scrubText, property names included (objects and arrays copied).
|
|
58
|
+
* Keys are defined, not assigned, so a "__proto__" key from upstream JSON stays a plain property. */
|
|
59
|
+
export function scrubDeep(v: Json, o: RewriteOptions, depth = 0): Json {
|
|
60
|
+
if (depth > 20) return undefined;
|
|
61
|
+
if (typeof v === "string") return scrubText(v, o);
|
|
62
|
+
if (Array.isArray(v)) return v.map((x) => scrubDeep(x, o, depth + 1));
|
|
63
|
+
if (v && typeof v === "object") {
|
|
64
|
+
const out: Json = {};
|
|
65
|
+
for (const [k, x] of Object.entries(v))
|
|
66
|
+
Object.defineProperty(out, scrubText(k, o), { value: scrubDeep(x, o, depth + 1), enumerable: true, writable: true, configurable: true });
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
return v;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A URL field (documentationUrl, iconUrl, provider.url): kept when public, else undefined (dropped). */
|
|
73
|
+
export function publicUrlOrNothing(v: unknown, o: RewriteOptions): string | undefined {
|
|
74
|
+
if (typeof v !== "string" || !v) return undefined;
|
|
75
|
+
try { new URL(v); } catch { return undefined; }
|
|
76
|
+
return isHiddenUrl(v, o) ? undefined : v;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Origins named by an upstream card (1.0 supportedInterfaces, 0.3 url / additionalInterfaces): all are private
|
|
80
|
+
* as far as the public card is concerned, because callers only ever reach the upstream through the façade. */
|
|
81
|
+
export function upstreamCardOrigins(card: Json): string[] {
|
|
82
|
+
const urls: unknown[] = [];
|
|
83
|
+
if (card && typeof card === "object") {
|
|
84
|
+
urls.push(card.url);
|
|
85
|
+
for (const i of [...(Array.isArray(card.supportedInterfaces) ? card.supportedInterfaces : []), ...(Array.isArray(card.additionalInterfaces) ? card.additionalInterfaces : [])])
|
|
86
|
+
if (i && typeof i === "object") urls.push(i.url);
|
|
87
|
+
}
|
|
88
|
+
const out = new Set<string>();
|
|
89
|
+
for (const u of urls) if (typeof u === "string") try { out.add(new URL(u).origin); } catch { /* skip */ }
|
|
90
|
+
return [...out];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** JSON-RPC protocol versions the upstream card advertises ("1.0", "0.3"); empty when it names none we can proxy. */
|
|
94
|
+
export function upstreamJsonRpcVersions(card: Json): string[] {
|
|
95
|
+
const out = new Set<string>();
|
|
96
|
+
if (!card || typeof card !== "object") return [];
|
|
97
|
+
const norm = (v: unknown) => { const m = String(v ?? "").match(/^(\d+)\.(\d+)/); return m ? `${m[1]}.${m[2]}` : ""; };
|
|
98
|
+
if (Array.isArray(card.supportedInterfaces))
|
|
99
|
+
for (const i of card.supportedInterfaces)
|
|
100
|
+
if (i && /^jsonrpc$/i.test(String(i.protocolBinding || i.transport || ""))) { const v = norm(i.protocolVersion); if (v) out.add(v); }
|
|
101
|
+
// 0.3 card: top-level protocolVersion + preferredTransport (default JSONRPC) or a JSONRPC additional interface
|
|
102
|
+
if (card.protocolVersion && (!card.preferredTransport || /^jsonrpc$/i.test(card.preferredTransport) ||
|
|
103
|
+
(Array.isArray(card.additionalInterfaces) && card.additionalInterfaces.some((i: Json) => i && /^jsonrpc$/i.test(String(i.transport || "")))))) {
|
|
104
|
+
const v = norm(card.protocolVersion); if (v) out.add(v);
|
|
105
|
+
}
|
|
106
|
+
return [...out].filter((v) => v === "1.0" || v === "0.3").sort().reverse();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type CardConfig = {
|
|
110
|
+
name?: string; description?: string; version?: string; skills?: Json[];
|
|
111
|
+
providerOrganization?: string; providerUrl?: string; documentationUrl?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type FacadeCardInput = {
|
|
115
|
+
upstream: Json | null; // the upstream's own card (1.0 or 0.3 shape), or null when unavailable
|
|
116
|
+
config: CardConfig; // operator overrides (AGENT_* vars): win over upstream values
|
|
117
|
+
publicBase: string;
|
|
118
|
+
upstreamOrigins: string[];
|
|
119
|
+
securitySchemes: Json; // the façade's (bearer + pairing): never the upstream's
|
|
120
|
+
securityRequirements: Json[];
|
|
121
|
+
defaults: { description: string; skills: Json[] };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The public A2A 1.0 card for a façade in front of a private upstream.
|
|
125
|
+
*
|
|
126
|
+
* Rewrite rules (documented in the README and the setup skill):
|
|
127
|
+
* - supportedInterfaces: one JSONRPC interface per protocol version the upstream advertises (1.0 / 0.3; both when the
|
|
128
|
+
* upstream card is unavailable), every one at `${publicBase}/`. Upstream interface URLs, gRPC / HTTP+JSON
|
|
129
|
+
* interfaces, `url`, `additionalInterfaces` and `preferredTransport` are never copied.
|
|
130
|
+
* - securitySchemes / securityRequirements: the façade's (per-peer bearer + device-flow pairing on the façade); the
|
|
131
|
+
* upstream's schemes describe the credential the façade holds, not what a caller needs.
|
|
132
|
+
* - capabilities: extensions from the upstream; streaming, pushNotifications and extendedAgentCard are false (the
|
|
133
|
+
* façade does not proxy SSE or the extended card yet, and refuses push configs: see wantsPush).
|
|
134
|
+
* - name, description, version, skills, default modes, provider, documentationUrl, iconUrl: operator config first,
|
|
135
|
+
* then the upstream card, then defaults. Every string is scrubbed: private / upstream URLs become
|
|
136
|
+
* "[private URL removed]", bare upstream and *.ts.net host names "[private host removed]"; documentationUrl,
|
|
137
|
+
* iconUrl and provider.url are dropped instead when private (provider is dropped without a public url).
|
|
138
|
+
* - signatures are dropped (the rewritten card no longer matches them); unknown top-level fields are not copied. */
|
|
139
|
+
export function facadeCard(i: FacadeCardInput): Json {
|
|
140
|
+
const up: Json = i.upstream && typeof i.upstream === "object" ? i.upstream : {};
|
|
141
|
+
const o: RewriteOptions = { publicBase: i.publicBase, upstreamOrigins: i.upstreamOrigins };
|
|
142
|
+
const base = i.publicBase.replace(/\/$/, "");
|
|
143
|
+
const str = (...vs: unknown[]) => { for (const v of vs) if (typeof v === "string" && v.trim()) return v; return undefined; };
|
|
144
|
+
const versions = i.upstream ? upstreamJsonRpcVersions(up) : [];
|
|
145
|
+
// media types only (type/subtype[;params]); anything else in the upstream's mode lists is dropped
|
|
146
|
+
const modes = (v: unknown) => {
|
|
147
|
+
const ok = Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.length <= 128 && /^[\w.+-]+\/[\w.+*-]+(?:\s*;[^/]*)?$/.test(x)) : [];
|
|
148
|
+
return ok.length ? ok.slice(0, 32) : ["text/plain", "application/json"];
|
|
149
|
+
};
|
|
150
|
+
const upSkills = Array.isArray(up.skills) && up.skills.length ? up.skills.filter((s: Json) => s && typeof s === "object") : null;
|
|
151
|
+
const card: Json = {
|
|
152
|
+
name: scrubText(str(i.config.name, up.name) || "A2A Agent", o),
|
|
153
|
+
description: scrubText(str(i.config.description, up.description) || i.defaults.description, o),
|
|
154
|
+
version: scrubText(str(i.config.version, up.version) || "1.0.0", o),
|
|
155
|
+
supportedInterfaces: (versions.length ? versions : ["1.0", "0.3"]).map((v) => ({ url: base + "/", protocolBinding: "JSONRPC", protocolVersion: v })),
|
|
156
|
+
securitySchemes: i.securitySchemes,
|
|
157
|
+
securityRequirements: i.securityRequirements,
|
|
158
|
+
capabilities: {
|
|
159
|
+
streaming: false,
|
|
160
|
+
pushNotifications: false,
|
|
161
|
+
extendedAgentCard: false,
|
|
162
|
+
...(Array.isArray(up.capabilities?.extensions) && up.capabilities.extensions.length ? { extensions: scrubDeep(up.capabilities.extensions, o) } : {}),
|
|
163
|
+
},
|
|
164
|
+
defaultInputModes: modes(up.defaultInputModes),
|
|
165
|
+
defaultOutputModes: modes(up.defaultOutputModes),
|
|
166
|
+
skills: scrubDeep(i.config.skills?.length ? i.config.skills : upSkills || i.defaults.skills, o),
|
|
167
|
+
};
|
|
168
|
+
const provOrg = str(i.config.providerOrganization, up.provider?.organization);
|
|
169
|
+
const provUrl = publicUrlOrNothing(i.config.providerOrganization ? i.config.providerUrl : up.provider?.url, o);
|
|
170
|
+
if (provOrg && provUrl) card.provider = { organization: scrubText(provOrg, o), url: provUrl };
|
|
171
|
+
const doc = publicUrlOrNothing(str(i.config.documentationUrl, up.documentationUrl), o);
|
|
172
|
+
if (doc) card.documentationUrl = doc;
|
|
173
|
+
const icon = publicUrlOrNothing(up.iconUrl, o);
|
|
174
|
+
if (icon) card.iconUrl = icon;
|
|
175
|
+
// catch-all: one more scrub over the finished card (keys included), so a field added above can't leak by mistake;
|
|
176
|
+
// the façade's own URLs are public https and pass through unchanged
|
|
177
|
+
return scrubDeep(card, o);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Final pass over any card the Worker serves (inbox or façade): the interface URLs must be the public base, and no
|
|
181
|
+
* string may name a private network. Returns the problems found (empty = clean); tests assert on it. */
|
|
182
|
+
export function cardLeaks(card: Json, publicBase: string, upstreamOrigins: string[] = []): string[] {
|
|
183
|
+
const out: string[] = [];
|
|
184
|
+
const o: RewriteOptions = { publicBase, upstreamOrigins };
|
|
185
|
+
const walk = (v: Json, p: string) => {
|
|
186
|
+
if (typeof v === "string") {
|
|
187
|
+
for (const m of v.match(URL_IN_TEXT) || []) if (isHiddenUrl(m.replace(/[.,;:!?]+$/, ""), o)) out.push(`${p}: ${m}`);
|
|
188
|
+
if (/\.ts\.net\b/i.test(v)) out.push(`${p}: ${v}`);
|
|
189
|
+
} else if (Array.isArray(v)) v.forEach((x, k) => walk(x, `${p}[${k}]`));
|
|
190
|
+
else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) { walk(k, `${p || "."} (key)`); walk(x, p ? `${p}.${k}` : k); }
|
|
191
|
+
};
|
|
192
|
+
walk(card, "");
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ------------------------------------------------------------------ proxy mode
|
|
197
|
+
|
|
198
|
+
/** UPSTREAM_URL as the Worker will call it: an https URL without credentials on a public hostname (a Tunnel hostname
|
|
199
|
+
* behind Access); a Worker cannot reach a Tailnet, LAN or loopback address. Returns [url, ""] or ["", why]. */
|
|
200
|
+
export function upstreamEndpoint(v: string | undefined): [string, string] {
|
|
201
|
+
const s = (v || "").trim();
|
|
202
|
+
if (!s) return ["", "UPSTREAM_URL is not set"];
|
|
203
|
+
let u: URL;
|
|
204
|
+
try { u = new URL(s); } catch { return ["", "UPSTREAM_URL is not a URL"]; }
|
|
205
|
+
if (u.protocol !== "https:") return ["", "UPSTREAM_URL must be https"];
|
|
206
|
+
if (u.username || u.password) return ["", "UPSTREAM_URL must not carry credentials"];
|
|
207
|
+
if (isPrivateHost(u.hostname))
|
|
208
|
+
return ["", "UPSTREAM_URL is a private-network address the Worker cannot reach: publish the agent through a Cloudflare Tunnel hostname behind Access"];
|
|
209
|
+
return [u.href, ""];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Where the upstream's own card is: UPSTREAM_CARD_URL, else <upstream origin>/.well-known/agent-card.json.
|
|
213
|
+
* The card fetch carries the façade's upstream credentials (UPSTREAM_TOKEN, the Access service token), so a
|
|
214
|
+
* configured card URL must be on the UPSTREAM_URL origin; anything else (another origin, not https, not a URL) is
|
|
215
|
+
* refused and never fetched, rather than silently replaced. Returns [url, ""] or ["", why]. */
|
|
216
|
+
export function upstreamCardUrl(endpoint: string, configured: string | undefined): [string, string] {
|
|
217
|
+
const c = (configured || "").trim();
|
|
218
|
+
if (!c) return [new URL("/.well-known/agent-card.json", endpoint).href, ""];
|
|
219
|
+
const [u, why] = upstreamEndpoint(c);
|
|
220
|
+
if (!u) return ["", why.replace(/^UPSTREAM_URL/, "UPSTREAM_CARD_URL")];
|
|
221
|
+
if (!sameOrigin(u, endpoint))
|
|
222
|
+
return ["", `UPSTREAM_CARD_URL must be on the UPSTREAM_URL origin (${new URL(endpoint).origin}), not ${new URL(u).origin}: the card fetch carries the upstream credentials`];
|
|
223
|
+
return [u, ""];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Task / context ids the upstream returned for a SendMessage (1.0 { task } / { message }, 0.3 Task / Message). */
|
|
227
|
+
export function idsFromResult(result: Json): { taskId?: string; contextId?: string } {
|
|
228
|
+
if (!result || typeof result !== "object") return {};
|
|
229
|
+
const isMsg = !!result.message || result.kind === "message";
|
|
230
|
+
const r = result.task || result.message || result;
|
|
231
|
+
if (!r || typeof r !== "object") return {};
|
|
232
|
+
const taskId = isMsg ? r.taskId : r.id;
|
|
233
|
+
return { taskId: typeof taskId === "string" ? taskId : undefined, contextId: typeof r.contextId === "string" ? r.contextId : undefined };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The task a JSON-RPC call (other than SendMessage) is about, from the 1.0 or 0.3 params shapes. */
|
|
237
|
+
export function taskIdOf(op: string, p: Json): unknown {
|
|
238
|
+
if (!p || typeof p !== "object") return undefined;
|
|
239
|
+
const fromName = (n: unknown) => (typeof n === "string" && n.startsWith("tasks/") ? n.split("/")[1] : undefined);
|
|
240
|
+
if (op === "get" || op === "cancel") return p.id ?? fromName(p.name);
|
|
241
|
+
if (op === "pushset") return p.taskId ?? fromName(p.parent);
|
|
242
|
+
// pushget / pushlist / pushdel: 1.0 { taskId, id }, 0.3 { id: taskId, pushNotificationConfigId }
|
|
243
|
+
return p.taskId ?? fromName(p.parent) ?? fromName(p.name) ?? p.id;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The request asks the upstream for push notifications: a push config call, or SendMessage with a push config in
|
|
247
|
+
* its configuration (1.0 taskPushNotificationConfig, 0.3 pushNotificationConfig, any spelling). Proxy mode refuses
|
|
248
|
+
* these: the upstream would call the URL from inside the private network, where a public-looking name can resolve to a
|
|
249
|
+
* private address (127.0.0.1.nip.io, split-horizon DNS), so no check at the façade can make that safe. */
|
|
250
|
+
export function wantsPush(op: string, p: Json): boolean {
|
|
251
|
+
if (op.startsWith("push")) return true;
|
|
252
|
+
if (op !== "send" || !p || typeof p !== "object" || !p.configuration || typeof p.configuration !== "object") return false;
|
|
253
|
+
return Object.entries(p.configuration).some(([k, v]) => /push/i.test(k) && v !== undefined && v !== null);
|
|
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
|
+
}
|