a2a-exposed 0.8.6 → 0.8.8
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 +5 -5
- 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/cloudflare.config.ts +2 -0
- package/worker/deploy.env.example +4 -1
- package/worker/package.json +1 -1
- package/worker/src/feedback.ts +1 -1
- package/worker/src/index.ts +33 -21
- package/worker/src/mcp.ts +18 -13
- package/worker/src/pairing.ts +47 -9
- package/worker/src/tenancy.ts +16 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +12 -6
- package/worker/test/mcp.test.ts +32 -2
- package/worker/test/pairing.test.ts +94 -7
- package/worker/test/tenancy.test.ts +21 -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.8",
|
|
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"
|
|
@@ -62,6 +62,8 @@ const plain: Record<string, string> = {
|
|
|
62
62
|
APPROVAL_OIDC_CLIENT_ID: v("APPROVAL_OIDC_CLIENT_ID"),
|
|
63
63
|
APPROVAL_OIDC_ALLOWED_SUBJECTS: v("APPROVAL_OIDC_ALLOWED_SUBJECTS"),
|
|
64
64
|
APPROVAL_METHODS: v("APPROVAL_METHODS"),
|
|
65
|
+
// Optional https URL of this agent's dashboard page. A pairing wake and the MCP inbox line include it as manageUrl.
|
|
66
|
+
MANAGE_URL: v("MANAGE_URL"),
|
|
65
67
|
};
|
|
66
68
|
const hosted = v("TENANCY") === "host";
|
|
67
69
|
// Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
|
|
@@ -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
|
|
@@ -44,6 +44,9 @@ WAKE_PRESET=generic
|
|
|
44
44
|
# APPROVAL_OIDC_ALLOWED_SUBJECTS=
|
|
45
45
|
# APPROVAL_METHODS=password
|
|
46
46
|
# Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET.
|
|
47
|
+
# Optional https URL of this agent's dashboard page. When set, a pairing wake and the MCP inbox line
|
|
48
|
+
# include it as manageUrl.
|
|
49
|
+
# MANAGE_URL=https://example.com/app/agents/name#pairing
|
|
47
50
|
# Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
|
|
48
51
|
# A2A_BACKUP_BUCKET=1
|
|
49
52
|
# A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
|
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;
|