a2a-exposed 0.8.18 → 0.8.20

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/commands.mjs CHANGED
@@ -26,6 +26,14 @@ function printPush(res) {
26
26
  for (const r of (res && res.push) || []) console.error(`push -> ${r.url}: ${r.ok ? "ok" : "FAILED"} (${r.info})`);
27
27
  }
28
28
 
29
+ function printDelivery(res) {
30
+ const d = res && res.delivery;
31
+ if (!d) return;
32
+ const target = d.target ? `${d.target.alias ? `${d.target.alias} at ` : ""}${d.target.host}` : "task storage";
33
+ const http = d.httpStatus ? ` (HTTP ${d.httpStatus})` : "";
34
+ console.error(`reply -> ${d.status} via ${d.method}: ${target}${http}`);
35
+ }
36
+
29
37
  const readStdin = () => fs.readFileSync(0, "utf8");
30
38
 
31
39
  // ---------------------------------------------------------------- inbound side
@@ -121,7 +129,7 @@ export async function reply(id, o) {
121
129
  const res = await owner("POST", `/owner/tasks/${q(id)}/reply`, {
122
130
  text, state: o.state, artifact: !!o.artifact, artifactName: o["artifact-name"], force: !!o.force,
123
131
  });
124
- printPush(res);
132
+ printDelivery(res);
125
133
  console.log(`${id} -> ${o.state}`);
126
134
  }
127
135
 
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,12 @@ 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
96
+ the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
95
97
  --max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
96
98
  which defaults to 25 when this is unset
97
99
  wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
100
+ openbot includes a sample delivery ID and canonical signing-input description
98
101
  + sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
99
102
  local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
100
103
  are exported in this shell. match: the exported value is the one on the Worker.
@@ -116,7 +119,7 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
116
119
  creates nothing when you run it again. A partial tunnel stops and asks for tunnel rm.
117
120
  The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
118
121
  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.
122
+ Export WAKE_WEBHOOK_KEY (hermes/openbot: WAKE_HMAC_SECRET) so tunnel create uploads it.
120
123
  The connector machine needs cloudflared installed
121
124
  (https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
122
125
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
@@ -267,7 +270,7 @@ Feedback
267
270
  polling), and hosted or self-hosted. --connection overrides
268
271
  that detection. Remove tokens from the message and from --logs.
269
272
 
270
- Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
273
+ Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
271
274
  Which clients are tested end to end is in the client table:
272
275
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
273
276
  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 === "hermes" ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
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 === "hermes" ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
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 WAKE_WEBHOOK_KEY, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.18",
3
+ "version": "0.8.20",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@ const plain: Record<string, string> = {
28
28
  PROVIDER_ORGANIZATION: v("A2A_PROVIDER_ORGANIZATION"),
29
29
  PROVIDER_URL: v("A2A_PROVIDER_URL"),
30
30
  DOCUMENTATION_URL: v("A2A_DOCUMENTATION_URL"),
31
- WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | 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 "
@@ -0,0 +1,11 @@
1
+ -- One delivery outcome for one logical owner reply. The key includes the inbound turn and reply content,
2
+ -- so an HTTP/MCP retry is idempotent while a later peer message permits the same words to be sent again.
3
+ CREATE TABLE IF NOT EXISTS reply_deliveries (
4
+ delivery_key TEXT PRIMARY KEY,
5
+ task_id TEXT NOT NULL,
6
+ message_id TEXT NOT NULL,
7
+ result_json TEXT,
8
+ created_at TEXT NOT NULL,
9
+ updated_at TEXT NOT NULL
10
+ );
11
+ CREATE INDEX IF NOT EXISTS reply_deliveries_task ON reply_deliveries(task_id, created_at);
@@ -1,4 +1,4 @@
1
- // Write worker/src/version.ts from cli/package.json. Publish runs `npm version` in cli/
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";
@@ -16,7 +16,7 @@ export const EXPORT_TABLES = [
16
16
  "peers", "tasks", "messages", "history", "outbound", "wakes",
17
17
  "rate", "wake_budget", "device_requests", "pairing_rate", "settings",
18
18
  "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants",
19
- "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners",
19
+ "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners", "reply_deliveries",
20
20
  ] as const;
21
21
 
22
22
  export type ExportTable = (typeof EXPORT_TABLES)[number];
@@ -7,7 +7,7 @@
7
7
  import * as A from "./a2a.ts";
8
8
  import * as F from "./facade.ts";
9
9
  import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
10
- import { fetchCheckedWake } from "./wake-target.ts";
10
+ import { fetchCheckedPeer, fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
13
13
  import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion } from "./directory.ts";
@@ -1104,6 +1104,131 @@ async function notifyPeer(ctx: TenantContext, ectx: ExecutionContext, t: Json):
1104
1104
  return results;
1105
1105
  }
1106
1106
 
