a2a-exposed 0.8.21 → 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.
@@ -9,6 +9,8 @@ import * as A from "./a2a.ts";
9
9
  import * as F from "./facade.ts";
10
10
  import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
11
11
  import { fetchCheckedPeer, fetchCheckedWake } from "./wake-target.ts";
12
+ import { withDeadline } from "./deadline.ts";
13
+ import * as Addr from "./address.ts";
12
14
  import * as P from "./pairing.ts";
13
15
  import * as M from "./mcp.ts";
14
16
  import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion } from "./directory.ts";
@@ -140,7 +142,8 @@ async function fetchTenant(ctx: TenantContext, target: URL, init: RequestInit):
140
142
  const ns = namespaceForRegion(namespace, tenantRegion(pinned));
141
143
  const headers = new Headers(init.headers);
142
144
  headers.delete("x-a2a-tenant");
143
- const req = new Request(target, { method: init.method, headers, body: init.body ?? undefined, redirect: init.redirect });
145
+ init.signal?.throwIfAborted();
146
+ const req = new Request(target, { method: init.method, headers, body: init.body ?? undefined, redirect: init.redirect, signal: init.signal ?? undefined });
144
147
  return ns.get(ns.idFromName(pinned.id)).fetch(req);
145
148
  }
146
149
 
@@ -154,7 +157,7 @@ async function doFetch(ctx: TenantContext, ectx: ExecutionContext, url: string,
154
157
  // peerRpc, the peer card, and push delivery all come through here. A same-deployment tenant is an internal call.
155
158
  const internal = await fetchTenant(ctx, target, init);
156
159
  if (internal) return internal;
157
- return fetch(url, { ...init, signal: AbortSignal.timeout(15000) });
160
+ return fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(15000) });
158
161
  }
159
162
 
160
163
  // ------------------------------------------------------------------ wake webhook (presets), debounced per peer and context via D1
@@ -634,11 +637,13 @@ const V1: Record<string, string> = {
634
637
  ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
635
638
  DeleteTaskPushNotificationConfig: "pushdel",
636
639
  SendStreamingMessage: "stream", SubscribeToTask: "stream",
640
+ PeerAddressUpdate: "address",
637
641
  };
638
642
  const V03: Record<string, string> = {
639
643
  "message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
640
644
  "tasks/pushNotificationConfig/get": "pushget", "tasks/pushNotificationConfig/list": "pushlist",
641
- "tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
645
+ "tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
646
+ "peer/addressUpdate": "address",
642
647
  };
643
648
  const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
644
649
 
