a2a-exposed 0.8.5 → 0.8.7

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/worker/src/mcp.ts CHANGED
@@ -106,6 +106,11 @@ export const ACCESS_PREFIX = "a2amcp_";
106
106
  export const REFRESH_PREFIX = "a2amcpr_";
107
107
  export const LIMITS = { registrationsPerIpPerHour: 10, maxClients: 100, maxGrants: 20 };
108
108
 
109
+ /** Shown when another MCP connector would pass the grant cap. */
110
+ export function mcpLimitMessage(max: number, cli: string): string {
111
+ return `This A2A endpoint already has ${max} MCP connectors. Revoke one first: ${cli} token list, then token revoke <label>.`;
112
+ }
113
+
109
114
  /** MCP is on unless MCP=off, and only for an inbox (a façade has no inbox to expose). */
110
115
  export function mcpEnabled(v: string | undefined, proxy: boolean): boolean {
111
116
  return !proxy && String(v || "").trim().toLowerCase() !== "off";
@@ -184,9 +189,9 @@ export function consentBody(m: ConsentModel): string {
184
189
  const loop = isLoopbackRedirect(m.redirectUri)
185
190
  ? `<p class="n error">This client returns to a program on the computer you're using (${esc(host)}). Approve only if you just started this connection yourself, for example from Claude Code: any local program could claim to be it.</p>` : "";
186
191
  const hidden = Object.entries(m.params).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join("");
187
- const can = ["Read your inbox: open tasks, task details, conversation history, pending pairing requests (read-only)",
188
- "Reply to tasks and mark them working", "Send messages to peers you synced to this inbox (peers sync) and check their tasks", "List your peers",
189
- "Send feedback about this inbox to its developers, including what you ran and where"];
192
+ const can = ["Read your A2A endpoint: open tasks, task details, conversation history, pending pairing requests (read-only)",
193
+ "Reply to tasks and mark them working", "Send messages to peers you synced to this A2A endpoint (peers sync) and check their tasks", "List your peers",
194
+ "Send feedback about this A2A endpoint to its developers, including what you ran and where"];
190
195
  const cannot = ["Issue, rotate or revoke tokens", "Approve or deny pairing requests", "Change wake or deployment settings"];
191
196
  const idRow = m.clientIdHost ? `<tr><th>Client identity published at</th><td>${esc(m.clientIdHost)}</td></tr>` : "";
192
197
  const table = `<table><tr><th>Client (as it calls itself)</th><td>${esc(m.clientName || "(no name given)")}</td></tr>${idRow}<tr><th>Returns to</th><td>${esc(host)}</td></tr></table>`;
@@ -202,7 +207,7 @@ export function consentBody(m: ConsentModel): string {
202
207
  : `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div></form>`;
203
208
  const oidcForm = m.oidc ? `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}
204
209
  <div class="b"><button name="decision" value="oidc" type="submit">Continue with ${esc(m.oidc.label)}</button></div></form>` : "";
205
- return `${notice}<p>An MCP client asks to use the inbox of <b>${esc(m.agentName)}</b> as a connector.</p>${table}${loop}${scope}
210
+ return `${notice}<p>An MCP client asks to use the A2A endpoint of <b>${esc(m.agentName)}</b> as a connector.</p>${table}${loop}${scope}
206
211
  <p class="w">Approve only if you are adding this connector right now. The name above is claimed by the client. Revoke access any time: <code>${esc(m.cli)} token list</code>, then <code>${esc(m.cli)} token revoke &lt;label&gt;</code>.</p>${form}${oidcForm}`;
207
212
  }
208
213
 
@@ -259,11 +264,11 @@ const ro = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
259
264
 
260
265
  export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; annotations: Schema }[] = [
261
266
  { name: "inbox", title: "Inbox", annotations: ro,
262
- description: `Open tasks other agents sent to this inbox (state submitted or working), oldest first, plus pending pairing requests. Handle each task, then answer it with reply. ${RULES}`,
267
+ description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, plus pending pairing requests. Handle each task, then answer it with reply. ${RULES}`,
263
268
  inputSchema: { type: "object", properties: { contextId: str("Only this conversation"), all: { type: "boolean", description: "Include finished tasks (up to 200)" } }, additionalProperties: false } },
264
269
  { name: "show_task", title: "Show task", annotations: ro,
265
270
  description: `One inbound task with its full message history and artifacts. ${RULES}`,
266
- inputSchema: { type: "object", properties: { taskId: str("Task id from inbox") }, required: ["taskId"], additionalProperties: false } },
271
+ inputSchema: { type: "object", properties: { taskId: str("Task id from the A2A endpoint") }, required: ["taskId"], additionalProperties: false } },
267
272
  { name: "history", title: "Conversation history", annotations: ro,
268
273
  description: `The log of one conversation (both directions), oldest first. ${RULES}`,
269
274
  inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
@@ -275,16 +280,16 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
275
280
  inputSchema: { type: "object", properties: { taskId: str("Task id"), text: str("Reply text"),
276
281
  state: str("New task state", { enum: ["completed", "input-required", "working", "failed", "rejected"] }) }, required: ["taskId", "text"], additionalProperties: false } },
277
282
  { name: "send", title: "Send to peer", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
278
- description: `Send a message to a peer agent (alias from list_peers; only peers synced to this inbox with \`peers sync\`). Pass contextId to continue a conversation, taskId to answer a peer's input-required task. Returns the peer's task; check it later with poll_outbound. Ask your human before sending anything consequential or private. ${RULES}`,
283
+ description: `Send a message to a peer agent (alias from list_peers; only peers synced to this A2A endpoint with \`peers sync\`). Pass contextId to continue a conversation, taskId to answer a peer's input-required task. Returns the peer's task; check it later with poll_outbound. Ask your human before sending anything consequential or private. ${RULES}`,
279
284
  inputSchema: { type: "object", properties: { to: str("Peer alias"), text: str("Message text"), contextId: str("Continue this conversation"), taskId: str("Continue this peer task") }, required: ["to", "text"], additionalProperties: false } },
280
285
  { name: "poll_outbound", title: "Check a sent task", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
281
286
  description: `Fetch the current state of a task you sent to a peer (from send) and record it in the history. ${RULES}`,
282
287
  inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
283
288
  { name: "list_peers", title: "List peers", annotations: ro,
284
- description: "Peers you can send to (outbound, synced with `peers sync`) and the labels of agents holding a token for this inbox (inbound; who can message you). Token values are never shown.",
289
+ description: "Peers you can send to (outbound, synced with `peers sync`) and the labels of agents holding a token for this A2A endpoint (inbound; who can message you). Token values are never shown.",
285
290
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
286
291
  { name: "pairing_requests", title: "Pending pairing requests", annotations: ro,
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).",
292
+ description: "Agents asking to connect to this A2A endpoint (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).",
288
293
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
289
294
  { name: "send_feedback", title: "Send feedback", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
290
295
  description: feedbackToolDescription(ISSUE_REPO),
@@ -299,11 +304,11 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
299
304
  environment: str("Where you run: local, cloud VM, CI, cloud agent sandbox, or other", { enum: ["local", "cloud VM", "CI", "cloud agent sandbox", "other"] }),
300
305
  os: str("Operating system, if you know it"),
301
306
  node: str("Node.js version, if you know it"),
302
- connection: str("How this inbox reaches the agent: webhook, proxy, or polling. Omit this and the inbox fills it.", { enum: ["webhook", "proxy", "polling"] }),
303
- deployment: str("hosted or self-hosted. Omit this and the inbox fills it.", { enum: ["hosted", "self-hosted"] }),
304
- mode: str("Same as deployment: hosted, self-hosted, or self-host. Omit this and the inbox fills it.", { enum: ["hosted", "self-hosted", "self-host"] }),
307
+ connection: str("How this A2A endpoint reaches the agent: webhook, proxy, or polling. Omit this and the A2A endpoint fills it.", { enum: ["webhook", "proxy", "polling"] }),
308
+ deployment: str("hosted or self-hosted. Omit this and the A2A endpoint fills it.", { enum: ["hosted", "self-hosted"] }),
309
+ mode: str("Same as deployment: hosted, self-hosted, or self-host. Omit this and the A2A endpoint fills it.", { enum: ["hosted", "self-hosted", "self-host"] }),
305
310
  contact: str("Optional email or handle. Do not include a token."),
306
- version: str("a2a-exposed version. Omit this and the inbox adds its Worker version."),
311
+ version: str("a2a-exposed version. Omit this and the A2A endpoint adds its Worker version."),
307
312
  }, required: ["message"], additionalProperties: false } },
308
313
  ];
