a2a-exposed 0.8.22 → 0.8.23

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
@@ -355,8 +355,15 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
355
355
  description: `Fetch the current state of a task you sent to a peer (from send) and record it in the history. ${RULES}`,
356
356
  inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
357
357
  { name: "list_peers", title: "List peers", annotations: ro,
358
- 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.",
358
+ 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. An inbound peer with no card URL has no callback yet.",
359
359
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
360
+ { name: "announce_address", title: "Announce address", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
361
+ description: `Tell paired peers this inbox's current https agent-card URL, using the pairing token already stored for each one. Pass to for one alias, or all: true. A peer checks that the card proves the same pairing identity before it switches. A peer that does not implement this keeps the current pairing. This does not approve a pairing. ${RULES}`,
362
+ inputSchema: { type: "object", properties: {
363
+ to: str("One synced peer alias"),
364
+ all: { type: "boolean", description: "Announce to every synced peer" },
365
+ reissue: { type: "boolean", description: "Mint a new return token after the owner revoked the previous one. Requires to, and only after the human asks." },
366
+ }, additionalProperties: false } },
360
367
  { name: "pairing_requests", title: "Pending pairing requests", annotations: ro,
361
368
  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. The owner approves with the approval password on the /device link, with the owner token, or on the dashboard when a Manage link is set (this tool's output names that link). The names and card URLs are claimed by the requester (untrusted).",
362
369
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
@@ -13,6 +13,8 @@ import m0010 from "../migrations/0010_conversation_peer.sql?raw";
13
13
  import m0011 from "../migrations/0011_conversation_owner.sql?raw";
14
14
  import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
15
15
  import m0013 from "../migrations/0013_reply_delivery.sql?raw";
16
+ import m0014 from "../migrations/0014_peer_address.sql?raw";
17
+ import m0015 from "../migrations/0015_outbound_source.sql?raw";
16
18
  import type { Migration } from "./storage.ts";
17
19
 
18
20
  export const MIGRATIONS: Migration[] = [
@@ -29,4 +31,6 @@ export const MIGRATIONS: Migration[] = [
29
31
  { name: "0011_conversation_owner.sql", sql: m0011 },
30
32
  { name: "0012_conversation_peer_owner.sql", sql: m0012 },
31
33
  { name: "0013_reply_delivery.sql", sql: m0013 },
34
+ { name: "0014_peer_address.sql", sql: m0014 },
35
+ { name: "0015_outbound_source.sql", sql: m0015 },
32
36
  ];
@@ -29,6 +29,7 @@ const SECRET_KEYS = new Set([
29
29
  "token", "access_token", "refresh_token", "device_code",
30
30
  "ownertoken", "owner_token", "secretsenc", "secrets_enc",
31
31
  "clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
32
+ "privatekey", "private_key",
32
33
  ]);
33
34
 
34
35
  /**
@@ -154,6 +155,11 @@ const ROUTES: RouteDef[] = [
154
155
  { method: "GET", name: "GET /owner/tasks/:id", take: (s) => s.length === 3 && s[1] === "tasks" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/tasks/${s[2]}` },
155
156
  { method: "GET", name: "GET /owner/history/:id", take: (s) => s.length === 3 && s[1] === "history" && idOk(s[2]!), search: historyQuery, path: (s) => `/owner/history/${s[2]}` },
156
157
  { method: "GET", name: "GET /owner/outbound/:id", take: (s) => s.length === 3 && s[1] === "outbound" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/outbound/${s[2]}` },
158
+ { method: "GET", name: "GET /owner/address", take: (s) => s.length === 2 && s[1] === "address", search: noQuery, path: () => "/owner/address" },
159
+ { method: "GET", name: "GET /owner/identity", take: (s) => s.length === 2 && s[1] === "identity", search: noQuery, path: () => "/owner/identity" },
160
+ { method: "POST", name: "POST /owner/peers/announce", take: (s) => s.length === 3 && s[1] === "peers" && s[2] === "announce", search: noQuery, path: () => "/owner/peers/announce" },
161
+ { method: "PUT", name: "PUT /owner/address-forward", take: (s) => s.length === 2 && s[1] === "address-forward", search: noQuery, path: () => "/owner/address-forward" },
162
+ { method: "DELETE", name: "DELETE /owner/address-forward", take: (s) => s.length === 2 && s[1] === "address-forward", search: noQuery, path: () => "/owner/address-forward" },
157
163
  { method: "GET", name: "GET /owner/peers", take: (s) => s.length === 2 && s[1] === "peers", search: noQuery, path: () => "/owner/peers" },
158
164
  { method: "DELETE", name: "DELETE /owner/peers/:label", take: (s) => s.length === 3 && s[1] === "peers" && LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/peers/${s[2]}` },
159
165
  { method: "GET", name: "GET /owner/pairing", take: (s) => s.length === 2 && s[1] === "pairing", search: noQuery, path: () => "/owner/pairing" },
@@ -164,6 +170,7 @@ const ROUTES: RouteDef[] = [
164
170
  { method: "POST", name: "POST /owner/mcp/send", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "send", search: noQuery, path: () => "/owner/mcp/send" },
165
171
  { method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
166
172
  { method: "POST", name: "POST /owner/mcp/mark_working", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "mark_working", search: noQuery, path: () => "/owner/mcp/mark_working" },
173
+ { method: "POST", name: "POST /owner/mcp/announce_address", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "announce_address", search: noQuery, path: () => "/owner/mcp/announce_address" },
167
174
  { method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
168
175
  { method: "PUT", name: "PUT /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
169
176
  { method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
@@ -232,14 +239,51 @@ function outboundPeerBody(body: unknown): { ok: true; value: Record<string, stri
232
239
  return { ok: true, value };
233
240
  }
234
241
 
242
+ /** `{ to }` or `{ all: true }` for an address announcement. No token, no private key. */
243
+ function announceBody(body: unknown): { ok: true; value: Record<string, string> } | { ok: false; error: string } {
244
+ if (body == null || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "body must be an object" };
245
+ const keys = Object.keys(body);
246
+ if (keys.length > 3 || keys.some((key) => key !== "to" && key !== "all" && key !== "reissue")) return { ok: false, error: "body is not allowed" };
247
+ const raw = body as Record<string, unknown>;
248
+ const all = raw.all === true || raw.all === "true";
249
+ const reissue = raw.reissue === true || raw.reissue === "true";
250
+ const to = raw.to;
251
+ if (to !== undefined && (typeof to !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(to))) return { ok: false, error: "to is invalid" };
252
+ if (all === !!to) return { ok: false, error: "pass to (one alias) or all: true" };
253
+ if (reissue && (all || !to)) return { ok: false, error: "reissue needs one alias" };
254
+ const value: Record<string, string> = all ? { all: "true" } : { to: String(to) };
255
+ if (reissue) value.reissue = "true";
256
+ return { ok: true, value };
257
+ }
258
+
259
+ /** `{ cardUrl, hours? }` for the grace forward. The card URL is public. */
260
+ function forwardBody(body: unknown): { ok: true; value: Record<string, string> } | { ok: false; error: string } {
261
+ if (body == null || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "body must be an object" };
262
+ const keys = Object.keys(body);
263
+ if (!keys.length || keys.length > 2 || keys.some((key) => key !== "cardUrl" && key !== "hours")) return { ok: false, error: "body is not allowed" };
264
+ const raw = body as Record<string, unknown>;
265
+ if (typeof raw.cardUrl !== "string" || !raw.cardUrl || raw.cardUrl.length > PEER_URL_MAX) return { ok: false, error: "cardUrl is invalid" };
266
+ const value: Record<string, string> = { cardUrl: raw.cardUrl };
267
+ if (raw.hours !== undefined) {
268
+ const hours = typeof raw.hours === "number" ? raw.hours : typeof raw.hours === "string" ? Number(raw.hours) : NaN;
269
+ if (!Number.isInteger(hours) || hours < 1 || hours > 720) return { ok: false, error: "hours is invalid" };
270
+ value.hours = String(hours);
271
+ }
272
+ return { ok: true, value };
273
+ }
274
+
235
275
  /**
236
276
  * Body allowed on this owner RPC route.
237
- * MCP send, reply, and mark_working receive a flat object of tool arguments.
277
+ * MCP send, reply, mark_working, and announce_address receive a flat object of tool arguments.
238
278
  * PUT /owner/outbound-peers/:alias receives `{ url, token }`. The token is sealed before any log line.
279
+ * PUT /owner/address-forward receives `{ cardUrl, hours? }`. POST /owner/peers/announce receives `{ to }` or `{ all: true }`.
280
+ * The pairing identity private key is not accepted on this RPC.
239
281
  * Every other route accepts only an absent body.
240
282
  */
241
283
  export function ownerRpcBody(routeName: string, body: unknown): { ok: true; value?: Record<string, string> } | { ok: false; error: string } {
242
284
  if (routeName === "PUT /owner/outbound-peers/:alias") return outboundPeerBody(body);
285
+ if (routeName === "POST /owner/peers/announce" || routeName === "POST /owner/mcp/announce_address") return announceBody(body);
286
+ if (routeName === "PUT /owner/address-forward") return forwardBody(body);
243
287
  const spec = MCP_ARGS[routeName];
244
288
  if (!spec) {
245
289
  if (body !== undefined && body !== null) return { ok: false, error: "this call takes no body" };
@@ -202,7 +202,7 @@ function clockTime(ms: number, timeZone: string): string {
202
202
  return `${hour}:${minute}`;
203
203
  }
204
204
 
205
- export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string };
205
+ export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string; identityFingerprint?: string };
206
206
  export type PageModel = {
207
207
  agentName: string;
208
208
  mode: Mode;
@@ -238,11 +238,12 @@ export function devicePage(m: PageModel, nonce: string): string {
238
238
  ["Requested from", `${[r.ip || "unknown address", r.country].filter(Boolean).join(", ")} (IP address of the machine that asked to connect)`],
239
239
  ["Expires", expiresText(r.expiresMs, Date.now(), m.timeZone)],
240
240
  ...(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`]] : []),
241
+ ...(r.identityFingerprint ? [["Pairing identity", r.identityFingerprint]] : []),
241
242
  ] : [];
242
243
  const tokenLabel = r?.tokenLabel || "<label>";
243
244
  const revoke = `${m.cli} token revoke ${tokenLabel}`;
244
245
  const details = r ? `<table>${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join("")}</table>
245
- <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>` : "";
246
+ <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>${r.identityFingerprint ? `<p class="w">This request includes a pairing identity (<code>${esc(r.identityFingerprint)}</code>). Approving lets this inbox call that agent back at an address the same identity later proves, without a second pairing approval. The return credential is an ordinary peer token you can revoke. A change of host is shown to you.</p>` : ""}${r && !r.agentCardUrl ? `<p class="w">No agent card was given. This agent can send messages after approval. Replies stay stored on the task until it announces an address.</p>` : ""}` : "";
246
247
  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>`;
247
248
  const denyOnly = `<form method="post" action="/device">
248
249
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(r ? formatUserCode(r.userCode) : "")}">
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.22";
2
+ export const VERSION = "0.8.23";
@@ -217,7 +217,7 @@ async function deliverPinnedPeer(url: string, init: RequestInit, target: { ip: s
217
217
  timeouts: { totalMs: REQUEST_TIMEOUT_MS },
218
218
  });
219
219
  try {
220
- const res = await client.fetch(url, { method: init.method, headers: init.headers, body: init.body, redirect: "manual" });
220
+ const res = await client.fetch(url, { method: init.method, headers: init.headers, body: init.body, redirect: "manual", signal: init.signal ?? undefined });
221
221
  return await peerResponseForCaller(res);
222
222
  } catch (e) {
223
223
  if (e instanceof TlsError || e instanceof CertificateError) throw new Error("TLS Handshake Failed.");
@@ -234,7 +234,9 @@ export async function fetchCheckedPeer(
234
234
  init: RequestInit,
235
235
  deps: Pick<WakeDeps, "lookup" | "connect" | "deliver"> = {},
236
236
  ): Promise<{ response: Response } | { refused: string }> {
237
- const checked = await checkedWakeAddresses(url, deps.lookup ?? lookupAddresses);
237
+ init.signal?.throwIfAborted();
238
+ const checked = await checkedWakeAddresses(url, deps.lookup ?? ((host) => lookupAddresses(host, init.signal ?? undefined)));
239
+ init.signal?.throwIfAborted();
238
240
  if ("refused" in checked) return checked;
239
241
  const target = { ip: checked.ips[0]!, hostname: checked.hostname };
240
242
  const nextInit: RequestInit = { ...init, redirect: "manual" };
@@ -0,0 +1,414 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sha256 } from "../src/a2a.ts";
4
+ import * as Addr from "../src/address.ts";
5
+ import { clearDirectoryCache } from "../src/directory.ts";
6
+ import { dispatch, runOwnerRpc } from "../src/index.ts";
7
+ import { redactExport, exportRows } from "../src/export.ts";
8
+ import { hostedTenantContext, type DoNamespace, type KvNamespace, type TenantContext, type WorkerBindings } from "../src/tenancy.ts";
9
+ import { d1 } from "./d1.ts";
10
+
11
+ const MIGRATIONS = new URL("../migrations/", import.meta.url);
12
+ const ectx = { waitUntil(p: Promise<unknown>) { p.catch(() => {}); }, passThroughOnException() {} };
13
+
14
+ test("address proofs: a card signature is bound to that URL and that key", async () => {
15
+ const id = await Addr.generateIdentity();
16
+ const cardUrl = "https://alice.example.com/.well-known/agent-card.json";
17
+ const signature = await Addr.signBytes(id.privateKey, Addr.identityMessage(cardUrl, id.publicKey));
18
+ assert.ok(signature);
19
+ const card = { capabilities: { extensions: [Addr.identityExtension(id.publicKey, cardUrl, signature!)] } };
20
+ assert.equal(await Addr.cardProves(card, id.publicKey, cardUrl), true);
21
+ assert.equal(await Addr.cardProves(card, id.publicKey, "https://evil.example.com/.well-known/agent-card.json"), false);
22
+ const other = await Addr.generateIdentity();
23
+ assert.equal(await Addr.cardProves(card, other.publicKey, cardUrl), false);
24
+ assert.equal(Addr.canonicalCardUrl("http://alice.example.com/.well-known/agent-card.json"), "");
25
+ assert.equal(Addr.canonicalCardUrl("https://alice.example.com/.well-known/agent-card.json?x=1"), "");
26
+ assert.equal(Addr.graceHours(undefined), 168);
27
+ assert.equal(Addr.graceHours(0), null);
28
+ assert.equal(Addr.announceOutcome(200, { result: { ok: true } }).status, "updated");
29
+ assert.equal(Addr.announceOutcome(200, { error: { code: -32601, message: "Method not found" } }).status, "unsupported");
30
+ assert.equal(Addr.cleanReverseToken("a2aow_" + "a".repeat(43)).length > 0, true);
31
+ assert.equal(Addr.cleanReverseToken("owner-secret-value"), "");
32
+ });
33
+
34
+ async function world(t: { after: (fn: () => void) => void }) {
35
+ clearDirectoryCache();
36
+ t.after(clearDirectoryCache);
37
+ const store = new Map<string, string>();
38
+ const kv: KvNamespace = {
39
+ async get(key: string, type?: "json") { const v = store.get(key); return v == null ? null : type === "json" ? JSON.parse(v) : v; },
40
+ async put(key: string, value: string) { store.set(key, value); },
41
+ };
42
+ const byId = new Map<string, TenantContext>();
43
+ const ns: DoNamespace = {
44
+ idFromName(name: string) { return name; },
45
+ get(id: unknown) {
46
+ return { async fetch(req: Request) {
47
+ const ctx = byId.get(String(id));
48
+ if (!ctx) return new Response("missing", { status: 404 });
49
+ return dispatch(req, ctx, ectx as never);
50
+ } };
51
+ },
52
+ };
53
+ const env = { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-key", DATA_REGION: "", TENANT_DIRECTORY: kv, TENANT_DO: ns } as WorkerBindings;
54
+ const make = async (name: string, ownerToken: string) => {
55
+ const db = d1(MIGRATIONS);
56
+ const ctx = hostedTenantContext(env, { id: `id-${name}`, db, publicUrl: `https://${name}.example.com`, ownerTokenHash: await sha256(ownerToken), status: "active", config: { AGENT_NAME: name } });
57
+ byId.set(`id-${name}`, ctx);
58
+ store.set(`tenant:${name}`, JSON.stringify({ id: `id-${name}`, status: "active", region: "default", version: 1 }));
59
+ return { ctx, db, ownerToken };
60
+ };
61
+ const alice = await make("alice", "owner-alice");
62
+ const bob = await make("bob", "owner-bob");
63
+ const carol = await make("carol", "owner-carol");
64
+ const call = async (ctx: TenantContext, path: string, init: RequestInit = {}) => {
65
+ const res = await dispatch(new Request(ctx.PUBLIC_URL + path, init), ctx, ectx as never);
66
+ const text = await res.text();
67
+ let data: any = null;
68
+ try { data = JSON.parse(text); } catch { data = text; }
69
+ return { status: res.status, data, headers: res.headers };
70
+ };
71
+ const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
72
+ runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
73
+ return { alice, bob, carol, call, owner };
74
+ }
75
+
76
+ async function pair(bob: { ctx: TenantContext; ownerToken: string }, call: (ctx: TenantContext, path: string, init?: RequestInit) => Promise<{ status: number; data: any }>, identity?: string) {
77
+ const started = await call(bob.ctx, "/oauth/device_authorization", {
78
+ method: "POST", headers: { "content-type": "application/json" },
79
+ body: JSON.stringify({ client_name: "Alice", client_id: "alice", ...(identity ? { agent_identity: identity } : {}) }),
80
+ });
81
+ assert.equal(started.status, 200, JSON.stringify(started.data));
82
+ const code = String(started.data.user_code).replace("-", "");
83
+ const approved = await call(bob.ctx, `/owner/pairing/${code}/approve`, { method: "POST", headers: { authorization: `Bearer ${bob.ownerToken}` } });
84
+ assert.equal(approved.status, 200, JSON.stringify(approved.data));
85
+ const token = await call(bob.ctx, "/oauth/token", {
86
+ method: "POST", headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: started.data.device_code }),
88
+ });
89
+ assert.equal(token.status, 200, JSON.stringify(token.data));
90
+ assert.match(token.data.access_token, /^a2aow_/);
91
+ return { access: token.data.access_token as string, label: token.data.peer_label as string };
92
+ }
93
+
94
+ async function send(bob: TenantContext, token: string, text: string, contextId: string) {
95
+ const res = await dispatch(new Request(bob.PUBLIC_URL + "/", {
96
+ method: "POST",
97
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
98
+ body: JSON.stringify({ jsonrpc: "2.0", id: "s", method: "message/send", params: { message: { kind: "message", role: "user", messageId: `m-${text}`, contextId, parts: [{ kind: "text", text }] }, configuration: { blocking: false } } }),
99
+ }), bob, ectx as never);
100
+ const data = await res.json() as any;
101
+ assert.equal(res.status, 200, JSON.stringify(data));
102
+ return data.result.id as string;
103
+ }
104
+
105
+ test("an agent with no address can pair and send; replies stay on the task until a verified announcement", async (t) => {
106
+ const { alice, bob, call, owner } = await world(t as never);
107
+ const ident = await Addr.generateIdentity();
108
+ const installed = await call(alice.ctx, "/owner/identity", {
109
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
110
+ body: JSON.stringify({ publicKey: ident.publicKey, privateKey: ident.privateKey }),
111
+ });
112
+ assert.equal(installed.status, 200, JSON.stringify(installed.data));
113
+ assert.equal(JSON.stringify(installed.data).includes(ident.privateKey), false);
114
+ const paired = await pair(bob, call, ident.publicKey);
115
+ const taskId = await send(bob.ctx, paired.access, "question", "ctx-none");
116
+ const logs: string[] = [];
117
+ const orig = console.log;
118
+ console.log = (line?: unknown) => { logs.push(String(line)); };
119
+ let first: any;
120
+ try {
121
+ first = await owner(bob.ctx, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer", state: "completed" });
122
+ } finally { console.log = orig; }
123
+ assert.equal(first.status, 200, JSON.stringify(first.body));
124
+ assert.equal(first.body.structuredContent.status, "stored-only");
125
+ assert.equal(JSON.stringify(first.body).includes("a2aow_"), false);
126
+ const got = await call(bob.ctx, "/", {
127
+ method: "POST", headers: { authorization: `Bearer ${paired.access}`, "content-type": "application/json" },
128
+ body: JSON.stringify({ jsonrpc: "2.0", id: "g", method: "tasks/get", params: { id: taskId } }),
129
+ });
130
+ assert.equal(got.status, 200, JSON.stringify(got.data));
131
+ assert.match(JSON.stringify(got.data), /stored answer/);
132
+ const cardUrl = "https://alice.example.com/.well-known/agent-card.json";
133
+ const card = await call(alice.ctx, "/.well-known/agent-card.json");
134
+ assert.equal(await Addr.cardProves(card.data, ident.publicKey, cardUrl), true);
135
+ const forged = await Addr.generateIdentity();
136
+ const badSig = await Addr.signBytes(forged.privateKey, Addr.updateMessage(cardUrl, ident.publicKey, "-"));
137
+ const bad = await call(bob.ctx, "/", {
138
+ method: "POST", headers: { authorization: `Bearer ${paired.access}`, "content-type": "application/json" },
139
+ body: JSON.stringify({ jsonrpc: "2.0", id: "bad", method: "PeerAddressUpdate", params: { agentCardUrl: cardUrl, publicKey: ident.publicKey, announcementSignature: badSig } }),
140
+ });
141
+ assert.equal(bad.data.error.code, -32602);
142
+ assert.equal((await bob.db.prepare("SELECT card_url FROM peers WHERE label = ?").bind(paired.label).first() as any).card_url, null);
143
+ const reverse = (await call(alice.ctx, "/owner/peers", {
144
+ method: "POST", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
145
+ body: JSON.stringify({ label: "bob" }),
146
+ })).data.token as string;
147
+ const reverseHash = await sha256(reverse);
148
+ const signature = await Addr.signBytes(ident.privateKey, Addr.updateMessage(cardUrl, ident.publicKey, reverseHash));
149
+ const logs2: string[] = [];
150
+ console.log = (line?: unknown) => { logs2.push(String(line)); };
151
+ let updated: any;
152
+ try {
153
+ updated = await call(bob.ctx, "/", {
154
+ method: "POST", headers: { authorization: `Bearer ${paired.access}`, "content-type": "application/json" },
155
+ body: JSON.stringify({ jsonrpc: "2.0", id: "up", method: "PeerAddressUpdate", params: { agentCardUrl: cardUrl, publicKey: ident.publicKey, announcementSignature: signature, reverseToken: reverse } }),
156
+ });
157
+ } finally { console.log = orig; }
158
+ assert.equal(updated.data.result?.ok, true, JSON.stringify(updated.data));
159
+ assert.equal(JSON.stringify(updated.data).includes(reverse), false);
160
+ assert.equal(logs2.join("\n").includes(reverse), false);
161
+ assert.equal(logs2.join("\n").includes(ident.privateKey), false);
162
+ const row = await bob.db.prepare("SELECT url, token_enc FROM outbound_peers WHERE alias = ?").bind(paired.label).first() as { url: string; token_enc: string };
163
+ assert.equal(row.url, "https://alice.example.com");
164
+ assert.ok(row.token_enc);
165
+ assert.equal(String(row.token_enc).includes(reverse), false);
166
+ const wrongKey = await Addr.generateIdentity();
167
+ const wrongSig = await Addr.signBytes(wrongKey.privateKey, Addr.updateMessage(cardUrl, wrongKey.publicKey, "-"));
168
+ const mismatch = await call(bob.ctx, "/", {
169
+ method: "POST", headers: { authorization: `Bearer ${paired.access}`, "content-type": "application/json" },
170
+ body: JSON.stringify({ jsonrpc: "2.0", id: "mm", method: "PeerAddressUpdate", params: { agentCardUrl: "https://carol.example.com/.well-known/agent-card.json", publicKey: wrongKey.publicKey, announcementSignature: wrongSig } }),
171
+ });
172
+ assert.equal(mismatch.data.error.code, -32602);
173
+ assert.equal((await bob.db.prepare("SELECT url FROM outbound_peers WHERE alias = ?").bind(paired.label).first() as any).url, "https://alice.example.com");
174
+ const task2 = await send(bob.ctx, paired.access, "second", "ctx-none");
175
+ const second = await owner(bob.ctx, "acct-bob", "POST", "/owner/mcp/reply", { taskId: task2, text: "delivered answer", state: "completed" });
176
+ assert.equal(second.body.structuredContent.status, "delivered", JSON.stringify(second.body));
177
+ assert.equal(second.body.structuredContent.target.host, "alice.example.com");
178
+ assert.equal(JSON.stringify(second.body).includes(reverse), false);
179
+ const view = await owner(bob.ctx, "acct-bob", "GET", "/owner/address");
180
+ assert.equal(view.status, 200, JSON.stringify(view.body));
181
+ assert.equal(JSON.stringify(view.body).includes(reverse), false);
182
+ assert.equal(JSON.stringify(view.body).includes(ident.privateKey), false);
183
+ });
184
+
185
+ test("a legacy pairing binds an identity once, and a later key is refused", async (t) => {
186
+ const { alice, bob, call } = await world(t as never);
187
+ const ident = await Addr.generateIdentity();
188
+ assert.equal((await call(alice.ctx, "/owner/identity", {
189
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
190
+ body: JSON.stringify(ident),
191
+ })).status, 200);
192
+ const paired = await pair(bob, call);
193
+ assert.equal((await bob.db.prepare("SELECT identity_key FROM peers WHERE label = ?").bind(paired.label).first() as any).identity_key, null);
194
+ const cardUrl = "https://alice.example.com/.well-known/agent-card.json";
195
+ const signature = await Addr.signBytes(ident.privateKey, Addr.updateMessage(cardUrl, ident.publicKey, "-"));
196
+ const bound = await call(bob.ctx, "/", {
197
+ method: "POST", headers: { authorization: `Bearer ${paired.access}`, "content-type": "application/json" },
198
+ body: JSON.stringify({ jsonrpc: "2.0", id: "b", method: "PeerAddressUpdate", params: { agentCardUrl: cardUrl, publicKey: ident.publicKey, announcementSignature: signature } }),
199
+ });
200
+ assert.equal(bound.data.result?.ok, true, JSON.stringify(bound.data));
201
+ const other = await Addr.generateIdentity();
202
+ await call(alice.ctx, "/owner/identity", {
203
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
204
+ body: JSON.stringify(other),
205
+ });
206
+ const again = await Addr.signBytes(other.privateKey, Addr.updateMessage(cardUrl, other.publicKey, "-"));
207
+ const refused = await call(bob.ctx, "/", {
208
+ method: "POST", headers: { authorization: `Bearer ${paired.access}`, "content-type": "application/json" },
209
+ body: JSON.stringify({ jsonrpc: "2.0", id: "r", method: "PeerAddressUpdate", params: { agentCardUrl: cardUrl, publicKey: other.publicKey, announcementSignature: again } }),
210
+ });
211
+ assert.equal(refused.data.error.code, -32602);
212
+ assert.equal((await bob.db.prepare("SELECT identity_key FROM peers WHERE label = ?").bind(paired.label).first() as any).identity_key, ident.publicKey);
213
+ });
214
+
215
+ test("the old address forwards a direct reply and answers with a moved pointer", async (t) => {
216
+ const { alice, bob, call } = await world(t as never);
217
+ const cardUrl = "https://alice.example.com/.well-known/agent-card.json";
218
+ const ident = await Addr.generateIdentity();
219
+ const set = await call(bob.ctx, "/owner/address-forward", {
220
+ method: "PUT", headers: { authorization: `Bearer ${bob.ownerToken}`, "content-type": "application/json" },
221
+ body: JSON.stringify({ cardUrl, hours: 24 }),
222
+ });
223
+ assert.equal(set.status, 200, JSON.stringify(set.data));
224
+ const moved = await call(bob.ctx, "/.well-known/a2a-moved.json");
225
+ assert.equal(moved.data.cardUrl, cardUrl);
226
+ const token = (await call(bob.ctx, "/owner/peers", {
227
+ method: "POST", headers: { authorization: `Bearer ${bob.ownerToken}`, "content-type": "application/json" },
228
+ body: JSON.stringify({ label: "carol" }),
229
+ })).data.token as string;
230
+ const same = await sha256(token);
231
+ await alice.db.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('carol', ?, ?)").bind(same, new Date().toISOString()).run();
232
+ const early = await call(bob.ctx, "/", {
233
+ method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
234
+ body: JSON.stringify({ jsonrpc: "2.0", id: "early", method: "message/send", params: {
235
+ message: { kind: "message", role: "user", messageId: "m-early", contextId: "ctx-early", parts: [{ kind: "text", text: "before proof" }], metadata: { "a2a.exposed/reply-delivery": { taskId: "t0", messageId: "m0", state: "completed" } } },
236
+ metadata: { "a2a.exposed/reply-delivery": { taskId: "t0", messageId: "m0", state: "completed" } },
237
+ } }),
238
+ });
239
+ assert.equal(early.data.error.code, Addr.MOVED_CODE);
240
+ assert.equal((await alice.db.prepare("SELECT COUNT(*) AS n FROM tasks").first() as { n: number }).n, 0);
241
+ assert.equal((await call(bob.ctx, "/owner/identity", {
242
+ method: "PUT", headers: { authorization: `Bearer ${bob.ownerToken}`, "content-type": "application/json" },
243
+ body: JSON.stringify(ident),
244
+ })).status, 200);
245
+ assert.equal((await call(alice.ctx, "/owner/identity", {
246
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
247
+ body: JSON.stringify(ident),
248
+ })).status, 200);
249
+ const reply = await call(bob.ctx, "/", {
250
+ method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
251
+ body: JSON.stringify({ jsonrpc: "2.0", id: "fwd", method: "message/send", params: {
252
+ message: { kind: "message", role: "user", messageId: "m-fwd", contextId: "ctx-fwd", parts: [{ kind: "text", text: "please forward" }], metadata: { "a2a.exposed/reply-delivery": { taskId: "t", messageId: "m", state: "completed" } } },
253
+ metadata: { "a2a.exposed/reply-delivery": { taskId: "t", messageId: "m", state: "completed" } },
254
+ } }),
255
+ });
256
+ assert.equal(reply.headers.get("a2a-moved"), cardUrl);
257
+ assert.equal(reply.status, 200);
258
+ assert.equal(JSON.stringify(reply.data).includes(token), false);
259
+ assert.equal((await alice.db.prepare("SELECT COUNT(*) AS n FROM tasks").first() as { n: number }).n, 1);
260
+ const stranger = (await call(bob.ctx, "/owner/peers", {
261
+ method: "POST", headers: { authorization: `Bearer ${bob.ownerToken}`, "content-type": "application/json" },
262
+ body: JSON.stringify({ label: "stranger" }),
263
+ })).data.token as string;
264
+ const pointer = await call(bob.ctx, "/", {
265
+ method: "POST", headers: { authorization: `Bearer ${stranger}`, "content-type": "application/json" },
266
+ body: JSON.stringify({ jsonrpc: "2.0", id: "ptr", method: "message/send", params: {
267
+ message: { kind: "message", role: "user", messageId: "m-ptr", contextId: "ctx-ptr", parts: [{ kind: "text", text: "no token there" }], metadata: { "a2a.exposed/reply-delivery": { taskId: "t2", messageId: "m2", state: "completed" } } },
268
+ metadata: { "a2a.exposed/reply-delivery": { taskId: "t2", messageId: "m2", state: "completed" } },
269
+ } }),
270
+ });
271
+ assert.equal(pointer.data.error.code, Addr.MOVED_CODE);
272
+ assert.equal(pointer.data.error.data.movedTo, cardUrl);
273
+ assert.equal(pointer.headers.get("a2a-moved"), cardUrl);
274
+ assert.equal(JSON.stringify(pointer.data).includes(stranger), false);
275
+ });
276
+
277
+ test("announce does not store a return token when the peer does not accept the update", async (t) => {
278
+ const { alice, bob, call, owner } = await world(t as never);
279
+ const ident = await Addr.generateIdentity();
280
+ await call(alice.ctx, "/owner/identity", {
281
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
282
+ body: JSON.stringify(ident),
283
+ });
284
+ const token = "a2aow_" + "b".repeat(43);
285
+ await bob.db.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('old', ?, ?)").bind(await sha256(token), new Date().toISOString()).run();
286
+ // A peer that answers method-not-found. Carol's worker understands the method, so this stub is the old peer.
287
+ const oldUrl = "https://old.example.com";
288
+ assert.equal((await owner(alice.ctx, "acct-alice", "PUT", "/owner/outbound-peers/old", { url: oldUrl, token })).status, 200);
289
+ const announced = await owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: "old" });
290
+ assert.equal(announced.status, 200, JSON.stringify(announced.body));
291
+ const row = (announced.body as any).results[0];
292
+ assert.equal(row.status === "unsupported" || row.status === "failed", true, JSON.stringify(row));
293
+ assert.equal(JSON.stringify(announced.body).includes(token), false);
294
+ assert.equal((await alice.db.prepare("SELECT COUNT(*) AS n FROM peers WHERE source = 'reverse'").first() as any).n, 0);
295
+ const view = await call(alice.ctx, "/owner/address", { headers: { authorization: `Bearer ${alice.ownerToken}` } });
296
+ assert.equal(view.data.announcePending[0].alias, "old");
297
+ assert.equal(JSON.stringify(view.data).includes(ident.privateKey), false);
298
+ });
299
+
300
+ test("a reply follows a moved card only when the new card proves the stored identity", async (t) => {
301
+ const { alice, bob, carol, call, owner } = await world(t as never);
302
+ const ident = await Addr.generateIdentity();
303
+ assert.equal((await call(alice.ctx, "/owner/identity", {
304
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
305
+ body: JSON.stringify(ident),
306
+ })).status, 200);
307
+ assert.equal((await call(carol.ctx, "/owner/address-forward", {
308
+ method: "PUT", headers: { authorization: `Bearer ${carol.ownerToken}`, "content-type": "application/json" },
309
+ body: JSON.stringify({ cardUrl: "https://alice.example.com/.well-known/agent-card.json", hours: 48 }),
310
+ })).status, 200);
311
+ const inbound = (await call(bob.ctx, "/owner/peers", {
312
+ method: "POST", headers: { authorization: `Bearer ${bob.ownerToken}`, "content-type": "application/json" },
313
+ body: JSON.stringify({ label: "mover" }),
314
+ })).data.token as string;
315
+ await bob.db.prepare("UPDATE peers SET identity_key = ? WHERE label = 'mover'").bind(ident.publicKey).run();
316
+ const callback = (await call(alice.ctx, "/owner/peers", {
317
+ method: "POST", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
318
+ body: JSON.stringify({ label: "bob" }),
319
+ })).data.token as string;
320
+ assert.equal((await owner(bob.ctx, "acct-bob", "PUT", "/owner/outbound-peers/mover", { url: "https://carol.example.com", token: callback })).status, 200);
321
+ await bob.db.prepare("UPDATE outbound_peers SET source = 'announce' WHERE alias = 'mover'").run();
322
+ const taskId = await send(bob.ctx, inbound, "where", "ctx-move");
323
+ const reply = await owner(bob.ctx, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "followed", state: "completed" });
324
+ assert.equal(reply.body.structuredContent.status, "delivered", JSON.stringify(reply.body));
325
+ assert.equal(reply.body.structuredContent.target.host, "alice.example.com");
326
+ assert.equal((await bob.db.prepare("SELECT url FROM outbound_peers WHERE alias = 'mover'").first() as { url: string }).url, "https://alice.example.com");
327
+ assert.equal(JSON.stringify(reply.body).includes(callback), false);
328
+ });
329
+
330
+ test("an announcement cannot take over an outbound alias or keep its token", async (t) => {
331
+ const { alice, bob, call } = await world(t as never);
332
+ const ident = await Addr.generateIdentity();
333
+ assert.equal((await call(alice.ctx, "/owner/identity", {
334
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
335
+ body: JSON.stringify(ident),
336
+ })).status, 200);
337
+ const now = new Date().toISOString();
338
+ await bob.db.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('carol', 'https://carol.example.com', 'sealed-carol-token', ?, ?)").bind(now, now).run();
339
+ const started = await call(bob.ctx, "/oauth/device_authorization", {
340
+ method: "POST", headers: { "content-type": "application/json" },
341
+ body: JSON.stringify({ client_name: "carol", client_id: "carol", agent_identity: ident.publicKey }),
342
+ });
343
+ assert.equal(started.status, 200, JSON.stringify(started.data));
344
+ const code = String(started.data.user_code).replace("-", "");
345
+ assert.equal((await call(bob.ctx, `/owner/pairing/${code}/approve`, { method: "POST", headers: { authorization: `Bearer ${bob.ownerToken}` } })).status, 200);
346
+ const redeemed = await call(bob.ctx, "/oauth/token", {
347
+ method: "POST", headers: { "content-type": "application/json" },
348
+ body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: started.data.device_code }),
349
+ });
350
+ assert.equal(redeemed.data.peer_label, "carol-2");
351
+ assert.equal((await bob.db.prepare("SELECT url, token_enc FROM outbound_peers WHERE alias = 'carol'").first() as { url: string; token_enc: string }).url, "https://carol.example.com");
352
+ const planted = "a2aow_" + "d".repeat(43);
353
+ await bob.db.prepare("INSERT INTO peers (label, token_hash, created_at, source) VALUES ('carol', ?, ?, 'pairing')").bind(await sha256(planted), now).run();
354
+ const cardUrl = "https://alice.example.com/.well-known/agent-card.json";
355
+ const signature = await Addr.signBytes(ident.privateKey, Addr.updateMessage(cardUrl, ident.publicKey, "-"));
356
+ const taken = await call(bob.ctx, "/", {
357
+ method: "POST", headers: { authorization: `Bearer ${planted}`, "content-type": "application/json" },
358
+ body: JSON.stringify({ jsonrpc: "2.0", id: "take", method: "PeerAddressUpdate", params: { agentCardUrl: cardUrl, publicKey: ident.publicKey, announcementSignature: signature } }),
359
+ });
360
+ assert.equal(taken.data.error.code, -32602, JSON.stringify(taken.data));
361
+ const row = await bob.db.prepare("SELECT url, token_enc FROM outbound_peers WHERE alias = 'carol'").first() as { url: string; token_enc: string };
362
+ assert.equal(row.url, "https://carol.example.com");
363
+ assert.equal(row.token_enc, "sealed-carol-token");
364
+ });
365
+
366
+ test("revoking a return token stays revoked until an explicit reissue", async (t) => {
367
+ const { alice, carol, call, owner } = await world(t as never);
368
+ const ident = await Addr.generateIdentity();
369
+ assert.equal((await call(alice.ctx, "/owner/identity", {
370
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
371
+ body: JSON.stringify(ident),
372
+ })).status, 200);
373
+ const carolToken = (await call(carol.ctx, "/owner/peers", {
374
+ method: "POST", headers: { authorization: `Bearer ${carol.ownerToken}`, "content-type": "application/json" },
375
+ body: JSON.stringify({ label: "alice" }),
376
+ })).data.token as string;
377
+ assert.equal((await owner(alice.ctx, "acct-alice", "PUT", "/owner/outbound-peers/carol", { url: "https://carol.example.com", token: carolToken })).status, 200);
378
+ const first = await owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: "carol" });
379
+ assert.equal((first.body as any).results[0].status, "updated", JSON.stringify(first.body));
380
+ const before = await alice.db.prepare("SELECT token_hash, revoked_at FROM peers WHERE label = 'carol' AND source = 'reverse'").first() as { token_hash: string; revoked_at: string | null };
381
+ assert.equal(before.revoked_at, null);
382
+ assert.equal((await call(alice.ctx, "/owner/peers/carol", { method: "DELETE", headers: { authorization: `Bearer ${alice.ownerToken}` } })).status, 200);
383
+ const again = await owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: "carol" });
384
+ assert.equal((again.body as any).results[0].status, "revoked", JSON.stringify(again.body));
385
+ const stuck = await alice.db.prepare("SELECT token_hash, revoked_at FROM peers WHERE label = 'carol'").first() as { token_hash: string; revoked_at: string | null };
386
+ assert.equal(stuck.token_hash, before.token_hash);
387
+ assert.ok(stuck.revoked_at);
388
+ const reissued = await owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: "carol", reissue: true });
389
+ assert.equal((reissued.body as any).results[0].status, "updated", JSON.stringify(reissued.body));
390
+ const after = await alice.db.prepare("SELECT token_hash, revoked_at FROM peers WHERE label = 'carol'").first() as { token_hash: string; revoked_at: string | null };
391
+ assert.equal(after.revoked_at, null);
392
+ assert.notEqual(after.token_hash, before.token_hash);
393
+ assert.equal(JSON.stringify(again.body).includes("a2aow_"), false);
394
+ assert.equal((await carol.db.prepare("SELECT source FROM outbound_peers WHERE alias = 'alice'").first() as { source: string }).source, "announce");
395
+ assert.equal((await call(carol.ctx, "/owner/peers/alice", { method: "DELETE", headers: { authorization: `Bearer ${carol.ownerToken}` } })).status, 200);
396
+ assert.equal((await carol.db.prepare("SELECT COUNT(*) AS n FROM outbound_peers WHERE alias = 'alice'").first() as { n: number }).n, 0);
397
+ assert.equal((await alice.db.prepare("SELECT revoked_at FROM peers WHERE label = 'carol'").first() as { revoked_at: string | null }).revoked_at, null);
398
+ });
399
+
400
+ test("export omits the sealed pairing identity and a stored return token", async (t) => {
401
+ const { alice, call } = await world(t as never);
402
+ const ident = await Addr.generateIdentity();
403
+ await call(alice.ctx, "/owner/identity", {
404
+ method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
405
+ body: JSON.stringify(ident),
406
+ });
407
+ await alice.db.prepare("INSERT INTO peers (label, token_hash, created_at, source, token_enc) VALUES ('bob', 'h', 't', 'reverse', 'sealed-return')").run();
408
+ const file = await exportRows(alice.db as never);
409
+ const redacted = redactExport(file);
410
+ assert.equal(redacted.tables.settings?.some((row) => row.key === "peer_identity"), false);
411
+ assert.equal(redacted.tables.peers?.[0]?.token_enc, undefined);
412
+ assert.equal(JSON.stringify(redacted).includes(ident.privateKey), false);
413
+ assert.equal(JSON.stringify(redacted).includes("sealed-return"), false);
414
+ });
@@ -329,7 +329,7 @@ test("scope: the MCP token is not an owner or peer token, and peer/owner tokens
329
329
  assert.equal((await s.call("GET", "/mcp", { headers: { authorization: `Bearer ${a.access_token}` } })).status, 405);
330
330
  // tools can't administer tokens or pairings
331
331
  const names = (await s.mcp(a.access_token, "tools/list")).data.result.tools.map((x: any) => x.name);
332
- assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "pairing_requests", "send_feedback"]);
332
+ assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
333
333
  assert.ok(!names.some((n: string) => /approve|deny|token|revoke|issue|password/.test(n)));
334
334
  const unknown = await s.mcp(a.access_token, "tools/call", { name: "approve_pairing", arguments: { code: "X" } });
335
335
  assert.equal(unknown.data.error.code, -32602);