a2a-exposed 0.8.40 → 0.8.42

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/status.mjs CHANGED
@@ -2,11 +2,14 @@
2
2
  // calls only), so an agent that was interrupted can resume from the printed next step. The CLI fetches the agent card
3
3
  // itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
4
4
  import dns from "node:dns";
5
+ import { spawnSync } from "node:child_process";
5
6
  import fs from "node:fs";
6
7
  import { isIP } from "node:net";
7
8
  import path from "node:path";
9
+ import os from "node:os";
8
10
  import { promisify } from "node:util";
9
11
  import * as C from "./config.mjs";
12
+ import { runtimeInfo } from "./runtime.mjs";
10
13
  import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson, isPrivateHost } from "./a2a.mjs";
11
14
  import { baseUrl, owner, ownerTry } from "./commands.mjs";
12
15
  import * as D from "./deploy.mjs";
@@ -14,6 +17,44 @@ import { buildLayers, layerValue, usablePin } from "./layers.mjs";
14
17
  import * as T from "./tunnel.mjs";
15
18
  import { cacheFile, readCache, newer } from "./update.mjs";
16
19
 
20
+ /** Best-effort local check only. A terminal-attached process using this exact token file is ephemeral.
21
+ * Never return process arguments (they may contain credentials), or infer persistence from remote connections. */
22
+ export function connectorPersistence(tokenFile, { platform = process.platform, run = spawnSync, exists = fs.existsSync, home = os.homedir() } = {}) {
23
+ if (!["darwin", "linux"].includes(platform) || !tokenFile) return "unknown";
24
+ try {
25
+ const ps = run("ps", ["-axo", "tty=,comm=,args="], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 });
26
+ if (ps.status !== 0 || ps.error) return "unknown";
27
+ const fileForms = [tokenFile, C.shellQuote(tokenFile), '"' + tokenFile.replaceAll('"', '\\"') + '"'];
28
+ const foreground = ps.stdout.split("\n").some((line) => {
29
+ const row = line.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/);
30
+ if (!row || row[1] === "-" || row[1].startsWith("?")) return false;
31
+ if (path.basename(row[2]) !== "cloudflared" || !/\btunnel\s+run\b/.test(row[3])) return false;
32
+ return ["--token-file ", "--token-file="].some((flag) => fileForms.some((file) =>
33
+ row[3].endsWith(flag + file) || row[3].includes(flag + file + " --")));
34
+ });
35
+ if (!foreground) return "unknown";
36
+ const serviceFiles = platform === "darwin"
37
+ ? ["/Library/LaunchDaemons/com.cloudflare.cloudflared.plist", "/Library/LaunchAgents/com.cloudflare.cloudflared.plist", path.join(home, "Library/LaunchAgents/com.cloudflare.cloudflared.plist")]
38
+ : ["/etc/systemd/system/cloudflared.service", "/usr/lib/systemd/system/cloudflared.service", path.join(home, ".config/systemd/user/cloudflared.service")];
39
+ if (serviceFiles.some(exists)) return "unknown";
40
+ const service = platform === "darwin"
41
+ ? run("launchctl", ["list"], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 })
42
+ : run("systemctl", ["list-unit-files", "--type=service", "--no-pager", "--no-legend"], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 });
43
+ if (service.status !== 0 || service.error || /cloudflared/i.test(service.stdout)) return "unknown";
44
+ if (platform === "linux") {
45
+ const user = run("systemctl", ["--user", "list-unit-files", "--type=service", "--no-pager", "--no-legend"], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 });
46
+ if (user.status !== 0 || user.error || /cloudflared/i.test(user.stdout)) return "unknown";
47
+ }
48
+ // This describes the observed terminal process, not every possible custom service.
49
+ return "ephemeral";
50
+ } catch { return "unknown"; }
51
+ }
52
+
53
+ export function connectorLine(t) {
54
+ const state = t.connections == null ? "unknown" : t.connections > 0 ? `connected (${t.connections})` : "not connected (0)";
55
+ return `${state}, ${t.persistence === "ephemeral" ? "ephemeral (local terminal connector; no cloudflared service found)" : "persistence unknown"}`;
56
+ }
57
+
17
58
  const DOH_URL = "https://cloudflare-dns.com/dns-query";
18
59
  const LOOKUP_MS = 5000;
19
60
 
