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 +9 -1
- package/lib/main.mjs +1 -0
- package/package.json +1 -1
- package/worker/migrations/0013_reply_delivery.sql +11 -0
- package/worker/src/export.ts +1 -1
- package/worker/src/index.ts +185 -9
- package/worker/src/mcp.ts +10 -3
- package/worker/src/migrations.ts +2 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-target.ts +51 -0
- package/worker/src/wake.ts +2 -1
- package/worker/test/mcp.test.ts +2 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/reply-delivery.test.ts +161 -0
- package/worker/test/wake-retry.test.ts +27 -0
- package/worker/test/wake-target.test.ts +21 -1
- package/worker/test/wake.test.ts +14 -0
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
|
-
|
|
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
|
@@ -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);
|
package/worker/src/export.ts
CHANGED
|
@@ -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];
|
package/worker/src/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 } },
|
package/worker/src/migrations.ts
CHANGED
|
@@ -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
|
];
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.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);
|
package/worker/src/wake.ts
CHANGED
|
@@ -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
|
|
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();
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -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" }
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -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
|
+
});
|