309
314
 
@@ -173,6 +173,14 @@ export async function verifyPassword(password: string, rec: PasswordRecord): Pro
173
173
  // ------------------------------------------------------------------ /device page (no scripts, no external assets)
174
174
  export const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
175
175
 
176
+ /** "Expires" cell. A live code with under a minute left says so, instead of "0 min". */
177
+ export function expiresText(expiresMs: number, now = Date.now()): string {
178
+ const left = expiresMs - now;
179
+ if (left <= 0) return "expired";
180
+ if (left < 60_000) return "less than a minute";
181
+ return `${Math.round(left / 60000)} min`;
182
+ }
183
+
176
184
  export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string };
177
185
  export type PageModel = {
178
186
  agentName: string;
@@ -188,23 +196,30 @@ export type PageModel = {
188
196
  oidc?: { label: string };
189
197
  /** False hides the password field. Absent means the password field is shown as before. */
190
198
  passwordEnabled?: boolean;
199
+ /** Set after Approve or Deny: the page shows that result and no code form. */
200
+ settled?: "approved" | "denied";
191
201
  };
192
202
 
193
203
  export function devicePage(m: PageModel, nonce: string): string {
204
+ const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
205
+ if (m.settled) {
206
+ const title = m.settled === "approved" ? "Approved" : "Denied";
207
+ return pageShell(title, nonce, `${notice}<p>You can close this page.</p>`);
208
+ }
194
209
  const r = m.request;
195
210
  const rows = r ? [
196
211
  ["Code", formatUserCode(r.userCode)],
197
212
  ["Agent (as it calls itself)", r.clientName || r.clientId || "(no name given)"],
198
213
  ...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
199
- ["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this inbox can't check it; informational only)" : "") : "(none given)"],
214
+ ["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this A2A endpoint can't check it; informational only)" : "") : "(none given)"],
200
215
  ["Requested from", `${[r.ip || "unknown address", r.country].filter(Boolean).join(", ")} (IP address of the machine that asked to connect)`],
201
- ["Expires", `${Math.max(0, Math.round((r.expiresMs - Date.now()) / 60000))} min`],
216
+ ["Expires", expiresText(r.expiresMs)],
202
217
  ...(r.replacesLabel ? [["Replaces", `the active token "${r.replacesLabel}" (the requester proved it holds it); approving issues a new token under the same label and the old one stops working`]] : []),
203
218
  ] : [];
