a2a-exposed 0.8.4 → 0.8.5
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/main.mjs +34 -10
- package/lib/status.mjs +18 -2
- package/lib/tunnel.mjs +41 -17
- package/lib/uninstall.mjs +48 -0
- package/package.json +1 -1
- package/worker/src/index.ts +19 -7
- package/worker/src/pairing.ts +6 -5
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +11 -3
- package/worker/test/pairing.test.ts +6 -0
- package/worker/test/wake.test.ts +3 -0
package/lib/main.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import * as pair from "./pair.mjs";
|
|
|
10
10
|
import * as account from "./account.mjs";
|
|
11
11
|
import * as upd from "./update.mjs";
|
|
12
12
|
import * as fb from "./feedback.mjs";
|
|
13
|
+
import * as un from "./uninstall.mjs";
|
|
13
14
|
|
|
14
15
|
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
15
16
|
|
|
@@ -83,8 +84,12 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
83
84
|
which defaults to 25 when this is unset
|
|
84
85
|
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
85
86
|
+ sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
|
|
86
|
-
local env values
|
|
87
|
-
|
|
87
|
+
local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
|
|
88
|
+
are exported in this shell. match: the exported value is the one on the Worker.
|
|
89
|
+
DIFFERENT: they differ (absent from this shell, or still propagating, about
|
|
90
|
+
15 to 40 seconds after wake set, wake unset, or tunnel create). Wait about
|
|
91
|
+
30 seconds and run it again
|
|
92
|
+
wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
|
|
88
93
|
wake test send a test wake now and print the HTTP status
|
|
89
94
|
wake unset remove wake secrets (fall back to polling)
|
|
90
95
|
|
|
@@ -96,10 +101,24 @@ anywhere on the account: the inbox itself can be on workers.dev or a custom host
|
|
|
96
101
|
the Worker sends that token (CF-Access-Client-Id/Secret) on every wake.
|
|
97
102
|
Wake hostname: wake-<random>.<zone>, on the inbox hostname's zone, else the account's
|
|
98
103
|
only zone; with several zones pass --tunnel-zone. Safe to re-run (no duplicates).
|
|
104
|
+
The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
|
|
105
|
+
The DNS host is wake-<words>-<hex>.<zone> unless --tunnel-hostname is set.
|
|
106
|
+
Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
107
|
+
The connector machine needs cloudflared installed
|
|
108
|
+
(https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
|
|
99
109
|
Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
|
|
100
|
-
(pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600)
|
|
110
|
+
(pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600).
|
|
111
|
+
Worker secrets show up in about 15 to 40 seconds
|
|
101
112
|
tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
|
|
102
|
-
tunnel rm delete
|
|
113
|
+
tunnel rm delete the DNS record, the tunnel, the Access app, and the service token
|
|
114
|
+
(each printed by name), plus Worker secrets WAKE_ACCESS_CLIENT_ID,
|
|
115
|
+
WAKE_ACCESS_CLIENT_SECRET, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET. The wake URL
|
|
116
|
+
goes too when it is this tunnel's. Prints one line per object removed
|
|
117
|
+
uninstall [--yes] remove the wake tunnel (when the config has one), the Worker, and the D1 database.
|
|
118
|
+
One line per step, including when that object is already gone. Asks first;
|
|
119
|
+
off a terminal, pass --yes. The config directory stays (owner token and peer
|
|
120
|
+
tokens): remove that directory to delete them. cf runs from the Worker project
|
|
121
|
+
when it has node_modules, otherwise from a directory with .cloudflare/cache
|
|
103
122
|
init ... --tunnel init, then tunnel create (also with --workers-dev)
|
|
104
123
|
|
|
105
124
|
Status
|
|
@@ -111,7 +130,9 @@ Status
|
|
|
111
130
|
When GET /owner/mcp reports enabled and the agent card is reachable, prints
|
|
112
131
|
\`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
|
|
113
132
|
A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
|
|
114
|
-
Read-only; run it after an interruption and continue from "next step". Exit 1 = broken
|
|
133
|
+
Read-only; run it after an interruption and continue from "next step". Exit 1 = broken.
|
|
134
|
+
Right after tunnel create, for about 15 to 40 seconds, while the connector
|
|
135
|
+
reports 0 connections, status prints a wait-about-30-seconds line and exits 0
|
|
115
136
|
upstream verify [--json] proxy mode: check the façade's whole path, layer by layer: façade peer auth
|
|
116
137
|
(unauthenticated call -> 401), then the Worker calls the upstream with its stored
|
|
117
138
|
secrets (never sent to the CLI) using a JSON-RPC method that doesn't exist (no task
|
|
@@ -135,8 +156,9 @@ Inbox (owner side)
|
|
|
135
156
|
|
|
136
157
|
Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
|
|
137
158
|
connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
|
|
138
|
-
ask another inbox for a token: prints a code + link for your human (
|
|
139
|
-
code with that inbox's owner), waits for approval, stores
|
|
159
|
+
ask another inbox for a token: prints a code + link for your human (the code lasts
|
|
160
|
+
10 minutes; they confirm the code with that inbox's owner), waits for approval, stores
|
|
161
|
+
the token as outbound peer A
|
|
140
162
|
(never printed). --no-wait: print the code and exit; run the same command again to
|
|
141
163
|
check (same code). An expired code exits 1 (nothing new is requested silently).
|
|
142
164
|
An alias whose token still works is refused unless --replace (alias --force): the
|
|
@@ -149,9 +171,9 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
149
171
|
configured agent name, the worker name, or the public host's first label. The
|
|
150
172
|
other inbox labels the token from that name and adds -2, -3, … when it is taken
|
|
151
173
|
pair set-password --web [--ttl MIN] [--json]
|
|
152
|
-
one-time link (default 15 min, single use) where your human sets or
|
|
153
|
-
approval password on a web page. Send it to them privately; never open
|
|
154
|
-
yourself. A new link invalidates the previous one
|
|
174
|
+
one-time link (default 15 min, --ttl 1 to 60, single use) where your human sets or
|
|
175
|
+
changes the approval password on a web page. Send it to them privately; never open
|
|
176
|
+
or fill it yourself. A new link invalidates the previous one
|
|
155
177
|
pair set-password the same in a terminal, no echo (the human runs it; never via argv/stdin)
|
|
156
178
|
pair set-oidc --issuer URL --client-id ID --subjects SUB[,SUB...] [--methods password,oidc] [--secret-stdin]
|
|
157
179
|
optional OpenID Connect approval on /device and the MCP consent page. The client
|
|
@@ -239,6 +261,7 @@ const SPEC = {
|
|
|
239
261
|
login: { "control-url": S, json: B },
|
|
240
262
|
pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
|
|
241
263
|
feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
|
|
264
|
+
uninstall: { yes: B, dir: S, "worker-dir": S },
|
|
242
265
|
};
|
|
243
266
|
const STATES = ["completed", "input-required", "failed", "rejected", "working"];
|
|
244
267
|
|
|
@@ -274,6 +297,7 @@ export async function main(argv) {
|
|
|
274
297
|
throw e;
|
|
275
298
|
}
|
|
276
299
|
case "tunnel": return tun.tunnel(need(p[0], "tunnel create|status|rm"), o);
|
|
300
|
+
case "uninstall": return un.uninstall(o);
|
|
277
301
|
case "deploy": return dep.deploy(o);
|
|
278
302
|
case "status": return st.status(o);
|
|
279
303
|
case "upstream": {
|
package/lib/status.mjs
CHANGED
|
@@ -77,6 +77,17 @@ export function shadowedPeerTokens() {
|
|
|
77
77
|
return [...new Set(Object.values(C.loadPeers()).map((p) => p && p.token_env).filter(Boolean))].filter((k) => C.envOverridesFile(k));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** How long after `tunnel create` uploads secrets `status` treats a missing Worker preview as propagation. */
|
|
81
|
+
export const SECRET_PROPAGATION_MS = 60_000;
|
|
82
|
+
|
|
83
|
+
/** True while the tunnel's Worker secrets were uploaded recently and may still be invisible to `wake preview`. */
|
|
84
|
+
export function secretsPropagating(tunnel, now = Date.now()) {
|
|
85
|
+
const at = Number(tunnel && tunnel.secretsAt);
|
|
86
|
+
if (!Number.isFinite(at) || at <= 0) return false;
|
|
87
|
+
const age = now - at;
|
|
88
|
+
return age >= 0 && age < SECRET_PROPAGATION_MS;
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
/** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
|
|
81
92
|
* as separate lines (so the next step stays one short instruction). */
|
|
82
93
|
export function nextStep(s) {
|
|
@@ -143,8 +154,12 @@ function nextStepCore(s) {
|
|
|
143
154
|
return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
|
|
144
155
|
const agentSecret = s.wake.preset === "hermes" ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
|
|
145
156
|
const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
146
|
-
|
|
157
|
+
const now = typeof s.now === "number" ? s.now : Date.now();
|
|
158
|
+
if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
|
|
159
|
+
if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
|
|
160
|
+
return done(`Worker wake secrets are still propagating (about 15 to 40 seconds after tunnel create). Wait about 30 seconds and run \`${CLI} status\` again.`);
|
|
147
161
|
return fail(`the Worker does not have the tunnel's wake secrets: ${needsAgentSecret ? `export ${agentSecret} and ` : ""}run \`${CLI} tunnel create\` again (it re-uploads them; nothing new is created)`);
|
|
162
|
+
}
|
|
148
163
|
if (t && t.connections === 0)
|
|
149
164
|
return fail(`the tunnel has no running connector: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
|
|
150
165
|
if (s.wake.configured && needsAgentSecret)
|
|
@@ -223,7 +238,8 @@ export async function collect() {
|
|
|
223
238
|
if (host || C.get("A2A_TUNNEL_ID")) {
|
|
224
239
|
const url = T.wakeUrl(host || "?", C.get("A2A_TUNNEL_PATH") || "/");
|
|
225
240
|
s.tunnel = { hostname: host, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
|
|
226
|
-
tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
|
|
241
|
+
tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
|
|
242
|
+
secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
|
|
227
243
|
if (haveCf && C.get("A2A_TUNNEL_ID")) {
|
|
228
244
|
try {
|
|
229
245
|
const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
|
package/lib/tunnel.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// A Cloudflare zone on the account is required on purpose (no quick tunnels, no unprotected hostnames). Any zone
|
|
7
7
|
// on the account works: the inbox itself can stay on workers.dev or on another hostname.
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import crypto from "node:crypto";
|
|
11
12
|
import { spawnSync } from "node:child_process";
|
|
@@ -32,7 +33,7 @@ export const SERVICE_TOKEN_DURATION = "8760h"; // 1 year (Cloudflare's default);
|
|
|
32
33
|
export const TUNNEL_KEYS = [
|
|
33
34
|
"A2A_TUNNEL_HOSTNAME", "A2A_TUNNEL_ORIGIN", "A2A_TUNNEL_PATH", "A2A_TUNNEL_ZONE_ID", "A2A_TUNNEL_ID", "A2A_TUNNEL_DNS_ID",
|
|
34
35
|
"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
|
+
"A2A_TUNNEL_TOKEN_FILE", "A2A_TUNNEL_SECRETS_AT",
|
|
36
37
|
];
|
|
37
38
|
|
|
38
39
|
// ------------------------------------------------------------------ pure helpers (unit-tested)
|
|
@@ -147,7 +148,8 @@ export function secretsPatch(map) {
|
|
|
147
148
|
export function connectorInstructions(tokenFile, token = "") {
|
|
148
149
|
const t = token || `"$(cat ${tokenFile})"`;
|
|
149
150
|
return [
|
|
150
|
-
"
|
|
151
|
+
"Install cloudflared on the agent's machine: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/",
|
|
152
|
+
"Run the connector there (outbound TCP/UDP 7844 to Cloudflare must be allowed):",
|
|
151
153
|
` cloudflared tunnel run --token-file ${tokenFile} # cloudflared 2025.4+`,
|
|
152
154
|
` cloudflared tunnel run --token ${t}`,
|
|
153
155
|
` sudo cloudflared service install ${t} # as a system service (starts on boot)`,
|
|
@@ -214,11 +216,28 @@ export function listZones(dir) {
|
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
// ------------------------------------------------------------------ commands
|
|
219
|
+
/** A working directory for `cf`. A project with `node_modules` keeps its own cache. Otherwise `.cloudflare/cache`
|
|
220
|
+
* in that directory (or a temp directory, when the project is gone) is where `cf` writes its account cache. */
|
|
221
|
+
export function cfWorkDir(preferred) {
|
|
222
|
+
if (preferred && fs.existsSync(path.join(preferred, "node_modules"))) return preferred;
|
|
223
|
+
const base = preferred && fs.existsSync(preferred) ? preferred : fs.mkdtempSync(path.join(os.tmpdir(), "a2a-cf-"));
|
|
224
|
+
fs.mkdirSync(path.join(base, ".cloudflare", "cache"), { recursive: true });
|
|
225
|
+
return base;
|
|
226
|
+
}
|
|
227
|
+
|
|
217
228
|
function needDeployment(o) {
|
|
218
229
|
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
|
|
219
230
|
const dir = D.workerDir(o);
|
|
220
231
|
if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
|
|
221
|
-
return dir;
|
|
232
|
+
return cfWorkDir(dir);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `cf` cwd for `tunnel rm` / `uninstall`. A missing Worker project uses a temp directory with a writable cache. */
|
|
236
|
+
function cfDirForRemove(o, { allowMissingProject = false } = {}) {
|
|
237
|
+
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
|
|
238
|
+
const dir = D.workerDir(o);
|
|
239
|
+
if (!fs.existsSync(dir) && !allowMissingProject) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
|
|
240
|
+
return cfWorkDir(dir);
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
/** Checks that need no Cloudflare calls (also used by `init --tunnel` before anything is deployed). */
|
|
@@ -293,7 +312,7 @@ export async function create(o) {
|
|
|
293
312
|
console.log(url);
|
|
294
313
|
console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
|
|
295
314
|
console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
|
|
296
|
-
console.error(`\nThen (Worker secrets apply within
|
|
315
|
+
console.error(`\nThen (Worker secrets apply within about 15 to 40 seconds): ${CLI} tunnel status and ${CLI} wake test`);
|
|
297
316
|
if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
|
|
298
317
|
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
318
|
} catch (e) {
|
|
@@ -363,8 +382,10 @@ async function resume(dir, o) {
|
|
|
363
382
|
console.error(`Then: ${CLI} tunnel status and ${CLI} wake test`);
|
|
364
383
|
}
|
|
365
384
|
|
|
366
|
-
function putSecrets(dir, worker, map) {
|
|
367
|
-
|
|
385
|
+
async function putSecrets(dir, worker, map) {
|
|
386
|
+
const r = await D.withSecretsFile(secretsPatch(map), (file) => cfCall(dir, ["workers", "secrets", "bulk", "--worker", worker], { file }));
|
|
387
|
+
if (Object.values(map).some((v) => v != null)) C.saveConfig({ A2A_TUNNEL_SECRETS_AT: String(Date.now()) });
|
|
388
|
+
return r;
|
|
368
389
|
}
|
|
369
390
|
|
|
370
391
|
export async function status(o) {
|
|
@@ -406,9 +427,10 @@ export async function status(o) {
|
|
|
406
427
|
if (!/^HTTP 40[13]/.test(info.probe.withoutToken)) console.error("WARNING: the bare request was not blocked by Access; check the Access application");
|
|
407
428
|
}
|
|
408
429
|
|
|
409
|
-
export async function remove(o, { quiet = false } = {}) {
|
|
410
|
-
const dir =
|
|
430
|
+
export async function remove(o, { quiet = false, allowMissingProject = false } = {}) {
|
|
431
|
+
const dir = cfDirForRemove(o, { allowMissingProject });
|
|
411
432
|
const worker = D.workerName();
|
|
433
|
+
const label = `a2a-exposed wake ${worker}`;
|
|
412
434
|
const failures = [];
|
|
413
435
|
const attempt = async (what, key, fn) => {
|
|
414
436
|
if (key && !C.get(key)) return;
|
|
@@ -417,25 +439,27 @@ export async function remove(o, { quiet = false } = {}) {
|
|
|
417
439
|
};
|
|
418
440
|
const host = C.get("A2A_TUNNEL_HOSTNAME");
|
|
419
441
|
// 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)
|
|
421
|
-
const
|
|
442
|
+
if (host) {
|
|
443
|
+
const names = ["WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"];
|
|
422
444
|
let ours = false;
|
|
423
445
|
try { const p = await owner("GET", "/owner/wake/preview"); ours = p.fingerprints?.url === fingerprint(wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/")); }
|
|
424
446
|
catch { /* Worker unreachable: leave the URL */ }
|
|
425
|
-
if (ours)
|
|
426
|
-
await
|
|
427
|
-
|
|
447
|
+
if (ours) names.push("WAKE_WEBHOOK_URL");
|
|
448
|
+
await attempt(`Worker secrets for ${host} (${names.join(", ")})`, null, async () => {
|
|
449
|
+
await putSecrets(dir, worker, Object.fromEntries(names.map((k) => [k, null])));
|
|
450
|
+
});
|
|
451
|
+
}
|
|
428
452
|
// 2. DNS before Access, so the hostname never resolves without its Access app
|
|
429
|
-
await attempt(
|
|
430
|
-
await attempt(
|
|
453
|
+
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 }));
|
|
454
|
+
await attempt(`tunnel "${label}"`, "A2A_TUNNEL_ID", () => {
|
|
431
455
|
const id = C.get("A2A_TUNNEL_ID");
|
|
432
456
|
// Already deleted (a previous rm stopped on the confirm prompt, or it was removed by hand): clear the saved id.
|
|
433
457
|
if (!cfCall(dir, ["tunnels", "get", id], { allowNotFound: true })) return;
|
|
434
458
|
cfCall(dir, ["tunnels", "connections", "cleanup", id, "--force"], { allowNotFound: true });
|
|
435
459
|
cfCall(dir, ["tunnels", "delete", id, "--force"], { allowNotFound: true });
|
|
436
460
|
});
|
|
437
|
-
await attempt(
|
|
438
|
-
await attempt(
|
|
461
|
+
await attempt(`Access application "${label}"`, "A2A_TUNNEL_ACCESS_APP_ID", () => cfCall(dir, ["zero-trust", "access", "applications", "delete", C.get("A2A_TUNNEL_ACCESS_APP_ID"), "--force"], { allowNotFound: true }));
|
|
462
|
+
await attempt(`Access service token "${label}"`, "A2A_TUNNEL_ACCESS_TOKEN_ID", () => {
|
|
439
463
|
cfCall(dir, ["zero-trust", "access", "service-tokens", "delete", C.get("A2A_TUNNEL_ACCESS_TOKEN_ID"), "--force"], { allowNotFound: true });
|
|
440
464
|
C.saveConfig({ A2A_TUNNEL_ACCESS_CLIENT_ID: null, A2A_TUNNEL_ACCESS_CLIENT_SECRET: null });
|
|
441
465
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// `uninstall`: remove the wake tunnel (when one is saved), the Worker, and the D1 database for this config.
|
|
2
|
+
import readline from "node:readline/promises";
|
|
3
|
+
import { CliError } from "./a2a.mjs";
|
|
4
|
+
import * as C from "./config.mjs";
|
|
5
|
+
import * as D from "./deploy.mjs";
|
|
6
|
+
import * as T from "./tunnel.mjs";
|
|
7
|
+
|
|
8
|
+
const step = (s) => console.error(`==> ${s}`);
|
|
9
|
+
|
|
10
|
+
/** Ask before deleting. On a terminal, wait for y or yes unless `yes` is set. Off a terminal, `--yes` is required. */
|
|
11
|
+
export async function confirmUninstall(yes, input = process.stdin, output = process.stderr) {
|
|
12
|
+
if (yes) return;
|
|
13
|
+
if (!input.isTTY) throw new CliError("uninstall removes the Worker, the D1 database, and the wake tunnel. Pass --yes.");
|
|
14
|
+
const rl = readline.createInterface({ input, output });
|
|
15
|
+
try {
|
|
16
|
+
const answer = (await rl.question("Remove the Worker, the D1 database, and the wake tunnel? [y/N] ")).trim().toLowerCase();
|
|
17
|
+
if (answer !== "y" && answer !== "yes") throw new CliError("uninstall cancelled");
|
|
18
|
+
} finally {
|
|
19
|
+
rl.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const kept = () => `config directory kept at ${C.CONFIG_DIR} (owner token and peer tokens). Remove that directory to delete them.`;
|
|
24
|
+
|
|
25
|
+
/** One line per removal. A 404 counts as already gone. The config directory stays. */
|
|
26
|
+
export async function uninstall(o) {
|
|
27
|
+
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
|
+
|
|
32
|
+
const name = C.get("A2A_WORKER_NAME");
|
|
33
|
+
const d1 = C.get("A2A_D1_ID");
|
|
34
|
+
const dir = name || d1 ? T.cfWorkDir(D.workerDir(o)) : "";
|
|
35
|
+
if (name) {
|
|
36
|
+
const r = T.cfCall(dir, ["workers", "delete", name, "--force"], { allowNotFound: true });
|
|
37
|
+
step(r === null ? `Worker ${name} is already gone` : `removed Worker ${name}`);
|
|
38
|
+
} else step("no Worker name saved");
|
|
39
|
+
if (d1) {
|
|
40
|
+
const r = T.cfCall(dir, ["d1", "delete", d1, "--force"], { allowNotFound: true });
|
|
41
|
+
step(r === null ? `D1 database ${d1} is already gone` : `removed D1 database ${d1}`);
|
|
42
|
+
} else step("no D1 database id saved");
|
|
43
|
+
C.saveConfig({
|
|
44
|
+
A2A_WORKER_NAME: null, A2A_D1_ID: null, A2A_D1_NAME: null, A2A_BASE_URL: null, A2A_HOSTNAME: null,
|
|
45
|
+
A2A_WORKER_DIR: null, A2A_WORKERS_DEV_SUBDOMAIN: null,
|
|
46
|
+
});
|
|
47
|
+
console.error(kept());
|
|
48
|
+
}
|
package/package.json
CHANGED
package/worker/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// context is the single self-host tenant and behaviour matches a Worker that read env.* directly.
|
|
7
7
|
import * as A from "./a2a.ts";
|
|
8
8
|
import * as F from "./facade.ts";
|
|
9
|
-
import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
|
|
9
|
+
import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
|
|
10
10
|
import { fetchCheckedWake } from "./wake-target.ts";
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
@@ -142,7 +142,9 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
142
142
|
r = await doFetch(ctx, ectx, req.url, { method: "POST", headers: req.headers, body: req.body });
|
|
143
143
|
}
|
|
144
144
|
// explain Cloudflare edge errors (tunnel connector down, Access block) without echoing the body
|
|
145
|
-
const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096)
|
|
145
|
+
const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096), {
|
|
146
|
+
tunnel: !!(ctx.WAKE_ACCESS_CLIENT_ID && ctx.WAKE_ACCESS_CLIENT_SECRET) || wakeUrlIsTunnelHost(req.url),
|
|
147
|
+
});
|
|
146
148
|
log("wake_sent", { preset: preset(ctx), contextId: ev.contextId, taskId: ev.taskId, status: r.status, ...(hint ? { hint } : {}) });
|
|
147
149
|
if (r.status === 429 || r.status === 503) await requeueWake(ctx, ectx, payload, r.headers.get("retry-after"));
|
|
148
150
|
return { status: r.status, info: `HTTP ${r.status}${hint ? ` (${hint})` : ""}` };
|
|
@@ -440,7 +442,7 @@ async function fetchUpstreamCard(ctx: TenantContext, fresh = false): Promise<{ c
|
|
|
440
442
|
cardFailures.delete(url);
|
|
441
443
|
return { card, status: 200, url };
|
|
442
444
|
}
|
|
443
|
-
const hint = cloudflareErrorHint(r.status, text.slice(0, 4096));
|
|
445
|
+
const hint = cloudflareErrorHint(r.status, text.slice(0, 4096), { tunnel: true });
|
|
444
446
|
log("upstream_card_failed", { status: r.status, ...(hint ? { hint } : {}) });
|
|
445
447
|
return fail(r.status, `HTTP ${r.status}${hint ? ` (${hint})` : ""}`);
|
|
446
448
|
} catch (e) {
|
|
@@ -1385,6 +1387,16 @@ const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, client
|
|
|
1385
1387
|
agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
|
|
1386
1388
|
replacesLabel: r.replaces_label || undefined });
|
|
1387
1389
|
|
|
1390
|
+
/** The label approval will assign: the live token being replaced, otherwise the next free label. */
|
|
1391
|
+
async function withTokenLabel(ctx: TenantContext, r: Json): Promise<P.PageRequest> {
|
|
1392
|
+
const base = pageRequest(r);
|
|
1393
|
+
if (r.replaces_label && r.replaces_hash) {
|
|
1394
|
+
const live = await ctx.DB.prepare("SELECT 1 FROM peers WHERE label = ? AND token_hash = ? AND revoked_at IS NULL").bind(r.replaces_label, r.replaces_hash).first();
|
|
1395
|
+
if (live) return { ...base, tokenLabel: String(r.replaces_label) };
|
|
1396
|
+
}
|
|
1397
|
+
return { ...base, tokenLabel: await freeLabel(ctx, P.labelBase(r.client_name || "", r.client_id || "")) };
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1388
1400
|
/** Optional page fields. Omitted when they would not change today's HTML. */
|
|
1389
1401
|
function approvalPageBits(ctx: TenantContext): { oidc?: { label: string }; passwordEnabled?: boolean } {
|
|
1390
1402
|
const p = O.approvalPolicy(ctx.gates.approvalOidc);
|
|
@@ -1442,7 +1454,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1442
1454
|
}
|
|
1443
1455
|
const code = P.normUserCode(raw);
|
|
1444
1456
|
const r = code ? await pendingRequest(ctx, code) : null;
|
|
1445
|
-
if (r && r.status === "pending" && Date.now() < r.expires_ms) model.request =
|
|
1457
|
+
if (r && r.status === "pending" && Date.now() < r.expires_ms) model.request = await withTokenLabel(ctx, r);
|
|
1446
1458
|
else {
|
|
1447
1459
|
model.askCode = true;
|
|
1448
1460
|
model.codeValue = raw || "";
|
|
@@ -1549,14 +1561,14 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
|
|
|
1549
1561
|
if (!seen.ok) {
|
|
1550
1562
|
if (seen.userCode) {
|
|
1551
1563
|
const pending = await pendingRequest(ctx, seen.userCode);
|
|
1552
|
-
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request =
|
|
1564
|
+
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = await withTokenLabel(ctx, pending);
|
|
1553
1565
|
}
|
|
1554
1566
|
model.notice = { kind: "error", text: seen.error };
|
|
1555
1567
|
return send(400);
|
|
1556
1568
|
}
|
|
1557
1569
|
const pending = seen.userCode ? await pendingRequest(ctx, seen.userCode) : null;
|
|
1558
1570
|
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) {
|
|
1559
|
-
model.request =
|
|
1571
|
+
model.request = await withTokenLabel(ctx, pending);
|
|
1560
1572
|
return send(200);
|
|
1561
1573
|
}
|
|
1562
1574
|
model.notice = { kind: "error", text: "Nothing was approved." };
|
|
@@ -1567,7 +1579,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
|
|
|
1567
1579
|
log("oidc_rejected", { kind: "device", reason: done.reason });
|
|
1568
1580
|
if (done.userCode) {
|
|
1569
1581
|
const pending = await pendingRequest(ctx, done.userCode);
|
|
1570
|
-
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request =
|
|
1582
|
+
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = await withTokenLabel(ctx, pending);
|
|
1571
1583
|
}
|
|
1572
1584
|
model.notice = { kind: "error", text: done.error };
|
|
1573
1585
|
return send(400);
|
package/worker/src/pairing.ts
CHANGED
|
@@ -173,7 +173,7 @@ export async function verifyPassword(password: string, rec: PasswordRecord): Pro
|
|
|
173
173
|
// ------------------------------------------------------------------ /device page (no scripts, no external assets)
|
|
174
174
|
export const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
175
175
|
|
|
176
|
-
export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string };
|
|
176
|
+
export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string };
|
|
177
177
|
export type PageModel = {
|
|
178
178
|
agentName: string;
|
|
179
179
|
mode: Mode;
|
|
@@ -197,13 +197,14 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
197
197
|
["Agent (as it calls itself)", r.clientName || r.clientId || "(no name given)"],
|
|
198
198
|
...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
|
|
199
199
|
["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this inbox can't check it; informational only)" : "") : "(none given)"],
|
|
200
|
-
["Requested from", [r.ip || "unknown address", r.country].filter(Boolean).join(", ")],
|
|
200
|
+
["Requested from", `${[r.ip || "unknown address", r.country].filter(Boolean).join(", ")} (IP address of the machine that asked to connect)`],
|
|
201
201
|
["Expires", `${Math.max(0, Math.round((r.expiresMs - Date.now()) / 60000))} min`],
|
|
202
202
|
...(r.replacesLabel ? [["Replaces", `the active token "${r.replacesLabel}" (the requester proved it holds it); approving issues a new token under the same label and the old one stops working`]] : []),
|
|
203
203
|
] : [];
|
|
204
204
|
const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
|
|
205
|
+
const revoke = r?.tokenLabel ? `${m.cli} token revoke ${r.tokenLabel}` : `${m.cli} token revoke`;
|
|
205
206
|
const details = r ? `<table>${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join("")}</table>
|
|
206
|
-
<p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(
|
|
207
|
+
<p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(revoke)}</code>.</p>` : "";
|
|
207
208
|
const noPassword = `<p class="n info">No approval password is set yet. Easiest: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and send you the one-time link, then set the password on that page. Or run <code>${esc(m.cli)} pair set-password</code> yourself in a terminal on the owner's machine. Then reload this page. You can deny without a password.</p>`;
|
|
208
209
|
const denyOnly = `<form method="post" action="/device">
|
|
209
210
|
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(r ? formatUserCode(r.userCode) : "")}">
|
|
@@ -294,11 +295,11 @@ export function setupPage(m: SetupModel, nonce: string): string {
|
|
|
294
295
|
? `<p>An approval password is already set (since ${esc(m.passwordSetAt)}). Saving here replaces it.</p>`
|
|
295
296
|
: `<p>Choose the password you will use to approve agents that ask to connect to this inbox.</p>`;
|
|
296
297
|
return pageShell(`Approval password for ${m.agentName}`, nonce, `${notice}${intro}
|
|
297
|
-
<p class="w">Only you should know it: don't share it with your agent or anyone else.
|
|
298
|
+
<p class="w">Only you should know it: don't share it with your agent or anyone else. Use ${MIN_PASSWORD_LENGTH} to 1024 characters. A passphrase of several unrelated words is stronger than one short word. This one-time link expires in ${mins} min.</p>
|
|
298
299
|
<form method="post" action="/device/setup">
|
|
299
300
|
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="t" value="${esc(m.token)}">
|
|
300
301
|
<label for="pw1">New approval password</label><input id="pw1" name="password" type="password" autocomplete="new-password" required minlength="${MIN_PASSWORD_LENGTH}" maxlength="1024" autofocus>
|
|
301
|
-
<label for="pw2">Repeat
|
|
302
|
+
<label for="pw2">Repeat the password</label><input id="pw2" name="password2" type="password" autocomplete="new-password" required minlength="${MIN_PASSWORD_LENGTH}" maxlength="1024">
|
|
302
303
|
<div class="b"><button type="submit">Set approval password</button></div>
|
|
303
304
|
</form>`);
|
|
304
305
|
}
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.5";
|
package/worker/src/wake.ts
CHANGED
|
@@ -236,12 +236,20 @@ export function cloudflareErrorCode(body: string): string {
|
|
|
236
236
|
return m ? m[1] || m[2] || m[3] || m[4] : "";
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
/**
|
|
240
|
-
export function
|
|
239
|
+
/** True when the wake URL's host is a Cloudflare Tunnel hostname (`*.cfargotunnel.com`). */
|
|
240
|
+
export function wakeUrlIsTunnelHost(url: string): boolean {
|
|
241
|
+
try { return /\.cfargotunnel\.com$/i.test(new URL(url).hostname); } catch { return false; }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Short explanation of a Cloudflare edge error page (e.g. "error code: 1033"), for wake test output.
|
|
245
|
+
* `tunnel` selects the 1016 wording: a tunnel hostname's DNS record, or a webhook host that failed to resolve. */
|
|
246
|
+
export function cloudflareErrorHint(status: number, body: string, opts?: { tunnel?: boolean }): string {
|
|
241
247
|
const code = cloudflareErrorCode(body);
|
|
242
248
|
const known: Record<string, string> = {
|
|
243
249
|
"1033": "Cloudflare Tunnel has no running connector (start cloudflared on the agent's machine)",
|
|
244
|
-
"1016":
|
|
250
|
+
"1016": opts?.tunnel
|
|
251
|
+
? "origin DNS error (the tunnel hostname has no matching DNS record)"
|
|
252
|
+
: "origin DNS error (the webhook host failed to resolve)",
|
|
245
253
|
"1010": "blocked by a Cloudflare security rule",
|
|
246
254
|
};
|
|
247
255
|
if (code) return `Cloudflare error ${code}${known[code] ? ": " + known[code] : ""}`;
|
|
@@ -290,6 +290,9 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
290
290
|
assert.equal(g.status, 200);
|
|
291
291
|
assert.ok(!g.text.includes("<script>") && g.text.includes("Barry <script>"));
|
|
292
292
|
assert.ok(g.text.includes("https://barry.example.org/.well-known/agent-card.json") && g.text.includes("198.51.100.7"));
|
|
293
|
+
assert.match(g.text, /198\.51\.100\.7 \(IP address of the machine that asked to connect\)/);
|
|
294
|
+
assert.match(g.text, /token revoke Barry-script-alert-1-scr/);
|
|
295
|
+
assert.ok(!g.text.includes("<label>"));
|
|
293
296
|
assert.match(g.headers.get("content-security-policy")!, /^default-src 'none'; style-src 'nonce-[A-Za-z0-9_-]+'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'$/);
|
|
294
297
|
assert.equal(g.headers.get("cache-control"), "no-store");
|
|
295
298
|
assert.equal(g.headers.get("x-frame-options"), "DENY");
|
|
@@ -660,6 +663,9 @@ test("password setup link: one-time page sets the password (hashed on the Worker
|
|
|
660
663
|
assert.ok(!/<script|src=/i.test(get.text), "no scripts or external assets");
|
|
661
664
|
assert.match(get.text, /name="password" type="password" autocomplete="new-password" required minlength="12"/);
|
|
662
665
|
assert.match(get.text, /name="password2"/);
|
|
666
|
+
assert.match(get.text, /Repeat the password/);
|
|
667
|
+
assert.match(get.text, /Use 12 to 1024 characters/);
|
|
668
|
+
assert.match(get.text, /passphrase of several unrelated words/);
|
|
663
669
|
assert.match(get.text, /expires in 15 min/);
|
|
664
670
|
assert.equal(post.status, 200, post.text);
|
|
665
671
|
assert.match(post.text, /Approval password set \(\d{4}-\d\d-\d\dT[^)]+\)/);
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -173,5 +173,8 @@ test("cloudflareErrorHint explains tunnel / Access failures", () => {
|
|
|
173
173
|
assert.match(cloudflareErrorHint(530, '<script>a={event:"feedback clicked",properties:{errorCode: 1033 }}</script>'), /1033/);
|
|
174
174
|
assert.match(cloudflareErrorHint(403, "Forbidden. You don't have access. cloudflareaccess.com"), /Cloudflare Access/);
|
|
175
175
|
assert.equal(cloudflareErrorHint(500, "boom"), "");
|
|
176
|
+
assert.match(cloudflareErrorHint(530, "error code: 1016"), /webhook host failed to resolve/);
|
|
177
|
+
assert.doesNotMatch(cloudflareErrorHint(530, "error code: 1016"), /tunnel hostname/);
|
|
178
|
+
assert.match(cloudflareErrorHint(530, "error code: 1016", { tunnel: true }), /tunnel hostname has no matching DNS record/);
|
|
176
179
|
});
|
|
177
180
|
|