a2a-exposed 0.8.22 → 0.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/address.mjs +129 -0
- package/lib/commands.mjs +2 -1
- package/lib/main.mjs +17 -0
- package/lib/pair.mjs +5 -0
- package/lib/status.mjs +20 -1
- package/package.json +1 -1
- package/worker/migrations/0014_peer_address.sql +10 -0
- package/worker/migrations/0015_outbound_source.sql +3 -0
- package/worker/src/address.ts +220 -0
- package/worker/src/deadline.ts +22 -0
- package/worker/src/export.ts +2 -1
- package/worker/src/index.ts +547 -58
- package/worker/src/mcp.ts +8 -1
- package/worker/src/migrations.ts +4 -0
- package/worker/src/owner-rpc.ts +45 -1
- package/worker/src/pairing.ts +3 -2
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-target.ts +4 -2
- package/worker/test/address.test.ts +414 -0
- package/worker/test/mcp.test.ts +1 -1
- package/worker/test/migrations.test.ts +21 -4
- package/worker/test/reply-delivery.test.ts +65 -5
- package/worker/test/wake-target.test.ts +10 -0
package/worker/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
const r = await checkedPeerFetch(ctx, ectx, base, new URL(
|
|
1146
|
-
if (r.status !== 200)
|
|
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
|
-
|
|
1150
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1572
|
+
let target = safeTarget(peer.alias, peer.url);
|
|
1191
1573
|
try {
|
|
1192
|
-
const card = await checkedPeerCard(ctx, ectx, peer.url);
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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) => ({
|
|
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 =
|
|
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 } : {}),
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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);
|