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.
@@ -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.",
@@ -1591,6 +2005,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1591
2005
  .bind(deliveryKey, previous.updated_at).run();
1592
2006
  previous = null;
1593
2007
  }
2008
+ if (previous) return json({ error: "this reply is already being delivered; retry to read its result" }, 409);
1594
2009
  if (A.TERMINAL.has(t.status.state) && !b.force) return json({ error: `task is already ${t.status.state}` }, 409);
1595
2010
  const msg = { kind: "message", role: "agent", messageId: A.newId(), contextId: t.contextId, taskId: t.id, parts: [{ kind: "text", text: b.text }] };
1596
2011
  const now = A.nowIso();
@@ -1609,13 +2024,18 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1609
2024
  histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text })]);
1610
2025
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
1611
2026
  let delivery: ReplyDelivery;
1612
- 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)); }
1613
2028
  catch {
1614
2029
  delivery = { status: "delivery-failed", method: t._push?.length ? "push" : "a2a-message", taskId: t.id, state,
1615
2030
  detail: "Delivery could not be completed; the reply remains stored on the task." };
1616
2031
  }
1617
- 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")
1618
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
+ }
1619
2039
  return json({ task: ownerTaskView(t), delivery, idempotent: false });
1620
2040
  }
1621
2041
  }
@@ -1681,10 +2101,53 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1681
2101
  return json({ ok: true });
1682
2102
  }
1683
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
+ }
1684
2147
  if (seg[0] === "peers") {
1685
2148
  if (m === "GET") {
1686
- const rows = await ctx.DB.prepare("SELECT label, created_at, revoked_at, source, user_code, client_name FROM peers ORDER BY created_at").all();
1687
- 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 })));
1688
2151
  }
1689
2152
  if (m === "POST" && !seg[1]) {
1690
2153
  const b = await body();
@@ -1694,7 +2157,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1694
2157
  if (!ex && b.mustExist) return json({ error: `no token with label ${b.label} (see token list; \`token issue ${b.label}\` creates one)` }, 404);
1695
2158
  const token = A.randomToken();
1696
2159
  await ctx.DB.prepare(
1697
- "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",
1698
2161
  ).bind(b.label, await A.sha256(token), A.nowIso()).run();
1699
2162
  return json({ label: b.label, token, card: ctx.PUBLIC_URL + "/.well-known/agent-card.json" });
1700
2163
  }
@@ -1708,7 +2171,10 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1708
2171
  `UPDATE outbound SET push_token_hash = NULL
1709
2172
  WHERE peer = ? AND EXISTS (SELECT 1 FROM peers WHERE label = ? AND revoked_at IS NULL)`,
1710
2173
  ).bind(label, label),
1711
- ctx.DB.prepare("UPDATE peers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL").bind(now, 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),
1712
2178
  ]);
1713
2179
  if (changed(revoked) !== 1) {
1714
2180
  const ex: Json = await ctx.DB.prepare("SELECT revoked_at FROM peers WHERE label = ?").bind(label).first();
@@ -1719,7 +2185,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1719
2185
  }
1720
2186
  }
1721
2187
  if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
1722
- 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")) {
1723
2189
  const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
1724
2190
  if (!checked.ok) return json({ error: checked.error }, 400);
1725
2191
  try {
@@ -1759,7 +2225,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
1759
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);
1760
2226
  const enc = b.token ? await M.sealPeerTokenWithKey(peerKey!, alias, b.token) : null;
1761
2227
  const now = A.nowIso();
1762
- 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")
1763
2229
  .bind(alias, String(b.url).replace(/\/$/, ""), enc, now, now).run();
1764
2230
  log("outbound_peer_synced", { alias, hasToken: !!enc });
1765
2231
  return json({ ok: true, alias, hasToken: !!enc });
@@ -1933,6 +2399,8 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1933
2399
  const clientName = P.cleanText(p.client_name), clientId = P.cleanText(p.client_id);
1934
2400
  const cardUrl = P.cleanCardUrl(p.agent_card_url);