@@ -863,7 +868,7 @@ const rpcList: Rpc = async (p, label, version, ctx) => {
863
868
 
864
869
  const HANDLERS: Record<string, Rpc> = {
865
870
  send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet,
866
- pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList,
871
+ pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList, address: rpcAddress,
867
872
  };
868
873
 
869
874
  /** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
@@ -918,7 +923,12 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
918
923
  if (op === "stream") return err(-32004, proxy ? "Streaming is not supported through this façade yet" : "Streaming is not supported");
919
924
  const params = rq.params ?? {};
920
925
  if (typeof params !== "object" || Array.isArray(params)) return err(-32602, "Invalid params");
921
- if (proxy) return await proxyRpc(req, ctx, { raw, rid, method, op, version, params, label, ip });
926
+ if (directReplyParams(params)) {
927
+ const moved = await forwardMovedReply(ctx, ectx, raw, rid, req);
928
+ if (moved) return moved;
929
+ }
930
+ // Address updates stay on this inbox. A façade still proxies every other method.
931
+ if (op !== "address" && proxy) return await proxyRpc(req, ctx, { raw, rid, method, op, version, params, label, ip });
922
932
  try {
923
933
  const result = await HANDLERS[op](params, label, version, ctx, ectx);
924
934
  log("rpc_ok", { peer: label, method, ip });
@@ -1076,7 +1086,7 @@ async function proxyRpc(req: Request, ctx: TenantContext, c: { raw: string; rid:
1076
1086
  }
1077
1087
 
1078
1088
  // ------------------------------------------------------------------ push delivery (to peers) and receipt (from peers)
1079
- async function sendPush(ctx: TenantContext, ectx: ExecutionContext, task: Json, cfg: Json): Promise<[boolean, string]> {
1089
+ async function sendPush(ctx: TenantContext, ectx: ExecutionContext, task: Json, cfg: Json, signal?: AbortSignal): Promise<[boolean, string]> {
1080
1090
  const [ok, why] = A.pushUrlAllowed(cfg.url);
1081
1091
  if (!ok) return [false, why];
1082
1092
  const v1 = String(cfg._version || "0.3").startsWith("1");
@@ -1088,17 +1098,21 @@ async function sendPush(ctx: TenantContext, ectx: ExecutionContext, task: Json,
1088
1098
  if (scheme && auth.credentials) headers["authorization"] = `${scheme} ${auth.credentials}`;
1089
1099
  const body = v1 ? { task: A.publicTask(task, "1.0") } : A.publicTask(task, "0.3");
1090
1100
  try {
1091
- const r = await doFetch(ctx, ectx, cfg.url, { method: "POST", headers, body: JSON.stringify(body) });
1101
+ const r = await doFetch(ctx, ectx, cfg.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1092
1102
  return [r.status >= 200 && r.status < 300, `HTTP ${r.status}`];
1093
1103
  } catch (e) {
1094
1104
  return [false, String(e).slice(0, 200)];
1095
1105
  }
1096
1106
  }
1097
1107
 
1098
- async function notifyPeer(ctx: TenantContext, ectx: ExecutionContext, t: Json): Promise<Json[]> {
1108
+ async function notifyPeer(ctx: TenantContext, ectx: ExecutionContext, t: Json, signal?: AbortSignal): Promise<Json[]> {
1099
1109
  const results = [];
1100
1110
  for (const cfg of t._push || []) {
1101
- const [ok, info] = await sendPush(ctx, ectx, t, cfg);
1111
+ signal?.throwIfAborted();
1112
+ const [ok, info] = signal
1113
+ ? await withDeadline(7000, (step) => sendPush(ctx, ectx, t, cfg, step), signal)
1114
+ : await sendPush(ctx, ectx, t, cfg);
1115
+ signal?.throwIfAborted();
1102
1116
  results.push({ url: cfg.url, ok, info });
1103
1117
  await histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, event: "push", state: t.status.state, text: `${ok ? "ok" : "FAILED"} ${info}` }).run();
1104
1118
  }
@@ -1124,6 +1138,7 @@ function safeTarget(alias: string | undefined, raw: string): { alias?: string; h
1124
1138
  /** A paired endpoint fetch. Same-deployment tenants stay inside the Durable Object namespace. Every other host is
1125
1139
  * resolved once and the checked address is pinned to the socket; redirects are refused. */
1126
1140
  async function checkedPeerFetch(ctx: TenantContext, ectx: ExecutionContext, base: string, url: string, init: RequestInit): Promise<Response> {
1141
+ init.signal?.throwIfAborted();
1127
1142
  let baseUrl: URL, target: URL;
1128
1143
  try { baseUrl = new URL(base); target = new URL(url); } catch { throw new ToolError("peer endpoint is not a URL"); }
1129
1144
  if (target.origin !== baseUrl.origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
@@ -1139,36 +1154,403 @@ async function checkedPeerFetch(ctx: TenantContext, ectx: ExecutionContext, base
1139
1154
  return checked.response;
1140
1155
  }
1141
1156
 
1142
- async function checkedPeerCard(ctx: TenantContext, ectx: ExecutionContext, base: string): Promise<Json> {
1143
- for (const p of ["/.well-known/agent-card.json", "/.well-known/agent.json"]) {
1144
- try {
1145
- const r = await checkedPeerFetch(ctx, ectx, base, new URL(p, base).href, { headers: { accept: "application/json" } });
1146
- if (r.status !== 200) continue;
1157
+ async function checkedPeerCard(ctx: TenantContext, ectx: ExecutionContext, base: string, signal: AbortSignal): Promise<Json> {
1158
+ try {
1159
+ return await withDeadline(7000, async (step) => {
1160
+ const r = await checkedPeerFetch(ctx, ectx, base, new URL("/.well-known/agent-card.json", base).href, { headers: { accept: "application/json" }, signal: step });
1161
+ if (r.status !== 200) return null;
1147
1162
  const text = (await r.text()).slice(0, 1 << 20);
1148
1163
  const d = JSON.parse(text);
1149
- if (d && typeof d === "object") return d;
1150
- } catch { /* default endpoint below */ }
1164
+ return d && typeof d === "object" ? d : null;
1165
+ }, signal);
1166
+ } catch {
1167
+ signal.throwIfAborted();
1168
+ return null; // Use the paired base endpoint; never fetch a second card.
1169
+ }
1170
+ }
1171
+
1172
+ function directReplyParams(params: Json): boolean {
1173
+ const meta = params?.metadata?.[DIRECT_REPLY_META] || params?.message?.metadata?.[DIRECT_REPLY_META];
1174
+ return !!meta && typeof meta === "object";
1175
+ }
1176
+
1177
+ async function activeForward(ctx: TenantContext, now = Date.now()): Promise<{ cardUrl: string; untilMs: number } | null> {
1178
+ const row: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.FORWARD_SETTING).first();
1179
+ if (!row?.value) return null;
1180
+ let parsed: Json = null;
1181
+ try { parsed = JSON.parse(row.value); } catch { return null; }
1182
+ const cardUrl = Addr.canonicalCardUrl(parsed?.cardUrl);
1183
+ const untilMs = Number(parsed?.untilMs);
1184
+ if (!cardUrl || !Number.isFinite(untilMs) || untilMs <= now || peerUrlProblem(cardUrl)) return null;
1185
+ return { cardUrl, untilMs };
1186
+ }
1187
+
1188
+ /** During grace, a direct reply is forwarded to the new inbox when that inbox accepts it. Otherwise the caller
1189
+ * gets a moved pointer. Other methods keep working here and only gain the `a2a-moved` header. */
1190
+ async function forwardMovedReply(ctx: TenantContext, ectx: ExecutionContext, raw: string, rid: Json, req: Request): Promise<Response | null> {
1191
+ const forward = await activeForward(ctx);
1192
+ if (!forward) return null;
1193
+ let own = "";
1194
+ try { own = new URL(ctx.PUBLIC_URL).origin; } catch { own = ""; }
1195
+ const target = Addr.originOf(forward.cardUrl);
1196
+ if (!target || target === own) return null;
1197
+ const until = new Date(forward.untilMs).toISOString();
1198
+ const pointer = () => json({
1199
+ jsonrpc: "2.0", id: rid,
1200
+ error: { code: Addr.MOVED_CODE, message: "This inbox has moved", data: { movedTo: forward.cardUrl, until } },
1201
+ }, 200, { "a2a-moved": forward.cardUrl });
1202
+ try {
1203
+ const signal = AbortSignal.timeout(7000);
1204
+ const ident = await readIdentity(ctx);
1205
+ const card = await checkedPeerCard(ctx, ectx, target, signal);
1206
+ // The caller's bearer is forwarded only after the new card proves this inbox's own identity.
1207
+ if (!ident || ident === "unreadable" || !card || !await Addr.cardProves(card, ident.publicKey, forward.cardUrl)) return pointer();
1208
+ const [endpoint] = M.pickEndpoint(target, card);
1209
+ if (new URL(endpoint).origin !== target) return pointer();
1210
+ const headers: Record<string, string> = {
1211
+ "content-type": req.headers.get("content-type") || "application/json",
1212
+ accept: "application/json",
1213
+ "a2a-version": req.headers.get("a2a-version") || "1.0",
1214
+ "user-agent": "a2a-exposed",
1215
+ };
1216
+ const auth = req.headers.get("authorization");
1217
+ if (auth) headers.authorization = auth;
1218
+ const response = await checkedPeerFetch(ctx, ectx, target, endpoint, { method: "POST", headers, body: raw, signal });
1219
+ const text = (await response.text()).slice(0, 1 << 20);
1220
+ let body: Json = null;
1221
+ try { body = JSON.parse(text); } catch { body = null; }
1222
+ if (response.status === 200 && body && typeof body === "object" && body.result && !body.error)
1223
+ return json(body, 200, { "a2a-moved": forward.cardUrl });
1224
+ } catch { /* the pointer still tells the caller where this inbox went */ }
1225
+ return pointer();
1226
+ }
1227
+
1228
+ async function withMovedHeader(ctx: TenantContext, res: Response): Promise<Response> {
1229
+ const forward = await activeForward(ctx);
1230
+ if (!forward) return res;
1231
+ const headers = new Headers(res.headers);
1232
+ if (!headers.has("a2a-moved")) headers.set("a2a-moved", forward.cardUrl);
1233
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
1234
+ }
1235
+
1236
+ type PairingIdentity = { publicKey: string; privateKey: string };
1237
+
1238
+ async function readIdentity(ctx: TenantContext): Promise<PairingIdentity | null | "unreadable"> {
1239
+ const row: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first();
1240
+ if (!row?.value) return null;
1241
+ const key = await ctx.peerKey();
1242
+ if (!key) return "unreadable";
1243
+ const opened = await M.openPeerTokenWithKey(key, Addr.IDENTITY_AAD, row.value);
1244
+ if (!opened) return "unreadable";
1245
+ try {
1246
+ const parsed = JSON.parse(opened);
1247
+ const publicKey = Addr.cleanPublicKey(parsed?.publicKey);
1248
+ if (!publicKey || typeof parsed?.privateKey !== "string") return "unreadable";
1249
+ if (!await Addr.identityMatches(parsed.privateKey, publicKey)) return "unreadable";
1250
+ return { publicKey, privateKey: parsed.privateKey };
1251
+ } catch { return "unreadable"; }
1252
+ }
1253
+
1254
+ async function storeIdentity(ctx: TenantContext, ident: PairingIdentity): Promise<boolean> {
1255
+ const key = await ctx.peerKey();
1256
+ if (!key) return false;
1257
+ const sealed = await M.sealPeerTokenWithKey(key, Addr.IDENTITY_AAD, JSON.stringify({ publicKey: ident.publicKey, privateKey: ident.privateKey }));
1258
+ await ctx.DB.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1259
+ .bind(Addr.IDENTITY_SETTING, sealed).run();
1260
+ return true;
1261
+ }
1262
+
1263
+ /** The inbox's pairing identity. Creates one on first use. A sealed key that no longer opens is left in place. */
1264
+ async function ensureIdentity(ctx: TenantContext): Promise<PairingIdentity | null> {
1265
+ const cur = await readIdentity(ctx);
1266
+ if (cur && cur !== "unreadable") return cur;
1267
+ if (cur === "unreadable") return null;
1268
+ const key = await ctx.peerKey();
1269
+ if (!key) return null;
1270
+ const created = await Addr.generateIdentity();
1271
+ const sealed = await M.sealPeerTokenWithKey(key, Addr.IDENTITY_AAD, JSON.stringify(created));
1272
+ await ctx.DB.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING").bind(Addr.IDENTITY_SETTING, sealed).run();
1273
+ const again = await readIdentity(ctx);
1274
+ return again && again !== "unreadable" ? again : null;
1275
+ }
1276
+
1277
+ async function installIdentity(ctx: TenantContext, publicKey: unknown, privateKey: unknown): Promise<{ ok: true; publicKey: string; fingerprint: string } | { ok: false; status: number; error: string }> {
1278
+ const pub = Addr.cleanPublicKey(publicKey);
1279
+ if (!pub || typeof privateKey !== "string" || !await Addr.identityMatches(privateKey, pub))
1280
+ return { ok: false, status: 400, error: "identity key is invalid" };
1281
+ const cur = await readIdentity(ctx);
1282
+ if (cur && cur !== "unreadable" && cur.publicKey !== pub)
1283
+ return { ok: false, status: 409, error: "this inbox already has a different pairing identity" };
1284
+ if (!await storeIdentity(ctx, { publicKey: pub, privateKey }))
1285
+ return { ok: false, status: 409, error: "this inbox cannot seal a pairing identity" };
1286
+ const fingerprint = (await A.fingerprint(pub)) || "";
1287
+ log("identity_set", { fingerprint });
1288
+ return { ok: true, publicKey: pub, fingerprint };
1289
+ }
1290
+
1291
+ async function publicCard(ctx: TenantContext): Promise<Json> {
1292
+ const card = await agentCard(ctx);
1293
+ const cardUrl = Addr.cardUrlFor(ctx.PUBLIC_URL);
1294
+ const ident = cardUrl ? await readIdentity(ctx) : null;
1295
+ const extensions: Json[] = [];
1296
+ if (ident && ident !== "unreadable" && cardUrl) {
1297
+ const signature = await Addr.signBytes(ident.privateKey, Addr.identityMessage(cardUrl, ident.publicKey));
1298
+ if (signature) extensions.push(Addr.identityExtension(ident.publicKey, cardUrl, signature));
1299
+ }
1300
+ const forward = await activeForward(ctx);
1301
+ if (forward) extensions.push(Addr.movedExtension(forward.cardUrl, new Date(forward.untilMs).toISOString()));
1302
+ if (!extensions.length) return card;
1303
+ const caps = card.capabilities && typeof card.capabilities === "object" ? card.capabilities : {};
1304
+ const prev = Array.isArray(caps.extensions) ? caps.extensions : [];
1305
+ card.capabilities = { ...caps, extensions: [...prev, ...extensions] };
1306
+ return card;
1307
+ }
1308
+
1309
+ async function rememberPeerAddress(ctx: TenantContext, label: string, cardUrl: string, opts: { identity?: string | null; token?: string; firstBind?: boolean; graceMs?: number; previous?: string | null } = {}): Promise<void> {
1310
+ const origin = Addr.originOf(cardUrl);
1311
+ const now = A.nowIso();
1312
+ const nowMs = Date.now();
1313
+ const existing: Json = await ctx.DB.prepare("SELECT url, source FROM outbound_peers WHERE alias = ?").bind(label).first();
1314
+ const sameOrigin = !!existing && Addr.originOf(String(existing.url)) === origin;
1315
+ // An owner-synced outbound name is not this announcement's to move. A first identity bind also
1316
+ // cannot move an address that is already stored: that needs the owner, not a TOFU key.
1317
+ if (existing && existing.source !== "announce")
1318
+ throw new RpcError(-32602, "address update refused: this name already has an outbound address");
1319
+ if (existing && opts.firstBind && !sameOrigin)
1320
+ throw new RpcError(-32602, "address update refused: a first identity cannot move an existing address");
1321
+ if (existing && !sameOrigin && !opts.token)
1322
+ throw new RpcError(-32602, "address update refused: a new address needs a new return token");
1323
+ if (opts.token && (!existing || existing.source === "announce")) {
1324
+ const key = await ctx.peerKey();
1325
+ if (!key) throw new RpcError(-32603, "this inbox cannot store a return token");
1326
+ const enc = await M.sealPeerTokenWithKey(key, label, opts.token);
1327
+ await ctx.DB.prepare(
1328
+ `INSERT INTO outbound_peers (alias, url, token_enc, source, created_at, updated_at) VALUES (?, ?, ?, 'announce', ?, ?)
1329
+ ON CONFLICT(alias) DO UPDATE SET url = excluded.url, token_enc = excluded.token_enc, source = 'announce', updated_at = excluded.updated_at
1330
+ WHERE outbound_peers.source = 'announce'`,
1331
+ ).bind(label, origin, enc, now, now).run();
1151
1332
  }
1152
- return null;
1333
+ const until = opts.graceMs ? nowMs + opts.graceMs : null;
1334
+ const previous = opts.previous && Addr.hostOf(opts.previous) !== Addr.hostOf(cardUrl) ? opts.previous : null;
1335
+ await ctx.DB.prepare(
1336
+ `UPDATE peers SET card_url = ?, identity_key = COALESCE(?, identity_key),
1337
+ previous_card_url = CASE WHEN ? IS NOT NULL THEN ? ELSE previous_card_url END,
1338
+ previous_until_ms = CASE WHEN ? IS NOT NULL THEN ? ELSE previous_until_ms END
1339
+ WHERE label = ?`,
1340
+ ).bind(cardUrl, opts.identity || null, previous, previous, until, until, label).run();
1341
+ }
1342
+
1343
+ /** `PeerAddressUpdate` / `peer/addressUpdate`. The bearer is the existing pairing. The card has to prove the same key. */
1344
+ async function rpcAddress(params: Json, label: string, _version: string, ctx: TenantContext, ectx: ExecutionContext): Promise<Json> {
1345
+ if ((await bump(ctx, `addr:${label}`, 3600)) > 30) throw new RpcError(-32603, "too many address updates; try again later");
1346
+ const cardUrl = Addr.canonicalCardUrl(params.agentCardUrl);
1347
+ if (!cardUrl || peerUrlProblem(cardUrl)) throw new RpcError(-32602, "agentCardUrl must be the public https URL of an agent card");
1348
+ const publicKey = Addr.cleanPublicKey(params.publicKey);
1349
+ if (!publicKey) throw new RpcError(-32602, "publicKey must be an Ed25519 public key");
1350
+ const signature = typeof params.announcementSignature === "string" ? params.announcementSignature : "";
1351
+ if (!signature || signature.length > 200) throw new RpcError(-32602, "announcementSignature is required");
1352
+ let reverse = "";
1353
+ if (params.reverseToken !== undefined && params.reverseToken !== "") {
1354
+ reverse = Addr.cleanReverseToken(params.reverseToken);
1355
+ if (!reverse) throw new RpcError(-32602, "reverseToken is not a peer token");
1356
+ }
1357
+ const hours = Addr.graceHours(params.graceHours);
1358
+ if (hours === null) throw new RpcError(-32602, "graceHours must be a whole number from 1 to 720");
1359
+ let own = "";
1360
+ try { own = new URL(ctx.PUBLIC_URL).origin; } catch { own = ""; }
1361
+ if (own && Addr.originOf(cardUrl) === own) throw new RpcError(-32602, "address update refused: that card is this inbox");
1362
+ const peer: Json = await ctx.DB.prepare("SELECT identity_key, card_url FROM peers WHERE label = ? AND revoked_at IS NULL").bind(label).first();
1363
+ if (!peer) throw new RpcError(-32602, "address update refused: this pairing is not active");
1364
+ const stored = Addr.cleanPublicKey(peer.identity_key);
1365
+ const firstBind = !stored;
1366
+ if (stored && stored !== publicKey) throw new RpcError(-32602, "address update refused: identity does not match this pairing");
1367
+ const reverseHash = reverse ? await A.sha256(reverse) : "-";
1368
+ if (!await Addr.verifyBytes(publicKey, Addr.updateMessage(cardUrl, publicKey, reverseHash), signature))
1369
+ throw new RpcError(-32602, "address update refused: announcement signature is not valid");
1370
+ let card: Json = null;
1371
+ try { card = await checkedPeerCard(ctx, ectx, Addr.originOf(cardUrl), AbortSignal.timeout(7000)); }
1372
+ catch { throw new RpcError(-32602, "address update refused: the card could not be fetched"); }
1373
+ if (!card || !await Addr.cardProves(card, publicKey, cardUrl))
1374
+ throw new RpcError(-32602, "address update refused: the card does not prove this identity");
1375
+ const old = typeof peer.card_url === "string" ? peer.card_url : "";
1376
+ const hostChanged = !!old && Addr.hostOf(old) !== Addr.hostOf(cardUrl);
1377
+ await rememberPeerAddress(ctx, label, cardUrl, {
1378
+ identity: firstBind ? publicKey : null, firstBind,
1379
+ ...(reverse ? { token: reverse } : {}),
1380
+ ...(hostChanged ? { graceMs: hours * 3600 * 1000, previous: old } : {}),
1381
+ });
1382
+ if (firstBind || hostChanged) {
1383
+ const preview = firstBind
1384
+ ? `pairing identity bound; callback host is now ${Addr.hostOf(cardUrl)}`
1385
+ : `callback host changed from ${Addr.hostOf(old)} to ${Addr.hostOf(cardUrl)}`;
1386
+ try { await wake(ctx, ectx, "address", "none", label, preview, "address_change"); }
1387
+ catch (e) { log("address_wake_failed", { peer: label, error: String(e).slice(0, 200) }); }
1388
+ }
1389
+ log("address_updated", { peer: label, host: Addr.hostOf(cardUrl), ...(hostChanged ? { previousHost: Addr.hostOf(old) } : {}), reverse: !!reverse, bound: firstBind });
1390
+ return {
1391
+ ok: true, host: Addr.hostOf(cardUrl), identityFingerprint: await A.fingerprint(publicKey), reverse: !!reverse,
1392
+ ...(hostChanged ? { previousHost: Addr.hostOf(old), graceUntil: new Date(Date.now() + hours * 3600 * 1000).toISOString() } : {}),
1393
+ };
1394
+ }
1395
+
1396
+ /** Where this inbox keeps the return token it minted for `alias`. A revoked reverse row stays revoked. */
1397
+ async function planReverse(ctx: TenantContext, alias: string): Promise<{ kind: "use"; label: string } | { kind: "revoked"; label: string } | { kind: "none" }> {
1398
+ const alt = `return-${alias}`.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32).replace(/-+$/, "");
1399
+ const labels = [...new Set([alias, alt].filter((label) => !!label))];
1400
+ const rows: { label: string; row: Json }[] = [];
1401
+ for (const label of labels) {
1402
+ const row: Json = await ctx.DB.prepare("SELECT source, revoked_at FROM peers WHERE label = ?").bind(label).first();
1403
+ rows.push({ label, row });
1404
+ }
1405
+ for (const { label, row } of rows) if (row && row.source === "reverse" && row.revoked_at) return { kind: "revoked", label };
1406
+ for (const { label, row } of rows) if (row && row.source === "reverse") return { kind: "use", label };
1407
+ for (const { label, row } of rows) if (!row) return { kind: "use", label };
1408
+ return { kind: "none" };
1409
+ }
1410
+
1411
+ async function openReverse(ctx: TenantContext, label: string): Promise<string | null> {
1412
+ const row: Json = await ctx.DB.prepare("SELECT token_enc, source, revoked_at FROM peers WHERE label = ?").bind(label).first();
1413
+ if (!row || row.revoked_at || row.source !== "reverse" || !row.token_enc) return null;
1414
+ const key = await ctx.peerKey();
1415
+ return key ? await M.openPeerTokenWithKey(key, label, row.token_enc) : null;
1416
+ }
1417
+
1418
+ async function storeReverse(ctx: TenantContext, label: string, token: string, clientName: string, reissue = false): Promise<void> {
1419
+ const key = await ctx.peerKey();
1420
+ if (!key) return;
1421
+ const enc = await M.sealPeerTokenWithKey(key, label, token);
1422
+ const hash = await A.sha256(token);
1423
+ const now = A.nowIso();
1424
+ if (reissue) {
1425
+ await ctx.DB.prepare(
1426
+ `UPDATE peers SET token_hash = ?, token_enc = ?, revoked_at = NULL, source = 'reverse', client_name = COALESCE(client_name, ?)
1427
+ WHERE label = ? AND source = 'reverse'`,
1428
+ ).bind(hash, enc, clientName, label).run();
1429
+ return;
1430
+ }
1431
+ await ctx.DB.prepare(
1432
+ `INSERT INTO peers (label, token_hash, created_at, source, client_name, token_enc) VALUES (?, ?, ?, 'reverse', ?, ?)
1433
+ ON CONFLICT(label) DO UPDATE SET token_hash = excluded.token_hash, source = 'reverse', token_enc = excluded.token_enc,
1434
+ client_name = COALESCE(peers.client_name, excluded.client_name)
1435
+ WHERE peers.revoked_at IS NULL AND peers.source = 'reverse'`,
1436
+ ).bind(label, hash, now, clientName, enc).run();
1437
+ }
1438
+
1439
+ async function announceOne(ctx: TenantContext, ectx: ExecutionContext, alias: string, ident: PairingIdentity, cardUrl: string, reissue = false): Promise<Json> {
1440
+ let peer: { alias: string; url: string; token: string | null };
1441
+ try { peer = await outboundPeer(ctx, alias); }
1442
+ catch (e: any) { return { alias, status: "failed", detail: scrubSecretText(String(e?.message || "no peer")).slice(0, 200) }; }
1443
+ if (!peer.token) return { alias, status: "failed", detail: "This peer has no stored token. Run peers sync after connect." };
1444
+ const plan = await planReverse(ctx, alias);
1445
+ if (plan.kind === "revoked" && !reissue)
1446
+ return { alias, status: "revoked", detail: "The return token was revoked. Run peer announce --to this alias --reissue to mint a new one.", host: Addr.hostOf(peer.url) };
1447
+ const label = plan.kind === "none" ? "" : plan.label;
1448
+ let reverse = "";
1449
+ if (label) reverse = plan.kind === "revoked" ? A.randomToken() : ((await openReverse(ctx, label)) || A.randomToken());
1450
+ const reverseHash = reverse ? await A.sha256(reverse) : "-";
1451
+ const announcementSignature = await Addr.signBytes(ident.privateKey, Addr.updateMessage(cardUrl, ident.publicKey, reverseHash));
1452
+ if (!announcementSignature) return { alias, status: "failed", detail: "This inbox could not sign the announcement.", host: Addr.hostOf(peer.url) };
1453
+ const params: Json = { agentCardUrl: cardUrl, publicKey: ident.publicKey, announcementSignature, graceHours: Addr.DEFAULT_GRACE_HOURS };
1454
+ if (reverse) params.reverseToken = reverse;
1455
+ const host = Addr.hostOf(peer.url);
1456
+ try {
1457
+ const signal = AbortSignal.timeout(7000);
1458
+ const card = await checkedPeerCard(ctx, ectx, peer.url, signal);
1459
+ const [endpoint, version] = M.pickEndpoint(peer.url, card);
1460
+ if (new URL(endpoint).origin !== new URL(peer.url).origin) return { alias, status: "failed", detail: "Peer card endpoint is on a different origin.", host };
1461
+ const v1 = String(version).startsWith("1");
1462
+ const response = await checkedPeerFetch(ctx, ectx, peer.url, endpoint, {
1463
+ method: "POST", signal,
1464
+ headers: {
1465
+ "content-type": "application/json", accept: "application/json", "a2a-version": v1 ? "1.0" : "0.3",
1466
+ authorization: `Bearer ${peer.token}`, "user-agent": "a2a-exposed",
1467
+ },
1468
+ body: JSON.stringify({ jsonrpc: "2.0", id: A.newId(), method: v1 ? "PeerAddressUpdate" : "peer/addressUpdate", params }),
1469
+ });
1470
+ const text = (await response.text()).slice(0, 1 << 20);
1471
+ let body: Json = null;
1472
+ try { body = JSON.parse(text); } catch { body = null; }
1473
+ const outcome = Addr.announceOutcome(response.status, body);
1474
+ if (outcome.status === "updated" && reverse && label) await storeReverse(ctx, label, reverse, alias, plan.kind === "revoked");
1475
+ return { alias, status: outcome.status, detail: scrubSecretText(outcome.detail).slice(0, 200), host };
1476
+ } catch (e: any) {
1477
+ return { alias, status: "failed", detail: scrubSecretText(String(e?.message || "announcement failed")).slice(0, 200), host };
1478
+ }
1479
+ }
1480
+
1481
+ async function saveAnnounce(ctx: TenantContext, results: Json[]): Promise<void> {
1482
+ const row: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.ANNOUNCE_SETTING).first();
1483
+ let pending: Json[] = [];
1484
+ try { pending = JSON.parse(row?.value || "{}").pending || []; } catch { pending = []; }
1485
+ const map = new Map<string, Json>();
1486
+ for (const item of pending) if (item && typeof item.alias === "string") map.set(item.alias, item);
1487
+ const at = A.nowIso();
1488
+ for (const r of results) {
1489
+ if (!r || typeof r.alias !== "string" || r.alias === "*") continue;
1490
+ if (r.status === "updated" || r.status === "revoked") map.delete(r.alias);
1491
+ else map.set(r.alias, { alias: r.alias, detail: scrubSecretText(String(r.detail || "")).slice(0, 200), at });
1492
+ }
1493
+ const next = [...map.values()].slice(0, 100);
1494
+ await ctx.DB.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1495
+ .bind(Addr.ANNOUNCE_SETTING, JSON.stringify({ pending: next })).run();
1496
+ }
1497
+
1498
+ async function announcePeers(ctx: TenantContext, ectx: ExecutionContext, which: { all?: boolean; to?: string; reissue?: boolean }): Promise<Json[]> {
1499
+ const ident = await ensureIdentity(ctx);
1500
+ const cardUrl = Addr.cardUrlFor(ctx.PUBLIC_URL);
1501
+ if (!ident) return [{ alias: which.to || "*", status: "failed", detail: "This inbox has no usable pairing identity." }];
1502
+ if (!cardUrl || peerUrlProblem(cardUrl)) return [{ alias: which.to || "*", status: "failed", detail: "This inbox has no public https address yet." }];
1503
+ let aliases: string[] = [];
1504
+ if (which.all) {
1505
+ const rows = await ctx.DB.prepare("SELECT alias FROM outbound_peers ORDER BY alias").all();
1506
+ aliases = (rows.results as Json[]).map((r) => String(r.alias));
1507
+ } else if (which.to) aliases = [which.to];
1508
+ if (!aliases.length) return [{ alias: which.to || "*", status: "failed", detail: "No synced peer to announce to. Run peers sync after connect." }];
1509
+ const results: Json[] = [];
1510
+ for (const alias of aliases) results.push(await announceOne(ctx, ectx, alias, ident, cardUrl, !!which.reissue && !which.all));
1511
+ await saveAnnounce(ctx, results);
1512
+ return results.map((r) => ({ alias: r.alias, status: r.status, detail: r.detail, ...(r.host ? { host: r.host } : {}) }));
1513
+ }
1514
+
1515
+ async function addressView(ctx: TenantContext): Promise<Json> {
1516
+ const ident = await readIdentity(ctx);
1517
+ const forward = await activeForward(ctx);
1518
+ const pendingRow: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.ANNOUNCE_SETTING).first();
1519
+ let announcePending: Json[] = [];
1520
+ try { announcePending = JSON.parse(pendingRow?.value || "{}").pending || []; } catch { announcePending = []; }
1521
+ const rows = await ctx.DB.prepare(
1522
+ `SELECT p.label, p.client_name FROM peers p
1523
+ LEFT JOIN outbound_peers o ON o.alias = p.label
1524
+ WHERE p.revoked_at IS NULL AND p.source IN ('pairing', 'reverse')
1525
+ AND (p.card_url IS NULL OR p.card_url = '') AND (o.url IS NULL OR o.url = '')
1526
+ ORDER BY p.label`,
1527
+ ).all();
1528
+ return {
1529
+ identity: ident && ident !== "unreadable" ? { fingerprint: await A.fingerprint(ident.publicKey) } : null,
1530
+ forward: forward ? { cardUrl: forward.cardUrl, until: new Date(forward.untilMs).toISOString(), active: true } : null,
1531
+ inboundNoAddress: (rows.results as Json[]).map((r) => ({ label: r.label, clientName: r.client_name || null })),
1532
+ announcePending: announcePending.map((r) => ({ alias: r.alias, detail: r.detail, at: r.at })),
1533
+ };
1153
1534
  }
1154
1535
 
1155
1536
  /** The exact inbound label must still have a live token here and an outbound pairing whose token we can decrypt.
1156
- * A claimed card URL or a tokenless outbound entry is never enough. */
1157
- async function replyPeer(ctx: TenantContext, label: string): Promise<{ alias: string; url: string; token: string } | null> {
1537
+ * A claimed card URL or a tokenless outbound entry is never enough. `identityKey` is set only after a verified
1538
+ * address announcement. It is a public key, and it is what a moved card has to match. */
1539
+ async function replyPeer(ctx: TenantContext, label: string): Promise<{ alias: string; url: string; token: string; identityKey: string; announced: boolean } | null> {
1158
1540
  const row: Json = await ctx.DB.prepare(
1159
- `SELECT o.alias, o.url, o.token_enc FROM outbound_peers o
1541
+ `SELECT o.alias, o.url, o.token_enc, o.source, p.identity_key FROM outbound_peers o
1160
1542
  JOIN peers p ON p.label = o.alias
1161
1543
  WHERE o.alias = ? AND o.token_enc IS NOT NULL AND p.revoked_at IS NULL`,
1162
1544
  ).bind(label).first();
1163
1545
  if (!row) return null;
1164
1546
  const key = await ctx.peerKey();
1165
1547
  const token = key ? await M.openPeerTokenWithKey(key, row.alias, row.token_enc) : null;
1166
- return token ? { alias: row.alias, url: row.url, token } : null;
1548
+ return token ? { alias: row.alias, url: row.url, token, identityKey: Addr.cleanPublicKey(row.identity_key), announced: row.source === "announce" } : null;
1167
1549
  }
1168
1550
 
1169
- async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, task: Json, message: Json, state: string, text: string): Promise<ReplyDelivery> {
1551
+ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, task: Json, message: Json, state: string, text: string, signal: AbortSignal): Promise<ReplyDelivery> {
1170
1552
  if (task._push?.length) {
1171
- const pushed = await notifyPeer(ctx, ectx, task);
1553
+ const pushed = await notifyPeer(ctx, ectx, task, signal);
1172
1554
  const failed = pushed.find((x: Json) => !x.ok);
1173
1555
  const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
1174
1556
  const http = /HTTP (\d{3})/.exec(String((failed || pushed[0])?.info || ""));
@@ -1187,11 +1569,30 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
1187
1569
  status: "stored-only", method: "stored", taskId: task.id, state,
1188
1570
  detail: "Stored on the task for the peer to fetch; no live paired outbound address with a held token was found.",
1189
1571
  };
1190
- const target = safeTarget(peer.alias, peer.url);
1572
+ let target = safeTarget(peer.alias, peer.url);
1191
1573
  try {
1192
- const card = await checkedPeerCard(ctx, ectx, peer.url);
1193
- const [endpoint, version] = M.pickEndpoint(peer.url, card);
1194
- if (new URL(endpoint).origin !== new URL(peer.url).origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
1574
+ const card = await checkedPeerCard(ctx, ectx, peer.url, signal);
1575
+ let base = peer.url;
1576
+ let cardUsed = card;
1577
+ // One extra card fetch, and only when the card we already fetched names a move. The ordinary path stays one card.
1578
+ const moved = peer.identityKey ? Addr.movedFromCard(card) : null;
1579
+ if (moved && Addr.originOf(moved.cardUrl) !== new URL(base).origin) {
1580
+ signal.throwIfAborted();
1581
+ try {
1582
+ const next = await checkedPeerCard(ctx, ectx, Addr.originOf(moved.cardUrl), signal);
1583
+ if (next && peer.announced && await Addr.cardProves(next, peer.identityKey, moved.cardUrl)) {
1584
+ base = Addr.originOf(moved.cardUrl);
1585
+ cardUsed = next;
1586
+ target = safeTarget(peer.alias, base);
1587
+ await ctx.DB.prepare("UPDATE outbound_peers SET url = ?, updated_at = ? WHERE alias = ? AND source = 'announce'").bind(base, A.nowIso(), peer.alias).run();
1588
+ await ctx.DB.prepare("UPDATE peers SET card_url = ? WHERE label = ?").bind(moved.cardUrl, peer.alias).run();
1589
+ }
1590
+ } catch {
1591
+ signal.throwIfAborted();
1592
+ }
1593
+ }
1594
+ const [endpoint, version] = M.pickEndpoint(base, cardUsed);
1595
+ if (new URL(endpoint).origin !== new URL(base).origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
1195
1596
  const v1 = version.startsWith("1");
1196
1597
  const direct: Json = {
1197
1598
  messageId: message.messageId, role: v1 ? "ROLE_USER" : "user", contextId: task.contextId,
@@ -1205,15 +1606,28 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
1205
1606
  "content-type": "application/json", accept: "application/json", "a2a-version": v1 ? "1.0" : "0.3",
1206
1607
  authorization: `Bearer ${peer.token}`,
1207
1608
  };
1208
- const response = await checkedPeerFetch(ctx, ectx, peer.url, endpoint, {
1209
- method: "POST", headers,
1210
- body: JSON.stringify({ jsonrpc: "2.0", id: message.messageId, method: v1 ? "SendMessage" : "message/send",
1211
- params: { message: direct, metadata: directMetadata, configuration: v1 ? { returnImmediately: true } : { blocking: false } } }),
1212
- });
1609
+ const { response, raw } = await withDeadline(7000, async (step) => {
1610
+ const response = await checkedPeerFetch(ctx, ectx, base, endpoint, {
1611
+ method: "POST", headers, signal: step,
1612
+ body: JSON.stringify({ jsonrpc: "2.0", id: message.messageId, method: v1 ? "SendMessage" : "message/send",
1613
+ params: { message: direct, metadata: directMetadata, configuration: v1 ? { returnImmediately: true } : { blocking: false } } }),
1614
+ });
1615
+ return { response, raw: (await response.text()).slice(0, 1 << 20) };
1616
+ }, signal);
1213
1617
  const httpStatus = response.status;
1214
- const raw = (await response.text()).slice(0, 1 << 20);
1215
1618
  let body: Json = null;
1216
1619
  try { body = JSON.parse(raw); } catch { /* failure below */ }
1620
+ const hinted = Addr.canonicalCardUrl(response.headers.get("a2a-moved") || body?.error?.data?.movedTo);
1621
+ if (hinted && peer.identityKey && Addr.originOf(hinted) !== new URL(base).origin) {
1622
+ try {
1623
+ signal.throwIfAborted();
1624
+ const next = await checkedPeerCard(ctx, ectx, Addr.originOf(hinted), signal);
1625
+ if (next && peer.announced && await Addr.cardProves(next, peer.identityKey, hinted)) {
1626
+ await ctx.DB.prepare("UPDATE outbound_peers SET url = ?, updated_at = ? WHERE alias = ? AND source = 'announce'").bind(Addr.originOf(hinted), A.nowIso(), peer.alias).run();
1627
+ await ctx.DB.prepare("UPDATE peers SET card_url = ? WHERE label = ?").bind(hinted, peer.alias).run();
1628
+ }
1629
+ } catch { /* the result below stands; a second send is not attempted */ }
1630
+ }
1217
1631
  if (httpStatus !== 200 || !body || typeof body !== "object" || body.error) return {
1218
1632
  status: "delivery-failed", method: "a2a-message", taskId: task.id, state, ...(target ? { target } : {}), httpStatus,
1219
1633
  detail: "The paired peer did not accept the A2A message; the reply remains stored on the task.",
@@ -1339,22 +1753,31 @@ function changed(res: { meta?: { changes?: number } } | null | undefined): numbe
1339
1753
  return Number(res?.meta?.changes ?? 0);
1340
1754
  }
1341
1755
 
1756
+ /** Outbound rows whose peer label is still active. A row with no inbound label stays eligible, because send does not require one. */
1757
+ const LIVE_OUTBOUND = "SELECT o.* FROM outbound o LEFT JOIN peers p ON p.label = o.peer WHERE p.revoked_at IS NULL";
1758
+
1342
1759
  /** The outbound row this push may update. A callback that arrives before SendMessage returns matches the
1343
1760
  * pending row by token hash and claims the peer's task id. A row already stored under that id must
1344
- * present the same hash. */
1761
+ * present the same hash. A revoked peer matches neither, so the handler returns 401 and stores nothing. */
1345
1762
  async function outboundForPush(ctx: TenantContext, taskId: string, givenHash: string): Promise<Json | null> {
1346
1763
  if (!givenHash) return null;
1347
- const rec: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
1348
- if (rec) return rec.push_token_hash && A.timingSafeEqualStr(rec.push_token_hash, givenHash) ? rec : null;
1349
- const pending: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE push_token_hash = ? AND task_id LIKE 'pend.%'").bind(givenHash).first();
1350
- if (!pending?.push_token_hash || !A.timingSafeEqualStr(pending.push_token_hash, givenHash)) return null;
1764
+ const same = (row: Json | null) => row?.push_token_hash && A.timingSafeEqualStr(row.push_token_hash, givenHash) ? row : null;
1765
+ const rec: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
1766
+ if (rec) return same(rec);
1767
+ const pending: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.push_token_hash = ? AND o.task_id LIKE 'pend.%'`).bind(givenHash).first();
1768
+ if (!same(pending)) return null;
1351
1769
  try {
1352
- const renamed = await ctx.DB.prepare("UPDATE outbound SET task_id = ? WHERE task_id = ? AND push_token_hash = ?")
1353
- .bind(taskId, pending.task_id, givenHash).run();
1354
- if (changed(renamed) === 1) return { ...pending, task_id: taskId };
1770
+ const renamed = await ctx.DB.prepare(
1771
+ `UPDATE outbound SET task_id = ? WHERE task_id = ? AND push_token_hash = ?
1772
+ AND NOT EXISTS (SELECT 1 FROM peers WHERE label = outbound.peer AND revoked_at IS NOT NULL)`,
1773
+ ).bind(taskId, pending.task_id, givenHash).run();
1774
+ if (changed(renamed) === 1) {
1775
+ const claimed: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
1776
+ return same(claimed);
1777
+ }
1355
1778
  } catch { /* the send path already stored this task id */ }
1356
- const now: Json = await ctx.DB.prepare("SELECT * FROM outbound WHERE task_id = ?").bind(taskId).first();
1357
- return now?.push_token_hash && A.timingSafeEqualStr(now.push_token_hash, givenHash) ? now : null;
1779
+ const now: Json = await ctx.DB.prepare(`${LIVE_OUTBOUND} AND o.task_id = ?`).bind(taskId).first();
1780
+ return same(now);
1358
1781
  }
1359
1782
 
1360
1783
  /** Move a pending push row onto the peer's task id without dropping an update that already arrived. */
@@ -1582,6 +2005,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1582
2005
  .bind(deliveryKey, previous.updated_at).run();
1583
2006
  previous = null;
1584
2007
  }
2008
+ if (previous) return json({ error: "this reply is already being delivered; retry to read its result" }, 409);
1585
2009
  if (A.TERMINAL.has(t.status.state) && !b.force) return json({ error: `task is already ${t.status.state}` }, 409);
1586
2010
  const msg = { kind: "message", role: "agent", messageId: A.newId(), contextId: t.contextId, taskId: t.id, parts: [{ kind: "text", text: b.text }] };
1587
2011
  const now = A.nowIso();
@@ -1600,13 +2024,18 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1600
2024
  histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text })]);
