a2a-exposed 0.8.17 → 0.8.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands.mjs +4 -1
- package/lib/deploy.mjs +1 -1
- package/lib/main.mjs +13 -8
- package/lib/status.mjs +4 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/migrations/0012_conversation_peer_owner.sql +13 -0
- package/worker/scripts/stamp-version.mjs +1 -1
- package/worker/src/index.ts +147 -40
- package/worker/src/mcp.ts +2 -1
- package/worker/src/migrations.ts +2 -0
- package/worker/src/owner-rpc.ts +11 -5
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +16 -4
- package/worker/test/context-peer.test.ts +198 -79
- package/worker/test/facade.test.ts +6 -2
- package/worker/test/list-tasks.test.ts +120 -0
- package/worker/test/migrations.test.ts +18 -6
- package/worker/test/owner-rpc.test.ts +4 -0
- package/worker/test/push-config.test.ts +161 -0
- package/worker/test/wake-retry.test.ts +28 -0
- package/worker/test/wake.test.ts +25 -3
|
@@ -12,9 +12,11 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
12
12
|
assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
|
|
13
13
|
assert.equal(ok("GET", "/owner/summary")?.name, "GET /owner/summary");
|
|
14
14
|
assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
|
|
15
|
+
assert.equal(ok("GET", "/owner/inbox", { from: "ada" })?.search, "?from=ada");
|
|
15
16
|
assert.equal(ok("GET", "/owner/inbox", { limit: "20", cursor: "abc_DEF-012" })?.search, "?limit=20&cursor=abc_DEF-012");
|
|
16
17
|
assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
|
|
17
18
|
assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
|
|
19
|
+
assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50", from: "ada" })?.search, "?n=50&from=ada");
|
|
18
20
|
assert.equal(ok("GET", "/owner/outbound/task-1")?.name, "GET /owner/outbound/:id");
|
|
19
21
|
assert.equal(ok("GET", "/owner/peers")?.name, "GET /owner/peers");
|
|
20
22
|
assert.equal(ok("DELETE", "/owner/peers/ada")?.path, "/owner/peers/ada");
|
|
@@ -63,6 +65,8 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
63
65
|
assert.deepEqual(refused("GET", "/owner/inbox", { limit: "201" }), { ok: false, reason: "query" });
|
|
64
66
|
assert.deepEqual(refused("GET", "/owner/inbox", { limit: "0" }), { ok: false, reason: "query" });
|
|
65
67
|
assert.deepEqual(refused("GET", "/owner/inbox", { cursor: "bad+cursor" }), { ok: false, reason: "query" });
|
|
68
|
+
assert.deepEqual(refused("GET", "/owner/inbox", { from: "bad peer" }), { ok: false, reason: "query" });
|
|
69
|
+
assert.deepEqual(refused("GET", "/owner/history/ctx-1", { from: "../x" }), { ok: false, reason: "query" });
|
|
66
70
|
assert.deepEqual(refused("GET", "/owner/summary", { all: "1" }), { ok: false, reason: "query" });
|
|
67
71
|
assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
|
|
68
72
|
assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The inbox lists and deletes the push configs stored on a task, for the calling peer only.
|
|
2
|
+
// Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import worker from "../src/index.ts";
|
|
6
|
+
import { sha256 } from "../src/a2a.ts";
|
|
7
|
+
import { d1 } from "./d1.ts";
|
|
8
|
+
|
|
9
|
+
const BASE = "https://agent.example.com";
|
|
10
|
+
|
|
11
|
+
function setup() {
|
|
12
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
13
|
+
const env: any = {
|
|
14
|
+
DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", WAKE_PRESET: "generic",
|
|
15
|
+
};
|
|
16
|
+
const logs: string[] = [];
|
|
17
|
+
const secrets: string[] = [];
|
|
18
|
+
const orig = console.log;
|
|
19
|
+
console.log = (...args: unknown[]) => {
|
|
20
|
+
const line = args.map((a) => String(a)).join(" ");
|
|
21
|
+
logs.push(line);
|
|
22
|
+
if (secrets.some((secret) => secret && line.includes(secret))) return;
|
|
23
|
+
orig(...args);
|
|
24
|
+
};
|
|
25
|
+
const call = async (token: string, method: string, params: unknown, headers: Record<string, string> = {}) => {
|
|
26
|
+
const res = await worker.fetch(new Request(BASE + "/", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...headers },
|
|
29
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
30
|
+
}) as any, env, { waitUntil() {}, passThroughOnException() {} } as any);
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
let data: any = text;
|
|
33
|
+
try { data = text ? JSON.parse(text) : null; } catch { /* not json */ }
|
|
34
|
+
return { status: res.status, data, text };
|
|
35
|
+
};
|
|
36
|
+
const owner = async (method: string, path: string, json?: unknown) => {
|
|
37
|
+
const res = await worker.fetch(new Request(BASE + path, {
|
|
38
|
+
method, headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
|
|
39
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
40
|
+
}) as any, env, { waitUntil() {}, passThroughOnException() {} } as any);
|
|
41
|
+
return res.json() as Promise<any>;
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
DB, call, owner, logs, secrets,
|
|
45
|
+
restore: () => { console.log = orig; },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hides(body: string, secret: string) {
|
|
50
|
+
assert.equal(body.includes(secret), false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("list and delete return the caller's push config and hide it from other peers", async (t) => {
|
|
54
|
+
const s = setup();
|
|
55
|
+
t.after(s.restore);
|
|
56
|
+
const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).token as string;
|
|
57
|
+
const bob = (await s.owner("POST", "/owner/peers", { label: "bob" })).token as string;
|
|
58
|
+
const token = `pt_${crypto.randomUUID()}`;
|
|
59
|
+
s.secrets.push(token);
|
|
60
|
+
const tokenHash = await sha256(token);
|
|
61
|
+
const sent = await s.call(ada, "message/send", {
|
|
62
|
+
message: { kind: "message", role: "user", messageId: crypto.randomUUID(), parts: [{ kind: "text", text: "hello" }] },
|
|
63
|
+
});
|
|
64
|
+
assert.equal(sent.status, 200);
|
|
65
|
+
const taskId = sent.data.result.id as string;
|
|
66
|
+
const set = await s.call(ada, "tasks/pushNotificationConfig/set", {
|
|
67
|
+
taskId, pushNotificationConfig: { id: "cfg-1", url: "https://hooks.example.net/ada", token },
|
|
68
|
+
});
|
|
69
|
+
assert.equal(set.status, 200);
|
|
70
|
+
assert.equal(set.data.result.taskId, taskId);
|
|
71
|
+
assert.equal(await sha256(String(set.data.result.pushNotificationConfig.token)), tokenHash);
|
|
72
|
+
|
|
73
|
+
const listed = await s.call(ada, "ListTaskPushNotificationConfig", { taskId });
|
|
74
|
+
assert.equal(listed.status, 200);
|
|
75
|
+
assert.equal(listed.data.result.nextPageToken, "");
|
|
76
|
+
assert.equal(listed.data.result.configs.length, 1);
|
|
77
|
+
assert.equal(listed.data.result.configs[0].id, "cfg-1");
|
|
78
|
+
assert.equal(listed.data.result.configs[0].taskId, taskId);
|
|
79
|
+
assert.equal(await sha256(String(listed.data.result.configs[0].token)), tokenHash);
|
|
80
|
+
const plural = await s.call(ada, "ListTaskPushNotificationConfigs", { taskId });
|
|
81
|
+
assert.equal(await sha256(String(plural.data.result.configs[0].token)), tokenHash);
|
|
82
|
+
|
|
83
|
+
const as03 = await s.call(ada, "tasks/pushNotificationConfig/list", { id: taskId });
|
|
84
|
+
assert.equal(Array.isArray(as03.data.result), true);
|
|
85
|
+
assert.equal(as03.data.result.length, 1);
|
|
86
|
+
assert.equal(as03.data.result[0].pushNotificationConfig.id, "cfg-1");
|
|
87
|
+
assert.equal(await sha256(String(as03.data.result[0].pushNotificationConfig.token)), tokenHash);
|
|
88
|
+
|
|
89
|
+
const missing = await s.call(bob, "ListTaskPushNotificationConfig", { taskId: "missing-task-id" });
|
|
90
|
+
const foreign = await s.call(bob, "ListTaskPushNotificationConfig", { taskId });
|
|
91
|
+
assert.deepEqual(foreign.data.error, { code: -32001, message: "Task not found" });
|
|
92
|
+
assert.deepEqual(missing.data.error, foreign.data.error);
|
|
93
|
+
hides(foreign.text, token);
|
|
94
|
+
hides(missing.text, token);
|
|
95
|
+
const foreignDel = await s.call(bob, "DeleteTaskPushNotificationConfig", { taskId, id: "cfg-1" });
|
|
96
|
+
assert.deepEqual(foreignDel.data.error, { code: -32001, message: "Task not found" });
|
|
97
|
+
hides(foreignDel.text, token);
|
|
98
|
+
const foreign03 = await s.call(bob, "tasks/pushNotificationConfig/list", { id: taskId });
|
|
99
|
+
assert.deepEqual(foreign03.data.error, { code: -32001, message: "Task not found" });
|
|
100
|
+
hides(foreign03.text, token);
|
|
101
|
+
|
|
102
|
+
const gone = await s.call(ada, "DeleteTaskPushNotificationConfig", { taskId, id: "cfg-1" });
|
|
103
|
+
assert.deepEqual(gone.data.result, {});
|
|
104
|
+
const again = await s.call(ada, "DeleteTaskPushNotificationConfig", { taskId, id: "cfg-1" });
|
|
105
|
+
assert.deepEqual(again.data.result, {});
|
|
106
|
+
const after = await s.call(ada, "ListTaskPushNotificationConfig", { taskId });
|
|
107
|
+
assert.deepEqual(after.data.result, { configs: [], nextPageToken: "" });
|
|
108
|
+
|
|
109
|
+
const noId = await s.call(ada, "DeleteTaskPushNotificationConfig", { taskId });
|
|
110
|
+
assert.equal(noId.data.error?.code, -32602);
|
|
111
|
+
assert.equal(noId.data.error?.message, "Invalid params: push notification config id");
|
|
112
|
+
|
|
113
|
+
const bad = "page-token-secret-value";
|
|
114
|
+
const rejected = await s.call(ada, "ListTaskPushNotificationConfig", { taskId, pageToken: bad });
|
|
115
|
+
assert.equal(rejected.data.error?.message, "Invalid params: pageToken");
|
|
116
|
+
hides(rejected.text, bad);
|
|
117
|
+
hides(rejected.text, token);
|
|
118
|
+
|
|
119
|
+
assert.equal(s.logs.some((line) => line.includes(token)), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("1.0 list pages configs and 0.3 delete returns null", async (t) => {
|
|
123
|
+
const s = setup();
|
|
124
|
+
t.after(s.restore);
|
|
125
|
+
const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).token as string;
|
|
126
|
+
const token = `pt_${crypto.randomUUID()}`;
|
|
127
|
+
s.secrets.push(token);
|
|
128
|
+
const sent = await s.call(ada, "SendMessage", {
|
|
129
|
+
message: { role: "ROLE_USER", messageId: crypto.randomUUID(), parts: [{ text: "hello" }] },
|
|
130
|
+
});
|
|
131
|
+
const taskId = sent.data.result.task.id as string;
|
|
132
|
+
for (const id of ["c-1", "c-2", "c-3"]) {
|
|
133
|
+
const cfgToken = id === "c-2" ? token : `other-${id}`;
|
|
134
|
+
s.secrets.push(cfgToken);
|
|
135
|
+
const set = await s.call(ada, "CreateTaskPushNotificationConfig", {
|
|
136
|
+
taskId, pushNotificationConfig: { id, url: `https://hooks.example.net/${id}`, token: cfgToken },
|
|
137
|
+
});
|
|
138
|
+
assert.equal(set.data.error, undefined);
|
|
139
|
+
}
|
|
140
|
+
const seen: string[] = [];
|
|
141
|
+
let pageToken = "";
|
|
142
|
+
do {
|
|
143
|
+
const page = await s.call(ada, "ListTaskPushNotificationConfigs", { taskId, pageSize: 1, ...(pageToken ? { pageToken } : {}) });
|
|
144
|
+
assert.equal(page.data.result.configs.length, 1);
|
|
145
|
+
seen.push(page.data.result.configs[0].id);
|
|
146
|
+
if (page.data.result.configs[0].id === "c-2") assert.equal(await sha256(String(page.data.result.configs[0].token)), await sha256(token));
|
|
147
|
+
pageToken = page.data.result.nextPageToken;
|
|
148
|
+
if (!pageToken) assert.equal(pageToken, "");
|
|
149
|
+
} while (pageToken);
|
|
150
|
+
assert.deepEqual(seen, ["c-1", "c-2", "c-3"]);
|
|
151
|
+
|
|
152
|
+
const del = await s.call(ada, "tasks/pushNotificationConfig/delete", { id: taskId, pushNotificationConfigId: "c-2" });
|
|
153
|
+
assert.equal(del.data.result, null);
|
|
154
|
+
const delAgain = await s.call(ada, "tasks/pushNotificationConfig/delete", { id: taskId, pushNotificationConfigId: "c-2" });
|
|
155
|
+
assert.equal(delAgain.data.result, null);
|
|
156
|
+
const left = await s.call(ada, "tasks/pushNotificationConfig/list", { id: taskId });
|
|
157
|
+
assert.deepEqual(left.data.result.map((row: { pushNotificationConfig: { id: string } }) => row.pushNotificationConfig.id), ["c-1", "c-3"]);
|
|
158
|
+
hides(left.text, token);
|
|
159
|
+
hides(del.text, token);
|
|
160
|
+
assert.equal(s.logs.some((line) => line.includes(token)), false);
|
|
161
|
+
});
|
|
@@ -72,3 +72,31 @@ test("a 429 is due at Retry-After when the debounce is longer", async (t) => {
|
|
|
72
72
|
const at = await nextCronAt(resolveTenant(env), Date.now());
|
|
73
73
|
assert.ok(at != null && at >= before + 25_000 && at <= before + 35_000, `alarm ${at} is Retry-After, not the 120s debounce`);
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
test("OpenBot preview masks signing credentials and wake test accepts HTTP 202", async (t) => {
|
|
77
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
|
|
78
|
+
WAKE_PRESET: "openbot", WAKE_WEBHOOK_URL: "https://hooks.example.com/a2a", WAKE_HMAC_SECRET: "dummy-openbot-secret" };
|
|
79
|
+
const pending: Promise<unknown>[] = [];
|
|
80
|
+
const call = (method: string, path: string) => worker.fetch(new Request(BASE + path, { method, headers: { authorization: "Bearer owner-secret" } }) as any,
|
|
81
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
82
|
+
const preview = await (await call("GET", "/owner/wake/preview")).json() as any;
|
|
83
|
+
assert.equal(preview.preset, "openbot");
|
|
84
|
+
assert.equal(preview.signingInput, "<timestamp>.<delivery-id>.<exact-body-bytes>");
|
|
85
|
+
assert.equal(preview.request.headers["x-openbot-delivery-id"], "preview");
|
|
86
|
+
assert.equal(preview.request.headers["x-openbot-signature"], "••••");
|
|
87
|
+
assert.ok(!JSON.stringify(preview).includes(env.WAKE_HMAC_SECRET));
|
|
88
|
+
const original = globalThis.fetch;
|
|
89
|
+
let sent = false;
|
|
90
|
+
globalThis.fetch = (async (_url: any, init: any) => {
|
|
91
|
+
sent = true;
|
|
92
|
+
assert.equal(JSON.parse(init.body).type, "a2a_wake");
|
|
93
|
+
assert.match(init.headers["x-openbot-signature"], /^sha256=[0-9a-f]{64}$/);
|
|
94
|
+
assert.ok(init.headers["x-openbot-delivery-id"]);
|
|
95
|
+
return new Response("{}", { status: 202 });
|
|
96
|
+
}) as any;
|
|
97
|
+
t.after(() => { globalThis.fetch = original; });
|
|
98
|
+
const result = await (await call("POST", "/owner/wake/test")).json() as any;
|
|
99
|
+
assert.equal(result.status, 202);
|
|
100
|
+
assert.equal(sent, true);
|
|
101
|
+
await Promise.allSettled(pending);
|
|
102
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import { createHmac } from "node:crypto";
|
|
5
|
-
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, type WakeEvent } from "../src/wake.ts";
|
|
5
|
+
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, wakePayload, type WakeEvent } from "../src/wake.ts";
|
|
6
6
|
|
|
7
7
|
const ev: WakeEvent = {
|
|
8
8
|
contextId: "ctx-1", taskId: "t-2", taskIds: ["t-1", "t-2"], from: "peer-a",
|
|
@@ -19,7 +19,7 @@ test("grok-bot: bearer + JSON payload", async () => {
|
|
|
19
19
|
const b = JSON.parse(r.body);
|
|
20
20
|
assert.deepEqual([b.contextId, b.taskId, b.from, b.kind], ["ctx-1", "t-2", "peer-a", "inbound"]);
|
|
21
21
|
assert.deepEqual(b.taskIds, ["t-1", "t-2"]);
|
|
22
|
-
assert.match(b.hint, /inbox --context ctx-1/);
|
|
22
|
+
assert.match(b.hint, /inbox --context ctx-1 --from peer-a/);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
test("claude-code: anthropic-version + text body", async () => {
|
|
@@ -106,7 +106,7 @@ test("redact masks credentials", async () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test("summary for outbound updates points at history", () => {
|
|
109
|
-
assert.match(wakeSummary({ ...ev, kind: "outbound_update" }, "a2a"), /a2a history ctx-1/);
|
|
109
|
+
assert.match(wakeSummary({ ...ev, kind: "outbound_update" }, "a2a"), /a2a history ctx-1 --from peer-a/);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
test("generic custom key header is rendered with prefix and redacted in previews", async () => {
|
|
@@ -144,6 +144,7 @@ test("Access service-token headers are added for every preset, next to the prese
|
|
|
144
144
|
assert.equal(r.headers["CF-Access-Client-Id"], "abc123.access", preset);
|
|
145
145
|
assert.equal(r.headers["CF-Access-Client-Secret"], "s3cr3t-value-0123456789", preset);
|
|
146
146
|
if (preset === "hermes") assert.ok(r.headers["X-Webhook-Signature-V2"], "hermes keeps its HMAC");
|
|
147
|
+
else if (preset === "openbot") assert.ok(r.headers["x-openbot-signature"], "openbot keeps its HMAC");
|
|
147
148
|
else assert.ok(r.headers["authorization"], `${preset} keeps its bearer`);
|
|
148
149
|
}
|
|
149
150
|
});
|
|
@@ -180,3 +181,24 @@ test("cloudflareErrorHint explains tunnel / Access failures", () => {
|
|
|
180
181
|
assert.match(cloudflareErrorHint(530, "error code: 1016", { tunnel: true }), /tunnel hostname has no matching DNS record/);
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
|
|
185
|
+
// Fixed vector independently generated with Python hashlib/hmac.
|
|
186
|
+
test("openbot: envelope and signature cover the exact body and delivery ID", async () => {
|
|
187
|
+
assert.ok(PRESETS.includes("openbot"));
|
|
188
|
+
const req = (await renderWake({ preset: "openbot", url: "https://hooks.example.com/a2a", hmacSecret: "openbot-test-secret", key: "unused-key" }, ev,
|
|
189
|
+
{ nowMs: 1760000000000, requestId: "delivery-1" }))!;
|
|
190
|
+
assert.deepEqual(JSON.parse(req.body), { type: "a2a_wake", occurredAt: "2025-10-09T08:53:20.000Z", data: wakePayload(ev, "npx -y a2a-exposed@latest") });
|
|
191
|
+
assert.equal(req.headers["x-openbot-timestamp"], "1760000000");
|
|
192
|
+
assert.equal(req.headers["x-openbot-delivery-id"], "delivery-1");
|
|
193
|
+
assert.equal(req.headers["x-openbot-signature"], "sha256=8d28056bd4d5f700fd49865a0cc240bd769462d6e75aac4aa15fc7051c3ecc26");
|
|
194
|
+
assert.equal(req.headers["authorization"], undefined);
|
|
195
|
+
assert.equal(req.headers["X-Webhook-Signature-V2"], undefined);
|
|
196
|
+
const preview = redact(req);
|
|
197
|
+
assert.equal(preview.headers["x-openbot-delivery-id"], "delivery-1");
|
|
198
|
+
assert.equal(preview.headers["x-openbot-timestamp"], "1760000000");
|
|
199
|
+
assert.equal(preview.headers["x-openbot-signature"], "••••");
|
|
200
|
+
assert.ok(!JSON.stringify(preview).includes("openbot-test-secret"));
|
|
201
|
+
const unicode = (await renderWake({ preset: "openbot", url: req.url, hmacSecret: "s" }, { ...ev, preview: '雪 "quoted"\ntext' },
|
|
202
|
+
{ nowMs: 1760000000000, requestId: "delivery-2" }))!;
|
|
203
|
+
assert.equal(unicode.headers["x-openbot-signature"], "sha256=" + createHmac("sha256", "s").update(`1760000000.delivery-2.${unicode.body}`).digest("hex"));
|
|
204
|
+
});
|