a2a-exposed 0.8.18 → 0.8.19
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 +1 -1
- package/lib/main.mjs +4 -2
- package/lib/status.mjs +4 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/scripts/stamp-version.mjs +1 -1
- package/worker/src/index.ts +1 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +12 -2
- package/worker/test/wake-retry.test.ts +28 -0
- package/worker/test/wake.test.ts +23 -1
package/lib/deploy.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export const CLAUDE_CODE_NOTE = [
|
|
|
25
25
|
" Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
|
|
26
26
|
].join("\n");
|
|
27
27
|
|
|
28
|
-
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
|
|
28
|
+
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
|
|
29
29
|
// WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
|
|
30
30
|
// by hand for your own Access-protected endpoint)
|
|
31
31
|
export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
|
package/lib/main.mjs
CHANGED
|
@@ -92,9 +92,11 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
92
92
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
93
93
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
94
94
|
save wake settings, upload wake secrets from env, and redeploy (one step)
|
|
95
|
+
openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
|
|
95
96
|
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
96
97
|
which defaults to 25 when this is unset
|
|
97
98
|
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
99
|
+
openbot includes a sample delivery ID and canonical signing-input description
|
|
98
100
|
+ sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
|
|
99
101
|
local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
|
|
100
102
|
are exported in this shell. match: the exported value is the one on the Worker.
|
|
@@ -116,7 +118,7 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
|
|
|
116
118
|
creates nothing when you run it again. A partial tunnel stops and asks for tunnel rm.
|
|
117
119
|
The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
|
|
118
120
|
The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
|
|
119
|
-
Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
121
|
+
Export WAKE_WEBHOOK_KEY (hermes/openbot: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
120
122
|
The connector machine needs cloudflared installed
|
|
121
123
|
(https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
|
|
122
124
|
Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
|
|
@@ -267,7 +269,7 @@ Feedback
|
|
|
267
269
|
polling), and hosted or self-hosted. --connection overrides
|
|
268
270
|
that detection. Remove tokens from the message and from --logs.
|
|
269
271
|
|
|
270
|
-
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
|
|
272
|
+
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
|
|
271
273
|
Which clients are tested end to end is in the client table:
|
|
272
274
|
https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
|
|
273
275
|
Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
|
package/lib/status.mjs
CHANGED
|
@@ -171,8 +171,8 @@ function nextStepCore(s) {
|
|
|
171
171
|
if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
|
|
172
172
|
if (t && !t.tokenFileOk)
|
|
173
173
|
return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
|
|
174
|
-
const agentSecret = s.wake.preset
|
|
175
|
-
const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
174
|
+
const agentSecret = ["hermes", "openbot"].includes(s.wake.preset) ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
|
|
175
|
+
const needsAgentSecret = s.wake.preset === "openbot" ? !s.wake.hasHmacSecret : LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
176
176
|
const now = typeof s.now === "number" ? s.now : Date.now();
|
|
177
177
|
if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
|
|
178
178
|
if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
|
|
@@ -184,7 +184,7 @@ function nextStepCore(s) {
|
|
|
184
184
|
if (t && t.connections === 0)
|
|
185
185
|
return fail(`the tunnel has ${t.state === "inactive" ? "never connected" : "no running connector"}: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
|
|
186
186
|
if (s.wake.configured && needsAgentSecret)
|
|
187
|
-
return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${s.wake.preset
|
|
187
|
+
return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${["hermes", "openbot"].includes(s.wake.preset) ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
|
|
188
188
|
// not broken, but peers can't pair until the human sets the approval password
|
|
189
189
|
const also = [];
|
|
190
190
|
if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
|
|
@@ -195,7 +195,7 @@ function nextStepCore(s) {
|
|
|
195
195
|
if (!s.wake.configured) {
|
|
196
196
|
const poll = "the agent must check the A2A endpoint on a schedule (Hermes and OpenClaw have copy-paste commands in the setup references)";
|
|
197
197
|
if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
|
|
198
|
-
return done2(`no wake webhook: export WAKE_WEBHOOK_URL and
|
|
198
|
+
return done2(`no wake webhook: export WAKE_WEBHOOK_URL and ${agentSecret}, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
|
|
199
199
|
const z = s.zones;
|
|
200
200
|
const tunnel = !z ? `a local-only webhook can use \`${CLI} tunnel create\` (needs a zone on the account)`
|
|
201
201
|
: !z.length ? "the account has no domain (zone), so the secure tunnel is not available"
|
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 | generic
|
|
31
|
+
WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | 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 "
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Write worker/src/version.ts from cli/package.json.
|
|
1
|
+
// Write worker/src/version.ts from cli/package.json. Release preparation runs `npm run version:set`
|
|
2
2
|
// before the Worker tests and build, and prepack bundles this tree into the npm package.
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
package/worker/src/index.ts
CHANGED
|
@@ -1619,6 +1619,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1619
1619
|
fingerprints: { url: await A.fingerprint(ctx.WAKE_WEBHOOK_URL), key: await A.fingerprint(ctx.WAKE_WEBHOOK_KEY),
|
|
1620
1620
|
hmacSecret: await A.fingerprint(ctx.WAKE_HMAC_SECRET),
|
|
1621
1621
|
accessClientId: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_ID), accessClientSecret: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_SECRET) },
|
|
1622
|
+
...(preset(ctx) === "openbot" ? { signingInput: "<timestamp>.<delivery-id>.<exact-body-bytes>" } : {}),
|
|
1622
1623
|
request: req ? redact(req) : null });
|
|
1623
1624
|
}
|
|
1624
1625
|
if (seg[0] === "wake" && seg[1] === "test" && m === "POST") {
|
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.19";
|
package/worker/src/wake.ts
CHANGED
|
@@ -46,7 +46,7 @@ export type WakeConfig = {
|
|
|
46
46
|
|
|
47
47
|
export type WakeRequest = { url: string; method: "POST"; headers: Record<string, string>; body: string };
|
|
48
48
|
|
|
49
|
-
export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"] as const;
|
|
49
|
+
export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"] as const;
|
|
50
50
|
|
|
51
51
|
/** Default debounce window per preset (seconds), used when WAKE_DEBOUNCE_SECONDS is unset. */
|
|
52
52
|
export function defaultDebounceSeconds(preset: string): number {
|
|
@@ -207,6 +207,16 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
|
|
|
207
207
|
// `name` is the hook name OpenClaw filters match. It stays "A2A inbox". The wake text itself starts with "A2A endpoint:".
|
|
208
208
|
body = JSON.stringify({ message: wakeSummary(ev, cli), name: "A2A inbox", agentId: cfg.agentId || "main", sessionMode: "isolated", deliver: false });
|
|
209
209
|
break;
|
|
210
|
+
case "openbot": {
|
|
211
|
+
const ts = String(Math.floor(nowMs / 1000));
|
|
212
|
+
headers["x-openbot-timestamp"] = ts;
|
|
213
|
+
headers["x-openbot-delivery-id"] = requestId;
|
|
214
|
+
// Serialize once: the signature covers exactly the UTF-8 bytes sent to OpenBot.
|
|
215
|
+
body = JSON.stringify({ type: "a2a_wake", occurredAt: new Date(nowMs).toISOString(), data: wakePayload(ev, cli) });
|
|
216
|
+
if (cfg.hmacSecret)
|
|
217
|
+
headers["x-openbot-signature"] = `sha256=${await hmacSha256Hex(cfg.hmacSecret, `${ts}.${requestId}.${body}`)}`;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
210
220
|
case "hermes":
|
|
211
221
|
headers["x-request-id"] = requestId;
|
|
212
222
|
body = JSON.stringify(wakePayload(ev, cli));
|
|
@@ -268,7 +278,7 @@ export function cloudflareErrorHint(status: number, body: string, opts?: { tunne
|
|
|
268
278
|
|
|
269
279
|
/** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
|
|
270
280
|
export const PREVIEW_PLAIN_HEADERS = new Set([
|
|
271
|
-
"content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
|
|
281
|
+
"content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp", "x-openbot-timestamp", "x-openbot-delivery-id",
|
|
272
282
|
]);
|
|
273
283
|
|
|
274
284
|
export function previewHeaderPlain(name: string): boolean {
|
|
@@ -72,3 +72,31 @@ test("a 429 is due at Retry-After when the debounce is longer", async (t) => {
|
|
|
72
72
|
const at = await nextCronAt(resolveTenant(env), Date.now());
|
|
73
73
|
assert.ok(at != null && at >= before + 25_000 && at <= before + 35_000, `alarm ${at} is Retry-After, not the 120s debounce`);
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
test("OpenBot preview masks signing credentials and wake test accepts HTTP 202", async (t) => {
|
|
77
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
|
|
78
|
+
WAKE_PRESET: "openbot", WAKE_WEBHOOK_URL: "https://hooks.example.com/a2a", WAKE_HMAC_SECRET: "dummy-openbot-secret" };
|
|
79
|
+
const pending: Promise<unknown>[] = [];
|
|
80
|
+
const call = (method: string, path: string) => worker.fetch(new Request(BASE + path, { method, headers: { authorization: "Bearer owner-secret" } }) as any,
|
|
81
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
82
|
+
const preview = await (await call("GET", "/owner/wake/preview")).json() as any;
|
|
83
|
+
assert.equal(preview.preset, "openbot");
|
|
84
|
+
assert.equal(preview.signingInput, "<timestamp>.<delivery-id>.<exact-body-bytes>");
|
|
85
|
+
assert.equal(preview.request.headers["x-openbot-delivery-id"], "preview");
|
|
86
|
+
assert.equal(preview.request.headers["x-openbot-signature"], "••••");
|
|
87
|
+
assert.ok(!JSON.stringify(preview).includes(env.WAKE_HMAC_SECRET));
|
|
88
|
+
const original = globalThis.fetch;
|
|
89
|
+
let sent = false;
|
|
90
|
+
globalThis.fetch = (async (_url: any, init: any) => {
|
|
91
|
+
sent = true;
|
|
92
|
+
assert.equal(JSON.parse(init.body).type, "a2a_wake");
|
|
93
|
+
assert.match(init.headers["x-openbot-signature"], /^sha256=[0-9a-f]{64}$/);
|
|
94
|
+
assert.ok(init.headers["x-openbot-delivery-id"]);
|
|
95
|
+
return new Response("{}", { status: 202 });
|
|
96
|
+
}) as any;
|
|
97
|
+
t.after(() => { globalThis.fetch = original; });
|
|
98
|
+
const result = await (await call("POST", "/owner/wake/test")).json() as any;
|
|
99
|
+
assert.equal(result.status, 202);
|
|
100
|
+
assert.equal(sent, true);
|
|
101
|
+
await Promise.allSettled(pending);
|
|
102
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import { createHmac } from "node:crypto";
|
|
5
|
-
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, type WakeEvent } from "../src/wake.ts";
|
|
5
|
+
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, wakePayload, type WakeEvent } from "../src/wake.ts";
|
|
6
6
|
|
|
7
7
|
const ev: WakeEvent = {
|
|
8
8
|
contextId: "ctx-1", taskId: "t-2", taskIds: ["t-1", "t-2"], from: "peer-a",
|
|
@@ -144,6 +144,7 @@ test("Access service-token headers are added for every preset, next to the prese
|
|
|
144
144
|
assert.equal(r.headers["CF-Access-Client-Id"], "abc123.access", preset);
|
|
145
145
|
assert.equal(r.headers["CF-Access-Client-Secret"], "s3cr3t-value-0123456789", preset);
|
|
146
146
|
if (preset === "hermes") assert.ok(r.headers["X-Webhook-Signature-V2"], "hermes keeps its HMAC");
|
|
147
|
+
else if (preset === "openbot") assert.ok(r.headers["x-openbot-signature"], "openbot keeps its HMAC");
|
|
147
148
|
else assert.ok(r.headers["authorization"], `${preset} keeps its bearer`);
|
|
148
149
|
}
|
|
149
150
|
});
|
|
@@ -180,3 +181,24 @@ test("cloudflareErrorHint explains tunnel / Access failures", () => {
|
|
|
180
181
|
assert.match(cloudflareErrorHint(530, "error code: 1016", { tunnel: true }), /tunnel hostname has no matching DNS record/);
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
|
|
185
|
+
// Fixed vector independently generated with Python hashlib/hmac.
|
|
186
|
+
test("openbot: envelope and signature cover the exact body and delivery ID", async () => {
|
|
187
|
+
assert.ok(PRESETS.includes("openbot"));
|
|
188
|
+
const req = (await renderWake({ preset: "openbot", url: "https://hooks.example.com/a2a", hmacSecret: "openbot-test-secret", key: "unused-key" }, ev,
|
|
189
|
+
{ nowMs: 1760000000000, requestId: "delivery-1" }))!;
|
|
190
|
+
assert.deepEqual(JSON.parse(req.body), { type: "a2a_wake", occurredAt: "2025-10-09T08:53:20.000Z", data: wakePayload(ev, "npx -y a2a-exposed@latest") });
|
|
191
|
+
assert.equal(req.headers["x-openbot-timestamp"], "1760000000");
|
|
192
|
+
assert.equal(req.headers["x-openbot-delivery-id"], "delivery-1");
|
|
193
|
+
assert.equal(req.headers["x-openbot-signature"], "sha256=8d28056bd4d5f700fd49865a0cc240bd769462d6e75aac4aa15fc7051c3ecc26");
|
|
194
|
+
assert.equal(req.headers["authorization"], undefined);
|
|
195
|
+
assert.equal(req.headers["X-Webhook-Signature-V2"], undefined);
|
|
196
|
+
const preview = redact(req);
|
|
197
|
+
assert.equal(preview.headers["x-openbot-delivery-id"], "delivery-1");
|
|
198
|
+
assert.equal(preview.headers["x-openbot-timestamp"], "1760000000");
|
|
199
|
+
assert.equal(preview.headers["x-openbot-signature"], "••••");
|
|
200
|
+
assert.ok(!JSON.stringify(preview).includes("openbot-test-secret"));
|
|
201
|
+
const unicode = (await renderWake({ preset: "openbot", url: req.url, hmacSecret: "s" }, { ...ev, preview: '雪 "quoted"\ntext' },
|
|
202
|
+
{ nowMs: 1760000000000, requestId: "delivery-2" }))!;
|
|
203
|
+
assert.equal(unicode.headers["x-openbot-signature"], "sha256=" + createHmac("sha256", "s").update(`1760000000.delivery-2.${unicode.body}`).digest("hex"));
|
|
204
|
+
});
|