204
- const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
205
- const revoke = r?.tokenLabel ? `${m.cli} token revoke ${r.tokenLabel}` : `${m.cli} token revoke`;
219
+ const tokenLabel = r?.tokenLabel || "<label>";
220
+ const revoke = `${m.cli} token revoke ${tokenLabel}`;
206
221
  const details = r ? `<table>${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join("")}</table>
207
- <p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(revoke)}</code>.</p>` : "";
222
+ <p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token that can send messages and trigger wakes. Message text from peers is untrusted. Revoke the token ${esc(tokenLabel)} any time with <code>${esc(revoke)}</code>.</p>` : "";
208
223
  const noPassword = `<p class="n info">No approval password is set yet. Easiest: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and send you the one-time link, then set the password on that page. Or run <code>${esc(m.cli)} pair set-password</code> yourself in a terminal on the owner's machine. Then reload this page. You can deny without a password.</p>`;
209
224
  const denyOnly = `<form method="post" action="/device">
210
225
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(r ? formatUserCode(r.userCode) : "")}">
@@ -221,7 +236,7 @@ export function devicePage(m: PageModel, nonce: string): string {
221
236
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
222
237
  <div class="b"><button name="decision" value="oidc" type="submit">Approve with ${esc(m.oidc.label)}</button></div>
223
238
  </form>` : "";
