a2a-exposed 0.5.0 → 0.7.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 +24 -15
- package/lib/deploy.mjs +20 -17
- package/lib/main.mjs +24 -9
- 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 +32 -1
- 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 +748 -584
- package/worker/src/mcp.ts +30 -12
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +17 -4
- 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-target.ts +202 -0
- package/worker/src/wake.ts +3 -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/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +5 -4
- 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 +24 -1
- package/worker/test/wake-target.test.ts +187 -0
- package/worker/test/wake.test.ts +3 -0
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// re-sent after Retry-After, at most 3 times. Run: npm test
|
|
3
3
|
import { test } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import worker from "../src/index.ts";
|
|
5
|
+
import worker, { nextCronAt } from "../src/index.ts";
|
|
6
6
|
import { d1 } from "./d1.ts";
|
|
7
|
+
import { resolveTenant } from "../src/tenancy.ts";
|
|
7
8
|
|
|
8
9
|
const BASE = "https://agent.example.com";
|
|
9
10
|
const realNow = Date.now;
|
|
@@ -49,3 +50,25 @@ test("429 with Retry-After: the wake is re-sent after the wait, merged with newe
|
|
|
49
50
|
assert.equal(sent.length, 5);
|
|
50
51
|
assert.equal(row().pending_json, null);
|
|
51
52
|
});
|
|
53
|
+
|
|
54
|
+
test("a 429 is due at Retry-After when the debounce is longer", async (t) => {
|
|
55
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
56
|
+
WAKE_WEBHOOK_URL: "https://example.com/hook", WAKE_WEBHOOK_KEY: "k", WAKE_DEBOUNCE_SECONDS: "120" };
|
|
57
|
+
const sent: any[] = [];
|
|
58
|
+
const orig = globalThis.fetch;
|
|
59
|
+
globalThis.fetch = (async () => { sent.push(1); return new Response("{}", { status: 429, headers: { "retry-after": "30" } }); }) as any;
|
|
60
|
+
t.after(() => { globalThis.fetch = orig; });
|
|
61
|
+
const pending: Promise<unknown>[] = [];
|
|
62
|
+
const ectx = { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} };
|
|
63
|
+
const call = async (method: string, path: string, json?: any, auth = "Bearer owner-secret") => {
|
|
64
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers: { "content-type": "application/json", authorization: auth }, body: json ? JSON.stringify(json) : undefined }) as any, env, ectx as any);
|
|
65
|
+
await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, 200))]);
|
|
66
|
+
return res.json().catch(() => null);
|
|
67
|
+
};
|
|
68
|
+
const tok = (await call("POST", "/owner/peers", { label: "barry" })).token;
|
|
69
|
+
const before = Date.now();
|
|
70
|
+
await call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", contextId: "ctx-long", parts: [{ kind: "text", text: "one" }] } } }, `Bearer ${tok}`);
|
|
71
|
+
assert.equal(sent.length, 1);
|
|
72
|
+
const at = await nextCronAt(resolveTenant(env), Date.now());
|
|
73
|
+
assert.ok(at != null && at >= before + 25_000 && at <= before + 35_000, `alarm ${at} is Retry-After, not the 120s debounce`);
|
|
74
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// WAKE_TARGET_POLICY=public-https refuses private, metadata, and redirect-to-private wake targets.
|
|
2
|
+
// Unset keeps today's fetch. 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 { d1 } from "./d1.ts";
|
|
7
|
+
import * as M from "../src/mcp.ts";
|
|
8
|
+
import { fetchCheckedWake, publicWakeProblem, type AddressLookup, type WakeDeps } from "../src/wake-target.ts";
|
|
9
|
+
|
|
10
|
+
const publicLookup: AddressLookup = async (host) => {
|
|
11
|
+
if (host === "hook.example" || host === "also.example") return ["1.2.3.4"];
|
|
12
|
+
if (host === "split.example") return ["1.2.3.4", "10.0.0.1"];
|
|
13
|
+
if (host === "meta.example") return ["169.254.169.254"];
|
|
14
|
+
if (host === "missing.example") return [];
|
|
15
|
+
return null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
test("public-https refuses private, metadata, and non-https URLs before any request", async () => {
|
|
19
|
+
const refused = [
|
|
20
|
+
"http://hook.example/wake",
|
|
21
|
+
"https://user:secret@hook.example/wake",
|
|
22
|
+
"https://127.0.0.1/wake",
|
|
23
|
+
"https://10.1.2.3/wake",
|
|
24
|
+
"https://192.168.1.1/wake",
|
|
25
|
+
"https://172.16.0.1/wake",
|
|
26
|
+
"https://100.64.0.1/wake",
|
|
27
|
+
"https://100.100.100.200/wake",
|
|
28
|
+
"https://169.254.169.254/latest/meta-data",
|
|
29
|
+
"https://[::1]/wake",
|
|
30
|
+
"https://[fe80::1]/wake",
|
|
31
|
+
"https://localhost/wake",
|
|
32
|
+
"https://metadata.google.internal/wake",
|
|
33
|
+
"https://agent.ts.net/wake",
|
|
34
|
+
"https://metadata/wake",
|
|
35
|
+
"not a url",
|
|
36
|
+
];
|
|
37
|
+
for (const url of refused) {
|
|
38
|
+
const why = await publicWakeProblem(url, publicLookup);
|
|
39
|
+
assert.notEqual(why, "", url);
|
|
40
|
+
}
|
|
41
|
+
assert.equal(await publicWakeProblem("https://hook.example/wake", publicLookup), "");
|
|
42
|
+
assert.equal(await publicWakeProblem("https://1.2.3.4/wake", publicLookup), "");
|
|
43
|
+
assert.equal(await publicWakeProblem("https://split.example/wake", publicLookup), "resolves to a private or reserved address");
|
|
44
|
+
assert.equal(await publicWakeProblem("https://meta.example/wake", publicLookup), "resolves to a private or reserved address");
|
|
45
|
+
assert.equal(await publicWakeProblem("https://missing.example/wake", publicLookup), "the host has no address");
|
|
46
|
+
assert.equal(await publicWakeProblem("https://unknown.example/wake", publicLookup), "its address could not be checked");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function pinned(answer: (request: string) => { status: number; location?: string }) {
|
|
50
|
+
const calls: { ip: string; sni: string; request: string }[] = [];
|
|
51
|
+
const connect: NonNullable<WakeDeps["connect"]> = (addr) => ({
|
|
52
|
+
startTls: ({ expectedServerHostname: sni }) => {
|
|
53
|
+
let req = "";
|
|
54
|
+
let respond!: (bytes: Uint8Array) => void;
|
|
55
|
+
const ready = new Promise<Uint8Array>((resolve) => { respond = resolve; });
|
|
56
|
+
const writable = new WritableStream<Uint8Array>({
|
|
57
|
+
write(chunk) {
|
|
58
|
+
req += new TextDecoder().decode(chunk);
|
|
59
|
+
if (!req.includes("\r\n\r\n")) return;
|
|
60
|
+
calls.push({ ip: addr.hostname, sni, request: req });
|
|
61
|
+
const made = answer(req);
|
|
62
|
+
const headers = [`HTTP/1.1 ${made.status} X`, "content-length: 0"];
|
|
63
|
+
if (made.location) headers.push(`location: ${made.location}`);
|
|
64
|
+
headers.push("connection: close", "", "");
|
|
65
|
+
respond(new TextEncoder().encode(headers.join("\r\n")));
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
69
|
+
async start(controller) {
|
|
70
|
+
controller.enqueue(await ready);
|
|
71
|
+
controller.close();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return { readable, writable, close: async () => {} };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return { connect, calls };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("a redirect to a private address is not connected, and the first hop uses the checked IP", async () => {
|
|
81
|
+
const socket = pinned(() => ({ status: 302, location: "http://169.254.169.254/latest/meta-data" }));
|
|
82
|
+
const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
|
|
83
|
+
assert.equal(socket.calls.length, 1);
|
|
84
|
+
assert.equal(socket.calls[0].ip, "1.2.3.4");
|
|
85
|
+
assert.equal(socket.calls[0].sni, "hook.example");
|
|
86
|
+
assert.match(socket.calls[0].request, /^POST \/wake HTTP\/1\.1/);
|
|
87
|
+
assert.match(socket.calls[0].request, /authorization: Bearer secret/i);
|
|
88
|
+
assert.equal("refused" in result && result.refused, "must be https");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a cross-origin redirect is not followed, so credentials stay on the first host", async () => {
|
|
92
|
+
const socket = pinned(() => ({ status: 307, location: "https://also.example/wake" }));
|
|
93
|
+
const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
|
|
94
|
+
assert.equal(socket.calls.length, 1);
|
|
95
|
+
assert.equal(socket.calls[0].ip, "1.2.3.4");
|
|
96
|
+
assert.equal("refused" in result && result.refused, "cross-origin redirect");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a same-origin redirect is pinned again and keeps the wake headers", async () => {
|
|
100
|
+
const socket = pinned((request) => request.startsWith("POST /wake ")
|
|
101
|
+
? { status: 307, location: "https://hook.example/next" }
|
|
102
|
+
: { status: 200 });
|
|
103
|
+
const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
|
|
104
|
+
assert.equal(socket.calls.length, 2);
|
|
105
|
+
assert.deepEqual(socket.calls.map((call) => call.ip), ["1.2.3.4", "1.2.3.4"]);
|
|
106
|
+
assert.match(socket.calls[1].request, /^POST \/next HTTP\/1\.1/);
|
|
107
|
+
assert.match(socket.calls[1].request, /authorization: Bearer secret/i);
|
|
108
|
+
assert.equal("response" in result && result.response.status, 200);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const BASE = "https://agent.example.com";
|
|
112
|
+
|
|
113
|
+
async function wakeTest(env: Record<string, unknown>, fetchImpl: typeof fetch) {
|
|
114
|
+
const orig = globalThis.fetch;
|
|
115
|
+
globalThis.fetch = fetchImpl;
|
|
116
|
+
try {
|
|
117
|
+
const res = await worker.fetch(new Request(BASE + "/owner/wake/test", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { authorization: "Bearer owner-secret" },
|
|
120
|
+
}), env as any, { waitUntil() {}, passThroughOnException() {} } as any);
|
|
121
|
+
return res.json() as Promise<{ status: number | null; info: string }>;
|
|
122
|
+
} finally {
|
|
123
|
+
globalThis.fetch = orig;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("the policy is off unless WAKE_TARGET_POLICY is exactly public-https", async () => {
|
|
128
|
+
const env = {
|
|
129
|
+
DB: d1(new URL("../migrations/", import.meta.url)),
|
|
130
|
+
PUBLIC_URL: BASE,
|
|
131
|
+
OWNER_TOKEN: "owner-secret",
|
|
132
|
+
WAKE_PRESET: "generic",
|
|
133
|
+
WAKE_WEBHOOK_URL: "http://127.0.0.1/hook",
|
|
134
|
+
};
|
|
135
|
+
const requested: string[] = [];
|
|
136
|
+
const body = await wakeTest(env, (async (url: string) => {
|
|
137
|
+
requested.push(String(url));
|
|
138
|
+
return new Response("ok", { status: 200 });
|
|
139
|
+
}) as typeof fetch);
|
|
140
|
+
assert.equal(body.status, 200);
|
|
141
|
+
assert.equal(requested.length, 1);
|
|
142
|
+
assert.match(requested[0], /^http:\/\/127\.0\.0\.1\/hook/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("public-https does not request a metadata address, and does post to a checked public host", async () => {
|
|
146
|
+
const db = () => d1(new URL("../migrations/", import.meta.url));
|
|
147
|
+
const requested: string[] = [];
|
|
148
|
+
const fetchImpl = (async (url: string | URL) => {
|
|
149
|
+
const href = String(url);
|
|
150
|
+
requested.push(href);
|
|
151
|
+
if (href.startsWith("https://cloudflare-dns.com/dns-query")) {
|
|
152
|
+
const name = new URL(href).searchParams.get("name");
|
|
153
|
+
const type = new URL(href).searchParams.get("type");
|
|
154
|
+
if (name === "hook.example" && type === "A") {
|
|
155
|
+
return new Response(JSON.stringify({ Status: 0, Answer: [{ type: 1, data: "1.2.3.4" }] }), { status: 200 });
|
|
156
|
+
}
|
|
157
|
+
return new Response(JSON.stringify({ Status: 0, Answer: [] }), { status: 200 });
|
|
158
|
+
}
|
|
159
|
+
return new Response("ok", { status: 200 });
|
|
160
|
+
}) as typeof fetch;
|
|
161
|
+
|
|
162
|
+
const blocked = await wakeTest({
|
|
163
|
+
DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
164
|
+
WAKE_WEBHOOK_URL: "https://169.254.169.254/latest/meta-data", WAKE_TARGET_POLICY: "public-https",
|
|
165
|
+
}, fetchImpl);
|
|
166
|
+
assert.equal(blocked.status, null);
|
|
167
|
+
assert.match(blocked.info, /refused: private or internal host/);
|
|
168
|
+
assert.equal(requested.some((url) => url.includes("169.254.169.254")), false);
|
|
169
|
+
|
|
170
|
+
requested.length = 0;
|
|
171
|
+
const socket = pinned(() => ({ status: 200 }));
|
|
172
|
+
M.net.connect = socket.connect;
|
|
173
|
+
try {
|
|
174
|
+
const allowed = await wakeTest({
|
|
175
|
+
DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
176
|
+
WAKE_WEBHOOK_URL: "https://hook.example/wake", WAKE_WEBHOOK_KEY: "hook-key", WAKE_TARGET_POLICY: "public-https",
|
|
177
|
+
}, fetchImpl);
|
|
178
|
+
assert.equal(allowed.status, 200);
|
|
179
|
+
assert.equal(requested.every((url) => url.startsWith("https://cloudflare-dns.com/")), true);
|
|
180
|
+
assert.equal(socket.calls.length, 1);
|
|
181
|
+
assert.equal(socket.calls[0].ip, "1.2.3.4");
|
|
182
|
+
assert.equal(socket.calls[0].sni, "hook.example");
|
|
183
|
+
assert.match(socket.calls[0].request, /authorization: Bearer hook-key/i);
|
|
184
|
+
} finally {
|
|
185
|
+
M.net.connect = null;
|
|
186
|
+
}
|
|
187
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -35,6 +35,9 @@ test("claude-code: anthropic-version + text body", async () => {
|
|
|
35
35
|
assert.match(b.text, /a2a-exposed connector \(MCP tools/, "the connector comes first");
|
|
36
36
|
assert.ok(b.text.indexOf("connector") < b.text.indexOf("A2A_OWNER_TOKEN"), "the CLI is the fallback");
|
|
37
37
|
assert.match(b.text, /never ask for the token in chat/);
|
|
38
|
+
assert.match(b.text, /npx -y a2a-exposed@latest inbox/, "the default hint runs the CLI through npx");
|
|
39
|
+
assert.match(b.text, /run as `npx -y a2a-exposed@latest`, no global install/);
|
|
40
|
+
assert.ok(!/npm i(nstall)? -g/.test(b.text), "never asks for a global install");
|
|
38
41
|
assert.ok(!b.text.includes("Bearer rt") && !b.text.includes(" rt "), "no secret in the text");
|
|
39
42
|
});
|
|
40
43
|
|