a2a-exposed 0.8.21 → 0.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +576 -69
- 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/outbound-push.test.ts +190 -0
- package/worker/test/reply-delivery.test.ts +65 -5
- package/worker/test/wake-target.test.ts +10 -0
package/lib/address.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Announce this inbox's address to paired peers, and keep the previous address forwarding for a grace period.
|
|
2
|
+
// The pairing identity private key stays in peer-identity.json (mode 600) or sealed on the Worker. It is never printed.
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import * as C from "./config.mjs";
|
|
7
|
+
import { CLI, die, httpJson } from "./a2a.mjs";
|
|
8
|
+
import { baseUrl } from "./commands.mjs";
|
|
9
|
+
|
|
10
|
+
const IDENTITY_FILE = () => path.join(C.CONFIG_DIR, "peer-identity.json");
|
|
11
|
+
|
|
12
|
+
function b64url(buf) {
|
|
13
|
+
return Buffer.from(buf).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Local Ed25519 key, created on first use. The file is mode 600. */
|
|
17
|
+
export function loadOrCreateLocalIdentity() {
|
|
18
|
+
const file = IDENTITY_FILE();
|
|
19
|
+
try {
|
|
20
|
+
const cur = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
21
|
+
if (cur && typeof cur.publicKey === "string" && typeof cur.privateKey === "string" && cur.publicKey && cur.privateKey)
|
|
22
|
+
return { publicKey: cur.publicKey, privateKey: cur.privateKey };
|
|
23
|
+
} catch { /* create below */ }
|
|
24
|
+
const kp = crypto.generateKeyPairSync("ed25519");
|
|
25
|
+
// JWK `x` is the raw 32-byte public key. PKCS8 is what the Worker imports. Both are base64url.
|
|
26
|
+
const publicKey = kp.publicKey.export({ format: "jwk" }).x;
|
|
27
|
+
const privateKey = b64url(kp.privateKey.export({ type: "pkcs8", format: "der" }));
|
|
28
|
+
const ident = { publicKey, privateKey };
|
|
29
|
+
if (!ident.publicKey || !ident.privateKey) die("could not create a pairing identity");
|
|
30
|
+
fs.mkdirSync(C.CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
31
|
+
fs.writeFileSync(file, JSON.stringify(ident, null, 2) + "\n", { mode: 0o600 });
|
|
32
|
+
try { fs.chmodSync(file, 0o600); } catch { /* the write mode is already 600 */ }
|
|
33
|
+
return ident;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function ownerCall(method, p, body) {
|
|
37
|
+
const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
|
|
38
|
+
if (!base || !tok) return null;
|
|
39
|
+
return httpJson(base + p, { method, body, headers: { authorization: `Bearer ${tok}` } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ownerError = (r) => (r.data && typeof r.data === "object" && typeof r.data.error === "string" ? r.data.error : `HTTP ${r.status}`);
|
|
43
|
+
|
|
44
|
+
/** Public key to name in a pairing request. Prefers the key this inbox already serves on its card. */
|
|
45
|
+
export async function publicIdentityForConnect() {
|
|
46
|
+
let local = null;
|
|
47
|
+
const ensureLocal = () => local || (local = loadOrCreateLocalIdentity());
|
|
48
|
+
const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
|
|
49
|
+
if (!base || !tok) return ensureLocal().publicKey;
|
|
50
|
+
let got;
|
|
51
|
+
try { got = await ownerCall("GET", "/owner/identity"); }
|
|
52
|
+
catch { return ensureLocal().publicKey; }
|
|
53
|
+
if (!got) return ensureLocal().publicKey;
|
|
54
|
+
if (got.status === 200 && got.data && typeof got.data.publicKey === "string" && got.data.publicKey) {
|
|
55
|
+
const file = IDENTITY_FILE();
|
|
56
|
+
if (fs.existsSync(file)) {
|
|
57
|
+
try {
|
|
58
|
+
const cur = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
59
|
+
if (cur.publicKey && cur.publicKey !== got.data.publicKey)
|
|
60
|
+
console.error("note: the local pairing identity differs from this A2A endpoint. connect uses the endpoint's identity, which is the one its card can prove.");
|
|
61
|
+
} catch { /* unreadable local file: the endpoint key still wins */ }
|
|
62
|
+
}
|
|
63
|
+
return got.data.publicKey;
|
|
64
|
+
}
|
|
65
|
+
if (got.status === 200 && got.data && got.data.publicKey == null) {
|
|
66
|
+
const ident = ensureLocal();
|
|
67
|
+
try {
|
|
68
|
+
const put = await ownerCall("PUT", "/owner/identity", { publicKey: ident.publicKey, privateKey: ident.privateKey });
|
|
69
|
+
if (put && put.status === 200 && put.data && put.data.publicKey) return put.data.publicKey;
|
|
70
|
+
} catch { /* the request still carries the local public key */ }
|
|
71
|
+
return ident.publicKey;
|
|
72
|
+
}
|
|
73
|
+
return ensureLocal().publicKey;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function requireIdentityOnWorker() {
|
|
77
|
+
const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
|
|
78
|
+
if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
|
|
79
|
+
let got;
|
|
80
|
+
try { got = await ownerCall("GET", "/owner/identity"); }
|
|
81
|
+
catch (e) { die(`could not reach this A2A endpoint: ${e?.message || e}`); }
|
|
82
|
+
if (!got || got.status === 404) die(`this A2A endpoint's Worker predates address announcements: run \`${CLI} deploy\` first`);
|
|
83
|
+
if (got.status !== 200) die(`could not read the pairing identity: ${ownerError(got)}`);
|
|
84
|
+
if (got.data && got.data.publicKey) return got.data.publicKey;
|
|
85
|
+
const ident = loadOrCreateLocalIdentity();
|
|
86
|
+
const put = await ownerCall("PUT", "/owner/identity", { publicKey: ident.publicKey, privateKey: ident.privateKey });
|
|
87
|
+
if (!put || put.status !== 200) die(`could not install the pairing identity: ${put ? ownerError(put) : "no answer"}`);
|
|
88
|
+
return put.data.publicKey;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function announce(o = {}) {
|
|
92
|
+
if (!!o.to === !!o.all) die(`usage: ${CLI} peer announce (--to <alias> | --all) [--reissue] [--json]`);
|
|
93
|
+
if (o.reissue && (o.all || !o.to)) die("--reissue needs --to <alias>");
|
|
94
|
+
if (o.to !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(o.to)) die("--to must be a peer alias");
|
|
95
|
+
await requireIdentityOnWorker();
|
|
96
|
+
const r = await ownerCall("POST", "/owner/peers/announce", o.all ? { all: true } : { to: o.to, ...(o.reissue ? { reissue: true } : {}) });
|
|
97
|
+
if (!r || r.status !== 200) die(`could not announce this address: ${r ? ownerError(r) : "no answer"}`);
|
|
98
|
+
const results = Array.isArray(r.data.results) ? r.data.results : [];
|
|
99
|
+
if (results.length === 1 && results[0].alias === "*") die(results[0].detail || "could not announce this address");
|
|
100
|
+
if (o.json) return console.log(JSON.stringify({ results }));
|
|
101
|
+
if (!results.length) return console.log("No synced peer to announce to. Run peers sync after connect.");
|
|
102
|
+
for (const row of results) {
|
|
103
|
+
const host = row.host ? ` (${row.host})` : "";
|
|
104
|
+
console.log(`${row.alias}${host}: ${row.status}${row.detail ? ` — ${row.detail}` : ""}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function forward(o = {}) {
|
|
109
|
+
const base = baseUrl(), tok = C.get("A2A_OWNER_TOKEN");
|
|
110
|
+
if (!base || !tok) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
|
|
111
|
+
if (o.off) {
|
|
112
|
+
const r = await ownerCall("DELETE", "/owner/address-forward");
|
|
113
|
+
if (!r || r.status === 404) die(`this A2A endpoint's Worker predates address forwarding: run \`${CLI} deploy\` first`);
|
|
114
|
+
if (r.status !== 200) die(`could not clear the grace forward: ${ownerError(r)}`);
|
|
115
|
+
return console.log("grace forward cleared");
|
|
116
|
+
}
|
|
117
|
+
if (!o.to) die(`usage: ${CLI} peer forward --to <card-url> [--hours N] | ${CLI} peer forward --off`);
|
|
118
|
+
let hours;
|
|
119
|
+
if (o.hours !== undefined) {
|
|
120
|
+
if (!/^\d{1,4}$/.test(String(o.hours))) die("--hours must be a whole number from 1 to 720 (default 168)");
|
|
121
|
+
hours = Number(o.hours);
|
|
122
|
+
}
|
|
123
|
+
const r = await ownerCall("PUT", "/owner/address-forward", { cardUrl: o.to, ...(hours ? { hours } : {}) });
|
|
124
|
+
if (!r || r.status === 404) die(`this A2A endpoint's Worker predates address forwarding: run \`${CLI} deploy\` first`);
|
|
125
|
+
if (r.status !== 200) die(`could not set the grace forward: ${ownerError(r)}`);
|
|
126
|
+
const until = r.data.until || "";
|
|
127
|
+
if (o.json) return console.log(JSON.stringify({ cardUrl: r.data.cardUrl, until }));
|
|
128
|
+
console.log(`grace forward to ${r.data.cardUrl}${until ? ` until ${until}` : ""}`);
|
|
129
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -152,7 +152,8 @@ export async function contexts() {
|
|
|
152
152
|
export async function token(action, label, o) {
|
|
153
153
|
if (action === "list") {
|
|
154
154
|
for (const r of await owner("GET", "/owner/peers")) {
|
|
155
|
-
const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}`
|
|
155
|
+
const via = r.source === "pairing" ? `\tvia pairing: code ${r.user_code || "?"}${r.client_name ? `, ${JSON.stringify(r.client_name)}` : ""}`
|
|
156
|
+
: r.source === "reverse" ? "\tvia reverse pairing (return token; revoke ends the return path)" : "";
|
|
156
157
|
console.log(`${r.label}\tcreated=${r.created_at}\t${r.revoked_at ? "REVOKED " + r.revoked_at : "active"}${via}`);
|
|
157
158
|
}
|
|
158
159
|
// MCP connectors (Worker 0.5+): their own tokens, listed and revoked by label like peer tokens
|
package/lib/main.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import * as dep from "./deploy.mjs";
|
|
|
7
7
|
import * as tun from "./tunnel.mjs";
|
|
8
8
|
import * as st from "./status.mjs";
|
|
9
9
|
import * as pair from "./pair.mjs";
|
|
10
|
+
import * as address from "./address.mjs";
|
|
10
11
|
import * as account from "./account.mjs";
|
|
11
12
|
import * as upd from "./update.mjs";
|
|
12
13
|
import * as fb from "./feedback.mjs";
|
|
@@ -181,6 +182,7 @@ Inbox (owner side)
|
|
|
181
182
|
working <taskId> mark task working
|
|
182
183
|
reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
|
|
183
184
|
[--artifact] [--artifact-name N] [--force]
|
|
185
|
+
delivery waits at most 18 s; identical retries read the first result
|
|
184
186
|
history <contextId> [--from PEER] [-n 50] [--json]
|
|
185
187
|
conversation log. --from is one peer (the wake hint includes it)
|
|
186
188
|
contexts recent conversations
|
|
@@ -235,6 +237,14 @@ Outbound (agents you call)
|
|
|
235
237
|
peers sync [alias...] | peers unsync <alias>
|
|
236
238
|
upload peers (URL + token, encrypted on the Worker) so the MCP connector can send to
|
|
237
239
|
them; default: all peers. Re-run after connect, token changes or an owner-token rotation
|
|
240
|
+
peer announce (--to <alias> | --all) [--reissue] [--json]
|
|
241
|
+
tell paired peers this inbox's current card URL. They switch only after the card
|
|
242
|
+
proves the same pairing identity. A peer that does not support this keeps the
|
|
243
|
+
current pairing. A revoked return token stays revoked unless --to and --reissue.
|
|
244
|
+
The return token is not printed
|
|
245
|
+
peer forward --to <card-url> [--hours N] | peer forward --off
|
|
246
|
+
on the old inbox, forward direct replies and answer with a moved pointer for N hours
|
|
247
|
+
(default 168, max 720)
|
|
238
248
|
a token variable set in the environment overrides config.env; when a peer token
|
|
239
249
|
differs between the two, connect/send/poll/peers/status warn (values never shown)
|
|
240
250
|
send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
|
|
@@ -324,6 +334,7 @@ const SPEC = {
|
|
|
324
334
|
history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
|
|
325
335
|
token: { rotate: B },
|
|
326
336
|
peers: { "token-env": S, "token-stdin": B },
|
|
337
|
+
peer: { to: S, all: B, reissue: B, hours: S, off: B, json: B },
|
|
327
338
|
send: { to: S, text: S, context: S, task: S, push: B, proto: S },
|
|
328
339
|
poll: { to: S, proto: S },
|
|
329
340
|
status: { json: B },
|
|
@@ -432,6 +443,12 @@ export async function main(argv) {
|
|
|
432
443
|
case "export": return cmd.exportInbox(o);
|
|
433
444
|
case "token": return cmd.token(need(p[0], "token issue|list|revoke|rotate [label]"), p[1], o);
|
|
434
445
|
case "peers": return cmd.peers(p[0] || "list", p.slice(1), o);
|
|
446
|
+
case "peer": {
|
|
447
|
+
const sub = need(p[0], "peer announce (--to <alias> | --all) | peer forward --to <card-url> [--hours N] | peer forward --off");
|
|
448
|
+
if (sub === "announce") return address.announce(o);
|
|
449
|
+
if (sub === "forward") return address.forward(o);
|
|
450
|
+
throw new CliError(`unknown peer action ${sub} (announce|forward)`);
|
|
451
|
+
}
|
|
435
452
|
case "signup":
|
|
436
453
|
case "login": {
|
|
437
454
|
const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
|
package/lib/pair.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import * as C from "./config.mjs";
|
|
8
8
|
import { CLI, describeHttp, die, fetchCard, httpJson, isPrivateHost, pickEndpoint } from "./a2a.mjs";
|
|
9
9
|
import { baseUrl, peerToken, warnPeerTokenEnv } from "./commands.mjs";
|
|
10
|
+
import { publicIdentityForConnect } from "./address.mjs";
|
|
10
11
|
|
|
11
12
|
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
12
13
|
export const PBKDF2_ITERATIONS = 100000; // default (the Workers runtime's maximum); the Worker advertises its own setting
|
|
@@ -168,6 +169,8 @@ export async function list(o) {
|
|
|
168
169
|
for (const p of d.pending) {
|
|
169
170
|
console.log(`--- ${p.userCode} ${JSON.stringify(p.clientName || p.clientId || "(no name)")} (claimed, untrusted) expires ${p.expiresAt}${p.replacesLabel ? ` replaces the active token "${p.replacesLabel}"` : ""}`);
|
|
170
171
|
console.log(` card: ${p.agentCardUrl || "-"} from: ${[p.ip || "?", p.country].filter(Boolean).join(", ")}`);
|
|
172
|
+
if (p.identityFingerprint) console.log(` identity: ${p.identityFingerprint}`);
|
|
173
|
+
if (!p.agentCardUrl) console.log(" no callback address yet: replies stay on the task until this agent announces one");
|
|
171
174
|
console.log(` link: ${p.verificationUriComplete}`);
|
|
172
175
|
}
|
|
173
176
|
console.log(`Ask your human about each request; never approve on your own.`);
|
|
@@ -356,6 +359,8 @@ A different agent at the same URL? Pass another --alias.`);
|
|
|
356
359
|
// deployment exists) wins. With no override, read this deployment's card, including an http one.
|
|
357
360
|
const nameUrl = sentCard || (!o["card-url"] && /^https?:\/\//.test(own || "") ? `${own.replace(/\/$/, "")}/.well-known/agent-card.json` : "");
|
|
358
361
|
const params = { client_name: pairingClientName({ explicit: o.name, cardName: await readCardName(nameUrl), agentName: C.get("A2A_AGENT_NAME"), workerName: C.get("A2A_WORKER_NAME"), ownUrl: own }), client_id: C.get("A2A_WORKER_NAME") || alias };
|
|
362
|
+
const identity = await publicIdentityForConnect();
|
|
363
|
+
if (identity) params.agent_identity = identity;
|
|
359
364
|
if (sentCard) params.agent_card_url = sentCard;
|
|
360
365
|
// replacing: present the old token, so a peer that supports it swaps the token under the same label (no orphan)
|
|
361
366
|
const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
|
package/lib/status.mjs
CHANGED
|
@@ -126,6 +126,11 @@ export function nextStep(s) {
|
|
|
126
126
|
if (s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
|
|
127
127
|
n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
|
|
128
128
|
const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
|
|
129
|
+
const addr = s.addresses;
|
|
130
|
+
if (addr && Array.isArray(addr.announcePending) && addr.announcePending.length)
|
|
131
|
+
n.also.push(`an address announcement is still pending: run \`${CLI} peer announce --all\``);
|
|
132
|
+
if (addr && Array.isArray(addr.inboundNoAddress) && addr.inboundNoAddress.length)
|
|
133
|
+
n.also.push("paired peers with no callback keep replies on the task until they announce an address");
|
|
129
134
|
return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -238,7 +243,7 @@ export async function collect() {
|
|
|
238
243
|
card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
|
|
239
244
|
upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
|
|
240
245
|
upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
|
|
241
|
-
peerTokenShadowed: shadowedPeerTokens(),
|
|
246
|
+
peerTokenShadowed: shadowedPeerTokens(), addresses: null,
|
|
242
247
|
mcp: "unknown", health: null, workerVersion: null, cliVersion: D.VERSION, latestVersion: readCache(cacheFile())?.latest || null,
|
|
243
248
|
};
|
|
244
249
|
const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
|
|
@@ -263,6 +268,10 @@ export async function collect() {
|
|
|
263
268
|
...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
|
|
264
269
|
...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
|
|
265
270
|
} catch { /* older Worker (no pairing yet): leave unknown */ }
|
|
271
|
+
try {
|
|
272
|
+
const ad = await ownerTry("GET", "/owner/address");
|
|
273
|
+
if (ad.status === 200 && ad.data && typeof ad.data === "object") s.addresses = ad.data;
|
|
274
|
+
} catch { /* older Worker: no address lifecycle */ }
|
|
266
275
|
if (s.upstream) {
|
|
267
276
|
try { s.facade = await owner("GET", "/owner/facade"); }
|
|
268
277
|
catch (e) { s.facade = { mode: "unknown", error: e.message }; }
|
|
@@ -378,6 +387,16 @@ export async function status(o) {
|
|
|
378
387
|
rows.push(["tunnel", !t ? "none" : `${t.wakeUrl} -> ${t.origin || "?"}; ${!t.complete ? "INCOMPLETE" : t.tokenFileOk ? "set up" : "set up, but the connector token file is MISSING"}` +
|
|
379
388
|
(t.connections != null ? `; ${tunnelStateLabel(t.state)}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
|
|
380
389
|
if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
|
|
390
|
+
const addr = s.addresses;
|
|
391
|
+
if (addr) {
|
|
392
|
+
const bits = [];
|
|
393
|
+
if (Array.isArray(addr.inboundNoAddress) && addr.inboundNoAddress.length)
|
|
394
|
+
bits.push(`${addr.inboundNoAddress.length} paired peer(s) with no callback (${addr.inboundNoAddress.map((x) => x.label).join(", ")})`);
|
|
395
|
+
if (Array.isArray(addr.announcePending) && addr.announcePending.length)
|
|
396
|
+
bits.push(`announce still pending for ${addr.announcePending.map((x) => x.alias).join(", ")}`);
|
|
397
|
+
if (addr.forward && addr.forward.active) bits.push(`grace forward until ${addr.forward.until}`);
|
|
398
|
+
if (bits.length) rows.push(["addresses", bits.join("; ")]);
|
|
399
|
+
}
|
|
381
400
|
const pg = s.pairing;
|
|
382
401
|
if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
|
|
383
402
|
: `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.manageUrl ? `; dashboard ${pg.manageUrl}` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Pairing identity and the last verified callback address. Existing rows stay valid: every new column is nullable,
|
|
2
|
+
-- so a peer paired before this migration keeps its token and has no callback until it announces one.
|
|
3
|
+
ALTER TABLE peers ADD COLUMN identity_key TEXT;
|
|
4
|
+
ALTER TABLE peers ADD COLUMN card_url TEXT;
|
|
5
|
+
ALTER TABLE peers ADD COLUMN previous_card_url TEXT;
|
|
6
|
+
ALTER TABLE peers ADD COLUMN previous_until_ms INTEGER;
|
|
7
|
+
-- Sealed copy of a return token this inbox minted (source = 'reverse'). Null for every other peer.
|
|
8
|
+
-- The owner API never selects this column. Export omits it.
|
|
9
|
+
ALTER TABLE peers ADD COLUMN token_enc TEXT;
|
|
10
|
+
ALTER TABLE device_requests ADD COLUMN identity_key TEXT;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Pairing identity and address proofs. Web Crypto only, so this runs in Workers and in Node's test runner.
|
|
2
|
+
// The private key never appears in a return value of the HTTP handlers. Callers seal it before storage.
|
|
3
|
+
|
|
4
|
+
export const IDENTITY_URI = "https://a2a.exposed/ext/peer-identity/v1";
|
|
5
|
+
export const MOVED_URI = "https://a2a.exposed/ext/moved/v1";
|
|
6
|
+
export const MOVED_CODE = -32011;
|
|
7
|
+
export const DEFAULT_GRACE_HOURS = 168;
|
|
8
|
+
export const MAX_GRACE_HOURS = 720;
|
|
9
|
+
export const IDENTITY_SETTING = "peer_identity";
|
|
10
|
+
export const FORWARD_SETTING = "address_forward";
|
|
11
|
+
export const ANNOUNCE_SETTING = "announce_status";
|
|
12
|
+
/** Additional data for the sealed identity blob. Not a peer label. */
|
|
13
|
+
export const IDENTITY_AAD = "peer-identity";
|
|
14
|
+
|
|
15
|
+
const IDENTITY_TEXT = "a2a-exposed-peer-identity-v1";
|
|
16
|
+
const UPDATE_TEXT = "a2a-exposed-address-update-v1";
|
|
17
|
+
|
|
18
|
+
export function b64urlEncode(b: Uint8Array): string {
|
|
19
|
+
let s = "";
|
|
20
|
+
for (const byte of b) s += String.fromCharCode(byte);
|
|
21
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function b64urlDecode(s: string): Uint8Array | null {
|
|
25
|
+
if (typeof s !== "string" || !/^[A-Za-z0-9_-]{1,512}$/.test(s)) return null;
|
|
26
|
+
try {
|
|
27
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
|
28
|
+
const raw = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
29
|
+
const out = new Uint8Array(raw.length);
|
|
30
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
31
|
+
return out;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Canonical base64url of a raw 32-byte Ed25519 public key, or "". */
|
|
38
|
+
export function cleanPublicKey(v: unknown): string {
|
|
39
|
+
if (typeof v !== "string") return "";
|
|
40
|
+
const raw = b64urlDecode(v.trim());
|
|
41
|
+
return raw && raw.length === 32 ? b64urlEncode(raw) : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A peer token the caller grants this inbox, so this inbox can call the new address back. Anything else is refused. */
|
|
45
|
+
export function cleanReverseToken(v: unknown): string {
|
|
46
|
+
return typeof v === "string" && /^(?:a2aow_|s2a_)[A-Za-z0-9_-]{43}$/.test(v) ? v : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** https agent-card URL, no query, no hash, no credentials. */
|
|
50
|
+
export function canonicalCardUrl(v: unknown): string {
|
|
51
|
+
if (typeof v !== "string" || !v || v.length > 300) return "";
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(v);
|
|
54
|
+
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) return "";
|
|
55
|
+
if (u.pathname !== "/.well-known/agent-card.json") return "";
|
|
56
|
+
return u.href;
|
|
57
|
+
} catch {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Card URL for this deployment. Empty unless the public URL is https. */
|
|
63
|
+
export function cardUrlFor(publicUrl: string): string {
|
|
64
|
+
try {
|
|
65
|
+
const u = new URL(publicUrl);
|
|
66
|
+
if (u.protocol !== "https:") return "";
|
|
67
|
+
return new URL("/.well-known/agent-card.json", u.origin).href;
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function originOf(url: string): string {
|
|
74
|
+
try { return new URL(url).origin; } catch { return ""; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function hostOf(url: string): string {
|
|
78
|
+
try { return new URL(url).host; } catch { return ""; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Grace length in hours. `undefined` is the default. Anything else outside 1–720 is refused (`null`). */
|
|
82
|
+
export function graceHours(v: unknown): number | null {
|
|
83
|
+
if (v === undefined || v === null || v === "") return DEFAULT_GRACE_HOURS;
|
|
84
|
+
const n = typeof v === "number" ? v : typeof v === "string" && /^\d{1,4}$/.test(v) ? Number(v) : NaN;
|
|
85
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_GRACE_HOURS) return null;
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function identityMessage(cardUrl: string, publicKey: string): Uint8Array {
|
|
90
|
+
return new TextEncoder().encode(`${IDENTITY_TEXT}\n${cardUrl}\n${publicKey}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function updateMessage(cardUrl: string, publicKey: string, reverseHash: string): Uint8Array {
|
|
94
|
+
return new TextEncoder().encode(`${UPDATE_TEXT}\n${cardUrl}\n${publicKey}\n${reverseHash || "-"}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ed25519 = { name: "Ed25519" };
|
|
98
|
+
|
|
99
|
+
export async function generateIdentity(): Promise<{ publicKey: string; privateKey: string }> {
|
|
100
|
+
const kp = await crypto.subtle.generateKey(ed25519 as any, true, ["sign", "verify"]) as CryptoKeyPair;
|
|
101
|
+
const pub = new Uint8Array(await (crypto.subtle.exportKey as any)("raw", kp.publicKey));
|
|
102
|
+
const priv = new Uint8Array(await (crypto.subtle.exportKey as any)("pkcs8", kp.privateKey));
|
|
103
|
+
return { publicKey: b64urlEncode(pub), privateKey: b64urlEncode(priv) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function importPrivate(pkcs8: string): Promise<CryptoKey | null> {
|
|
107
|
+
const raw = b64urlDecode(pkcs8);
|
|
108
|
+
if (!raw) return null;
|
|
109
|
+
try {
|
|
110
|
+
return await crypto.subtle.importKey("pkcs8", raw, ed25519 as any, false, ["sign"]);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function importPublic(publicKey: string): Promise<CryptoKey | null> {
|
|
117
|
+
const raw = b64urlDecode(publicKey);
|
|
118
|
+
if (!raw || raw.length !== 32) return null;
|
|
119
|
+
try {
|
|
120
|
+
return await crypto.subtle.importKey("raw", raw, ed25519 as any, false, ["verify"]);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function signBytes(privateKey: string, message: Uint8Array): Promise<string | null> {
|
|
127
|
+
const key = await importPrivate(privateKey);
|
|
128
|
+
if (!key) return null;
|
|
129
|
+
try {
|
|
130
|
+
return b64urlEncode(new Uint8Array(await crypto.subtle.sign(ed25519 as any, key, message as BufferSource)));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function verifyBytes(publicKey: string, message: Uint8Array, signature: string): Promise<boolean> {
|
|
137
|
+
const key = await importPublic(publicKey);
|
|
138
|
+
const sig = b64urlDecode(signature);
|
|
139
|
+
if (!key || !sig) return false;
|
|
140
|
+
try {
|
|
141
|
+
return await crypto.subtle.verify(ed25519 as any, key, sig as BufferSource, message as BufferSource);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when this private key produces signatures this public key accepts. */
|
|
148
|
+
export async function identityMatches(privateKey: string, publicKey: string): Promise<boolean> {
|
|
149
|
+
const clean = cleanPublicKey(publicKey);
|
|
150
|
+
if (!clean) return false;
|
|
151
|
+
const sig = await signBytes(privateKey, new TextEncoder().encode("a2a-exposed-identity-check-v1"));
|
|
152
|
+
return !!sig && await verifyBytes(clean, new TextEncoder().encode("a2a-exposed-identity-check-v1"), sig);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function identityExtension(publicKey: string, cardUrl: string, signature: string): JsonExt {
|
|
156
|
+
return {
|
|
157
|
+
uri: IDENTITY_URI,
|
|
158
|
+
description: "Proves this card URL is served by this pairing identity",
|
|
159
|
+
params: { publicKey, cardUrl, signature },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function movedExtension(cardUrl: string, until: string): JsonExt {
|
|
164
|
+
return {
|
|
165
|
+
uri: MOVED_URI,
|
|
166
|
+
description: "This inbox answers with a moved pointer until the grace time, and forwards a direct reply when the new inbox accepts it",
|
|
167
|
+
params: { cardUrl, until },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type JsonExt = { uri: string; description: string; params: Record<string, string> };
|
|
172
|
+
|
|
173
|
+
export function extensionParams(card: unknown, uri: string): Record<string, unknown> | null {
|
|
174
|
+
const caps = card && typeof card === "object" ? (card as { capabilities?: { extensions?: unknown } }).capabilities : null;
|
|
175
|
+
const list = caps?.extensions;
|
|
176
|
+
if (!Array.isArray(list)) return null;
|
|
177
|
+
for (const item of list) {
|
|
178
|
+
if (!item || typeof item !== "object") continue;
|
|
179
|
+
const ext = item as { uri?: unknown; params?: unknown };
|
|
180
|
+
if (ext.uri === uri && ext.params && typeof ext.params === "object" && !Array.isArray(ext.params))
|
|
181
|
+
return ext.params as Record<string, unknown>;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function movedFromCard(card: unknown, now = Date.now()): { cardUrl: string; until: string } | null {
|
|
187
|
+
const params = extensionParams(card, MOVED_URI);
|
|
188
|
+
if (!params) return null;
|
|
189
|
+
const cardUrl = canonicalCardUrl(params.cardUrl);
|
|
190
|
+
const until = typeof params.until === "string" ? params.until : "";
|
|
191
|
+
const untilMs = Date.parse(until);
|
|
192
|
+
if (!cardUrl || !Number.isFinite(untilMs) || untilMs <= now) return null;
|
|
193
|
+
return { cardUrl, until };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The card's identity proof, when the public key and signature match this card URL. */
|
|
197
|
+
export async function cardProves(card: unknown, publicKey: string, cardUrl: string): Promise<boolean> {
|
|
198
|
+
const want = cleanPublicKey(publicKey);
|
|
199
|
+
const url = canonicalCardUrl(cardUrl);
|
|
200
|
+
if (!want || !url) return false;
|
|
201
|
+
const params = extensionParams(card, IDENTITY_URI);
|
|
202
|
+
if (!params) return false;
|
|
203
|
+
if (cleanPublicKey(params.publicKey) !== want) return false;
|
|
204
|
+
if (canonicalCardUrl(params.cardUrl) !== url) return false;
|
|
205
|
+
if (typeof params.signature !== "string") return false;
|
|
206
|
+
return verifyBytes(want, identityMessage(url, want), params.signature);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type AnnounceStatus = "updated" | "unsupported" | "rejected" | "failed";
|
|
210
|
+
|
|
211
|
+
/** What an address announcement got back. Method-not-found keeps the current pairing. */
|
|
212
|
+
export function announceOutcome(status: number, body: unknown): { status: AnnounceStatus; detail: string } {
|
|
213
|
+
const obj = body && typeof body === "object" ? body as { result?: { ok?: unknown }; error?: { code?: unknown; message?: unknown } } : null;
|
|
214
|
+
if (status === 200 && obj?.result?.ok === true) return { status: "updated", detail: "Peer stored the address." };
|
|
215
|
+
const code = obj?.error?.code;
|
|
216
|
+
if (code === -32601) return { status: "unsupported", detail: "This peer does not accept address announcements. The current pairing is unchanged." };
|
|
217
|
+
if (status === 401 || status === 403 || code === -32000) return { status: "rejected", detail: "The pairing token was rejected." };
|
|
218
|
+
const message = typeof obj?.error?.message === "string" ? obj.error.message.slice(0, 200) : "";
|
|
219
|
+
return { status: "failed", detail: message || `HTTP ${status}` };
|
|
220
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Stop waiting even when an internal binding ignores cancellation. Callers pass
|
|
2
|
+
* the signal into every request and check it before starting another side effect. */
|
|
3
|
+
export async function withDeadline<T>(ms: number, run: (signal: AbortSignal) => Promise<T>, parent?: AbortSignal): Promise<T> {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const cancel = () => controller.abort(parent?.reason);
|
|
6
|
+
if (parent?.aborted) cancel();
|
|
7
|
+
else parent?.addEventListener("abort", cancel, { once: true });
|
|
8
|
+
const timer = setTimeout(() => controller.abort(new Error("Reply delivery timed out; its outcome may be uncertain")), ms);
|
|
9
|
+
let stop!: () => void;
|
|
10
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
11
|
+
stop = () => reject(controller.signal.reason);
|
|
12
|
+
controller.signal.addEventListener("abort", stop, { once: true });
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
controller.signal.throwIfAborted();
|
|
16
|
+
return await Promise.race([run(controller.signal), aborted]);
|
|
17
|
+
} finally {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
parent?.removeEventListener("abort", cancel);
|
|
20
|
+
controller.signal.removeEventListener("abort", stop);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/worker/src/export.ts
CHANGED
|
@@ -100,6 +100,7 @@ const OMIT_COLUMNS: Record<string, readonly string[]> = {
|
|
|
100
100
|
outbound: ["push_token_hash"],
|
|
101
101
|
mcp_grants: ["access_hash", "refresh_hash"],
|
|
102
102
|
outbound_peers: ["token_enc"],
|
|
103
|
+
peers: ["token_enc"],
|
|
103
104
|
device_requests: ["replaces_hash", "ip", "country"],
|
|
104
105
|
};
|
|
105
106
|
|
|
@@ -137,7 +138,7 @@ export function redactExport(file: ExportFile): ExportFile {
|
|
|
137
138
|
const omit = new Set(OMIT_COLUMNS[name] ?? []);
|
|
138
139
|
const rows: Record<string, unknown>[] = [];
|
|
139
140
|
for (const [index, row] of (file.tables[name] ?? []).entries()) {
|
|
140
|
-
if (name === "settings" && (row.key === "approval_password" || row.key === "password_setup")) continue;
|
|
141
|
+
if (name === "settings" && (row.key === "approval_password" || row.key === "password_setup" || row.key === "peer_identity")) continue;
|
|
141
142
|
const out: Record<string, unknown> = {};
|
|
142
143
|
for (const [key, value] of Object.entries(row)) {
|
|
143
144
|
if (omit.has(key) || key.toLowerCase() === "replaces_hash") continue;
|