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