a2a-exposed 0.7.17 → 0.7.20

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.
@@ -0,0 +1,190 @@
1
+ // Feedback scrubbing, the owner discovery route, and the hosted post. No network.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import worker, { dispatch } from "../src/index.ts";
5
+ import { sha256 } from "../src/a2a.ts";
6
+ import { connectionOf, feedbackEndpoint, feedbackIssueUrl, feedbackResultText, feedbackToolDescription, scrubFeedbackText, sendFeedback, WORKER_VERSION } from "../src/feedback.ts";
7
+ import { readFileSync } from "node:fs";
8
+ import { hostedTenantContext, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
9
+ import { d1 } from "./d1.ts";
10
+
11
+ const BASE = "https://agent.example.com";
12
+
13
+ function execution(): { ectx: ExecutionContext; done: () => Promise<unknown> } {
14
+ const pending: Promise<unknown>[] = [];
15
+ return {
16
+ ectx: { waitUntil(p: Promise<unknown>) { pending.push(p); }, passThroughOnException() {} } as ExecutionContext,
17
+ done: () => Promise.allSettled(pending),
18
+ };
19
+ }
20
+
21
+ function db() {
22
+ return d1(new URL("../migrations/", import.meta.url));
23
+ }
24
+
25
+ function selfEnv(over: Record<string, unknown> = {}): WorkerBindings {
26
+ return { DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Ada", ...over } as WorkerBindings;
27
+ }
28
+
29
+ async function call(env: WorkerBindings, path: string, headers: Record<string, string> = {}) {
30
+ const run = execution();
31
+ const res = await worker.fetch(new Request(BASE + path, { headers }), env, run.ectx);
32
+ await run.done();
33
+ const text = await res.text();
34
+ let data: any = text;
35
+ try { data = JSON.parse(text); } catch { /* html or empty */ }
36
+ return { status: res.status, data, text };
37
+ }
38
+
39
+ test("scrubFeedbackText removes token shapes and Bearer values", () => {
40
+ const token = "a2aow_" + "b".repeat(24);
41
+ const pem = "-----BEGIN RSA PRIVATE KEY-----\nSECRETKEYMATERIAL\n-----END RSA PRIVATE KEY-----";
42
+ const text = scrubFeedbackText(`token ${token} and Bearer supersecretvalue stay out ${pem} AKIAIOSFODNN7EXAMPLE`);
43
+ assert.equal(text.includes(token), false);
44
+ assert.equal(text.includes("supersecretvalue"), false);
45
+ assert.equal(text.includes("SECRETKEYMATERIAL"), false);
46
+ assert.equal(text.includes("AKIAIOSFODNN7EXAMPLE"), false);
47
+ assert.match(text, /\[redacted\]/);
48
+ assert.match(text, /Bearer \[redacted\]/);
49
+ });
50
+
51
+ test("the fallback URL prefills the bug report form", () => {
52
+ const url = new URL(feedbackIssueUrl({ title: "hi <there>", message: "line", logs: "Bearer supersecretvalue" }));
53
+ assert.equal(`${url.origin}${url.pathname}`, "https://github.com/telegraphic-dev/a2a-exposed/issues/new");
54
+ assert.equal(url.searchParams.get("template"), "bug_report.yml");
55
+ assert.equal(url.searchParams.get("body"), null);
56
+ assert.equal(url.searchParams.get("title"), "hi <there>");
57
+ assert.equal(url.searchParams.get("message"), "line");
58
+ assert.match(url.searchParams.get("logs") ?? "", /Bearer supersecretvalue/);
59
+ });
60
+
61
+ test("GET /owner/feedback is hosted only for a hosted tenant", async () => {
62
+ const env = selfEnv({ FEEDBACK_URL: "https://control.example.com" });
63
+ assert.equal((await call(env, "/owner/feedback")).status, 401);
64
+ const owned = await call(env, "/owner/feedback", { authorization: "Bearer owner-secret" });
65
+ assert.deepEqual(owned.data, { hosted: false, version: WORKER_VERSION, connection: "polling" });
66
+ assert.equal(feedbackEndpoint(resolveTenant(env)), "");
67
+ assert.equal(WORKER_VERSION, JSON.parse(readFileSync(new URL("../../cli/package.json", import.meta.url), "utf8")).version);
68
+ const waking = selfEnv({ WAKE_WEBHOOK_URL: "https://hooks.example/wake" });
69
+ const wakeFacts = await call(waking, "/owner/feedback", { authorization: "Bearer owner-secret" });
70
+ assert.equal(wakeFacts.data.connection, "webhook");
71
+ assert.equal(JSON.stringify(wakeFacts.data).includes("hooks.example"), false);
72
+ const proxy = selfEnv({ UPSTREAM_URL: "https://up.example", WAKE_WEBHOOK_URL: "https://hooks.example/wake" });
73
+ assert.equal(connectionOf(resolveTenant(proxy)), "proxy");
74
+ assert.equal(connectionOf(resolveTenant(selfEnv({ UPSTREAM_URL: "none", WAKE_WEBHOOK_URL: "https://hooks.example/wake" }))), "webhook");
75
+
76
+ const hash = await sha256("owner-secret");
77
+ const hostedEnv = selfEnv({
78
+ FEEDBACK_URL: "https://control.example.com",
79
+ TENANT_DOMAIN: "example.com",
80
+ PUBLIC_URL: "https://northwind.example.com",
81
+ });
82
+ const ctx = hostedTenantContext(hostedEnv, {
83
+ id: "t1", db: hostedEnv.DB, publicUrl: "https://northwind.example.com", ownerTokenHash: hash, config: { AGENT_NAME: "Ada" },
84
+ });
85
+ assert.equal(feedbackEndpoint(ctx), "https://control.example.com/api/feedback");
86
+ const run = execution();
87
+ const res = await dispatch(new Request("https://northwind.example.com/owner/feedback", {
88
+ headers: { authorization: "Bearer owner-secret" },
89
+ }), ctx, run.ectx);
90
+ await run.done();
91
+ assert.deepEqual(await res.json(), { hosted: true, url: "https://control.example.com/api/feedback", version: WORKER_VERSION, connection: "polling" });
92
+ });
93
+
94
+ test("the public-issue note names the repository in the issue URL", () => {
95
+ const filed = feedbackResultText({ filed: true, issue_url: "https://github.com/example-org/example-repo/issues/4" });
96
+ assert.match(filed, /public GitHub issue on example-org\/example-repo/);
97
+ assert.equal(filed.includes("telegraphic-dev/a2a-exposed"), false);
98
+ assert.match(filed, /Feedback filed: https:\/\/github.com\/example-org\/example-repo\/issues\/4/);
99
+ const form = feedbackResultText({ filed: false, issue_url: feedbackIssueUrl({ title: "hi" }) });
100
+ assert.match(form, /public GitHub issue on telegraphic-dev\/a2a-exposed/);
101
+ assert.match(feedbackToolDescription(""), /configured feedback repository/);
102
+ assert.equal(feedbackToolDescription("").includes("telegraphic-dev/a2a-exposed"), false);
103
+ assert.match(feedbackToolDescription("example-org/example-repo"), /public GitHub issue on example-org\/example-repo/);
104
+ });
105
+
106
+ test("sendFeedback does not call out from a self-hosted inbox", async () => {
107
+ const ctx = resolveTenant(selfEnv({ FEEDBACK_URL: "https://control.example.com" }));
108
+ const result = await sendFeedback(ctx, { message: "hello", kind: "other", contact: "", version: "" }, () => {
109
+ throw new Error("fetch should not run");
110
+ });
111
+ assert.equal(result.filed, false);
112
+ const fallback = new URL(result.issue_url);
113
+ assert.equal(`${fallback.origin}${fallback.pathname}`, "https://github.com/telegraphic-dev/a2a-exposed/issues/new");
114
+ assert.equal(fallback.searchParams.get("template"), "bug_report.yml");
115
+ assert.equal(fallback.searchParams.get("body"), null);
116
+ assert.equal(fallback.searchParams.get("deployment"), "self-hosted");
117
+ assert.equal(fallback.searchParams.get("connection"), "polling");
118
+ assert.equal(fallback.searchParams.get("version"), `worker ${WORKER_VERSION}`);
119
+ await assert.rejects(
120
+ () => sendFeedback(ctx, { message: "hello", kind: "bug", contact: "", version: "" }, () => {
121
+ throw new Error("fetch should not run");
122
+ }),
123
+ /reproduction steps/,
124
+ );
125
+ });
126
+
127
+ test("a hosted tenant posts through the control binding with the owner-token hash", async () => {
128
+ const hash = await sha256("owner-secret");
129
+ const seen: { host: string | null; auth: string | null; body: any }[] = [];
130
+ const binding = {
131
+ async fetch(input: RequestInfo | URL) {
132
+ const req = input instanceof Request ? input : new Request(String(input));
133
+ seen.push({ host: req.headers.get("host"), auth: req.headers.get("authorization"), body: await req.json() });
134
+ return Response.json({ ok: true, issue_url: "https://github.com/telegraphic-dev/a2a-exposed/issues/4" });
135
+ },
136
+ };
137
+ const token = "a2amcp_" + "c".repeat(24);
138
+ const env = selfEnv({
139
+ FEEDBACK_URL: "https://control.example.com",
140
+ TENANT_DOMAIN: "example.com",
141
+ PUBLIC_URL: "https://northwind.example.com",
142
+ CONTROL_SERVICE: binding,
143
+ });
144
+ const ctx = hostedTenantContext(env, {
145
+ id: "t1", db: env.DB, publicUrl: "https://northwind.example.com", ownerTokenHash: hash, config: { AGENT_NAME: "Ada" },
146
+ });
147
+ const result = await sendFeedback(ctx, {
148
+ message: `broke ${token}`, kind: "bug", contact: "", version: "1",
149
+ steps: `ran ${token}`, agent: "Claude Code", environment: "Cloudflare Worker",
150
+ }, () => {
151
+ throw new Error("public fetch should not run");
152
+ });
153
+ assert.equal(result.filed, true);
154
+ assert.equal(result.issue_url, "https://github.com/telegraphic-dev/a2a-exposed/issues/4");
155
+ assert.equal(seen.length, 1);
156
+ assert.equal(seen[0].host, "control.example.com");
157
+ assert.equal(seen[0].auth, `Bearer ${hash}`);
158
+ assert.equal(seen[0].body.source, "mcp");
159
+ assert.equal(seen[0].body.agent, "Claude Code");
160
+ assert.equal(seen[0].body.mode, "hosted");
161
+ assert.equal(seen[0].body.deployment, "hosted");
162
+ assert.equal(seen[0].body.connection, "polling");
163
+ assert.equal(seen[0].body.version, `1 (worker ${WORKER_VERSION})`);
164
+ assert.equal(seen[0].body.environment, "Cloudflare Worker");
165
+ assert.equal(seen[0].body.message.includes(token), false);
166
+ assert.equal(seen[0].body.steps.includes(token), false);
167
+ assert.match(seen[0].body.steps, /\[redacted\]/);
168
+
169
+ const missing = {
170
+ async fetch() { return new Response("no", { status: 404 }); },
171
+ };
172
+ const again = hostedTenantContext({ ...env, CONTROL_SERVICE: missing }, {
173
+ id: "t1", db: env.DB, publicUrl: "https://northwind.example.com", ownerTokenHash: hash,
174
+ });
175
+ const fell = await sendFeedback(again, { message: "still broken", kind: "bug", contact: "", version: "", steps: "open it" });
176
+ assert.equal(fell.filed, false);
177
+ const fellUrl = new URL(fell.issue_url);
178
+ assert.equal(fellUrl.searchParams.get("template"), "bug_report.yml");
179
+ assert.equal(fellUrl.searchParams.get("steps"), "open it");
180
+ });
181
+
182
+ test("the landing page footer links to the repository", async () => {
183
+ const env = selfEnv();
184
+ const run = execution();
185
+ const res = await worker.fetch(new Request(BASE + "/", { headers: { accept: "text/html" } }), env, run.ectx);
186
+ await run.done();
187
+ const html = await res.text();
188
+ assert.match(html, /<footer class="w">Served by <a href="https:\/\/github\.com\/telegraphic-dev\/a2a-exposed">a2a-exposed<\/a>\.<\/footer>/);
189
+ assert.equal(/<script|<link|<img|<iframe|src=/i.test(html), false);
190
+ });
@@ -3,6 +3,7 @@
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import worker from "../src/index.ts";
6
+ import { WORKER_VERSION } from "../src/feedback.ts";
6
7
  import * as P from "../src/pairing.ts";
7
8
  import * as M from "../src/mcp.ts";
8
9
  import { d1 } from "./d1.ts";
@@ -327,12 +328,55 @@ test("scope: the MCP token is not an owner or peer token, and peer/owner tokens
327
328
  assert.equal((await s.call("GET", "/mcp", { headers: { authorization: `Bearer ${a.access_token}` } })).status, 405);
328
329
  // tools can't administer tokens or pairings
329
330
  const names = (await s.mcp(a.access_token, "tools/list")).data.result.tools.map((x: any) => x.name);
330
- assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "pairing_requests"]);
331
+ assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "pairing_requests", "send_feedback"]);
331
332
  assert.ok(!names.some((n: string) => /approve|deny|token|revoke|issue|password/.test(n)));