1935
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");
1936
2404
  await ctx.DB.batch(pairingCleanup(ctx, now));
1937
2405
  // flood control: approval prompts (wakes, pending requests) must stay few
1938
2406
  const retry = { "retry-after": "600" };
@@ -1952,9 +2420,9 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1952
2420
  for (let i = 0; i < 5 && !userCode; i++) {
1953
2421
  const c = P.newUserCode();
1954
2422
  const r = await ctx.DB.prepare(
1955
- `INSERT INTO device_requests (device_hash, user_code, client_id, client_name, agent_card_url, ip, country, status, created_ms, expires_ms, interval_s)
1956
- VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
1957
- ).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,
1958
2426
  now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
1959
2427
  if (r.meta.changes === 1) userCode = c;
1960
2428
  }
@@ -1974,9 +2442,11 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
1974
2442
  const manage = dashboardUrl(ctx);
1975
2443
  ectx.waitUntil((async () => {
1976
2444
  if (!(await wakeBudgetOk(ctx))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
2445
+ const identityFingerprint = identity ? await A.fingerprint(identity) : null;
1977
2446
  await sendWake(ctx, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
1978
2447
  kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
1979
- ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}), expiresIn: P.EXPIRES_S,
2448
+ ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}),
2449
+ ...(identityFingerprint ? { identityFingerprint } : {}), expiresIn: P.EXPIRES_S,
1980
2450
  ...(manage ? { manageUrl: manage } : {}),
1981
2451
  ...(replaces ? { replacesLabel: replaces.label } : {}) } });
1982
2452
  })());
@@ -2019,8 +2489,8 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
2019
2489
  // re-pairing (approved as a replacement): the new token takes over the label only if the presented token is still the
2020
2490
  // one on that label (hash match). A rotate or revoke in between leaves u.meta.changes = 0.
2021
2491
  if (r.replaces_label && r.replaces_hash && r.label === r.replaces_label) {
2022
- 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")
2023
- .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();
2024
2494
  if (u.meta.changes === 1) {
2025
2495
  await ctx.DB.prepare("DELETE FROM device_requests WHERE device_hash = ?").bind(hash).run();
2026
2496
  log("pairing_redeemed", { ip, label: r.replaces_label, userCode: P.formatUserCode(r.user_code), replaced: true });
@@ -2032,8 +2502,8 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
2032
2502
  : r.label ? await freeLabel(ctx, P.labelBase(r.client_name || "", r.client_id || "")) : P.labelBase(r.client_name || "", r.client_id || "");
2033
2503
  for (let i = 0; i < 5; i++) {
2034
2504
  try {
2035
- await ctx.DB.prepare("INSERT INTO peers (label, token_hash, created_at, source, user_code, client_name) VALUES (?, ?, ?, 'pairing', ?, ?)")
2036
- .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();
2037
2507
  break;
2038
2508
  } catch (e) {
2039
2509
  if (i === 4) throw e;
@@ -2048,7 +2518,8 @@ async function tokenEndpoint(req: Request, ctx: TenantContext): Promise<Response
2048
2518
  async function freeLabel(ctx: TenantContext, base: string): Promise<string> {
2049
2519
  const rows = await ctx.DB.prepare("SELECT label FROM peers WHERE label = ? OR label LIKE ?").bind(base, `${base}-%`).all();
2050
2520
  const pend = await ctx.DB.prepare("SELECT label FROM device_requests WHERE label IS NOT NULL").all();
2051
- 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)));
2052
2523
  }
2053
2524
 
2054
2525
  const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json | null> =>
@@ -2117,6 +2588,8 @@ const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, client
2117
2588
  /** The label approval will assign: the live token being replaced, otherwise the next free label. */
2118
2589
  async function withTokenLabel(ctx: TenantContext, r: Json): Promise<P.PageRequest> {
2119
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;
2120
2593
  if (r.replaces_label && r.replaces_hash) {
2121
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();
2122
2595
  if (live) return { ...base, tokenLabel: String(r.replaces_label) };
@@ -2431,10 +2904,11 @@ async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionCon
2431
2904
  oidcIssuer: policy.oidc ? O.issuerHost(ctx.gates.approvalOidc.issuer) : null,
2432
2905
  ...(manage ? { manageUrl: manage } : {}),
2433
2906
  setupLinkExpiresAt: linkLive ? new Date(setup!.rec.expiresMs).toISOString() : null, verificationUri: oauthUrls(ctx).page,
2434
- 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,
2435
2908
  agentCardUrl: r.agent_card_url, ip: r.ip, country: r.country, createdAt: new Date(r.created_ms).toISOString(),
2436
2909
  expiresAt: new Date(r.expires_ms).toISOString(), verificationUriComplete: `${oauthUrls(ctx).page}?user_code=${P.formatUserCode(r.user_code)}`,
2437
- replacesLabel: r.replaces_label || null })) });
2910
+ replacesLabel: r.replaces_label || null,
2911
+ ...(r.identity_key ? { identityFingerprint: await A.fingerprint(String(r.identity_key)) } : {}) }))) });
2438
2912
  }
2439
2913
  if (seg[0] === "password-link" && m === "POST" && !seg[1]) {
2440
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);
@@ -2489,7 +2963,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionCon
2489
2963
  async function landing(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
2490
2964
  const accept = req.headers.get("accept") || "";
2491
2965
  const html = url.searchParams.get("format") !== "json" && /\btext\/html\b/i.test(accept);
2492
- const card: any = await agentCard(ctx);
2966
+ const card: any = await publicCard(ctx);
2493
2967
  const cardUrl = ctx.PUBLIC_URL + "/.well-known/agent-card.json";
2494
2968
  if (!html) return json({ name: card.name, description: card.description, agentCard: cardUrl, a2a: ctx.PUBLIC_URL + "/",
2495
2969
  pairing: pairingOn(ctx) ? ctx.PUBLIC_URL + "/device" : undefined }, 200, { vary: "accept" });
@@ -3239,9 +3713,9 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
3239
3713
  case "list_peers": {
3240
3714
  const outRows = must(await ownerCall(ctx, ectx, "GET", "/owner/outbound-peers"));
3241
3715
  const inRows = must(await ownerCall(ctx, ectx, "GET", "/owner/peers")).filter((r: Json) => !r.revoked_at);
3242
- 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.`,
3243
3717
  { outbound: outRows.map((r: Json) => ({ alias: r.alias, url: r.url, hasToken: r.has_token })),
3244
- 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 })) });
3245
3719
  }
