a2a-exposed 0.4.1 → 0.6.0
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/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +62 -12
- package/lib/deploy.mjs +42 -19
- package/lib/main.mjs +32 -11
- package/lib/pair.mjs +47 -3
- package/lib/status.mjs +4 -4
- package/lib/tunnel.mjs +11 -11
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +51 -7
- package/worker/deploy.env.example +28 -1
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/migrations/0008_oidc_txns.sql +12 -0
- package/worker/package.json +6 -2
- package/worker/src/approval-oidc.ts +247 -0
- package/worker/src/backup.ts +186 -0
- package/worker/src/directory.ts +120 -0
- package/worker/src/export.ts +156 -0
- package/worker/src/hosted.ts +403 -0
- package/worker/src/index.ts +1327 -446
- package/worker/src/mcp.ts +336 -0
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +18 -5
- package/worker/src/sql-raw.d.ts +5 -0
- package/worker/src/storage.ts +100 -0
- package/worker/src/tenancy.ts +450 -0
- package/worker/src/wake.ts +14 -2
- package/worker/test/alarms.test.ts +231 -0
- package/worker/test/approval-oidc.test.ts +331 -0
- package/worker/test/bridge-thread.mjs +40 -0
- package/worker/test/d1.ts +146 -2
- package/worker/test/directory.test.ts +90 -0
- package/worker/test/export.test.ts +544 -0
- package/worker/test/facade.test.ts +1 -1
- package/worker/test/harness-worker.ts +80 -0
- package/worker/test/hosted.test.ts +221 -0
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +20 -7
- package/worker/test/register-hooks.mjs +4 -0
- package/worker/test/sql-raw-hook.mjs +22 -0
- package/worker/test/storage.test.ts +74 -0
- package/worker/test/tenancy.test.ts +217 -0
- package/worker/test/wake-retry.test.ts +74 -0
- package/worker/test/wake.test.ts +18 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Hosted router and TenantStore, in workerd via Miniflare. Self-host behaviour is the node suite (and the
|
|
2
|
+
// gates-unset case below). Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
10
|
+
import worker from "../src/index.ts";
|
|
11
|
+
import { sha256 } from "../src/a2a.ts";
|
|
12
|
+
import { d1 } from "./d1.ts";
|
|
13
|
+
|
|
14
|
+
const require = createRequire(new URL("../package.json", import.meta.url));
|
|
15
|
+
const MIGRATIONS = new URL("../migrations/", import.meta.url);
|
|
16
|
+
const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
|
|
17
|
+
|
|
18
|
+
function bundleHosted(): Promise<string> {
|
|
19
|
+
const { build } = require("esbuild") as typeof import("esbuild");
|
|
20
|
+
return build({
|
|
21
|
+
entryPoints: [fileURLToPath(new URL("../src/hosted.ts", import.meta.url))],
|
|
22
|
+
bundle: true,
|
|
23
|
+
format: "esm",
|
|
24
|
+
write: false,
|
|
25
|
+
platform: "neutral",
|
|
26
|
+
external: ["cloudflare:*"],
|
|
27
|
+
target: "es2022",
|
|
28
|
+
logLevel: "silent",
|
|
29
|
+
plugins: [{
|
|
30
|
+
name: "sql-raw",
|
|
31
|
+
setup(b) {
|
|
32
|
+
b.onResolve({ filter: /\.sql\?raw$/ }, (args) => ({
|
|
33
|
+
path: path.resolve(args.resolveDir, args.path.replace(/\?raw$/, "")),
|
|
34
|
+
namespace: "sql-raw",
|
|
35
|
+
}));
|
|
36
|
+
b.onLoad({ filter: /.*/, namespace: "sql-raw" }, (args) => ({
|
|
37
|
+
contents: `export default ${JSON.stringify(fs.readFileSync(args.path, "utf8"))};`,
|
|
38
|
+
loader: "js",
|
|
39
|
+
}));
|
|
40
|
+
},
|
|
41
|
+
}],
|
|
42
|
+
}).then((built) => {
|
|
43
|
+
const file = built.outputFiles?.[0];
|
|
44
|
+
if (!file) throw new Error("hosted bundle was empty");
|
|
45
|
+
return file.text;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SCRIPT = bundleHosted();
|
|
50
|
+
|
|
51
|
+
async function start(opts: Record<string, unknown>) {
|
|
52
|
+
const mf = new Miniflare(convertV4MiniflareOptions({
|
|
53
|
+
name: "a2a",
|
|
54
|
+
modules: true,
|
|
55
|
+
script: await SCRIPT,
|
|
56
|
+
compatibilityDate: "2026-10-06",
|
|
57
|
+
d1Databases: { DB: `db-${Math.random().toString(36).slice(2)}` },
|
|
58
|
+
...opts,
|
|
59
|
+
} as never));
|
|
60
|
+
return mf;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sqlStatements(sql: string): string[] {
|
|
64
|
+
return sql.split("\n").map((l) => l.replace(/--.*$/, "")).join("\n").split(";").map((s) => s.trim()).filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function migrateD1(mf: Miniflare) {
|
|
68
|
+
const db = await mf.getD1Database("DB");
|
|
69
|
+
for (const name of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith(".sql")).sort()) {
|
|
70
|
+
const stmts = sqlStatements(fs.readFileSync(new URL(name, MIGRATIONS), "utf8"));
|
|
71
|
+
if (stmts.length) await db.batch(stmts.map((q) => db.prepare(q)));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function text(res: Response): Promise<string> {
|
|
76
|
+
return await res.text();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("gates unset: the hosted entry matches the self-host handler, including TENANCY=host without TENANT_DO", async (t) => {
|
|
80
|
+
const base = "https://agent.example.com";
|
|
81
|
+
const nodeEnv = { DB: d1(MIGRATIONS), PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" };
|
|
82
|
+
const nodeHealth = await text(await worker.fetch(new Request(base + "/health"), nodeEnv as never, ectx as never));
|
|
83
|
+
const nodeDenied = await text(await worker.fetch(new Request(base + "/", {
|
|
84
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
|
|
86
|
+
}), nodeEnv as never, ectx as never));
|
|
87
|
+
|
|
88
|
+
const plain = await start({ bindings: { PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" }, durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } } });
|
|
89
|
+
t.after(() => plain.dispose());
|
|
90
|
+
await plain.ready;
|
|
91
|
+
await migrateD1(plain);
|
|
92
|
+
assert.equal(await text(await plain.dispatchFetch(base + "/health")), nodeHealth);
|
|
93
|
+
assert.equal(await text(await plain.dispatchFetch(base + "/", {
|
|
94
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
|
|
96
|
+
})), nodeDenied);
|
|
97
|
+
const issued = await (await plain.dispatchFetch(base + "/owner/peers", {
|
|
98
|
+
method: "POST", headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ label: "ada" }),
|
|
100
|
+
})).json() as { token: string };
|
|
101
|
+
assert.match(issued.token, /^a2aow_/);
|
|
102
|
+
const peers = await (await plain.getD1Database("DB")).prepare("SELECT label FROM peers").all();
|
|
103
|
+
assert.deepEqual(peers.results?.map((r) => (r as { label: string }).label), ["ada"]);
|
|
104
|
+
assert.deepEqual(await plain.listDurableObjectIds("TENANT_DO"), [], "the flag is off, so the object is never addressed");
|
|
105
|
+
|
|
106
|
+
// TENANCY=host but no TENANT_DO binding: still the self-host path. The class is registered unbound so the
|
|
107
|
+
// script can load; the router only switches when the binding is present.
|
|
108
|
+
const hostOnly = await start({
|
|
109
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret", PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" },
|
|
110
|
+
additionalUnboundDurableObjects: [{ className: "TenantStore", useSQLite: true }],
|
|
111
|
+
});
|
|
112
|
+
t.after(() => hostOnly.dispose());
|
|
113
|
+
await hostOnly.ready;
|
|
114
|
+
await migrateD1(hostOnly);
|
|
115
|
+
assert.equal(await text(await hostOnly.dispatchFetch(base + "/health")), nodeHealth);
|
|
116
|
+
const again = await (await hostOnly.dispatchFetch(base + "/owner/peers", {
|
|
117
|
+
method: "POST", headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ label: "ada" }),
|
|
119
|
+
})).json() as { token?: string; error?: string };
|
|
120
|
+
assert.equal(again.error, undefined);
|
|
121
|
+
assert.match(again.token ?? "", /^a2aow_/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
|
|
125
|
+
const mf = await start({
|
|
126
|
+
bindings: {
|
|
127
|
+
TENANCY: "host",
|
|
128
|
+
TENANT_DOMAIN: "example.com",
|
|
129
|
+
TENANT_SECRETS_KEY: "platform-secret",
|
|
130
|
+
OWNER_TOKEN: "platform-owner",
|
|
131
|
+
DATA_REGION: "",
|
|
132
|
+
},
|
|
133
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants" },
|
|
134
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
135
|
+
});
|
|
136
|
+
t.after(() => mf.dispose());
|
|
137
|
+
await mf.ready;
|
|
138
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
139
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO") as DurableObjectNamespace & {
|
|
140
|
+
jurisdiction(loc: string): DurableObjectNamespace;
|
|
141
|
+
};
|
|
142
|
+
const tokenA = "owner-alice";
|
|
143
|
+
const tokenB = "owner-bobby";
|
|
144
|
+
const hashA = await sha256(tokenA);
|
|
145
|
+
const hashB = await sha256(tokenB);
|
|
146
|
+
|
|
147
|
+
const before = await mf.listDurableObjectIds("TENANT_DO");
|
|
148
|
+
assert.equal((await mf.dispatchFetch("https://nope.example.com/health")).status, 404);
|
|
149
|
+
assert.equal((await mf.dispatchFetch("https://example.com/health")).status, 404);
|
|
150
|
+
assert.equal((await mf.dispatchFetch("https://a.b.example.com/health")).status, 404);
|
|
151
|
+
assert.deepEqual(await mf.listDurableObjectIds("TENANT_DO"), before, "unknown names do not create an object");
|
|
152
|
+
|
|
153
|
+
await kv.put("tenant:paused", JSON.stringify({ id: "id-paused", status: "suspended", region: "", version: 1 }));
|
|
154
|
+
assert.equal((await mf.dispatchFetch("https://paused.example.com/health")).status, 403);
|
|
155
|
+
assert.deepEqual(await mf.listDurableObjectIds("TENANT_DO"), before, "suspended is answered from the directory");
|
|
156
|
+
|
|
157
|
+
await kv.put("tenant:dave", JSON.stringify({ id: "id-dave", status: "active", region: "", version: 1 }));
|
|
158
|
+
assert.equal((await mf.dispatchFetch("https://dave.example.com/health")).status, 404);
|
|
159
|
+
assert.equal((await kv.get("tenant:dave", "json") as { region: string }).region, "default", "an empty region is pinned before the object is addressed");
|
|
160
|
+
const dave = ns.get(ns.idFromName("id-dave")) as unknown as { storageStatus(): Promise<{ configured: boolean; tables: string[] }> };
|
|
161
|
+
const daveStatus = await dave.storageStatus();
|
|
162
|
+
assert.equal(daveStatus.configured, false);
|
|
163
|
+
assert.deepEqual(daveStatus.tables, [], "a fetch before pushConfig writes no tables");
|
|
164
|
+
|
|
165
|
+
type Push = { version: number; applied: boolean };
|
|
166
|
+
const push = async (id: string, body: Record<string, unknown>, target: DurableObjectNamespace = ns) => {
|
|
167
|
+
const stub = target.get(target.idFromName(id)) as unknown as { pushConfig(b: Record<string, unknown>): Promise<Push>; fetch(r: Request): Promise<Response> };
|
|
168
|
+
return { stub, result: await stub.pushConfig(body) };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const alice = await push("id-alice", { version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice" } });
|
|
172
|
+
assert.equal(alice.result.applied, true, JSON.stringify(alice.result));
|
|
173
|
+
const stale = await alice.stub.pushConfig({ version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Stale" } });
|
|
174
|
+
assert.equal(stale.version, 1);
|
|
175
|
+
assert.equal(stale.applied, false);
|
|
176
|
+
await kv.put("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "", version: 1 }));
|
|
177
|
+
|
|
178
|
+
const bobPush = await push("id-bobby", { version: 1, tenantId: "id-bobby", name: "bobby", status: "active", ownerTokenHash: hashB, config: { AGENT_NAME: "Bob" } });
|
|
179
|
+
assert.equal(bobPush.result.applied, true, JSON.stringify(bobPush.result));
|
|
180
|
+
await kv.put("tenant:bobby", JSON.stringify({ id: "id-bobby", status: "active", region: "", version: 1 }));
|
|
181
|
+
|
|
182
|
+
const card = async (name: string, headers?: HeadersInit) => (await mf.dispatchFetch(`https://${name}.example.com/.well-known/agent-card.json`, { headers })).json() as Promise<{ name: string }>;
|
|
183
|
+
assert.equal((await card("alice")).name, "Alice");
|
|
184
|
+
assert.equal((await card("bobby")).name, "Bob");
|
|
185
|
+
assert.equal((await card("alice", { "x-a2a-tenant": JSON.stringify({ name: "bob", config: { AGENT_NAME: "Bob" } }) })).name, "Alice");
|
|
186
|
+
|
|
187
|
+
const bumped = await alice.stub.pushConfig({ version: 3, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
|
|
188
|
+
assert.equal(bumped.version, 3);
|
|
189
|
+
assert.equal(bumped.applied, true);
|
|
190
|
+
const rolled = await alice.stub.pushConfig({ version: 2, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Rolled" } });
|
|
191
|
+
assert.equal(rolled.version, 3);
|
|
192
|
+
assert.equal(rolled.applied, false);
|
|
193
|
+
assert.equal((await card("alice")).name, "Alice 3");
|
|
194
|
+
|
|
195
|
+
const call = (url: string, method: string, auth: string, json?: unknown) => mf.dispatchFetch(url, {
|
|
196
|
+
method, headers: { authorization: auth, "content-type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json),
|
|
197
|
+
});
|
|
198
|
+
assert.equal((await call("https://alice.example.com/owner/peers", "GET", "Bearer platform-owner")).status, 401, "the platform OWNER_TOKEN is not a tenant owner");
|
|
199
|
+
const issued = await (await call("https://alice.example.com/owner/peers", "POST", `Bearer ${tokenA}`, { label: "ada" })).json() as { token: string };
|
|
200
|
+
assert.match(issued.token, /^a2aow_/);
|
|
201
|
+
const sent = await call("https://alice.example.com/", "POST", `Bearer ${issued.token}`, {
|
|
202
|
+
jsonrpc: "2.0", id: 1, method: "message/send",
|
|
203
|
+
params: { message: { kind: "message", role: "user", messageId: "m1", parts: [{ kind: "text", text: "hello alice" }] } },
|
|
204
|
+
});
|
|
205
|
+
assert.equal(sent.status, 200, await sent.clone().text());
|
|
206
|
+
assert.equal((await call("https://bobby.example.com/", "POST", `Bearer ${issued.token}`, {
|
|
207
|
+
jsonrpc: "2.0", id: 1, method: "message/send",
|
|
208
|
+
params: { message: { kind: "message", role: "user", messageId: "m2", parts: [{ kind: "text", text: "for bob?" }] } },
|
|
209
|
+
})).status, 401);
|
|
210
|
+
assert.equal((await call("https://bobby.example.com/owner/peers", "GET", `Bearer ${tokenA}`)).status, 401);
|
|
211
|
+
const bobInbox = await (await call("https://bobby.example.com/owner/inbox", "GET", `Bearer ${tokenB}`)).json() as unknown[];
|
|
212
|
+
assert.deepEqual(bobInbox, []);
|
|
213
|
+
const aliceInbox = await (await call("https://alice.example.com/owner/inbox?all=1", "GET", `Bearer ${tokenA}`)).json() as { text: string }[];
|
|
214
|
+
assert.equal(aliceInbox.length, 1);
|
|
215
|
+
assert.match(aliceInbox[0]!.text, /hello alice/);
|
|
216
|
+
|
|
217
|
+
// workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
|
|
218
|
+
// namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
|
|
219
|
+
await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
|
|
220
|
+
assert.equal((await mf.dispatchFetch("https://alice.example.com/health")).status, 403, "the object enforces a newer status than the directory cache");
|
|
221
|
+
});
|