a2a-exposed 0.8.40 → 0.8.41

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/deploy.mjs CHANGED
@@ -33,7 +33,9 @@ export const CLAUDE_CODE_NOTE = [
33
33
  " Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
34
34
  ].join("\n");
35
35
 
36
- const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
36
+ export const MAUSBOT_NOTE = "note (mausbot): the first test may only capture a verification sample. Verify and enable the webhook in MausBot, then run wake test again. HTTP 202 proves acceptance; check that the bot reads the inbox and replies.";
37
+
38
+ const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "mausbot", "generic"];
37
39
  // WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
38
40
  // by hand for your own Access-protected endpoint)
39
41
  export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
@@ -203,7 +205,7 @@ export function deploySecretsFromEnv() {
203
205
 
204
206
  const csv = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
205
207
 
206
- /** Save init/deploy flags. Returns { from, to } when the public URL switches between a custom domain and
208
+ /** Validate init/deploy flags, check Node before writing, then save them. Returns { from, to } when the public URL switches between a custom domain and
207
209
  * workers.dev (or to another custom domain), so the caller can tell the user what changed. */
208
210
  function applyFlags(o) {
209
211
  const upd = {};
@@ -273,6 +275,8 @@ function applyFlags(o) {
273
275
  if (upd.A2A_AGENT_SKILLS) {
274
276
  try { if (!Array.isArray(JSON.parse(upd.A2A_AGENT_SKILLS))) throw 0; } catch { die("--agent-skills must be a JSON array of A2A AgentSkill objects"); }
275
277
  }
278
+ // Report local input errors before Node guidance, but never save or deploy on unsupported Node.
279
+ checkNode();
276
280
  C.saveConfig(upd);
277
281
  return switched;
278
282
  }
@@ -562,7 +566,6 @@ function deployedBase() {
562
566
  }
563
567
 
564
568
  export async function init(o) {
565
- checkNode();
566
569
  const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
567
570
  const switched = applyFlags(o);
568
571
  const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
@@ -702,7 +705,6 @@ export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
702
705
  }
703
706
 
704
707
  export async function deploy(o) {
705
- checkNode();
706
708
  if (!C.get("A2A_D1_ID")) die(`no saved deployment; run \`${CLI} init\` first`);
707
709
  // init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
708
710
  // refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
@@ -744,7 +746,35 @@ function guardTunnelUrl() {
744
746
  if (host !== th) die(`this deployment wakes through the tunnel https://${th} (tunnel create); unset WAKE_WEBHOOK_URL, or run \`${CLI} tunnel rm\` first`);
745
747
  }
746
748
 
749
+ /** Delivery diagnostic only: a test wake has no task or execution receipt. */
750
+ export async function checkWake(o, { request = fetch, callOwner = owner } = {}) {
751
+ if (C.get("WAKE_PRESET") !== "hermes") die("wake check requires the hermes preset; inspect wake preview first, then use wake set --preset hermes");
752
+ const local = o["local-url"] || `${C.get("A2A_TUNNEL_ORIGIN", "http://127.0.0.1:8644")}${C.get("A2A_TUNNEL_PATH", "/webhooks/a2a-wake")}`;
753
+ let url;
754
+ try { url = new URL(local); } catch { die("--local-url must be a loopback HTTP(S) webhook URL"); }
755
+ if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password || url.search || url.hash)
756
+ die("--local-url must be a loopback HTTP(S) webhook URL without credentials, query or fragment");
757
+ let status;
758
+ try {
759
+ const response = await request(url, { method: "POST", headers: { "content-type": "application/json", "user-agent": "a2a-exposed-cli" },
760
+ body: JSON.stringify({ event_type: "a2a_wake", kind: "test" }), redirect: "manual", signal: AbortSignal.timeout(10_000) });
761
+ status = response.status;
762
+ await response.body?.cancel();
763
+ } catch { die("unsigned local check could not reach the webhook; start the Hermes gateway and check --local-url"); }
764
+ const result = { unsigned: { status, refused: status === 401 || status === 403 }, signed: null, receiverExecution: "not-verified" };
765
+ if (result.unsigned.refused) {
766
+ const signed = await callOwner("POST", "/owner/wake/test", {});
767
+ result.signed = { status: Number.isInteger(signed.status) ? signed.status : null, delivered: signed.configured !== false && Number.isInteger(signed.status) && signed.status >= 200 && signed.status < 300 };
768
+ }
769
+ console.log(JSON.stringify(result, null, 2));
770
+ if (!result.unsigned.refused || !result.signed?.delivered) {
771
+ process.exitCode = 1;
772
+ console.error(!result.unsigned.refused ? "unsigned local POST must return 401 or 403; check the route's HMAC secret. Signed delivery was skipped." : "signed Worker delivery failed; run wake preview and tunnel status, then retry after secrets propagate");
773
+ } else console.error("delivery checks passed; receiver execution is not verified. Test wakes create no inbox task; inspect the Hermes job output for execution.");
774
+ }
775
+
747
776
  export async function wake(sub, o) {
777
+ if (sub === "check") return checkWake(o);
748
778
  if (sub === "fingerprint") {
749
779
  const fp = localFingerprints();
750
780
  if (!Object.values(fp).some(Boolean)) die(`no ${WAKE_SECRETS.join(" / ")} in the environment`);
@@ -769,6 +799,7 @@ export async function wake(sub, o) {
769
799
  console.log(JSON.stringify(r, null, 2));
770
800
  if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
771
801
  else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
802
+ if (r.preset === "mausbot" || C.get("WAKE_PRESET") === "mausbot") console.error(MAUSBOT_NOTE);
772
803
  return;
773
804
  }
774
805
  if (sub === "set") {
@@ -779,6 +810,7 @@ export async function wake(sub, o) {
779
810
  await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
780
811
  console.error(`wake settings deployed. Next: ${CLI} wake preview, then ${CLI} wake test`);
781
812
  if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
813
+ if (C.get("WAKE_PRESET") === "mausbot") console.error(MAUSBOT_NOTE);
782
814
  return;
783
815
  }
784
816
  if (sub === "unset") {
@@ -796,5 +828,5 @@ export async function wake(sub, o) {
796
828
  if (problems.length) die(`could not remove wake secrets:\n ${problems.join("\n ")}`);
797
829
  return console.error("wake secrets removed (wake is now a no-op; use polling)");
798
830
  }
799
- die("usage: wake set|unset|test|preview|fingerprint");
831
+ die("usage: wake set|unset|test|check|preview|fingerprint");
800
832
  }
package/lib/hosted.mjs CHANGED
@@ -36,17 +36,30 @@ function originFor(o = {}) {
36
36
  async function request(origin, path, { token = "", method = "GET", body, fetchImpl = fetch } = {}) {
37
37
  let response, data;
38
38
  const discovery = method === "GET" && path === "/api/v1/setup/capabilities";
39
+ const wakeRequest = /^\/api\/v1\/agents\/[^/]+\/wake(?:\/test)?$/.test(path);
39
40
  try {
40
41
  response = await fetchImpl(`${origin}${path}`, {
41
42
  method, redirect: "error", signal: AbortSignal.timeout(30_000),
42
43
  headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }) },
43
44
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
44
45
  });
45
- if (!(discovery && response.status === 404)) data = await response.json();
46
+ if (!(discovery && response.status === 404)) {
47
+ try { data = await response.json(); }
48
+ catch (error) {
49
+ if (wakeRequest && [401, 403, 404].includes(response.status)) data = {};
50
+ else throw error;
51
+ }
52
+ }
46
53
  } catch { fail("unavailable", "Hosted setup did not answer with JSON. Check the service and try again. Do not repeat a create request until hosted list confirms its result."); }
47
54
  if (discovery && response.status === 404) fail("unsupported", SETUP_UNSUPPORTED);
48
55
  if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
49
56
  if (!response.ok) {
57
+ if (wakeRequest && [401, 403, 404].includes(response.status)) {
58
+ const code = ["invalid_grant", "insufficient_scope"].includes(data.error) ? data.error : `http_${response.status}`;
59
+ fail(code, response.status === 404
60
+ ? "This service does not expose the hosted wake API for this agent. Check --control-url and the selected agent; use dashboard Wake settings or hosted MCP set_wake. No settings were changed."
61
+ : "Hosted wake access was refused. Check that A2A_BASE_URL and A2A_OWNER_TOKEN belong to the same agent and service, or approve the required setup scopes. Use dashboard Wake settings or hosted MCP set_wake if needed. No settings were changed.");
62
+ }
50
63
  if (response.status === 429 && data.error === "too_many_references") {
51
64
  const seconds = retryAfter(response, data);
52
65
  const next = seconds === undefined
@@ -198,8 +211,6 @@ export async function status(o = {}) {
198
211
 
199
212
  export async function wake(sub, o = {}) {
200
213
  if (!["preview", "test", "set", "prepare", "remove", "unset"].includes(sub)) fail("usage", "Use wake preview|test|set|prepare|remove. List presets with hosted capabilities --json.");
201
- if (sub === "set" && C.deploymentKind() === "hosted" && !o.agent)
202
- fail("hosted_config", "This is a hosted config. Use dashboard Wake settings or hosted MCP set_wake; the human enters wake secrets on the confirmation page. For scoped CLI setup, run hosted capabilities --json, then hosted list --json to select an explicit --agent NAME before wake set --agent NAME --url URL --preset P --json. No wake settings were changed.");
203
214
  const removing = sub === "remove" || sub === "unset";
204
215
  const preparingRemoval = sub === "prepare" && o.clear === true;
205
216
  if (o.clear && !preparingRemoval) fail("usage", "Use wake prepare --clear for an MCP removal reference, or wake remove --version N. Run wake preview --json to list the current version.");
@@ -226,7 +237,7 @@ export async function wake(sub, o = {}) {
226
237
  const { origin, token, name } = wakeCredential(o, sub !== "prepare" && !o["reference-file"] && !o["reference-env"]), path = `/api/v1/agents/${encodeURIComponent(name)}/wake`;
227
238
  const current = await request(origin, path, { token });
228
239
  if (!Number.isSafeInteger(current.version)) fail("invalid_response", "The service did not return a valid wake version. Saved settings were not changed.");
229
- if (sub === "preview") return { ok: true, code: "preview", next: "Test an explicit draft before saving.", version: current.version, preset: current.preset, configured: Boolean(current.url), key_header: ["hermes", "openbot"].includes(current.preset) ? "" : ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent"].includes(current.preset) ? "Authorization" : current.keyHeader || "Authorization", url: previewUrl(current.url) };
240
+ if (sub === "preview") return { ok: true, code: "preview", next: "Test an explicit draft before saving.", version: current.version, preset: current.preset, configured: Boolean(current.url), key_header: ["hermes", "openbot"].includes(current.preset) ? "" : ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "mausbot"].includes(current.preset) ? "Authorization" : current.keyHeader || "Authorization", url: previewUrl(current.url) };
230
241
  if (removing || preparingRemoval) {
231
242
  const version = Number(o.version);
232
243
  if (version !== current.version) fail("conflict", "Wake settings changed. Run wake preview --agent NAME --json, review them, then repeat removal with its explicit --version.");
@@ -249,6 +260,7 @@ export async function wake(sub, o = {}) {
249
260
  }
250
261
  const url = o.url || process.env.WAKE_WEBHOOK_URL || current.url;
251
262
  const preset = o.preset || current.preset || "generic";
263
+ if (!url) fail("wake_unconfigured", "No hosted wake URL is configured. Run hosted capabilities --json to list presets, then wake set --preset P with WAKE_WEBHOOK_URL and protected wake credentials. Alternatively use dashboard Wake settings or hosted MCP set_wake; no settings were changed.");
252
264
  const problem = upstreamUrlProblem(url);
253
265
  if (problem) fail("url", "Pass --url with a public HTTPS webhook URL without credentials. Use polling if no public webhook is available.");
254
266
  const available = await capabilities({ "control-url": origin });
package/lib/main.mjs CHANGED
@@ -25,6 +25,7 @@ Usage: npx -y a2a-exposed@latest <command> [options]
25
25
  --help, -h print this help
26
26
 
27
27
  Setup options: --skip-install skips dependency installation when dependencies already exist.
28
+ Local option and missing-deployment errors appear before Node version guidance; deployment still requires Node 22.18+.
28
29
 
29
30
  Hosted setup:
30
31
  hosted capabilities --json list setup scopes, wake presets and service capabilities
@@ -143,8 +144,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
143
144
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
144
145
  [--cli-command CMD] [--debounce S] [--busy S] [--max-per-hour N]
145
146
  self-hosted: save wake settings, upload wake secrets from env, and redeploy
146
- hosted config without --agent: stop with dashboard Wake settings / hosted MCP set_wake guidance
147
- hosted CLI: list hosted capabilities --json and hosted list --json, then select --agent NAME
147
+ hosted config: use the saved address and owner token to test then save; no deploy
148
+ hosted CLI: list hosted capabilities --json for presets; scoped setup selects --agent from hosted list --json
149
+ mausbot: HTTPS /hooks/<endpointId> + WAKE_WEBHOOK_KEY Bearer auth from protected env
148
150
  openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
149
151
  the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
150
152
  --busy S seconds a conversation stays busy after a sent wake.
@@ -162,6 +164,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
162
164
  30 seconds and run it again
163
165
  wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
164
166
  wake test send a test wake now and print the HTTP status
167
+ mausbot: verify and enable the first sample in MausBot, then test again
168
+ wake check [--local-url URL] Hermes: require unsigned local 401/403, then Worker test 2xx (delivery only)
169
+ Defaults to the saved tunnel origin/path; --json prints both checks
165
170
  wake unset remove wake secrets (fall back to polling)
166
171
  handler prompts save autonomous, review-all or escalate mode and owner notices;
167
172
  see docs/receiving-messages.md for setup and the every 6h webhook fallback
@@ -169,7 +174,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
169
174
  Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
170
175
  anywhere on the account: the A2A endpoint itself can be on workers.dev or a custom hostname)
171
176
  tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p] [--show-token]
172
- [--zero-trust-org TEAM]
177
+ [--zero-trust-org TEAM] [--preset hermes --hermes-route a2a-wake --hmac-secret-stdin]
173
178
  named Cloudflare Tunnel + proxied DNS + Access app that admits ONE service token;
174
179
  the Worker sends that token (CF-Access-Client-Id/Secret) on every wake.
175
180
  Wake hostname: wake-<word>-<word>-<4 hex>.<zone>, on the A2A endpoint hostname's zone, else the
@@ -181,8 +186,9 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
181
186
  The connector machine needs cloudflared installed
182
187
  (https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
183
188
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
184
- (pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600).
189
+ /webhooks/a2a-wake (override with --hermes-route NAME). Token -> <config dir>/tunnel-token (chmod 600).
185
190
  Worker secrets show up in about 15 to 40 seconds. Wait about 30 seconds and check again
191
+ tunnel zones [--json] list available Cloudflare zones before selecting --tunnel-zone
186
192
  tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
187
193
  A network error says DNS may still be propagating; retry in 30 seconds
188
194
  tunnel rm delete the DNS record, the tunnel, the Access app, and the service token
@@ -360,24 +366,30 @@ Feedback
360
366
  polling), and hosted or self-hosted. --connection overrides
361
367
  that detection. Remove tokens from the message and from --logs.
362
368
 
363
- Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
369
+ Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | mausbot | generic
364
370
  Clients and how to load them:
365
371
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
366
372
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
367
373
  Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
368
374
 
369
- const HELP_TUNNEL = `tunnel create|status|rm
375
+ const HELP_TUNNEL = `tunnel create|zones|status|rm
370
376
 
371
377
  Self-hosted only. Hosted configs use hosted webhook settings or polling. Check status --json first.
372
378
 
373
379
  tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p]
374
380
  [--show-token] [--zero-trust-org TEAM] [--dir DIR | --worker-dir DIR]
381
+ [--preset hermes --hermes-route a2a-wake --hmac-secret-stdin]
382
+ Hermes defaults to :8644/webhooks/a2a-wake. --hermes-route selects another route.
383
+ --preset must match the deployed preset (configure with wake set --preset first).
384
+ --hmac-secret-stdin reads the existing subscription secret from protected stdin,
385
+ never prompts or saves it in config.env. See docs/hermes.md for the receiver recipe.
375
386
  Named Cloudflare Tunnel, proxied DNS, and an Access app that admits one service token.
376
387
  The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
377
388
  The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
378
389
  A finished tunnel creates nothing when you run it again. A partial tunnel stops and
379
390
  asks for tunnel rm. Prints the Access application name and ids, then how to undo it
380
391
  with tunnel rm. Worker secrets show up in about 15 to 40 seconds. Wait about 30 seconds.
392
+ tunnel zones [--json] List active zones before choosing --tunnel-zone; no resource changes.
381
393
  tunnel status Tunnel, Access, and Worker state, plus two probes.
382
394
  A network error says DNS may still be propagating; retry in 30 seconds.
383
395
  Warns about Access only when the bare request returns an HTTP status other than 401 or 403.
@@ -412,7 +424,7 @@ const deployOpts = {
412
424
  const SPEC = {
413
425
  hosted: { url: S, version: S, "upstream-token-file": S, "upstream-token-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S, name: S, json: B, agent: S, hostname: S, origin: S, path: S, preset: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
414
426
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts,
415
- wake: { ...deployOpts, json: B, url: S, "reference-file": S, "reference-env": S, clear: B, version: S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: tunnelOpts,
427
+ wake: { ...deployOpts, "local-url": S, json: B, url: S, "reference-file": S, "reference-env": S, clear: B, version: S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: { ...tunnelOpts, preset: S, "hermes-route": S, "hmac-secret-stdin": B, json: B },
416
428
  inbox: { context: S, from: S, all: B, json: B },
417
429
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
418
430
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
@@ -491,7 +503,7 @@ export async function main(argv) {
491
503
  throw e;
492
504
  }
493
505
  case "tunnel": {
494
- const sub = need(p[0], "tunnel create|status|rm");
506
+ const sub = need(p[0], "tunnel create|zones|status|rm");
495
507
  if (p.length > 1) throw new CliError(`tunnel ${sub} takes no arguments (${p.slice(1).join(" ")}). Run \`${CLI} tunnel --help\`.`);
496
508
  return tun.tunnel(sub, o);
497
509
  }
package/lib/tunnel.mjs CHANGED
@@ -22,7 +22,7 @@ const step = (s) => console.error(`==> ${s}`);
22
22
  export const ORIGIN_DEFAULTS = {
23
23
  "openclaw-wake": ["http://127.0.0.1:18789", "/hooks/wake"],
24
24
  "openclaw-agent": ["http://127.0.0.1:18789", "/hooks/agent"],
25
- hermes: ["http://127.0.0.1:8644", ""], // path is /webhooks/<subscription name>: pass --tunnel-path
25
+ hermes: ["http://127.0.0.1:8644", "/webhooks/a2a-wake"],
26
26
  generic: ["", ""],
27
27
  };
28
28
  export const HOSTED_PRESETS = ["grok-bot", "claude-code"];
@@ -99,7 +99,7 @@ export function pickTunnelHostname({ tunnelHostname, tunnelZone, inboxHost, zone
99
99
  return die([
100
100
  `this Cloudflare account has ${usable.length} zones; choose one for the wake hostname (the A2A endpoint URL is unchanged):`,
101
101
  zoneList(usable),
102
- "Re-run with --tunnel-zone <zone> (or --tunnel-hostname wake-<name>.<zone>).",
102
+ `List choices with \`${CLI} tunnel zones --json\`. Re-run with --tunnel-zone <zone> (or --tunnel-hostname wake-<name>.<zone>).`,
103
103
  ].join("\n"));
104
104
  }
105
105
 
@@ -281,9 +281,13 @@ export function preflight(o) {
281
281
  if (HOSTED_PRESETS.includes(preset)) die(`preset ${preset} wakes a hosted service; a tunnel is only for local-only webhooks (openclaw-*, hermes, generic)`);
282
282
  const [dOrigin, dPath] = ORIGIN_DEFAULTS[preset] || ["", ""];
283
283
  const origin = o["tunnel-origin"] || C.get("A2A_TUNNEL_ORIGIN") || dOrigin;
284
- const wpath = o["tunnel-path"] || C.get("A2A_TUNNEL_PATH") || dPath;
284
+ if (o["hermes-route"] !== undefined && (preset !== "hermes" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(o["hermes-route"])))
285
+ die("--hermes-route requires --preset hermes and a route name of 1–64 letters, digits, underscores or hyphens");
286
+ if (o["hermes-route"] && o["tunnel-path"]) die("choose --hermes-route or --tunnel-path, not both");
287
+ if (o["hmac-secret-stdin"] && preset !== "hermes") die("--hmac-secret-stdin requires --preset hermes");
288
+ const wpath = o["hermes-route"] ? `/webhooks/${o["hermes-route"]}` : o["tunnel-path"] || C.get("A2A_TUNNEL_PATH") || dPath;
285
289
  if (!origin) die(`--tunnel-origin is required for preset ${preset} (the local webhook, e.g. http://127.0.0.1:8080)`);
286
- if (!wpath || !wpath.startsWith("/")) die(`--tunnel-path is required for preset ${preset} and must start with / (e.g. /webhooks/a2a for Hermes)`);
290
+ if (!wpath || !wpath.startsWith("/")) die(`--tunnel-path is required for preset ${preset} and must start with / (e.g. /webhooks/a2a-wake for Hermes)`);
287
291
  if (o["tunnel-hostname"] && !HOST_RE.test(o["tunnel-hostname"])) die("--tunnel-hostname must be a hostname like wake-abc.example.com");
288
292
  if (o["tunnel-zone"] && !HOST_RE.test(o["tunnel-zone"].toLowerCase())) die("--tunnel-zone must be a zone name like example.com");
289
293
  if (o["tunnel-zone"] && o["tunnel-hostname"]) die("pass --tunnel-zone or --tunnel-hostname, not both");
@@ -292,10 +296,18 @@ export function preflight(o) {
292
296
  }
293
297
 
294
298
  export async function create(o) {
295
- D.checkNode();
296
299
  const dir = needDeployment(o);
297
- if (tunnelStarted()) return resume(dir, o);
300
+ D.checkNode();
301
+ if (tunnelStarted() && !tunnelComplete()) return resume(dir, o, {});
298
302
  const { preset, origin, wpath } = preflight(o);
303
+ if (o.preset && o.preset !== C.get("WAKE_PRESET", "generic"))
304
+ die(`the deployed preset must match --preset ${preset}; run \`${CLI} wake set --preset ${preset}\` first`);
305
+ const agentAuth = tunnelAuth(o);
306
+ if (tunnelStarted()) {
307
+ if (tunnelComplete() && ((o["tunnel-origin"] && origin !== C.get("A2A_TUNNEL_ORIGIN")) || ((o["tunnel-path"] || o["hermes-route"]) && wpath !== C.get("A2A_TUNNEL_PATH"))))
308
+ die("a tunnel already exists with a different origin/path; run tunnel rm before changing it");
309
+ return resume(dir, o, agentAuth);
310
+ }
299
311
  const worker = D.workerName();
300
312
  try {
301
313
  const preview = await owner("GET", "/owner/wake/preview");
@@ -346,19 +358,20 @@ export async function create(o) {
346
358
 
347
359
  const url = wakeUrl(hostname, wpath);
348
360
  step("uploading Worker secrets: WAKE_WEBHOOK_URL, WAKE_ACCESS_CLIENT_ID, WAKE_ACCESS_CLIENT_SECRET" +
349
- (process.env.WAKE_WEBHOOK_KEY ? ", WAKE_WEBHOOK_KEY" : "") + (process.env.WAKE_HMAC_SECRET ? ", WAKE_HMAC_SECRET" : ""));
361
+ (process.env.WAKE_WEBHOOK_KEY ? ", WAKE_WEBHOOK_KEY" : "") + (agentAuth.WAKE_HMAC_SECRET ? ", WAKE_HMAC_SECRET" : ""));
350
362
  const secrets = { WAKE_WEBHOOK_URL: url, WAKE_ACCESS_CLIENT_ID: st.client_id, WAKE_ACCESS_CLIENT_SECRET: st.client_secret };
351
- for (const k of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[k]) secrets[k] = process.env[k];
363
+ Object.assign(secrets, agentAuth);
352
364
  await putSecrets(dir, worker, secrets);
353
365
  createdSecrets = Object.keys(secrets);
354
366
 
367
+ if (preset === "hermes" && /^\/webhooks\/[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(wpath)) console.error(hermesInstructions(wpath.slice("/webhooks/".length)));
355
368
  console.log(url);
356
369
  console.error(`\nAccess application "${label}" (id ${app.id}); service token id ${st.id}; tunnel id ${tun.id}.`);
357
370
  console.error(`tunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
358
371
  console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
359
372
  console.error(`\nTo undo this, run \`${CLI} tunnel rm\`.`);
360
373
  console.error(`Then (Worker secrets apply within about 15 to 40 seconds; wait about 30 seconds): ${CLI} tunnel status and ${CLI} wake test`);
361
- if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
374
+ if (!Object.keys(agentAuth).length && preset !== "generic")
362
375
  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\`)`);
363
376
  } catch (e) {
364
377
  console.error("==> rolling back what was created");
@@ -367,6 +380,31 @@ export async function create(o) {
367
380
  }
368
381
  }
369
382
 
383
+ /** No prompt, argv secret, or config.env persistence. */
384
+ function tunnelAuth(o) {
385
+ const auth = {};
386
+ for (const key of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[key]) auth[key] = process.env[key];
387
+ if (o["hmac-secret-stdin"]) {
388
+ if (process.stdin.isTTY) die("--hmac-secret-stdin requires a protected file redirected to stdin; terminal prompts are not supported");
389
+ const secret = fs.readFileSync(0, "utf8").replace(/\r?\n$/, "");
390
+ if (!secret || secret.length > 4096 || /[\u0000-\u001f\u007f]/.test(secret))
391
+ die("--hmac-secret-stdin requires one non-empty line of at most 4096 printable characters");
392
+ auth.WAKE_HMAC_SECRET = secret;
393
+ }
394
+ return auth;
395
+ }
396
+
397
+ export function hermesInstructions(route) {
398
+ return [
399
+ "Hermes receiver (run subscribe once; keep its generated secret in protected storage):",
400
+ ` hermes cron create "every 6h" "Use the a2a-exposed skill. Run '${CLI} inbox'; follow the owner's handling mode and approval rules. Report peer, task and outcome without peer text or secrets; if empty, respond only [SILENT]." --skill a2a-exposed --name a2a-inbox --deliver origin`,
401
+ ` hermes webhook subscribe ${route} --events a2a_wake --cron-job a2a-inbox --prompt "A2A wake: {kind} from {from}, context {contextId}. Run: {hint}"`,
402
+ "Do not recreate an existing subscription: that can change its signing secret.",
403
+ `After starting the connector: ${CLI} wake check`,
404
+ "Recipe: https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/hermes.md",
405
+ ].join("\n");
406
+ }
407
+
370
408
  /** Download the tunnel's connector token into <config dir>/tunnel-token (chmod 600) and save its path. */
371
409
  function fetchTokenFile(dir, tunnelId) {
372
410
  const tok = cfCall(dir, ["tunnels", "token", "get", tunnelId]);
@@ -396,7 +434,7 @@ export const tunnelComplete = () =>
396
434
  * are re-uploaded if the Worker lacks them (e.g. the first run was interrupted), together with WAKE_WEBHOOK_KEY /
397
435
  * WAKE_HMAC_SECRET when exported (as on a first run); a missing connector token file is downloaded again. A partial
398
436
  * tunnel must be removed first. */
399
- async function resume(dir, o) {
437
+ async function resume(dir, o, agentAuth) {
400
438
  if (!tunnelComplete())
401
439
  die(`a previous \`tunnel create\` did not finish (some ids are saved, see \`${CLI} status\`); run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\` again`);
402
440
  const host = C.get("A2A_TUNNEL_HOSTNAME");
@@ -409,8 +447,6 @@ async function resume(dir, o) {
409
447
  const url = wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/");
410
448
  let p = null;
411
449
  try { p = await owner("GET", "/owner/wake/preview"); } catch (e) { console.error(`warning: Worker not reachable (${e.message}); cannot check its wake secrets`); }
412
- const agentAuth = {};
413
- for (const k of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[k]) agentAuth[k] = process.env[k];
414
450
  const lacksTunnel = p && (!p.hasAccessServiceToken || p.fingerprints?.url !== fingerprint(url));
415
451
  if (lacksTunnel || Object.keys(agentAuth).length) {
416
452
  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 };
@@ -420,6 +456,7 @@ async function resume(dir, o) {
420
456
  const preset = C.get("WAKE_PRESET", "generic");
421
457
  if (p && !p.hasKey && !p.hasHmacSecret && !Object.keys(agentAuth).length && preset !== "generic")
422
458
  console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`${CLI} tunnel create\` again`);
459
+ if (preset === "hermes" && /^\/webhooks\/[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(C.get("A2A_TUNNEL_PATH"))) console.error(hermesInstructions(C.get("A2A_TUNNEL_PATH").slice("/webhooks/".length)));
423
460
  console.log(url);
424
461
  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)`);
425
462
  console.error(connectorInstructions(C.get("A2A_TUNNEL_TOKEN_FILE")));
@@ -543,8 +580,12 @@ export async function remove(o, { quiet = false, allowMissingProject = false, sk
543
580
  }
544
581
 
545
582
  export async function tunnel(sub, o) {
583
+ if (sub === "zones") {
584
+ const zones = usableZones(listZones(needDeployment(o))).map(({ id, name, status }) => ({ id, name, status: status || "active" }));
585
+ return console.log(o.json ? JSON.stringify(zones, null, 2) : zoneList(zones) || "No active zones; use polling.");
586
+ }
546
587
  if (sub === "create") return create(o);
547
588
  if (sub === "status") return status(o);
548
589
  if (sub === "rm") return remove(o);
549
- die("usage: tunnel create|status|rm");
590
+ die("usage: tunnel create|zones|status|rm");
550
591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.40",
3
+ "version": "0.8.41",
4
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": {
@@ -28,7 +28,7 @@ const plain: Record<string, string> = {
28
28
  PROVIDER_ORGANIZATION: v("A2A_PROVIDER_ORGANIZATION"),
29
29
  PROVIDER_URL: v("A2A_PROVIDER_URL"),
30
30
  DOCUMENTATION_URL: v("A2A_DOCUMENTATION_URL"),
31
- WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
31
+ WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | mausbot | generic
32
32
  WAKE_AGENT_ID: v("WAKE_AGENT_ID"), // openclaw presets (default "main")
33
33
  WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
34
34
  WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
@@ -7,7 +7,7 @@
7
7
  import { VERSION } from "./version.ts";
8
8
  import * as A from "./a2a.ts";
9
9
  import * as F from "./facade.ts";
10
- import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultBusySeconds, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
10
+ import { renderWake, mausbotConfigProblem, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultBusySeconds, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
11
11
  import { fetchCheckedPeer, fetchCheckedWake } from "./wake-target.ts";
12
12
  import { withDeadline } from "./deadline.ts";
13
13
  import * as Addr from "./address.ts";
@@ -397,7 +397,8 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
397
397
  }
398
398
 
399
399
  async function wake(ctx: TenantContext, ectx: ExecutionContext, contextId: string, taskId: string, from: string, preview: string, kind = "inbound") {
400
- const payload: Json = { contextId, taskId, taskIds: [taskId], from, preview: preview.slice(0, 300), kind };
400
+ const payload: Json = { contextId, taskId, taskIds: [taskId], from, preview: preview.slice(0, 300), kind,
401
+ ...(preset(ctx) === "mausbot" ? { deliveryId: crypto.randomUUID() } : {}) };
401
402
  const now = Date.now(), deb = debounceMs(ctx);
402
403
  let row: Json = await loadWake(ctx, contextId, from);
403
404
  // A pre-peer row (peer '') is this conversation when it names this peer, or names nobody yet.
@@ -2408,6 +2409,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2408
2409
  preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
2409
2410
  if (preset(ctx) === "openbot" && !ctx.WAKE_HMAC_SECRET?.trim())
2410
2411
  return json({ error: "OpenBot requires WAKE_HMAC_SECRET from the routine settings" }, 400);
2412
+ const mausbotProblem = mausbotConfigProblem(wakeConfig(ctx));
2413
+ if (mausbotProblem) return json({ error: mausbotProblem }, 400);
2411
2414
  const req = await renderWake(wakeConfig(ctx), ev, { requestId: "preview" });
2412
2415
  return json({ preset: preset(ctx), configured: !!req, debounceSeconds: debounceMs(ctx) / 1000, busySeconds: busySecondsReported(ctx, ectx), maxPerHour: maxPerHour(ctx),
2413
2416
  hasKey: !!ctx.WAKE_WEBHOOK_KEY, hasHmacSecret: !!ctx.WAKE_HMAC_SECRET,
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.40";
2
+ export const VERSION = "0.8.41";
@@ -10,6 +10,7 @@ export type WakeEvent = {
10
10
  preview: string;
11
11
  kind: string; // "inbound" | "outbound_update" | "test" | "pairing_request"
12
12
  publicUrl: string;
13
+ deliveryId?: string; // MausBot wake identity, retained by Retry-After retries
13
14
  pairing?: PairingInfo; // kind "pairing_request"
14
15
  };
15
16
 
@@ -48,7 +49,7 @@ export type WakeConfig = {
48
49
 
49
50
  export type WakeRequest = { url: string; method: "POST"; headers: Record<string, string>; body: string };
50
51
 
51
- export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"] as const;
52
+ export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "mausbot", "generic"] as const;
52
53
 
53
54
  /** Default debounce window per preset (seconds), used when WAKE_DEBOUNCE_SECONDS is unset. */
54
55
  export function defaultDebounceSeconds(preset: string): number {
@@ -190,9 +191,24 @@ async function hermesSign(headers: Record<string, string>, secret: string, body:
190
191
  headers["X-Webhook-Signature-V2"] = await hmacSha256Hex(secret, `${ts}.${body}`);
191
192
  }
192
193
 
193
- /** Build the HTTP request, return null without a URL, or throw when OpenBot signing is missing. */
194
+ /** MausBot uses header auth, never a capability URL containing a secret. */
195
+ export function mausbotConfigProblem(cfg: WakeConfig): string {
196
+ if (cfg.preset !== "mausbot" || !cfg.url) return "";
197
+ try {
198
+ const url = new URL(cfg.url);
199
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^\/hooks\/wh_[A-Za-z0-9_-]+$/.test(url.pathname))
200
+ return "MausBot requires an HTTPS /hooks/<endpointId> URL without credentials, query or fragment; use WAKE_WEBHOOK_KEY for Bearer auth";
201
+ } catch { return "MausBot requires an HTTPS /hooks/<endpointId> URL"; }
202
+ if (!cfg.key?.trim() || cfg.key.length > 4096 || /[\u0000-\u001f\u007f]/.test(cfg.key))
203
+ return "MausBot requires WAKE_WEBHOOK_KEY from protected environment/secret storage";
204
+ return "";
205
+ }
206
+
207
+ /** Build the HTTP request, return null without a URL, or throw when required preset auth/config is missing. */
194
208
  export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?: number; requestId?: string } = {}): Promise<WakeRequest | null> {
195
209
  if (!cfg.url) return null;
210
+ const mausbotProblem = mausbotConfigProblem(cfg);
211
+ if (mausbotProblem) throw new Error(mausbotProblem);
196
212
  if (cfg.preset === "openbot" && !cfg.hmacSecret?.trim()) throw new Error("OpenBot requires WAKE_HMAC_SECRET from the routine settings");
197
213
  const cli = cfg.cliCommand || DEFAULT_CLI;
198
214
  const nowMs = opts.nowMs ?? Date.now();
@@ -221,6 +237,12 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
221
237
  // `name` is the hook name OpenClaw filters match. It stays "A2A inbox". The wake text itself starts with "A2A endpoint:".
222
238
  body = JSON.stringify({ message: wakeSummary(ev, cli), name: "A2A inbox", agentId: cfg.agentId || "main", sessionMode: "isolated", deliver: false });
223
239
  break;
240
+ case "mausbot":
241
+ bearer();
242
+ // A task can wake more than once. Retry the same wake identity, not every update on that task.
243
+ headers["idempotency-key"] = `${ev.taskId}:${ev.deliveryId || requestId}`;
244
+ body = JSON.stringify(wakePayload(ev, cli));
245
+ break;
224
246
  case "openbot": {
225
247
  const ts = String(Math.floor(nowMs / 1000));
226
248
  headers["x-openbot-timestamp"] = ts;
@@ -127,3 +127,45 @@ test("unsigned OpenBot owner test/preview and inbound wake never contact the web
127
127
  assert.equal(message.body.error, undefined, "stored inbox message succeeds without a wake");
128
128
  assert.equal(calls, 0);
129
129
  });
130
+
131
+
132
+ test("MausBot retries keep their key, later messages and verification tests get new keys", async (t) => {
133
+ const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
134
+ WAKE_PRESET: "mausbot", WAKE_WEBHOOK_URL: "https://hooks.example.com/hooks/wh_test", WAKE_WEBHOOK_KEY: "dummy-secret", WAKE_DEBOUNCE_SECONDS: "0", WAKE_BUSY_SECONDS: "0" };
135
+ const sent: any[] = [];
136
+ let answer = 429;
137
+ const original = globalThis.fetch;
138
+ globalThis.fetch = (async (_url: any, init: any) => { sent.push(init); return new Response("{}", { status: answer, headers: { "retry-after": "30" } }); }) as typeof fetch;
139
+ t.after(() => { globalThis.fetch = original; });
140
+ const call = async (method: string, path: string, body?: any, auth = "Bearer owner-secret") => {
141
+ const pending: Promise<unknown>[] = [];
142
+ const response = await worker.fetch(new Request(BASE + path, { method, headers: { authorization: auth, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }) as any,
143
+ env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
144
+ await Promise.allSettled(pending);
145
+ return { status: response.status, body: await response.json() as any };
146
+ };
147
+ const preview = await call("GET", "/owner/wake/preview");
148
+ assert.equal(preview.status, 200);
149
+ assert.equal(preview.body.request.headers.authorization, "Bearer ••••");
150
+ assert.ok(!JSON.stringify(preview.body).includes(env.WAKE_WEBHOOK_KEY));
151
+ const peer = await call("POST", "/owner/peers", { label: "maus-test" });
152
+ const msg = (id: string, taskId?: string) => call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: id, contextId: "ctx-maus", ...(taskId ? { taskId } : {}), parts: [{ kind: "text", text: "same text" }] } } }, `Bearer ${peer.body.token}`);
153
+ const first = await msg("m1");
154
+ assert.equal(sent.length, 1);
155
+ answer = 202;
156
+ // Advance only the saved retry deadline, without changing the global clock.
157
+ await env.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ?").bind(Date.now() - 1000, "ctx-maus").run();
158
+ await call("GET", "/health");
159
+ assert.equal(sent.length, 2);
160
+ assert.equal(sent[0].headers["idempotency-key"], sent[1].headers["idempotency-key"]);
161
+ await msg("m2", first.body.result.id);
162
+ assert.equal(sent.length, 3);
163
+ assert.notEqual(sent[2].headers["idempotency-key"], sent[1].headers["idempotency-key"]);
164
+ await call("POST", "/owner/wake/test");
165
+ await call("POST", "/owner/wake/test");
166
+ assert.notEqual(sent[3].headers["idempotency-key"], sent[4].headers["idempotency-key"]);
167
+ delete env.WAKE_WEBHOOK_KEY;
168
+ assert.equal((await call("GET", "/owner/wake/preview")).status, 400);
169
+ assert.equal((await call("POST", "/owner/wake/test")).body.status, null);
170
+ assert.equal(sent.length, 5, "no unsigned request leaves the Worker");
171
+ });
@@ -106,7 +106,7 @@ test("generic: invalid template throws", () => {
106
106
 
107
107
  test("every preset renders valid JSON", async () => {
108
108
  for (const p of PRESETS) {
109
- const r = (await renderWake({ preset: p, url: "https://x/y", key: "k", hmacSecret: "s" }, ev))!;
109
+ const r = (await renderWake({ preset: p, url: "https://x/hooks/wh_test", key: "k", hmacSecret: "s" }, ev))!;
110
110
  JSON.parse(r.body);
111
111
  }
112
112
  });
@@ -151,7 +151,7 @@ const ACCESS = { accessClientId: "abc123.access", accessClientSecret: "s3cr3t-va
151
151
 
152
152
  test("Access service-token headers are added for every preset, next to the preset's own auth", async () => {
153
153
  for (const preset of PRESETS) {
154
- const r = (await renderWake({ preset, url: "https://wake-x.example.com/hooks/wake", key: "k1", hmacSecret: "h1", ...ACCESS }, ev))!;
154
+ const r = (await renderWake({ preset, url: "https://wake-x.example.com/hooks/wh_test", key: "k1", hmacSecret: "h1", ...ACCESS }, ev))!;
155
155
  assert.equal(r.headers["CF-Access-Client-Id"], "abc123.access", preset);
156
156
  assert.equal(r.headers["CF-Access-Client-Secret"], "s3cr3t-value-0123456789", preset);
157
157
  if (preset === "hermes") assert.ok(r.headers["X-Webhook-Signature-V2"], "hermes keeps its HMAC");
@@ -227,3 +227,36 @@ test("OpenBot assigns a fresh delivery ID to every rendered attempt", async () =
227
227
  const second = (await renderWake(cfg, ev))!;
228
228
  assert.notEqual(first.headers["x-openbot-delivery-id"], second.headers["x-openbot-delivery-id"]);
229
229
  });
230
+
231
+
232
+ test("mausbot: Bearer JSON, bounded peer preview, stable delivery identity and masked credentials", async () => {
233
+ const cfg = { preset: "mausbot", url: "https://hooks.example.com/hooks/wh_example", key: "dummy-mausbot-secret" };
234
+ const event = { ...ev, preview: '雪 "quoted"\n'.repeat(100), deliveryId: "wake-1" };
235
+ const first = (await renderWake(cfg, event, { requestId: "attempt-1" }))!;
236
+ const retry = (await renderWake(cfg, event, { requestId: "attempt-2" }))!;
237
+ assert.equal(first.headers.authorization, "Bearer dummy-mausbot-secret");
238
+ assert.equal(first.headers["content-type"], "application/json");
239
+ assert.equal(first.headers["idempotency-key"], "t-2:wake-1");
240
+ assert.equal(retry.headers["idempotency-key"], first.headers["idempotency-key"]);
241
+ assert.deepEqual(JSON.parse(first.body), wakePayload(event, "npx -y a2a-exposed@latest"));
242
+ assert.equal(JSON.parse(first.body).preview.length, 300);
243
+ assert.equal(JSON.parse(first.body).deliveryId, undefined);
244
+ assert.equal(redact(first).headers.authorization, "Bearer ••••");
245
+ assert.ok(!JSON.stringify(redact(first)).includes(cfg.key));
246
+ const update = (await renderWake(cfg, { ...event, kind: "outbound_update", deliveryId: "wake-2" }))!;
247
+ assert.notEqual(update.headers["idempotency-key"], first.headers["idempotency-key"]);
248
+ assert.match(JSON.parse(update.body).hint, /history ctx-1 --from peer-a/);
249
+ const test1 = (await renderWake(cfg, { ...ev, kind: "test", taskId: "none" }))!;
250
+ const test2 = (await renderWake(cfg, { ...ev, kind: "test", taskId: "none" }))!;
251
+ assert.notEqual(test1.headers["idempotency-key"], test2.headers["idempotency-key"], "verification test must not suppress the enabled test");
252
+ });
253
+
254
+ test("mausbot: rejects unsafe URLs, capability credentials and missing Bearer auth without echoing input", async () => {
255
+ for (const url of ["http://example.com/hooks/wh_test", "https://user:secret@example.com/hooks/wh_test", "https://example.com/hooks/wh_test/capability-secret", "https://example.com/hooks/wh_test?secret=dummy", "https://example.com/hooks/wh_test#dummy", "not-a-url", "https://example.com/api"]) {
256
+ await assert.rejects(renderWake({ preset: "mausbot", url, key: "dummy" }, ev), error => error instanceof Error && /MausBot requires an HTTPS/.test(error.message) && !error.message.includes(url));
257
+ }
258
+ for (const key of [undefined, "", " ", "line\nbreak"]) {
259
+ await assert.rejects(renderWake({ preset: "mausbot", url: "https://example.com/hooks/wh_test", key, hmacSecret: "cannot-replace-bearer" }, ev), /MausBot requires WAKE_WEBHOOK_KEY/);
260
+ }
261
+ assert.equal(await renderWake({ preset: "mausbot" }, ev), null);
262
+ });