1107
+ type ReplyDelivery = {
1108
+ status: "delivered" | "stored-only" | "delivery-failed";
1109
+ method: "push" | "a2a-message" | "stored";
1110
+ taskId: string;
1111
+ state: string;
1112
+ target?: { alias?: string; host: string };
1113
+ httpStatus?: number;
1114
+ detail: string;
1115
+ };
1116
+
1117
+ const DIRECT_REPLY_META = "a2a.exposed/reply-delivery";
1118
+
1119
+ function safeTarget(alias: string | undefined, raw: string): { alias?: string; host: string } | undefined {
1120
+ try { return { ...(alias ? { alias } : {}), host: new URL(raw).host }; } catch { return undefined; }
1121
+ }
1122
+
1123
+ /** A paired endpoint fetch. Same-deployment tenants stay inside the Durable Object namespace. Every other host is
1124
+ * resolved once and the checked address is pinned to the socket; redirects are refused. */
1125
+ async function checkedPeerFetch(ctx: TenantContext, ectx: ExecutionContext, base: string, url: string, init: RequestInit): Promise<Response> {
1126
+ let baseUrl: URL, target: URL;
1127
+ try { baseUrl = new URL(base); target = new URL(url); } catch { throw new ToolError("peer endpoint is not a URL"); }
1128
+ if (target.origin !== baseUrl.origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
1129
+ const why = peerUrlProblem(target.href);
1130
+ if (why) throw new ToolError(`peer endpoint ${target.href} refused: ${why}`);
1131
+ const internal = await fetchTenant(ctx, target, { ...init, redirect: "manual" });
1132
+ if (internal) {
1133
+ if (internal.status >= 300 && internal.status < 400) throw new ToolError("peer endpoint refused: redirects are not allowed");
1134
+ return internal;
1135
+ }
1136
+ const checked = await fetchCheckedPeer(target.href, { ...init, redirect: "manual" });
1137
+ if ("refused" in checked) throw new ToolError(`peer endpoint refused: ${checked.refused}`);
1138
+ return checked.response;
1139
+ }
1140
+
1141
+ async function checkedPeerCard(ctx: TenantContext, ectx: ExecutionContext, base: string): Promise<Json> {
1142
+ for (const p of ["/.well-known/agent-card.json", "/.well-known/agent.json"]) {
1143
+ try {
1144
+ const r = await checkedPeerFetch(ctx, ectx, base, new URL(p, base).href, { headers: { accept: "application/json" } });
1145
+ if (r.status !== 200) continue;
1146
+ const text = (await r.text()).slice(0, 1 << 20);
1147
+ const d = JSON.parse(text);
1148
+ if (d && typeof d === "object") return d;
1149
+ } catch { /* default endpoint below */ }
1150
+ }
1151
+ return null;
1152
+ }
1153
+
1154
+ /** The exact inbound label must still have a live token here and an outbound pairing whose token we can decrypt.
1155
+ * A claimed card URL or a tokenless outbound entry is never enough. */
1156
+ async function replyPeer(ctx: TenantContext, label: string): Promise<{ alias: string; url: string; token: string } | null> {
1157
+ const row: Json = await ctx.DB.prepare(
1158
+ `SELECT o.alias, o.url, o.token_enc FROM outbound_peers o
1159
+ JOIN peers p ON p.label = o.alias
1160
+ WHERE o.alias = ? AND o.token_enc IS NOT NULL AND p.revoked_at IS NULL`,
1161
+ ).bind(label).first();
1162
+ if (!row) return null;
1163
+ const key = await ctx.peerKey();
1164
+ const token = key ? await M.openPeerTokenWithKey(key, row.alias, row.token_enc) : null;
1165
+ return token ? { alias: row.alias, url: row.url, token } : null;
1166
+ }
1167
+
1168
+ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, task: Json, message: Json, state: string, text: string): Promise<ReplyDelivery> {
1169
+ if (task._push?.length) {
1170
+ const pushed = await notifyPeer(ctx, ectx, task);
1171
+ const failed = pushed.find((x: Json) => !x.ok);
1172
+ const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
1173
+ const http = /HTTP (\d{3})/.exec(String((failed || pushed[0])?.info || ""));
1174
+ return {
1175
+ status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state,
1176
+ ...(target ? { target } : {}), ...(http ? { httpStatus: Number(http[1]) } : {}),
1177
+ detail: failed ? "A task push failed; the reply remains stored on the task." : "Delivered as the task's existing push update.",
1178
+ };
1179
+ }
1180
+ if (task.metadata?.[DIRECT_REPLY_META]) return {
1181
+ status: "stored-only", method: "stored", taskId: task.id, state,
1182
+ detail: "Stored on the task; a directly delivered reply is not delivered again.",
1183
+ };
1184
+ const peer = await replyPeer(ctx, task._peer);
1185
+ if (!peer) return {
1186
+ status: "stored-only", method: "stored", taskId: task.id, state,
1187
+ detail: "Stored on the task for the peer to fetch; no live paired outbound address with a held token was found.",
1188
+ };
1189
+ const target = safeTarget(peer.alias, peer.url);
1190
+ try {
1191
+ const card = await checkedPeerCard(ctx, ectx, peer.url);
1192
+ const [endpoint, version] = M.pickEndpoint(peer.url, card);
1193
+ if (new URL(endpoint).origin !== new URL(peer.url).origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
1194
+ const v1 = version.startsWith("1");
1195
+ const direct: Json = {
1196
+ messageId: message.messageId, role: v1 ? "ROLE_USER" : "user", contextId: task.contextId,
1197
+ referenceTaskIds: [task.id],
1198
+ parts: v1 ? [{ text }] : [{ kind: "text", text }],
1199
+ metadata: { [DIRECT_REPLY_META]: { taskId: task.id, messageId: message.messageId, state } },
1200
+ };
1201
+ if (!v1) direct.kind = "message";
1202
+ const directMetadata = { [DIRECT_REPLY_META]: { taskId: task.id, messageId: message.messageId, state } };
1203
+ const headers: Record<string, string> = {
1204
+ "content-type": "application/json", accept: "application/json", "a2a-version": v1 ? "1.0" : "0.3",
1205
+ authorization: `Bearer ${peer.token}`,
1206
+ };
1207
+ const response = await checkedPeerFetch(ctx, ectx, peer.url, endpoint, {
1208
+ method: "POST", headers,
1209
+ body: JSON.stringify({ jsonrpc: "2.0", id: message.messageId, method: v1 ? "SendMessage" : "message/send",
1210
+ params: { message: direct, metadata: directMetadata, configuration: v1 ? { returnImmediately: true } : { blocking: false } } }),
1211
+ });
1212
+ const httpStatus = response.status;
1213
+ const raw = (await response.text()).slice(0, 1 << 20);
1214
+ let body: Json = null;
1215
+ try { body = JSON.parse(raw); } catch { /* failure below */ }
1216
+ if (httpStatus !== 200 || !body || typeof body !== "object" || body.error) return {
1217
+ status: "delivery-failed", method: "a2a-message", taskId: task.id, state, ...(target ? { target } : {}), httpStatus,
1218
+ detail: "The paired peer did not accept the A2A message; the reply remains stored on the task.",
1219
+ };
1220
+ return {
1221
+ status: "delivered", method: "a2a-message", taskId: task.id, state, ...(target ? { target } : {}), httpStatus,
1222
+ detail: "Delivered as an A2A message in the same context, referencing the original task.",
1223
+ };
1224
+ } catch (e: any) {
1225
+ return {
1226
+ status: "delivery-failed", method: "a2a-message", taskId: task.id, state, ...(target ? { target } : {}),
1227
+ detail: `${scrubSecretText(String(e?.message || "delivery failed")).slice(0, 200)}; the reply remains stored on the task.`,
1228
+ };
1229
+ }
1230
+ }
1231
+
1107
1232
  /** State and text of one pushed task, in the internal shape history and the inbox store.
1108
1233
  * A full task or a status update carries both. A message and an artifact update carry text and no task state. */
1109
1234
  function pushUpdateView(body: Json): { state: string | null; text: string } {
@@ -1436,15 +1561,52 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1436
1561
  const state = b.state || "completed";
1437
1562
  if (!["completed", "input-required", "failed", "rejected", "working"].includes(state)) return json({ error: "bad state" }, 400);
1438
1563
  if (typeof b.text !== "string" || !b.text) return json({ error: "text required" }, 400);
1564
+ const turn = [...t.history].reverse().find((x: Json) => x.role === "user")?.messageId || t.id;
1565
+ const deliveryKey = await A.sha256(JSON.stringify([t.id, turn, state, b.text]));
1566
+ let previous: Json = await ctx.DB.prepare("SELECT message_id, result_json, updated_at FROM reply_deliveries WHERE delivery_key = ?").bind(deliveryKey).first();
1567
+ if (previous?.result_json) return json({ task: ownerTaskView(t), delivery: JSON.parse(previous.result_json), idempotent: true });
1568
+ if (previous && Date.now() - Date.parse(previous.updated_at) > 20000) {
1569
+ const storedMessage = t.history.find((x: Json) => x.messageId === previous.message_id);
1570
+ if (storedMessage) {
1571
+ const recovered: ReplyDelivery = {
1572
+ status: "delivery-failed", method: t._push?.length ? "push" : "a2a-message", taskId: t.id, state,
1573
+ detail: "The earlier delivery result is uncertain, so it was not sent again; the reply remains stored on the task.",
1574
+ };
1575
+ await ctx.DB.prepare("UPDATE reply_deliveries SET result_json = ?, updated_at = ? WHERE delivery_key = ? AND result_json IS NULL")
1576
+ .bind(JSON.stringify(recovered), A.nowIso(), deliveryKey).run();
1577
+ const settled: Json = await ctx.DB.prepare("SELECT result_json FROM reply_deliveries WHERE delivery_key = ?").bind(deliveryKey).first();
1578
+ return json({ task: ownerTaskView(t), delivery: JSON.parse(settled.result_json), idempotent: true });
1579
+ }
1580
+ await ctx.DB.prepare("DELETE FROM reply_deliveries WHERE delivery_key = ? AND result_json IS NULL AND updated_at = ?")
1581
+ .bind(deliveryKey, previous.updated_at).run();
1582
+ previous = null;
1583
+ }
1439
1584
  if (A.TERMINAL.has(t.status.state) && !b.force) return json({ error: `task is already ${t.status.state}` }, 409);
1440
1585
  const msg = { kind: "message", role: "agent", messageId: A.newId(), contextId: t.contextId, taskId: t.id, parts: [{ kind: "text", text: b.text }] };
1586
+ const now = A.nowIso();
1587
+ const claimed = await ctx.DB.prepare(
1588
+ "INSERT INTO reply_deliveries (delivery_key, task_id, message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING",
1589
+ ).bind(deliveryKey, t.id, msg.messageId, now, now).run();
1590
+ if (claimed.meta.changes !== 1) {
1591
+ const raced: Json = await ctx.DB.prepare("SELECT result_json FROM reply_deliveries WHERE delivery_key = ?").bind(deliveryKey).first();
1592
+ if (raced?.result_json) return json({ task: ownerTaskView(await loadTask(ctx, t.id)), delivery: JSON.parse(raced.result_json), idempotent: true });
1593
+ return json({ error: "this reply is already being delivered; retry to read its result" }, 409);
1594
+ }
1441
1595
  if (state === "completed" || b.artifact) t.artifacts.push({ artifactId: A.newId(), name: b.artifactName || "response", parts: [{ kind: "text", text: b.text }] });
1442
1596
  t.history.push(msg);
1443
- t.status = { state, timestamp: A.nowIso(), message: msg };
1597
+ t.status = { state, timestamp: now, message: msg };
1444
1598
  await ctx.DB.batch([saveTaskStmt(ctx, t), msgStmt(ctx, t, msg),
1445
1599
  histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text })]);
