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/a2a.mjs +2 -1
- package/lib/commands.mjs +17 -2
- package/lib/config.mjs +6 -1
- package/lib/deploy.mjs +51 -6
- package/lib/hosted.mjs +16 -4
- package/lib/main.mjs +40 -12
- package/lib/pair.mjs +4 -4
- package/lib/runtime.mjs +54 -0
- package/lib/status.mjs +49 -5
- package/lib/tunnel.mjs +58 -14
- package/lib/update.mjs +2 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/src/index.ts +32 -15
- package/worker/src/mcp.ts +4 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +24 -2
- package/worker/test/push-config.test.ts +67 -0
- package/worker/test/wake-retry.test.ts +42 -0
- package/worker/test/wake.test.ts +35 -2
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
|
|
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 \`${
|
|
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 \`${
|
|
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", ""],
|
|
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
|
-
|
|
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} #
|
|
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
|
-
|
|
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
|
-
|
|
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" : "") + (
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
@@ -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 "
|
package/worker/src/index.ts
CHANGED
|
@@ -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
|
|
1240
|
-
return [false,
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
|
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(
|
|
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 },
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.42";
|
package/worker/src/wake.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
});
|