1601
2025
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
1602
2026
  let delivery: ReplyDelivery;
1603
- try { delivery = await deliverReplyMessage(ctx, ectx, t, msg, state, b.text); }
2027
+ try { delivery = await withDeadline(18000, (signal) => deliverReplyMessage(ctx, ectx, t, msg, state, b.text, signal)); }
1604
2028
  catch {
1605
2029
  delivery = { status: "delivery-failed", method: t._push?.length ? "push" : "a2a-message", taskId: t.id, state,
1606
2030
  detail: "Delivery could not be completed; the reply remains stored on the task." };
1607
2031
  }
1608
- await ctx.DB.prepare("UPDATE reply_deliveries SET result_json = ?, updated_at = ? WHERE delivery_key = ?")
2032
+ const finalized = await ctx.DB.prepare("UPDATE reply_deliveries SET result_json = ?, updated_at = ? WHERE delivery_key = ? AND result_json IS NULL")
1609
2033
  .bind(JSON.stringify(delivery), A.nowIso(), deliveryKey).run();
2034
+ if (finalized.meta.changes !== 1) {
2035
+ const settled: Json = await ctx.DB.prepare("SELECT result_json FROM reply_deliveries WHERE delivery_key = ?").bind(deliveryKey).first();
2036
+ if (!settled?.result_json) return json({ error: "reply is stored but its delivery result is unavailable; read history before retrying" }, 409);
2037
+ delivery = JSON.parse(settled.result_json);
2038
+ }
1610
2039
  return json({ task: ownerTaskView(t), delivery, idempotent: false });
