a2a-exposed 0.4.1 → 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.
@@ -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
- const s = setup({ PAIRING_APPROVAL: "off" }); t.after(s.restore);
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 mode: no link
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
+ });
@@ -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 () => {