a2a-exposed 0.0.0-stage → 0.4.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.
@@ -0,0 +1,254 @@
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
+ }