1611
2040
  }
1612
2041
  }
@@ -1672,10 +2101,53 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1672
2101
  return json({ ok: true });
1673
2102
  }
1674
2103
  }
2104
+ if (seg[0] === "identity" && !seg[1] && m === "GET") {
2105
+ const ident = await readIdentity(ctx);
2106
+ if (!ident || ident === "unreadable") return json({ publicKey: null, fingerprint: null });
2107
+ return json({ publicKey: ident.publicKey, fingerprint: await A.fingerprint(ident.publicKey) });
2108
+ }
2109
+ if (seg[0] === "identity" && !seg[1] && m === "PUT") {
2110
+ const b = await body();
2111
+ const installed = await installIdentity(ctx, b.publicKey, b.privateKey);
2112
+ if (!installed.ok) return json({ error: installed.error }, installed.status);
2113
+ return json({ publicKey: installed.publicKey, fingerprint: installed.fingerprint });
2114
+ }
2115
+ if (seg[0] === "address" && !seg[1] && m === "GET") return json(await addressView(ctx));
2116
+ if (seg[0] === "address-forward" && !seg[1] && m === "DELETE") {
2117
+ await ctx.DB.prepare("DELETE FROM settings WHERE key = ?").bind(Addr.FORWARD_SETTING).run();
2118
+ log("address_forward_cleared", {});
2119
+ return json({ ok: true });
2120
+ }
2121
+ if (seg[0] === "address-forward" && !seg[1] && m === "PUT") {
2122
+ const b = await body();
2123
+ const cardUrl = Addr.canonicalCardUrl(b.cardUrl);
2124
+ const hours = Addr.graceHours(b.hours);
2125
+ if (!cardUrl || peerUrlProblem(cardUrl)) return json({ error: "cardUrl must be the public https URL of an agent card" }, 400);
2126
+ if (hours === null) return json({ error: "hours must be a whole number from 1 to 720" }, 400);
2127
+ let own = "";
2128
+ try { own = new URL(ctx.PUBLIC_URL).origin; } catch { own = ""; }
2129
+ if (own && Addr.originOf(cardUrl) === own) return json({ error: "cardUrl is this inbox" }, 400);
2130
+ const untilMs = Date.now() + hours * 3600 * 1000;
2131
+ await ctx.DB.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
2132
+ .bind(Addr.FORWARD_SETTING, JSON.stringify({ cardUrl, untilMs })).run();
2133
+ log("address_forward_set", { host: Addr.hostOf(cardUrl), until: new Date(untilMs).toISOString() });
2134
+ return json({ ok: true, cardUrl, until: new Date(untilMs).toISOString() });
2135
+ }
2136
+ if (seg[0] === "peers" && seg[1] === "announce" && !seg[2] && m === "POST") {
2137
+ const b = await body();
2138
+ const all = b.all === true || b.all === "true";
2139
+ const to = typeof b.to === "string" ? b.to : "";
2140
+ const reissue = b.reissue === true || b.reissue === "true";
2141
+ if (all === !!to) return json({ error: "pass to (one alias) or all: true" }, 400);
2142
+ if (reissue && (all || !to)) return json({ error: "reissue needs one alias" }, 400);
2143
+ if (to && !/^[A-Za-z0-9_-]{1,64}$/.test(to)) return json({ error: "to is invalid" }, 400);
2144
+ const results = await announcePeers(ctx, ectx, all ? { all: true } : { to, reissue });
2145
+ return json({ results });
2146
+ }
1675
2147
  if (seg[0] === "peers") {
1676
2148
  if (m === "GET") {
1677
- const rows = await ctx.DB.prepare("SELECT label, created_at, revoked_at, source, user_code, client_name FROM peers ORDER BY created_at").all();
1678
- return json((rows.results as Json[]).map((r) => ({ ...r, user_code: r.user_code ? P.formatUserCode(r.user_code) : null })));
2149
+ const rows = await ctx.DB.prepare("SELECT label, created_at, revoked_at, source, user_code, client_name, card_url FROM peers ORDER BY created_at").all();
2150
+ return json((rows.results as Json[]).map((r) => ({ label: r.label, created_at: r.created_at, revoked_at: r.revoked_at, source: r.source, user_code: r.user_code ? P.formatUserCode(r.user_code) : null, client_name: r.client_name, card_url: r.card_url || null })));
1679
2151
  }
1680
2152
  if (m === "POST" && !seg[1]) {
1681
2153
  const b = await body();
@@ -1685,14 +2157,26 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1685
2157
  if (!ex && b.mustExist) return json({ error: `no token with label ${b.label} (see token list; \`token issue ${b.label}\` creates one)` }, 404);
1686
2158
  const token = A.randomToken();
1687
2159
  await ctx.DB.prepare(
1688
- "INSERT INTO peers (label, token_hash, created_at) VALUES (?, ?, ?) ON CONFLICT(label) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at, revoked_at = NULL",
2160
+ "INSERT INTO peers (label, token_hash, created_at) VALUES (?, ?, ?) ON CONFLICT(label) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at, revoked_at = NULL, token_enc = NULL",
1689
2161
  ).bind(b.label, await A.sha256(token), A.nowIso()).run();
1690
2162
  return json({ label: b.label, token, card: ctx.PUBLIC_URL + "/.well-known/agent-card.json" });
1691
2163
  }
1692
2164
  if (m === "DELETE" && seg[1]) {
1693
2165
  const label = decodeURIComponent(seg[1]);
1694
- const r = await ctx.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(A.nowIso(), label).run();
1695
- if (r.meta.changes !== 1) {
2166
+ const now = A.nowIso();
2167
+ // Drop push hashes only while this label is still active, in the same transaction as the revoke.
2168
+ // An unknown or already-revoked label leaves every outbound row alone, including an alias of the same name.
2169
+ const [, revoked] = await ctx.DB.batch([
2170
+ ctx.DB.prepare(
2171
+ `UPDATE outbound SET push_token_hash = NULL
2172
+ WHERE peer = ? AND EXISTS (SELECT 1 FROM peers WHERE label = ? AND revoked_at IS NULL)`,
2173
+ ).bind(label, label),
2174
+ ctx.DB.prepare("UPDATE peers SET revoked_at = ?, token_enc = NULL WHERE label = ? AND revoked_at IS NULL").bind(now, label),
2175
+ // Drop only the callback this announcement created. An owner-synced alias of the same name stays.
2176
+ // The other inbox still accepts the return token until that owner revokes it.
2177
+ ctx.DB.prepare("DELETE FROM outbound_peers WHERE alias = ? AND source = 'announce'").bind(label),
2178
+ ]);
2179
+ if (changed(revoked) !== 1) {
1696
2180
  const ex: Json = await ctx.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(label).first();
1697
2181
  return json({ revoked: false, error: ex ? `the token with label ${label} was already revoked (${ex.revoked_at})` : `no active token with label ${label} (see token list)` }, 404);
1698
2182
  }
@@ -1701,7 +2185,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1701
2185
  }
1702
2186
  }
1703
2187
  if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
1704
- if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working")) {
2188
+ if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address")) {
1705
2189
  const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
1706
2190
  if (!checked.ok) return json({ error: checked.error }, 400);
1707
2191
  try {
@@ -1741,7 +2225,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1741
2225
  if (b.token && !peerKey) return json({ error: ctx.mode === "host" ? "this agent has no secret key to encrypt peer tokens with" : "the Worker has no OWNER_TOKEN to encrypt peer tokens with" }, 409);
1742
2226
  const enc = b.token ? await M.sealPeerTokenWithKey(peerKey!, alias, b.token) : null;
1743
2227
  const now = A.nowIso();
1744
- await ctx.DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(alias) DO UPDATE SET url = excluded.url, token_enc = excluded.token_enc, updated_at = excluded.updated_at")
2228
+ await ctx.DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(alias) DO UPDATE SET url = excluded.url, token_enc = excluded.token_enc, source = NULL, updated_at = excluded.updated_at")
1745
2229
  .bind(alias, String(b.url).replace(/\/$/, ""), enc, now, now).run();
1746
2230
  log("outbound_peer_synced", { alias, hasToken: !!enc });
1747
2231
  return json({ ok: true, alias, hasToken: !!enc });
@@ -1915,6 +2399,8 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1915
2399
  const clientName = P.cleanText(p.client_name), clientId = P.cleanText(p.client_id);
1916
2400
  const cardUrl = P.cleanCardUrl(p.agent_card_url);
1917
2401
  if (p.agent_card_url && !cardUrl) return oauthError("invalid_request", "agent_card_url must be an https URL (at most 300 characters)");
2402
+ const identity = Addr.cleanPublicKey(p.agent_identity);
2403
+ if (p.agent_identity && !identity) return oauthError("invalid_request", "agent_identity must be a base64url Ed25519 public key");
1918
2404
  await ctx.DB.batch(pairingCleanup(ctx, now));
1919
2405
  // flood control: approval prompts (wakes, pending requests) must stay few
1920
2406
  const retry = { "retry-after": "600" };
@@ -1934,9 +2420,9 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1934
2420
  for (let i = 0; i < 5 && !userCode; i++) {
1935
2421
  const c = P.newUserCode();
1936
2422
  const r = await ctx.DB.prepare(
1937
- `INSERT INTO device_requests (device_hash, user_code, client_id, client_name, agent_card_url, ip, country, status, created_ms, expires_ms, interval_s)
1938
- VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
1939
- ).bind(deviceHash, c, clientId || null, clientName || null, cardUrl || null, ip || null, country || null,
2423
+ `INSERT INTO device_requests (device_hash, user_code, client_id, client_name, agent_card_url, identity_key, ip, country, status, created_ms, expires_ms, interval_s)
2424
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
2425
+ ).bind(deviceHash, c, clientId || null, clientName || null, cardUrl || null, identity || null, ip || null, country || null,
1940
2426
  now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
1941
2427
  if (r.meta.changes === 1) userCode = c;
1942
2428
  }
@@ -1956,9 +2442,11 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1956
2442
  const manage = dashboardUrl(ctx);
1957
2443
  ectx.waitUntil((async () => {
1958
2444
  if (!(await wakeBudgetOk(ctx))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
2445
+ const identityFingerprint = identity ? await A.fingerprint(identity) : null;
1959
2446
  await sendWake(ctx, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
1960
2447
  kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
1961
- ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}), expiresIn: P.EXPIRES_S,
2448
+ ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}),
2449
+ ...(identityFingerprint ? { identityFingerprint } : {}), expiresIn: P.EXPIRES_S,
1962
2450
  ...(manage ? { manageUrl: manage } : {}),
1963
2451
  ...(replaces ? { replacesLabel: replaces.label } : {}) } });
1964
2452
  })());