3246
3720
  case "pairing_requests": {
3247
3721
  if (!pairingOn(ctx)) return "Device-flow pairing is off on this A2A endpoint (PAIRING_APPROVAL=off): there are no pairing requests.";
@@ -3274,6 +3748,16 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
3274
3748
  });
3275
3749
  return Fb.feedbackResultText(result);
3276
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
+ }
3277
3761
  }
3278
3762
  throw new ToolError(`unknown tool ${name}`);
3279
3763
  }
@@ -3419,10 +3903,15 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
3419
3903
  const path = url.pathname;
3420
3904
  try {
3421
3905
  if (req.method === "GET" && path === "/.well-known/agent-card.json")
3422
- 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
+ }
3423
3912
  // legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
3424
3913
  if (req.method === "GET" && path === "/.well-known/agent.json")
3425
- 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) });
3426
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) });
3427
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);
3428
3917
  if (mcpOn(ctx)) {
@@ -3446,7 +3935,7 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
3446
3935
  if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
3447
3936
  return json({ error: "not found" }, 404);
3448
3937
  }
3449
- 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));
3450
3939
  if (req.method === "POST" && path === "/push") return await handlePush(req, ctx, ectx);
3451
3940
  if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, ctx, ectx, path, url, false, head);
3452
3941
  if ((req.method === "GET" || req.method === "HEAD") && path === "/") return await landing(req, ctx, url);