a2a-exposed 0.8.18 → 0.8.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands.mjs +9 -1
- package/lib/deploy.mjs +1 -1
- package/lib/main.mjs +5 -2
- package/lib/status.mjs +4 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/migrations/0013_reply_delivery.sql +11 -0
- package/worker/scripts/stamp-version.mjs +1 -1
- package/worker/src/export.ts +1 -1
- package/worker/src/index.ts +186 -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 +14 -3
- 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 +55 -0
- package/worker/test/wake-target.test.ts +21 -1
- package/worker/test/wake.test.ts +37 -1
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/deploy.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export const CLAUDE_CODE_NOTE = [
|
|
|
25
25
|
" Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
|
|
26
26
|
].join("\n");
|
|
27
27
|
|
|
28
|
-
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
|
|
28
|
+
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
|
|
29
29
|
// WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
|
|
30
30
|
// by hand for your own Access-protected endpoint)
|
|
31
31
|
export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
|
package/lib/main.mjs
CHANGED
|
@@ -92,9 +92,12 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
92
92
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
93
93
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
94
94
|
save wake settings, upload wake secrets from env, and redeploy (one step)
|
|
95
|
+
openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
|
|
96
|
+
the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
|
|
95
97
|
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
96
98
|
which defaults to 25 when this is unset
|
|
97
99
|
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
100
|
+
openbot includes a sample delivery ID and canonical signing-input description
|
|
98
101
|
+ sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
|
|
99
102
|
local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
|
|
100
103
|
are exported in this shell. match: the exported value is the one on the Worker.
|
|
@@ -116,7 +119,7 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
|
|
|
116
119
|
creates nothing when you run it again. A partial tunnel stops and asks for tunnel rm.
|
|
117
120
|
The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
|
|
118
121
|
The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
|
|
119
|
-
Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
122
|
+
Export WAKE_WEBHOOK_KEY (hermes/openbot: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
120
123
|
The connector machine needs cloudflared installed
|
|
121
124
|
(https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
|
|
122
125
|
Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
|
|
@@ -267,7 +270,7 @@ Feedback
|
|
|
267
270
|
polling), and hosted or self-hosted. --connection overrides
|
|
268
271
|
that detection. Remove tokens from the message and from --logs.
|
|
269
272
|
|
|
270
|
-
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
|
|
273
|
+
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
|
|
271
274
|
Which clients are tested end to end is in the client table:
|
|
272
275
|
https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
|
|
273
276
|
Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
|
package/lib/status.mjs
CHANGED
|
@@ -171,8 +171,8 @@ function nextStepCore(s) {
|
|
|
171
171
|
if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
|
|
172
172
|
if (t && !t.tokenFileOk)
|
|
173
173
|
return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
|
|
174
|
-
const agentSecret = s.wake.preset
|
|
175
|
-
const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
174
|
+
const agentSecret = ["hermes", "openbot"].includes(s.wake.preset) ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
|
|
175
|
+
const needsAgentSecret = s.wake.preset === "openbot" ? !s.wake.hasHmacSecret : LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
176
176
|
const now = typeof s.now === "number" ? s.now : Date.now();
|
|
177
177
|
if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
|
|
178
178
|
if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
|
|
@@ -184,7 +184,7 @@ function nextStepCore(s) {
|
|
|
184
184
|
if (t && t.connections === 0)
|
|
185
185
|
return fail(`the tunnel has ${t.state === "inactive" ? "never connected" : "no running connector"}: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
|
|
186
186
|
if (s.wake.configured && needsAgentSecret)
|
|
187
|
-
return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${s.wake.preset
|
|
187
|
+
return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${["hermes", "openbot"].includes(s.wake.preset) ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
|
|
188
188
|
// not broken, but peers can't pair until the human sets the approval password
|
|
189
189
|
const also = [];
|
|
190
190
|
if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
|
|
@@ -195,7 +195,7 @@ function nextStepCore(s) {
|
|
|
195
195
|
if (!s.wake.configured) {
|
|
196
196
|
const poll = "the agent must check the A2A endpoint on a schedule (Hermes and OpenClaw have copy-paste commands in the setup references)";
|
|
197
197
|
if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
|
|
198
|
-
return done2(`no wake webhook: export WAKE_WEBHOOK_URL and
|
|
198
|
+
return done2(`no wake webhook: export WAKE_WEBHOOK_URL and ${agentSecret}, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
|
|
199
199
|
const z = s.zones;
|
|
200
200
|
const tunnel = !z ? `a local-only webhook can use \`${CLI} tunnel create\` (needs a zone on the account)`
|
|
201
201
|
: !z.length ? "the account has no domain (zone), so the secure tunnel is not available"
|
package/package.json
CHANGED
|
@@ -28,7 +28,7 @@ const plain: Record<string, string> = {
|
|
|
28
28
|
PROVIDER_ORGANIZATION: v("A2A_PROVIDER_ORGANIZATION"),
|
|
29
29
|
PROVIDER_URL: v("A2A_PROVIDER_URL"),
|
|
30
30
|
DOCUMENTATION_URL: v("A2A_DOCUMENTATION_URL"),
|
|
31
|
-
WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
|
|
31
|
+
WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
|
|
32
32
|
WAKE_AGENT_ID: v("WAKE_AGENT_ID"), // openclaw presets (default "main")
|
|
33
33
|
WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
|
|
34
34
|
WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- One delivery outcome for one logical owner reply. The key includes the inbound turn and reply content,
|
|
2
|
+
-- so an HTTP/MCP retry is idempotent while a later peer message permits the same words to be sent again.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS reply_deliveries (
|
|
4
|
+
delivery_key TEXT PRIMARY KEY,
|
|
5
|
+
task_id TEXT NOT NULL,
|
|
6
|
+
message_id TEXT NOT NULL,
|
|
7
|
+
result_json TEXT,
|
|
8
|
+
created_at TEXT NOT NULL,
|
|
9
|
+
updated_at TEXT NOT NULL
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS reply_deliveries_task ON reply_deliveries(task_id, created_at);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Write worker/src/version.ts from cli/package.json.
|
|
1
|
+
// Write worker/src/version.ts from cli/package.json. Release preparation runs `npm run version:set`
|
|
2
2
|
// before the Worker tests and build, and prepack bundles this tree into the npm package.
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
package/worker/src/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,
|
|
@@ -1619,6 +1784,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1619
1784
|
fingerprints: { url: await A.fingerprint(ctx.WAKE_WEBHOOK_URL), key: await A.fingerprint(ctx.WAKE_WEBHOOK_KEY),
|
|
1620
1785
|
hmacSecret: await A.fingerprint(ctx.WAKE_HMAC_SECRET),
|
|
1621
1786
|
accessClientId: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_ID), accessClientSecret: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_SECRET) },
|
|
1787
|
+
...(preset(ctx) === "openbot" ? { signingInput: "<timestamp>.<delivery-id>.<exact-body-bytes>" } : {}),
|
|
1622
1788
|
request: req ? redact(req) : null });
|
|
1623
1789
|
}
|
|
1624
1790
|
if (seg[0] === "wake" && seg[1] === "test" && m === "POST") {
|
|
@@ -2912,7 +3078,9 @@ function newPushToken(): string {
|
|
|
2912
3078
|
return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2913
3079
|
}
|
|
2914
3080
|
|
|
2915
|
-
|
|
3081
|
+
type ToolRun = string | { text: string; structuredContent: Json };
|
|
3082
|
+
|
|
3083
|
+
async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string, a: Json, grant?: Json): Promise<ToolRun> {
|
|
2916
3084
|
const enc = encodeURIComponent;
|
|
2917
3085
|
switch (name) {
|
|
2918
3086
|
case "inbox": {
|
|
@@ -2965,7 +3133,15 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2965
3133
|
case "reply": {
|
|
2966
3134
|
const state = argStr(a, "state", false) || "completed";
|
|
2967
3135
|
const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/reply`, { text: argStr(a, "text"), state }));
|
|
2968
|
-
|
|
3136
|
+
const d: ReplyDelivery = r.delivery;
|
|
3137
|
+
const where = d.target ? `${d.target.alias ? `**${d.target.alias}** at ` : ""}\`${d.target.host}\`` : "the task";
|
|
3138
|
+
const http = d.httpStatus ? ` (HTTP ${d.httpStatus})` : "";
|
|
3139
|
+
const line = d.status === "delivered"
|
|
3140
|
+
? `Task \`${d.taskId}\` is now ${d.state}. Reply stored and delivered to ${where} via ${d.method}${http}.`
|
|
3141
|
+
: d.status === "delivery-failed"
|
|
3142
|
+
? `Task \`${d.taskId}\` is now ${d.state}. Reply stored; delivery to ${where} failed${http}, so the stored reply was not lost.`
|
|
3143
|
+
: `Task \`${d.taskId}\` is now ${d.state}. Reply stored for the peer to fetch.`;
|
|
3144
|
+
return { text: `${line}\n\n${d.detail}`, structuredContent: d };
|
|
2969
3145
|
}
|
|
2970
3146
|
case "send": {
|
|
2971
3147
|
const peer = await outboundPeer(ctx, argStr(a, "to"));
|
|
@@ -3104,9 +3280,10 @@ async function callTool(ctx: TenantContext, ectx: ExecutionContext, grant: Json,
|
|
|
3104
3280
|
const args = params.arguments ?? {};
|
|
3105
3281
|
if (typeof args !== "object" || Array.isArray(args)) return { error: [M.ERR.INVALID_PARAMS, "arguments must be an object"] };
|
|
3106
3282
|
try {
|
|
3107
|
-
const
|
|
3283
|
+
const value = await runTool(ctx, ectx, name, args, grant);
|
|
3108
3284
|
log("mcp_tool", { label: grant.label, tool: name });
|
|
3109
|
-
return { result: { content: [{ type: "text", text
|
|
3285
|
+
return { result: { content: [{ type: "text", text: typeof value === "string" ? value : value.text }],
|
|
3286
|
+
...(typeof value === "string" ? {} : { structuredContent: value.structuredContent }), isError: false } };
|
|
3110
3287
|
} catch (e: any) {
|
|
3111
3288
|
log("mcp_tool_failed", { label: grant.label, tool: name, error: String(e.message).slice(0, 200) });
|
|
3112
3289
|
return { result: { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true } };
|
package/worker/src/mcp.ts
CHANGED
|
@@ -318,7 +318,7 @@ type Schema = Record<string, unknown>;
|
|
|
318
318
|
const str = (description: string, extra: Schema = {}): Schema => ({ type: "string", description, ...extra });
|
|
319
319
|
const ro = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
320
320
|
|
|
321
|
-
export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; annotations: Schema }[] = [
|
|
321
|
+
export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; outputSchema?: Schema; annotations: Schema }[] = [
|
|
322
322
|
{ name: "inbox", title: "Inbox", annotations: ro,
|
|
323
323
|
description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
|
|
324
324
|
inputSchema: { type: "object", properties: {
|
|
@@ -338,9 +338,16 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
338
338
|
description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
|
|
339
339
|
inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
|
|
340
340
|
{ name: "reply", title: "Reply to task", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
341
|
-
description: `Answer an inbound task. state: completed (default; the text becomes the result), input-required (ask the peer a question), working, failed or rejected. The
|
|
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
|
@@ -46,7 +46,7 @@ export type WakeConfig = {
|
|
|
46
46
|
|
|
47
47
|
export type WakeRequest = { url: string; method: "POST"; headers: Record<string, string>; body: string };
|
|
48
48
|
|
|
49
|
-
export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"] as const;
|
|
49
|
+
export const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"] as const;
|
|
50
50
|
|
|
51
51
|
/** Default debounce window per preset (seconds), used when WAKE_DEBOUNCE_SECONDS is unset. */
|
|
52
52
|
export function defaultDebounceSeconds(preset: string): number {
|
|
@@ -177,9 +177,10 @@ async function hermesSign(headers: Record<string, string>, secret: string, body:
|
|
|
177
177
|
headers["X-Webhook-Signature-V2"] = await hmacSha256Hex(secret, `${ts}.${body}`);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
/** Build the HTTP request
|
|
180
|
+
/** Build the HTTP request, return null without a URL, or throw when OpenBot signing is missing. */
|
|
181
181
|
export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?: number; requestId?: string } = {}): Promise<WakeRequest | null> {
|
|
182
182
|
if (!cfg.url) return null;
|
|
183
|
+
if (cfg.preset === "openbot" && !cfg.hmacSecret?.trim()) throw new Error("OpenBot requires WAKE_HMAC_SECRET from the routine settings");
|
|
183
184
|
const cli = cfg.cliCommand || DEFAULT_CLI;
|
|
184
185
|
const nowMs = opts.nowMs ?? Date.now();
|
|
185
186
|
const requestId = opts.requestId ?? crypto.randomUUID();
|
|
@@ -207,6 +208,16 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
|
|
|
207
208
|
// `name` is the hook name OpenClaw filters match. It stays "A2A inbox". The wake text itself starts with "A2A endpoint:".
|
|
208
209
|
body = JSON.stringify({ message: wakeSummary(ev, cli), name: "A2A inbox", agentId: cfg.agentId || "main", sessionMode: "isolated", deliver: false });
|
|
209
210
|
break;
|
|
211
|
+
case "openbot": {
|
|
212
|
+
const ts = String(Math.floor(nowMs / 1000));
|
|
213
|
+
headers["x-openbot-timestamp"] = ts;
|
|
214
|
+
headers["x-openbot-delivery-id"] = requestId;
|
|
215
|
+
// Serialize once: the signature covers exactly the UTF-8 bytes sent to OpenBot.
|
|
216
|
+
body = JSON.stringify({ type: "a2a_wake", occurredAt: new Date(nowMs).toISOString(), data: wakePayload(ev, cli) });
|
|
217
|
+
if (cfg.hmacSecret)
|
|
218
|
+
headers["x-openbot-signature"] = `sha256=${await hmacSha256Hex(cfg.hmacSecret, `${ts}.${requestId}.${body}`)}`;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
210
221
|
case "hermes":
|
|
211
222
|
headers["x-request-id"] = requestId;
|
|
212
223
|
body = JSON.stringify(wakePayload(ev, cli));
|
|
@@ -268,7 +279,7 @@ export function cloudflareErrorHint(status: number, body: string, opts?: { tunne
|
|
|
268
279
|
|
|
269
280
|
/** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
|
|
270
281
|
export const PREVIEW_PLAIN_HEADERS = new Set([
|
|
271
|
-
"content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
|
|
282
|
+
"content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp", "x-openbot-timestamp", "x-openbot-delivery-id",
|
|
272
283
|
]);
|
|
273
284
|
|
|
274
285
|
export function previewHeaderPlain(name: string): boolean {
|
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
|
+
});
|
|
@@ -72,3 +72,58 @@ test("a 429 is due at Retry-After when the debounce is longer", async (t) => {
|
|
|
72
72
|
const at = await nextCronAt(resolveTenant(env), Date.now());
|
|
73
73
|
assert.ok(at != null && at >= before + 25_000 && at <= before + 35_000, `alarm ${at} is Retry-After, not the 120s debounce`);
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
test("OpenBot preview masks signing credentials and wake test accepts HTTP 202", async (t) => {
|
|
77
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
|
|
78
|
+
WAKE_PRESET: "openbot", WAKE_WEBHOOK_URL: "https://hooks.example.com/a2a", WAKE_HMAC_SECRET: "dummy-openbot-secret" };
|
|
79
|
+
const pending: Promise<unknown>[] = [];
|
|
80
|
+
const call = (method: string, path: string) => worker.fetch(new Request(BASE + path, { method, headers: { authorization: "Bearer owner-secret" } }) as any,
|
|
81
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
82
|
+
const preview = await (await call("GET", "/owner/wake/preview")).json() as any;
|
|
83
|
+
assert.equal(preview.preset, "openbot");
|
|
84
|
+
assert.equal(preview.signingInput, "<timestamp>.<delivery-id>.<exact-body-bytes>");
|
|
85
|
+
assert.equal(preview.request.headers["x-openbot-delivery-id"], "preview");
|
|
86
|
+
assert.equal(preview.request.headers["x-openbot-signature"], "••••");
|
|
87
|
+
assert.ok(!JSON.stringify(preview).includes(env.WAKE_HMAC_SECRET));
|
|
88
|
+
const original = globalThis.fetch;
|
|
89
|
+
let sent = false;
|
|
90
|
+
globalThis.fetch = (async (_url: any, init: any) => {
|
|
91
|
+
sent = true;
|
|
92
|
+
assert.equal(JSON.parse(init.body).type, "a2a_wake");
|
|
93
|
+
assert.match(init.headers["x-openbot-signature"], /^sha256=[0-9a-f]{64}$/);
|
|
94
|
+
assert.ok(init.headers["x-openbot-delivery-id"]);
|
|
95
|
+
return new Response("{}", { status: 202 });
|
|
96
|
+
}) as any;
|
|
97
|
+
t.after(() => { globalThis.fetch = original; });
|
|
98
|
+
const result = await (await call("POST", "/owner/wake/test")).json() as any;
|
|
99
|
+
assert.equal(result.status, 202);
|
|
100
|
+
assert.equal(sent, true);
|
|
101
|
+
await Promise.allSettled(pending);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("unsigned OpenBot owner test/preview and inbound wake never contact the webhook", async (t) => {
|
|
105
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
|
|
106
|
+
WAKE_PRESET: "openbot", WAKE_WEBHOOK_URL: "https://hooks.example.com/wake", WAKE_WEBHOOK_KEY: "not-a-signing-secret" };
|
|
107
|
+
let calls = 0;
|
|
108
|
+
const original = globalThis.fetch;
|
|
109
|
+
globalThis.fetch = (async () => { calls++; throw new Error("must not send"); }) as typeof fetch;
|
|
110
|
+
t.after(() => { globalThis.fetch = original; });
|
|
111
|
+
const call = async (method: string, path: string, body?: unknown, auth = "Bearer owner-secret") => {
|
|
112
|
+
const pending: Promise<unknown>[] = [];
|
|
113
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers: { authorization: auth, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }) as any,
|
|
114
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
115
|
+
await Promise.allSettled(pending);
|
|
116
|
+
return { status: res.status, body: await res.json() as any };
|
|
117
|
+
};
|
|
118
|
+
const preview = await call("GET", "/owner/wake/preview");
|
|
119
|
+
assert.equal(preview.status, 400);
|
|
120
|
+
assert.match(preview.body.error, /WAKE_HMAC_SECRET/);
|
|
121
|
+
const result = await call("POST", "/owner/wake/test");
|
|
122
|
+
assert.equal(result.body.status, null);
|
|
123
|
+
assert.match(result.body.info, /WAKE_HMAC_SECRET/);
|
|
124
|
+
const peer = await call("POST", "/owner/peers", { label: "dummy" });
|
|
125
|
+
const message = await call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "unsigned", parts: [{ kind: "text", text: "dummy" }] } } }, `Bearer ${peer.body.token}`);
|
|
126
|
+
assert.equal(message.status, 200);
|
|
127
|
+
assert.equal(message.body.error, undefined, "stored inbox message succeeds without a wake");
|
|
128
|
+
assert.equal(calls, 0);
|
|
129
|
+
});
|
|
@@ -7,7 +7,7 @@ import { Duplex } from "node:stream";
|
|
|
7
7
|
import worker from "../src/index.ts";
|
|
8
8
|
import { d1 } from "./d1.ts";
|
|
9
9
|
import * as M from "../src/mcp.ts";
|
|
10
|
-
import { dialChecked, fetchCheckedWake, publicWakeProblem, wakeNet, type AddressLookup, type CheckedDeliver, type WakeConnect } from "../src/wake-target.ts";
|
|
10
|
+
import { dialChecked, fetchCheckedPeer, fetchCheckedWake, publicWakeProblem, wakeNet, type AddressLookup, type CheckedDeliver, type WakeConnect } from "../src/wake-target.ts";
|
|
11
11
|
|
|
12
12
|
const publicLookup: AddressLookup = async (host) => {
|
|
13
13
|
if (host === "hook.example" || host === "also.example") return ["1.2.3.4"];
|
|
@@ -196,6 +196,26 @@ test("a cross-origin redirect is not followed, so credentials stay on the first
|
|
|
196
196
|
assert.equal("refused" in result && result.refused, "cross-origin redirect");
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
test("an authenticated peer call pins DNS, refuses every redirect, and fails closed on private or missing answers", async () => {
|
|
200
|
+
const hop = recorded(() => ({ status: 307, location: "https://hook.example/next" }));
|
|
201
|
+
const redirected = await fetchCheckedPeer("https://hook.example/a2a", {
|
|
202
|
+
method: "POST", headers: { authorization: "Bearer peer-secret" }, body: "{\"jsonrpc\":\"2.0\"}",
|
|
203
|
+
}, { lookup: publicLookup, deliver: hop.deliver });
|
|
204
|
+
assert.equal(hop.calls.length, 1);
|
|
205
|
+
assert.deepEqual({ ip: hop.calls[0].ip, hostname: hop.calls[0].hostname, redirect: hop.calls[0].redirect },
|
|
206
|
+
{ ip: "1.2.3.4", hostname: "hook.example", redirect: "manual" });
|
|
207
|
+
assert.equal(hop.calls[0].authorization, "Bearer peer-secret");
|
|
208
|
+
assert.equal("refused" in redirected && redirected.refused, "redirects are not allowed");
|
|
209
|
+
|
|
210
|
+
let calls = 0;
|
|
211
|
+
const never: CheckedDeliver = async () => { calls++; return new Response(null, { status: 200 }); };
|
|
212
|
+
for (const url of ["https://split.example/a2a", "https://meta.example/a2a", "https://missing.example/a2a", "https://unknown.example/a2a"]) {
|
|
213
|
+
const result = await fetchCheckedPeer(url, { method: "POST", headers: { authorization: "Bearer peer-secret" }, body: "{}" }, { lookup: publicLookup, deliver: never });
|
|
214
|
+
assert.equal("refused" in result, true, url);
|
|
215
|
+
}
|
|
216
|
+
assert.equal(calls, 0, "no socket is opened after a failed address check");
|
|
217
|
+
});
|
|
218
|
+
|
|
199
219
|
test("a same-origin redirect is checked again and keeps the wake headers", async () => {
|
|
200
220
|
const hop = recorded((call) => call.url.endsWith("/wake")
|
|
201
221
|
? { status: 307, location: "https://hook.example/next" }
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import { createHmac } from "node:crypto";
|
|
5
|
-
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, type WakeEvent } from "../src/wake.ts";
|
|
5
|
+
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, wakePayload, type WakeEvent } from "../src/wake.ts";
|
|
6
6
|
|
|
7
7
|
const ev: WakeEvent = {
|
|
8
8
|
contextId: "ctx-1", taskId: "t-2", taskIds: ["t-1", "t-2"], from: "peer-a",
|
|
@@ -144,6 +144,7 @@ test("Access service-token headers are added for every preset, next to the prese
|
|
|
144
144
|
assert.equal(r.headers["CF-Access-Client-Id"], "abc123.access", preset);
|
|
145
145
|
assert.equal(r.headers["CF-Access-Client-Secret"], "s3cr3t-value-0123456789", preset);
|
|
146
146
|
if (preset === "hermes") assert.ok(r.headers["X-Webhook-Signature-V2"], "hermes keeps its HMAC");
|
|
147
|
+
else if (preset === "openbot") assert.ok(r.headers["x-openbot-signature"], "openbot keeps its HMAC");
|
|
147
148
|
else assert.ok(r.headers["authorization"], `${preset} keeps its bearer`);
|
|
148
149
|
}
|
|
149
150
|
});
|
|
@@ -180,3 +181,38 @@ test("cloudflareErrorHint explains tunnel / Access failures", () => {
|
|
|
180
181
|
assert.match(cloudflareErrorHint(530, "error code: 1016", { tunnel: true }), /tunnel hostname has no matching DNS record/);
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
|
|
185
|
+
// Fixed vector independently generated with Python hashlib/hmac.
|
|
186
|
+
test("openbot: envelope and signature cover the exact body and delivery ID", async () => {
|
|
187
|
+
assert.ok(PRESETS.includes("openbot"));
|
|
188
|
+
const req = (await renderWake({ preset: "openbot", url: "https://hooks.example.com/a2a", hmacSecret: "openbot-test-secret", key: "unused-key" }, ev,
|
|
189
|
+
{ nowMs: 1760000000000, requestId: "delivery-1" }))!;
|
|
190
|
+
assert.deepEqual(JSON.parse(req.body), { type: "a2a_wake", occurredAt: "2025-10-09T08:53:20.000Z", data: wakePayload(ev, "npx -y a2a-exposed@latest") });
|
|
191
|
+
assert.equal(req.headers["x-openbot-timestamp"], "1760000000");
|
|
192
|
+
assert.equal(req.headers["x-openbot-delivery-id"], "delivery-1");
|
|
193
|
+
assert.equal(req.headers["x-openbot-signature"], "sha256=8d28056bd4d5f700fd49865a0cc240bd769462d6e75aac4aa15fc7051c3ecc26");
|
|
194
|
+
assert.equal(req.headers["authorization"], undefined);
|
|
195
|
+
assert.equal(req.headers["X-Webhook-Signature-V2"], undefined);
|
|
196
|
+
const preview = redact(req);
|
|
197
|
+
assert.equal(preview.headers["x-openbot-delivery-id"], "delivery-1");
|
|
198
|
+
assert.equal(preview.headers["x-openbot-timestamp"], "1760000000");
|
|
199
|
+
assert.equal(preview.headers["x-openbot-signature"], "••••");
|
|
200
|
+
assert.ok(!JSON.stringify(preview).includes("openbot-test-secret"));
|
|
201
|
+
const unicode = (await renderWake({ preset: "openbot", url: req.url, hmacSecret: "s" }, { ...ev, preview: '雪 "quoted"\ntext' },
|
|
202
|
+
{ nowMs: 1760000000000, requestId: "delivery-2" }))!;
|
|
203
|
+
assert.equal(unicode.headers["x-openbot-signature"], "sha256=" + createHmac("sha256", "s").update(`1760000000.delivery-2.${unicode.body}`).digest("hex"));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("OpenBot refuses unsigned requests; a webhook key or Access credentials cannot replace signing", async () => {
|
|
207
|
+
for (const hmacSecret of [undefined, "", " "]) {
|
|
208
|
+
await assert.rejects(renderWake({ preset: "openbot", url: "https://hooks.example.com/wake", key: "wrong-auth", ...ACCESS, hmacSecret }, ev), /OpenBot requires WAKE_HMAC_SECRET/);
|
|
209
|
+
}
|
|
210
|
+
assert.equal(await renderWake({ preset: "openbot" }, ev), null);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("OpenBot assigns a fresh delivery ID to every rendered attempt", async () => {
|
|
214
|
+
const cfg = { preset: "openbot", url: "https://hooks.example.com/wake", hmacSecret: "dummy" };
|
|
215
|
+
const first = (await renderWake(cfg, ev))!;
|
|
216
|
+
const second = (await renderWake(cfg, ev))!;
|
|
217
|
+
assert.notEqual(first.headers["x-openbot-delivery-id"], second.headers["x-openbot-delivery-id"]);
|
|
218
|
+
});
|