332
333
  const unknown = await s.mcp(a.access_token, "tools/call", { name: "approve_pairing", arguments: { code: "X" } });
333
334
  assert.equal(unknown.data.error.code, -32602);
334
335
  });
335
336
 
337
+ test("send_feedback returns a GitHub issue link and strips tokens", async (t) => {
338
+ const s = setup(); t.after(s.restore);
339
+ const a = await s.connect();
340
+ const token = "a2aot_" + "a".repeat(24);
341
+ const r = await s.tool(a.access_token, "send_feedback", {
342
+ message: `dropped a task ${token}`, kind: "bug", steps: `ran ${token}`, agent: "Claude Code", environment: "cloud agent sandbox",
343
+ });
344
+ assert.equal(r.isError, false);
345
+ assert.match(r.content[0].text, /public GitHub issue on telegraphic-dev\/a2a-exposed/);
346
+ assert.match(r.content[0].text, /confidential information/);
347
+ const link = r.content[0].text.split("\n").pop() ?? "";
348
+ const url = new URL(link);
349
+ assert.equal(`${url.origin}${url.pathname}`, "https://github.com/telegraphic-dev/a2a-exposed/issues/new");
350
+ assert.equal(url.searchParams.get("template"), "bug_report.yml");
351
+ assert.equal(url.searchParams.get("body"), null);
352
+ assert.equal((url.searchParams.get("message") ?? "").includes(token), false);
353
+ assert.match(url.searchParams.get("message") ?? "", /\[redacted\]/);
354
+ assert.match(url.searchParams.get("steps") ?? "", /ran \[redacted\]/);
355
+ assert.equal(url.searchParams.get("agent"), "Claude Code");
356
+ assert.equal(url.searchParams.get("environment"), "cloud agent sandbox");
357
+ assert.equal(url.searchParams.get("connection"), "webhook");
358
+ assert.equal(url.searchParams.get("deployment"), "self-hosted");
359
+ assert.equal(url.searchParams.get("version"), `worker ${WORKER_VERSION}`);
360
+ assert.equal((url.searchParams.get("message") ?? "").includes("hook.example.net"), false);
361
+ const listed = (await s.mcp(a.access_token, "tools/list")).data.result.tools.find((tool: { name: string }) => tool.name === "send_feedback");
362
+ assert.match(listed.description, /public GitHub issue on telegraphic-dev\/a2a-exposed/);
363
+ assert.match(listed.description, /Redact before sending/);
364
+ assert.match(listed.description, /exact commands/);
365
+ assert.match(listed.description, /filled by the inbox/);
366
+ assert.match(listed.inputSchema.properties.steps.description, /Remove tokens and secrets/);
367
+ assert.match(listed.inputSchema.properties.environment.description, /cloud agent sandbox/);
368
+ assert.match(listed.inputSchema.properties.connection.description, /webhook, proxy, or polling/);
369
+ assert.match(listed.inputSchema.properties.deployment.description, /self-hosted/);
370
+ assert.match(listed.inputSchema.properties.version.description, /Worker version/);
371
+ assert.equal((await s.tool(a.access_token, "send_feedback", {})).isError, true);
372
+ const missingSteps = await s.tool(a.access_token, "send_feedback", { message: "broke", kind: "bug" });
373
+ assert.equal(missingSteps.isError, true);
374
+ assert.match(missingSteps.content[0].text, /reproduction steps/);
375
+ const { token: peerTok } = await s.inbound("barry", "hello");
376
+ assert.equal((await s.mcp(peerTok, "tools/call", { name: "send_feedback", arguments: { message: "hi" } })).status, 401);
377
+ assert.equal((await s.mcp("owner-secret", "tools/call", { name: "send_feedback", arguments: { message: "hi" } })).status, 401);
378
+ });
379
+
336
380
  test("JSON-RPC plumbing: notifications 202, batches and junk rejected, unknown method", async (t) => {
337
381
  const s = setup(); t.after(s.restore);
338
382
  const a = await s.connect();
@@ -24,6 +24,7 @@ test("gates: unset is single-tenant and every reserved gate is off", () => {
24
24
  assert.equal(g.wakeTargetPolicy, "");
25
25
  assert.equal(g.signupUrl, "");
26
26
  assert.equal(g.branding, "");
27
+ assert.equal(g.feedbackUrl, "");
27
28
  assert.equal(g.dataRegion, "");
28
29
  assert.equal(g.tenantDomain, "");
29
30
  assert.deepEqual(g.approvalOidc, { issuer: "", clientId: "", hasClientSecret: false, allowedSubjects: [], methods: ["password"], methodsSpecified: false, label: "" });
@@ -42,6 +43,7 @@ test("gates: only exact values turn on; typos and non-https URLs stay off", () =
42
43
  assert.equal(g.branding, "");
43
44
  assert.equal(g.dataRegion, "");
44
45
  assert.equal(g.signupUrl, "");
46
+ assert.equal(g.feedbackUrl, "");
45
47
  assert.equal(g.approvalOidc.issuer, "");
46
48
  assert.deepEqual(g.approvalOidc.methods, ["password"]);
47
49
  assert.equal(g.approvalOidc.methodsSpecified, false);
@@ -50,7 +52,8 @@ test("gates: only exact values turn on; typos and non-https URLs stay off", () =
50
52
  const on = parseGates({
51
53
  DB: dummy, TENANCY: "host", TENANT_DO: {} as any, QUOTAS: "on", USAGE_SINK: "d1",
52
54
  WAKE_TARGET_POLICY: "public-https", BRANDING: "hosted", DATA_REGION: "EU",
53
- SIGNUP_URL: "https://example.com/start", APPROVAL_OIDC_ISSUER: "https://example.com",
55
+ SIGNUP_URL: "https://example.com/start", FEEDBACK_URL: "https://control.example.com",
56
+ APPROVAL_OIDC_ISSUER: "https://example.com",
54
57
  APPROVAL_OIDC_CLIENT_ID: "client", APPROVAL_OIDC_CLIENT_SECRET: "secret",
55
58
  APPROVAL_OIDC_ALLOWED_SUBJECTS: " acct_1 , acct_2 ", APPROVAL_METHODS: "oidc,password",
56
59
  TENANT_DOMAIN: "example.com",
@@ -63,6 +66,9 @@ test("gates: only exact values turn on; typos and non-https URLs stay off", () =
63
66
  assert.equal(on.branding, "hosted");
64
67
  assert.equal(on.dataRegion, "eu");
65
68
  assert.equal(on.signupUrl, "https://example.com/start");
69
+ assert.equal(on.feedbackUrl, "https://control.example.com");
70
+ assert.equal(parseGates({ DB: dummy, FEEDBACK_URL: "http://control.example.com" } as WorkerBindings).gates.feedbackUrl, "");
71
+ assert.equal(parseGates({ DB: dummy, FEEDBACK_URL: "https://control.example.com/api" } as WorkerBindings).gates.feedbackUrl, "");
66
72
  assert.equal(on.approvalOidc.issuer, "https://example.com");
67
73
  assert.equal(on.approvalOidc.hasClientSecret, true);
68
74
  assert.deepEqual(on.approvalOidc.allowedSubjects, ["acct_1", "acct_2"]);