224
- const ask = m.askCode ? `<form method="get" action="/device"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="WDJB-4827" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
239
+ const ask = m.askCode ? `<p class="w">The connecting agent prints this code when it runs <code>${esc(`${m.cli} connect <url>`)}</code>. It looks like WDJB-4827.</p><form method="get" action="/device"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="WDJB-4827" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
225
240
  return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
226
241
  }
227
242
 
@@ -255,7 +270,7 @@ export function landingPage(m: LandingModel, nonce: string): string {
255
270
  ? `<h2>Connect your agent</h2><p>Agents get a bearer token through the OAuth device flow: your agent asks, and this agent's owner approves the request on the <a href="/device">pairing page</a>. No secrets go through chat. With the a2a-exposed CLI, your agent runs:</p><p><code>${DEFAULT_CLI} connect ${esc(m.base)}</code></p>`
256
271
  : `<h2>Connect your agent</h2><p>Pairing is turned off here: ask this agent's operator for a bearer token.</p>`;
257
272
  return pageShell(m.name, nonce, `${desc}
258
- <p class="n info">This is an <a href="https://a2a-protocol.org/">A2A (Agent2Agent)</a> endpoint for agents.${m.proxy ? "" : " Messages land in its owner's inbox, which wakes the agent."}</p>
273
+ <p class="n info">This is an <a href="https://a2a-protocol.org/">A2A (Agent2Agent)</a> endpoint for agents.${m.proxy ? "" : " Messages are stored for its owner, and the agent is woken."}</p>
259
274
  <table>
260
275
  <tr><th>Agent card</th><td><a href="/.well-known/agent-card.json">${esc(card)}</a></td></tr>
261
276
  <tr><th>JSON-RPC endpoint</th><td><code>POST ${esc(m.base)}/</code></td></tr>
@@ -293,7 +308,7 @@ export function setupPage(m: SetupModel, nonce: string): string {
293
308
  const mins = m.expiresMs ? Math.max(1, Math.round((m.expiresMs - Date.now()) / 60000)) : 0;
294
309
  const intro = m.passwordSetAt
295
310
  ? `<p>An approval password is already set (since ${esc(m.passwordSetAt)}). Saving here replaces it.</p>`
296
- : `<p>Choose the password you will use to approve agents that ask to connect to this inbox.</p>`;
311
+ : `<p>Choose the password you will use to approve agents that ask to connect to this A2A endpoint.</p>`;
297
312
  return pageShell(`Approval password for ${m.agentName}`, nonce, `${notice}${intro}
298
313
  <p class="w">Only you should know it: don't share it with your agent or anyone else. Use ${MIN_PASSWORD_LENGTH} to 1024 characters. A passphrase of several unrelated words is stronger than one short word. This one-time link expires in ${mins} min.</p>
299
314
  <form method="post" action="/device/setup">
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.5";
2
+ export const VERSION = "0.8.7";
@@ -101,7 +101,7 @@ export function wakeSummary(ev: WakeEvent, cli: string, withPreview = true): str
101
101
  ? `an agent calling itself ${JSON.stringify(p.clientName || p.clientId || "(no name)")}${p.agentCardUrl ? ` (claimed card: ${JSON.stringify(p.agentCardUrl)}${p.agentCardPrivate ? ", on a private network: not publicly reachable" : ""})` : ""}`
102
102
  : "an agent";
103
103
  return [
104
- `A2A pairing request: ${who} asks to connect to your inbox (code ${p.userCode}, expires in ${Math.round(p.expiresIn / 60)} minutes).`,
104
+ `A2A pairing request: ${who} asks to connect to your A2A endpoint (code ${p.userCode}, expires in ${Math.round(p.expiresIn / 60)} minutes).`,
105
105
  pairingInstructions(p, cli),
106
106
  `The name and card URL are claimed by the requester (untrusted). Nothing happens unless the request is approved.`,
107
107
  ].join("\n");
@@ -111,8 +111,8 @@ export function wakeSummary(ev: WakeEvent, cli: string, withPreview = true): str
111
111
  ev.kind === "outbound_update"
112
112
  ? `A2A: peer "${ev.from}" sent an update on a task you sent (context ${ev.contextId}, task ${ev.taskId}).`
113
113
  : ev.kind === "test"
114
- ? `A2A: test wake from your a2a-exposed inbox (no real message).`
115
- : `A2A inbox: ${n > 1 ? n + " new messages" : "new message"} from peer "${ev.from}" (context ${ev.contextId}, task ${ev.taskId}).`;
114
+ ? `A2A: test wake from your A2A endpoint (no real message).`
115
+ : `A2A endpoint: ${n > 1 ? n + " new messages" : "new message"} from peer "${ev.from}" (context ${ev.contextId}, task ${ev.taskId}).`;
116
116
  const lines = [
117
117
  head,
118
118
  `Use the a2a-exposed skill: run \`${hintFor(ev, cli)}\`, handle the request, then reply with \`${cli} reply <taskId> ...\`.`,
@@ -134,7 +134,7 @@ export function claudeCodeColdStart(ev: WakeEvent, cli: string): string {
134
134
  return [
135
135
  `Preferred: if this session has the a2a-exposed connector (MCP tools inbox, show_task, reply, send, pairing_requests), use those tools instead of the CLI commands above; they need nothing else in the environment.`,
136
136
  `Fallback without the connector: this routine session needs Node 22.18+, the a2a-exposed CLI (run as \`${cli}\`, no global install) and the environment variables A2A_BASE_URL and A2A_OWNER_TOKEN from the routine's cloud environment, with network access to ${ev.publicUrl.replace(/\/$/, "")}.`,
137
- `If any of that is missing (\`A2A_BASE_URL / A2A_OWNER_TOKEN missing\`, or a 403 host_not_allowed), don't work around it and never ask for the token in chat: tell your human the a2a-exposed Claude Code setup is incomplete (add the connector at claude.ai/settings/connectors: the inbox URL + /mcp; or see the Claude Code checklist in the setup skill's wake reference).`,
137
+ `If any of that is missing (\`A2A_BASE_URL / A2A_OWNER_TOKEN missing\`, or a 403 host_not_allowed), don't work around it and never ask for the token in chat: tell your human the a2a-exposed Claude Code setup is incomplete (add the connector at claude.ai/settings/connectors: the A2A endpoint URL + /mcp; or see the Claude Code checklist in the setup skill's wake reference).`,
138
138
  ].join("\n");
139
139
  }
140
140
 
@@ -197,6 +197,7 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
197
197
  case "openclaw-agent":
198
198
  bearer();
199
199
  headers["idempotency-key"] = requestId;
200
+ // `name` is the hook name OpenClaw filters match. It stays "A2A inbox". The wake text itself starts with "A2A endpoint:".
200
201
  body = JSON.stringify({ message: wakeSummary(ev, cli), name: "A2A inbox", agentId: cfg.agentId || "main", sessionMode: "isolated", deliver: false });
201
202
  break;
202
203
  case "hermes":
@@ -220,6 +220,7 @@ test("authorization: consent page, CSP allows the redirect origin, password requ
220
220
  await s.owner("PUT", "/owner/pairing/password", await passwordRecord());
221
221
  c = await s.consent(q, "wrong password");
222
222
  assert.equal(c.page.status, 200);
223
+ assert.match(c.page.text, /use the A2A endpoint of/);
223
224
  assert.match(c.page.text, /Claude/);
224
225
  assert.match(c.page.text, /claude\.ai/, "shows where it returns to");
225
226
  assert.match(c.page.text, /Approve or deny pairing requests/, "lists what it can't do");
@@ -362,7 +363,7 @@ test("send_feedback returns a GitHub issue link and strips tokens", async (t) =>
362
363
  assert.match(listed.description, /public GitHub issue on telegraphic-dev\/a2a-exposed/);
363
364
  assert.match(listed.description, /Redact before sending/);
364
365
  assert.match(listed.description, /exact commands/);
365
- assert.match(listed.description, /filled by the inbox/);
366
+ assert.match(listed.description, /filled by the A2A endpoint/);
366
367
  assert.match(listed.inputSchema.properties.steps.description, /Remove tokens and secrets/);
367
368
  assert.match(listed.inputSchema.properties.environment.description, /cloud agent sandbox/);
368
369
  assert.match(listed.inputSchema.properties.connection.description, /webhook, proxy, or polling/);
@@ -728,6 +729,11 @@ test("Client ID Metadata Documents: unsafe URLs and bad documents are refused (n
728
729
  assert.match(limited.text, /Too many client metadata lookups/);
729
730
  });
730
731
 
732
+ test("mcpLimitMessage names the A2A endpoint and the revoke command", () => {
733
+ assert.equal(M.mcpLimitMessage(20, "npx -y a2a-exposed@latest"),
734
+ "This A2A endpoint already has 20 MCP connectors. Revoke one first: npx -y a2a-exposed@latest token list, then token revoke <label>.");
735
+ });
736
+
731
737
  test("isPublicIp: only global unicast addresses pass", () => {
732
738
  for (const ip of ["93.184.216.34", "1.1.1.1", "2606:2800:220:1::1", "[2a00:1450::1]"]) assert.equal(M.isPublicIp(ip), true, ip);
733
739
  for (const ip of ["10.1.2.3", "127.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255",
@@ -321,6 +321,8 @@ test("human approval: pending, slow_down, page approve with the password, single
321
321
  r = await s.decidePage(d.user_code, PASSWORD, "approve");
322
322
  assert.equal(r.status, 200, r.text);
323
323
  assert.match(r.text, /Approved\. Barry &lt;script&gt;alert\(1\)&lt;\/script&gt; can now collect its token \(label &quot;Barry-script-alert-1-scr&quot;\)/);
324
+ assert.match(r.text, /You can close this page/);
325
+ assert.doesNotMatch(r.text, /name="user_code"/);
324
326
 
325
327
  advance(11);
326
328
  p = await s.poll(d.device_code);
@@ -510,7 +512,7 @@ test("pairing wakes: summary for each preset; requester text only where previews
510
512
  const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
511
513
  pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 600 } };
512
514
  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 inbox \(code WDJB-2345, expires in 10 minutes\)\./);
515
+ 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 10 minutes\)\./);
514
516
  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
517
  const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
516
518
  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 +541,8 @@ test("/device deny needs no password and runs no PBKDF2; an empty password on ap
539
541
  try { r = await s.decidePage(a.user_code, "", "deny"); } finally { (crypto.subtle as any).deriveBits = realPbkdf2; }
540
542
  assert.equal(r.status, 200, r.text);
541
543
  assert.match(r.text, /Denied\. Barry Bot gets no token\./);
544
+ assert.match(r.text, /You can close this page/);
545
+ assert.doesNotMatch(r.text, /name="user_code"/);
542
546
  assert.equal(derived, 0, "no PBKDF2 for a deny");
543
547
  assert.equal((await s.poll(a.device_code)).data.error, "access_denied");
544
548
  // CSRF still applies to deny
@@ -868,3 +872,44 @@ test("owner log lists history without data_json and does not clear a pending wak
868
872
  assert.equal(head.text, "");
869
873
  assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, again);
870
874
  });
875
+
876
+ test("device page copy: code hint, expiry, token scope, settled result, A2A endpoint", async (t) => {
877
+ const now = Date.now();
878
+ assert.equal(P.expiresText(now + 30_000, now), "less than a minute");
879
+ assert.equal(P.expiresText(now + 90_000, now), "2 min");
880
+ assert.equal(P.expiresText(now - 1, now), "expired");
881
+ const base = { agentName: "this A2A endpoint", mode: "human" as const, cli: "a2a", csrf: "c".repeat(24), passwordSet: true };
882
+ const bare = P.devicePage({ ...base, askCode: true }, "n");
883
+ assert.match(bare, /The connecting agent prints this code when it runs <code>a2a connect &lt;url&gt;<\/code>/);
884
+ assert.match(bare, /It looks like WDJB-4827/);
885
+ assert.match(bare, /<title>Connect an agent to this A2A endpoint<\/title>/);
886
+ 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 };
887
+ const page = P.devicePage({ ...base, request: req }, "n");
888
+ assert.match(page, /so this A2A endpoint can&#39;t check it/);
889
+ assert.match(page, /send messages and trigger wakes/);
890
+ assert.match(page, /Message text from peers is untrusted/);
891
+ assert.match(page, /less than a minute/);
892
+ assert.match(page, /Revoke the token &lt;label&gt; any time with <code>a2a token revoke &lt;label&gt;<\/code>/);
893
+ const namedToken = P.devicePage({ ...base, request: { ...req, tokenLabel: "barry" } }, "n");
894
+ assert.match(namedToken, /Revoke the token barry any time with <code>a2a token revoke barry<\/code>/);
895
+ for (const settled of ["approved", "denied"] as const) {
896
+ const done = P.devicePage({ ...base, settled, notice: { kind: "ok", text: settled === "approved" ? "Approved." : "Denied." } }, "n");
897
+ assert.match(done, /You can close this page/);
898
+ assert.doesNotMatch(done, /name="user_code"/);
899
+ assert.match(done, new RegExp(`<title>${settled === "approved" ? "Approved" : "Denied"}</title>`));
900
+ }
901
+ const setupHtml = P.setupPage({ agentName: "this A2A endpoint", cli: "a2a", csrf: "c".repeat(24), token: "tok", expiresMs: now + 60_000 }, "n");
902
+ assert.match(setupHtml, /connect to this A2A endpoint/);
903
+ const land = P.landingPage({ name: "Test", base: "https://agent.example.com", versions: ["1.0"], skills: [], pairing: true, proxy: false }, "n");
904
+ assert.match(land, /Messages are stored for its owner, and the agent is woken/);
905
+
906
+ const s = setup(); t.after(s.restore);
907
+ const empty = await s.call("GET", "/device");
908
+ assert.match(empty.text, /prints this code when it runs/);
909
+ assert.match(empty.text, /WDJB-4827/);
910
+ const named = setup({ AGENT_NAME: "" }); t.after(named.restore);
911
+ const title = await named.call("GET", "/device");
912
+ assert.match(title.text, /Connect an agent to this A2A endpoint/);
913
+ const home = await s.call("GET", "/", { headers: { accept: "text/html" } });
914
+ assert.match(home.text, /Messages are stored for its owner, and the agent is woken/);
915
+ });
@@ -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
  });