a2a-exposed 0.8.6 → 0.8.7
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/README.md +4 -4
- package/lib/deploy.mjs +3 -3
- package/lib/feedback.mjs +1 -1
- package/lib/main.mjs +108 -32
- package/lib/pair.mjs +4 -4
- package/lib/status.mjs +35 -10
- package/lib/tunnel.mjs +94 -28
- package/lib/uninstall.mjs +124 -14
- package/lib/workersdev.mjs +1 -1
- package/package.json +2 -2
- package/worker/deploy.env.example +1 -1
- package/worker/package.json +1 -1
- package/worker/src/feedback.ts +1 -1
- package/worker/src/index.ts +19 -16
- package/worker/src/mcp.ts +18 -13
- package/worker/src/pairing.ts +23 -8
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +5 -4
- package/worker/test/mcp.test.ts +7 -1
- package/worker/test/pairing.test.ts +46 -1
- package/worker/test/wake.test.ts +2 -0
package/lib/tunnel.mjs
CHANGED
|
@@ -40,8 +40,11 @@ export const TUNNEL_KEYS = [
|
|
|
40
40
|
const WORDS = ["amber", "brisk", "cedar", "delta", "ember", "fable", "gale", "harbor", "iris", "jade", "kelp", "lumen", "maple",
|
|
41
41
|
"nova", "onyx", "pebble", "quill", "raven", "sable", "tidal", "umber", "velvet", "willow", "zephyr", "otter", "lark", "fjord", "heron"];
|
|
42
42
|
export function randomLabel(rand = crypto.randomInt) {
|
|
43
|
-
const
|
|
44
|
-
|
|
43
|
+
const a = rand(WORDS.length);
|
|
44
|
+
let b = rand(WORDS.length);
|
|
45
|
+
if (b === a) b = rand(WORDS.length);
|
|
46
|
+
if (b === a) b = (a + 1) % WORDS.length;
|
|
47
|
+
return `wake-${WORDS[a]}-${WORDS[b]}-${rand(0x10000).toString(16).padStart(4, "0")}`;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
@@ -62,8 +65,8 @@ export function noZoneHelp(zones) {
|
|
|
62
65
|
inactive.length
|
|
63
66
|
? `no active domain (zone) on this Cloudflare account (not active yet: ${inactive.map((z) => `${z.name} [${z.status}]`).join(", ")}).`
|
|
64
67
|
: "this Cloudflare account has no domain (zone).",
|
|
65
|
-
"The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the
|
|
66
|
-
"Without one, use scheduled polling
|
|
68
|
+
"The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the A2A endpoint is on workers.dev or a custom hostname).",
|
|
69
|
+
"Without one, use scheduled polling. The A2A endpoint works as it is. Agents without inbound webhooks poll.",
|
|
67
70
|
`To use the tunnel later, add a domain to the account and run \`${CLI} tunnel create\` (no redeploy needed).`,
|
|
68
71
|
].join("\n");
|
|
69
72
|
}
|
|
@@ -88,12 +91,12 @@ export function pickTunnelHostname({ tunnelHostname, tunnelZone, inboxHost, zone
|
|
|
88
91
|
return { hostname: `${label()}.${zone.name}`, zone, note: `wake hostname on the zone ${zone.name} (--tunnel-zone)` };
|
|
89
92
|
}
|
|
90
93
|
const own = inboxHost ? zoneFor(inboxHost, usable) : null;
|
|
91
|
-
if (own) return { hostname: `${label()}.${own.name}`, zone: own, note: `wake hostname on ${own.name}, the zone of the
|
|
94
|
+
if (own) return { hostname: `${label()}.${own.name}`, zone: own, note: `wake hostname on ${own.name}, the zone of the A2A endpoint hostname` };
|
|
92
95
|
if (usable.length === 1)
|
|
93
|
-
return { hostname: `${label()}.${usable[0].name}`, zone: usable[0], note: `the account has one zone, ${usable[0].name}: the wake hostname goes there (the
|
|
96
|
+
return { hostname: `${label()}.${usable[0].name}`, zone: usable[0], note: `the account has one zone, ${usable[0].name}: the wake hostname goes there (the A2A endpoint URL is unchanged)` };
|
|
94
97
|
if (!usable.length) die(noZoneHelp(zones));
|
|
95
98
|
return die([
|
|
96
|
-
`this Cloudflare account has ${usable.length} zones; choose one for the wake hostname (the
|
|
99
|
+
`this Cloudflare account has ${usable.length} zones; choose one for the wake hostname (the A2A endpoint URL is unchanged):`,
|
|
97
100
|
zoneList(usable),
|
|
98
101
|
"Re-run with --tunnel-zone <zone> (or --tunnel-hostname wake-<name>.<zone>).",
|
|
99
102
|
].join("\n"));
|
|
@@ -164,6 +167,31 @@ export function cloudflareErrorCode(body) {
|
|
|
164
167
|
return m ? m[1] || m[2] || m[3] || m[4] : "";
|
|
165
168
|
}
|
|
166
169
|
|
|
170
|
+
/** API token permission named in `[10000]` and `[7403]` errors, from the `cf` command. */
|
|
171
|
+
export function cfPermission(args) {
|
|
172
|
+
const head = args[0];
|
|
173
|
+
if (head === "workers") return "Workers Scripts: Edit";
|
|
174
|
+
if (head === "d1") return "D1: Edit";
|
|
175
|
+
if (head === "dns") return "Zone DNS: Edit";
|
|
176
|
+
if (head === "zero-trust") return "Access: Edit";
|
|
177
|
+
if (head === "tunnels") return "Cloudflare Tunnel: Edit";
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Append the token permission when Cloudflare returns code 10000 or 7403. */
|
|
182
|
+
export function withPermissionHint(args, msg) {
|
|
183
|
+
if (!/\[(10000|7403)\]/.test(msg) || /The API token needs/.test(msg)) return msg;
|
|
184
|
+
const perm = cfPermission(args);
|
|
185
|
+
const text = String(msg || "").trim().replace(/[.]+$/, "");
|
|
186
|
+
return perm ? `${text}. The API token needs ${perm}.` : `${text}.`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** True when `cf` reports that this Worker does not exist (code 10007). */
|
|
190
|
+
export function workerMissingError(err) {
|
|
191
|
+
const msg = err instanceof Error ? err.message : String(err || "");
|
|
192
|
+
return /\[10007\]/.test(msg) || /this worker does not exist/i.test(msg);
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
// ------------------------------------------------------------------ cf calls (JSON in, JSON out, errors captured)
|
|
168
196
|
export function cfCall(dir, args, { body, file, allowNotFound = false } = {}) {
|
|
169
197
|
const full = [...args];
|
|
@@ -175,7 +203,7 @@ export function cfCall(dir, args, { body, file, allowNotFound = false } = {}) {
|
|
|
175
203
|
const err = (r.stderr || r.stdout || "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
176
204
|
if (allowNotFound && /404|not found|does not exist|could not be found/i.test(err)) return null;
|
|
177
205
|
const msg = (err.match(/\[\d+\][^\n]*/g) || [err.trim().split("\n").slice(-3).join(" ")]).join("; ");
|
|
178
|
-
throw new CliError(`cf ${args.slice(0, 3).join(" ")} failed: ${msg.slice(0, 400)}`);
|
|
206
|
+
throw new CliError(`cf ${args.slice(0, 3).join(" ")} failed: ${withPermissionHint(args, msg.slice(0, 400))}`);
|
|
179
207
|
}
|
|
180
208
|
const out = (r.stdout || "").trim();
|
|
181
209
|
if (!out) return {};
|
|
@@ -187,7 +215,7 @@ const ACCESS_HELP = (acct) => [
|
|
|
187
215
|
" - dashboard: https://one.dash.cloudflare.com/ -> pick a team name and the Free plan (up to 50 users), then re-run",
|
|
188
216
|
" - or let the CLI create the organization: re-run with --zero-trust-org <team-name> (gives <team-name>.cloudflareaccess.com)",
|
|
189
217
|
` account: ${acct || "(see cf auth whoami)"}`,
|
|
190
|
-
"No tunnel? Use scheduled polling instead
|
|
218
|
+
"No tunnel? Use scheduled polling instead. Agents without inbound webhooks poll.",
|
|
191
219
|
].join("\n");
|
|
192
220
|
|
|
193
221
|
function accessOrg(dir, o) {
|
|
@@ -226,7 +254,7 @@ export function cfWorkDir(preferred) {
|
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
function needDeployment(o) {
|
|
229
|
-
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`
|
|
257
|
+
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
|
|
230
258
|
const dir = D.workerDir(o);
|
|
231
259
|
if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
|
|
232
260
|
return cfWorkDir(dir);
|
|
@@ -234,7 +262,7 @@ function needDeployment(o) {
|
|
|
234
262
|
|
|
235
263
|
/** `cf` cwd for `tunnel rm` / `uninstall`. A missing Worker project uses a temp directory with a writable cache. */
|
|
236
264
|
function cfDirForRemove(o, { allowMissingProject = false } = {}) {
|
|
237
|
-
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`
|
|
265
|
+
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
|
|
238
266
|
const dir = D.workerDir(o);
|
|
239
267
|
if (!fs.existsSync(dir) && !allowMissingProject) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
|
|
240
268
|
return cfWorkDir(dir);
|
|
@@ -262,6 +290,11 @@ export async function create(o) {
|
|
|
262
290
|
if (tunnelStarted()) return resume(dir, o);
|
|
263
291
|
const { preset, origin, wpath } = preflight(o);
|
|
264
292
|
const worker = D.workerName();
|
|
293
|
+
try {
|
|
294
|
+
const preview = await owner("GET", "/owner/wake/preview");
|
|
295
|
+
if (preview && preview.configured)
|
|
296
|
+
console.error(`warning: the Worker already has a wake URL. tunnel create replaces it with the tunnel hostname. \`${CLI} tunnel rm\` removes that tunnel. It does not put the previous wake URL back.`);
|
|
297
|
+
} catch { /* Worker unreachable: the upload below reports it */ }
|
|
265
298
|
|
|
266
299
|
step("finding a zone for the wake hostname");
|
|
267
300
|
const { hostname, zone, note } = pickTunnelHostname({
|
|
@@ -278,6 +311,8 @@ export async function create(o) {
|
|
|
278
311
|
const label = `a2a-exposed wake ${worker}`;
|
|
279
312
|
const save = (u) => C.saveConfig(u);
|
|
280
313
|
save({ A2A_TUNNEL_HOSTNAME: hostname, A2A_TUNNEL_ORIGIN: origin, A2A_TUNNEL_PATH: wpath, A2A_TUNNEL_ZONE_ID: zone.id });
|
|
314
|
+
/** Secret names this run uploaded. Rollback deletes only these. */
|
|
315
|
+
let createdSecrets = [];
|
|
281
316
|
try {
|
|
282
317
|
step(`Access service token "${label}"`);
|
|
283
318
|
const st = cfCall(dir, ["zero-trust", "access", "service-tokens", "create"], { body: serviceTokenBody(label) });
|
|
@@ -308,16 +343,19 @@ export async function create(o) {
|
|
|
308
343
|
const secrets = { WAKE_WEBHOOK_URL: url, WAKE_ACCESS_CLIENT_ID: st.client_id, WAKE_ACCESS_CLIENT_SECRET: st.client_secret };
|
|
309
344
|
for (const k of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[k]) secrets[k] = process.env[k];
|
|
310
345
|
await putSecrets(dir, worker, secrets);
|
|
346
|
+
createdSecrets = Object.keys(secrets);
|
|
311
347
|
|
|
312
348
|
console.log(url);
|
|
313
|
-
console.error(`\
|
|
349
|
+
console.error(`\nAccess application "${label}" (id ${app.id}); service token id ${st.id}; tunnel id ${tun.id}.`);
|
|
350
|
+
console.error(`tunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
|
|
314
351
|
console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
|
|
315
|
-
console.error(`\
|
|
352
|
+
console.error(`\nTo undo this, run \`${CLI} tunnel rm\`.`);
|
|
353
|
+
console.error(`Then (Worker secrets apply within about 15 to 40 seconds; wait about 30 seconds): ${CLI} tunnel status and ${CLI} wake test`);
|
|
316
354
|
if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
|
|
317
355
|
console.error(`note: no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET in the environment; keep the ${preset} webhook's own auth too (export it and run \`wake set\`)`);
|
|
318
356
|
} catch (e) {
|
|
319
|
-
console.error(
|
|
320
|
-
await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
|
|
357
|
+
console.error("==> rolling back what was created");
|
|
358
|
+
await remove(o, { quiet: true, secretNames: createdSecrets }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
|
|
321
359
|
throw e;
|
|
322
360
|
}
|
|
323
361
|
}
|
|
@@ -396,7 +434,7 @@ export async function status(o) {
|
|
|
396
434
|
const tid = C.get("A2A_TUNNEL_ID");
|
|
397
435
|
if (tid) {
|
|
398
436
|
const t = cfCall(dir, ["tunnels", "get", tid], { allowNotFound: true });
|
|
399
|
-
info.tunnel = t ? { id: tid, status: t.status, connections: (t.connections || []).length } : "missing";
|
|
437
|
+
info.tunnel = t ? { id: tid, status: t.status === "inactive" ? "never connected" : t.status, connections: (t.connections || []).length } : "missing";
|
|
400
438
|
}
|
|
401
439
|
const app = C.get("A2A_TUNNEL_ACCESS_APP_ID");
|
|
402
440
|
if (app) {
|
|
@@ -424,30 +462,54 @@ export async function status(o) {
|
|
|
424
462
|
console.log(JSON.stringify(info, null, 2));
|
|
425
463
|
const w = info.probe.withServiceToken || "";
|
|
426
464
|
if (/1033/.test(w)) console.error("connector not running: start cloudflared on the agent's machine (tunnel create printed the command)");
|
|
427
|
-
|
|
465
|
+
const note = bareProbeNote(info.probe.withoutToken);
|
|
466
|
+
if (note) console.error(note);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** What `tunnel status` prints about the request that carries no service token. A network error is DNS, not a missed Access block. */
|
|
470
|
+
export function bareProbeNote(withoutToken) {
|
|
471
|
+
const bare = withoutToken || "";
|
|
472
|
+
if (/^network error:/.test(bare)) return "DNS may still be propagating; retry in 30 seconds";
|
|
473
|
+
if (!/^HTTP 40[13]/.test(bare)) return "WARNING: the bare request was not blocked by Access; check the Access application";
|
|
474
|
+
return "";
|
|
428
475
|
}
|
|
429
476
|
|
|
430
|
-
export async function remove(o, { quiet = false, allowMissingProject = false } = {}) {
|
|
477
|
+
export async function remove(o, { quiet = false, allowMissingProject = false, skipSecrets = false, secretNames = undefined, rerun = "" } = {}) {
|
|
478
|
+
if (!TUNNEL_KEYS.some((k) => C.get(k))) {
|
|
479
|
+
if (!quiet) console.error("nothing to remove");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
431
482
|
const dir = cfDirForRemove(o, { allowMissingProject });
|
|
432
483
|
const worker = D.workerName();
|
|
433
484
|
const label = `a2a-exposed wake ${worker}`;
|
|
434
485
|
const failures = [];
|
|
486
|
+
const removedParts = [];
|
|
435
487
|
const attempt = async (what, key, fn) => {
|
|
436
488
|
if (key && !C.get(key)) return;
|
|
437
|
-
try { await fn(); if (key) C.saveConfig({ [key]: null }); step(`removed ${what}`); }
|
|
489
|
+
try { await fn(); if (key) C.saveConfig({ [key]: null }); step(`removed ${what}`); removedParts.push(what); }
|
|
438
490
|
catch (e) { failures.push(`${what}: ${e.message}`); }
|
|
439
491
|
};
|
|
440
492
|
const host = C.get("A2A_TUNNEL_HOSTNAME");
|
|
441
493
|
// 1. Worker secrets first, so no wake is sent to a half-removed hostname. WAKE_WEBHOOK_URL only if it is ours.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
494
|
+
// secretNames lists what this run created (rollback). Omitted: the usual tunnel-rm set. skipSecrets: the Worker is already gone.
|
|
495
|
+
const explicitSecrets = secretNames !== undefined;
|
|
496
|
+
const names = explicitSecrets ? [...secretNames] : ["WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"];
|
|
497
|
+
if (host && !skipSecrets && names.length) {
|
|
498
|
+
if (!explicitSecrets) {
|
|
499
|
+
let ours = false;
|
|
500
|
+
try { const p = await owner("GET", "/owner/wake/preview"); ours = p.fingerprints?.url === fingerprint(wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/")); }
|
|
501
|
+
catch { /* Worker unreachable: leave the URL */ }
|
|
502
|
+
if (ours && !names.includes("WAKE_WEBHOOK_URL")) names.push("WAKE_WEBHOOK_URL");
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
449
505
|
await putSecrets(dir, worker, Object.fromEntries(names.map((k) => [k, null])));
|
|
450
|
-
|
|
506
|
+
const secretsWhat = `Worker secrets for ${host} (${names.join(", ")})`;
|
|
507
|
+
step(`removed ${secretsWhat}`);
|
|
508
|
+
removedParts.push(secretsWhat);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
if (workerMissingError(e)) step(`Worker for ${host} is already gone; its secrets went with it`);
|
|
511
|
+
else failures.push(`Worker secrets for ${host} (${names.join(", ")}): ${e.message}`);
|
|
512
|
+
}
|
|
451
513
|
}
|
|
452
514
|
// 2. DNS before Access, so the hostname never resolves without its Access app
|
|
453
515
|
await attempt(`DNS record ${host || C.get("A2A_TUNNEL_DNS_ID")}`, "A2A_TUNNEL_DNS_ID", () => cfCall(dir, ["dns", "records", "delete", C.get("A2A_TUNNEL_DNS_ID"), "-z", C.get("A2A_TUNNEL_ZONE_ID"), "--force"], { allowNotFound: true }));
|
|
@@ -464,7 +526,11 @@ export async function remove(o, { quiet = false, allowMissingProject = false } =
|
|
|
464
526
|
C.saveConfig({ A2A_TUNNEL_ACCESS_CLIENT_ID: null, A2A_TUNNEL_ACCESS_CLIENT_SECRET: null });
|
|
465
527
|
});
|
|
466
528
|
await attempt("tunnel token file", "A2A_TUNNEL_TOKEN_FILE", () => fs.rmSync(C.get("A2A_TUNNEL_TOKEN_FILE"), { force: true }));
|
|
467
|
-
if (failures.length)
|
|
529
|
+
if (failures.length) {
|
|
530
|
+
const err = new CliError(`some parts could not be removed (ids kept in ${C.CONFIG_FILE}; re-run ${rerun || "`tunnel rm`"}):\n ${failures.join("\n ")}`);
|
|
531
|
+
err.removed = removedParts;
|
|
532
|
+
throw err;
|
|
533
|
+
}
|
|
468
534
|
C.saveConfig(Object.fromEntries(TUNNEL_KEYS.map((k) => [k, null])));
|
|
469
535
|
if (!quiet) console.error("tunnel removed. Stop cloudflared on the agent's machine (and `cloudflared service uninstall` if installed). Wakes are off until you set a new WAKE_WEBHOOK_URL (or use polling).");
|
|
470
536
|
}
|
package/lib/uninstall.mjs
CHANGED
|
@@ -1,45 +1,155 @@
|
|
|
1
|
-
// `uninstall`: remove the wake tunnel (when one is saved),
|
|
1
|
+
// `uninstall`: remove the Worker, the wake tunnel (when one is saved), and the D1 database for this config.
|
|
2
2
|
import readline from "node:readline/promises";
|
|
3
|
-
import { CliError } from "./a2a.mjs";
|
|
3
|
+
import { CLI, CliError } from "./a2a.mjs";
|
|
4
4
|
import * as C from "./config.mjs";
|
|
5
5
|
import * as D from "./deploy.mjs";
|
|
6
6
|
import * as T from "./tunnel.mjs";
|
|
7
7
|
|
|
8
8
|
const step = (s) => console.error(`==> ${s}`);
|
|
9
9
|
|
|
10
|
+
/** Saved Worker, D1 id, or tunnel: something this config deployed. */
|
|
11
|
+
export function deploymentSaved() {
|
|
12
|
+
return !!(C.get("A2A_WORKER_NAME") || C.get("A2A_D1_ID") || T.TUNNEL_KEYS.some((k) => C.get(k)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Lines printed before the prompt and by `--dry-run`. The wake line is present only when a tunnel is saved. */
|
|
16
|
+
export function uninstallPlan() {
|
|
17
|
+
const lines = ["This removes:"];
|
|
18
|
+
lines.push(`Worker: ${C.get("A2A_WORKER_NAME") || "(none saved)"}`);
|
|
19
|
+
const d1 = [C.get("A2A_D1_NAME"), C.get("A2A_D1_ID")].filter(Boolean).join(" ");
|
|
20
|
+
lines.push(`D1: ${d1 || "(none saved)"}`);
|
|
21
|
+
lines.push(`Account: ${C.get("CLOUDFLARE_ACCOUNT_ID") || "(none saved)"}`);
|
|
22
|
+
if (T.TUNNEL_KEYS.some((k) => C.get(k))) {
|
|
23
|
+
const host = C.get("A2A_TUNNEL_HOSTNAME");
|
|
24
|
+
const id = C.get("A2A_TUNNEL_ID");
|
|
25
|
+
const worker = C.get("A2A_WORKER_NAME");
|
|
26
|
+
const label = worker ? `a2a-exposed wake ${worker}` : "(none saved)";
|
|
27
|
+
lines.push(`Wake: ${[host, id && `tunnel ${id}`].filter(Boolean).join(", ") || "(tunnel saved)"}`);
|
|
28
|
+
const app = C.get("A2A_TUNNEL_ACCESS_APP_ID");
|
|
29
|
+
const token = C.get("A2A_TUNNEL_ACCESS_TOKEN_ID");
|
|
30
|
+
const dns = C.get("A2A_TUNNEL_DNS_ID");
|
|
31
|
+
lines.push(`Access application: ${app ? `${label} (${app})` : "(none saved)"}`);
|
|
32
|
+
lines.push(`Service token: ${token ? `${label} (${token})` : "(none saved)"}`);
|
|
33
|
+
lines.push(`DNS record: ${[host, dns && `id ${dns}`].filter(Boolean).join(", ") || "(none saved)"}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push("Removal is permanent. It takes the messages and the paired peers with it.");
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
/** Ask before deleting. On a terminal, wait for y or yes unless `yes` is set. Off a terminal, `--yes` is required. */
|
|
11
40
|
export async function confirmUninstall(yes, input = process.stdin, output = process.stderr) {
|
|
12
41
|
if (yes) return;
|
|
13
42
|
if (!input.isTTY) throw new CliError("uninstall removes the Worker, the D1 database, and the wake tunnel. Pass --yes.");
|
|
14
43
|
const rl = readline.createInterface({ input, output });
|
|
15
44
|
try {
|
|
16
|
-
const answer = (await rl.question("Remove the Worker,
|
|
45
|
+
const answer = (await rl.question("Remove the Worker, D1 database and wake tunnel listed above? [y/N] ")).trim().toLowerCase();
|
|
17
46
|
if (answer !== "y" && answer !== "yes") throw new CliError("uninstall cancelled");
|
|
18
47
|
} finally {
|
|
19
48
|
rl.close();
|
|
20
49
|
}
|
|
21
50
|
}
|
|
22
51
|
|
|
23
|
-
const kept = () => `
|
|
52
|
+
const kept = () => `Config directory kept at ${C.CONFIG_DIR}. The owner token and WAKE_* values stay in config.env. A later init reuses the owner token. Peer tokens stay in this directory too. Remove the directory to delete them.`;
|
|
53
|
+
|
|
54
|
+
/** A tunnel failure names the DNS record when that is the only object left. */
|
|
55
|
+
function tunnelLeft(err) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
57
|
+
const names = msg.split("\n").slice(1).map((l) => l.trim().split(":")[0].trim()).filter(Boolean);
|
|
58
|
+
if (names.length && names.every((n) => n.startsWith("DNS record"))) return names.join("; ");
|
|
59
|
+
return "wake tunnel";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** [10000] / [7403], a Worker that is still there, a DNS failure, and a D1 failure after the Worker is gone, say how to continue. */
|
|
63
|
+
function removalError(err, { removed, left, workerGone, workerStill }) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
const code = /\[(10000|7403)\]/.test(msg);
|
|
66
|
+
const d1Failed = /cf d1 delete/i.test(msg);
|
|
67
|
+
const dnsFailed = /DNS record/i.test(msg);
|
|
68
|
+
if (!code && !(d1Failed && workerGone) && !dnsFailed && !workerStill) throw err instanceof CliError ? err : new CliError(msg);
|
|
69
|
+
const lines = [msg];
|
|
70
|
+
if (!/uninstall --yes/.test(msg))
|
|
71
|
+
lines.push(`Run \`${CLI} uninstall --yes\` again. A rerun continues from what is left.`);
|
|
72
|
+
lines.push(`Removed: ${removed.length ? removed.join("; ") : "nothing"}.`);
|
|
73
|
+
lines.push(`Not removed: ${left.length ? left.join("; ") : "nothing"}.`);
|
|
74
|
+
if (d1Failed && workerGone) lines.push("The Worker is already gone. A rerun removes the D1 database.");
|
|
75
|
+
if (workerStill) lines.push("The Worker is still there, so the D1 database was left in place. A rerun is safe.");
|
|
76
|
+
throw new CliError(lines.join("\n"));
|
|
77
|
+
}
|
|
24
78
|
|
|
25
|
-
/** One line per removal. A
|
|
79
|
+
/** One line per removal. A missing Worker or D1 counts as already gone. The config directory stays. */
|
|
26
80
|
export async function uninstall(o) {
|
|
81
|
+
if (!deploymentSaved())
|
|
82
|
+
throw new CliError(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
|
|
83
|
+
console.error(uninstallPlan());
|
|
84
|
+
if (o["dry-run"]) {
|
|
85
|
+
console.error("Nothing was changed.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
27
88
|
await confirmUninstall(Boolean(o.yes));
|
|
28
|
-
const tunnelish = T.TUNNEL_KEYS.some((k) => C.get(k));
|
|
29
|
-
if (tunnelish) await T.remove(o, { allowMissingProject: true });
|
|
30
|
-
else step("no tunnel to remove");
|
|
31
89
|
|
|
32
90
|
const name = C.get("A2A_WORKER_NAME");
|
|
33
91
|
const d1 = C.get("A2A_D1_ID");
|
|
34
|
-
const
|
|
92
|
+
const d1Label = C.get("A2A_D1_NAME") || d1;
|
|
93
|
+
const tunnelish = T.TUNNEL_KEYS.some((k) => C.get(k));
|
|
94
|
+
const removed = [];
|
|
95
|
+
const dir = name || d1 || tunnelish ? T.cfWorkDir(D.workerDir(o)) : "";
|
|
96
|
+
let workerGone = !name;
|
|
97
|
+
let workerDeletedThisRun = false;
|
|
98
|
+
let workerError = null;
|
|
35
99
|
if (name) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
100
|
+
try {
|
|
101
|
+
const r = T.cfCall(dir, ["workers", "delete", name, "--force"], { allowNotFound: true });
|
|
102
|
+
workerGone = true;
|
|
103
|
+
workerDeletedThisRun = r !== null;
|
|
104
|
+
const line = r === null ? `Worker ${name} is already gone` : `Worker ${name}`;
|
|
105
|
+
step(r === null ? line : `removed ${line}`);
|
|
106
|
+
removed.push(line);
|
|
107
|
+
} catch (e) { workerError = e; }
|
|
108
|
+
}
|
|
109
|
+
// A 10000 on the Worker delete is the same missing Workers Scripts permission the secrets
|
|
110
|
+
// upload would hit. Skip that upload so the tunnel can still be removed. The Worker is
|
|
111
|
+
// still there, so the D1 database stays until a later run deletes the Worker.
|
|
112
|
+
const workerAuth = workerError && /\[10000\]/.test(workerError.message || "") && /cf workers delete/i.test(workerError.message || "");
|
|
113
|
+
let tunnelError = null;
|
|
114
|
+
if (tunnelish) {
|
|
115
|
+
try {
|
|
116
|
+
await T.remove(o, { allowMissingProject: true, skipSecrets: workerGone || workerAuth, quiet: !!name, rerun: `${CLI} uninstall --yes` });
|
|
117
|
+
removed.push("wake tunnel");
|
|
118
|
+
} catch (e) {
|
|
119
|
+
tunnelError = e;
|
|
120
|
+
if (Array.isArray(e.removed)) removed.push(...e.removed);
|
|
121
|
+
}
|
|
122
|
+
} else step("no tunnel to remove");
|
|
123
|
+
if (workerError) {
|
|
124
|
+
const left = [d1 && `D1 ${d1Label} (untouched)`, name && `Worker ${name}`, tunnelError && tunnelLeft(tunnelError)].filter(Boolean);
|
|
125
|
+
const err = tunnelError ? new CliError(`${workerError.message}\n${tunnelError.message}`) : workerError;
|
|
126
|
+
removalError(err, { removed, left, workerGone: false, workerStill: true });
|
|
127
|
+
}
|
|
128
|
+
if (tunnelError) {
|
|
129
|
+
// The Worker delete did not happen in this run only when this call did not delete it (already gone, or no Worker name).
|
|
130
|
+
const d1Left = d1 && (workerDeletedThisRun
|
|
131
|
+
? `D1 ${d1Label}`
|
|
132
|
+
: `D1 ${d1Label} (untouched because the Worker delete did not happen in this run)`);
|
|
133
|
+
const left = [d1Left, tunnelLeft(tunnelError)].filter(Boolean);
|
|
134
|
+
removalError(tunnelError, { removed, left, workerGone });
|
|
135
|
+
}
|
|
136
|
+
|
|
39
137
|
if (d1) {
|
|
40
|
-
|
|
41
|
-
|
|
138
|
+
try {
|
|
139
|
+
const r = T.cfCall(dir, ["d1", "delete", d1, "--force"], { allowNotFound: true });
|
|
140
|
+
const line = r === null ? `D1 database ${d1} is already gone` : `D1 database ${d1}`;
|
|
141
|
+
step(r === null ? line : `removed ${line}`);
|
|
142
|
+
removed.push(line);
|
|
143
|
+
C.saveConfig({ A2A_D1_ID: null, A2A_D1_NAME: null });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
removalError(e, { removed, left: [`D1 ${C.get("A2A_D1_NAME") || d1} (untouched)`, workerError && name && `Worker ${name}`].filter(Boolean), workerGone });
|
|
146
|
+
}
|
|
42
147
|
} else step("no D1 database id saved");
|
|
148
|
+
if (workerError || tunnelError) {
|
|
149
|
+
const err = workerError && tunnelError ? new CliError(`${workerError.message}\n${tunnelError.message}`) : (workerError || tunnelError);
|
|
150
|
+
const left = [workerError && name && `Worker ${name}`, tunnelError && "wake tunnel"].filter(Boolean);
|
|
151
|
+
removalError(err, { removed, left, workerGone });
|
|
152
|
+
}
|
|
43
153
|
C.saveConfig({
|
|
44
154
|
A2A_WORKER_NAME: null, A2A_D1_ID: null, A2A_D1_NAME: null, A2A_BASE_URL: null, A2A_HOSTNAME: null,
|
|
45
155
|
A2A_WORKER_DIR: null, A2A_WORKERS_DEV_SUBDOMAIN: null,
|
package/lib/workersdev.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export function noSubdomainHelp(accountId) {
|
|
|
31
31
|
return [
|
|
32
32
|
"this Cloudflare account has no workers.dev subdomain yet. Create one (once per account), then re-run:",
|
|
33
33
|
` - let the CLI do it: re-run with --workers-dev-subdomain <name> (gives https://<worker>.<name>.workers.dev)`,
|
|
34
|
-
` - or in the dashboard: ${onboardingUrl(accountId)} (the first visit to Workers
|
|
34
|
+
` - or in the dashboard: ${onboardingUrl(accountId)} (the first visit to the Workers section asks for it)`,
|
|
35
35
|
` - or the API: PUT /accounts/<account-id>/workers/subdomain {"subdomain":"<name>"}`,
|
|
36
36
|
` docs: ${DOCS}`,
|
|
37
37
|
].join("\n");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "a2a-exposed",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker
|
|
3
|
+
"version": "0.8.7",
|
|
4
|
+
"description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"a2a-exposed": "bin/a2a-exposed.mjs"
|
|
@@ -34,7 +34,7 @@ WAKE_PRESET=generic
|
|
|
34
34
|
#
|
|
35
35
|
# WAKE_TARGET_POLICY=public-https requires a wake to be https to a public address. The Worker
|
|
36
36
|
# connects to an address from that check. A redirect is followed only on the same origin. Leave it
|
|
37
|
-
# unset for a self-hosted
|
|
37
|
+
# unset for a self-hosted A2A endpoint.
|
|
38
38
|
# WAKE_TARGET_POLICY=public-https
|
|
39
39
|
# OpenID Connect approval (optional). All four of issuer, client id, allowlist and the client secret
|
|
40
40
|
# must be set or the pages stay password-only. APPROVAL_METHODS=oidc hides the password once that
|
package/worker/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "a2a-exposed-worker",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Cloudflare Worker for a2a-exposed: public A2A endpoint, D1
|
|
5
|
+
"description": "Cloudflare Worker for a2a-exposed: public A2A endpoint, D1 database, wake webhooks, owner API",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "cf build",
|
package/worker/src/feedback.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function feedbackToolDescription(repo: string): string {
|
|
|
41
41
|
const note = REPO_RE.test(repo) && !repo.includes("..")
|
|
42
42
|
? publicIssueNote(repo)
|
|
43
43
|
: "This report is filed as a public GitHub issue on this deployment's configured feedback repository. The result names that repository. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.";
|
|
44
|
-
return `${note} Redact before sending. Report a bug or an idea about this
|
|
44
|
+
return `${note} Redact before sending. Report a bug or an idea about this A2A endpoint to its developers. A bug requires reproduction steps. Include exact commands, error output with tokens and secrets removed, which agent you are, and where you run (local, cloud VM, CI, cloud agent sandbox, or other). Version, connection (webhook, proxy, or polling), and deployment (hosted or self-hosted) are filled by the A2A endpoint when you omit them. When this deployment does not file the report, the result is a link to the bug report form. You do not open a browser.`;
|
|
45
45
|
}
|
|
46
46
|
/** Inbox build. The same value as `version` in cli/package.json. */
|
|
47
47
|
export const WORKER_VERSION = VERSION;
|
package/worker/src/index.ts
CHANGED
|
@@ -1114,7 +1114,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1114
1114
|
publicCardLeaks: F.cardLeaks(card, ctx.PUBLIC_URL, upstreamOrigins(ctx, up.card)) });
|
|
1115
1115
|
}
|
|
1116
1116
|
if (seg[0] === "facade" && seg[1] === "verify" && !seg[2] && m === "POST") {
|
|
1117
|
-
if (!proxyMode(ctx)) return json({ mode: "inbox", ok: false, reason: "not_proxy_mode", detail: "this Worker is an
|
|
1117
|
+
if (!proxyMode(ctx)) return json({ mode: "inbox", ok: false, reason: "not_proxy_mode", detail: "this Worker is an A2A endpoint (no UPSTREAM_URL): nothing to verify" }, 409);
|
|
1118
1118
|
return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
|
|
1119
1119
|
}
|
|
1120
1120
|
if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
|
|
@@ -1262,7 +1262,7 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
|
|
|
1262
1262
|
const pending: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(now).first();
|
|
1263
1263
|
if ((pending?.n ?? 0) >= P.LIMITS.maxPending || (await bump(ctx, "dev:global", 3600)) > P.LIMITS.perHour) {
|
|
1264
1264
|
log("pairing_rate_limited", { ip, scope: "global" });
|
|
1265
|
-
return oauthError("slow_down", "this
|
|
1265
|
+
return oauthError("slow_down", "this A2A endpoint has too many pairing requests waiting; try again later", 429, retry);
|
|
1266
1266
|
}
|
|
1267
1267
|
const deviceCode = P.newDeviceCode();
|
|
1268
1268
|
let userCode = "";
|
|
@@ -1439,7 +1439,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1439
1439
|
const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
|
|
1440
1440
|
if (fresh) csrf = P.randomB64();
|
|
1441
1441
|
const pw = await approvalPassword(ctx);
|
|
1442
|
-
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A
|
|
1442
|
+
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1443
1443
|
const send = (status = 200) => {
|
|
1444
1444
|
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1445
1445
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
@@ -1489,6 +1489,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1489
1489
|
if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
|
|
1490
1490
|
const d0 = await decide(ctx, code, false, "page");
|
|
1491
1491
|
if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
|
|
1492
|
+
model.settled = "denied";
|
|
1492
1493
|
model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
|
|
1493
1494
|
return send();
|
|
1494
1495
|
}
|
|
@@ -1536,6 +1537,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1536
1537
|
const d = await decide(ctx, code, true, "human");
|
|
1537
1538
|
if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
|
|
1538
1539
|
const who = r.client_name || r.client_id || "the agent";
|
|
1540
|
+
model.settled = "approved";
|
|
1539
1541
|
model.notice = { kind: "ok", text: `Approved. ${who} can now collect its token (label "${d.label}"${d.replaced ? ", replacing its old token" : ""}). Revoke it any time: ${cliCommand(ctx)} token revoke ${d.label}` };
|
|
1540
1542
|
return send();
|
|
1541
1543
|
}
|
|
@@ -1544,7 +1546,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1544
1546
|
async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1545
1547
|
const nonce = P.randomB64();
|
|
1546
1548
|
const pw = await approvalPassword(ctx);
|
|
1547
|
-
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A
|
|
1549
|
+
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1548
1550
|
const send = (status = 200) => {
|
|
1549
1551
|
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1550
1552
|
h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
@@ -1587,6 +1589,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
|
|
|
1587
1589
|
const d = await decide(ctx, done.userCode || "", true, "oidc");
|
|
1588
1590
|
if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
|
|
1589
1591
|
const who = d.request.client_name || d.request.client_id || "the agent";
|
|
1592
|
+
model.settled = "approved";
|
|
1590
1593
|
model.notice = { kind: "ok", text: `Approved. ${who} can now collect its token (label "${d.label}"${d.replaced ? ", replacing its old token" : ""}). Revoke it any time: ${cliCommand(ctx)} token revoke ${d.label}` };
|
|
1591
1594
|
log("oidc_approved", { kind: "device" });
|
|
1592
1595
|
return send();
|
|
@@ -1617,7 +1620,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
|
|
|
1617
1620
|
const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
|
|
1618
1621
|
if (fresh) csrf = P.randomB64();
|
|
1619
1622
|
const pw = await approvalPassword(ctx);
|
|
1620
|
-
const model: P.SetupModel = { agentName: ctx.AGENT_NAME || "this A2A
|
|
1623
|
+
const model: P.SetupModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", cli: cliCommand(ctx), csrf, passwordSetAt: pw?.setAt };
|
|
1621
1624
|
const send = (status = 200) => {
|
|
1622
1625
|
const h: Record<string, string> = P.pageHeaders(nonce);
|
|
1623
1626
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
@@ -1772,7 +1775,7 @@ async function landing(req: Request, ctx: TenantContext, url: URL): Promise<Resp
|
|
|
1772
1775
|
function mcpResourceMetadata(ctx: TenantContext): Json {
|
|
1773
1776
|
const u = oauthUrls(ctx);
|
|
1774
1777
|
return { resource: u.mcp, authorization_servers: [u.issuer], bearer_methods_supported: ["header"], scopes_supported: [M.SCOPE],
|
|
1775
|
-
resource_name: `${ctx.AGENT_NAME || "A2A
|
|
1778
|
+
resource_name: `${ctx.AGENT_NAME || "A2A endpoint"} (MCP)`, resource_documentation: "https://github.com/telegraphic-dev/a2a-exposed#mcp-connector" };
|
|
1776
1779
|
}
|
|
1777
1780
|
|
|
1778
1781
|
/** Why an outbound peer URL is refused (empty: fine). https only, no private / internal hosts. */
|
|
@@ -1933,7 +1936,7 @@ async function resolveClient(ctx: TenantContext, id: string, ip?: string, head =
|
|
|
1933
1936
|
const c: Json = await ctx.DB.prepare("SELECT * FROM oauth_clients WHERE client_id = ?").bind(id).first();
|
|
1934
1937
|
return c ? { client: c } : { error: "Unknown client. Remove the connector and add it again (the client registers itself)." };
|
|
1935
1938
|
}
|
|
1936
|
-
const bad = M.cimdUrlProblem(id) || peerUrlProblem(id) || (new URL(id).host === new URL(ctx.PUBLIC_URL).host ? "must not be on this
|
|
1939
|
+
const bad = M.cimdUrlProblem(id) || peerUrlProblem(id) || (new URL(id).host === new URL(ctx.PUBLIC_URL).host ? "must not be on this A2A endpoint's own host" : "");
|
|
1937
1940
|
if (bad) return { error: `The client's metadata URL is not acceptable: ${bad}.` };
|
|
1938
1941
|
const now = Date.now();
|
|
1939
1942
|
const hit: Json = await ctx.DB.prepare("SELECT * FROM oauth_client_metadata WHERE client_id = ? AND expires_ms > ?").bind(id, now).first();
|
|
@@ -2032,7 +2035,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL, head =
|
|
|
2032
2035
|
if (c.fatal) return page(400, `<p class="n error">${P.esc(c.fatal)}</p>`);
|
|
2033
2036
|
if (c.error) return redirectWith(ctx, c.redirect!, { error: c.error, error_description: c.description!, state: c.params.state });
|
|
2034
2037
|
const origin = new URL(c.redirect!).origin;
|
|
2035
|
-
const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A
|
|
2038
|
+
const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A endpoint", cli: cliCommand(ctx),
|
|
2036
2039
|
csrf, clientName: c.client!.client_name || "", clientIdHost: c.client!.cimd ? new URL(c.client!.client_id).host : undefined,
|
|
2037
2040
|
redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), [origin, ...oidcFormOrigins(ctx)]);
|
|
2038
2041
|
if (req.method === "GET") return show(200);
|
|
@@ -2080,7 +2083,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL, head =
|
|
|
2080
2083
|
async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: string, params: Record<string, string>, ip: string, via = ""): Promise<{ ok: true; response: Response } | { ok: false; error: string }> {
|
|
2081
2084
|
const active: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM mcp_grants WHERE revoked_at IS NULL").first();
|
|
2082
2085
|
if ((active?.n ?? 0) >= M.LIMITS.maxGrants)
|
|
2083
|
-
return { ok: false, error:
|
|
2086
|
+
return { ok: false, error: M.mcpLimitMessage(M.LIMITS.maxGrants, cliCommand(ctx)) };
|
|
2084
2087
|
const code = A.randomToken("a2amcpc_"), now = Date.now();
|
|
2085
2088
|
await ctx.DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
2086
2089
|
.bind(await A.sha256(code), params.client_id, redirect, params.code_challenge, params.scope, params.resource || oauthUrls(ctx).mcp, now, now + M.CODE_TTL_S * 1000).run();
|
|
@@ -2289,7 +2292,7 @@ const argStr = (a: Json, k: string, required = true): string => {
|
|
|
2289
2292
|
/** A synced outbound peer with its decrypted token (null token: the peer takes none, or it can't be decrypted). */
|
|
2290
2293
|
async function outboundPeer(ctx: TenantContext, alias: string): Promise<{ alias: string; url: string; token: string | null }> {
|
|
2291
2294
|
const r: Json = await ctx.DB.prepare("SELECT * FROM outbound_peers WHERE alias = ?").bind(alias).first();
|
|
2292
|
-
if (!r) throw new ToolError(`no peer "${alias}" synced to this
|
|
2295
|
+
if (!r) throw new ToolError(`no peer "${alias}" synced to this A2A endpoint. On the machine with the CLI: ${cliCommand(ctx)} peers sync ${alias}`);
|
|
2293
2296
|
let token: string | null = null;
|
|
2294
2297
|
if (r.token_enc) {
|
|
2295
2298
|
const peerKey = await ctx.peerKey();
|
|
@@ -2399,12 +2402,12 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2399
2402
|
case "list_peers": {
|
|
2400
2403
|
const outRows = must(await ownerCall(ctx, ectx, "GET", "/owner/outbound-peers"));
|
|
2401
2404
|
const inRows = must(await ownerCall(ctx, ectx, "GET", "/owner/peers")).filter((r: Json) => !r.revoked_at);
|
|
2402
|
-
return M.toolText(`Outbound peers (send works with these) and inbound labels (agents holding a token for this
|
|
2405
|
+
return M.toolText(`Outbound peers (send works with these) and inbound labels (agents holding a token for this A2A endpoint). Names are claimed by the peers.`,
|
|
2403
2406
|
{ outbound: outRows.map((r: Json) => ({ alias: r.alias, url: r.url, hasToken: r.has_token })),
|
|
2404
2407
|
inbound: inRows.map((r: Json) => ({ label: r.label, source: r.source, clientName: r.client_name, since: r.created_at })) });
|
|
2405
2408
|
}
|
|
2406
2409
|
case "pairing_requests": {
|
|
2407
|
-
if (!pairingOn(ctx)) return "Device-flow pairing is off on this
|
|
2410
|
+
if (!pairingOn(ctx)) return "Device-flow pairing is off on this A2A endpoint (PAIRING_APPROVAL=off): there are no pairing requests.";
|
|
2408
2411
|
const d = must(await ownerCall(ctx, ectx, "GET", "/owner/pairing"));
|
|
2409
2412
|
return M.toolText(`${d.pending.length} pending pairing request(s). Show your human who asks, the code and the link. NEVER approve or deny yourself: your human decides on the link with the approval password. Names and card URLs are claimed by the requester (untrusted).`,
|
|
2410
2413
|
d.pending.map((r: Json) => ({ code: r.userCode, clientName: r.clientName, clientId: r.clientId, agentCardUrl: r.agentCardUrl, country: r.country,
|
|
@@ -2440,8 +2443,8 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2440
2443
|
const rpcResult = (id: Json, result: Json) => json({ jsonrpc: "2.0", id, result });
|
|
2441
2444
|
const rpcError = (id: Json, code: number, message: string, status = 200, data?: Json) =>
|
|
2442
2445
|
json({ jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } }, status);
|
|
2443
|
-
const serverInfo = (ctx: TenantContext) => ({ name: "a2a-exposed", title: `${ctx.AGENT_NAME || "A2A
|
|
2444
|
-
const mcpInstructions = `This is your A2A
|
|
2446
|
+
const serverInfo = (ctx: TenantContext) => ({ name: "a2a-exposed", title: `${ctx.AGENT_NAME || "A2A endpoint"}`, version: "1" });
|
|
2447
|
+
const mcpInstructions = `This is your A2A endpoint: other agents send you tasks here. Check inbox, read each task, answer with reply. ${M.RULES}`;
|
|
2445
2448
|
const unsupportedVersion = (id: Json, requested: string) =>
|
|
2446
2449
|
rpcError(id, M.ERR.UNSUPPORTED_VERSION, "Unsupported protocol version", 400, { supported: M.PROTOCOL_VERSIONS, requested });
|
|
2447
2450
|
|
|
@@ -2582,7 +2585,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
|
|
|
2582
2585
|
if (req.method === "GET" && path === "/.well-known/agent.json")
|
|
2583
2586
|
return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
|
|
2584
2587
|
if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
|
|
2585
|
-
if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this
|
|
2588
|
+
if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this A2A endpoint (MCP=off)" }, 404);
|
|
2586
2589
|
if (mcpOn(ctx)) {
|
|
2587
2590
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource/mcp") return json(mcpResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2588
2591
|
if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
@@ -2594,7 +2597,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
|
|
|
2594
2597
|
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
|
|
2595
2598
|
}
|
|
2596
2599
|
if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-protected-resource" || path.startsWith("/oauth/") || path === "/device" || path === "/device/setup" || path === "/device/oidc/callback") {
|
|
2597
|
-
if (!pairingOn(ctx)) return json({ error: "not_found", error_description: "device-flow pairing is disabled on this
|
|
2600
|
+
if (!pairingOn(ctx)) return json({ error: "not_found", error_description: "device-flow pairing is disabled on this A2A endpoint (PAIRING_APPROVAL=off); ask its operator for a token" }, 404);
|
|
2598
2601
|
if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2599
2602
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2600
2603
|
if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
|