a2a-exposed 0.4.0 → 0.5.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/lib/a2a.mjs +67 -0
- package/lib/commands.mjs +41 -0
- package/lib/config.mjs +4 -6
- package/lib/deploy.mjs +124 -20
- package/lib/main.mjs +34 -8
- package/lib/pair.mjs +4 -2
- package/lib/status.mjs +121 -5
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -0
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/src/facade.ts +118 -0
- package/worker/src/index.ts +811 -16
- package/worker/src/mcp.ts +318 -0
- package/worker/src/pairing.ts +1 -1
- package/worker/src/wake.ts +12 -1
- package/worker/test/facade.test.ts +162 -1
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +15 -3
- package/worker/test/wake-retry.test.ts +51 -0
- package/worker/test/wake.test.ts +15 -0
|
@@ -57,7 +57,7 @@ test("migration files: NNNN_name.sql, unique numbers, no gaps, 0002_wake_budget
|
|
|
57
57
|
test("fresh database: every migration applies in order; a second run applies nothing", () => {
|
|
58
58
|
const db = new DatabaseSync(":memory:");
|
|
59
59
|
assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
|
|
60
|
-
for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners"]) assert.ok(tables(db).includes(t), t);
|
|
60
|
+
for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata"]) assert.ok(tables(db).includes(t), t);
|
|
61
61
|
assert.ok(columns(db, "peers").includes("source"));
|
|
62
62
|
assert.ok(columns(db, "device_requests").includes("replaces_label"));
|
|
63
63
|
assert.ok(columns(db, "device_requests").includes("replaces_hash"));
|
|
@@ -67,15 +67,15 @@ test("fresh database: every migration applies in order; a second run applies not
|
|
|
67
67
|
test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
|
|
68
68
|
const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
|
|
69
69
|
db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
|
|
70
|
-
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql"]);
|
|
70
|
+
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql"]);
|
|
71
71
|
assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
|
|
75
75
|
const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
|
|
76
76
|
db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
|
|
77
|
-
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql"]);
|
|
78
|
-
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql"]);
|
|
77
|
+
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql"]);
|
|
78
|
+
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql"]);
|
|
79
79
|
assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
|
|
80
80
|
});
|
|
81
81
|
|
|
@@ -107,7 +107,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
|
|
|
107
107
|
assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
|
|
108
108
|
assert.equal(wakes.length, 0);
|
|
109
109
|
|
|
110
|
-
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql"]);
|
|
110
|
+
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql"]);
|
|
111
111
|
assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
|
|
112
112
|
assert.deepEqual(applyMigrations(db), []);
|
|
113
113
|
|
|
@@ -108,7 +108,7 @@ test("agent card, RFC 8414 metadata and the 401 hint advertise the device flow;
|
|
|
108
108
|
const meta = await s.call("GET", "/.well-known/oauth-authorization-server");
|
|
109
109
|
assert.equal(meta.status, 200);
|
|
110
110
|
assert.deepEqual([meta.data.issuer, meta.data.device_authorization_endpoint, meta.data.token_endpoint], [BASE, `${BASE}/oauth/device_authorization`, `${BASE}/oauth/token`]);
|
|
111
|
-
assert.deepEqual(meta.data.grant_types_supported, [GRANT]);
|
|
111
|
+
assert.deepEqual(meta.data.grant_types_supported, [GRANT, "authorization_code", "refresh_token"], "device flow + the MCP connector's grants");
|
|
112
112
|
assert.deepEqual(meta.data.token_endpoint_auth_methods_supported, ["none"]);
|
|
113
113
|
|
|
114
114
|
for (const [method, v] of [["message/send", "0.3"], ["SendMessage", "1.0"]]) {
|
|
@@ -157,7 +157,16 @@ test("/.well-known/agent.json serves an A2A 0.3 card (url, preferredTransport, p
|
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
test("PAIRING_APPROVAL=off: no pairing scheme, endpoints 404, plain 401", async (t) => {
|
|
160
|
-
|
|
160
|
+
// with MCP on (the default) the authorization server stays up for the connector, without the device flow
|
|
161
|
+
const m = setup({ PAIRING_APPROVAL: "off" }); t.after(m.restore);
|
|
162
|
+
const meta = await m.call("GET", "/.well-known/oauth-authorization-server");
|
|
163
|
+
assert.equal(meta.status, 200);
|
|
164
|
+
assert.equal(meta.data.device_authorization_endpoint, undefined);
|
|
165
|
+
assert.deepEqual(meta.data.grant_types_supported, ["authorization_code", "refresh_token"]);
|
|
166
|
+
for (const [mm, p] of [["POST", "/oauth/device_authorization"], ["GET", "/device"]]) assert.equal((await m.call(mm, p, mm === "POST" ? { form: {} } : {})).status, 404, p);
|
|
167
|
+
assert.equal((await m.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: "x" } })).data.error, "unsupported_grant_type");
|
|
168
|
+
m.restore();
|
|
169
|
+
const s = setup({ PAIRING_APPROVAL: "off", MCP: "off" }); t.after(s.restore);
|
|
161
170
|
const card = (await s.call("GET", "/.well-known/agent-card.json")).data;
|
|
162
171
|
assert.deepEqual(Object.keys(card.securitySchemes), ["bearer"]);
|
|
163
172
|
assert.deepEqual(card.securityRequirements, [{ schemes: { bearer: { list: [] } } }]);
|
|
@@ -581,8 +590,11 @@ test("password setup link: expiry, mismatch, too short, CSRF, a new link invalid
|
|
|
581
590
|
for (let i = 0; i < 20; i++) await s.call("GET", "/device/setup?t=nope", { ip: "192.0.2.200" });
|
|
582
591
|
const limited = await s.call("GET", new URL(l.url).pathname + new URL(l.url).search, { ip: "192.0.2.200" });
|
|
583
592
|
assert.equal(limited.status, 429);
|
|
584
|
-
// off
|
|
593
|
+
// pairing off: the link still works while MCP is on (the connector consent needs the password), not with both off
|
|
585
594
|
s.env.PAIRING_APPROVAL = "off";
|
|
595
|
+
assert.equal((await s.owner("POST", "/owner/pairing/password-link", {})).status, 200);
|
|
596
|
+
assert.equal((await s.call("GET", "/device/setup?t=x", { ip: "192.0.2.201" })).status, 404, "reachable (unknown setup token)");
|
|
597
|
+
s.env.MCP = "off";
|
|
586
598
|
assert.equal((await s.owner("POST", "/owner/pairing/password-link", {})).status, 409);
|
|
587
599
|
assert.equal((await s.call("GET", "/device/setup?t=x")).status, 404);
|
|
588
600
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A wake endpoint that answers 429 / 503 (e.g. a Claude Code routine over its fire limit): the wake is kept pending and
|
|
2
|
+
// re-sent after Retry-After, at most 3 times. 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
|
+
|
|
8
|
+
const BASE = "https://agent.example.com";
|
|
9
|
+
const realNow = Date.now;
|
|
10
|
+
let offset = 0;
|
|
11
|
+
Date.now = () => realNow() + offset;
|
|
12
|
+
|
|
13
|
+
test("429 with Retry-After: the wake is re-sent after the wait, merged with newer tasks, and dropped after 3 retries", async (t) => {
|
|
14
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "claude-code",
|
|
15
|
+
WAKE_WEBHOOK_URL: "https://api.anthropic.com/v1/claude_code/routines/trig_x/fire", WAKE_WEBHOOK_KEY: "rt", WAKE_DEBOUNCE_SECONDS: "20" };
|
|
16
|
+
const sent: any[] = [];
|
|
17
|
+
let answer = 429;
|
|
18
|
+
const orig = globalThis.fetch;
|
|
19
|
+
globalThis.fetch = (async (_u: any, init: any) => { sent.push(JSON.parse(init.body)); return new Response("{}", { status: answer, headers: { "retry-after": "120" } }); }) as any;
|
|
20
|
+
t.after(() => { globalThis.fetch = orig; Date.now = realNow; });
|
|
21
|
+
const call = async (method: string, path: string, json?: any, auth = "Bearer owner-secret") => {
|
|
22
|
+
const pending: Promise<unknown>[] = [];
|
|
23
|
+
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,
|
|
24
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
25
|
+
// settle the request's background work (the 20 s debounce timer is never needed here)
|
|
26
|
+
await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, 200))]);
|
|
27
|
+
return res.json().catch(() => null);
|
|
28
|
+
};
|
|
29
|
+
const tok = (await call("POST", "/owner/peers", { label: "barry" })).token;
|
|
30
|
+
const msg = (text: string) => call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), contextId: "ctx-r", parts: [{ kind: "text", text }] } } }, `Bearer ${tok}`);
|
|
31
|
+
await msg("one");
|
|
32
|
+
assert.equal(sent.length, 1);
|
|
33
|
+
const row = () => env.DB.db.prepare("SELECT * FROM wakes WHERE context_id = 'ctx-r'").get() as any;
|
|
34
|
+
assert.ok(row().pending_json, "kept pending after the 429");
|
|
35
|
+
offset += 60_000;
|
|
36
|
+
await call("GET", "/health");
|
|
37
|
+
assert.equal(sent.length, 1, "not before Retry-After");
|
|
38
|
+
offset += 61_000;
|
|
39
|
+
await call("GET", "/health");
|
|
40
|
+
assert.equal(sent.length, 2, "re-sent after Retry-After");
|
|
41
|
+
assert.equal(JSON.parse(row().pending_json).attempts, 2);
|
|
42
|
+
for (let i = 0; i < 3; i++) { offset += 121_000; await call("GET", "/health"); }
|
|
43
|
+
assert.equal(sent.length, 4, "3 retries, then dropped");
|
|
44
|
+
assert.equal(row().pending_json, null);
|
|
45
|
+
// a success clears it for good
|
|
46
|
+
answer = 200;
|
|
47
|
+
offset += 3_600_000;
|
|
48
|
+
await msg("two");
|
|
49
|
+
assert.equal(sent.length, 5);
|
|
50
|
+
assert.equal(row().pending_json, null);
|
|
51
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -30,6 +30,21 @@ test("claude-code: anthropic-version + text body", async () => {
|
|
|
30
30
|
assert.deepEqual(Object.keys(b), ["text"]);
|
|
31
31
|
assert.match(b.text, /2 new messages from peer "peer-a"/);
|
|
32
32
|
assert.match(b.text, /untrusted/);
|
|
33
|
+
// a fresh routine session learns what it needs and what to do when setup is missing
|
|
34
|
+
assert.match(b.text, /A2A_BASE_URL and A2A_OWNER_TOKEN/);
|
|
35
|
+
assert.match(b.text, /a2a-exposed connector \(MCP tools/, "the connector comes first");
|
|
36
|
+
assert.ok(b.text.indexOf("connector") < b.text.indexOf("A2A_OWNER_TOKEN"), "the CLI is the fallback");
|
|
37
|
+
assert.match(b.text, /never ask for the token in chat/);
|
|
38
|
+
assert.ok(!b.text.includes("Bearer rt") && !b.text.includes(" rt "), "no secret in the text");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("claude-code: a human-approved pairing wake stays CLI-free (no cold-start note)", async () => {
|
|
42
|
+
const pe = { ...ev, kind: "pairing_request" as const, taskIds: [], pairing: { userCode: "WDJB-4827", verificationUriComplete: "https://agent.example.com/device?user_code=WDJB-4827",
|
|
43
|
+
approval: "human" as const, clientName: "Peer", clientId: "", agentCardUrl: "", expiresIn: 600 } };
|
|
44
|
+
const r = (await renderWake({ preset: "claude-code", url: "https://api.anthropic.com/v1/claude_code/routines/trig_x/fire", key: "rt" }, pe))!;
|
|
45
|
+
const b = JSON.parse(r.body);
|
|
46
|
+
assert.match(b.text, /WDJB-4827/);
|
|
47
|
+
assert.ok(!b.text.includes("A2A_OWNER_TOKEN"), "human pairing needs no CLI or owner token");
|
|
33
48
|
});
|
|
34
49
|
|
|
35
50
|
test("openclaw-wake: no peer text, mode now, agentId", async () => {
|