a2a-exposed 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tunnel.mjs ADDED
@@ -0,0 +1,450 @@
1
+ // `tunnel create|status|rm`: wake a local-only webhook (OpenClaw gateway, Hermes, ...) through a named Cloudflare
2
+ // Tunnel whose hostname is locked down by Cloudflare Access to ONE service token held by the Worker.
3
+ //
4
+ // Layout: Worker --(CF-Access-Client-Id/Secret + preset auth)--> https://wake-xxx.<zone><path>
5
+ // --> Access (service-token-only policy) --> Tunnel --> cloudflared on the agent's machine --> local origin
6
+ // A Cloudflare zone on the account is required on purpose (no quick tunnels, no unprotected hostnames). Any zone
7
+ // on the account works: the inbox itself can stay on workers.dev or on another hostname.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import crypto from "node:crypto";
11
+ import { spawnSync } from "node:child_process";
12
+ import * as C from "./config.mjs";
13
+ import { CliError, die, fingerprint, httpJson } from "./a2a.mjs";
14
+ import { owner } from "./commands.mjs";
15
+ import * as D from "./deploy.mjs";
16
+
17
+ const step = (s) => console.error(`==> ${s}`);
18
+
19
+ /** Local webhook defaults per preset: [origin, path]. Hosted presets (grok-bot, claude-code) need no tunnel. */
20
+ export const ORIGIN_DEFAULTS = {
21
+ "openclaw-wake": ["http://127.0.0.1:18789", "/hooks/wake"],
22
+ "openclaw-agent": ["http://127.0.0.1:18789", "/hooks/agent"],
23
+ hermes: ["http://127.0.0.1:8644", ""], // path is /webhooks/<subscription name>: pass --tunnel-path
24
+ generic: ["", ""],
25
+ };
26
+ export const HOSTED_PRESETS = ["grok-bot", "claude-code"];
27
+ export const ACCESS_SESSION = "15m";
28
+ export const SERVICE_TOKEN_DURATION = "8760h"; // 1 year (Cloudflare's default); rotate with tunnel rm + create
29
+
30
+ // Config keys written by `tunnel create` (ids are recorded as soon as each object exists, so `tunnel rm`
31
+ // can clean up after a partial failure too).
32
+ export const TUNNEL_KEYS = [
33
+ "A2A_TUNNEL_HOSTNAME", "A2A_TUNNEL_ORIGIN", "A2A_TUNNEL_PATH", "A2A_TUNNEL_ZONE_ID", "A2A_TUNNEL_ID", "A2A_TUNNEL_DNS_ID",
34
+ "A2A_TUNNEL_ACCESS_APP_ID", "A2A_TUNNEL_ACCESS_TOKEN_ID", "A2A_TUNNEL_ACCESS_CLIENT_ID", "A2A_TUNNEL_ACCESS_CLIENT_SECRET",
35
+ "A2A_TUNNEL_TOKEN_FILE",
36
+ ];
37
+
38
+ // ------------------------------------------------------------------ pure helpers (unit-tested)
39
+ const WORDS = ["amber", "brisk", "cedar", "delta", "ember", "fable", "gale", "harbor", "iris", "jade", "kelp", "lumen", "maple",
40
+ "nova", "onyx", "pebble", "quill", "raven", "sable", "tidal", "umber", "velvet", "willow", "zephyr", "otter", "lark", "fjord", "heron"];
41
+ export function randomLabel(rand = crypto.randomInt) {
42
+ const w = () => WORDS[rand(WORDS.length)];
43
+ return `wake-${w()}-${w()}-${rand(0x10000).toString(16).padStart(4, "0")}`;
44
+ }
45
+
46
+ export const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
47
+
48
+ /** The account zone a hostname belongs to (longest matching suffix), or null. */
49
+ export function zoneFor(hostname, zones) {
50
+ const h = hostname.toLowerCase();
51
+ return (zones || []).filter((z) => h === z.name || h.endsWith("." + z.name)).sort((a, b) => b.name.length - a.name.length)[0] || null;
52
+ }
53
+
54
+ const usableZones = (zones) => (zones || []).filter((z) => z && z.name && (!z.status || z.status === "active"));
55
+ const zoneList = (zones) => zones.map((z) => ` ${z.name}`).join("\n");
56
+
57
+ /** Error text when the account has no usable zone: the tunnel is impossible, polling is the option. */
58
+ export function noZoneHelp(zones) {
59
+ const inactive = (zones || []).filter((z) => z && z.name && z.status && z.status !== "active");
60
+ return [
61
+ inactive.length
62
+ ? `no active domain (zone) on this Cloudflare account (not active yet: ${inactive.map((z) => `${z.name} [${z.status}]`).join(", ")}).`
63
+ : "this Cloudflare account has no domain (zone).",
64
+ "The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the inbox is on workers.dev or a custom hostname).",
65
+ "Without one, use scheduled polling: the inbox works as it is (setup skill: \"Agents without inbound webhooks: polling\").",
66
+ "To use the tunnel later, add a domain to the account and run `a2a-exposed tunnel create` (no redeploy needed).",
67
+ ].join("\n");
68
+ }
69
+
70
+ /** The wake hostname and its zone. Order: --tunnel-hostname, --tunnel-zone, the zone of the inbox's custom hostname,
71
+ * then the account's only zone. Several zones and no choice: stop and list them. No zone: point to polling. */
72
+ export function pickTunnelHostname({ tunnelHostname, tunnelZone, inboxHost, zones, label = randomLabel }) {
73
+ const usable = usableZones(zones);
74
+ if (tunnelHostname) {
75
+ const hostname = tunnelHostname.toLowerCase();
76
+ const zone = zoneFor(hostname, usable);
77
+ const inactive = zone ? null : zoneFor(hostname, zones);
78
+ if (inactive) die(`${hostname} is on the zone ${inactive.name}, which is not active yet (${inactive.status}); ` +
79
+ (usable.length ? `pick a hostname on an active zone:\n${zoneList(usable)}` : "wait until it is active, or use polling meanwhile"));
80
+ if (!zone) die(usable.length ? `${hostname} is not on a zone in this Cloudflare account; the wake hostname must be on one of:\n${zoneList(usable)}` : noZoneHelp(zones));
81
+ return { hostname, zone, note: "" };
82
+ }
83
+ if (tunnelZone) {
84
+ const want = tunnelZone.toLowerCase().replace(/\.$/, "");
85
+ const zone = usable.find((z) => z.name.toLowerCase() === want);
86
+ if (!zone) die(usable.length ? `--tunnel-zone ${want}: not an active zone on this Cloudflare account; pick one of:\n${zoneList(usable)}` : noZoneHelp(zones));
87
+ return { hostname: `${label()}.${zone.name}`, zone, note: `wake hostname on the zone ${zone.name} (--tunnel-zone)` };
88
+ }
89
+ const own = inboxHost ? zoneFor(inboxHost, usable) : null;
90
+ if (own) return { hostname: `${label()}.${own.name}`, zone: own, note: `wake hostname on ${own.name}, the zone of the inbox hostname` };
91
+ if (usable.length === 1)
92
+ return { hostname: `${label()}.${usable[0].name}`, zone: usable[0], note: `the account has one zone, ${usable[0].name}: the wake hostname goes there (the inbox URL is unchanged)` };
93
+ if (!usable.length) die(noZoneHelp(zones));
94
+ return die([
95
+ `this Cloudflare account has ${usable.length} zones; choose one for the wake hostname (the inbox URL is unchanged):`,
96
+ zoneList(usable),
97
+ "Re-run with --tunnel-zone <zone> (or --tunnel-hostname wake-<name>.<zone>).",
98
+ ].join("\n"));
99
+ }
100
+
101
+ export function validateOrigin(origin) {
102
+ let u;
103
+ try { u = new URL(origin); } catch { die(`--tunnel-origin must be a URL like http://127.0.0.1:18789 (got ${JSON.stringify(origin)})`); }
104
+ if (!/^https?:$/.test(u.protocol) || (u.pathname !== "/" && u.pathname !== "") || u.search || u.username)
105
+ die("--tunnel-origin must be scheme://host:port only (put the path in --tunnel-path)");
106
+ return `${u.protocol}//${u.host}`;
107
+ }
108
+
109
+ export const wakeUrl = (hostname, p) => `https://${hostname}${p.startsWith("/") ? p : "/" + p}`;
110
+
111
+ export const tunnelCreateBody = (name) => ({ name, config_src: "cloudflare" });
112
+
113
+ const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
+
115
+ /** Remotely-managed ingress: only the wake path on the wake hostname reaches the origin (everything else: 404),
116
+ * and cloudflared also checks the Access JWT itself. */
117
+ export function tunnelConfigBody({ hostname, origin, wpath, teamName, audTag }) {
118
+ const access = teamName && audTag ? { access: { required: true, teamName, audTag: [audTag] } } : {};
119
+ const rule = { hostname, ...(wpath ? { path: `^${reEscape(wpath)}$` } : {}), service: origin, originRequest: { ...access } };
120
+ return { config: { ingress: [rule, { service: "http_status:404" }] } };
121
+ }
122
+
123
+ export const dnsCnameBody = (hostname, tunnelId, comment) =>
124
+ ({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: true, ttl: 1, comment });
125
+
126
+ export const serviceTokenBody = (name) => ({ name, duration: SERVICE_TOKEN_DURATION });
127
+
128
+ /** Self-hosted Access app with exactly one policy: Service Auth (non_identity) for one service token. */
129
+ export function accessAppBody({ name, hostname, serviceTokenId }) {
130
+ return {
131
+ type: "self_hosted", name, domain: hostname, destinations: [{ type: "public", uri: hostname }],
132
+ session_duration: ACCESS_SESSION, app_launcher_visible: false, auto_redirect_to_identity: false,
133
+ service_auth_401_redirect: true, // blocked service-auth requests get 401 instead of a login redirect
134
+ policies: [{ name: "a2a-exposed wake: service token only", decision: "non_identity", precedence: 1,
135
+ include: [{ service_token: { token_id: serviceTokenId } }] }],
136
+ };
137
+ }
138
+
139
+ /** Workers secrets-bulk body (JSON Merge Patch): value -> set, null -> delete. */
140
+ export function secretsPatch(map) {
141
+ const secrets = {};
142
+ for (const [k, v] of Object.entries(map)) secrets[k] = v == null ? null : { name: k, type: "secret_text", text: String(v) };
143
+ return { secrets };
144
+ }
145
+
146
+ /** How to run the connector on the agent's machine. The token itself is only printed with --show-token. */
147
+ export function connectorInstructions(tokenFile, token = "") {
148
+ const t = token || `"$(cat ${tokenFile})"`;
149
+ return [
150
+ "Run the connector on the agent's machine (outbound TCP/UDP 7844 to Cloudflare must be allowed):",
151
+ ` cloudflared tunnel run --token-file ${tokenFile} # cloudflared 2025.4+`,
152
+ ` cloudflared tunnel run --token ${t}`,
153
+ ` sudo cloudflared service install ${t} # as a system service (starts on boot)`,
154
+ token ? " (token shown because of --show-token: treat it like a password)"
155
+ : ` The tunnel token is in ${tokenFile} (chmod 600). Copy that file to the agent's machine if it is another machine.`,
156
+ ].join("\n");
157
+ }
158
+
159
+ /** Cloudflare edge error code in an error page (plain text or HTML), or "". Same rules as the Worker's. */
160
+ export function cloudflareErrorCode(body) {
161
+ 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 || "");
162
+ return m ? m[1] || m[2] || m[3] || m[4] : "";
163
+ }
164
+
165
+ // ------------------------------------------------------------------ cf calls (JSON in, JSON out, errors captured)
166
+ export function cfCall(dir, args, { body, file, allowNotFound = false } = {}) {
167
+ const full = [...args];
168
+ if (body !== undefined) full.push("--body", JSON.stringify(body));
169
+ if (file) full.push("--file", file);
170
+ const r = spawnSync(D.cfBin(dir), D.cfArgs(full), { cwd: dir, env: D.cfEnv(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
171
+ if (r.error) die(`cf: ${r.error.message}`);
172
+ if (r.status !== 0) {
173
+ const err = (r.stderr || r.stdout || "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
174
+ if (allowNotFound && /404|not found|does not exist|could not be found/i.test(err)) return null;
175
+ const msg = (err.match(/\[\d+\][^\n]*/g) || [err.trim().split("\n").slice(-3).join(" ")]).join("; ");
176
+ throw new CliError(`cf ${args.slice(0, 3).join(" ")} failed: ${msg.slice(0, 400)}`);
177
+ }
178
+ const out = (r.stdout || "").trim();
179
+ if (!out) return {};
180
+ try { return JSON.parse(out); } catch { return { raw: out }; }
181
+ }
182
+
183
+ const ACCESS_HELP = (acct) => [
184
+ "Cloudflare Access (Zero Trust) is not enabled on this account yet. It is required: the tunnel hostname is locked to a service token.",
185
+ " - dashboard: https://one.dash.cloudflare.com/ -> pick a team name and the Free plan (up to 50 users), then re-run",
186
+ " - or let the CLI create the organization: re-run with --zero-trust-org <team-name> (gives <team-name>.cloudflareaccess.com)",
187
+ ` account: ${acct || "(see cf auth whoami)"}`,
188
+ "No tunnel? Use scheduled polling instead (setup skill: \"Agents without inbound webhooks: polling\").",
189
+ ].join("\n");
190
+
191
+ function accessOrg(dir, o) {
192
+ try {
193
+ return cfCall(dir, ["zero-trust", "organization", "get"]);
194
+ } catch (e) {
195
+ if (!/not_enabled|not enabled/i.test(e.message)) throw e;
196
+ const team = o["zero-trust-org"];
197
+ if (!team) die(ACCESS_HELP(C.get("CLOUDFLARE_ACCOUNT_ID")));
198
+ if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(team)) die("--zero-trust-org must be a DNS label (lowercase letters, digits, hyphens)");
199
+ step(`creating the Zero Trust organization ${team}.cloudflareaccess.com (account-level, stays afterwards)`);
200
+ return cfCall(dir, ["zero-trust", "organization", "create"], { body: { name: team, auth_domain: `${team}.cloudflareaccess.com` } });
201
+ }
202
+ }
203
+
204
+ /** Zones of the deployment's account (the login may see other accounts' zones too), all pages. */
205
+ export function listZones(dir) {
206
+ const acct = C.get("CLOUDFLARE_ACCOUNT_ID"), per = 50, all = [];
207
+ for (let page = 1; page <= 40; page++) {
208
+ const r = cfCall(dir, ["zones", "list", "--per-page", String(per), "--page", String(page), ...(acct ? ["--account-id", acct] : [])]);
209
+ const zones = Array.isArray(r) ? r : Array.isArray(r?.result) ? r.result : [];
210
+ all.push(...zones);
211
+ if (zones.length < per) break;
212
+ }
213
+ return all.filter((z) => z && z.name && (!acct || !z.account?.id || z.account.id === acct));
214
+ }
215
+
216
+ // ------------------------------------------------------------------ commands
217
+ function needDeployment(o) {
218
+ if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die("no saved deployment; run `a2a-exposed init` first");
219
+ const dir = D.workerDir(o);
220
+ if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`a2a-exposed deploy\` once`);
221
+ return dir;
222
+ }
223
+
224
+ /** Checks that need no Cloudflare calls (also used by `init --tunnel` before anything is deployed). */
225
+ export function preflight(o) {
226
+ const preset = o.preset || C.get("WAKE_PRESET", "generic"); // o.preset: `init --tunnel` checks before saving flags
227
+ if (HOSTED_PRESETS.includes(preset)) die(`preset ${preset} wakes a hosted service; a tunnel is only for local-only webhooks (openclaw-*, hermes, generic)`);
228
+ const [dOrigin, dPath] = ORIGIN_DEFAULTS[preset] || ["", ""];
229
+ const origin = o["tunnel-origin"] || C.get("A2A_TUNNEL_ORIGIN") || dOrigin;
230
+ const wpath = o["tunnel-path"] || C.get("A2A_TUNNEL_PATH") || dPath;
231
+ if (!origin) die(`--tunnel-origin is required for preset ${preset} (the local webhook, e.g. http://127.0.0.1:8080)`);
232
+ if (!wpath || !wpath.startsWith("/")) die(`--tunnel-path is required for preset ${preset} and must start with / (e.g. /webhooks/a2a for Hermes)`);
233
+ if (o["tunnel-hostname"] && !HOST_RE.test(o["tunnel-hostname"])) die("--tunnel-hostname must be a hostname like wake-abc.example.com");
234
+ if (o["tunnel-zone"] && !HOST_RE.test(o["tunnel-zone"].toLowerCase())) die("--tunnel-zone must be a zone name like example.com");
235
+ if (o["tunnel-zone"] && o["tunnel-hostname"]) die("pass --tunnel-zone or --tunnel-hostname, not both");
236
+ if (process.env.WAKE_WEBHOOK_URL) die("unset WAKE_WEBHOOK_URL: `tunnel create` sets the wake URL to the tunnel hostname");
237
+ return { preset, origin: validateOrigin(origin), wpath };
238
+ }
239
+
240
+ export async function create(o) {
241
+ D.checkNode();
242
+ const dir = needDeployment(o);
243
+ if (tunnelStarted()) return resume(dir, o);
244
+ const { preset, origin, wpath } = preflight(o);
245
+ const worker = D.workerName();
246
+
247
+ step("finding a zone for the wake hostname");
248
+ const { hostname, zone, note } = pickTunnelHostname({
249
+ tunnelHostname: o["tunnel-hostname"], tunnelZone: o["tunnel-zone"], inboxHost: C.get("A2A_HOSTNAME"), zones: listZones(dir),
250
+ });
251
+ if (note) step(note);
252
+ const existing = cfCall(dir, ["dns", "records", "list", "-z", zone.id, "--name", hostname]);
253
+ if (Array.isArray(existing) && existing.length) die(`${hostname} already has a DNS record; pick another --tunnel-hostname`);
254
+
255
+ step("checking Cloudflare Access (Zero Trust)");
256
+ const org = accessOrg(dir, o);
257
+ const teamName = String(org.auth_domain || "").replace(/\.cloudflareaccess\.com$/, "");
258
+
259
+ const label = `a2a-exposed wake ${worker}`;
260
+ const save = (u) => C.saveConfig(u);
261
+ save({ A2A_TUNNEL_HOSTNAME: hostname, A2A_TUNNEL_ORIGIN: origin, A2A_TUNNEL_PATH: wpath, A2A_TUNNEL_ZONE_ID: zone.id });
262
+ try {
263
+ step(`Access service token "${label}"`);
264
+ const st = cfCall(dir, ["zero-trust", "access", "service-tokens", "create"], { body: serviceTokenBody(label) });
265
+ if (!st.id || !st.client_id || !st.client_secret) die("unexpected response creating the service token");
266
+ save({ A2A_TUNNEL_ACCESS_TOKEN_ID: st.id, A2A_TUNNEL_ACCESS_CLIENT_ID: st.client_id, A2A_TUNNEL_ACCESS_CLIENT_SECRET: st.client_secret });
267
+
268
+ step(`Access application for ${hostname} (one policy: Service Auth, this token only; session ${ACCESS_SESSION})`);
269
+ const app = cfCall(dir, ["zero-trust", "access", "applications", "create"], { body: accessAppBody({ name: label, hostname, serviceTokenId: st.id }) });
270
+ if (!app.id) die("unexpected response creating the Access application");
271
+ save({ A2A_TUNNEL_ACCESS_APP_ID: app.id });
272
+
273
+ step(`tunnel "${label}" (remotely managed)`);
274
+ const tun = cfCall(dir, ["tunnels", "create"], { body: tunnelCreateBody(label) });
275
+ if (!tun.id) die("unexpected response creating the tunnel");
276
+ save({ A2A_TUNNEL_ID: tun.id });
277
+ cfCall(dir, ["tunnels", "config", "update", tun.id], { body: tunnelConfigBody({ hostname, origin, wpath, teamName, audTag: app.aud }) });
278
+
279
+ step(`DNS: ${hostname} CNAME ${tun.id}.cfargotunnel.com (proxied)`);
280
+ const rec = cfCall(dir, ["dns", "records", "create", "-z", zone.id], { body: dnsCnameBody(hostname, tun.id, label) });
281
+ if (!rec.id) die("unexpected response creating the DNS record");
282
+ save({ A2A_TUNNEL_DNS_ID: rec.id });
283
+
284
+ const { tokenFile, token } = fetchTokenFile(dir, tun.id);
285
+
286
+ const url = wakeUrl(hostname, wpath);
287
+ step("uploading Worker secrets: WAKE_WEBHOOK_URL, WAKE_ACCESS_CLIENT_ID, WAKE_ACCESS_CLIENT_SECRET" +
288
+ (process.env.WAKE_WEBHOOK_KEY ? ", WAKE_WEBHOOK_KEY" : "") + (process.env.WAKE_HMAC_SECRET ? ", WAKE_HMAC_SECRET" : ""));
289
+ const secrets = { WAKE_WEBHOOK_URL: url, WAKE_ACCESS_CLIENT_ID: st.client_id, WAKE_ACCESS_CLIENT_SECRET: st.client_secret };
290
+ for (const k of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[k]) secrets[k] = process.env[k];
291
+ await putSecrets(dir, worker, secrets);
292
+
293
+ console.log(url);
294
+ console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
295
+ console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
296
+ console.error(`\nThen (Worker secrets apply within ~15 s): a2a-exposed tunnel status and a2a-exposed wake test`);
297
+ if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
298
+ 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\`)`);
299
+ } catch (e) {
300
+ console.error(`error: ${e.message}\n==> rolling back what was created`);
301
+ await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`a2a-exposed tunnel rm\``));
302
+ throw e;
303
+ }
304
+ }
305
+
306
+ /** Download the tunnel's connector token into <config dir>/tunnel-token (chmod 600) and save its path. */
307
+ function fetchTokenFile(dir, tunnelId) {
308
+ const tok = cfCall(dir, ["tunnels", "token", "get", tunnelId]);
309
+ const token = typeof tok === "string" ? tok.trim() : tok.raw || tok.token || tok.result || "";
310
+ if (!token) die("could not read the tunnel token");
311
+ const tokenFile = path.join(C.CONFIG_DIR, "tunnel-token");
312
+ fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
313
+ fs.chmodSync(tokenFile, 0o600);
314
+ C.saveConfig({ A2A_TUNNEL_TOKEN_FILE: tokenFile });
315
+ return { tokenFile, token };
316
+ }
317
+
318
+ /** The saved connector token file exists and is not empty (`cloudflared --token-file` needs it). */
319
+ export function tokenFileOk() {
320
+ const f = C.get("A2A_TUNNEL_TOKEN_FILE");
321
+ try { return !!f && fs.statSync(f).isFile() && fs.readFileSync(f, "utf8").trim().length > 0; } catch { return false; }
322
+ }
323
+
324
+ /** Ids a `tunnel create` saves once each Cloudflare object exists. */
325
+ const tunnelStarted = () => ["A2A_TUNNEL_ID", "A2A_TUNNEL_ACCESS_APP_ID", "A2A_TUNNEL_ACCESS_TOKEN_ID", "A2A_TUNNEL_DNS_ID"].some((k) => C.get(k));
326
+ /** Every object's id and the token file path are saved (the token file itself: tokenFileOk; the Worker secrets are
327
+ * checked separately). */
328
+ export const tunnelComplete = () =>
329
+ ["A2A_TUNNEL_HOSTNAME", "A2A_TUNNEL_ID", "A2A_TUNNEL_DNS_ID", "A2A_TUNNEL_ACCESS_APP_ID", "A2A_TUNNEL_ACCESS_TOKEN_ID",
330
+ "A2A_TUNNEL_ACCESS_CLIENT_ID", "A2A_TUNNEL_ACCESS_CLIENT_SECRET", "A2A_TUNNEL_TOKEN_FILE"].every((k) => C.get(k));
331
+
332
+ /** `tunnel create` again on an existing tunnel: safe to re-run. A complete tunnel creates nothing; its Worker secrets
333
+ * are re-uploaded if the Worker lacks them (e.g. the first run was interrupted), together with WAKE_WEBHOOK_KEY /
334
+ * WAKE_HMAC_SECRET when exported (as on a first run); a missing connector token file is downloaded again. A partial
335
+ * tunnel must be removed first. */
336
+ async function resume(dir, o) {
337
+ if (!tunnelComplete())
338
+ die("a previous `tunnel create` did not finish (some ids are saved, see `a2a-exposed status`); run `a2a-exposed tunnel rm`, then `a2a-exposed tunnel create` again");
339
+ const host = C.get("A2A_TUNNEL_HOSTNAME");
340
+ if (o["tunnel-hostname"] && o["tunnel-hostname"].toLowerCase() !== host)
341
+ die(`a tunnel already exists on ${host}; to use another hostname run \`a2a-exposed tunnel rm\` first`);
342
+ if (!tokenFileOk()) {
343
+ step(`the connector token file ${C.get("A2A_TUNNEL_TOKEN_FILE")} is missing or empty: downloading the tunnel token again`);
344
+ fetchTokenFile(dir, C.get("A2A_TUNNEL_ID"));
345
+ }
346
+ const url = wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/");
347
+ let p = null;
348
+ try { p = await owner("GET", "/owner/wake/preview"); } catch (e) { console.error(`warning: Worker not reachable (${e.message}); cannot check its wake secrets`); }
349
+ const agentAuth = {};
350
+ for (const k of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[k]) agentAuth[k] = process.env[k];
351
+ const lacksTunnel = p && (!p.hasAccessServiceToken || p.fingerprints?.url !== fingerprint(url));
352
+ if (lacksTunnel || Object.keys(agentAuth).length) {
353
+ const secrets = { WAKE_WEBHOOK_URL: url, WAKE_ACCESS_CLIENT_ID: C.get("A2A_TUNNEL_ACCESS_CLIENT_ID"), WAKE_ACCESS_CLIENT_SECRET: C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"), ...agentAuth };
354
+ step(`${lacksTunnel ? "the Worker lacks the tunnel's wake secrets: " : ""}uploading Worker secrets: ${Object.keys(secrets).join(", ")}`);
355
+ await putSecrets(dir, D.workerName(), secrets);
356
+ }
357
+ const preset = C.get("WAKE_PRESET", "generic");
358
+ if (p && !p.hasKey && !p.hasHmacSecret && !Object.keys(agentAuth).length && preset !== "generic")
359
+ console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`a2a-exposed tunnel create\` again`);
360
+ console.log(url);
361
+ console.error(`tunnel already set up: wakes go to ${url} -> ${C.get("A2A_TUNNEL_ORIGIN")}${C.get("A2A_TUNNEL_PATH")} (nothing new created; to change it: tunnel rm, then tunnel create)`);
362
+ console.error(connectorInstructions(C.get("A2A_TUNNEL_TOKEN_FILE")));
363
+ console.error("Then: a2a-exposed tunnel status and a2a-exposed wake test");
364
+ }
365
+
366
+ function putSecrets(dir, worker, map) {
367
+ return D.withSecretsFile(secretsPatch(map), (file) => cfCall(dir, ["workers", "secrets", "bulk", "--worker", worker], { file }));
368
+ }
369
+
370
+ export async function status(o) {
371
+ const dir = needDeployment(o);
372
+ const host = C.get("A2A_TUNNEL_HOSTNAME");
373
+ if (!host) return console.log("no tunnel configured (a2a-exposed tunnel create)");
374
+ const info = { hostname: host, wakeUrl: wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/"), origin: C.get("A2A_TUNNEL_ORIGIN") };
375
+ const tid = C.get("A2A_TUNNEL_ID");
376
+ if (tid) {
377
+ const t = cfCall(dir, ["tunnels", "get", tid], { allowNotFound: true });
378
+ info.tunnel = t ? { id: tid, status: t.status, connections: (t.connections || []).length } : "missing";
379
+ }
380
+ const app = C.get("A2A_TUNNEL_ACCESS_APP_ID");
381
+ if (app) {
382
+ const a = cfCall(dir, ["zero-trust", "access", "applications", "get", app], { allowNotFound: true });
383
+ info.accessApp = a ? { id: app, domain: a.domain, policies: (a.policies || []).map((p) => `${p.decision}:${JSON.stringify(p.include)}`) } : "missing";
384
+ }
385
+ try {
386
+ const p = await owner("GET", "/owner/wake/preview");
387
+ info.worker = { hasAccessServiceToken: !!p.hasAccessServiceToken, wakeUrlMatches: p.fingerprints?.url === fingerprint(info.wakeUrl) };
388
+ } catch (e) { info.worker = `unreachable: ${e.message}`; }
389
+ // GET probes (never POST: that would wake the agent): Access must block the bare request; with the service
390
+ // token the request reaches the tunnel (1033 = no connector running; anything from the origin = connector up)
391
+ const probe = async (headers) => {
392
+ try {
393
+ const r = await fetch(info.wakeUrl, { method: "GET", headers, redirect: "manual", signal: AbortSignal.timeout(15000) });
394
+ const body = (await r.text()).slice(0, 2000);
395
+ const code = cloudflareErrorCode(body);
396
+ const access = (r.headers.get("www-authenticate") || /cloudflareaccess\.com/.test(r.headers.get("location") || "")) ? " (Cloudflare Access)" : "";
397
+ return `HTTP ${r.status}${code ? ` (Cloudflare error ${code})` : access}`;
398
+ } catch (e) { return `network error: ${e.message}`; }
399
+ };
400
+ info.probe = { withoutToken: await probe({}) };
401
+ const id = C.get("A2A_TUNNEL_ACCESS_CLIENT_ID"), sec = C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET");
402
+ if (id && sec) info.probe.withServiceToken = await probe({ "CF-Access-Client-Id": id, "CF-Access-Client-Secret": sec });
403
+ console.log(JSON.stringify(info, null, 2));
404
+ const w = info.probe.withServiceToken || "";
405
+ if (/1033/.test(w)) console.error("connector not running: start cloudflared on the agent's machine (tunnel create printed the command)");
406
+ if (!/^HTTP 40[13]/.test(info.probe.withoutToken)) console.error("WARNING: the bare request was not blocked by Access; check the Access application");
407
+ }
408
+
409
+ export async function remove(o, { quiet = false } = {}) {
410
+ const dir = needDeployment(o);
411
+ const worker = D.workerName();
412
+ const failures = [];
413
+ const attempt = async (what, key, fn) => {
414
+ if (key && !C.get(key)) return;
415
+ try { await fn(); if (key) C.saveConfig({ [key]: null }); step(`removed ${what}`); }
416
+ catch (e) { failures.push(`${what}: ${e.message}`); }
417
+ };
418
+ const host = C.get("A2A_TUNNEL_HOSTNAME");
419
+ // 1. Worker secrets first, so no wake is sent to a half-removed hostname. WAKE_WEBHOOK_URL only if it is ours.
420
+ if (host) await attempt("Worker wake secrets (Access token, tunnel URL)", null, async () => {
421
+ const patch = { WAKE_ACCESS_CLIENT_ID: null, WAKE_ACCESS_CLIENT_SECRET: null };
422
+ let ours = false;
423
+ try { const p = await owner("GET", "/owner/wake/preview"); ours = p.fingerprints?.url === fingerprint(wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/")); }
424
+ catch { /* Worker unreachable: leave the URL */ }
425
+ if (ours) patch.WAKE_WEBHOOK_URL = null;
426
+ await putSecrets(dir, worker, patch);
427
+ });
428
+ // 2. DNS before Access, so the hostname never resolves without its Access app
429
+ await attempt("DNS record", "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 }));
430
+ await attempt("tunnel", "A2A_TUNNEL_ID", () => {
431
+ cfCall(dir, ["tunnels", "connections", "cleanup", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
432
+ cfCall(dir, ["tunnels", "delete", C.get("A2A_TUNNEL_ID"), "--force"], { allowNotFound: true });
433
+ });
434
+ await attempt("Access application (and its policy)", "A2A_TUNNEL_ACCESS_APP_ID", () => cfCall(dir, ["zero-trust", "access", "applications", "delete", C.get("A2A_TUNNEL_ACCESS_APP_ID"), "--force"], { allowNotFound: true }));
435
+ await attempt("Access service token", "A2A_TUNNEL_ACCESS_TOKEN_ID", () => {
436
+ cfCall(dir, ["zero-trust", "access", "service-tokens", "delete", C.get("A2A_TUNNEL_ACCESS_TOKEN_ID"), "--force"], { allowNotFound: true });
437
+ C.saveConfig({ A2A_TUNNEL_ACCESS_CLIENT_ID: null, A2A_TUNNEL_ACCESS_CLIENT_SECRET: null });
438
+ });
439
+ await attempt("tunnel token file", "A2A_TUNNEL_TOKEN_FILE", () => fs.rmSync(C.get("A2A_TUNNEL_TOKEN_FILE"), { force: true }));
440
+ if (failures.length) die(`some parts could not be removed (ids kept in ${C.CONFIG_FILE}; re-run \`tunnel rm\`):\n ${failures.join("\n ")}`);
441
+ C.saveConfig(Object.fromEntries(TUNNEL_KEYS.map((k) => [k, null])));
442
+ 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).");
443
+ }
444
+
445
+ export async function tunnel(sub, o) {
446
+ if (sub === "create") return create(o);
447
+ if (sub === "status") return status(o);
448
+ if (sub === "rm") return remove(o);
449
+ die("usage: tunnel create|status|rm");
450
+ }
package/lib/update.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Outdated-version notice. At most once per 24 h a detached child process asks the npm registry for the latest
2
+ // version and caches it; every run only reads that cache (no network call in the foreground). Off with
3
+ // A2A_NO_UPDATE_CHECK=1, DO_NOT_TRACK=1, NO_UPDATE_NOTIFIER, CI, and when running from a source checkout.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const CHECK_INTERVAL_MS = 24 * 3600 * 1000;
11
+ export const REGISTRY_URL = "https://registry.npmjs.org/a2a-exposed/latest";
12
+ const truthy = (v) => v !== undefined && v !== "" && v !== "0" && v !== "false";
13
+
14
+ export const cacheFile = (env = process.env) =>
15
+ path.join(env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "a2a-exposed", "update-check.json");
16
+
17
+ /** Is the notice switched off (by the user, CI, or because this is not an npm install)? */
18
+ export function disabled(env = process.env, here = fileURLToPath(import.meta.url)) {
19
+ if (truthy(env.A2A_NO_UPDATE_CHECK) || truthy(env.DO_NOT_TRACK) || truthy(env.NO_UPDATE_NOTIFIER) || truthy(env.CI)) return true;
20
+ return !here.split(path.sep).includes("node_modules"); // a repo checkout: the user builds what they have
21
+ }
22
+
23
+ /** a > b for x.y.z versions (pre-release tags sort before the release; good enough for a notice). */
24
+ export function newer(a, b) {
25
+ const parse = (v) => String(v || "").replace(/^v/, "").split("-")[0].split(".").map((n) => Number(n) || 0);
26
+ const [x, y] = [parse(a), parse(b)];
27
+ for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0);
28
+ return String(b).includes("-") && !String(a).includes("-") && parse(a).join(".") === parse(b).join(".");
29
+ }
30
+
31
+ /** The notice for `current`, given the cache contents (or "" when up to date / unknown). */
32
+ export function notice(current, cache) {
33
+ if (!cache || !cache.latest || !newer(cache.latest, current)) return "";
34
+ return `a2a-exposed ${cache.latest} is available (you have ${current}): npm i -g a2a-exposed@latest, then a2a-exposed deploy (updates the Worker; applies new D1 migrations). Silence: A2A_NO_UPDATE_CHECK=1`;
35
+ }
36
+
37
+ export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
38
+
39
+ function readCache(file) {
40
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
41
+ }
42
+
43
+ /** Print the notice (stderr) from the cache and refresh the cache in the background when it is older than 24 h. */
44
+ export function check(current, { env = process.env, here = fileURLToPath(import.meta.url), spawnCheck = true, write = (s) => process.stderr.write(s + "\n") } = {}) {
45
+ try {
46
+ if (disabled(env, here)) return;
47
+ const file = cacheFile(env);
48
+ const cache = readCache(file);
49
+ const msg = notice(current, cache);
50
+ if (msg) write(`note: ${msg}`);
51
+ if (!due(cache)) return;
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ // claim the slot first, so parallel runs don't all spawn a check
54
+ fs.writeFileSync(file, JSON.stringify({ ...(cache || {}), checkedAt: Date.now() }));
55
+ if (!spawnCheck) return;
56
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--refresh", file], { detached: true, stdio: "ignore" });
57
+ child.unref();
58
+ } catch { /* a notice must never break a command */ }
59
+ }
60
+
61
+ /** Child process: fetch the latest version and store it. */
62
+ async function refresh(file) {
63
+ try {
64
+ const r = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5000), headers: { accept: "application/json" } });
65
+ if (!r.ok) return;
66
+ const { version } = await r.json();
67
+ if (typeof version === "string" && /^\d+\.\d+\.\d+/.test(version)) fs.writeFileSync(file, JSON.stringify({ latest: version, checkedAt: Date.now() }));
68
+ } catch { /* offline: try again in 24 h */ }
69
+ }
70
+
71
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === "--refresh") await refresh(process.argv[3]);
@@ -0,0 +1,97 @@
1
+ // workers.dev support: URL helpers, cf output parsing, and registering the account's workers.dev subdomain
2
+ // through cf's own interactive prompt (cf has no non-interactive command for it).
3
+ import { spawn } from "node:child_process";
4
+ import { CliError, die } from "./a2a.mjs";
5
+
6
+ /** A valid DNS label: worker names and workers.dev subdomains must be one. */
7
+ export const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
8
+
9
+ export const DOCS = "https://developers.cloudflare.com/workers/configuration/routing/workers-dev/";
10
+ export const onboardingUrl = (accountId) => `https://dash.cloudflare.com/${accountId || "<account-id>"}/workers/onboarding`;
11
+
12
+ /** Base URL of a Worker: custom domain if set, else <worker>.<subdomain>.workers.dev, else "" (unknown yet). */
13
+ export function baseUrlFor({ hostname, worker, subdomain }) {
14
+ if (hostname) return `https://${hostname}`;
15
+ return subdomain ? `https://${worker}.${subdomain}.workers.dev` : "";
16
+ }
17
+
18
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[\]PX^_][^\x07\x1b]*(\x07|\x1b\\)/g, "");
19
+
20
+ /** The account's workers.dev subdomain, read from `cf deploy` output ("<worker>.<subdomain>.workers.dev"), or "". */
21
+ export function parseWorkersDevSubdomain(output, worker) {
22
+ const esc = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ const m = stripAnsi(output).match(new RegExp(`(?:^|[^a-z0-9-])${esc}\\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\\.workers\\.dev\\b`, "i"));
24
+ return m ? m[1].toLowerCase() : "";
25
+ }
26
+
27
+ /** cf refused to deploy because the account has no workers.dev subdomain yet. */
28
+ export const needsSubdomain = (output) => /register a workers\.dev subdomain/i.test(stripAnsi(output));
29
+
30
+ export function noSubdomainHelp(accountId) {
31
+ return [
32
+ "this Cloudflare account has no workers.dev subdomain yet. Create one (once per account), then re-run:",
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 & Pages asks for it)`,
35
+ ` - or the API: PUT /accounts/<account-id>/workers/subdomain {"subdomain":"<name>"}`,
36
+ ` docs: ${DOCS}`,
37
+ ].join("\n");
38
+ }
39
+
40
+ /** Prompt text with ANSI codes, whitespace and box-drawing glyphs removed, lowercased. Prompt libraries redraw
41
+ * with cursor moves and wrap to the terminal width (one character per line on a 0-column pty), so match on this. */
42
+ export const compactPrompt = (s) => stripAnsi(s).replace(/[\s\u2500-\u257f\u25a0-\u25ff]/g, "").toLowerCase();
43
+
44
+ /** cf's workers.dev registration prompts, in order, with the keys that answer them (matched on compactPrompt). */
45
+ export const REGISTRATION_STEPS = (name) => [
46
+ [/registeraworkers\.devsubdomainnow\?/, "y"],
47
+ [/whatwouldyoulikeyourworkers\.devsubdomaintobe\?/, `${name}\r`],
48
+ [/oktoproceed\?/, "y"],
49
+ ];
50
+ export const UNAVAILABLE_RE = /subdomainisunavailable/;
51
+
52
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
53
+
54
+ /**
55
+ * Run `cf deploy` in a pseudo-terminal (via `script`) and answer cf's own workers.dev registration prompts:
56
+ * "register a workers.dev subdomain now?" -> y, "what would you like your subdomain to be?" -> name, "Ok to proceed?" -> y.
57
+ * Resolves { status, out }. Any other prompt is left unanswered and the run is stopped by the idle timeout.
58
+ * Works with the logged-in cf session (OAuth or CLOUDFLARE_API_TOKEN); needs a Unix `script` (Linux, macOS).
59
+ */
60
+ export function deployWithRegistration(cfBin, args, name, { cwd, env, idleMs = 180000 } = {}) {
61
+ if (process.platform === "win32") die(`registering a workers.dev subdomain from the CLI needs a Unix 'script' command; use the dashboard instead.\n${noSubdomainHelp(env?.CLOUDFLARE_ACCOUNT_ID)}`);
62
+ // give the pty a real size: `script` under a pipe starts with 0 columns, and cf's prompts then wrap per character
63
+ const cmd = `stty cols 160 rows 50 2>/dev/null; exec ${[cfBin, ...args].map(shq).join(" ")}`;
64
+ const sargs = process.platform === "darwin" ? ["-q", "/dev/null", "/bin/sh", "-c", cmd] : ["-qec", cmd, "/dev/null"];
65
+ const penv = { ...env, TERM: env?.TERM || "xterm-256color", COLUMNS: "160", LINES: "50" };
66
+ delete penv.CI; // cf only prompts when interactive
67
+ return new Promise((resolve, reject) => {
68
+ const ch = spawn("script", sargs, { cwd, env: penv, stdio: ["pipe", "pipe", "pipe"] });
69
+ // cf's prompts, in order; each is answered once, and only from output that arrived after the previous
70
+ // answer (prompt libraries re-render answered questions, which must not be answered twice)
71
+ const steps = REGISTRATION_STEPS(name);
72
+ let out = "", pending = "", stage = 0, failure = "";
73
+ const stop = (why) => { failure ||= why; ch.kill("SIGTERM"); };
74
+ const arm = () => setTimeout(() => stop("cf stopped responding (unexpected prompt?)"), idleMs);
75
+ let idle = arm();
76
+ const onData = (d) => {
77
+ process.stderr.write(d);
78
+ out += d; pending += d;
79
+ clearTimeout(idle); idle = arm();
80
+ const p = compactPrompt(pending);
81
+ if (UNAVAILABLE_RE.test(p)) return stop(`workers.dev subdomain "${name}" is unavailable; pick another name`);
82
+ if (stage < steps.length && steps[stage][0].test(p)) {
83
+ pending = "";
84
+ ch.stdin.write(steps[stage++][1]);
85
+ }
86
+ };
87
+ ch.stdout.on("data", onData);
88
+ ch.stderr.on("data", onData);
89
+ ch.on("error", (e) => { clearTimeout(idle); reject(e); });
90
+ ch.on("close", (code) => {
91
+ clearTimeout(idle);
92
+ ch.stdin.destroy();
93
+ if (failure) return reject(new CliError(failure));
94
+ resolve({ status: code ?? 1, out });
95
+ });
96
+ });
97
+ }