a2a-exposed 0.4.0 → 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/lib/a2a.mjs +67 -0
- package/lib/config.mjs +4 -6
- package/lib/deploy.mjs +101 -17
- package/lib/main.mjs +26 -6
- package/lib/pair.mjs +4 -2
- package/lib/status.mjs +121 -5
- package/package.json +1 -1
- package/worker/src/facade.ts +118 -0
- package/worker/src/index.ts +74 -7
- package/worker/test/facade.test.ts +162 -1
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/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,8 +5,9 @@ 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)), "..");
|
|
@@ -43,10 +44,8 @@ export const PAIRING_MODES = ["human", "agent", "off"];
|
|
|
43
44
|
export const DEPLOY_FLAGS = Object.keys(FLAG_KEYS);
|
|
44
45
|
|
|
45
46
|
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");
|
|
47
|
+
// Worker (and default D1) name: A2A_WORKER_NAME (saved by init), else a2a-exposed.
|
|
48
|
+
export const workerName = () => C.get("A2A_WORKER_NAME") || "a2a-exposed";
|
|
50
49
|
/** No custom hostname: the Worker is served on <worker>.<account subdomain>.workers.dev (and only there). */
|
|
51
50
|
const workersDevMode = () => !C.get("A2A_HOSTNAME");
|
|
52
51
|
export const publicBase = () =>
|
|
@@ -63,6 +62,9 @@ export const NODE_HELP = `How to get Node 22 (any one; no root needed):
|
|
|
63
62
|
or the official installer / binaries: https://nodejs.org/en/download
|
|
64
63
|
Then check: node --version (v22.18.0 or newer)`;
|
|
65
64
|
|
|
65
|
+
/** What to do when the Cloudflare login itself is blocked (printed with "not logged in"; the setup skill says the same). */
|
|
66
|
+
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).`;
|
|
67
|
+
|
|
66
68
|
/** Dies unless `version` (default: this Node) is 22.18+; the message says how to get it. */
|
|
67
69
|
export function checkNode(version = process.versions.node) {
|
|
68
70
|
const [maj, min] = String(version).replace(/^v/, "").split(".").map(Number);
|
|
@@ -199,7 +201,11 @@ function applyFlags(o) {
|
|
|
199
201
|
if (upd.WAKE_PRESET && !PRESETS.includes(upd.WAKE_PRESET)) die(`unknown preset ${upd.WAKE_PRESET} (${PRESETS.join(" | ")})`);
|
|
200
202
|
for (const [flag, key] of [["upstream", "A2A_UPSTREAM_URL"], ["upstream-card-url", "A2A_UPSTREAM_CARD_URL"]]) {
|
|
201
203
|
if (upd[key] === undefined) continue;
|
|
202
|
-
if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) {
|
|
204
|
+
if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) {
|
|
205
|
+
upd[key] = null;
|
|
206
|
+
if (key === "A2A_UPSTREAM_URL") upd.A2A_UPSTREAM_NO_TOKEN = null; // the opt-out belonged to that upstream
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
203
209
|
const why = upstreamUrlProblem(String(upd[key]).trim());
|
|
204
210
|
if (why) die(`--${flag} ${why}`);
|
|
205
211
|
upd[key] = new URL(String(upd[key]).trim()).href;
|
|
@@ -346,7 +352,9 @@ function deployedBase() {
|
|
|
346
352
|
|
|
347
353
|
export async function init(o) {
|
|
348
354
|
checkNode();
|
|
355
|
+
const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
|
|
349
356
|
const switched = applyFlags(o);
|
|
357
|
+
const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
|
|
350
358
|
const dir = workerDir(o);
|
|
351
359
|
C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName() });
|
|
352
360
|
if (workersDevMode()) {
|
|
@@ -361,11 +369,15 @@ export async function init(o) {
|
|
|
361
369
|
const prof = cfProfile();
|
|
362
370
|
step(`checking Cloudflare login (cf auth whoami${prof ? ` --profile ${prof}` : ""})`);
|
|
363
371
|
const who = cfJson(dir, ["auth", "whoami"]);
|
|
364
|
-
if (!who.authenticated) die(
|
|
372
|
+
if (!who.authenticated) die((process.env.CLOUDFLARE_API_TOKEN
|
|
373
|
+
? "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"
|
|
374
|
+
: prof
|
|
365
375
|
? `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");
|
|
376
|
+
: "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
377
|
const accts = who.accounts || [];
|
|
368
378
|
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
379
|
+
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
380
|
+
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
381
|
if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
|
|
370
382
|
C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
|
|
371
383
|
} else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
|
|
@@ -391,30 +403,102 @@ export async function init(o) {
|
|
|
391
403
|
if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
|
|
392
404
|
C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
|
|
393
405
|
|
|
394
|
-
const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv() };
|
|
406
|
+
const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv(), ...upstreamSecrets };
|
|
395
407
|
step(`deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})`);
|
|
396
408
|
await deployAndLearn(dir, secrets, o);
|
|
397
409
|
const base = deployedBase();
|
|
398
410
|
if (base) await verifyDeployedCard(base);
|
|
399
411
|
console.log(base);
|
|
400
412
|
reportSwitch(switched);
|
|
401
|
-
proxyNote();
|
|
413
|
+
proxyNote(secrets);
|
|
402
414
|
console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: a2a-exposed status (shows the next setup step)`);
|
|
403
415
|
}
|
|
404
416
|
|
|
405
|
-
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their
|
|
406
|
-
function proxyNote() {
|
|
417
|
+
/** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their names are shown). */
|
|
418
|
+
function proxyNote(secrets = {}) {
|
|
407
419
|
const up = C.get("A2A_UPSTREAM_URL");
|
|
408
420
|
if (!up) return;
|
|
409
|
-
const sent = UPSTREAM_SECRETS.filter((k) =>
|
|
421
|
+
const sent = UPSTREAM_SECRETS.filter((k) => k in secrets);
|
|
410
422
|
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
|
|
423
|
+
` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the whole path (Access, the agent's bearer check) with: a2a-exposed status`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** stdin and stderr are a terminal (a human at the keyboard), and not CI: only then may setup prompt. */
|
|
427
|
+
const interactive = () => !!(process.stdin.isTTY && process.stderr.isTTY && !process.env.CI);
|
|
428
|
+
|
|
429
|
+
/** Proxy mode setup check: the façade must hold the bearer token the upstream agent checks (UPSTREAM_TOKEN) in addition
|
|
430
|
+
* to the Cloudflare Access service token, which only gets the Worker through the tunnel. Returns extra secrets to
|
|
431
|
+
* upload ({ UPSTREAM_TOKEN } when read from stdin or a hidden prompt), or dies with what to do. Never reads argv.
|
|
432
|
+
* Order: --upstream-token-stdin / exported UPSTREAM_TOKEN (uploaded) > --no-upstream-token (saved opt-out) > the
|
|
433
|
+
* Worker already holds one for this upstream, or the card it fetched declares no bearer > the upstream card fetched from
|
|
434
|
+
* here (with the exported Access token): no bearer declared -> fine; bearer or unknown -> hidden prompt on a terminal,
|
|
435
|
+
* else exit 1. */
|
|
436
|
+
export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
|
|
437
|
+
const up = C.get("A2A_UPSTREAM_URL");
|
|
438
|
+
if (!up) return {};
|
|
439
|
+
if (o["upstream-token-stdin"] && o["no-upstream-token"]) die("--upstream-token-stdin and --no-upstream-token are mutually exclusive");
|
|
440
|
+
if (o["upstream-token-stdin"]) {
|
|
441
|
+
const t = (process.stdin.isTTY ? await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown): ") : fs.readFileSync(0, "utf8")).trim();
|
|
442
|
+
if (!t) die("--upstream-token-stdin: no token on stdin");
|
|
443
|
+
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null });
|
|
444
|
+
return { UPSTREAM_TOKEN: t };
|
|
445
|
+
}
|
|
446
|
+
if (process.env.UPSTREAM_TOKEN) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return {}; } // uploaded with the other exported secrets
|
|
447
|
+
if (o["no-upstream-token"]) {
|
|
448
|
+
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
|
|
449
|
+
console.error("note: --no-upstream-token: deploying without an upstream bearer token (saved; `status` warns if the upstream card asks for one)");
|
|
450
|
+
return {};
|
|
451
|
+
}
|
|
452
|
+
if (C.get("A2A_UPSTREAM_NO_TOKEN") === "1") return {};
|
|
453
|
+
// an existing façade for this upstream: the Worker's secrets persist across deploys, and it reads the card through Access
|
|
454
|
+
if (!upstreamChanged && C.get("A2A_BASE_URL") && C.get("A2A_OWNER_TOKEN")) {
|
|
455
|
+
try {
|
|
456
|
+
const r = await ownerTry("GET", "/owner/facade");
|
|
457
|
+
if (r.status === 200 && r.data && r.data.mode === "proxy") {
|
|
458
|
+
if (r.data.hasUpstreamToken) return {};
|
|
459
|
+
if (r.data.upstreamAuth && r.data.upstreamAuth.bearer === false) return {};
|
|
460
|
+
}
|
|
461
|
+
} catch { /* not deployed yet / unreachable: check the card from here */ }
|
|
462
|
+
}
|
|
463
|
+
const cardUrl = C.get("A2A_UPSTREAM_CARD_URL") || new URL("/.well-known/agent-card.json", up).href;
|
|
464
|
+
step(`checking which credential the upstream's agent card asks for (${cardUrl})`);
|
|
465
|
+
const { card, error } = await fetchUpstreamCard(cardUrl);
|
|
466
|
+
const auth = upstreamAuth(card);
|
|
467
|
+
if (auth.unsupported.length)
|
|
468
|
+
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`);
|
|
469
|
+
if (auth.bearer === false) {
|
|
470
|
+
console.error(`upstream card: no bearer auth declared${auth.schemes.length ? ` (schemes: ${auth.schemes.join(", ")})` : ""}, so no UPSTREAM_TOKEN is needed`);
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
const why = auth.bearer
|
|
474
|
+
? `the upstream's agent card asks for a bearer token (${auth.schemes.join(", ")})`
|
|
475
|
+
: `the upstream's agent card could not be read from here (${error}), so whether the agent needs a bearer token is unknown`;
|
|
476
|
+
const access = "Cloudflare Access (UPSTREAM_ACCESS_CLIENT_ID / _SECRET) only lets the Worker through the tunnel; the agent checks its own token too";
|
|
477
|
+
if (interactive()) {
|
|
478
|
+
console.error(`${why}. ${access}.`);
|
|
479
|
+
const t = (await readHidden("UPSTREAM_TOKEN, the bearer the upstream agent expects (not shown; empty = the agent needs none): ")).trim();
|
|
480
|
+
if (t) { C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: null }); return { UPSTREAM_TOKEN: t }; }
|
|
481
|
+
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");
|
|
482
|
+
C.saveConfig({ A2A_UPSTREAM_NO_TOKEN: "1" });
|
|
483
|
+
console.error("note: deploying without an upstream bearer token (saved; check the whole path with `a2a-exposed status`)");
|
|
484
|
+
return {};
|
|
485
|
+
}
|
|
486
|
+
die(`${why}, and no UPSTREAM_TOKEN is set. ${access}, so without it every peer call fails with HTTP 502.\n` +
|
|
487
|
+
" 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 " +
|
|
488
|
+
`${C.get("A2A_D1_ID") ? "deploy" : "init ..."} --upstream-token-stdin\n` +
|
|
489
|
+
" The agent needs no bearer token? Re-run with --no-upstream-token (saved for later deploys).");
|
|
412
490
|
}
|
|
413
491
|
|
|
414
492
|
export async function deploy(o) {
|
|
415
493
|
checkNode();
|
|
416
494
|
if (!C.get("A2A_D1_ID")) die("no saved deployment; run `a2a-exposed init` first");
|
|
495
|
+
// init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
|
|
496
|
+
// refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
|
|
497
|
+
if (!C.get("A2A_WORKER_NAME") && o["worker-name"] === undefined)
|
|
498
|
+
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)`);
|
|
499
|
+
const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
|
|
417
500
|
const switched = applyFlags(o);
|
|
501
|
+
const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
|
|
418
502
|
const dir = workerDir(o);
|
|
419
503
|
if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
|
|
420
504
|
guardTunnelUrl();
|
|
@@ -423,14 +507,14 @@ export async function deploy(o) {
|
|
|
423
507
|
if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
|
|
424
508
|
const env = cfEnv();
|
|
425
509
|
applyMigrations(dir, env);
|
|
426
|
-
const secrets = deploySecretsFromEnv();
|
|
510
|
+
const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets };
|
|
427
511
|
if (Object.keys(secrets).length && C.get("A2A_OWNER_TOKEN")) secrets.OWNER_TOKEN = C.get("A2A_OWNER_TOKEN");
|
|
428
512
|
step(Object.keys(secrets).length ? `deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})` : "deploying (existing secrets kept)");
|
|
429
513
|
await deployAndLearn(dir, secrets, o);
|
|
430
514
|
const base = deployedBase();
|
|
431
515
|
if (base) await verifyDeployedCard(base);
|
|
432
516
|
if (switched) { console.log(base); reportSwitch(switched); }
|
|
433
|
-
proxyNote();
|
|
517
|
+
proxyNote(secrets);
|
|
434
518
|
}
|
|
435
519
|
|
|
436
520
|
/** Fingerprints of the wake secrets in the local environment (what `wake set` would upload). */
|
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
25
|
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--cron]
|
|
25
|
-
[--upstream URL [--upstream-card-url URL]] [--worker-dir DIR]
|
|
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
|
|
@@ -48,6 +49,12 @@ npx cf auth login --no-browser)
|
|
|
48
49
|
--upstream-card-url the upstream's card (default <upstream origin>/.well-known/
|
|
49
50
|
agent-card.json); must be on the --upstream origin (it is
|
|
50
51
|
fetched with the upstream credentials)
|
|
52
|
+
Setup reads that card: unless it declares no bearer auth, the
|
|
53
|
+
façade needs UPSTREAM_TOKEN besides the Access token (Access only
|
|
54
|
+
gets the Worker through the tunnel). Without it: a hidden prompt
|
|
55
|
+
on a terminal, else exit 1 with what to do
|
|
56
|
+
--upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
|
|
57
|
+
--no-upstream-token the upstream needs no bearer token: skip that check (saved)
|
|
51
58
|
--cli-command command shown in wake hints (default "npx a2a-exposed";
|
|
52
59
|
e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
|
|
53
60
|
--worker-dir where the Worker project (template copy) lives (default
|
|
@@ -79,9 +86,16 @@ anywhere on the account: the inbox itself can be on workers.dev or a custom host
|
|
|
79
86
|
|
|
80
87
|
Status
|
|
81
88
|
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
|
|
89
|
+
mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
|
|
90
|
+
upstream card, Access credential and upstream bearer configured (separately), the
|
|
91
|
+
live upstream check, the last failed peer call, card-leak check; warnings for peer
|
|
92
|
+
token variables shadowing saved tokens; and the next step to run.
|
|
84
93
|
Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
|
|
94
|
+
upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
|
|
95
|
+
(unauthenticated call -> 401), then the Worker calls the upstream with its stored
|
|
96
|
+
secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
|
|
97
|
+
is created): Cloudflare Access, the agent's bearer check, the A2A app. Exit 1 unless
|
|
98
|
+
the app answered. \`status\` runs the same check
|
|
85
99
|
url print the public base URL (exit 1 if none is configured)
|
|
86
100
|
config print config (secrets masked) and its location
|
|
87
101
|
|
|
@@ -126,7 +140,7 @@ Outbound (agents you call)
|
|
|
126
140
|
peers add <alias> <url> [--token-env VAR | --token-stdin]
|
|
127
141
|
peers list | peers rm <alias>
|
|
128
142
|
a token variable set in the environment overrides config.env; when a peer token
|
|
129
|
-
differs between the two, connect/send/poll/peers warn
|
|
143
|
+
differs between the two, connect/send/poll/peers/status warn (values never shown)
|
|
130
144
|
send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
|
|
131
145
|
poll --to <alias|url> <taskId> [--proto 0.3|1.0]
|
|
132
146
|
outbound <taskId> stored state of a task you sent (incl. pushed updates)
|
|
@@ -139,7 +153,7 @@ const S = { type: "string" }, B = { type: "boolean" };
|
|
|
139
153
|
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
154
|
const deployOpts = {
|
|
141
155
|
...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,
|
|
156
|
+
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B,
|
|
143
157
|
};
|
|
144
158
|
const SPEC = {
|
|
145
159
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
@@ -151,6 +165,7 @@ const SPEC = {
|
|
|
151
165
|
send: { to: S, text: S, context: S, task: S, push: B, proto: S },
|
|
152
166
|
poll: { to: S, proto: S },
|
|
153
167
|
status: { json: B },
|
|
168
|
+
upstream: { json: B },
|
|
154
169
|
connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
|
|
155
170
|
pair: { json: B, web: B, ttl: S },
|
|
156
171
|
};
|
|
@@ -188,6 +203,11 @@ export async function main(argv) {
|
|
|
188
203
|
case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
|
|
189
204
|
case "deploy": return dep.deploy(o);
|
|
190
205
|
case "status": return st.status(o);
|
|
206
|
+
case "upstream": {
|
|
207
|
+
const sub = need(p[0], "upstream verify [--json]");
|
|
208
|
+
if (sub === "verify" || sub === "check") return st.upstreamVerify(o);
|
|
209
|
+
throw new CliError(`unknown upstream action ${sub} (verify)`);
|
|
210
|
+
}
|
|
191
211
|
case "wake": return dep.wake(p[0], o);
|
|
192
212
|
case "url": {
|
|
193
213
|
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 || "",
|
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
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
|
+
}
|
package/worker/src/index.ts
CHANGED
|
@@ -602,6 +602,63 @@ async function handleRpc(req: Request, env: Env, ectx: ExecutionContext): Promis
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
// ------------------------------------------------------------------ proxy mode: forward JSON-RPC to the private upstream
|
|
605
|
+
/** What a peer sees when the upstream hop fails: generic on purpose (no secret names, no upstream host, no hint which
|
|
606
|
+
* lock refused). The operator's diagnosis is the reason code in the Worker log, `status` and `upstream verify`. */
|
|
607
|
+
const PUBLIC_UPSTREAM_ERROR: Record<string, string> = {
|
|
608
|
+
access_credentials_missing: "This agent is unavailable: its façade is misconfigured; tell its operator",
|
|
609
|
+
access_rejected: "This agent is unavailable: its façade is misconfigured; tell its operator",
|
|
610
|
+
upstream_auth_missing: "This agent is unavailable: its façade is misconfigured; tell its operator",
|
|
611
|
+
upstream_auth_rejected: "This agent is unavailable: its façade is misconfigured; tell its operator",
|
|
612
|
+
tunnel_down: "This agent is not reachable right now; try again later",
|
|
613
|
+
upstream_unavailable: "This agent is not reachable right now; try again later",
|
|
614
|
+
network: "This agent is not reachable right now; try again later",
|
|
615
|
+
unexpected_response: "This agent gave an invalid answer; try again later or tell its operator",
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
/** Last failed upstream hop of a peer call (settings row `upstream_last_failure`): reason code, HTTP status, when, which
|
|
619
|
+
* method and peer. Shown to the owner by `GET /owner/facade` (and so `status`); never to peers. Best effort. */
|
|
620
|
+
const hasAccess = (env: Env) => !!(env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET);
|
|
621
|
+
|
|
622
|
+
async function recordUpstreamFailure(env: Env, v: F.UpstreamVerdict, c: { method: string; label: string }) {
|
|
623
|
+
const rec = { at: A.nowIso(), reason: v.reason, status: v.status, ...(v.cloudflareError ? { cloudflareError: v.cloudflareError } : {}), method: c.method, peer: c.label };
|
|
624
|
+
try {
|
|
625
|
+
await env.DB.prepare("INSERT INTO settings (key, value) VALUES ('upstream_last_failure', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
626
|
+
.bind(JSON.stringify(rec)).run();
|
|
627
|
+
} catch (e) { log("upstream_failure_not_recorded", { error: String(e).slice(0, 200) }); }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function lastUpstreamFailure(env: Env): Promise<Json | null> {
|
|
631
|
+
try {
|
|
632
|
+
const r: Json = await env.DB.prepare("SELECT value FROM settings WHERE key = 'upstream_last_failure'").first();
|
|
633
|
+
return r ? JSON.parse(r.value) : null;
|
|
634
|
+
} catch { return null; }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Owner-only upstream check (`POST /owner/facade/verify`): one JSON-RPC call with a method no A2A server implements,
|
|
638
|
+
* sent from the Worker with the stored upstream credentials over the same path as peer traffic (Access, tunnel, the
|
|
639
|
+
* agent's bearer check), then classified. An unknown method can't create or touch a task, and the secrets never leave
|
|
640
|
+
* the Worker: the answer carries reason codes and HTTP status only. */
|
|
641
|
+
const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
|
|
642
|
+
async function verifyUpstream(env: Env): Promise<Json> {
|
|
643
|
+
const sent = { bearer: !!env.UPSTREAM_TOKEN, accessServiceToken: hasAccess(env) };
|
|
644
|
+
const [ep, why] = F.upstreamEndpoint(env.UPSTREAM_URL);
|
|
645
|
+
if (!ep) return { ok: false, reason: "misconfigured", status: null, detail: why, sent, method: VERIFY_METHOD };
|
|
646
|
+
const up = await fetchUpstreamCard(env);
|
|
647
|
+
const versions = F.upstreamJsonRpcVersions(up.card);
|
|
648
|
+
const v1 = !versions.length || versions.includes("1.0");
|
|
649
|
+
const headers = upstreamHeaders(env, ep, { "content-type": "application/json", accept: "application/json", "a2a-version": v1 ? "1.0" : "0.3" });
|
|
650
|
+
const rid = `a2a-exposed-verify-${A.newId()}`;
|
|
651
|
+
let verdict: F.UpstreamVerdict;
|
|
652
|
+
try {
|
|
653
|
+
const r = await fetch(ep, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: rid, method: VERIFY_METHOD, params: {} }), redirect: "manual", signal: AbortSignal.timeout(20000) });
|
|
654
|
+
verdict = F.classifyUpstream({ status: r.status, headers: r.headers, body: (await r.text()).slice(0, 8192), bearerSent: sent.bearer, accessSent: sent.accessServiceToken });
|
|
655
|
+
} catch (e) {
|
|
656
|
+
verdict = F.classifyUpstream({ status: null, error: String(e), bearerSent: sent.bearer, accessSent: sent.accessServiceToken });
|
|
657
|
+
}
|
|
658
|
+
log("upstream_verify", { reason: verdict.reason, status: verdict.status, ...(verdict.rpcErrorCode !== undefined ? { code: verdict.rpcErrorCode } : {}) });
|
|
659
|
+
return { ok: verdict.reason === "reachable", ...verdict, sent, method: VERIFY_METHOD, upstreamWantsBearer: F.upstreamAuth(up.card).bearer };
|
|
660
|
+
}
|
|
661
|
+
|
|
605
662
|
// Methods only the façade forwards (the inbox has one push config per task and no delete).
|
|
606
663
|
const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
|
|
607
664
|
const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
|
|
@@ -659,8 +716,10 @@ async function proxyRpc(req: Request, env: Env, c: { raw: string; rid: Json; met
|
|
|
659
716
|
try {
|
|
660
717
|
r = await fetch(ep, { method: "POST", headers, body: c.raw, redirect: "manual", signal: AbortSignal.timeout(90000) });
|
|
661
718
|
} catch (e) {
|
|
662
|
-
|
|
663
|
-
|
|
719
|
+
const v = F.classifyUpstream({ status: null, error: String(e), bearerSent: !!env.UPSTREAM_TOKEN, accessSent: hasAccess(env) });
|
|
720
|
+
log("upstream_error", { reason: v.reason, peer: c.label, method: c.method, detail: v.detail });
|
|
721
|
+
await recordUpstreamFailure(env, v, c);
|
|
722
|
+
return err(-32603, PUBLIC_UPSTREAM_ERROR[v.reason] || PUBLIC_UPSTREAM_ERROR.network, 502);
|
|
664
723
|
}
|
|
665
724
|
const text = await r.text();
|
|
666
725
|
if (text.length > 8 * 1048576) { log("upstream_too_large", { peer: c.label, method: c.method, bytes: text.length }); return err(-32603, "Upstream response too large", 502); }
|
|
@@ -668,11 +727,12 @@ async function proxyRpc(req: Request, env: Env, c: { raw: string; rid: Json; met
|
|
|
668
727
|
try { body = JSON.parse(text); } catch { /* not JSON */ }
|
|
669
728
|
const isRpc = body && typeof body === "object" && !Array.isArray(body) && body.jsonrpc === "2.0";
|
|
670
729
|
if (r.status === 401 || r.status === 403 || !isRpc) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
730
|
+
// the operator gets the precise reason (Worker log line + `status` / `upstream verify`); the peer a generic 502
|
|
731
|
+
// that names no secret, header or upstream detail
|
|
732
|
+
const v = F.classifyUpstream({ status: r.status, headers: r.headers, body: text.slice(0, 8192), bearerSent: !!env.UPSTREAM_TOKEN, accessSent: hasAccess(env) });
|
|
733
|
+
log("upstream_error", { reason: v.reason, peer: c.label, method: c.method, status: r.status, ...(v.cloudflareError ? { cloudflareError: v.cloudflareError } : {}) });
|
|
734
|
+
await recordUpstreamFailure(env, v, c);
|
|
735
|
+
return err(-32603, PUBLIC_UPSTREAM_ERROR[v.reason] || PUBLIC_UPSTREAM_ERROR.unexpected_response, 502);
|
|
676
736
|
}
|
|
677
737
|
if (c.op === "send" && body.result) {
|
|
678
738
|
const { taskId, contextId } = F.idsFromResult(body.result);
|
|
@@ -893,10 +953,17 @@ async function handleOwner(req: Request, env: Env, ectx: ExecutionContext, path:
|
|
|
893
953
|
const [, cardWhy] = ep ? F.upstreamCardUrl(ep, env.UPSTREAM_CARD_URL) : ["", ""];
|
|
894
954
|
return json({ mode: "proxy", upstreamValid: !!ep, ...(why ? { upstreamProblem: why } : {}), ...(cardWhy ? { upstreamCardProblem: cardWhy } : {}), upstreamCardUrl: up.url || null,
|
|
895
955
|
upstreamCard: up.status === 200 ? "ok" : up.error || "unavailable", upstreamVersions: F.upstreamJsonRpcVersions(up.card),
|
|
956
|
+
// what the upstream card asks for (scheme names and kinds only), from the copy the Worker fetched through Access
|
|
957
|
+
upstreamAuth: F.upstreamAuth(up.status === 200 ? up.card : null),
|
|
896
958
|
hasUpstreamToken: !!env.UPSTREAM_TOKEN, hasUpstreamAccessServiceToken: !!(env.UPSTREAM_ACCESS_CLIENT_ID && env.UPSTREAM_ACCESS_CLIENT_SECRET),
|
|
897
959
|
fingerprints: { upstreamToken: await A.fingerprint(env.UPSTREAM_TOKEN), upstreamAccessClientId: await A.fingerprint(env.UPSTREAM_ACCESS_CLIENT_ID) },
|
|
960
|
+
lastFailure: await lastUpstreamFailure(env),
|
|
898
961
|
publicCardLeaks: F.cardLeaks(card, env.PUBLIC_URL, upstreamOrigins(env, up.card)) });
|
|
899
962
|
}
|
|
963
|
+
if (seg[0] === "facade" && seg[1] === "verify" && !seg[2] && m === "POST") {
|
|
964
|
+
if (!proxyMode(env)) return json({ mode: "inbox", ok: false, reason: "not_proxy_mode", detail: "this Worker is an inbox (no UPSTREAM_URL): nothing to verify" }, 409);
|
|
965
|
+
return json({ mode: "proxy", ...(await verifyUpstream(env)) });
|
|
966
|
+
}
|
|
900
967
|
if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
|
|
901
968
|
// rendered wake request for a sample event, partially masked, plus short sha256 fingerprints of the
|
|
902
969
|
// uploaded secrets so the owner can compare them with local values without revealing them
|
|
@@ -360,7 +360,8 @@ test("proxy mode: upstream failures are the façade's errors, never a 401 to the
|
|
|
360
360
|
const send = () => s.rpc(a, "SendMessage", { message: { role: "ROLE_USER", parts: [{ text: "x" }] } });
|
|
361
361
|
let r = await send();
|
|
362
362
|
assert.equal(r.status, 502);
|
|
363
|
-
assert.match(r.data.error.message, /
|
|
363
|
+
assert.match(r.data.error.message, /façade is misconfigured; tell its operator/);
|
|
364
|
+
assert.doesNotMatch(r.text, /UPSTREAM|Access|token|bearer|agent-upstream|401/i, "the public 502 names no secret, lock or upstream detail");
|
|
364
365
|
mode = "html";
|
|
365
366
|
r = await send();
|
|
366
367
|
assert.equal(r.status, 502);
|
|
@@ -419,3 +420,163 @@ test("proxy mode: the landing page shows the rewritten card (upstream name and s
|
|
|
419
420
|
assert.equal(j.data.name, "Jean");
|
|
420
421
|
assert.ok(!JSON.stringify(j.data).includes("ts.net"));
|
|
421
422
|
});
|
|
423
|
+
|
|
424
|
+
// ------------------------------------------------------------------ upstream auth, failure reasons, owner verify
|
|
425
|
+
|
|
426
|
+
test("upstreamAuth: bearer wanted or not, from A2A 1.0 and 0.3 card shapes", () => {
|
|
427
|
+
assert.deepEqual(F.upstreamAuth(null), { bearer: null, schemes: [], unsupported: [] });
|
|
428
|
+
assert.equal(F.upstreamAuth({ name: "x" }).bearer, false, "no schemes: no auth declared");
|
|
429
|
+
// 1.0 (ProtoJSON oneof wrappers + securityRequirements)
|
|
430
|
+
const v1 = { securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: "Bearer" } } }, securityRequirements: [{ schemes: { bearer: { list: [] } } }] };
|
|
431
|
+
assert.deepEqual(F.upstreamAuth(v1), { bearer: true, schemes: ["bearer: bearer"], unsupported: [] });
|
|
432
|
+
assert.equal(F.upstreamAuth({ securitySchemes: { o: { oauth2SecurityScheme: { flows: {} } } }, securityRequirements: [{ schemes: { o: { list: ["a2a"] } } }] }).bearer, true, "OAuth 2.0 sends a bearer");
|
|
433
|
+
assert.equal(F.upstreamAuth({ securitySchemes: { o: { openIdConnectSecurityScheme: { openIdConnectUrl: "https://id.example.com" } } } }).bearer, true);
|
|
434
|
+
// 0.3 (OpenAPI style + security)
|
|
435
|
+
const v03 = { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } }, security: [{ bearerAuth: [] }] };
|
|
436
|
+
assert.deepEqual(F.upstreamAuth(v03), { bearer: true, schemes: ["bearerAuth: bearer"], unsupported: [] });
|
|
437
|
+
assert.equal(F.upstreamAuth({ securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } }).bearer, true, "declared without requirements still counts");
|
|
438
|
+
// anonymous allowed as an alternative
|
|
439
|
+
assert.equal(F.upstreamAuth({ ...v03, security: [{ bearerAuth: [] }, {}] }).bearer, false);
|
|
440
|
+
// only schemes the façade can't present
|
|
441
|
+
const key = F.upstreamAuth({ securitySchemes: { k: { apiKeySecurityScheme: { location: "header", name: "x-api-key" } } }, securityRequirements: [{ schemes: { k: { list: [] } } }] });
|
|
442
|
+
assert.deepEqual(key, { bearer: false, schemes: ["k: apiKey"], unsupported: ["k: apiKey"] });
|
|
443
|
+
assert.deepEqual(F.upstreamAuth({ securitySchemes: { b: { type: "http", scheme: "basic" } }, security: [{ b: [] }] }).unsupported, ["b: basic"]);
|
|
444
|
+
assert.equal(F.upstreamAuth(tailnetCard()).bearer, false, "the test card wants an API key header");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("classifyUpstream: Access, the agent's bearer check, tunnel, network and a reachable app are told apart", () => {
|
|
448
|
+
const c = (status: number | null, body = "", headers: Record<string, string> = {}, bearerSent = false, error = "") => F.classifyUpstream({ status, body, headers, bearerSent, error });
|
|
449
|
+
const rpc = JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "Method not found" } });
|
|
450
|
+
assert.deepEqual(c(200, rpc), { reason: "reachable", status: 200, rpcErrorCode: -32601, detail: "JSON-RPC error -32601 (HTTP 200)" });
|
|
451
|
+
assert.equal(c(404, rpc).reason, "reachable", "any JSON-RPC answer means the app was reached");
|
|
452
|
+
assert.equal(c(200, JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} })).reason, "reachable");
|
|
453
|
+
assert.equal(c(302, "", { Location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/up.example.com?kid=x" }).reason, "access_rejected");
|
|
454
|
+
assert.equal(c(403, "<html>Forbidden. You don't have permission ... Cloudflare Access</html>").reason, "access_rejected");
|
|
455
|
+
assert.equal(c(401, "", { "cf-access-domain": "up.example.com" }, true).reason, "access_rejected");
|
|
456
|
+
assert.equal(F.classifyUpstream({ status: 302, headers: { location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/x" }, bearerSent: true, accessSent: false }).reason, "access_credentials_missing");
|
|
457
|
+
assert.equal(F.classifyUpstream({ status: 403, body: "Forbidden (Cloudflare Access)", bearerSent: true, accessSent: true }).reason, "access_rejected", "a configured token Access refuses: policy / wrong token");
|
|
458
|
+
assert.equal(c(401, "unauthorized", {}, false).reason, "upstream_auth_missing");
|
|
459
|
+
// Hermes, verbatim from Jean's probe: Access headers only -> 401 + JSON-RPC -32050; Access + Hermes bearer -> 200 + -32601
|
|
460
|
+
const hermes401 = JSON.stringify({ jsonrpc: "2.0", id: "probe", error: { code: -32050, message: "unauthorized" } });
|
|
461
|
+
assert.equal(c(401, hermes401, { "content-type": "application/json" }, false).reason, "upstream_auth_missing");
|
|
462
|
+
assert.equal(c(401, hermes401, { "content-type": "application/json" }, true).reason, "upstream_auth_rejected");
|
|
463
|
+
const hermes200 = JSON.stringify({ jsonrpc: "2.0", id: "probe", error: { code: -32601, message: "Method not found" } });
|
|
464
|
+
assert.deepEqual(c(200, hermes200, { "content-type": "application/json" }, true), { reason: "reachable", status: 200, rpcErrorCode: -32601, detail: "JSON-RPC error -32601 (HTTP 200)" });
|
|
465
|
+
assert.equal(c(403, JSON.stringify({ error: "bad token" }), {}, true).reason, "upstream_auth_rejected");
|
|
466
|
+
assert.equal(c(401, JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32000, message: "unauthorized" } }), {}, true).reason, "upstream_auth_rejected", "a JSON-RPC body on 401 is still an auth failure");
|
|
467
|
+
assert.deepEqual(c(530, "error code: 1033"), { reason: "tunnel_down", status: 530, cloudflareError: "1033", detail: "HTTP 530, Cloudflare error 1033: Cloudflare can't reach the tunnel (is cloudflared running?)" });
|
|
468
|
+
assert.equal(c(502, "<html>Bad gateway</html>").reason, "upstream_unavailable");
|
|
469
|
+
assert.equal(c(null, "", {}, false, "TypeError: fetch failed").reason, "network");
|
|
470
|
+
assert.equal(c(404, "<html>Not found</html>").reason, "unexpected_response");
|
|
471
|
+
assert.equal(c(301, "", { location: "https://elsewhere.example.com/" }).reason, "unexpected_response");
|
|
472
|
+
const h = new Headers({ location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/x" });
|
|
473
|
+
assert.equal(F.classifyUpstream({ status: 302, headers: h, body: "", bearerSent: false }).reason, "access_rejected", "Headers objects work too");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
/** Jean's façade: Access admits the Worker's service token, but the agent (Hermes) also checks its own bearer. */
|
|
477
|
+
function bearerCheckingUpstream(want = "hermes-bearer") {
|
|
478
|
+
const seen: any[] = [];
|
|
479
|
+
const card = { ...tailnetCard(), securitySchemes: { bearer: { type: "http", scheme: "bearer" } }, securityRequirements: undefined, security: [{ bearer: [] }] }; // 0.3-style auth, as Hermes advertises
|
|
480
|
+
const fn = async (url: string, init: any): Promise<Response> => {
|
|
481
|
+
const h = init.headers || {};
|
|
482
|
+
if (h["cf-access-client-id"] !== "cid.access" || h["cf-access-client-secret"] !== "csecret")
|
|
483
|
+
return new Response(null, { status: 302, headers: { location: "https://team.cloudflareaccess.com/cdn-cgi/access/login/agent-upstream.example.net" } });
|
|
484
|
+
if (url.endsWith(".json")) return Response.json(card); // the card itself is public behind Access
|
|
485
|
+
const rq = JSON.parse(init.body);
|
|
486
|
+
seen.push(rq);
|
|
487
|
+
if (h.authorization !== `Bearer ${want}`) return Response.json({ jsonrpc: "2.0", id: rq.id, error: { code: -32050, message: "unauthorized" } }, { status: 401 }); // Hermes, as Jean probed it
|
|
488
|
+
if (rq.method !== "SendMessage" && rq.method !== "GetTask") return Response.json({ jsonrpc: "2.0", id: rq.id, error: { code: -32601, message: "Method not found" } });
|
|
489
|
+
return Response.json({ jsonrpc: "2.0", id: rq.id, result: { task: { id: "t-1", contextId: "c-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
|
|
490
|
+
};
|
|
491
|
+
return { fn, seen };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
test("proxy mode: missing UPSTREAM_TOKEN (Jean's case): peers get a generic 502, the owner gets the precise reason", async (t) => {
|
|
495
|
+
const up = bearerCheckingUpstream();
|
|
496
|
+
const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-jean.json", UPSTREAM_ACCESS_CLIENT_ID: "cid.access", UPSTREAM_ACCESS_CLIENT_SECRET: "csecret" }, up.fn);
|
|
497
|
+
t.after(s.restore);
|
|
498
|
+
const a = await s.issue("alice");
|
|
499
|
+
const r = await s.rpc(a, "SendMessage", { message: { role: "ROLE_USER", parts: [{ text: "hi" }] } });
|
|
500
|
+
assert.equal(r.status, 502);
|
|
501
|
+
assert.equal(r.data.error.code, -32603);
|
|
502
|
+
assert.doesNotMatch(r.text, /UPSTREAM|Access|bearer|token|agent-upstream|401/i);
|
|
503
|
+
const diag = (await s.call("GET", "/owner/facade", { token: "owner-secret" })).data;
|
|
504
|
+
assert.equal(diag.upstreamCard, "ok", "the card passes (Access only), the calls don't");
|
|
505
|
+
assert.deepEqual(diag.upstreamAuth, { bearer: true, schemes: ["bearer: bearer"], unsupported: [] });
|
|
506
|
+
assert.equal(diag.hasUpstreamToken, false);
|
|
507
|
+
assert.equal(diag.hasUpstreamAccessServiceToken, true);
|
|
508
|
+
assert.equal(diag.lastFailure.reason, "upstream_auth_missing");
|
|
509
|
+
assert.equal(diag.lastFailure.status, 401);
|
|
510
|
+
assert.equal(diag.lastFailure.method, "SendMessage");
|
|
511
|
+
assert.equal(diag.lastFailure.peer, "alice");
|
|
512
|
+
assert.match(diag.lastFailure.at, /^\d{4}-/);
|
|
513
|
+
|
|
514
|
+
const v = await s.call("POST", "/owner/facade/verify", { token: "owner-secret" });
|
|
515
|
+
assert.equal(v.status, 200);
|
|
516
|
+
assert.equal(v.data.ok, false);
|
|
517
|
+
assert.equal(v.data.reason, "upstream_auth_missing");
|
|
518
|
+
assert.equal(v.data.status, 401);
|
|
519
|
+
assert.deepEqual(v.data.sent, { bearer: false, accessServiceToken: true });
|
|
520
|
+
assert.equal(v.data.upstreamWantsBearer, true);
|
|
521
|
+
assert.equal(up.seen.at(-1).method, "a2a-exposed/verify-unknown-method");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("owner verify: classifies Access, bearer, tunnel and network failures; a reachable app answers -32601; never creates a task", async (t) => {
|
|
525
|
+
const up = bearerCheckingUpstream();
|
|
526
|
+
const env = { UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-v.json", UPSTREAM_ACCESS_CLIENT_ID: "cid.access", UPSTREAM_ACCESS_CLIENT_SECRET: "csecret", UPSTREAM_TOKEN: "hermes-bearer" };
|
|
527
|
+
const s = setup(env, up.fn);
|
|
528
|
+
t.after(s.restore);
|
|
529
|
+
const verify = async (st = s) => (await st.call("POST", "/owner/facade/verify", { token: "owner-secret" })).data;
|
|
530
|
+
let v = await verify();
|
|
531
|
+
assert.equal(v.ok, true, JSON.stringify(v));
|
|
532
|
+
assert.equal(v.reason, "reachable");
|
|
533
|
+
assert.equal(v.rpcErrorCode, -32601);
|
|
534
|
+
assert.deepEqual(v.sent, { bearer: true, accessServiceToken: true });
|
|
535
|
+
assert.ok(!JSON.stringify(v).includes("hermes-bearer") && !JSON.stringify(v).includes("csecret"), "no secret values in the answer");
|
|
536
|
+
const probe = s.calls.filter((c) => c.url === UPSTREAM).at(-1);
|
|
537
|
+
assert.equal(probe.headers.authorization, "Bearer hermes-bearer");
|
|
538
|
+
assert.equal(probe.headers["cf-access-client-id"], "cid.access");
|
|
539
|
+
assert.equal(JSON.parse(probe.body).method, "a2a-exposed/verify-unknown-method");
|
|
540
|
+
assert.equal((s.DB.db.prepare("SELECT COUNT(*) AS n FROM facade_owners").get() as any).n, 0, "nothing recorded");
|
|
541
|
+
assert.equal((s.DB.db.prepare("SELECT COUNT(*) AS n FROM tasks").get() as any).n, 0, "no task");
|
|
542
|
+
|
|
543
|
+
const wrong = setup({ ...env, UPSTREAM_TOKEN: "stale" }, up.fn);
|
|
544
|
+
t.after(wrong.restore);
|
|
545
|
+
assert.equal((await verify(wrong)).reason, "upstream_auth_rejected");
|
|
546
|
+
const wrongAccess = setup({ ...env, UPSTREAM_ACCESS_CLIENT_SECRET: "wrong" }, up.fn);
|
|
547
|
+
t.after(wrongAccess.restore);
|
|
548
|
+
v = await verify(wrongAccess);
|
|
549
|
+
assert.equal(v.reason, "access_rejected");
|
|
550
|
+
assert.equal(v.status, 302);
|
|
551
|
+
const { UPSTREAM_ACCESS_CLIENT_ID: _i, UPSTREAM_ACCESS_CLIENT_SECRET: _s, ...noAccessEnv } = env;
|
|
552
|
+
const noAccess = setup(noAccessEnv, up.fn);
|
|
553
|
+
t.after(noAccess.restore);
|
|
554
|
+
assert.equal((await verify(noAccess)).reason, "access_credentials_missing");
|
|
555
|
+
const down = setup(env, async () => new Response("error code: 1033", { status: 530 }));
|
|
556
|
+
t.after(down.restore);
|
|
557
|
+
assert.equal((await verify(down)).reason, "tunnel_down");
|
|
558
|
+
const net = setup(env, async () => { throw new TypeError("fetch failed"); });
|
|
559
|
+
t.after(net.restore);
|
|
560
|
+
assert.equal((await verify(net)).reason, "network");
|
|
561
|
+
const bad = setup({ UPSTREAM_URL: "https://jean.tail1234.ts.net/a2a" });
|
|
562
|
+
t.after(bad.restore);
|
|
563
|
+
v = await verify(bad);
|
|
564
|
+
assert.equal(v.reason, "misconfigured");
|
|
565
|
+
assert.equal(bad.calls.length, 0);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("owner verify is owner-only and proxy-only (no public or peer-reachable probe)", async (t) => {
|
|
569
|
+
const up = bearerCheckingUpstream();
|
|
570
|
+
const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-o.json", UPSTREAM_ACCESS_CLIENT_ID: "cid.access", UPSTREAM_ACCESS_CLIENT_SECRET: "csecret", UPSTREAM_TOKEN: "hermes-bearer" }, up.fn);
|
|
571
|
+
t.after(s.restore);
|
|
572
|
+
const peer = await s.issue("mallory");
|
|
573
|
+
const n0 = s.calls.filter((c) => c.url === UPSTREAM).length;
|
|
574
|
+
assert.equal((await s.call("POST", "/owner/facade/verify")).status, 401);
|
|
575
|
+
assert.equal((await s.call("POST", "/owner/facade/verify", { token: peer })).status, 401, "a peer token is not the owner token");
|
|
576
|
+
assert.equal(s.calls.filter((c) => c.url === UPSTREAM).length, n0, "unauthenticated calls never reach the upstream");
|
|
577
|
+
const inbox = setup();
|
|
578
|
+
t.after(inbox.restore);
|
|
579
|
+
const r = await inbox.call("POST", "/owner/facade/verify", { token: "owner-secret" });
|
|
580
|
+
assert.equal(r.status, 409);
|
|
581
|
+
assert.equal(r.data.reason, "not_proxy_mode");
|
|
582
|
+
});
|