a2a-exposed 0.8.14 → 0.8.16
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 +3 -1
- package/lib/deploy.mjs +33 -6
- package/lib/main.mjs +6 -3
- package/package.json +1 -1
- package/worker/migrations/0009_outbound_seen.sql +2 -0
- package/worker/src/index.ts +276 -36
- package/worker/src/mcp.ts +2 -2
- package/worker/src/migrations.ts +2 -0
- package/worker/src/owner-rpc.ts +32 -7
- package/worker/src/tenancy.ts +19 -0
- package/worker/src/version.ts +1 -1
- package/worker/test/facade.test.ts +56 -1
- package/worker/test/migrations.test.ts +4 -4
- package/worker/test/outbound-push.test.ts +342 -0
- package/worker/test/outbound-reply.test.ts +538 -0
- package/worker/test/owner-rpc.test.ts +15 -1
- package/worker/test/version-header.test.ts +100 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// A sends, B replies, and the reply shows in A's history and inbox. Both directions.
|
|
2
|
+
// Public peers go through fetch. Tenants on this deployment's domain are a directory lookup and stub.fetch:
|
|
3
|
+
// a public fetch of that hostname is 522, and these tests fail if one happens.
|
|
4
|
+
// Push tokens and peer tokens must not appear in a response or a log line. The peer request does carry the
|
|
5
|
+
// push token; that is how the reply is authenticated, and only its hash is stored on the sender.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
13
|
+
import { sha256 } from "../src/a2a.ts";
|
|
14
|
+
import { clearDirectoryCache } from "../src/directory.ts";
|
|
15
|
+
import { dispatch, runOwnerRpc } from "../src/index.ts";
|
|
16
|
+
import { hostedTenantContext, resolveTenant, type DoNamespace, type KvNamespace, type TenantContext, type WorkerBindings } from "../src/tenancy.ts";
|
|
17
|
+
import { d1 } from "./d1.ts";
|
|
18
|
+
|
|
19
|
+
const MIGRATIONS = new URL("../migrations/", import.meta.url);
|
|
20
|
+
const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
|
|
21
|
+
const require = createRequire(new URL("../package.json", import.meta.url));
|
|
22
|
+
|
|
23
|
+
type Row = { taskId?: string; contextId?: string; text?: string; kind?: string; state?: string; from?: string };
|
|
24
|
+
type Hist = { dir?: string; event?: string; text?: string; context_id?: string };
|
|
25
|
+
type SendBody = { text?: string; isError?: boolean };
|
|
26
|
+
|
|
27
|
+
function forbid(blob: string, secrets: string[], where: string) {
|
|
28
|
+
for (const secret of secrets) {
|
|
29
|
+
assert.equal(secret.length > 0 && blob.includes(secret), false, `${where} contains a token`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function contextOf(text: string): string {
|
|
34
|
+
const m = /context ([A-Za-z0-9._:-]+)\. Check/.exec(text);
|
|
35
|
+
assert.ok(m, text);
|
|
36
|
+
return m[1]!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function taskOf(text: string): string {
|
|
40
|
+
const m = /task ([A-Za-z0-9._:-]+) \(/.exec(text);
|
|
41
|
+
assert.ok(m, text);
|
|
42
|
+
return m[1]!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("a public peer's reply is stored, and a follow-up keeps the conversation, both ways", async (t) => {
|
|
46
|
+
const logs: string[] = [];
|
|
47
|
+
const origLog = console.log;
|
|
48
|
+
console.log = (...args: unknown[]) => {
|
|
49
|
+
logs.push(args.map((a) => String(a)).join(" "));
|
|
50
|
+
origLog(...args);
|
|
51
|
+
};
|
|
52
|
+
const aUrl = "https://a.example.net";
|
|
53
|
+
const bUrl = "https://b.example.net";
|
|
54
|
+
const dbA = d1(MIGRATIONS);
|
|
55
|
+
const dbB = d1(MIGRATIONS);
|
|
56
|
+
const ctxA = resolveTenant({ DB: dbA, PUBLIC_URL: aUrl, OWNER_TOKEN: "owner-a-secret", AGENT_NAME: "Ann" } as WorkerBindings);
|
|
57
|
+
const ctxB = resolveTenant({ DB: dbB, PUBLIC_URL: bUrl, OWNER_TOKEN: "owner-b-secret", AGENT_NAME: "Bea" } as WorkerBindings);
|
|
58
|
+
const byHost = new Map<string, TenantContext>([["a.example.net", ctxA], ["b.example.net", ctxB]]);
|
|
59
|
+
const publicHosts: string[] = [];
|
|
60
|
+
const pushTokens: string[] = [];
|
|
61
|
+
const origFetch = globalThis.fetch;
|
|
62
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
63
|
+
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
64
|
+
const u = new URL(raw);
|
|
65
|
+
publicHosts.push(u.host);
|
|
66
|
+
const ctx = byHost.get(u.host);
|
|
67
|
+
if (!ctx) return new Response("origin is unreachable", { status: 522 });
|
|
68
|
+
const body = typeof init?.body === "string" ? init.body : null;
|
|
69
|
+
if (body && (u.pathname === "/" || u.pathname === "/a2a")) {
|
|
70
|
+
const parsed = JSON.parse(body) as { params?: { configuration?: { taskPushNotificationConfig?: { url?: string; token?: string }; pushNotificationConfig?: { url?: string; token?: string } } } };
|
|
71
|
+
const cfg = parsed.params?.configuration?.taskPushNotificationConfig || parsed.params?.configuration?.pushNotificationConfig;
|
|
72
|
+
if (cfg?.token) {
|
|
73
|
+
pushTokens.push(cfg.token);
|
|
74
|
+
assert.equal(cfg.url, `${ctx === ctxA ? bUrl : aUrl}/push`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const next: RequestInit = { method: init?.method, headers: init?.headers, redirect: init?.redirect };
|
|
78
|
+
if (init?.body != null) next.body = init.body;
|
|
79
|
+
return dispatch(new Request(u, next), ctx, ectx as never);
|
|
80
|
+
}) as typeof fetch;
|
|
81
|
+
t.after(() => {
|
|
82
|
+
globalThis.fetch = origFetch;
|
|
83
|
+
console.log = origLog;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const outputs: string[] = [];
|
|
87
|
+
const http = async (ctx: TenantContext, method: string, path: string, auth: string, json?: unknown) => {
|
|
88
|
+
const res = await dispatch(new Request(ctx.PUBLIC_URL + path, {
|
|
89
|
+
method, headers: { authorization: auth, "content-type": "application/json" },
|
|
90
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
91
|
+
}), ctx, ectx as never);
|
|
92
|
+
const text = await res.text();
|
|
93
|
+
let data: unknown = text;
|
|
94
|
+
try { data = JSON.parse(text); } catch { /* plain */ }
|
|
95
|
+
const packed = JSON.stringify({ status: res.status, data });
|
|
96
|
+
outputs.push(packed);
|
|
97
|
+
return { status: res.status, data, text: packed };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const tokenFor = async (ctx: TenantContext, owner: string, label: string) => {
|
|
101
|
+
const issued = await dispatch(new Request(ctx.PUBLIC_URL + "/owner/peers", {
|
|
102
|
+
method: "POST", headers: { authorization: `Bearer ${owner}`, "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify({ label }),
|
|
104
|
+
}), ctx, ectx as never);
|
|
105
|
+
const body = await issued.json() as { token?: string };
|
|
106
|
+
assert.match(body.token || "", /^a2aow_/);
|
|
107
|
+
return body.token!;
|
|
108
|
+
};
|
|
109
|
+
// Issuing a token still returns it to the owner. Everything after this must not.
|
|
110
|
+
const annAtBea = await tokenFor(ctxB, "owner-b-secret", "ann");
|
|
111
|
+
const beaAtAnn = await tokenFor(ctxA, "owner-a-secret", "bea");
|
|
112
|
+
outputs.length = 0;
|
|
113
|
+
|
|
114
|
+
const syncedA = await http(ctxA, "PUT", "/owner/outbound-peers/bea", "Bearer owner-a-secret", { url: bUrl, token: annAtBea });
|
|
115
|
+
assert.equal(syncedA.status, 200, syncedA.text);
|
|
116
|
+
assert.deepEqual(syncedA.data, { ok: true, alias: "bea", hasToken: true });
|
|
117
|
+
const syncedB = await http(ctxB, "PUT", "/owner/outbound-peers/ann", "Bearer owner-b-secret", { url: aUrl, token: beaAtAnn });
|
|
118
|
+
assert.equal(syncedB.status, 200, syncedB.text);
|
|
119
|
+
const listed = await http(ctxA, "GET", "/owner/outbound-peers", "Bearer owner-a-secret");
|
|
120
|
+
assert.equal((listed.data as { has_token: boolean }[])[0]?.has_token, true);
|
|
121
|
+
|
|
122
|
+
const secrets = [annAtBea, beaAtAnn, "owner-a-secret", "owner-b-secret"];
|
|
123
|
+
const round = async (from: TenantContext, fromOwner: string, toAlias: string, contextId: string, ping: string, pong: string, again: string) => {
|
|
124
|
+
const sent = await http(from, "POST", "/owner/mcp/send", `Bearer ${fromOwner}`, { to: toAlias, text: ping, contextId });
|
|
125
|
+
assert.equal(sent.status, 200, sent.text);
|
|
126
|
+
const sentBody = sent.data as SendBody;
|
|
127
|
+
assert.equal(sentBody.isError, false, sent.text);
|
|
128
|
+
assert.equal(contextOf(sentBody.text || ""), contextId);
|
|
129
|
+
const taskId = taskOf(sentBody.text || "");
|
|
130
|
+
const peer = from === ctxA ? ctxB : ctxA;
|
|
131
|
+
const peerOwner = from === ctxA ? "owner-b-secret" : "owner-a-secret";
|
|
132
|
+
const inbox = await http(peer, "GET", "/owner/inbox", `Bearer ${peerOwner}`);
|
|
133
|
+
const inbound = (inbox.data as Row[]).find((r) => r.text === ping && r.kind !== "outbound");
|
|
134
|
+
assert.ok(inbound, inbox.text);
|
|
135
|
+
assert.equal(inbound.taskId, taskId);
|
|
136
|
+
assert.equal(inbound.contextId, contextId);
|
|
137
|
+
const replied = await http(peer, "POST", "/owner/mcp/reply", `Bearer ${peerOwner}`, { taskId, text: pong, state: "completed" });
|
|
138
|
+
assert.equal(replied.status, 200, replied.text);
|
|
139
|
+
assert.equal((replied.data as SendBody).isError, false, replied.text);
|
|
140
|
+
const history = await http(from, "GET", `/owner/history/${contextId}`, `Bearer ${fromOwner}`);
|
|
141
|
+
const rows = history.data as Hist[];
|
|
142
|
+
assert.ok(rows.some((r) => r.dir === "out" && r.text === ping), history.text);
|
|
143
|
+
assert.ok(rows.some((r) => r.dir === "in" && r.event === "push" && r.text === pong), history.text);
|
|
144
|
+
assert.ok(rows.every((r) => r.context_id === contextId), history.text);
|
|
145
|
+
const mine = await http(from, "GET", "/owner/inbox", `Bearer ${fromOwner}`);
|
|
146
|
+
const reply = (mine.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === taskId);
|
|
147
|
+
assert.ok(reply, mine.text);
|
|
148
|
+
assert.equal(reply.text, pong);
|
|
149
|
+
assert.equal(reply.contextId, contextId);
|
|
150
|
+
assert.equal(reply.state, "completed");
|
|
151
|
+
const seen = await http(from, "GET", "/owner/inbox", `Bearer ${fromOwner}`);
|
|
152
|
+
assert.equal((seen.data as Row[]).some((r) => r.taskId === taskId), false, seen.text);
|
|
153
|
+
const kept = await http(from, "GET", "/owner/inbox?all=1", `Bearer ${fromOwner}`);
|
|
154
|
+
assert.equal((kept.data as Row[]).some((r) => r.kind === "outbound" && r.taskId === taskId && r.text === pong), true, kept.text);
|
|
155
|
+
const follow = await http(from, "POST", "/owner/mcp/send", `Bearer ${fromOwner}`, { to: toAlias, text: again, contextId });
|
|
156
|
+
assert.equal((follow.data as SendBody).isError, false, follow.text);
|
|
157
|
+
assert.equal(contextOf((follow.data as SendBody).text || ""), contextId);
|
|
158
|
+
const againHist = await http(from, "GET", `/owner/history/${contextId}`, `Bearer ${fromOwner}`);
|
|
159
|
+
const outs = (againHist.data as Hist[]).filter((r) => r.dir === "out");
|
|
160
|
+
assert.equal(outs.length >= 2, true, againHist.text);
|
|
161
|
+
assert.ok(outs.some((r) => r.text === again), againHist.text);
|
|
162
|
+
const stored = await (from === ctxA ? dbA : dbB).prepare("SELECT push_token_hash, updates_json FROM outbound WHERE task_id = ?").bind(taskId).first() as { push_token_hash?: string; updates_json?: string } | null;
|
|
163
|
+
assert.match(stored?.push_token_hash || "", /^[0-9a-f]{64}$/);
|
|
164
|
+
assert.equal(stored?.updates_json?.includes(pong), true);
|
|
165
|
+
const outbound = await http(from, "GET", `/owner/outbound/${taskId}`, `Bearer ${fromOwner}`);
|
|
166
|
+
assert.equal(outbound.status, 200, outbound.text);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await round(ctxA, "owner-a-secret", "bea", "ctx-ab-public", "ping from ann", "pong from bea", "again from ann");
|
|
170
|
+
await round(ctxB, "owner-b-secret", "ann", "ctx-ba-public", "ping from bea", "pong from ann", "again from bea");
|
|
171
|
+
|
|
172
|
+
assert.equal(pushTokens.length >= 4, true, "each send registers a push");
|
|
173
|
+
assert.equal(publicHosts.includes("a.example.net"), true);
|
|
174
|
+
assert.equal(publicHosts.includes("b.example.net"), true);
|
|
175
|
+
const encA = await dbA.prepare("SELECT token_enc FROM outbound_peers WHERE alias = ?").bind("bea").first() as { token_enc?: string } | null;
|
|
176
|
+
assert.equal(String(encA?.token_enc || "").includes(annAtBea), false);
|
|
177
|
+
forbid(outputs.join("\n"), [...secrets, ...pushTokens], "public peer response");
|
|
178
|
+
forbid(logs.join("\n"), [...secrets, ...pushTokens], "public peer log");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("same-deployment tenants call each other through the directory, both ways", async (t) => {
|
|
182
|
+
clearDirectoryCache();
|
|
183
|
+
const logs: string[] = [];
|
|
184
|
+
const origLog = console.log;
|
|
185
|
+
console.log = (...args: unknown[]) => {
|
|
186
|
+
logs.push(args.map((a) => String(a)).join(" "));
|
|
187
|
+
origLog(...args);
|
|
188
|
+
};
|
|
189
|
+
const publicHosts: string[] = [];
|
|
190
|
+
const pushTokens: string[] = [];
|
|
191
|
+
const origFetch = globalThis.fetch;
|
|
192
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
193
|
+
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
194
|
+
publicHosts.push(new URL(raw).host);
|
|
195
|
+
return new Response("origin is unreachable", { status: 522 });
|
|
196
|
+
}) as typeof fetch;
|
|
197
|
+
t.after(() => {
|
|
198
|
+
globalThis.fetch = origFetch;
|
|
199
|
+
console.log = origLog;
|
|
200
|
+
clearDirectoryCache();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const store = new Map<string, string>();
|
|
204
|
+
const kv: KvNamespace = {
|
|
205
|
+
async get(key: string, type?: "json") {
|
|
206
|
+
const v = store.get(key);
|
|
207
|
+
if (v == null) return null;
|
|
208
|
+
return type === "json" ? JSON.parse(v) : v;
|
|
209
|
+
},
|
|
210
|
+
async put(key: string, value: string) { store.set(key, value); },
|
|
211
|
+
};
|
|
212
|
+
const byId = new Map<string, TenantContext>();
|
|
213
|
+
const ns: DoNamespace = {
|
|
214
|
+
idFromName(name: string) { return name; },
|
|
215
|
+
get(id: unknown) {
|
|
216
|
+
return {
|
|
217
|
+
async fetch(req: Request) {
|
|
218
|
+
const ctx = byId.get(String(id));
|
|
219
|
+
if (!ctx) return new Response("missing", { status: 404 });
|
|
220
|
+
return dispatch(req, ctx, ectx as never);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
const env = {
|
|
226
|
+
TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-key-for-peers",
|
|
227
|
+
DATA_REGION: "", TENANT_DIRECTORY: kv, TENANT_DO: ns,
|
|
228
|
+
} as WorkerBindings;
|
|
229
|
+
const ownerA = "owner-alice-hosted";
|
|
230
|
+
const ownerB = "owner-bobby-hosted";
|
|
231
|
+
const hashA = await sha256(ownerA);
|
|
232
|
+
const hashB = await sha256(ownerB);
|
|
233
|
+
const dbA = d1(MIGRATIONS);
|
|
234
|
+
const dbB = d1(MIGRATIONS);
|
|
235
|
+
const ctxA = hostedTenantContext(env, {
|
|
236
|
+
id: "id-outreplya", db: dbA, publicUrl: "https://outreplya.example.com", ownerTokenHash: hashA, status: "active",
|
|
237
|
+
config: { AGENT_NAME: "Alice" },
|
|
238
|
+
});
|
|
239
|
+
const ctxB = hostedTenantContext(env, {
|
|
240
|
+
id: "id-outreplyb", db: dbB, publicUrl: "https://outreplyb.example.com", ownerTokenHash: hashB, status: "active",
|
|
241
|
+
config: { AGENT_NAME: "Bob" },
|
|
242
|
+
});
|
|
243
|
+
byId.set("id-outreplya", ctxA);
|
|
244
|
+
byId.set("id-outreplyb", ctxB);
|
|
245
|
+
store.set("tenant:outreplya", JSON.stringify({ id: "id-outreplya", status: "active", region: "default", version: 1 }));
|
|
246
|
+
store.set("tenant:outreplyb", JSON.stringify({ id: "id-outreplyb", status: "active", region: "default", version: 1 }));
|
|
247
|
+
|
|
248
|
+
const outputs: string[] = [];
|
|
249
|
+
const note = (status: number, data: unknown) => {
|
|
250
|
+
const packed = JSON.stringify({ status, data });
|
|
251
|
+
outputs.push(packed);
|
|
252
|
+
return packed;
|
|
253
|
+
};
|
|
254
|
+
const http = async (ctx: TenantContext, method: string, path: string, auth: string, json?: unknown) => {
|
|
255
|
+
const res = await dispatch(new Request(ctx.PUBLIC_URL + path, {
|
|
256
|
+
method, headers: { authorization: auth, "content-type": "application/json" },
|
|
257
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
258
|
+
}), ctx, ectx as never);
|
|
259
|
+
const data = await res.json().catch(() => null);
|
|
260
|
+
return { status: res.status, data, text: note(res.status, data) };
|
|
261
|
+
};
|
|
262
|
+
const rpc = async (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown, query?: Record<string, string>) => {
|
|
263
|
+
const res = await runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }), ...(query ? { query } : {}) });
|
|
264
|
+
return { status: res.status, data: res.body, text: note(res.status, res.body) };
|
|
265
|
+
};
|
|
266
|
+
const tokenFor = async (ctx: TenantContext, owner: string, label: string) => {
|
|
267
|
+
const issued = await http(ctx, "POST", "/owner/peers", `Bearer ${owner}`, { label });
|
|
268
|
+
const token = (issued.data as { token?: string }).token || "";
|
|
269
|
+
assert.match(token, /^a2aow_/);
|
|
270
|
+
outputs.pop();
|
|
271
|
+
return token;
|
|
272
|
+
};
|
|
273
|
+
const aliceAtBob = await tokenFor(ctxB, ownerB, "alice");
|
|
274
|
+
const bobAtAlice = await tokenFor(ctxA, ownerA, "bobby");
|
|
275
|
+
|
|
276
|
+
const putA = await rpc(ctxA, "acct_alice", "PUT", "/owner/outbound-peers/bobby", { url: "https://outreplyb.example.com", token: aliceAtBob });
|
|
277
|
+
assert.equal(putA.status, 200, putA.text);
|
|
278
|
+
assert.deepEqual(putA.data, { ok: true, alias: "bobby", hasToken: true });
|
|
279
|
+
const putB = await rpc(ctxB, "acct_bobby", "PUT", "/owner/outbound-peers/alice", { url: "https://outreplya.example.com", token: bobAtAlice });
|
|
280
|
+
assert.equal(putB.status, 200, putB.text);
|
|
281
|
+
const peers = await rpc(ctxA, "acct_alice", "GET", "/owner/outbound-peers");
|
|
282
|
+
assert.equal((peers.data as { alias: string; has_token?: boolean; token?: string }[])[0]?.has_token, true);
|
|
283
|
+
assert.equal(JSON.stringify(peers.data).includes("a2aow_"), false);
|
|
284
|
+
|
|
285
|
+
const missing = await rpc(ctxA, "acct_alice", "PUT", "/owner/outbound-peers/gone", { url: "https://outgone.example.com", token: aliceAtBob });
|
|
286
|
+
assert.equal(missing.status, 200, missing.text);
|
|
287
|
+
const missed = await rpc(ctxA, "acct_alice", "POST", "/owner/mcp/send", { to: "gone", text: "nobody home", contextId: "ctx-missing" });
|
|
288
|
+
assert.equal((missed.data as SendBody).isError, true, missed.text);
|
|
289
|
+
assert.equal(publicHosts.includes("outgone.example.com"), false, `public fetch hit ${publicHosts.join(",")}`);
|
|
290
|
+
|
|
291
|
+
const secrets = [aliceAtBob, bobAtAlice, ownerA, ownerB, "platform-key-for-peers"];
|
|
292
|
+
const round = async (from: TenantContext, account: string, toAlias: string, contextId: string, ping: string, pong: string, again: string) => {
|
|
293
|
+
const peer = from === ctxA ? ctxB : ctxA;
|
|
294
|
+
const peerAccount = from === ctxA ? "acct_bobby" : "acct_alice";
|
|
295
|
+
const sent = await rpc(from, account, "POST", "/owner/mcp/send", { to: toAlias, text: ping, contextId });
|
|
296
|
+
assert.equal(sent.status, 200, sent.text);
|
|
297
|
+
const sentBody = sent.data as SendBody;
|
|
298
|
+
assert.equal(sentBody.isError, false, sent.text);
|
|
299
|
+
assert.equal(contextOf(sentBody.text || ""), contextId);
|
|
300
|
+
const taskId = taskOf(sentBody.text || "");
|
|
301
|
+
const inbox = await rpc(peer, peerAccount, "GET", "/owner/inbox");
|
|
302
|
+
const inbound = (inbox.data as Row[]).find((r) => r.text === ping && r.kind !== "outbound");
|
|
303
|
+
assert.ok(inbound, inbox.text);
|
|
304
|
+
assert.equal(inbound.taskId, taskId);
|
|
305
|
+
assert.equal(inbound.contextId, contextId);
|
|
306
|
+
const replied = await rpc(peer, peerAccount, "POST", "/owner/mcp/reply", { taskId, text: pong, state: "completed" });
|
|
307
|
+
assert.equal(replied.status, 200, replied.text);
|
|
308
|
+
assert.equal((replied.data as SendBody).isError, false, replied.text);
|
|
309
|
+
const history = await rpc(from, account, "GET", `/owner/history/${contextId}`);
|
|
310
|
+
const rows = history.data as Hist[];
|
|
311
|
+
assert.ok(rows.some((r) => r.dir === "out" && r.text === ping), history.text);
|
|
312
|
+
assert.ok(rows.some((r) => r.dir === "in" && r.event === "push" && r.text === pong), history.text);
|
|
313
|
+
assert.ok(rows.every((r) => r.context_id === contextId), history.text);
|
|
314
|
+
const mine = await rpc(from, account, "GET", "/owner/inbox");
|
|
315
|
+
const reply = (mine.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === taskId);
|
|
316
|
+
assert.ok(reply, mine.text);
|
|
317
|
+
assert.equal(reply.text, pong);
|
|
318
|
+
assert.equal(reply.contextId, contextId);
|
|
319
|
+
const seen = await rpc(from, account, "GET", "/owner/inbox");
|
|
320
|
+
assert.equal((seen.data as Row[]).some((r) => r.taskId === taskId), false, seen.text);
|
|
321
|
+
const kept = await rpc(from, account, "GET", "/owner/inbox", undefined, { all: "1" });
|
|
322
|
+
assert.equal((kept.data as Row[]).some((r) => r.kind === "outbound" && r.taskId === taskId && r.text === pong), true, kept.text);
|
|
323
|
+
const follow = await rpc(from, account, "POST", "/owner/mcp/send", { to: toAlias, text: again, contextId });
|
|
324
|
+
assert.equal((follow.data as SendBody).isError, false, follow.text);
|
|
325
|
+
assert.equal(contextOf((follow.data as SendBody).text || ""), contextId);
|
|
326
|
+
const db = from === ctxA ? dbA : dbB;
|
|
327
|
+
const stored = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id = ?").bind(taskId).first() as { push_token_hash?: string } | null;
|
|
328
|
+
assert.match(stored?.push_token_hash || "", /^[0-9a-f]{64}$/);
|
|
329
|
+
const outbound = await rpc(from, account, "GET", `/owner/outbound/${taskId}`);
|
|
330
|
+
assert.equal(outbound.status, 200, outbound.text);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
await round(ctxA, "acct_alice", "bobby", "ctx-ab-host", "ping from alice", "pong from bobby", "again from alice");
|
|
334
|
+
await round(ctxB, "acct_bobby", "alice", "ctx-ba-host", "ping from bobby", "pong from alice", "again from bobby");
|
|
335
|
+
|
|
336
|
+
// The receiver stores the push token so it can call back. That plaintext must not show up in a response or a log.
|
|
337
|
+
const internalTokens: string[] = [];
|
|
338
|
+
for (const db of [dbA, dbB]) {
|
|
339
|
+
const tasks = await db.prepare("SELECT push_configs_json FROM tasks").all() as { results: { push_configs_json?: string }[] };
|
|
340
|
+
for (const row of tasks.results) {
|
|
341
|
+
const configs = JSON.parse(row.push_configs_json || "[]") as { token?: string; url?: string }[];
|
|
342
|
+
for (const cfg of configs) {
|
|
343
|
+
if (!cfg.token) continue;
|
|
344
|
+
internalTokens.push(cfg.token);
|
|
345
|
+
assert.match(cfg.url || "", /^https:\/\/outreply[ab]\.example\.com\/push$/);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
assert.equal(internalTokens.length >= 4, true, "each send registered a push on the peer");
|
|
350
|
+
pushTokens.push(...internalTokens);
|
|
351
|
+
assert.equal(publicHosts.some((h) => h === "outreplya.example.com" || h === "outreplyb.example.com" || h === "outgone.example.com"), false, `public fetch hit ${publicHosts.join(",")}`);
|
|
352
|
+
const enc = await dbA.prepare("SELECT token_enc FROM outbound_peers WHERE alias = ?").bind("bobby").first() as { token_enc?: string } | null;
|
|
353
|
+
assert.equal(String(enc?.token_enc || "").includes(aliceAtBob), false);
|
|
354
|
+
forbid(outputs.join("\n"), [...secrets, ...pushTokens], "same-deployment response");
|
|
355
|
+
forbid(logs.join("\n"), [...secrets, ...pushTokens], "same-deployment log");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
function bundleHosted(): Promise<string> {
|
|
359
|
+
const { build } = require("esbuild") as typeof import("esbuild");
|
|
360
|
+
return build({
|
|
361
|
+
entryPoints: [fileURLToPath(new URL("../src/hosted.ts", import.meta.url))],
|
|
362
|
+
bundle: true,
|
|
363
|
+
format: "esm",
|
|
364
|
+
write: false,
|
|
365
|
+
platform: "neutral",
|
|
366
|
+
external: ["cloudflare:*"],
|
|
367
|
+
target: "es2022",
|
|
368
|
+
logLevel: "silent",
|
|
369
|
+
plugins: [{
|
|
370
|
+
name: "sql-raw",
|
|
371
|
+
setup(b) {
|
|
372
|
+
b.onResolve({ filter: /\.sql\?raw$/ }, (args) => ({
|
|
373
|
+
path: path.resolve(args.resolveDir, args.path.replace(/\?raw$/, "")),
|
|
374
|
+
namespace: "sql-raw",
|
|
375
|
+
}));
|
|
376
|
+
b.onLoad({ filter: /.*/, namespace: "sql-raw" }, (args) => ({
|
|
377
|
+
contents: `export default ${JSON.stringify(fs.readFileSync(args.path, "utf8"))};`,
|
|
378
|
+
loader: "js",
|
|
379
|
+
}));
|
|
380
|
+
},
|
|
381
|
+
}],
|
|
382
|
+
}).then((built) => {
|
|
383
|
+
const file = built.outputFiles?.[0];
|
|
384
|
+
if (!file) throw new Error("hosted bundle was empty");
|
|
385
|
+
return file.text;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const SCRIPT = bundleHosted();
|
|
390
|
+
|
|
391
|
+
test("same-deployment tenants in workerd use stub.fetch, and a public fetch is 522", async (t) => {
|
|
392
|
+
const publicHosts: string[] = [];
|
|
393
|
+
const lines: string[] = [];
|
|
394
|
+
const origLog = console.log;
|
|
395
|
+
console.log = (...args: unknown[]) => {
|
|
396
|
+
lines.push(args.map((a) => String(a)).join(" "));
|
|
397
|
+
origLog(...args);
|
|
398
|
+
};
|
|
399
|
+
t.after(() => { console.log = origLog; });
|
|
400
|
+
const mf = new Miniflare(convertV4MiniflareOptions({
|
|
401
|
+
name: "a2a",
|
|
402
|
+
modules: true,
|
|
403
|
+
script: await SCRIPT,
|
|
404
|
+
compatibilityDate: "2026-10-06",
|
|
405
|
+
bindings: {
|
|
406
|
+
TENANCY: "host",
|
|
407
|
+
TENANT_DOMAIN: "example.com",
|
|
408
|
+
TENANT_SECRETS_KEY: "platform-secret",
|
|
409
|
+
OWNER_TOKEN: "platform-owner",
|
|
410
|
+
DATA_REGION: "",
|
|
411
|
+
},
|
|
412
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-outbound-reply" },
|
|
413
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
414
|
+
outboundService: async (request: Request) => {
|
|
415
|
+
publicHosts.push(new URL(request.url).hostname);
|
|
416
|
+
return new Response("origin is unreachable", { status: 522 });
|
|
417
|
+
},
|
|
418
|
+
} as never));
|
|
419
|
+
t.after(() => mf.dispose());
|
|
420
|
+
await mf.ready;
|
|
421
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
422
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
423
|
+
const ownerA = "owner-alice-zone";
|
|
424
|
+
const ownerB = "owner-bobby-zone";
|
|
425
|
+
const hashA = await sha256(ownerA);
|
|
426
|
+
const hashB = await sha256(ownerB);
|
|
427
|
+
type Stub = {
|
|
428
|
+
owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
|
|
429
|
+
pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
|
|
430
|
+
};
|
|
431
|
+
const alice = ns.get(ns.idFromName("id-zone-alice")) as unknown as Stub;
|
|
432
|
+
const bobby = ns.get(ns.idFromName("id-zone-bobby")) as unknown as Stub;
|
|
433
|
+
assert.equal((await alice.pushConfig({
|
|
434
|
+
version: 1, tenantId: "id-zone-alice", name: "zonealice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice" },
|
|
435
|
+
})).applied, true);
|
|
436
|
+
assert.equal((await bobby.pushConfig({
|
|
437
|
+
version: 1, tenantId: "id-zone-bobby", name: "zonebobby", status: "active", ownerTokenHash: hashB, config: { AGENT_NAME: "Bob" },
|
|
438
|
+
})).applied, true);
|
|
439
|
+
await kv.put("tenant:zonealice", JSON.stringify({ id: "id-zone-alice", status: "active", region: "default", version: 1 }));
|
|
440
|
+
await kv.put("tenant:zonebobby", JSON.stringify({ id: "id-zone-bobby", status: "active", region: "default", version: 1 }));
|
|
441
|
+
|
|
442
|
+
const outputs: string[] = [];
|
|
443
|
+
const note = (status: number, data: unknown) => {
|
|
444
|
+
const packed = JSON.stringify({ status, data });
|
|
445
|
+
outputs.push(packed);
|
|
446
|
+
return packed;
|
|
447
|
+
};
|
|
448
|
+
const http = async (name: string, method: string, path: string, auth: string, json?: unknown) => {
|
|
449
|
+
const res = await mf.dispatchFetch(`https://${name}.example.com${path}`, {
|
|
450
|
+
method, headers: { authorization: auth, "content-type": "application/json" },
|
|
451
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
452
|
+
});
|
|
453
|
+
const data = await res.json().catch(() => null);
|
|
454
|
+
return { status: res.status, data, text: note(res.status, data) };
|
|
455
|
+
};
|
|
456
|
+
const rpc = async (stub: Stub, accountId: string, method: string, path: string, body?: unknown, query?: Record<string, string>) => {
|
|
457
|
+
const res = await stub.owner({ accountId, method, path, ...(body === undefined ? {} : { body }), ...(query ? { query } : {}) });
|
|
458
|
+
return { status: res.status, data: res.body, text: note(res.status, res.body) };
|
|
459
|
+
};
|
|
460
|
+
const saw = async (part: string) => {
|
|
461
|
+
const start = Date.now();
|
|
462
|
+
while (!lines.some((l) => l.includes(part))) {
|
|
463
|
+
if (Date.now() - start > 2000) return false;
|
|
464
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
};
|
|
468
|
+
const tokenFor = async (name: string, owner: string, label: string) => {
|
|
469
|
+
const issued = await http(name, "POST", "/owner/peers", `Bearer ${owner}`, { label });
|
|
470
|
+
const token = (issued.data as { token?: string }).token || "";
|
|
471
|
+
assert.match(token, /^a2aow_/, issued.text);
|
|
472
|
+
outputs.pop();
|
|
473
|
+
return token;
|
|
474
|
+
};
|
|
475
|
+
const aliceAtBob = await tokenFor("zonebobby", ownerB, "alice");
|
|
476
|
+
const bobAtAlice = await tokenFor("zonealice", ownerA, "bobby");
|
|
477
|
+
const putA = await rpc(alice, "acct_alice", "PUT", "/owner/outbound-peers/bobby", { url: "https://zonebobby.example.com", token: aliceAtBob });
|
|
478
|
+
assert.equal(putA.status, 200, putA.text);
|
|
479
|
+
assert.deepEqual(putA.data, { ok: true, alias: "bobby", hasToken: true });
|
|
480
|
+
const putB = await rpc(bobby, "acct_bobby", "PUT", "/owner/outbound-peers/alice", { url: "https://zonealice.example.com", token: bobAtAlice });
|
|
481
|
+
assert.equal(putB.status, 200, putB.text);
|
|
482
|
+
assert.equal(await saw('"event":"outbound_peer_synced"'), true);
|
|
483
|
+
|
|
484
|
+
const secrets = [aliceAtBob, bobAtAlice, ownerA, ownerB, "platform-secret", "platform-owner"];
|
|
485
|
+
const round = async (from: Stub, fromName: string, fromOwner: string, account: string, peer: Stub, peerName: string, peerOwner: string, peerAccount: string, toAlias: string, contextId: string, ping: string, pong: string, again: string) => {
|
|
486
|
+
const sent = await rpc(from, account, "POST", "/owner/mcp/send", { to: toAlias, text: ping, contextId });
|
|
487
|
+
assert.equal(sent.status, 200, sent.text);
|
|
488
|
+
const sentBody = sent.data as SendBody;
|
|
489
|
+
assert.equal(sentBody.isError, false, sent.text);
|
|
490
|
+
assert.equal(contextOf(sentBody.text || ""), contextId);
|
|
491
|
+
const taskId = taskOf(sentBody.text || "");
|
|
492
|
+
const inbox = await rpc(peer, peerAccount, "GET", "/owner/inbox");
|
|
493
|
+
const inbound = (inbox.data as Row[]).find((r) => r.text === ping && r.kind !== "outbound");
|
|
494
|
+
assert.ok(inbound, inbox.text);
|
|
495
|
+
assert.equal(inbound.taskId, taskId);
|
|
496
|
+
assert.equal(inbound.contextId, contextId);
|
|
497
|
+
const replied = await rpc(peer, peerAccount, "POST", "/owner/mcp/reply", { taskId, text: pong, state: "completed" });
|
|
498
|
+
assert.equal(replied.status, 200, replied.text);
|
|
499
|
+
assert.equal((replied.data as SendBody).isError, false, replied.text);
|
|
500
|
+
const history = await rpc(from, account, "GET", `/owner/history/${contextId}`);
|
|
501
|
+
const rows = history.data as Hist[];
|
|
502
|
+
assert.ok(rows.some((r) => r.dir === "out" && r.text === ping), history.text);
|
|
503
|
+
assert.ok(rows.some((r) => r.dir === "in" && r.event === "push" && r.text === pong), history.text);
|
|
504
|
+
const mine = await rpc(from, account, "GET", "/owner/inbox");
|
|
505
|
+
const reply = (mine.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === taskId);
|
|
506
|
+
assert.ok(reply, mine.text);
|
|
507
|
+
assert.equal(reply.text, pong);
|
|
508
|
+
assert.equal(reply.contextId, contextId);
|
|
509
|
+
const follow = await rpc(from, account, "POST", "/owner/mcp/send", { to: toAlias, text: again, contextId });
|
|
510
|
+
assert.equal((follow.data as SendBody).isError, false, follow.text);
|
|
511
|
+
assert.equal(contextOf((follow.data as SendBody).text || ""), contextId);
|
|
512
|
+
const dumped = await http(peerName, "GET", "/owner/export", `Bearer ${peerOwner}`);
|
|
513
|
+
const tasks = ((dumped.data as { tables?: { tasks?: { id?: string; push_configs_json?: string }[] } }).tables?.tasks) || [];
|
|
514
|
+
const cfgRaw = tasks.find((row) => row.id === taskId)?.push_configs_json || "[]";
|
|
515
|
+
const configs = JSON.parse(cfgRaw) as { token?: string; url?: string }[];
|
|
516
|
+
assert.equal(configs[0]?.url, `https://${fromName}.example.com/push`);
|
|
517
|
+
assert.ok(configs[0]?.token);
|
|
518
|
+
secrets.push(configs[0]!.token!);
|
|
519
|
+
outputs.pop();
|
|
520
|
+
const senderDump = await http(fromName, "GET", "/owner/export", `Bearer ${fromOwner}`);
|
|
521
|
+
const outbound = ((senderDump.data as { tables?: { outbound?: { task_id?: string; push_token_hash?: string }[] } }).tables?.outbound) || [];
|
|
522
|
+
const saved = outbound.find((row) => row.task_id === taskId);
|
|
523
|
+
assert.match(saved?.push_token_hash || "", /^[0-9a-f]{64}$/);
|
|
524
|
+
assert.equal(saved?.push_token_hash, await sha256(configs[0]!.token!));
|
|
525
|
+
const outboundJson = JSON.stringify(saved);
|
|
526
|
+
assert.equal(outboundJson.includes(configs[0]!.token!), false);
|
|
527
|
+
// The receiver keeps the push token so it can call back. A later export of that tenant is not the sender's output.
|
|
528
|
+
outputs.pop();
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
await round(alice, "zonealice", ownerA, "acct_alice", bobby, "zonebobby", ownerB, "acct_bobby", "bobby", "ctx-ab-zone", "ping from zone alice", "pong from zone bobby", "again from zone alice");
|
|
532
|
+
await round(bobby, "zonebobby", ownerB, "acct_bobby", alice, "zonealice", ownerA, "acct_alice", "alice", "ctx-ba-zone", "ping from zone bobby", "pong from zone alice", "again from zone bobby");
|
|
533
|
+
|
|
534
|
+
assert.equal(await saw('"event":"push_received"'), true);
|
|
535
|
+
assert.equal(publicHosts.some((h) => h === "zonealice.example.com" || h === "zonebobby.example.com"), false, `public fetch hit ${publicHosts.join(",")}`);
|
|
536
|
+
forbid(outputs.join("\n"), secrets, "workerd response");
|
|
537
|
+
forbid(lines.join("\n"), secrets, "workerd log");
|
|
538
|
+
});
|
|
@@ -27,6 +27,8 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
27
27
|
assert.equal(ok("POST", "/owner/mcp/reply")?.path, "/owner/mcp/reply");
|
|
28
28
|
assert.equal(ok("POST", "/owner/mcp/mark_working")?.name, "POST /owner/mcp/mark_working");
|
|
29
29
|
assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
|
|
30
|
+
assert.equal(ok("PUT", "/owner/outbound-peers/ada.bot")?.name, "PUT /owner/outbound-peers/:alias");
|
|
31
|
+
assert.equal(ok("PUT", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
|
|
30
32
|
assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
|
|
31
33
|
assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
|
|
32
34
|
assert.equal(ok("POST", "/owner/facade/verify")?.name, "POST /owner/facade/verify");
|
|
@@ -41,7 +43,8 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
41
43
|
assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
|
|
42
44
|
assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
|
|
43
45
|
assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
|
|
44
|
-
assert.deepEqual(refused("PUT", "/owner/outbound-peers
|
|
46
|
+
assert.deepEqual(refused("PUT", "/owner/outbound-peers"), { ok: false, reason: "path" });
|
|
47
|
+
assert.deepEqual(refused("PUT", "/owner/peers/ada"), { ok: false, reason: "path" });
|
|
45
48
|
assert.deepEqual(refused("GET", "/owner/export", { format: "json" }), { ok: false, reason: "query" });
|
|
46
49
|
assert.deepEqual(refused("GET", "/owner/export", { format: "sql", n: "1" }), { ok: false, reason: "query" });
|
|
47
50
|
assert.deepEqual(refused("POST", "/owner/export"), { ok: false, reason: "path" });
|
|
@@ -101,6 +104,17 @@ test("owner RPC accepts a tool-argument body only on send, reply, and mark_worki
|
|
|
101
104
|
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", force: true }), { ok: false, error: "body is not allowed" });
|
|
102
105
|
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: ".." }), { ok: false, error: "taskId is invalid" });
|
|
103
106
|
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: "task-1", text: "no" }), { ok: false, error: "body is not allowed" });
|
|
107
|
+
|
|
108
|
+
const synced = ownerRpcBody("PUT /owner/outbound-peers/:alias", { url: "https://alpha.example.org", token: "tok-alpha" });
|
|
109
|
+
assert.deepEqual(synced, { ok: true, value: { url: "https://alpha.example.org", token: "tok-alpha" } });
|
|
110
|
+
assert.deepEqual(ownerRpcBody("PUT /owner/outbound-peers/:alias", { url: "https://alpha.example.org" }), {
|
|
111
|
+
ok: true, value: { url: "https://alpha.example.org" },
|
|
112
|
+
});
|
|
113
|
+
assert.deepEqual(ownerRpcBody("PUT /owner/outbound-peers/:alias", undefined), { ok: false, error: "body must be an object" });
|
|
114
|
+
assert.deepEqual(ownerRpcBody("PUT /owner/outbound-peers/:alias", {}), { ok: false, error: "url is required" });
|
|
115
|
+
assert.deepEqual(ownerRpcBody("PUT /owner/outbound-peers/:alias", { url: "https://alpha.example.org", token: "" }), { ok: false, error: "token is invalid" });
|
|
116
|
+
assert.deepEqual(ownerRpcBody("PUT /owner/outbound-peers/:alias", { url: "https://alpha.example.org", token: "t".repeat(4097) }), { ok: false, error: "token is invalid" });
|
|
117
|
+
assert.deepEqual(ownerRpcBody("PUT /owner/outbound-peers/:alias", { url: "https://alpha.example.org", token: "tok-alpha", pushToken: "x" }), { ok: false, error: "body is not allowed" });
|
|
104
118
|
});
|
|
105
119
|
|
|
106
120
|
test("owner RPC withholds peer tokens and decrypted secrets", () => {
|