a2a-exposed 0.8.19 → 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/main.mjs CHANGED
@@ -93,6 +93,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
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
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)
96
97
  --max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
97
98
  which defaults to 25 when this is unset
98
99
  wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.19",
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": {
@@ -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);
@@ -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,
@@ -2913,7 +3078,9 @@ function newPushToken(): string {
2913
3078
  return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2914
3079
  }
2915
3080
 
2916
- 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> {
2917
3084
  const enc = encodeURIComponent;
2918
3085
  switch (name) {
2919
3086
  case "inbox": {
@@ -2966,7 +3133,15 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2966
3133
  case "reply": {
2967
3134
  const state = argStr(a, "state", false) || "completed";
2968
3135
  const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/reply`, { text: argStr(a, "text"), state }));
2969
- 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 };
2970
3145
  }
2971
3146
  case "send": {
2972
3147
  const peer = await outboundPeer(ctx, argStr(a, "to"));
@@ -3105,9 +3280,10 @@ async function callTool(ctx: TenantContext, ectx: ExecutionContext, grant: Json,
3105
3280
  const args = params.arguments ?? {};
3106
3281
  if (typeof args !== "object" || Array.isArray(args)) return { error: [M.ERR.INVALID_PARAMS, "arguments must be an object"] };
3107
3282
  try {
3108
- const text = await runTool(ctx, ectx, name, args, grant);
3283
+ const value = await runTool(ctx, ectx, name, args, grant);
3109
3284
  log("mcp_tool", { label: grant.label, tool: name });
3110
- 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 } };
3111
3287
  } catch (e: any) {
3112
3288
  log("mcp_tool_failed", { label: grant.label, tool: name, error: String(e.message).slice(0, 200) });
3113
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.19";
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);
@@ -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();
@@ -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
+ });
@@ -100,3 +100,30 @@ test("OpenBot preview masks signing credentials and wake test accepts HTTP 202",
100
100
  assert.equal(sent, true);
101
101
  await Promise.allSettled(pending);
102
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" }
@@ -202,3 +202,17 @@ test("openbot: envelope and signature cover the exact body and delivery ID", asy
202
202
  { nowMs: 1760000000000, requestId: "delivery-2" }))!;
203
203
  assert.equal(unicode.headers["x-openbot-signature"], "sha256=" + createHmac("sha256", "s").update(`1760000000.delivery-2.${unicode.body}`).digest("hex"));
204
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
+ });