@@ -2001,8 +2489,8 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
2001
2489
  // re-pairing (approved as a replacement): the new token takes over the label only if the presented token is still the
2002
2490
  // one on that label (hash match). A rotate or revoke in between leaves u.meta.changes = 0.
2003
2491
  if (r.replaces_label && r.replaces_hash && r.label === r.replaces_label) {
2004
- const u = await ctx.DB.prepare("UPDATE peers SET token_hash = ?, created_at = ?, source = 'pairing', user_code = ?, client_name = ? WHERE label = ? AND token_hash = ? AND revoked_at IS NULL")
2005
- .bind(await A.sha256(token), A.nowIso(), r.user_code, r.client_name || r.client_id || null, r.replaces_label, r.replaces_hash).run();
2492
+ const u = await ctx.DB.prepare("UPDATE peers SET token_hash = ?, created_at = ?, source = 'pairing', user_code = ?, client_name = ?, identity_key = COALESCE(?, identity_key) WHERE label = ? AND token_hash = ? AND revoked_at IS NULL")
2493
+ .bind(await A.sha256(token), A.nowIso(), r.user_code, r.client_name || r.client_id || null, r.identity_key || null, r.replaces_label, r.replaces_hash).run();
2006
2494
  if (u.meta.changes === 1) {
2007
2495
  await ctx.DB.prepare("DELETE FROM device_requests WHERE device_hash = ?").bind(hash).run();
2008
2496
  log("pairing_redeemed", { ip, label: r.replaces_label, userCode: P.formatUserCode(r.user_code), replaced: true });
@@ -2014,8 +2502,8 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
2014
2502
  : r.label ? await freeLabel(ctx, P.labelBase(r.client_name || "", r.client_id || "")) : P.labelBase(r.client_name || "", r.client_id || "");
2015
2503
  for (let i = 0; i < 5; i++) {
2016
2504
  try {
2017
- await ctx.DB.prepare("INSERT INTO peers (label, token_hash, created_at, source, user_code, client_name) VALUES (?, ?, ?, 'pairing', ?, ?)")
2018
- .bind(label, await A.sha256(token), A.nowIso(), r.user_code, r.client_name || r.client_id || null).run();
2505
+ await ctx.DB.prepare("INSERT INTO peers (label, token_hash, created_at, source, user_code, client_name, identity_key) VALUES (?, ?, ?, 'pairing', ?, ?, ?)")
2506
+ .bind(label, await A.sha256(token), A.nowIso(), r.user_code, r.client_name || r.client_id || null, r.identity_key || null).run();
2019
2507
  break;
2020
2508
  } catch (e) {
2021
2509
  if (i === 4) throw e;
@@ -2030,7 +2518,8 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
2030
2518
  async function freeLabel(ctx: TenantContext, base: string): Promise<string> {
2031
2519
  const rows = await ctx.DB.prepare("SELECT label FROM peers WHERE label = ? OR label LIKE ?").bind(base, `${base}-%`).all();
2032
2520
  const pend = await ctx.DB.prepare("SELECT label FROM device_requests WHERE label IS NOT NULL").all();
2033
- return P.dedupeLabel(base, new Set([...(rows.results as Json[]), ...(pend.results as Json[])].map((x) => x.label)));
2521
+ const outbound = await ctx.DB.prepare("SELECT alias AS label FROM outbound_peers").all();
2522
+ return P.dedupeLabel(base, new Set([...(rows.results as Json[]), ...(pend.results as Json[]), ...(outbound.results as Json[])].map((x) => x.label)));
2034
2523
  }
2035
2524
 
2036
2525
  const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json | null> =>
@@ -2099,6 +2588,8 @@ const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, client
2099
2588
  /** The label approval will assign: the live token being replaced, otherwise the next free label. */
2100
2589
  async function withTokenLabel(ctx: TenantContext, r: Json): Promise<P.PageRequest> {
2101
2590
  const base = pageRequest(r);
2591
+ const identityFingerprint = r.identity_key ? await A.fingerprint(String(r.identity_key)) : null;
2592
+ if (identityFingerprint) base.identityFingerprint = identityFingerprint;
2102
2593
  if (r.replaces_label && r.replaces_hash) {
2103
2594
  const live = await ctx.DB.prepare("SELECT 1 FROM peers WHERE label = ? AND token_hash = ? AND revoked_at IS NULL").bind(r.replaces_label, r.replaces_hash).first();
2104
2595
  if (live) return { ...base, tokenLabel: String(r.replaces_label) };
@@ -2413,10 +2904,11 @@ async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionCon
2413
2904
  oidcIssuer: policy.oidc ? O.issuerHost(ctx.gates.approvalOidc.issuer) : null,
2414
2905
  ...(manage ? { manageUrl: manage } : {}),
2415
2906
  setupLinkExpiresAt: linkLive ? new Date(setup!.rec.expiresMs).toISOString() : null, verificationUri: oauthUrls(ctx).page,
2416
- pending: pending.map((r) => ({ userCode: P.formatUserCode(r.user_code), clientName: r.client_name, clientId: r.client_id,
2907
+ pending: await Promise.all(pending.map(async (r) => ({ userCode: P.formatUserCode(r.user_code), clientName: r.client_name, clientId: r.client_id,
2417
2908
  agentCardUrl: r.agent_card_url, ip: r.ip, country: r.country, createdAt: new Date(r.created_ms).toISOString(),
2418
2909
  expiresAt: new Date(r.expires_ms).toISOString(), verificationUriComplete: `${oauthUrls(ctx).page}?user_code=${P.formatUserCode(r.user_code)}`,
2419
- replacesLabel: r.replaces_label || null })) });
2910
+ replacesLabel: r.replaces_label || null,
2911
+ ...(r.identity_key ? { identityFingerprint: await A.fingerprint(String(r.identity_key)) } : {}) }))) });
2420
2912
  }
2421
2913
  if (seg[0] === "password-link" && m === "POST" && !seg[1]) {
2422
2914
  if (mode === "off" && !mcpOn(ctx)) return json({ error: "device-flow pairing is disabled (PAIRING_APPROVAL=off) and so is MCP: there is nothing to approve, so no approval password is needed" }, 409);
@@ -2471,7 +2963,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionCon
2471
2963
  async function landing(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
2472
2964
  const accept = req.headers.get("accept") || "";
2473
2965
  const html = url.searchParams.get("format") !== "json" && /\btext\/html\b/i.test(accept);
2474
- const card: any = await agentCard(ctx);
2966
+ const card: any = await publicCard(ctx);
2475
2967
  const cardUrl = ctx.PUBLIC_URL + "/.well-known/agent-card.json";
2476
2968
  if (!html) return json({ name: card.name, description: card.description, agentCard: cardUrl, a2a: ctx.PUBLIC_URL + "/",
2477
2969
  pairing: pairingOn(ctx) ? ctx.PUBLIC_URL + "/device" : undefined }, 200, { vary: "accept" });
@@ -3221,9 +3713,9 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
3221
3713
  case "list_peers": {
3222
3714
  const outRows = must(await ownerCall(ctx, ectx, "GET", "/owner/outbound-peers"));
3223
3715
  const inRows = must(await ownerCall(ctx, ectx, "GET", "/owner/peers")).filter((r: Json) => !r.revoked_at);
3224
- return M.toolText(`Outbound peers (send works with these) and inbound labels (agents holding a token for this A2A endpoint). Names are claimed by the peers.`,
3716
+ return M.toolText(`Outbound peers (send works with these) and inbound labels (agents holding a token for this A2A endpoint). Names are claimed by the peers. A paired peer with no card URL has no callback yet: replies stay on the task until that peer announces an address.`,
3225
3717
  { outbound: outRows.map((r: Json) => ({ alias: r.alias, url: r.url, hasToken: r.has_token })),
3226
- inbound: inRows.map((r: Json) => ({ label: r.label, source: r.source, clientName: r.client_name, since: r.created_at })) });
3718
+ inbound: inRows.map((r: Json) => ({ label: r.label, source: r.source, clientName: r.client_name, since: r.created_at, cardUrl: r.card_url || null })) });
3227
3719
  }
3228
3720
  case "pairing_requests": {
3229
3721
  if (!pairingOn(ctx)) return "Device-flow pairing is off on this A2A endpoint (PAIRING_APPROVAL=off): there are no pairing requests.";
@@ -3256,6 +3748,16 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
3256
3748
  });
3257
3749
  return Fb.feedbackResultText(result);
3258
3750
  }
