a2a-exposed 0.8.6 → 0.8.8
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 +4 -4
- package/lib/deploy.mjs +3 -3
- package/lib/feedback.mjs +1 -1
- package/lib/main.mjs +108 -32
- package/lib/pair.mjs +5 -5
- package/lib/status.mjs +35 -10
- package/lib/tunnel.mjs +94 -28
- package/lib/uninstall.mjs +124 -14
- package/lib/workersdev.mjs +1 -1
- package/package.json +2 -2
- package/worker/cloudflare.config.ts +2 -0
- package/worker/deploy.env.example +4 -1
- package/worker/package.json +1 -1
- package/worker/src/feedback.ts +1 -1
- package/worker/src/index.ts +33 -21
- package/worker/src/mcp.ts +18 -13
- package/worker/src/pairing.ts +47 -9
- package/worker/src/tenancy.ts +16 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +12 -6
- package/worker/test/mcp.test.ts +32 -2
- package/worker/test/pairing.test.ts +94 -7
- package/worker/test/tenancy.test.ts +21 -1
- package/worker/test/wake.test.ts +2 -0
|
@@ -35,14 +35,16 @@ function setup(over: Record<string, string> = {}) {
|
|
|
35
35
|
wakes.push({ url: String(url), body: JSON.parse(init.body) });
|
|
36
36
|
return new Response("ok");
|
|
37
37
|
}) as any;
|
|
38
|
-
const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; ip?: string; cookie?: string } = {}) => {
|
|
38
|
+
const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; ip?: string; cookie?: string; cf?: Record<string, unknown> } = {}) => {
|
|
39
39
|
const headers: Record<string, string> = { "cf-connecting-ip": o.ip || "198.51.100.7", ...(o.headers || {}) };
|
|
40
40
|
let body: string | undefined;
|
|
41
41
|
if (o.form) { headers["content-type"] = "application/x-www-form-urlencoded"; body = new URLSearchParams(o.form).toString(); }
|
|
42
42
|
if (o.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(o.json); }
|
|
43
43
|
if (o.cookie) headers.cookie = o.cookie;
|
|
44
44
|
const pending: Promise<unknown>[] = [];
|
|
45
|
-
const
|
|
45
|
+
const req = new Request(BASE + path, { method, headers, body }) as any;
|
|
46
|
+
if (o.cf) req.cf = o.cf;
|
|
47
|
+
const res = await worker.fetch(req, env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
46
48
|
await Promise.allSettled(pending);
|
|
47
49
|
const text = await res.text();
|
|
48
50
|
let data: any = text;
|
|
@@ -255,7 +257,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
255
257
|
assert.match(d.user_code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
|
|
256
258
|
assert.equal(d.verification_uri, `${BASE}/device`);
|
|
257
259
|
assert.equal(d.verification_uri_complete, `${BASE}/device?user_code=${d.user_code}`);
|
|
258
|
-
assert.deepEqual([d.expires_in, d.interval], [
|
|
260
|
+
assert.deepEqual([d.expires_in, d.interval], [3600, 5]);
|
|
259
261
|
// the device code is stored hashed only
|
|
260
262
|
const row: any = s.DB.db.prepare("SELECT * FROM device_requests").get();
|
|
261
263
|
assert.notEqual(row.device_hash, d.device_code);
|
|
@@ -268,6 +270,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
268
270
|
assert.equal(w.hint, "npx -y a2a-exposed@latest pair list");
|
|
269
271
|
assert.deepEqual([w.pairing.userCode, w.pairing.verificationUriComplete, w.pairing.approval, w.pairing.clientId], [d.user_code, d.verification_uri_complete, "human", "barry"]);
|
|
270
272
|
assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
|
|
273
|
+
assert.equal("manageUrl" in w.pairing, false);
|
|
271
274
|
assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
|
|
272
275
|
|
|
273
276
|
let p = await s.poll(d.device_code);
|
|
@@ -321,6 +324,8 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
321
324
|
r = await s.decidePage(d.user_code, PASSWORD, "approve");
|
|
322
325
|
assert.equal(r.status, 200, r.text);
|
|
323
326
|
assert.match(r.text, /Approved\. Barry <script>alert\(1\)<\/script> can now collect its token \(label "Barry-script-alert-1-scr"\)/);
|
|
327
|
+
assert.match(r.text, /You can close this page/);
|
|
328
|
+
assert.doesNotMatch(r.text, /name="user_code"/);
|
|
324
329
|
|
|
325
330
|
advance(11);
|
|
326
331
|
p = await s.poll(d.device_code);
|
|
@@ -445,7 +450,7 @@ test("labels are deduplicated; denied and expired requests; JSON bodies; bad req
|
|
|
445
450
|
assert.equal((await s.poll(c.device_code)).data.error, "invalid_grant", "reported once, then gone");
|
|
446
451
|
|
|
447
452
|
const e = (await s.start(undefined, "203.0.113.4")).data;
|
|
448
|
-
advance(
|
|
453
|
+
advance(P.EXPIRES_S + 1);
|
|
449
454
|
assert.equal((await s.poll(e.device_code)).data.error, "expired_token");
|
|
450
455
|
assert.equal((await s.owner("POST", `/owner/pairing/${e.user_code}/approve`)).status, 409);
|
|
451
456
|
advance(3600);
|
|
@@ -498,7 +503,7 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
|
|
|
498
503
|
assert.equal(over.status, 429);
|
|
499
504
|
assert.match(over.data.error_description, /too many pairing requests waiting/);
|
|
500
505
|
// expiry frees the slots
|
|
501
|
-
advance(
|
|
506
|
+
advance(P.EXPIRES_S + 1);
|
|
502
507
|
assert.equal((await s.start(undefined, "198.51.100.99")).status, 200);
|
|
503
508
|
// the page: code lookups per IP are capped (no enumerating pending requests)
|
|
504
509
|
for (let i = 0; i < 30; i++) assert.doesNotMatch((await s.call("GET", "/device?user_code=BCDF-2345", { ip: "192.0.2.77" })).text, /Too many code lookups/);
|
|
@@ -508,9 +513,9 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
|
|
|
508
513
|
|
|
509
514
|
test("pairing wakes: summary for each preset; requester text only where previews are allowed", async () => {
|
|
510
515
|
const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
|
|
511
|
-
pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn:
|
|
516
|
+
pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 3600 } };
|
|
512
517
|
const sum = wakeSummary(ev, "npx -y a2a-exposed@latest");
|
|
513
|
-
assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your
|
|
518
|
+
assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your A2A endpoint \(code WDJB-2345, expires in 60 minutes\)\./);
|
|
514
519
|
assert.match(sum, /never approve on your own\. Show them the code WDJB-2345 and give them this link: https:\/\/agent\.example\.com\/device\?user_code=WDJB-2345/);
|
|
515
520
|
const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
|
|
516
521
|
assert.match(agentSum, /Only if they say yes, run `a2a pair approve WDJB-2345`; if they say no \(or don't answer\), `a2a pair deny WDJB-2345`/);
|
|
@@ -539,6 +544,8 @@ test("/device deny needs no password and runs no PBKDF2; an empty password on ap
|
|
|
539
544
|
try { r = await s.decidePage(a.user_code, "", "deny"); } finally { (crypto.subtle as any).deriveBits = realPbkdf2; }
|
|
540
545
|
assert.equal(r.status, 200, r.text);
|
|
541
546
|
assert.match(r.text, /Denied\. Barry Bot gets no token\./);
|
|
547
|
+
assert.match(r.text, /You can close this page/);
|
|
548
|
+
assert.doesNotMatch(r.text, /name="user_code"/);
|
|
542
549
|
assert.equal(derived, 0, "no PBKDF2 for a deny");
|
|
543
550
|
assert.equal((await s.poll(a.device_code)).data.error, "access_denied");
|
|
544
551
|
// CSRF still applies to deny
|
|
@@ -868,3 +875,83 @@ test("owner log lists history without data_json and does not clear a pending wak
|
|
|
868
875
|
assert.equal(head.text, "");
|
|
869
876
|
assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, again);
|
|
870
877
|
});
|
|
878
|
+
|
|
879
|
+
test("device page copy: code hint, expiry, token scope, settled result, A2A endpoint", async (t) => {
|
|
880
|
+
const at = Date.UTC(2026, 9, 16, 14, 32, 0);
|
|
881
|
+
assert.equal(P.expiresText(at, at - 30_000), "14:32 UTC (less than a minute)");
|
|
882
|
+
assert.equal(P.expiresText(at, at - 90_000), "14:32 UTC (2 min)");
|
|
883
|
+
assert.equal(P.expiresText(at, at - 90_000, "America/New_York"), "10:32 (2 min)");
|
|
884
|
+
assert.equal(P.expiresText(at, at - 90_000, "Not/AZone"), "14:32 UTC (2 min)");
|
|
885
|
+
assert.equal(P.expiresText(at, at + 1), "expired");
|
|
886
|
+
const now = Date.now();
|
|
887
|
+
const base = { agentName: "this A2A endpoint", mode: "human" as const, cli: "a2a", csrf: "c".repeat(24), passwordSet: true };
|
|
888
|
+
const bare = P.devicePage({ ...base, askCode: true }, "n");
|
|
889
|
+
assert.match(bare, /The connecting agent prints this code when it runs <code>a2a connect <url><\/code>/);
|
|
890
|
+
assert.match(bare, /It looks like WDJB-4827/);
|
|
891
|
+
assert.match(bare, /<title>Connect an agent to this A2A endpoint<\/title>/);
|
|
892
|
+
const req = { userCode: "WDJB4827", clientName: "Barry", clientId: "barry", agentCardUrl: "http://127.0.0.1/card", ip: "192.0.2.1", country: "", createdMs: now, expiresMs: now + 20_000 };
|
|
893
|
+
const page = P.devicePage({ ...base, request: req }, "n");
|
|
894
|
+
assert.match(page, /so this A2A endpoint can't check it/);
|
|
895
|
+
assert.match(page, /send messages and trigger wakes/);
|
|
896
|
+
assert.match(page, /Message text from peers is untrusted/);
|
|
897
|
+
assert.match(page, /\d{2}:\d{2} UTC \(less than a minute\)/);
|
|
898
|
+
const localExpiry = now + 10 * 24 * 3600 * 1000;
|
|
899
|
+
const localParts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "America/New_York" }).formatToParts(new Date(localExpiry));
|
|
900
|
+
const localClock = `${localParts.find((part) => part.type === "hour")?.value}:${localParts.find((part) => part.type === "minute")?.value}`;
|
|
901
|
+
const localPage = P.devicePage({ ...base, timeZone: "America/New_York", request: { ...req, expiresMs: localExpiry } }, "n");
|
|
902
|
+
assert.match(localPage, new RegExp(`${localClock} \\((?:less than a minute|\\d+ min)\\)`));
|
|
903
|
+
assert.equal(localPage.includes("UTC"), false);
|
|
904
|
+
assert.match(page, /Revoke the token <label> any time with <code>a2a token revoke <label><\/code>/);
|
|
905
|
+
const namedToken = P.devicePage({ ...base, request: { ...req, tokenLabel: "barry" } }, "n");
|
|
906
|
+
assert.match(namedToken, /Revoke the token barry any time with <code>a2a token revoke barry<\/code>/);
|
|
907
|
+
for (const settled of ["approved", "denied"] as const) {
|
|
908
|
+
const done = P.devicePage({ ...base, settled, notice: { kind: "ok", text: settled === "approved" ? "Approved." : "Denied." } }, "n");
|
|
909
|
+
assert.match(done, /You can close this page/);
|
|
910
|
+
assert.doesNotMatch(done, /name="user_code"/);
|
|
911
|
+
assert.match(done, new RegExp(`<title>${settled === "approved" ? "Approved" : "Denied"}</title>`));
|
|
912
|
+
}
|
|
913
|
+
const setupHtml = P.setupPage({ agentName: "this A2A endpoint", cli: "a2a", csrf: "c".repeat(24), token: "tok", expiresMs: now + 60_000 }, "n");
|
|
914
|
+
assert.match(setupHtml, /connect to this A2A endpoint/);
|
|
915
|
+
const land = P.landingPage({ name: "Test", base: "https://agent.example.com", versions: ["1.0"], skills: [], pairing: true, proxy: false }, "n");
|
|
916
|
+
assert.match(land, /Messages are stored for its owner, and the agent is woken/);
|
|
917
|
+
|
|
918
|
+
const s = setup(); t.after(s.restore);
|
|
919
|
+
const empty = await s.call("GET", "/device");
|
|
920
|
+
assert.match(empty.text, /prints this code when it runs/);
|
|
921
|
+
assert.match(empty.text, /WDJB-4827/);
|
|
922
|
+
const named = setup({ AGENT_NAME: "" }); t.after(named.restore);
|
|
923
|
+
const title = await named.call("GET", "/device");
|
|
924
|
+
assert.match(title.text, /Connect an agent to this A2A endpoint/);
|
|
925
|
+
const home = await s.call("GET", "/", { headers: { accept: "text/html" } });
|
|
926
|
+
assert.match(home.text, /Messages are stored for its owner, and the agent is woken/);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("approval page shows the visitor's clock, and an https MANAGE_URL is on the pairing wake", async (t) => {
|
|
930
|
+
const pageUrl = "https://example.com/app/agents/test#pairing";
|
|
931
|
+
const s = setup({ MANAGE_URL: pageUrl });
|
|
932
|
+
t.after(s.restore);
|
|
933
|
+
const st = await s.start(undefined, "203.0.113.80");
|
|
934
|
+
assert.equal(st.status, 200, JSON.stringify(st.data));
|
|
935
|
+
const row = s.DB.db.prepare("SELECT expires_ms FROM device_requests").get() as { expires_ms: number };
|
|
936
|
+
const parts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "America/New_York" }).formatToParts(new Date(row.expires_ms));
|
|
937
|
+
const clock = `${parts.find((part) => part.type === "hour")?.value}:${parts.find((part) => part.type === "minute")?.value}`;
|
|
938
|
+
const page = await s.call("GET", `/device?user_code=${st.data.user_code}`, { cf: { timezone: "America/New_York" }, ip: "203.0.113.80" });
|
|
939
|
+
assert.match(page.text, new RegExp(`${clock.replace(":", "\\:")} \\(60 min\\)`));
|
|
940
|
+
assert.equal(page.text.includes(" UTC "), false);
|
|
941
|
+
const wake = s.wakes[0].body.pairing;
|
|
942
|
+
assert.equal(wake.manageUrl, pageUrl);
|
|
943
|
+
assert.equal(wake.expiresIn, 3600);
|
|
944
|
+
assert.ok(wake.instructions.includes(`The dashboard page for this request is ${pageUrl}`));
|
|
945
|
+
|
|
946
|
+
const bare = setup();
|
|
947
|
+
t.after(bare.restore);
|
|
948
|
+
const plain = await bare.start(undefined, "203.0.113.81");
|
|
949
|
+
assert.equal("manageUrl" in bare.wakes[0].body.pairing, false);
|
|
950
|
+
const utc = await bare.call("GET", `/device?user_code=${plain.data.user_code}`, { ip: "203.0.113.81" });
|
|
951
|
+
assert.match(utc.text, /\d{2}:\d{2} UTC \(60 min\)/);
|
|
952
|
+
|
|
953
|
+
const bad = setup({ MANAGE_URL: "http://example.com/app" });
|
|
954
|
+
t.after(bad.restore);
|
|
955
|
+
await bad.start(undefined, "203.0.113.82");
|
|
956
|
+
assert.equal("manageUrl" in bad.wakes[0].body.pairing, false);
|
|
957
|
+
});
|
|
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import worker from "../src/index.ts";
|
|
6
6
|
import { sha256 } from "../src/a2a.ts";
|
|
7
7
|
import { openPeerToken, openPeerTokenWithKey, sealPeerToken, sealPeerTokenWithKey } from "../src/mcp.ts";
|
|
8
|
-
import { hostedTenantContext, namespaceForRegion, parseGates, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
8
|
+
import { hostedTenantContext, namespaceForRegion, parseGates, publicManageUrl, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
9
9
|
import { d1 } from "./d1.ts";
|
|
10
10
|
|
|
11
11
|
const dummy = { prepare() { throw new Error("storage should not be touched"); } } as any;
|
|
@@ -15,6 +15,26 @@ function selfEnv(over: Record<string, unknown> = {}): WorkerBindings {
|
|
|
15
15
|
return { DB: dummy, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", ...over } as WorkerBindings;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
test("publicManageUrl keeps an https dashboard page and drops anything else", () => {
|
|
19
|
+
const page = "https://example.com/app/agents/test#pairing";
|
|
20
|
+
assert.equal(publicManageUrl(page), page);
|
|
21
|
+
assert.equal(publicManageUrl(` ${page} `), page);
|
|
22
|
+
assert.equal(publicManageUrl(""), "");
|
|
23
|
+
assert.equal(publicManageUrl(undefined), "");
|
|
24
|
+
assert.equal(publicManageUrl("http://example.com/app"), "");
|
|
25
|
+
assert.equal(publicManageUrl("https://user:pw@example.com/app"), "");
|
|
26
|
+
assert.equal(resolveTenant(selfEnv({ MANAGE_URL: page })).MANAGE_URL, page);
|
|
27
|
+
assert.equal(resolveTenant(selfEnv()).MANAGE_URL, undefined);
|
|
28
|
+
const hosted = hostedTenantContext(selfEnv({ TENANCY: "host", MANAGE_URL: "https://platform.example/other" }), {
|
|
29
|
+
id: "ten_a", db: dummy, publicUrl: BASE, config: { MANAGE_URL: page },
|
|
30
|
+
});
|
|
31
|
+
assert.equal(hosted.MANAGE_URL, page);
|
|
32
|
+
const hostedUnset = hostedTenantContext(selfEnv({ TENANCY: "host", MANAGE_URL: page }), {
|
|
33
|
+
id: "ten_a", db: dummy, publicUrl: BASE,
|
|
34
|
+
});
|
|
35
|
+
assert.equal(hostedUnset.MANAGE_URL, undefined);
|
|
36
|
+
});
|
|
37
|
+
|
|
18
38
|
test("gates: unset is single-tenant and every reserved gate is off", () => {
|
|
19
39
|
const g = parseGates({ DB: dummy } as WorkerBindings).gates;
|
|
20
40
|
assert.equal(g.tenancy, "single");
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -61,6 +61,8 @@ test("openclaw-agent: isolated, deliver false, idempotency key", async () => {
|
|
|
61
61
|
const r = (await renderWake({ preset: "openclaw-agent", url: "https://gw/hooks/agent", key: "h" }, ev, { requestId: "rid-1" }))!;
|
|
62
62
|
const b = JSON.parse(r.body);
|
|
63
63
|
assert.equal(b.sessionMode, "isolated"); assert.equal(b.deliver, false); assert.equal(b.agentId, "main");
|
|
64
|
+
assert.equal(b.name, "A2A inbox");
|
|
65
|
+
assert.match(b.message, /^A2A endpoint:/);
|
|
64
66
|
assert.equal(r.headers["idempotency-key"], "rid-1");
|
|
65
67
|
assert.ok(b.message.includes("peer-a"));
|
|
66
68
|
});
|