1446
1600
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
1447
- return json({ task: ownerTaskView(t), push: await notifyPeer(ctx, ectx, t) });
1601
+ let delivery: ReplyDelivery;
1602
+ try { delivery = await deliverReplyMessage(ctx, ectx, t, msg, state, b.text); }
1603
+ catch {
1604
+ delivery = { status: "delivery-failed", method: t._push?.length ? "push" : "a2a-message", taskId: t.id, state,
1605
+ detail: "Delivery could not be completed; the reply remains stored on the task." };
1606
+ }
1607
+ await ctx.DB.prepare("UPDATE reply_deliveries SET result_json = ?, updated_at = ? WHERE delivery_key = ?")
1608
+ .bind(JSON.stringify(delivery), A.nowIso(), deliveryKey).run();
1609
+ return json({ task: ownerTaskView(t), delivery, idempotent: false });
1448
1610
  }
1449
1611
  }
1450
1612
  if (seg[0] === "history" && seg[1] && m === "GET") {
@@ -1542,8 +1704,9 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1542
1704
  const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
1543
1705
  if (!checked.ok) return json({ error: checked.error }, 400);
1544
1706
  try {
1545
- const text = await runTool(ctx, ectx, seg[1], checked.value);
1546
- return json({ text, isError: false });
1707
+ const result = await runTool(ctx, ectx, seg[1], checked.value);
1708
+ return json({ text: typeof result === "string" ? result : result.text, isError: false,
1709
+ ...(typeof result === "string" ? {} : { structuredContent: result.structuredContent }) });
1547
1710
  } catch (e: any) {
1548
1711
  if (e instanceof ToolError) return json({ text: `Error: ${e.message}`, isError: true });
1549
1712
  throw e;
@@ -1612,6 +1775,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1612
1775
  // uploaded secrets so the owner can compare them with local values without revealing them
1613
1776
  const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
1614
1777
  preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
1778
+ if (preset(ctx) === "openbot" && !ctx.WAKE_HMAC_SECRET?.trim())
1779
+ return json({ error: "OpenBot requires WAKE_HMAC_SECRET from the routine settings" }, 400);
1615
1780
  const req = await renderWake(wakeConfig(ctx), ev, { requestId: "preview" });
1616
1781
  return json({ preset: preset(ctx), configured: !!req, debounceSeconds: debounceMs(ctx) / 1000, maxPerHour: maxPerHour(ctx),
1617
1782
  hasKey: !!ctx.WAKE_WEBHOOK_KEY, hasHmacSecret: !!ctx.WAKE_HMAC_SECRET,
@@ -1619,6 +1784,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1619
1784
  fingerprints: { url: await A.fingerprint(ctx.WAKE_WEBHOOK_URL), key: await A.fingerprint(ctx.WAKE_WEBHOOK_KEY),
1620
1785
  hmacSecret: await A.fingerprint(ctx.WAKE_HMAC_SECRET),
1621
1786
  accessClientId: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_ID), accessClientSecret: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_SECRET) },
1787
+ ...(preset(ctx) === "openbot" ? { signingInput: "<timestamp>.<delivery-id>.<exact-body-bytes>" } : {}),
1622
1788
  request: req ? redact(req) : null });