@@ -284,7 +325,7 @@ export function nextStep(s) {
284
325
  const n = nextStepCore(s);
285
326
  const version = s.cliVersion || D.VERSION;
286
327
  if (s.deployment !== "hosted" && s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
287
- n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
328
+ n.also.push(`a2a-exposed ${s.latestVersion} is available: run ${CLI} deploy`);
288
329
  const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
289
330
  const addr = s.addresses;
290
331
  if (addr && Array.isArray(addr.announcePending) && addr.announcePending.length)
@@ -362,7 +403,7 @@ function nextStepCore(s) {
362
403
  if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
363
404
  also.push(`peers can't connect yet: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal); never set it yourself`);
364
405
  if (s.pairing && s.pairing.pending) also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
365
- return done(`none: the façade is up and forwards paired peers' A2A calls to the upstream. Peers connect with \`${CLI} connect ${s.baseUrl}\``, also);
406
+ return done(`none: the façade is up and forwards paired peers' A2A calls to the upstream. Peers connect with \`${C.DEFAULT_CLI_COMMAND} connect ${s.baseUrl}\``, also);
366
407
  }
367
408
  const t = s.tunnel;
368
409
  if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
@@ -402,7 +443,7 @@ function nextStepCore(s) {
402
443
  return done2(`no wake webhook, so ${poll}.`, [`optional: ${tunnel}`, ...(pub ? [`optional: ${pub}`] : [])]);
403
444
  }
404
445
  return done2(`none: setup is complete. Check a real wake with \`${CLI} wake test\`.`,
405
- [`peers connect with \`${CLI} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
446
+ [`peers connect with \`${C.DEFAULT_CLI_COMMAND} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
406
447
  }
407
448
 
408
449
  function hostOf(url) {
@@ -465,7 +506,7 @@ function finishLayers(s, extra) {
465
506
  * `net.lookup` and `net.doh` replace the system resolver and DNS-over-HTTPS in tests. */
466
507
  export async function collect(net = {}) {
467
508
  const s = {
468
- configFile: C.CONFIG_FILE,
509
+ configFile: C.CONFIG_FILE, runtime: runtimeInfo(),
469
510
  deployment: C.deploymentKind(),
470
511
  deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
471
512
  worker: C.get("A2A_WORKER_NAME"), d1: C.get("A2A_D1_NAME"), account: C.get("CLOUDFLARE_ACCOUNT_ID"), cfProfile: C.get("CF_PROFILE"),
@@ -535,7 +576,8 @@ export async function collect(net = {}) {
535
576
  const url = T.wakeUrl(tunnelHost || "?", C.get("A2A_TUNNEL_PATH") || "/");
536
577
  s.tunnel = { hostname: tunnelHost, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
537
578
  tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
538
- secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
579
+ secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null,
580
+ persistence: connectorPersistence(C.get("A2A_TUNNEL_TOKEN_FILE")) };
539
581
  if (haveCf && C.get("A2A_TUNNEL_ID")) {
540
582
  try {
541
583
  const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
@@ -609,6 +651,7 @@ export async function status(o) {
609
651
  else {
610
652
  const rows = [
611
653
  ["config", s.configFile],
654
+ ["runtime", `Node ${s.runtime.node} (${s.runtime.execPath})${s.runtime.warning ? `; WARNING: ${s.runtime.warning}` : ""}`],
612
655
  ["deployment", s.deployment === "hosted" ? "hosted" : s.deployed ? `Worker ${s.worker}, D1 ${s.d1 || s.worker}${s.account ? `, account ${s.account}` : ""}${s.cfProfile ? `, cf profile ${s.cfProfile}` : ""}${s.cron ? ", cron flush on" : ""}` : "none"],
613
656
  ["base URL", s.baseUrl ? `${s.baseUrl} (${s.baseUrlEnv ? `from the exported A2A_BASE_URL; ${s.configFile} has ${s.baseUrlSaved || "none"}` : s.urlKind})` : "(not saved)"],
614
657
  ];
@@ -636,6 +679,7 @@ export async function status(o) {
636
679
  const t = s.tunnel;
637
680
  rows.push(["tunnel", !t ? "none" : `${t.wakeUrl} -> ${t.origin || "?"}; ${!t.complete ? "INCOMPLETE" : t.tokenFileOk ? "set up" : "set up, but the connector token file is MISSING"}` +
638
681
  (t.connections != null ? `; ${tunnelStateLabel(t.state)}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
682
+ if (t) rows.push(["connector", connectorLine(t)]);
639
683
  if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
640
684
  const addr = s.addresses;
641
685
  if (addr) {
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
 
@@ -156,7 +156,9 @@ export function connectorInstructions(tokenFile, token = "") {
156
156
  "Run the connector there (outbound TCP/UDP 7844 to Cloudflare must be allowed):",
157
157
  ` cloudflared tunnel run --token-file ${tokenFile} # cloudflared 2025.4+`,
158
158
  ` cloudflared tunnel run --token ${t}`,
159
- ` sudo cloudflared service install ${t} # as a system service (starts on boot)`,
159
+ ` sudo cloudflared service install ${t} # optional system service (starts on boot)`,
160
+ " On macOS, service install needs an admin password at a terminal; do not run it unattended.",
161
+ " Without admin access, keep the --token-file command in a terminal, or use a user LaunchAgent (untested).",
160
162
  token ? " (token shown because of --show-token: treat it like a password)"
161
163
  : ` The tunnel token is in ${tokenFile} (chmod 600). Copy that file to the agent's machine if it is another machine.`,
162
164
  ].join("\n");
@@ -281,9 +283,13 @@ export function preflight(o) {
281
283
  if (HOSTED_PRESETS.includes(preset)) die(`preset ${preset} wakes a hosted service; a tunnel is only for local-only webhooks (openclaw-*, hermes, generic)`);
282
284
  const [dOrigin, dPath] = ORIGIN_DEFAULTS[preset] || ["", ""];
283
285
  const origin = o["tunnel-origin"] || C.get("A2A_TUNNEL_ORIGIN") || dOrigin;
284
- const wpath = o["tunnel-path"] || C.get("A2A_TUNNEL_PATH") || dPath;
286
+ if (o["hermes-route"] !== undefined && (preset !== "hermes" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(o["hermes-route"])))
287
+ die("--hermes-route requires --preset hermes and a route name of 1–64 letters, digits, underscores or hyphens");
288
+ if (o["hermes-route"] && o["tunnel-path"]) die("choose --hermes-route or --tunnel-path, not both");
289
+ if (o["hmac-secret-stdin"] && preset !== "hermes") die("--hmac-secret-stdin requires --preset hermes");
290
+ const wpath = o["hermes-route"] ? `/webhooks/${o["hermes-route"]}` : o["tunnel-path"] || C.get("A2A_TUNNEL_PATH") || dPath;
285
291
  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)`);
292
+ if (!wpath || !wpath.startsWith("/")) die(`--tunnel-path is required for preset ${preset} and must start with / (e.g. /webhooks/a2a-wake for Hermes)`);
287
293
  if (o["tunnel-hostname"] && !HOST_RE.test(o["tunnel-hostname"])) die("--tunnel-hostname must be a hostname like wake-abc.example.com");
288
294
  if (o["tunnel-zone"] && !HOST_RE.test(o["tunnel-zone"].toLowerCase())) die("--tunnel-zone must be a zone name like example.com");
289
295
  if (o["tunnel-zone"] && o["tunnel-hostname"]) die("pass --tunnel-zone or --tunnel-hostname, not both");
@@ -292,10 +298,18 @@ export function preflight(o) {
292
298
  }
293
299
 
294
300
  export async function create(o) {
295
- D.checkNode();
296
301
  const dir = needDeployment(o);
297
- if (tunnelStarted()) return resume(dir, o);
302
+ D.checkNode();
303
+ if (tunnelStarted() && !tunnelComplete()) return resume(dir, o, {});
298
304
  const { preset, origin, wpath } = preflight(o);
305
+ if (o.preset && o.preset !== C.get("WAKE_PRESET", "generic"))
306
+ die(`the deployed preset must match --preset ${preset}; run \`${CLI} wake set --preset ${preset}\` first`);
307
+ const agentAuth = tunnelAuth(o);
308
+ if (tunnelStarted()) {
309
+ if (tunnelComplete() && ((o["tunnel-origin"] && origin !== C.get("A2A_TUNNEL_ORIGIN")) || ((o["tunnel-path"] || o["hermes-route"]) && wpath !== C.get("A2A_TUNNEL_PATH"))))
310
+ die("a tunnel already exists with a different origin/path; run tunnel rm before changing it");
311
+ return resume(dir, o, agentAuth);
312
+ }
299
313
  const worker = D.workerName();
300
314
  try {
301
315
  const preview = await owner("GET", "/owner/wake/preview");
@@ -346,20 +360,22 @@ export async function create(o) {
346
360
 
347
361
  const url = wakeUrl(hostname, wpath);
348
362
  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" : ""));
363
+ (process.env.WAKE_WEBHOOK_KEY ? ", WAKE_WEBHOOK_KEY" : "") + (agentAuth.WAKE_HMAC_SECRET ? ", WAKE_HMAC_SECRET" : ""));
350
364
  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];
365
+ Object.assign(secrets, agentAuth);
352
366
  await putSecrets(dir, worker, secrets);
353
367
  createdSecrets = Object.keys(secrets);
354
368
 
369
+ if (preset === "hermes" && /^\/webhooks\/[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(wpath)) console.error(hermesInstructions(wpath.slice("/webhooks/".length)));
355
370
  console.log(url);
356
371
  console.error(`\nAccess application "${label}" (id ${app.id}); service token id ${st.id}; tunnel id ${tun.id}.`);
357
372
  console.error(`tunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
358
373
  console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
359
374
  console.error(`\nTo undo this, run \`${CLI} tunnel rm\`.`);
360
375
  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")
376
+ if (!Object.keys(agentAuth).length && preset !== "generic")
362
377
  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\`)`);
378
+ console.error("created: tunnel, DNS, Access app, token\nconnector: not running (start it now)\npersistent service: not installed (optional)");
363
379
  } catch (e) {
364
380
  console.error("==> rolling back what was created");
365
381
  await remove(o, { quiet: true, secretNames: createdSecrets }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
@@ -367,6 +383,31 @@ export async function create(o) {
367
383
  }
368
384
  }
369
385
 
386
+ /** No prompt, argv secret, or config.env persistence. */
387
+ function tunnelAuth(o) {
388
+ const auth = {};
389
+ for (const key of ["WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET"]) if (process.env[key]) auth[key] = process.env[key];
390
+ if (o["hmac-secret-stdin"]) {
391
+ if (process.stdin.isTTY) die("--hmac-secret-stdin requires a protected file redirected to stdin; terminal prompts are not supported");
392
+ const secret = fs.readFileSync(0, "utf8").replace(/\r?\n$/, "");
393
+ if (!secret || secret.length > 4096 || /[\u0000-\u001f\u007f]/.test(secret))
394
+ die("--hmac-secret-stdin requires one non-empty line of at most 4096 printable characters");
395
+ auth.WAKE_HMAC_SECRET = secret;
396
+ }
397
+ return auth;
398
+ }
399
+
400
+ export function hermesInstructions(route) {
401
+ return [
402
+ "Hermes receiver (run subscribe once; keep its generated secret in protected storage):",
403
+ ` 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`,
404
+ ` hermes webhook subscribe ${route} --events a2a_wake --cron-job a2a-inbox --prompt "A2A wake: {kind} from {from}, context {contextId}. Run: {hint}"`,
405
+ "Do not recreate an existing subscription: that can change its signing secret.",
406
+ `After starting the connector: ${CLI} wake check`,
407
+ "Recipe: https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/hermes.md",
408
+ ].join("\n");
409
+ }
410
+
370
411
  /** Download the tunnel's connector token into <config dir>/tunnel-token (chmod 600) and save its path. */
371
412
  function fetchTokenFile(dir, tunnelId) {
372
413
  const tok = cfCall(dir, ["tunnels", "token", "get", tunnelId]);
@@ -396,7 +437,7 @@ export const tunnelComplete = () =>
396
437
  * are re-uploaded if the Worker lacks them (e.g. the first run was interrupted), together with WAKE_WEBHOOK_KEY /
397
438
  * WAKE_HMAC_SECRET when exported (as on a first run); a missing connector token file is downloaded again. A partial
398
439
  * tunnel must be removed first. */
399
- async function resume(dir, o) {
440
+ async function resume(dir, o, agentAuth) {
400
441
  if (!tunnelComplete())
401
442
  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
443
  const host = C.get("A2A_TUNNEL_HOSTNAME");
@@ -409,8 +450,6 @@ async function resume(dir, o) {
409
450
  const url = wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/");
410
451
  let p = null;
411
452
  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
453
  const lacksTunnel = p && (!p.hasAccessServiceToken || p.fingerprints?.url !== fingerprint(url));
415
454
  if (lacksTunnel || Object.keys(agentAuth).length) {
416
455
  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 +459,7 @@ async function resume(dir, o) {
420
459
  const preset = C.get("WAKE_PRESET", "generic");
421
460
  if (p && !p.hasKey && !p.hasHmacSecret && !Object.keys(agentAuth).length && preset !== "generic")
422
461
  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`);
462
+ 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
463
  console.log(url);
424
464
  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
465
  console.error(connectorInstructions(C.get("A2A_TUNNEL_TOKEN_FILE")));
@@ -543,8 +583,12 @@ export async function remove(o, { quiet = false, allowMissingProject = false, sk
543
583
  }
544
584
 
545
585
  export async function tunnel(sub, o) {
586
+ if (sub === "zones") {
587
+ const zones = usableZones(listZones(needDeployment(o))).map(({ id, name, status }) => ({ id, name, status: status || "active" }));
588
+ return console.log(o.json ? JSON.stringify(zones, null, 2) : zoneList(zones) || "No active zones; use polling.");
589
+ }
546
590
  if (sub === "create") return create(o);
547
591
  if (sub === "status") return status(o);
548
592
  if (sub === "rm") return remove(o);
549
- die("usage: tunnel create|status|rm");
593
+ die("usage: tunnel create|zones|status|rm");
550
594
  }
package/lib/update.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // version and caches it; every run only reads that cache (no network call in the foreground). Off with
3
3
  // A2A_NO_UPDATE_CHECK=1, DO_NOT_TRACK=1, NO_UPDATE_NOTIFIER, CI, and when running from a source checkout.
4
4
  import fs from "node:fs";
5
+ import { CLI_COMMAND } from "./config.mjs";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { spawn } from "node:child_process";
@@ -31,7 +32,7 @@ export function newer(a, b) {
31
32
  /** The notice for `current`, given the cache contents (or "" when up to date / unknown). */
32
33
  export function notice(current, cache) {
33
34
  if (!cache || !cache.latest || !newer(cache.latest, current)) return "";
34
- return `a2a-exposed ${cache.latest} is available (you have ${current}): run npx -y a2a-exposed@latest deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
35
+ return `a2a-exposed ${cache.latest} is available (you have ${current}): run ${CLI_COMMAND} deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
35
36
  }
36
37
 
37
38
  export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.40",
3
+ "version": "0.8.42",
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.
@@ -833,7 +834,7 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
833
834
  const directReply = directReplyParams(params);
834
835
  const conf = params.configuration || {};
835
836
  const push = conf.taskPushNotificationConfig || conf.pushNotificationConfig;
836
- const pushCfg = push ? normPushCfg(push, version) : null;
837
+ const pushCfg = push ? { ...normPushCfg(push, version), _role: "message push" } : null;
837
838
  let task: Json = null;
838
839
  if (msg.taskId) {
839
840
  task = await ownedTask(ctx, msg.taskId, label);
@@ -895,7 +896,7 @@ const rpcPushSet: Rpc = async (p, label, version, ctx) => {
895
896
  else if (p.config && typeof p.config === "object") { taskId = p.taskId || String(p.parent || "").split("/").pop(); cfg = p.config; }
896
897
  else { taskId = p.taskId; cfg = { id: p.id, url: p.url, token: p.token, authentication: p.authentication }; }
897
898
  const t = await ownedTask(ctx, taskId, label);
898
- const c = normPushCfg(cfg, version);
899
+ const c = { ...normPushCfg(cfg, version), _role: "task push config" };
899
900
  t._push = [...t._push.filter((x: Json) => x.id !== c.id), c];
900
901
  await saveTaskStmt(ctx, t).run();
901
902
  return pubCfg(t.id, c, version);
@@ -1236,8 +1237,8 @@ async function sendPush(ctx: TenantContext, ectx: ExecutionContext, task: Json,
1236
1237
  try {
1237
1238
  const r = await doFetch(ctx, ectx, cfg.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1238
1239
  return [r.status >= 200 && r.status < 300, `HTTP ${r.status}`];
1239
- } catch (e) {
1240
- return [false, String(e).slice(0, 200)];
1240
+ } catch {
1241
+ return [false, "push request failed"];
1241
1242
  }
1242
1243
  }
1243
1244
 
@@ -1249,8 +1250,9 @@ async function notifyPeer(ctx: TenantContext, ectx: ExecutionContext, t: Json, s
1249
1250
  ? await withDeadline(7000, (step) => sendPush(ctx, ectx, t, cfg, step), signal)
1250
1251
  : await sendPush(ctx, ectx, t, cfg);
1251
1252
  signal?.throwIfAborted();
1252
- results.push({ url: cfg.url, ok, info });
1253
- await histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, event: "push", state: t.status.state, text: `${ok ? "ok" : "FAILED"} ${info}` }).run();
1253
+ const result = { role: cfg._role === "message push" ? "message push" : "task push config", host: new URL(cfg.url).host, fingerprint: await A.fingerprint(cfg.url), ok, info };
1254
+ results.push(result);
1255
+ await histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, event: "push", state: t.status.state, text: `${result.role} ${result.host} [${result.fingerprint}]: ${ok ? "ok" : "FAILED"} ${info}`, data: result }).run();
1254
1256
  }
1255
1257
  return results;
1256
1258
  }
@@ -1263,6 +1265,7 @@ type ReplyDelivery = {
1263
1265
  target?: { alias?: string; host: string };
1264
1266
  httpStatus?: number;
1265
1267
  detail: string;
1268
+ push?: Json[];
1266
1269
  };
1267
1270
 
1268
1271
  const DIRECT_REPLY_META = "a2a.exposed/reply-delivery";
@@ -1694,12 +1697,13 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
1694
1697
  if (task._push?.length) {
1695
1698
  const pushed = await notifyPeer(ctx, ectx, task, signal);
1696
1699
  const failed = pushed.find((x: Json) => !x.ok);
1697
- const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
1700
+ const host = (failed || pushed[0])?.host;
1701
+ const target = host ? { host } : undefined;
1698
1702
  const info = String((failed || pushed[0])?.info || "");
1699
1703
  const http = /^HTTP (\d{3})$/.exec(info);
1700
1704
  const httpStatus = http && http[0] === info ? Number(http[1]) : undefined;
1701
1705
  return {
1702
- status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state,
1706
+ status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state, push: pushed,
1703
1707
  ...(target ? { target } : {}), ...(httpStatus !== undefined && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {}),
1704
1708
  detail: failed ? "A task push failed; the reply remains stored on the task." : "Delivered as the task's existing push update.",
1705
1709
  };
@@ -2122,7 +2126,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2122
2126
  if (seg[2] === "working" && m === "POST") {
2123
2127
  if (A.TERMINAL.has(t.status.state)) return json({ error: `task is already ${t.status.state}` }, 409);
2124
2128
  t.status = { state: "working", timestamp: A.nowIso() };
2125
- await ctx.DB.batch([saveTaskStmt(ctx, t), histStmt(ctx, t.contextId, { dir: "local", taskId: t.id, event: "state", state: "working" })]);
2129
+ await ctx.DB.batch([saveTaskStmt(ctx, t), histStmt(ctx, t.contextId, { dir: "local", peer: t._peer, taskId: t.id, event: "state", state: "working" })]);
2126
2130
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
2127
2131
  return json({ task: ownerTaskView(t), push: await notifyPeer(ctx, ectx, t) });
2128
2132
  }
@@ -2163,11 +2167,14 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2163
2167
  if (raced?.result_json) return json({ task: ownerTaskView(await loadTask(ctx, t.id)), delivery: JSON.parse(raced.result_json), idempotent: true });
2164
2168
  return json({ error: "this reply is already being delivered; retry to read its result" }, 409);
2165
2169
  }
2170
+ const forced = !!b.force && A.TERMINAL.has(t.status.state);
2171
+ const previousState = t.status.state;
2166
2172
  if (state === "completed" || b.artifact) t.artifacts.push({ artifactId: A.newId(), name: b.artifactName || "response", parts: [{ kind: "text", text: b.text }] });
2167
2173
  t.history.push(msg);
2168
2174
  t.status = { state, timestamp: now, message: msg };
2169
2175
  await ctx.DB.batch([saveTaskStmt(ctx, t), msgStmt(ctx, t, msg),
2170
- histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text })]);
2176
+ histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text,
2177
+ ...(forced ? { event: "forced", data: { forced: true, previousState, state } } : {}) })]);
2171
2178
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
2172
2179
  let delivery: ReplyDelivery;
2173
2180
  try { delivery = await withDeadline(18000, (signal) => deliverReplyMessage(ctx, ectx, t, msg, state, b.text, signal)); }
@@ -2191,7 +2198,13 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2191
2198
  if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
2192
2199
  // Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
2193
2200
  // With `from`, one peer's rows only, so another peer's conversation is not in this read.
2194
- const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
2201
+ const rows = await ctx.DB.prepare(`SELECT h.*,
2202
+ (SELECT status_json FROM tasks WHERE id = h.task_id AND context_id = h.context_id
2203
+ AND (h.peer IS NULL OR h.peer = '' OR peer = h.peer)) AS current_status_json,
2204
+ (SELECT json_object('state', state, 'timestamp', ts) FROM history latest
2205
+ WHERE latest.context_id = h.context_id AND latest.task_id = h.task_id AND latest.peer IS h.peer
2206
+ AND latest.state IS NOT NULL ORDER BY latest.seq DESC LIMIT 1) AS current_history_json
2207
+ FROM (${fromFilter ? `SELECT * FROM (
2195
2208
  SELECT * FROM history WHERE context_id = ? AND (
2196
2209
  peer = ?
2197
2210
  OR (
@@ -2212,10 +2225,12 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2212
2225
  AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
2213
2226
  )
2214
2227
  ) ORDER BY seq DESC LIMIT ?
2215
- ) ORDER BY seq`).bind(...(fromFilter
2228
+ ) ORDER BY seq`} ) h ORDER BY h.seq`).bind(...(fromFilter
2216
2229
  ? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
2217
2230
  : [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
2218
- return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
2231
+ return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined,
2232
+ currentState: r.current_status_json ? (({ state, timestamp }) => ({ state, timestamp }))(JSON.parse(r.current_status_json))
2233
+ : r.current_history_json ? JSON.parse(r.current_history_json) : undefined, current_status_json: undefined, current_history_json: undefined })));
2219
2234
  }
2220
2235
  if (seg[0] === "history" && !seg[1] && m === "POST") {
2221
2236
  const b = await body();
@@ -2408,6 +2423,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2408
2423
  preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
2409
2424
  if (preset(ctx) === "openbot" && !ctx.WAKE_HMAC_SECRET?.trim())
2410
2425
  return json({ error: "OpenBot requires WAKE_HMAC_SECRET from the routine settings" }, 400);
2426
+ const mausbotProblem = mausbotConfigProblem(wakeConfig(ctx));
2427
+ if (mausbotProblem) return json({ error: mausbotProblem }, 400);
2411
2428
  const req = await renderWake(wakeConfig(ctx), ev, { requestId: "preview" });
2412
2429
  return json({ preset: preset(ctx), configured: !!req, debounceSeconds: debounceMs(ctx) / 1000, busySeconds: busySecondsReported(ctx, ectx), maxPerHour: maxPerHour(ctx),
2413
2430
  hasKey: !!ctx.WAKE_WEBHOOK_KEY, hasHmacSecret: !!ctx.WAKE_HMAC_SECRET,
package/worker/src/mcp.ts CHANGED
@@ -346,6 +346,10 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
346
346
  status: str("Delivery outcome", { enum: ["delivered", "stored-only", "delivery-failed"] }),
347
347
  method: str("Path used", { enum: ["push", "a2a-message", "stored"] }),
348
348
  target: { type: "object", properties: { alias: str("Paired peer alias"), host: str("Destination host") }, required: ["host"], additionalProperties: false },
349
+ push: { type: "array", items: { type: "object", properties: {
350
+ role: str("Callback origin", { enum: ["task push config", "message push"] }), host: str("Callback host"),
351
+ fingerprint: str("First 12 hex characters of SHA-256 of the callback URL"), ok: { type: "boolean" }, info: str("Safe push result"),
352
+ }, required: ["role", "host", "fingerprint", "ok", "info"], additionalProperties: false } },
349
353
  httpStatus: { type: "integer", minimum: 100, maximum: 599 }, detail: str("Safe delivery summary"),
350
354
  }, required: ["taskId", "state", "status", "method", "detail"], additionalProperties: false } },
351
355
  { name: "send", title: "Send to peer", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -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.42";
@@ -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;
@@ -159,3 +159,70 @@ test("1.0 list pages configs and 0.3 delete returns null", async (t) => {
159
159
  hides(del.text, token);
160
160
  assert.equal(s.logs.some((line) => line.includes(token)), false);
161
161
  });
162
+
163
+ test("push results identify callbacks without URLs, and forced replies preserve transitions", async (t) => {
164
+ const s = setup();
165
+ t.after(s.restore);
166
+ const originalFetch = globalThis.fetch;
167
+ t.after(() => { globalThis.fetch = originalFetch; });
168
+ const attempted: string[] = [];
169
+ globalThis.fetch = (async (url: any) => {
170
+ attempted.push(String(url));
171
+ return new Response(null, { status: String(url).includes("first") ? 401 : 200 });
172
+ }) as typeof fetch;
173
+ const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).token;
174
+ const first = "https://hooks.example.net/private/first?token=first-secret";
175
+ const second = "https://hooks.example.net/private/second?token=second-secret";
176
+ const sent = await s.call(ada, "message/send", {
177
+ message: { kind: "message", role: "user", messageId: crypto.randomUUID(), parts: [{ kind: "text", text: "hello" }] },
178
+ configuration: { pushNotificationConfig: { id: "message", url: first } },
179
+ });
180
+ const { id: taskId, contextId } = sent.data.result;
181
+ await s.call(ada, "tasks/pushNotificationConfig/set", { taskId, pushNotificationConfig: { id: "task", url: second } });
182
+ const working = await s.owner("POST", `/owner/tasks/${taskId}/working`, {});
183
+ assert.deepEqual(working.push.map((r: any) => [r.role, r.host, r.ok, r.info]), [
184
+ ["message push", "hooks.example.net", false, "HTTP 401"], ["task push config", "hooks.example.net", true, "HTTP 200"],
185
+ ]);
186
+ assert.deepEqual(working.push.map((r: any) => r.fingerprint), [(await sha256(first)).slice(0, 12), (await sha256(second)).slice(0, 12)]);
187
+ assert.doesNotMatch(JSON.stringify(working.push), /token=|first-secret|second-secret|private|https:/);
188
+ const done = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "done", state: "completed" });
189
+ assert.deepEqual(done.delivery.push, working.push);
190
+ assert.equal(done.task.status.state, "completed");
191
+ const blocked = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "question", state: "input-required" });
192
+ assert.match(blocked.error, /already completed/);
193
+ const forced = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "question", state: "input-required", force: true });
194
+ assert.equal(forced.task.status.state, "input-required");
195
+ const history = await s.owner("GET", `/owner/history/${contextId}?from=ada`);
196
+ const transitions = history.filter((r: any) => r.event === "forced");
197
+ assert.equal(transitions.length, 1);
198
+ assert.deepEqual(transitions[0].data, { forced: true, previousState: "completed", state: "input-required" });
199
+ assert.ok(history.every((r: any) => r.currentState.state === "input-required"));
200
+ assert.deepEqual(history.at(-1).currentState, { state: "input-required", timestamp: forced.task.status.timestamp });
201
+ assert.doesNotMatch(JSON.stringify(history), /first-secret|second-secret|private/);
202
+ const count = attempted.length;
203
+ const retry = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "question", state: "input-required", force: true });
204
+ assert.equal(retry.idempotent, true);
205
+ assert.equal(attempted.length, count);
206
+ const after = await s.owner("GET", `/owner/history/${contextId}?n=1&from=ada`);
207
+ assert.deepEqual(after[0].currentState, history.at(-1).currentState);
208
+ globalThis.fetch = (async () => { throw new Error(`failed ${first}`); }) as typeof fetch;
209
+ const failed = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "final", state: "completed" });
210
+ assert.doesNotMatch(JSON.stringify(failed.delivery), /first-secret|second-secret|private/);
211
+ assert.equal(failed.delivery.push[0].info, "push request failed");
212
+ });
213
+
214
+ test("limited outbound history reports the latest state for that peer only", async (t) => {
215
+ const s = setup();
216
+ t.after(s.restore);
217
+ const contextId = "outbound-history";
218
+ const taskId = "remote-task";
219
+ await s.owner("POST", "/owner/history", { contextId, taskId, peer: "ada", state: "working", ts: "2026-11-07T01:00:00.000Z" });
220
+ await s.owner("POST", "/owner/history", { contextId, taskId, peer: "bob", state: "failed", ts: "2026-11-07T01:01:00.000Z" });
221
+ await s.owner("POST", "/owner/history", { contextId, taskId, peer: "ada", event: "note", text: "latest note without a state" });
222
+ const rows = await s.owner("GET", `/owner/history/${contextId}?from=ada&n=1`);
223
+ assert.equal(rows.length, 1);
224
+ assert.equal(rows[0].event, "note");
225
+ assert.deepEqual(rows[0].currentState, { state: "working", timestamp: "2026-11-07T01:00:00.000Z" });
226
+ assert.equal(rows[0].current_history_json, undefined);
227
+ assert.equal(rows[0].current_status_json, undefined);
228
+ });