3751
+ case "announce_address": {
3752
+ const to = argStr(a, "to", false);
3753
+ const all = a.all === true || a.all === "true";
3754
+ const reissue = a.reissue === true || a.reissue === "true";
3755
+ if (!!to === all) throw new ToolError("Pass to (one alias) or all: true.");
3756
+ if (reissue && (all || !to)) throw new ToolError("reissue needs one alias.");
3757
+ if (to && !/^[A-Za-z0-9_-]{1,64}$/.test(to)) throw new ToolError("to is invalid");
3758
+ const results = await announcePeers(ctx, ectx, all ? { all: true } : { to, reissue });
3759
+ return M.toolText(`Address announcement for this inbox. A peer that does not support it keeps the current pairing. Tokens are not included. ${M.RULES}`, { results });
3760
+ }
3259
3761
  }
3260
3762
  throw new ToolError(`unknown tool ${name}`);
3261
3763
  }
@@ -3401,10 +3903,15 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
3401
3903
  const path = url.pathname;
3402
3904
  try {
3403
3905
  if (req.method === "GET" && path === "/.well-known/agent-card.json")
3404
- return json(await agentCard(ctx), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
3906
+ return json(await publicCard(ctx), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
3907
+ if (req.method === "GET" && path === "/.well-known/a2a-moved.json") {
3908
+ const forward = await activeForward(ctx);
3909
+ if (!forward) return json({ error: "not found" }, 404);
3910
+ return json({ cardUrl: forward.cardUrl, until: new Date(forward.untilMs).toISOString() }, 200, { "cache-control": "no-store" });
3911
+ }
3405
3912
  // legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
3406
3913
  if (req.method === "GET" && path === "/.well-known/agent.json")
3407
- return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
3914
+ return json(agentCard03(ctx, await publicCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
3408
3915
  if (req.method === "GET" && path === "/health") return json({ ok: true, version: VERSION, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
3409
3916
  if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this A2A endpoint (MCP=off)" }, 404);
3410
3917
  if (mcpOn(ctx)) {
@@ -3428,7 +3935,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
3428
3935
  if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
3429
3936
  return json({ error: "not found" }, 404);
3430
3937
  }
3431
- if (req.method === "POST" && (path === "/" || path === "/a2a" || path === "/a2a/v1")) return await handleRpc(req, ctx, ectx);
3938
+ if (req.method === "POST" && (path === "/" || path === "/a2a" || path === "/a2a/v1")) return await withMovedHeader(ctx, await handleRpc(req, ctx, ectx));
3432
3939
  if (req.method === "POST" && path === "/push") return await handlePush(req, ctx, ectx);
3433
3940
  if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, ctx, ectx, path, url, false, head);
3434
3941
  if ((req.method === "GET" || req.method === "HEAD") && path === "/") return await landing(req, ctx, url);