a2a-exposed 0.7.19 → 0.7.21

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/feedback.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Feedback: post to a hosted control plane when this inbox points at one, otherwise print a GitHub issue link.
2
2
  import os from "node:os";
3
+ import readline from "node:readline/promises";
3
4
  import { CliError } from "./a2a.mjs";
4
5
  import * as C from "./config.mjs";
5
6
 
@@ -12,14 +13,42 @@ const TOKEN_RE = /(?:a2aow_|s2a_|a2amcp_|a2amcpr_|a2aot_)[A-Za-z0-9_-]{20,}/g;
12
13
  const SETUP_RE = /([?&])t=[A-Za-z0-9_-]{16,}/g;
13
14
  const USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)[^/?\s]*@/gi;
14
15
  const BEARER_RE = /bearer\s+\S{8,200}/gi;
16
+ const PEM_RE = /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----|$)/g;
17
+ const API_KEY_RE = /(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|\bsk_(?:live|test)_[A-Za-z0-9]{10,}|\brk_(?:live|test)_[A-Za-z0-9]{10,}|\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{35}\b)/g;
15
18
 
16
- /** Same shapes as the Worker's scrubSecretText, plus a Bearer value. */
19
+ /** Same shapes as the Worker's scrubSecretText, plus a Bearer value, a PEM private key, and common API-key prefixes. */
17
20
  export function scrubFeedbackText(value) {
18
21
  return String(value ?? "")
19
22
  .replace(TOKEN_RE, "[redacted]")
20
23
  .replace(SETUP_RE, "$1t=[redacted]")
21
24
  .replace(USERINFO_RE, "$1[redacted]@")
22
- .replace(BEARER_RE, "Bearer [redacted]");
25
+ .replace(BEARER_RE, "Bearer [redacted]")
26
+ .replace(PEM_RE, "[redacted]")
27
+ .replace(API_KEY_RE, "[redacted]");
28
+ }
29
+
30
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
31
+
32
+ /** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the control plane's name. */
33
+ export function publicIssueWarning(repo = ISSUE_REPO) {
34
+ const name = typeof repo === "string" && REPO_RE.test(repo) && repo.length <= 100 && !repo.includes("..") ? repo : ISSUE_REPO;
35
+ return `This report is filed as a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
36
+ }
37
+
38
+ export const PUBLIC_ISSUE_WARNING = publicIssueWarning(ISSUE_REPO);
39
+
40
+ /** Print the public-issue warning. On a terminal, wait for y or yes unless `yes` is set. Off a terminal, `--yes` is required. */
41
+ export async function confirmPublicIssue(yes, input = process.stdin, output = process.stderr, repo = ISSUE_REPO) {
42
+ output.write(`${publicIssueWarning(repo)}\n`);
43
+ if (yes) return;
44
+ if (!input.isTTY) throw new CliError("feedback files a public GitHub issue; pass --yes to send it");
45
+ const rl = readline.createInterface({ input, output });
46
+ try {
47
+ const answer = (await rl.question("File this public issue? [y/N] ")).trim().toLowerCase();
48
+ if (answer !== "y" && answer !== "yes") throw new CliError("feedback not sent");
49
+ } finally {
50
+ rl.close();
51
+ }
23
52
  }
24
53
 
25
54
  const ENVIRONMENTS = ["local", "cloud VM", "CI", "cloud agent sandbox", "other"];
@@ -163,6 +192,51 @@ async function readJson(response) {
163
192
  try { return await response.json(); } catch { return null; }
164
193
  }
165
194
 
195
+ function namedRepo(value) {
196
+ const repo = typeof value === "string" ? value.trim() : "";
197
+ if (!REPO_RE.test(repo) || repo.length > 100 || repo.includes("..")) return "";
198
+ return repo;
199
+ }
200
+
201
+ /**
202
+ * Repository named in the warning before a report is sent.
203
+ * No hosted endpoint: the prefilled form on ISSUE_REPO.
204
+ * Hosted: GET /api/feedback must return `{ repo }`. A missing name does not guess ISSUE_REPO.
205
+ */
206
+ export async function feedbackPublicRepo(input) {
207
+ const base = String(input.baseUrl || "").replace(/\/$/, "");
208
+ const token = String(input.ownerToken || "");
209
+ const call = input.fetchImpl ?? fetch;
210
+ if (!base || !token) return ISSUE_REPO;
211
+ let probe;
212
+ try {
213
+ probe = await call(`${base}/owner/feedback`, {
214
+ headers: { accept: "application/json", authorization: `Bearer ${token}` },
215
+ redirect: "error",
216
+ signal: AbortSignal.timeout(5000),
217
+ });
218
+ } catch {
219
+ probe = null;
220
+ }
221
+ const info = probe && probe.ok ? await readJson(probe) : null;
222
+ if (!info || info.hosted !== true || !feedbackUrlOk(info.url)) return ISSUE_REPO;
223
+ let named;
224
+ try {
225
+ named = await call(info.url, {
226
+ method: "GET",
227
+ headers: { accept: "application/json" },
228
+ redirect: "error",
229
+ signal: AbortSignal.timeout(5000),
230
+ });
231
+ } catch {
232
+ named = null;
233
+ }
234
+ const body = named && named.ok ? await readJson(named) : null;
235
+ const repo = namedRepo(body?.repo);
236
+ if (!repo) throw new CliError("the control plane did not name the public repository; feedback was not sent");
237
+ return repo;
238
+ }
239
+
166
240
  /**
167
241
  * Post when GET /owner/feedback says this inbox is hosted and names an https /api/feedback URL.
168
242
  * A missing inbox, a probe failure, or a 404 prints the prefilled issue URL. 400, 429, and 5xx throw.
package/lib/main.mjs CHANGED
@@ -209,16 +209,23 @@ Feedback
209
209
  feedback "message" [--kind bug|idea|other] [--contact x]
210
210
  [--steps S] [--expected S] [--actual S] [--logs S] [--agent A]
211
211
  [--environment local|cloud VM|CI|cloud agent sandbox|other]
212
- [--connection webhook|proxy|polling]
212
+ [--connection webhook|proxy|polling] [--yes]
213
213
  report a bug or an idea. A bug needs --steps.
214
+ The report is a public GitHub issue. The warning names
215
+ the repository before it sends. A hosted inbox uses the
216
+ control plane's FEEDBACK_REPO. Otherwise the link opens
217
+ the form on telegraphic-dev/a2a-exposed. Do not include
218
+ confidential information: secrets, tokens, owner or pairing
219
+ tokens, private URLs or hostnames, message contents, or
220
+ personal data. Prints that warning and asks before sending,
221
+ unless --yes is set. Off a terminal, --yes is required.
214
222
  Posts to the control plane when this inbox is hosted and that
215
223
  plane accepts feedback. Otherwise prints a link to the bug
216
224
  report form and does not open a browser. The CLI adds its
217
225
  version, the inbox Worker version when the inbox reports it,
218
226
  the OS, the Node version, the connection (webhook, proxy, or
219
227
  polling), and hosted or self-hosted. --connection overrides
220
- that detection. Don't put tokens, passwords, or keys in the
221
- message. Remove them from --logs too.
228
+ that detection. Remove tokens from the message and from --logs.
222
229
 
223
230
  Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
224
231
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
@@ -246,7 +253,7 @@ const SPEC = {
246
253
  signup: { "control-url": S, json: B },
247
254
  login: { "control-url": S, json: B },
248
255
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
249
- feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S },
256
+ feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
250
257
  };
251
258
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
252
259
 
@@ -336,6 +343,9 @@ export async function main(argv) {
336
343
  const kind = o.kind || "other";
337
344
  if (!fb.KINDS.includes(kind)) throw new CliError("--kind must be bug, idea, or other");
338
345
  if (o.connection && !fb.CONNECTIONS.includes(o.connection)) throw new CliError("--connection must be webhook, proxy, or polling");
346
+ if (kind === "bug" && !String(o.steps || "").trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
347
+ const repo = await fb.feedbackPublicRepo({ baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN") });
348
+ await fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo);
339
349
  const result = await fb.runFeedback({
340
350
  message, kind, contact: o.contact || "", version: VERSION,
341
351
  steps: o.steps || "", expected: o.expected || "", actual: o.actual || "", logs: o.logs || "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,10 +6,49 @@ import type { TenantContext } from "./tenancy.ts";
6
6
  import { VERSION } from "./version.ts";
7
7
 
8
8
  export const ISSUE_REPO = "telegraphic-dev/a2a-exposed";
9
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
10
+
11
+ /** Name the repository that receives the issue. The prefilled form uses ISSUE_REPO. A filed issue uses the repository in its URL. */
12
+ export function publicIssueNote(repo: string): string {
13
+ const name = REPO_RE.test(repo) && !repo.includes("..") ? repo : ISSUE_REPO;
14
+ return `This report is filed as a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
15
+ }
16
+
17
+ export const PUBLIC_ISSUE_NOTE = publicIssueNote(ISSUE_REPO);
18
+
19
+ /** Owner/name from a GitHub issue URL or the prefilled new-issue URL. Empty when the URL is not that shape. */
20
+ export function repoFromIssueUrl(value: string): string {
21
+ try {
22
+ const url = new URL(value);
23
+ const match = url.pathname.match(/^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues(?:\/|$)/);
24
+ if (url.protocol !== "https:" || url.hostname !== "github.com" || url.username || url.password || !match) return "";
25
+ const repo = `${match[1]}/${match[2]}`;
26
+ return REPO_RE.test(repo) && !repo.includes("..") ? repo : "";
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
31
+
32
+ export function feedbackResultText(result: { filed: boolean; issue_url: string }): string {
33
+ const note = publicIssueNote(repoFromIssueUrl(result.issue_url));
34
+ return result.filed
35
+ ? `${note}\nFeedback filed: ${result.issue_url}`
36
+ : `${note}\nFeedback was not filed from here. Open this link to create the issue:\n${result.issue_url}`;
37
+ }
38
+
39
+ /** Tool description. A repository is named only when this call already knows it. A hosted inbox learns the name from the issue URL in the result. */
40
+ export function feedbackToolDescription(repo: string): string {
41
+ const note = REPO_RE.test(repo) && !repo.includes("..")
42
+ ? publicIssueNote(repo)
43
+ : "This report is filed as a public GitHub issue on this deployment's configured feedback repository. The result names that repository. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.";
44
+ return `${note} Redact before sending. Report a bug or an idea about this inbox to its developers. A bug requires reproduction steps. Include exact commands, error output with tokens and secrets removed, which agent you are, and where you run (local, cloud VM, CI, cloud agent sandbox, or other). Version, connection (webhook, proxy, or polling), and deployment (hosted or self-hosted) are filled by the inbox when you omit them. When this deployment does not file the report, the result is a link to the bug report form. You do not open a browser.`;
45
+ }
9
46
  /** Inbox build. The same value as `version` in cli/package.json. */
10
47
  export const WORKER_VERSION = VERSION;
11
48
  const TIMEOUT_MS = 5000;
12
49
  const BEARER_RE = /bearer\s+\S{8,200}/gi;
50
+ const PEM_RE = /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----|$)/g;
51
+ const API_KEY_RE = /(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|\bsk_(?:live|test)_[A-Za-z0-9]{10,}|\brk_(?:live|test)_[A-Za-z0-9]{10,}|\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{35}\b)/g;
13
52
  const KINDS = ["bug", "idea", "other"] as const;
14
53
  const CONNECTIONS = ["webhook", "proxy", "polling"] as const;
15
54
  const ENVIRONMENTS = ["local", "cloud VM", "CI", "cloud agent sandbox", "other"] as const;
@@ -36,9 +75,12 @@ export interface FeedbackNote {
36
75
  logs?: string;
37
76
  }
38
77
 
39
- /** scrubSecretText, then a Bearer value. The rest of the text stays. */
78
+ /** scrubSecretText, then a Bearer value, a PEM private key, and common API-key prefixes. The rest of the text stays. */
40
79
  export function scrubFeedbackText(value: string): string {
41
- return scrubSecretText(value).replace(BEARER_RE, "Bearer [redacted]");
80
+ return scrubSecretText(value)
81
+ .replace(BEARER_RE, "Bearer [redacted]")
82
+ .replace(PEM_RE, "[redacted]")
83
+ .replace(API_KEY_RE, "[redacted]");
42
84
  }
43
85
 
44
86
  /**
@@ -2414,9 +2414,7 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
2414
2414
  connection: argStr(a, "connection", false),
2415
2415
  deployment: argStr(a, "deployment", false),
2416
2416
  });
2417
- return result.filed
2418
- ? `Feedback filed: ${result.issue_url}`
2419
- : `Feedback was not filed from here. Open this link to create the issue:\n${result.issue_url}`;
2417
+ return Fb.feedbackResultText(result);
2420
2418
  }
2421
2419
  }
2422
2420
  throw new ToolError(`unknown tool ${name}`);
@@ -2430,6 +2428,12 @@ const mcpInstructions = `This is your A2A inbox: other agents send you tasks her
2430
2428
  const unsupportedVersion = (id: Json, requested: string) =>
2431
2429
  rpcError(id, M.ERR.UNSUPPORTED_VERSION, "Unsupported protocol version", 400, { supported: M.PROTOCOL_VERSIONS, requested });
2432
2430
 
2431
+ /** Hosted feedback is filed on FEEDBACK_REPO, which this inbox does not know until the issue URL comes back. Do not name the prefilled-form repository in that description. */
2432
+ function mcpTools(ctx: TenantContext) {
2433
+ if (!Fb.feedbackEndpoint(ctx)) return M.TOOLS;
2434
+ return M.TOOLS.map((tool) => tool.name === "send_feedback" ? { ...tool, description: Fb.feedbackToolDescription("") } : tool);
2435
+ }
2436
+
2433
2437
  /** One tools/call, shared by both eras. */
2434
2438
  async function callTool(ctx: TenantContext, ectx: ExecutionContext, grant: Json, params: Json): Promise<{ error?: [number, string]; result?: Json }> {
2435
2439
  const name = String(params.name || "");
@@ -2501,7 +2505,7 @@ async function handleMcp(req: Request, ctx: TenantContext, ectx: ExecutionContex
2501
2505
  case "server/discover":
2502
2506
  return done({ supportedVersions: M.PROTOCOL_VERSIONS, capabilities: { tools: {} }, instructions: mcpInstructions, ttlMs: M.LIST_TTL_MS, cacheScope: "private" });
2503
2507
  case "tools/list":
2504
- return done({ tools: M.TOOLS, ttlMs: M.LIST_TTL_MS, cacheScope: "private" });
2508
+ return done({ tools: mcpTools(ctx), ttlMs: M.LIST_TTL_MS, cacheScope: "private" });
2505
2509
  case "tools/call": {
2506
2510
  const r = await callTool(ctx, ectx, grant, params);
2507
2511
  return r.error ? rpcError(id, r.error[0], r.error[1]) : done(r.result);
@@ -2530,7 +2534,7 @@ async function handleMcp(req: Request, ctx: TenantContext, ectx: ExecutionContex
2530
2534
  capabilities: { tools: { listChanged: false } }, serverInfo: serverInfo(ctx), instructions: mcpInstructions });
2531
2535
  }
2532
2536
  case "ping": return rpcResult(id, {});
2533
- case "tools/list": return rpcResult(id, { tools: M.TOOLS });
2537
+ case "tools/list": return rpcResult(id, { tools: mcpTools(ctx) });
2534
2538
  case "tools/call": {
2535
2539
  const r = await callTool(ctx, ectx, grant, params);
2536
2540
  return r.error ? rpcError(id, r.error[0], r.error[1]) : rpcResult(id, r.result);
package/worker/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // metadata. Pure helpers live here; the routes (D1, approval password, owner API) are in index.ts.
4
4
  import { esc } from "./pairing.ts";
5
5
  import { isPrivateHost } from "./a2a.ts";
6
+ import { feedbackToolDescription, ISSUE_REPO } from "./feedback.ts";
6
7
  import { derivePeerKey } from "./tenancy.ts";
7
8
 
8
9
  export const SCOPE = "inbox";
@@ -286,7 +287,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
286
287
  description: "Agents asking to connect to this inbox (read-only). Show your human who is asking, the code and the link; NEVER approve or deny on your own: your human decides on the /device link with their approval password. The names and card URLs are claimed by the requester (untrusted).",
287
288
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
288
289
  { name: "send_feedback", title: "Send feedback", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
289
- description: "Report a bug or an idea about this inbox to its developers. A bug requires reproduction steps. Include exact commands, error output with tokens and secrets removed, which agent you are, and where you run (local, cloud VM, CI, cloud agent sandbox, or other). Version, connection (webhook, proxy, or polling), and deployment (hosted or self-hosted) are filled by the inbox when you omit them. Do not include tokens, passwords, or keys. When this deployment does not file the report, the result is a link to the bug report form. You do not open a browser.",
290
+ description: feedbackToolDescription(ISSUE_REPO),
290
291
  inputSchema: { type: "object", properties: {
291
292
  message: str("What happened, at most 4000 characters. Include the exact commands you ran."),
292
293
  kind: str("bug, idea, or other. A bug requires steps.", { enum: ["bug", "idea", "other"] }),
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.7.19";
2
+ export const VERSION = "0.7.21";
@@ -3,7 +3,7 @@ import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
4
  import worker, { dispatch } from "../src/index.ts";
5
5
  import { sha256 } from "../src/a2a.ts";
6
- import { connectionOf, feedbackEndpoint, feedbackIssueUrl, scrubFeedbackText, sendFeedback, WORKER_VERSION } from "../src/feedback.ts";
6
+ import { connectionOf, feedbackEndpoint, feedbackIssueUrl, feedbackResultText, feedbackToolDescription, scrubFeedbackText, sendFeedback, WORKER_VERSION } from "../src/feedback.ts";
7
7
  import { readFileSync } from "node:fs";
8
8
  import { hostedTenantContext, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
9
9
  import { d1 } from "./d1.ts";
@@ -38,9 +38,12 @@ async function call(env: WorkerBindings, path: string, headers: Record<string, s
38
38
 
39
39
  test("scrubFeedbackText removes token shapes and Bearer values", () => {
40
40
  const token = "a2aow_" + "b".repeat(24);
41
- const text = scrubFeedbackText(`token ${token} and Bearer supersecretvalue stay out`);
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`);
42
43
  assert.equal(text.includes(token), false);
43
44
  assert.equal(text.includes("supersecretvalue"), false);
45
+ assert.equal(text.includes("SECRETKEYMATERIAL"), false);
46
+ assert.equal(text.includes("AKIAIOSFODNN7EXAMPLE"), false);
44
47
  assert.match(text, /\[redacted\]/);
45
48
  assert.match(text, /Bearer \[redacted\]/);
46
49
  });
@@ -88,6 +91,18 @@ test("GET /owner/feedback is hosted only for a hosted tenant", async () => {
88
91
  assert.deepEqual(await res.json(), { hosted: true, url: "https://control.example.com/api/feedback", version: WORKER_VERSION, connection: "polling" });
89
92
  });
90
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
+
91
106
  test("sendFeedback does not call out from a self-hosted inbox", async () => {
92
107
  const ctx = resolveTenant(selfEnv({ FEEDBACK_URL: "https://control.example.com" }));
93
108
  const result = await sendFeedback(ctx, { message: "hello", kind: "other", contact: "", version: "" }, () => {
@@ -342,6 +342,8 @@ test("send_feedback returns a GitHub issue link and strips tokens", async (t) =>
342
342
  message: `dropped a task ${token}`, kind: "bug", steps: `ran ${token}`, agent: "Claude Code", environment: "cloud agent sandbox",
343
343
  });
344
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/);
345
347
  const link = r.content[0].text.split("\n").pop() ?? "";
346
348
  const url = new URL(link);
347
349
  assert.equal(`${url.origin}${url.pathname}`, "https://github.com/telegraphic-dev/a2a-exposed/issues/new");
@@ -357,6 +359,8 @@ test("send_feedback returns a GitHub issue link and strips tokens", async (t) =>
357
359
  assert.equal(url.searchParams.get("version"), `worker ${WORKER_VERSION}`);
358
360
  assert.equal((url.searchParams.get("message") ?? "").includes("hook.example.net"), false);
359
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/);
360
364
  assert.match(listed.description, /exact commands/);
361
365
  assert.match(listed.description, /filled by the inbox/);
362
366
  assert.match(listed.inputSchema.properties.steps.description, /Remove tokens and secrets/);