1623
1789
  }
1624
1790
  if (seg[0] === "wake" && seg[1] === "test" && m === "POST") {
@@ -2912,7 +3078,9 @@ function newPushToken(): string {
2912
3078
  return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2913
3079
  }
2914
3080
 
2915
- async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string, a: Json, grant?: Json): Promise<string> {
3081
+ type ToolRun = string | { text: string; structuredContent: Json };
3082
+
3083
+ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string, a: Json, grant?: Json): Promise<ToolRun> {
2916
3084
  const enc = encodeURIComponent;
2917
3085
  switch (name) {
2918
3086
  case "inbox": {
@@ -2965,7 +3133,15 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2965
3133
  case "reply": {
2966
3134
  const state = argStr(a, "state", false) || "completed";
2967
3135
  const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/reply`, { text: argStr(a, "text"), state }));
2968
- return M.toolText(`Replied; task ${r.task.id} is now ${r.task.status.state}.`, { push: r.push });
3136
+ const d: ReplyDelivery = r.delivery;
3137
+ const where = d.target ? `${d.target.alias ? `**${d.target.alias}** at ` : ""}\`${d.target.host}\`` : "the task";
3138
+ const http = d.httpStatus ? ` (HTTP ${d.httpStatus})` : "";
3139
+ const line = d.status === "delivered"
3140
+ ? `Task \`${d.taskId}\` is now ${d.state}. Reply stored and delivered to ${where} via ${d.method}${http}.`
3141
+ : d.status === "delivery-failed"
3142
+ ? `Task \`${d.taskId}\` is now ${d.state}. Reply stored; delivery to ${where} failed${http}, so the stored reply was not lost.`
3143
+ : `Task \`${d.taskId}\` is now ${d.state}. Reply stored for the peer to fetch.`;
3144
+ return { text: `${line}\n\n${d.detail}`, structuredContent: d };
2969
3145
  }
2970
3146
  case "send": {
2971
3147
  const peer = await outboundPeer(ctx, argStr(a, "to"));
@@ -3104,9 +3280,10 @@ async function callTool(ctx: TenantContext, ectx: ExecutionContext, grant: Json,
3104
3280
  const args = params.arguments ?? {};
3105
3281
  if (typeof args !== "object" || Array.isArray(args)) return { error: [M.ERR.INVALID_PARAMS, "arguments must be an object"] };
3106
3282
  try {
3107
- const text = await runTool(ctx, ectx, name, args, grant);
3283
+ const value = await runTool(ctx, ectx, name, args, grant);
3108
3284
  log("mcp_tool", { label: grant.label, tool: name });
3109
- return { result: { content: [{ type: "text", text }], isError: false } };
3285
+ return { result: { content: [{ type: "text", text: typeof value === "string" ? value : value.text }],
3286
+ ...(typeof value === "string" ? {} : { structuredContent: value.structuredContent }), isError: false } };
3110
3287
  } catch (e: any) {
3111
3288
  log("mcp_tool_failed", { label: grant.label, tool: name, error: String(e.message).slice(0, 200) });
3112
3289
  return { result: { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true } };
package/worker/src/mcp.ts CHANGED
@@ -318,7 +318,7 @@ type Schema = Record<string, unknown>;
318
318
  const str = (description: string, extra: Schema = {}): Schema => ({ type: "string", description, ...extra });
319
319
  const ro = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
320
320
 
321
- export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; annotations: Schema }[] = [
321
+ export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; outputSchema?: Schema; annotations: Schema }[] = [
322
322
  { name: "inbox", title: "Inbox", annotations: ro,
323
323
  description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
324
324
  inputSchema: { type: "object", properties: {
@@ -338,9 +338,16 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
338
338
  description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
339
339
  inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
340
340
  { name: "reply", title: "Reply to task", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
341
- description: `Answer an inbound task. state: completed (default; the text becomes the result), input-required (ask the peer a question), working, failed or rejected. The reply goes to the agent that sent the task. Don't include secrets or private data your human hasn't approved sharing. ${RULES}`,
341
+ description: `Answer an inbound task. state: completed (default; the text becomes the result), input-required (ask the peer a question), working, failed or rejected. The answer is always stored on the task. It uses the task's existing push update, or, when no push exists, an exact paired outbound peer with a held token; otherwise the peer fetches it. Don't include secrets or private data your human hasn't approved sharing. ${RULES}`,
342
342
  inputSchema: { type: "object", properties: { taskId: str("Task id"), text: str("Reply text"),
343
- state: str("New task state", { enum: ["completed", "input-required", "working", "failed", "rejected"] }) }, required: ["taskId", "text"], additionalProperties: false } },
343
+ state: str("New task state", { enum: ["completed", "input-required", "working", "failed", "rejected"] }) }, required: ["taskId", "text"], additionalProperties: false },
344
+ outputSchema: { type: "object", properties: {
345
+ taskId: str("Task that stores the reply"), state: str("Stored task state"),
346
+ status: str("Delivery outcome", { enum: ["delivered", "stored-only", "delivery-failed"] }),
347
+ method: str("Path used", { enum: ["push", "a2a-message", "stored"] }),
348
+ target: { type: "object", properties: { alias: str("Paired peer alias"), host: str("Destination host") }, required: ["host"], additionalProperties: false },
349
+ httpStatus: { type: "integer", minimum: 100, maximum: 599 }, detail: str("Safe delivery summary"),
350
+ }, required: ["taskId", "state", "status", "method", "detail"], additionalProperties: false } },
344
351
  { name: "send", title: "Send to peer", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
345
352
  description: `Send a message to a peer agent (alias from list_peers; only peers synced to this A2A endpoint with \`peers sync\`). When the peer's card advertises push notifications, the peer is asked to push its reply here, which then shows in history and in the inbox. Otherwise check the task with poll_outbound. Pass contextId to continue a conversation, taskId to answer a peer's input-required task. The conversation id stays the one returned. Returns the peer's task. Ask your human before sending anything consequential or private. ${RULES}`,
346
353
  inputSchema: { type: "object", properties: { to: str("Peer alias"), text: str("Message text"), contextId: str("Continue this conversation"), taskId: str("Continue this peer task") }, required: ["to", "text"], additionalProperties: false } },
@@ -12,6 +12,7 @@ import m0009 from "../migrations/0009_outbound_seen.sql?raw";
12
12
  import m0010 from "../migrations/0010_conversation_peer.sql?raw";
13
13
  import m0011 from "../migrations/0011_conversation_owner.sql?raw";
14
14
  import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
15
+ import m0013 from "../migrations/0013_reply_delivery.sql?raw";
15
16
  import type { Migration } from "./storage.ts";
16
17
 
17
18
  export const MIGRATIONS: Migration[] = [
@@ -27,4 +28,5 @@ export const MIGRATIONS: Migration[] = [
27
28
  { name: "0010_conversation_peer.sql", sql: m0010 },
28
29
  { name: "0011_conversation_owner.sql", sql: m0011 },
29
30
  { name: "0012_conversation_peer_owner.sql", sql: m0012 },
31
+ { name: "0013_reply_delivery.sql", sql: m0013 },
30
32
  ];
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.18";
2
+ export const VERSION = "0.8.20";
@@ -199,6 +199,57 @@ async function responseForCaller(res: Response): Promise<Response> {
199
199
  return new Response(merged, { status: res.status, headers });
200
200
  }
201
201
 
202
+ /** Keep a bounded response body for an authenticated peer JSON-RPC call. The socket client enforces the
203
+ * limit before this helper runs; copying detaches the returned body from the socket before it is closed. */
204
+ async function peerResponseForCaller(res: Response): Promise<Response> {
205
+ const body = res.body ? await res.arrayBuffer() : null;
206
+ return new Response(body, { status: res.status, headers: new Headers(res.headers) });
207
+ }
208
+
209
+ async function deliverPinnedPeer(url: string, init: RequestInit, target: { ip: string; hostname: string }, connect: WakeConnect): Promise<Response> {
210
+ const client = new Client({
211
+ connect: dialChecked(target.ip, connect),
212
+ forceTunnel: true,
213
+ maxRedirects: 0,
214
+ maxBodyBytes: 1 << 20,
215
+ http2: false,
216
+ keepAlive: false,
217
+ timeouts: { totalMs: REQUEST_TIMEOUT_MS },
218
+ });
219
+ try {
220
+ const res = await client.fetch(url, { method: init.method, headers: init.headers, body: init.body, redirect: "manual" });
221
+ return await peerResponseForCaller(res);
222
+ } catch (e) {
223
+ if (e instanceof TlsError || e instanceof CertificateError) throw new Error("TLS Handshake Failed.");
224
+ throw e;
225
+ } finally {
226
+ await client.close().catch(() => {});
227
+ }
228
+ }
229
+
230
+ /** One authenticated peer request with DNS resolution pinned to the connected socket. Redirects are refused:
231
+ * a bearer token and a reply must never be replayed at a card-selected second destination. */
232
+ export async function fetchCheckedPeer(
233
+ url: string,
234
+ init: RequestInit,
235
+ deps: Pick<WakeDeps, "lookup" | "connect" | "deliver"> = {},
236
+ ): Promise<{ response: Response } | { refused: string }> {
237
+ const checked = await checkedWakeAddresses(url, deps.lookup ?? lookupAddresses);
238
+ if ("refused" in checked) return checked;
239
+ const target = { ip: checked.ips[0]!, hostname: checked.hostname };
240
+ const nextInit: RequestInit = { ...init, redirect: "manual" };
241
+ const response = deps.deliver
242
+ ? await deps.deliver(url, nextInit, target)
243
+ : wakeNet.deliver
244
+ ? await wakeNet.deliver(url, nextInit, target)
245
+ : await deliverPinnedPeer(url, nextInit, target, deps.connect ?? wakeNet.connect ?? await runtimeConnect());
246
+ if (response.status >= 300 && response.status < 400) {
247
+ await response.body?.cancel().catch(() => {});
248
+ return { refused: "redirects are not allowed" };
249
+ }
250
+ return { response };
251
+ }
252
+
202
253
  function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
203
254
  if (body == null) return new Uint8Array();
204
255
  if (typeof body === "string") return new TextEncoder().encode(body);
@@ -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 {
@@ -177,9 +177,10 @@ async function hermesSign(headers: Record<string, string>, secret: string, body:
177
177
  headers["X-Webhook-Signature-V2"] = await hmacSha256Hex(secret, `${ts}.${body}`);
178
178
  }
179
179
 
180
- /** Build the HTTP request for a wake, or return null when the wake is not configured. */
180
+ /** Build the HTTP request, return null without a URL, or throw when OpenBot signing is missing. */
181
181
  export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?: number; requestId?: string } = {}): Promise<WakeRequest | null> {
182
182
  if (!cfg.url) return null;
183
+ if (cfg.preset === "openbot" && !cfg.hmacSecret?.trim()) throw new Error("OpenBot requires WAKE_HMAC_SECRET from the routine settings");
183
184
  const cli = cfg.cliCommand || DEFAULT_CLI;
184
185
  const nowMs = opts.nowMs ?? Date.now();
185
186
  const requestId = opts.requestId ?? crypto.randomUUID();
@@ -207,6 +208,16 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
207
208
  // `name` is the hook name OpenClaw filters match. It stays "A2A inbox". The wake text itself starts with "A2A endpoint:".
208
209
  body = JSON.stringify({ message: wakeSummary(ev, cli), name: "A2A inbox", agentId: cfg.agentId || "main", sessionMode: "isolated", deliver: false });
209
210
  break;
211
+ case "openbot": {
212
+ const ts = String(Math.floor(nowMs / 1000));
213
+ headers["x-openbot-timestamp"] = ts;
214
+ headers["x-openbot-delivery-id"] = requestId;
215
+ // Serialize once: the signature covers exactly the UTF-8 bytes sent to OpenBot.
216
+ body = JSON.stringify({ type: "a2a_wake", occurredAt: new Date(nowMs).toISOString(), data: wakePayload(ev, cli) });
217
+ if (cfg.hmacSecret)
218
+ headers["x-openbot-signature"] = `sha256=${await hmacSha256Hex(cfg.hmacSecret, `${ts}.${requestId}.${body}`)}`;
219
+ break;
220
+ }
210
221
  case "hermes":
211
222
  headers["x-request-id"] = requestId;
212
223
  body = JSON.stringify(wakePayload(ev, cli));
@@ -268,7 +279,7 @@ export function cloudflareErrorHint(status: number, body: string, opts?: { tunne
268
279
 
269
280
  /** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
270
281
  export const PREVIEW_PLAIN_HEADERS = new Set([
271
- "content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
282
+ "content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp", "x-openbot-timestamp", "x-openbot-delivery-id",
272
283
  ]);
273
284
 
274
285
  export function previewHeaderPlain(name: string): boolean {
@@ -411,6 +411,8 @@ test("tools: inbox, show_task, history, mark_working, reply (with state), pairin
411
411
  assert.match((await s.tool(a.access_token, "mark_working", { taskId: task.id })).content[0].text, /now working/);
412
412
  const q = await s.tool(a.access_token, "reply", { taskId: task.id, text: "Which report?", state: "input-required" });
413
413
  assert.match(q.content[0].text, /now input-required/);
414
+ assert.deepEqual({ status: q.structuredContent.status, method: q.structuredContent.method, taskId: q.structuredContent.taskId },
415
+ { status: "stored-only", method: "stored", taskId: task.id });
414
416
  const done = await s.tool(a.access_token, "reply", { taskId: task.id, text: "Here it is." });
415
417
  assert.match(done.content[0].text, /now completed/);
416
418
  assert.equal((await s.tool(a.access_token, "reply", { taskId: task.id, text: "again" })).isError, true, "terminal tasks stay closed");
@@ -57,7 +57,7 @@ test("migration files: NNNN_name.sql, unique numbers, no gaps, 0002_wake_budget
57
57
  test("fresh database: every migration applies in order; a second run applies nothing", () => {
58
58
  const db = new DatabaseSync(":memory:");
59
59
  assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
60
- for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners"]) assert.ok(tables(db).includes(t), t);
60
+ for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners", "reply_deliveries"]) assert.ok(tables(db).includes(t), t);
61
61
  assert.ok(columns(db, "peers").includes("source"));
62
62
  assert.ok(columns(db, "device_requests").includes("replaces_label"));
63
63
  assert.ok(columns(db, "device_requests").includes("replaces_hash"));
@@ -67,15 +67,15 @@ test("fresh database: every migration applies in order; a second run applies not
67
67
  test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
68
68
  const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
69
69
  db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
70
- assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql"]);
70
+ assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
71
71
  assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
72
72
  });
73
73
 
74
74
  test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
75
75
  const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
76
76
  db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
77
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql"]);
78
- assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql"]);
77
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
78
+ assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
79
79
  assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
80
80
  });
81
81
 
@@ -107,7 +107,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
107
107
  assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
108
108
  assert.equal(wakes.length, 0);
109
109
 
110
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql"]);
110
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
111
111
  assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
112
112
  assert.deepEqual(applyMigrations(db), []);
113
113
 
@@ -0,0 +1,161 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sha256 } from "../src/a2a.ts";
4
+ import { clearDirectoryCache } from "../src/directory.ts";
5
+ import { dispatch, runOwnerRpc } from "../src/index.ts";
6
+ import { hostedTenantContext, type DoNamespace, type KvNamespace, type TenantContext, type WorkerBindings } from "../src/tenancy.ts";
7
+ import { d1 } from "./d1.ts";
8
+
9
+ const MIGRATIONS = new URL("../migrations/", import.meta.url);
10
+ const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
11
+ type Delivery = { status: string; method: string; taskId: string; target?: { alias?: string; host?: string }; httpStatus?: number };
12
+
13
+ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never) {
14
+ clearDirectoryCache();
15
+ t.after(clearDirectoryCache);
16
+ const store = new Map<string, string>();
17
+ const kv: KvNamespace = {
18
+ async get(key: string, type?: "json") { const v = store.get(key); return v == null ? null : type === "json" ? JSON.parse(v) : v; },
19
+ async put(key: string, value: string) { store.set(key, value); },
20
+ };
21
+ const byId = new Map<string, TenantContext>();
22
+ let directPosts = 0;
23
+ let hold: Promise<void> | null = null;
24
+ const ns: DoNamespace = {
25
+ idFromName(name: string) { return name; },
26
+ get(id: unknown) {
27
+ return { async fetch(req: Request) {
28
+ const ctx = byId.get(String(id));
29
+ if (!ctx) return new Response("missing", { status: 404 });
30
+ if (req.method === "POST" && new URL(req.url).pathname === "/") {
31
+ directPosts++;
32
+ if (hold) await hold;
33
+ }
34
+ return dispatch(req, ctx, ectx as never);
35
+ } };
36
+ },
37
+ };
38
+ const env = { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-key", DATA_REGION: "", TENANT_DIRECTORY: kv, TENANT_DO: ns } as WorkerBindings;
39
+ const dbA = d1(MIGRATIONS);
40
+ const dbB = d1(MIGRATIONS);
41
+ const ownerA = "owner-alice";
42
+ const ownerB = "owner-bob";
43
+ const a = hostedTenantContext(env, { id: "id-alice", db: dbA, publicUrl: "https://alice.example.com", ownerTokenHash: await sha256(ownerA), status: "active", config: { AGENT_NAME: "Alice" } });
44
+ const b = hostedTenantContext(env, { id: "id-bob", db: dbB, publicUrl: "https://bob.example.com", ownerTokenHash: await sha256(ownerB), status: "active", config: { AGENT_NAME: "Bob" } });
45
+ byId.set("id-alice", a); byId.set("id-bob", b);
46
+ store.set("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "default", version: 1 }));
47
+ store.set("tenant:bob", JSON.stringify({ id: "id-bob", status: "active", region: "default", version: 1 }));
48
+ const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
49
+ runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
50
+ const issue = async (ctx: TenantContext, ownerToken: string, label: string) => {
51
+ const r = await dispatch(new Request(ctx.PUBLIC_URL + "/owner/peers", { method: "POST", headers: { authorization: `Bearer ${ownerToken}`, "content-type": "application/json" }, body: JSON.stringify({ label }) }), ctx, ectx as never);
52
+ return (await r.json() as { token: string }).token;
53
+ };
54
+ const aliceAtBob = await issue(b, ownerB, "alice");
55
+ const bobAtAlice = await issue(a, ownerA, "bob");
56
+ assert.equal((await owner(b, "acct-bob", "PUT", "/owner/outbound-peers/alice", { url: a.PUBLIC_URL, token: bobAtAlice })).status, 200);
57
+ const sendToBob = async (text: string, contextId: string, push?: unknown) => {
58
+ const body = { jsonrpc: "2.0", id: "send-1", method: "message/send", params: { message: { kind: "message", role: "user", messageId: `m-${text}`, contextId, parts: [{ kind: "text", text }] }, configuration: { blocking: false, ...(push ? { pushNotificationConfig: push } : {}) } } };
59
+ const r = await dispatch(new Request(b.PUBLIC_URL, { method: "POST", headers: { authorization: `Bearer ${aliceAtBob}`, "content-type": "application/json" }, body: JSON.stringify(body) }), b, ectx as never);
60
+ const data = await r.json() as any;
61
+ assert.equal(r.status, 200, JSON.stringify(data));
62
+ return data.result.id as string;
63
+ };
64
+ return { a, b, dbA, dbB, owner, sendToBob, get directPosts() { return directPosts; }, blockDirect() {
65
+ let release!: () => void;
66
+ hold = new Promise<void>((resolve) => { release = resolve; });
67
+ return () => { hold = null; release(); };
68
+ } };
69
+ }
70
+
71
+ test("reply delivers once to an exact paired peer in the same context and a direct reply cannot loop", async (t) => {
72
+ const s = await fixture(t as never);
73
+ const taskId = await s.sendToBob("question", "ctx-direct");
74
+ const first = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "answer", state: "completed" });
75
+ assert.equal(first.status, 200, JSON.stringify(first.body));
76
+ const structured = (first.body as any).structuredContent as Delivery;
77
+ assert.deepEqual({ status: structured.status, method: structured.method, taskId: structured.taskId, alias: structured.target?.alias, host: structured.target?.host, http: structured.httpStatus },
78
+ { status: "delivered", method: "a2a-message", taskId, alias: "alice", host: "alice.example.com", http: 200 });
79
+ assert.match(String((first.body as any).text), /stored.*delivered/i);
80
+ assert.equal(JSON.stringify(first.body).includes("a2aow_"), false);
81
+ assert.equal(s.directPosts, 1);
82
+
83
+ const inbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox");
84
+ const delivered = (inbox.body as any[]).find((row) => row.text === "answer");
85
+ assert.equal(delivered.contextId, "ctx-direct");
86
+ const stored = await s.dbA.prepare("SELECT metadata_json FROM tasks WHERE id = ?").bind(delivered.taskId).first() as { metadata_json?: string };
87
+ const metadata = JSON.parse(stored.metadata_json || "{}");
88
+ assert.deepEqual(metadata["a2a.exposed/reply-delivery"].taskId, taskId);
89
+
90
+ const noLoop = await s.owner(s.a, "acct-alice", "POST", "/owner/mcp/reply", { taskId: delivered.taskId, text: "ack", state: "completed" });
91
+ assert.equal((noLoop.body as any).structuredContent.status, "stored-only");
92
+ assert.equal(s.directPosts, 1, "a directly delivered reply is never delivered back");
93
+
94
+ const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "answer", state: "completed" });
95
+ assert.equal(retry.status, 200, JSON.stringify(retry.body));
96
+ assert.equal((retry.body as any).structuredContent.status, "delivered");
97
+ assert.equal(s.directPosts, 1, "the same logical reply is idempotent");
98
+ const count = await s.dbA.prepare("SELECT COUNT(*) AS n FROM tasks WHERE context_id = ?").bind("ctx-direct").first() as { n: number };
99
+ assert.equal(count.n, 1);
100
+ });
101
+
102
+ test("push wins over paired direct delivery and a revoked inbound peer stores only", async (t) => {
103
+ const s = await fixture(t as never);
104
+ const originalFetch = globalThis.fetch;
105
+ let pushes = 0;
106
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
107
+ const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
108
+ if (u.host === "push.example.net") { pushes++; return new Response(null, { status: 204 }); }
109
+ return originalFetch(input);
110
+ }) as typeof fetch;
111
+ t.after(() => { globalThis.fetch = originalFetch; });
112
+ const pushedTask = await s.sendToBob("push question", "ctx-push", { id: "cfg", url: "https://push.example.net/update" });
113
+ const pushed = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId: pushedTask, text: "push answer" });
114
+ assert.equal((pushed.body as any).structuredContent.method, "push");
115
+ assert.equal(pushes, 1);
116
+ assert.equal(s.directPosts, 0, "push and direct message are never both used");
117
+
118
+ const revokedTask = await s.sendToBob("revoked question", "ctx-revoked");
119
+ await s.dbB.prepare("UPDATE peers SET revoked_at = ? WHERE label = 'alice'").bind(new Date().toISOString()).run();
120
+ const revoked = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId: revokedTask, text: "stored answer" });
121
+ assert.equal((revoked.body as any).structuredContent.status, "stored-only");
122
+ assert.equal(s.directPosts, 0);
123
+ const task = await s.dbB.prepare("SELECT state, status_json FROM tasks WHERE id = ?").bind(revokedTask).first() as { state: string; status_json: string };
124
+ assert.equal(task.state, "completed");
125
+ assert.match(task.status_json, /stored answer/);
126
+ });
127
+
128
+ test("stale recovery never overlaps an uncertain delivery and a later retry reads the settled result", async (t) => {
129
+ const s = await fixture(t as never);
130
+ const taskId = await s.sendToBob("concurrent question", "ctx-concurrent");
131
+ const release = s.blockDirect();
132
+ const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
133
+ while (s.directPosts === 0) await new Promise((resolve) => setTimeout(resolve, 0));
134
+ await s.dbB.prepare("UPDATE reply_deliveries SET updated_at = '2000-01-01T00:00:00.000Z'").run();
135
+ const raced = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
136
+ assert.equal(raced.status, 200);
137
+ assert.equal((raced.body as any).structuredContent.status, "delivery-failed");
138
+ assert.match(String((raced.body as any).structuredContent.detail), /uncertain.*not sent again/);
139
+ assert.equal(s.directPosts, 1, "the overlapping call cannot start a second delivery");
140
+ release();
141
+ assert.equal(((await first).body as any).structuredContent.status, "delivered");
142
+ const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
143
+ assert.equal(retry.status, 200);
144
+ assert.equal((retry.body as any).structuredContent.status, "delivered");
145
+ assert.equal(s.directPosts, 1);
146
+ });
147
+
148
+ test("a remote token rejection reports HTTP status without losing the stored reply", async (t) => {
149
+ const s = await fixture(t as never);
150
+ const taskId = await s.sendToBob("will the old token work?", "ctx-rejected");
151
+ await s.dbA.prepare("UPDATE peers SET revoked_at = ? WHERE label = 'bob'").bind(new Date().toISOString()).run();
152
+ const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "the answer stays", state: "completed" });
153
+ const delivery = (result.body as any).structuredContent as Delivery;
154
+ assert.deepEqual({ status: delivery.status, method: delivery.method, http: delivery.httpStatus },
155
+ { status: "delivery-failed", method: "a2a-message", http: 401 });
156
+ assert.match(String((result.body as any).text), /stored.*failed/i);
157
+ assert.equal(JSON.stringify(result.body).includes("a2aow_"), false);
158
+ const stored = await s.dbB.prepare("SELECT state, status_json FROM tasks WHERE id = ?").bind(taskId).first() as { state: string; status_json: string };
159
+ assert.equal(stored.state, "completed");
160
+ assert.match(stored.status_json, /the answer stays/);
161
+ });
@@ -72,3 +72,58 @@ 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
+ });
103
+
104
+ test("unsigned OpenBot owner test/preview and inbound wake never contact the webhook", async (t) => {
105
+ const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
106
+ WAKE_PRESET: "openbot", WAKE_WEBHOOK_URL: "https://hooks.example.com/wake", WAKE_WEBHOOK_KEY: "not-a-signing-secret" };
107
+ let calls = 0;
108
+ const original = globalThis.fetch;
109
+ globalThis.fetch = (async () => { calls++; throw new Error("must not send"); }) as typeof fetch;
110
+ t.after(() => { globalThis.fetch = original; });
111
+ const call = async (method: string, path: string, body?: unknown, auth = "Bearer owner-secret") => {
112
+ const pending: Promise<unknown>[] = [];
113
+ const res = await worker.fetch(new Request(BASE + path, { method, headers: { authorization: auth, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }) as any,
114
+ env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
115
+ await Promise.allSettled(pending);
116
+ return { status: res.status, body: await res.json() as any };
117
+ };
118
+ const preview = await call("GET", "/owner/wake/preview");
119
+ assert.equal(preview.status, 400);
120
+ assert.match(preview.body.error, /WAKE_HMAC_SECRET/);
121
+ const result = await call("POST", "/owner/wake/test");
122
+ assert.equal(result.body.status, null);
123
+ assert.match(result.body.info, /WAKE_HMAC_SECRET/);
124
+ const peer = await call("POST", "/owner/peers", { label: "dummy" });
125
+ const message = await call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "unsigned", parts: [{ kind: "text", text: "dummy" }] } } }, `Bearer ${peer.body.token}`);
126
+ assert.equal(message.status, 200);
127
+ assert.equal(message.body.error, undefined, "stored inbox message succeeds without a wake");
128
+ assert.equal(calls, 0);
129
+ });
@@ -7,7 +7,7 @@ import { Duplex } from "node:stream";
7
7
  import worker from "../src/index.ts";
8
8
  import { d1 } from "./d1.ts";
9
9
  import * as M from "../src/mcp.ts";
10
- import { dialChecked, fetchCheckedWake, publicWakeProblem, wakeNet, type AddressLookup, type CheckedDeliver, type WakeConnect } from "../src/wake-target.ts";
10
+ import { dialChecked, fetchCheckedPeer, fetchCheckedWake, publicWakeProblem, wakeNet, type AddressLookup, type CheckedDeliver, type WakeConnect } from "../src/wake-target.ts";
11
11
 
12
12
  const publicLookup: AddressLookup = async (host) => {
13
13
  if (host === "hook.example" || host === "also.example") return ["1.2.3.4"];
@@ -196,6 +196,26 @@ test("a cross-origin redirect is not followed, so credentials stay on the first
196
196
  assert.equal("refused" in result && result.refused, "cross-origin redirect");
197
197
  });
198
198
 
199
+ test("an authenticated peer call pins DNS, refuses every redirect, and fails closed on private or missing answers", async () => {
200
+ const hop = recorded(() => ({ status: 307, location: "https://hook.example/next" }));
201
+ const redirected = await fetchCheckedPeer("https://hook.example/a2a", {
202
+ method: "POST", headers: { authorization: "Bearer peer-secret" }, body: "{\"jsonrpc\":\"2.0\"}",
203
+ }, { lookup: publicLookup, deliver: hop.deliver });
204
+ assert.equal(hop.calls.length, 1);
205
+ assert.deepEqual({ ip: hop.calls[0].ip, hostname: hop.calls[0].hostname, redirect: hop.calls[0].redirect },
206
+ { ip: "1.2.3.4", hostname: "hook.example", redirect: "manual" });
207
+ assert.equal(hop.calls[0].authorization, "Bearer peer-secret");
208
+ assert.equal("refused" in redirected && redirected.refused, "redirects are not allowed");
209
+
210
+ let calls = 0;
211
+ const never: CheckedDeliver = async () => { calls++; return new Response(null, { status: 200 }); };
212
+ for (const url of ["https://split.example/a2a", "https://meta.example/a2a", "https://missing.example/a2a", "https://unknown.example/a2a"]) {
213
+ const result = await fetchCheckedPeer(url, { method: "POST", headers: { authorization: "Bearer peer-secret" }, body: "{}" }, { lookup: publicLookup, deliver: never });
214
+ assert.equal("refused" in result, true, url);
215
+ }
216
+ assert.equal(calls, 0, "no socket is opened after a failed address check");
217
+ });
218
+
199
219
  test("a same-origin redirect is checked again and keeps the wake headers", async () => {
200
220
  const hop = recorded((call) => call.url.endsWith("/wake")
201
221
  ? { status: 307, location: "https://hook.example/next" }
@@ -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,38 @@ 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
+ });
205
+
206
+ test("OpenBot refuses unsigned requests; a webhook key or Access credentials cannot replace signing", async () => {
207
+ for (const hmacSecret of [undefined, "", " "]) {
208
+ await assert.rejects(renderWake({ preset: "openbot", url: "https://hooks.example.com/wake", key: "wrong-auth", ...ACCESS, hmacSecret }, ev), /OpenBot requires WAKE_HMAC_SECRET/);
209
+ }
210
+ assert.equal(await renderWake({ preset: "openbot" }, ev), null);
211
+ });
212
+
213
+ test("OpenBot assigns a fresh delivery ID to every rendered attempt", async () => {
214
+ const cfg = { preset: "openbot", url: "https://hooks.example.com/wake", hmacSecret: "dummy" };
215
+ const first = (await renderWake(cfg, ev))!;
216
+ const second = (await renderWake(cfg, ev))!;
217
+ assert.notEqual(first.headers["x-openbot-delivery-id"], second.headers["x-openbot-delivery-id"]);
218
+ });