a2a-exposed 0.8.33 → 0.8.35
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/link-finish.mjs +93 -29
- package/lib/main.mjs +6 -4
- package/package.json +1 -1
- package/worker/src/hosted.ts +0 -3
- package/worker/src/index.ts +23 -14
- package/worker/src/mcp.ts +2 -2
- package/worker/src/pairing.ts +4 -3
- package/worker/src/tenancy.ts +12 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-secrets.ts +90 -29
- package/worker/test/address.test.ts +59 -1
- package/worker/test/facade.test.ts +2 -0
- package/worker/test/hosted.test.ts +17 -2
- package/worker/test/mcp.test.ts +5 -0
- package/worker/test/owner-mcp.test.ts +11 -0
- package/worker/test/pairing.test.ts +64 -4
- package/worker/test/tenancy.test.ts +17 -0
package/lib/link-finish.mjs
CHANGED
|
@@ -1,11 +1,71 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { domainToASCII } from "node:url";
|
|
5
|
+
import { isIP } from "node:net";
|
|
4
6
|
import { CliError } from "./a2a.mjs";
|
|
5
7
|
import { controlOrigin } from "./account.mjs";
|
|
6
8
|
import * as C from "./config.mjs";
|
|
7
9
|
import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector, connectorCommand } from "./local-link.mjs";
|
|
8
10
|
|
|
11
|
+
// Keep JSON diagnostics local: never serialize arbitrary service fields or errors.
|
|
12
|
+
function resultShape(ok, code, next, details = {}) {
|
|
13
|
+
return { ok, code, reason: null, field: null, phase: null, status: null, hostname: null, next, ...details };
|
|
14
|
+
}
|
|
15
|
+
function linkError(code, next, details = {}) {
|
|
16
|
+
const error = new CliError(next);
|
|
17
|
+
error.result = resultShape(false, code, next, details);
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
const presetNext = "Pass --preset ID selected from hosted capabilities --json before requesting approval.";
|
|
21
|
+
|
|
22
|
+
export function normalizeHostname(value) {
|
|
23
|
+
const invalid = (reason, next) => { throw linkError("invalid_hostname", next, { field: "hostname", reason, phase: "validation" }); };
|
|
24
|
+
if (typeof value !== "string" || !value.trim()) invalid("required", "Pass --hostname with a full DNS hostname on your Cloudflare domain.");
|
|
25
|
+
let hostname = value.trim();
|
|
26
|
+
if (/[\s\\\x00-\x1f\x7f]/.test(hostname)) invalid("characters", "--hostname must not contain whitespace, control characters or backslashes.");
|
|
27
|
+
if (hostname.includes("://")) {
|
|
28
|
+
if (!/^https:\/\//i.test(hostname)) invalid("scheme", "--hostname accepts a DNS hostname or an HTTPS URL.");
|
|
29
|
+
// Inspect authority before URL normalization can remove a default port or path.
|
|
30
|
+
const authority = hostname.slice(8).split(/[/?#]/, 1)[0];
|
|
31
|
+
if (authority.includes("@")) invalid("credentials", "--hostname must not contain URL credentials.");
|
|
32
|
+
if (authority.includes(":")) invalid("port", "--hostname must not contain a port.");
|
|
33
|
+
if (/[?#]/.test(hostname)) invalid("query_fragment", "--hostname must not contain a query or fragment.");
|
|
34
|
+
const suffix = hostname.slice(8 + authority.length);
|
|
35
|
+
if (suffix && suffix !== "/") invalid("path", "--hostname HTTPS URLs must have no path beyond /.");
|
|
36
|
+
hostname = authority;
|
|
37
|
+
} else {
|
|
38
|
+
if (hostname.includes("@")) invalid("credentials", "--hostname must not contain URL credentials.");
|
|
39
|
+
if (hostname.includes(":")) invalid("port", "--hostname must not contain a scheme or port; use an HTTPS URL or a DNS hostname.");
|
|
40
|
+
if (/[/?#]/.test(hostname)) invalid("path", "--hostname must not contain a path, query or fragment.");
|
|
41
|
+
}
|
|
42
|
+
// IDNA also normalizes Unicode dot separators. Remove exactly one root dot.
|
|
43
|
+
hostname = domainToASCII(hostname).toLowerCase().replace(/\.$/, "");
|
|
44
|
+
if (isIP(hostname) || /^\d+(?:\.\d+)*$/.test(hostname)) invalid("ip_address", "--hostname must be a DNS hostname, not an IP address.");
|
|
45
|
+
if (!hostname || hostname.length > 253 || !hostname.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
|
|
46
|
+
invalid("dns_labels", "--hostname needs DNS labels of 1–63 letters, digits or hyphens, with no empty labels or leading/trailing hyphens (253 characters maximum).");
|
|
47
|
+
if (!hostname.includes(".") || !/^(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/.test(hostname.split(".").at(-1)))
|
|
48
|
+
invalid("domain", "--hostname must be a full DNS hostname with a valid domain suffix.");
|
|
49
|
+
return hostname;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function linkUsage() {
|
|
53
|
+
throw linkError("usage", "Use hosted link create --agent NAME --hostname HOST --origin URL --path PATH --preset ID [--json], or hosted link finish --agent NAME --preset ID [--json]; choose exactly one protected --secret-file FILE, --secret-env VAR, or --secret-from hermes. List choices with hosted list --json and hosted capabilities --json.", { phase: "validation" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function linkOutput(run, o = {}) {
|
|
57
|
+
try {
|
|
58
|
+
const result = await run();
|
|
59
|
+
if (o.json) console.log(JSON.stringify(resultShape(true, result.saved ? "saved" : "connector_saved",
|
|
60
|
+
result.saved ? "The connector connected; the wake test passed and settings were saved." : "Start cloudflared with the saved token file, then run hosted link finish with --preset ID for fresh approval.",
|
|
61
|
+
{ phase: result.saved ? "wake-test" : "connector", hostname: result.hostname, ...result })));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!o.json) throw error;
|
|
64
|
+
console.log(JSON.stringify(error.result || resultShape(false, "local_failure", "Hosted link setup did not finish. Check the local secret source, token-file permissions and cloudflared, then request fresh approval.")));
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
9
69
|
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
70
|
const ERRORS = {
|
|
11
71
|
expired_token: "The link request expired. Start a new one with hosted link create.",
|
|
@@ -70,7 +130,7 @@ const VALIDATION_MESSAGES = new Set([
|
|
|
70
130
|
"Use an http or https origin with no path or credentials.",
|
|
71
131
|
"Use a path beginning with /, with no query or fragment.",
|
|
72
132
|
]);
|
|
73
|
-
const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
133
|
+
const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/;
|
|
74
134
|
function validationDetail(body, hostname) {
|
|
75
135
|
if (typeof body?.message !== "string") return "";
|
|
76
136
|
if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
|
|
@@ -105,12 +165,17 @@ function failure(status, body, { command, hostname }, phase = "requests") {
|
|
|
105
165
|
: `The link request expired. Start a new one with hosted link ${command}.`;
|
|
106
166
|
if (code === "invalid_request" && phase === "token") guidance = `The approval polling request was refused. Run hosted link ${command} again for fresh approval.`;
|
|
107
167
|
const message = detail || guidance.replace(/run finish/gi, (match) => `${match.startsWith("R") ? "Run" : "run"} ${command}`);
|
|
108
|
-
|
|
168
|
+
const reason = ["hostname_format", "origin_format", "path_format", "hostname_invalid", "preset_invalid", "origin_invalid", "path_invalid", "apex_hostname", "outside_zone", "existing_dns", "existing_tunnel"].includes(body?.reason) ? body.reason : known(body?.reason) ? body.reason : null;
|
|
169
|
+
const field = ["hostname", "origin", "path", "preset"].includes(body?.field) ? body.field : null;
|
|
170
|
+
const provisionPhase = ["cleanup", "hostname", "zone", "existing_dns", "existing_tunnel"].includes(body?.phase) ? body.phase : null;
|
|
171
|
+
return linkError(code.startsWith("HTTP ") ? `http_${status}` : code,
|
|
172
|
+
`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`,
|
|
173
|
+
{ reason, field, phase: code === "provision_failed" && provisionPhase ? provisionPhase : phase, status: code === "provision_failed" && Number.isInteger(body?.status) && body.status >= 100 && body.status <= 599 ? body.status : status, hostname: hostname || null });
|
|
109
174
|
}
|
|
110
175
|
|
|
111
176
|
export function savedLinkSession(origin, session = C.controlSession(), command = "finish") {
|
|
112
177
|
if (controlOrigin(session.origin) !== origin || !session.token)
|
|
113
|
-
throw
|
|
178
|
+
throw linkError("login_required", `sign in to this service first: run login --control-url <service-origin>, then rerun hosted link ${command}; environment sessions require both CONTROL_URL and CONTROL_TOKEN`, { phase: "validation" });
|
|
114
179
|
return session.token;
|
|
115
180
|
}
|
|
116
181
|
|
|
@@ -118,18 +183,16 @@ const CREATE_FIELDS = ["hostname", "origin", "path", "preset"];
|
|
|
118
183
|
|
|
119
184
|
function createPlan(o) {
|
|
120
185
|
for (const key of CREATE_FIELDS) {
|
|
121
|
-
if (typeof o[key] !== "string" || !o[key]) throw
|
|
186
|
+
if (typeof o[key] !== "string" || !o[key]) throw linkError("required", `hosted link create requires --${key}${key === "preset" ? "; " + presetNext : ""}`, { field: key, reason: "required", phase: "validation" });
|
|
122
187
|
}
|
|
123
|
-
const hostname = o.hostname
|
|
124
|
-
if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname))
|
|
125
|
-
throw new CliError("--hostname must be a DNS hostname on your Cloudflare domain");
|
|
188
|
+
const hostname = normalizeHostname(o.hostname);
|
|
126
189
|
let origin;
|
|
127
|
-
try { origin = new URL(o.origin); } catch { throw
|
|
190
|
+
try { origin = new URL(o.origin); } catch { throw linkError("invalid_origin", "--origin must be an http or https origin", { field: "origin", phase: "validation" }); }
|
|
128
191
|
if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
|
|
129
|
-
throw
|
|
192
|
+
throw linkError("invalid_origin", "--origin must be an http or https origin with no path or credentials", { field: "origin", phase: "validation" });
|
|
130
193
|
if (!o.path.startsWith("/") || o.path.startsWith("//") || /[\s?#\\\x00-\x1f\x7f]/.test(o.path) || o.path.length > 1000)
|
|
131
|
-
throw
|
|
132
|
-
if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw
|
|
194
|
+
throw linkError("invalid_path", "--path must begin with / and have no query, fragment or whitespace", { field: "path", phase: "validation" });
|
|
195
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw linkError("preset", presetNext, { field: "preset", reason: o.preset === undefined || o.preset === "" ? "required" : "preset_invalid", phase: "validation" });
|
|
133
196
|
return { hostname, origin: origin.origin, path: o.path, preset: o.preset };
|
|
134
197
|
}
|
|
135
198
|
|
|
@@ -138,12 +201,12 @@ export function createLink(o, deps) {
|
|
|
138
201
|
// Catch a wrong local route before asking the owner to create any resources.
|
|
139
202
|
validateSecretSource(o);
|
|
140
203
|
if (o["secret-from"] === "hermes" && (create.preset !== "hermes" || !/^\/webhooks\/[A-Za-z0-9_-]+$/.test(create.path) || (o.route && create.path !== `/webhooks/${o.route}`)))
|
|
141
|
-
throw
|
|
204
|
+
throw linkError("route_mismatch", "Hermes route does not match the create path and preset", { field: "path", phase: "validation" });
|
|
142
205
|
return setupLink(o, deps, create);
|
|
143
206
|
}
|
|
144
207
|
|
|
145
208
|
export function finishLink(o, deps) {
|
|
146
|
-
if (CREATE_FIELDS.filter((key) => key !== "preset").some((key) => o[key] !== undefined)) throw
|
|
209
|
+
if (CREATE_FIELDS.filter((key) => key !== "preset").some((key) => o[key] !== undefined)) throw linkError("usage", "creation options require hosted link create; finish only reuses an existing link", { phase: "validation" });
|
|
147
210
|
return setupLink(o, deps);
|
|
148
211
|
}
|
|
149
212
|
|
|
@@ -156,10 +219,10 @@ async function setupLink(o, {
|
|
|
156
219
|
validateSecretSource(o);
|
|
157
220
|
const context = { command: create ? "create" : "finish", hostname: create?.hostname };
|
|
158
221
|
const agent = o.agent;
|
|
159
|
-
if (typeof agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(agent)) throw
|
|
160
|
-
if (
|
|
222
|
+
if (typeof agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(agent)) throw linkError("agent", "--agent must be an agent name (letters, digits, underscores or hyphens); select it with hosted list --json", { field: "agent", phase: "validation" });
|
|
223
|
+
if (typeof o.preset !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(o.preset)) throw linkError("preset", presetNext, { field: "preset", reason: o.preset === undefined || o.preset === "" ? "required" : "preset_invalid", phase: "validation" });
|
|
161
224
|
const origin = controlOrigin(o["control-url"]);
|
|
162
|
-
if (!origin) throw
|
|
225
|
+
if (!origin) throw linkError("control_url", "--control-url must be an https origin with no path", { field: "control-url", phase: "validation" });
|
|
163
226
|
const session = sessionToken(origin, undefined, context.command);
|
|
164
227
|
const request = async (endpoint, body, grant, timeout = 15000) => {
|
|
165
228
|
let response;
|
|
@@ -174,7 +237,7 @@ async function setupLink(o, {
|
|
|
174
237
|
const parsed = JSON.parse(text);
|
|
175
238
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
176
239
|
return { response, body: parsed };
|
|
177
|
-
} catch { throw
|
|
240
|
+
} catch { throw linkError("unavailable", `link ${context.command} request failed; check the connection and run ${context.command} again for fresh approval`, { phase: endpoint, hostname: context.hostname || null }); }
|
|
178
241
|
};
|
|
179
242
|
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
180
243
|
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
@@ -182,9 +245,9 @@ async function setupLink(o, {
|
|
|
182
245
|
if (started.response.status !== 201) throw failure(started.response.status, started.body, context);
|
|
183
246
|
const setup = started.body;
|
|
184
247
|
let approval;
|
|
185
|
-
try { approval = new URL(setup.approve_url); } catch { throw
|
|
248
|
+
try { approval = new URL(setup.approve_url); } catch { throw linkError("invalid_response", "invalid approval response", { phase: "requests" }); }
|
|
186
249
|
if (approval.origin !== origin || approval.username || approval.password || typeof setup.request_id !== "string" || !setup.request_id || typeof setup.user_code !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(setup.user_code))
|
|
187
|
-
throw
|
|
250
|
+
throw linkError("invalid_response", "invalid approval response", { phase: "requests" });
|
|
188
251
|
write(`${setup.approve_url}\n${setup.user_code}`);
|
|
189
252
|
const deadline = now() + Math.min(600, Math.max(1, Number(setup.expires_in) || 600)) * 1000;
|
|
190
253
|
let interval = Math.min(60, Math.max(5, Number(setup.interval) || 5)), authorized;
|
|
@@ -200,21 +263,22 @@ async function setupLink(o, {
|
|
|
200
263
|
if (!authorized) throw failure(410, { error: "expired_token" }, context, "token");
|
|
201
264
|
const { grant, link } = authorized;
|
|
202
265
|
if (typeof grant !== "string" || !/^[\x21-\x7e]+$/.test(grant) || grant.length > 4096 || link?.agent !== agent || typeof link.path !== "string" || !link.path.startsWith("/") || typeof link.preset !== "string" || typeof link.hostname !== "string" || typeof link.origin !== "string")
|
|
203
|
-
throw
|
|
266
|
+
throw linkError("invalid_response", "invalid approved link response", { phase: "token" });
|
|
204
267
|
if (create && CREATE_FIELDS.some((key) => link[key] !== create[key]))
|
|
205
|
-
throw
|
|
206
|
-
const
|
|
207
|
-
|
|
268
|
+
throw linkError("plan_mismatch", "approved link differs from the requested creation plan; no connector or signing secret was transferred", { phase: "token", hostname: context.hostname || null });
|
|
269
|
+
const hostname = normalizeHostname(link.hostname);
|
|
270
|
+
context.hostname = hostname;
|
|
271
|
+
const preset = o.preset;
|
|
208
272
|
// Resolve and validate the source before spending the one-time connector delivery.
|
|
209
273
|
const secret = readSecret(o, { ...link, preset });
|
|
210
274
|
const connector = await request("connector", {}, grant);
|
|
211
275
|
if (!connector.response.ok) throw failure(connector.response.status, connector.body, context, "connector");
|
|
212
|
-
if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw
|
|
276
|
+
if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw linkError("invalid_response", "invalid connector response; run finish again for fresh approval", { phase: "connector" });
|
|
213
277
|
const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
|
|
214
278
|
saveToken(tokenFile, connector.body.connector_token);
|
|
215
279
|
if (o["no-run"]) {
|
|
216
280
|
write(connectorCommand(tokenFile));
|
|
217
|
-
return { saved: false, connectorStarted: false };
|
|
281
|
+
return { saved: false, connectorStarted: false, hostname };
|
|
218
282
|
}
|
|
219
283
|
await runConnector(tokenFile);
|
|
220
284
|
let connected = false;
|
|
@@ -226,21 +290,21 @@ async function setupLink(o, {
|
|
|
226
290
|
if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
|
|
227
291
|
await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
|
|
228
292
|
}
|
|
229
|
-
if (!connected) throw
|
|
293
|
+
if (!connected) throw linkError("connector_timeout", "connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval", { phase: "status", hostname });
|
|
230
294
|
const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
|
|
231
295
|
// These 409 codes come from saveWakeSettings after a successful wake test.
|
|
232
296
|
const saveRejected = tested.response.status === 409 && ["url", "preset", "header", "key", "unavailable", "unreadable", "conflict", "failed"].includes(tested.body.error);
|
|
233
297
|
if (!tested.response.ok && !saveRejected) throw failure(tested.response.status, tested.body, context, "grant");
|
|
234
298
|
if (saveRejected || (tested.body.result === "ok" && tested.body.saved !== true)) {
|
|
235
299
|
write("wake_result=not-saved");
|
|
236
|
-
throw
|
|
300
|
+
throw linkError("not_saved", "The test wake was delivered, but the settings were not saved. Save them in Wake settings on the website, then test again. The saved connector token remains on disk.", { phase: "wake-test", status: tested.response.status, hostname });
|
|
237
301
|
}
|
|
238
302
|
if (tested.body.result !== "ok") {
|
|
239
303
|
const { result, message } = wakeFailure(tested.body.result);
|
|
240
304
|
write(`wake_result=${result}`);
|
|
241
|
-
throw
|
|
305
|
+
throw linkError("wake_failed", message, { reason: result, phase: "wake-test", status: tested.response.status, hostname });
|
|
242
306
|
}
|
|
243
307
|
write("wake_result=ok");
|
|
244
308
|
write("Connector connected. Wake test passed and settings saved.");
|
|
245
|
-
return { saved: true, connectorStarted: true };
|
|
309
|
+
return { saved: true, connectorStarted: true, hostname };
|
|
246
310
|
}
|
package/lib/main.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import * as hosted from "./hosted.mjs";
|
|
|
13
13
|
import * as upd from "./update.mjs";
|
|
14
14
|
import * as fb from "./feedback.mjs";
|
|
15
15
|
import * as un from "./uninstall.mjs";
|
|
16
|
-
import { finishLink, createLink } from "./link-finish.mjs";
|
|
16
|
+
import { finishLink, createLink, linkOutput, linkUsage } from "./link-finish.mjs";
|
|
17
17
|
|
|
18
18
|
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
19
19
|
|
|
@@ -232,7 +232,8 @@ Inbox (owner side)
|
|
|
232
232
|
history and continue with send. Then pending pairing
|
|
233
233
|
requests (tell your human; the dashboard URL when MANAGE_URL is set;
|
|
234
234
|
--json: rows on stdout, requests on stderr). Follows every page; past
|
|
235
|
-
10000 pages it errors instead of a short list
|
|
235
|
+
10000 pages it errors instead of a short list. Reading marks returned replies
|
|
236
|
+
seen and clears matching pending wakes; task state is unchanged
|
|
236
237
|
show <taskId> full task JSON
|
|
237
238
|
working <taskId> mark task working
|
|
238
239
|
reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
|
|
@@ -446,6 +447,7 @@ export async function main(argv) {
|
|
|
446
447
|
try {
|
|
447
448
|
parsed = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
|
|
448
449
|
} catch (e) {
|
|
450
|
+
if (name === "hosted" && rest.includes("link") && rest.some(value => ["create", "finish"].includes(value)) && rest.includes("--json")) return linkOutput(linkUsage, { json: true });
|
|
449
451
|
if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
450
452
|
const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
|
|
451
453
|
if (flag === "-y") throw new CliError("unknown flag -y. To skip a prompt, pass --yes.");
|
|
@@ -512,8 +514,8 @@ export async function main(argv) {
|
|
|
512
514
|
}
|
|
513
515
|
case "hosted": {
|
|
514
516
|
if (p.length === 1 && ["capabilities", "list", "create", "names", "capacity"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
|
|
515
|
-
if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1]))
|
|
516
|
-
return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
|
|
517
|
+
if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1])) return linkOutput(linkUsage, o);
|
|
518
|
+
return linkOutput(() => (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") }), o);
|
|
517
519
|
}
|
|
518
520
|
case "signup":
|
|
519
521
|
case "login": {
|
package/package.json
CHANGED
package/worker/src/hosted.ts
CHANGED
|
@@ -163,9 +163,6 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
163
163
|
// config_json is ignored, and a blob that cannot be opened leaves those fields unset.
|
|
164
164
|
if (row.secretsEnc) {
|
|
165
165
|
const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
|
|
166
|
-
if (!opened) {
|
|
167
|
-
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
|
|
168
|
-
}
|
|
169
166
|
for (const key of WAKE_SECRET_KEYS) {
|
|
170
167
|
const value = opened?.[key];
|
|
171
168
|
if (value) config[key] = value;
|
package/worker/src/index.ts
CHANGED
|
@@ -602,12 +602,12 @@ const agentCard = (ctx: TenantContext): Promise<Json> => (proxyMode(ctx) ? facad
|
|
|
602
602
|
|
|
603
603
|
/** The same agent as an A2A 0.3 AgentCard (served at the legacy /.well-known/agent.json for 0.2/0.3 clients): top-level
|
|
604
604
|
* url + preferredTransport + protocolVersion, OpenAPI-style securitySchemes and `security`. 0.3 has no device-code
|
|
605
|
-
* flow, so
|
|
605
|
+
* flow, so only bearer authentication is advertised; pairing discovery stays on the 1.0 card. */
|
|
606
606
|
function agentCard03(ctx: TenantContext, c: Json): Json {
|
|
607
607
|
const base = ctx.PUBLIC_URL.replace(/\/$/, "");
|
|
608
|
-
const schemes: Json = { bearer: { type: "http", scheme: "bearer", description:
|
|
609
|
-
|
|
610
|
-
|
|
608
|
+
const schemes: Json = { bearer: { type: "http", scheme: "bearer", description: pairingOn(ctx)
|
|
609
|
+
? `Per-peer bearer token: from device-flow pairing described at ${base}/.well-known/agent-card.json (A2A 1.0), or issued by the agent's operator`
|
|
610
|
+
: c.securitySchemes.bearer.httpAuthSecurityScheme.description } };
|
|
611
611
|
const card: Json = {
|
|
612
612
|
protocolVersion: "0.3.0", name: c.name, description: c.description, url: base + "/", preferredTransport: "JSONRPC",
|
|
613
613
|
additionalInterfaces: [{ url: base + "/", transport: "JSONRPC" }], version: c.version,
|
|
@@ -1233,14 +1233,21 @@ async function withMovedHeader(ctx: TenantContext, res: Response): Promise<Respo
|
|
|
1233
1233
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
1234
1234
|
}
|
|
1235
1235
|
|
|
1236
|
+
/** Every hosted credential sealed by peerKey must retain the previous key during rotation. */
|
|
1237
|
+
async function openPeerSecret(ctx: TenantContext, aad: string, sealed: string): Promise<string | null> {
|
|
1238
|
+
for (const key of await ctx.peerReadKeys()) {
|
|
1239
|
+
const opened = await M.openPeerTokenWithKey(key, aad, sealed);
|
|
1240
|
+
if (opened !== null) return opened;
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1236
1245
|
type PairingIdentity = { publicKey: string; privateKey: string };
|
|
1237
1246
|
|
|
1238
1247
|
async function readIdentity(ctx: TenantContext): Promise<PairingIdentity | null | "unreadable"> {
|
|
1239
1248
|
const row: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first();
|
|
1240
1249
|
if (!row?.value) return null;
|
|
1241
|
-
const
|
|
1242
|
-
if (!key) return "unreadable";
|
|
1243
|
-
const opened = await M.openPeerTokenWithKey(key, Addr.IDENTITY_AAD, row.value);
|
|
1250
|
+
const opened = await openPeerSecret(ctx, Addr.IDENTITY_AAD, row.value);
|
|
1244
1251
|
if (!opened) return "unreadable";
|
|
1245
1252
|
try {
|
|
1246
1253
|
const parsed = JSON.parse(opened);
|
|
@@ -1411,8 +1418,7 @@ async function planReverse(ctx: TenantContext, alias: string): Promise<{ kind: "
|
|
|
1411
1418
|
async function openReverse(ctx: TenantContext, label: string): Promise<string | null> {
|
|
1412
1419
|
const row: Json = await ctx.DB.prepare("SELECT token_enc, source, revoked_at FROM peers WHERE label = ?").bind(label).first();
|
|
1413
1420
|
if (!row || row.revoked_at || row.source !== "reverse" || !row.token_enc) return null;
|
|
1414
|
-
|
|
1415
|
-
return key ? await M.openPeerTokenWithKey(key, label, row.token_enc) : null;
|
|
1421
|
+
return openPeerSecret(ctx, label, row.token_enc);
|
|
1416
1422
|
}
|
|
1417
1423
|
|
|
1418
1424
|
async function storeReverse(ctx: TenantContext, label: string, token: string, clientName: string, reissue = false): Promise<void> {
|
|
@@ -1544,8 +1550,7 @@ async function replyPeer(ctx: TenantContext, label: string): Promise<{ alias: st
|
|
|
1544
1550
|
WHERE o.alias = ? AND o.token_enc IS NOT NULL AND p.revoked_at IS NULL`,
|
|
1545
1551
|
).bind(label).first();
|
|
1546
1552
|
if (!row) return null;
|
|
1547
|
-
const
|
|
1548
|
-
const token = key ? await M.openPeerTokenWithKey(key, row.alias, row.token_enc) : null;
|
|
1553
|
+
const token = await openPeerSecret(ctx, row.alias, row.token_enc);
|
|
1549
1554
|
return token ? { alias: row.alias, url: row.url, token, identityKey: Addr.cleanPublicKey(row.identity_key), announced: row.source === "announce" } : null;
|
|
1550
1555
|
}
|
|
1551
1556
|
|
|
@@ -2663,7 +2668,7 @@ async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionConte
|
|
|
2663
2668
|
model.askCode = true;
|
|
2664
2669
|
model.codeValue = raw || "";
|
|
2665
2670
|
if (raw && !notice) {
|
|
2666
|
-
let text = "
|
|
2671
|
+
let text = "Enter four consonants, a hyphen, and four digits.";
|
|
2667
2672
|
if (code && !r) { lookupStatus = 404; text = PAIRING_NOT_FOUND; }
|
|
2668
2673
|
else if (r && r.status !== "pending") text = `That request was already ${r.status === "denied" ? "denied" : "approved"}.`;
|
|
2669
2674
|
else if (code && r) { lookupStatus = 404; text = PAIRING_EXPIRED; }
|
|
@@ -2683,6 +2688,11 @@ async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionConte
|
|
|
2683
2688
|
await show(form.user_code || null, { kind: "error", text: "This form expired or came from another site. Reload the page and try again." });
|
|
2684
2689
|
return send(403);
|
|
2685
2690
|
}
|
|
2691
|
+
// Code entry is a lookup only; approval and denial remain separate CSRF-protected submissions.
|
|
2692
|
+
if (form.decision === "lookup") {
|
|
2693
|
+
await show(form.user_code || null);
|
|
2694
|
+
return send(lookupStatus);
|
|
2695
|
+
}
|
|
2686
2696
|
const code = P.normUserCode(form.user_code);
|
|
2687
2697
|
if (!code) { await show(form.user_code || null); return send(400); }
|
|
2688
2698
|
// Deny needs no password: it grants nothing, and the worst a holder of the code can do is cancel a pending request
|
|
@@ -3528,8 +3538,7 @@ async function outboundPeer(ctx: TenantContext, alias: string): Promise<{ alias:
|
|
|
3528
3538
|
if (!r) throw new ToolError(`no peer "${alias}" synced to this A2A endpoint. On the machine with the CLI: ${cliCommand(ctx)} peers sync ${alias}`);
|
|
3529
3539
|
let token: string | null = null;
|
|
3530
3540
|
if (r.token_enc) {
|
|
3531
|
-
|
|
3532
|
-
token = peerKey ? await M.openPeerTokenWithKey(peerKey, alias, r.token_enc) : null;
|
|
3541
|
+
token = await openPeerSecret(ctx, alias, r.token_enc);
|
|
3533
3542
|
if (!token) throw new ToolError(`the stored token for "${alias}" can't be read (the owner token was rotated since it was synced): run ${cliCommand(ctx)} peers sync ${alias} again`);
|
|
3534
3543
|
}
|
|
3535
3544
|
return { alias, url: r.url, token };
|
package/worker/src/mcp.ts
CHANGED
|
@@ -319,8 +319,8 @@ const str = (description: string, extra: Schema = {}): Schema => ({ type: "strin
|
|
|
319
319
|
const ro = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
320
320
|
|
|
321
321
|
export const TOOLS: { name: string; title: string; description: string; inputSchema: Schema; outputSchema?: Schema; annotations: Schema }[] = [
|
|
322
|
-
{ name: "inbox", title: "Inbox", annotations:
|
|
323
|
-
description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
|
|
322
|
+
{ name: "inbox", title: "Inbox", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
323
|
+
description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. Reading this page marks returned replies seen and clears matching pending wakes; task state is unchanged. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
|
|
324
324
|
inputSchema: { type: "object", properties: {
|
|
325
325
|
contextId: str("Only this conversation"),
|
|
326
326
|
from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
|
package/worker/src/pairing.ts
CHANGED
|
@@ -224,7 +224,8 @@ export type PageModel = {
|
|
|
224
224
|
};
|
|
225
225
|
|
|
226
226
|
export function devicePage(m: PageModel, nonce: string): string {
|
|
227
|
-
const
|
|
227
|
+
const errorAttrs = m.notice?.kind === "error" ? ` aria-invalid="true" aria-describedby="device-error"` : "";
|
|
228
|
+
const notice = m.notice ? `<p${errorAttrs ? ` id="device-error" role="alert"` : ""} class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
|
|
228
229
|
if (m.settled) {
|
|
229
230
|
const title = m.settled === "approved" ? "Approved" : "Denied";
|
|
230
231
|
return pageShell(title, nonce, `${notice}<p>You can close this page.</p>`);
|
|
@@ -253,14 +254,14 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
253
254
|
const passwordOn = m.passwordEnabled !== false;
|
|
254
255
|
const form = r && passwordOn && m.passwordSet ? `<form method="post" action="/device">
|
|
255
256
|
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
|
|
256
|
-
<label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password" autocomplete="current-password" required maxlength="1024" autofocus>
|
|
257
|
+
<label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password"${errorAttrs} autocomplete="current-password" required maxlength="1024" autofocus>
|
|
257
258
|
<div class="b"><button name="decision" value="approve" type="submit">Approve</button><button name="decision" value="deny" type="submit" class="d" formnovalidate>Deny</button></div>
|
|
258
259
|
</form>` : r && passwordOn && !m.passwordSet ? noPassword + denyOnly : r ? denyOnly : "";
|
|
259
260
|
const oidcForm = m.oidc && r ? `<form method="post" action="/device">
|
|
260
261
|
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
|
|
261
262
|
<div class="b"><button name="decision" value="oidc" type="submit">Approve with ${esc(m.oidc.label)}</button></div>
|
|
262
263
|
</form>` : "";
|
|
263
|
-
const ask = m.askCode ? `<p class="w">The connecting agent prints this code when it runs <code>${esc(`${m.cli} connect <url>`)}</code>.
|
|
264
|
+
const ask = m.askCode ? `<p class="w">The connecting agent prints this code when it runs <code>${esc(`${m.cli} connect <url>`)}</code>. Use four consonants, a hyphen, and four digits.</p><form method="post" action="/device"><input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="decision" value="lookup"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="XXXX-0000"${errorAttrs} spellcheck="false" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
|
|
264
265
|
return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
|
|
265
266
|
}
|
|
266
267
|
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// and an allowlist are all set (approval-oidc.ts).
|
|
12
12
|
|
|
13
13
|
import * as A from "./a2a.ts";
|
|
14
|
+
import { tenantSecretsMaterials } from "./wake-secrets.ts";
|
|
14
15
|
|
|
15
16
|
/** Subset of D1 used by the handlers. A Durable Object adapter satisfies the same shape. */
|
|
16
17
|
export interface SqlDb {
|
|
@@ -489,10 +490,20 @@ export class TenantContext implements ConfigFields {
|
|
|
489
490
|
return this.#peerKey;
|
|
490
491
|
}
|
|
491
492
|
|
|
493
|
+
/** Read-only keys for hosted outbound tokens during rotation; self-host keeps OWNER_TOKEN semantics. */
|
|
494
|
+
async peerReadKeys(): Promise<CryptoKey[]> {
|
|
495
|
+
const active = await this.peerKey();
|
|
496
|
+
if (!active) return [];
|
|
497
|
+
if (this.mode === "single") return [active];
|
|
498
|
+
const previous = tenantSecretsMaterials(this.#secretsKey || "").slice(1);
|
|
499
|
+
return [active, ...await Promise.all(previous.map((material) => derivePeerKey(material, peerKeyInfoForTenant(this.id))))];
|
|
500
|
+
}
|
|
501
|
+
|
|
492
502
|
async #derivePeerKey(): Promise<CryptoKey | null> {
|
|
493
503
|
if (this.mode === "single") return this.#ownerToken ? derivePeerKey(this.#ownerToken) : null;
|
|
494
504
|
if (!this.#secretsKey || !this.id) return null;
|
|
495
|
-
|
|
505
|
+
const material = tenantSecretsMaterials(this.#secretsKey)[0];
|
|
506
|
+
return material ? derivePeerKey(material, peerKeyInfoForTenant(this.id)) : null;
|
|
496
507
|
}
|
|
497
508
|
}
|
|
498
509
|
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.35";
|
|
@@ -36,8 +36,47 @@ export const MAX_WAKE_PLAIN = (() => {
|
|
|
36
36
|
return n;
|
|
37
37
|
})();
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
39
|
+
/** Longest envelope: `v2.<64-char kid>.<iv>.<ciphertext>`. */
|
|
40
|
+
export const MAX_WAKE_SEALED = 3 + 64 + 1 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
41
|
+
|
|
42
|
+
/** A bounded keyring lives in the existing secret binding, never in public config. */
|
|
43
|
+
export interface TenantSecretsKeyring {
|
|
44
|
+
active: string;
|
|
45
|
+
keys: Record<string, string>;
|
|
46
|
+
legacy: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Raw keys preserve v1 writes for a reader-first rollout. JSON keyrings enable v2 writes. */
|
|
50
|
+
export function tenantSecretsKeyring(input: string): TenantSecretsKeyring | null {
|
|
51
|
+
if (!input) return null;
|
|
52
|
+
if (!input.trimStart().startsWith("{")) return { active: "", keys: { "": input }, legacy: true };
|
|
53
|
+
if (input.length > 65536) return null;
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(input);
|
|
56
|
+
if (!value || value.v !== 2 || typeof value.active !== "string" ||
|
|
57
|
+
!value.keys || typeof value.keys !== "object" || Array.isArray(value.keys) ||
|
|
58
|
+
Object.keys(value).some((name) => !["v", "active", "keys"].includes(name))) return null;
|
|
59
|
+
const entries = Object.entries(value.keys);
|
|
60
|
+
if (entries.length < 1 || entries.length > 2 || !Object.hasOwn(value.keys, value.active)) return null;
|
|
61
|
+
for (const [kid, key] of entries) {
|
|
62
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(kid) || typeof key !== "string" || !key.trim() || key.length > 4096) return null;
|
|
63
|
+
}
|
|
64
|
+
return { active: value.active, keys: value.keys, legacy: false };
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Current material first, then the single read-only rotation key. No caller may log these values. */
|
|
69
|
+
export function tenantSecretsMaterials(input: string): string[] {
|
|
70
|
+
const ring = tenantSecretsKeyring(input);
|
|
71
|
+
if (!ring) return [];
|
|
72
|
+
return [ring.keys[ring.active], ...Object.entries(ring.keys).filter(([kid]) => kid !== ring.active).map(([, key]) => key)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unreadable(): null {
|
|
76
|
+
// Only a fixed event: ciphertext, key ids, credentials and tenant identifiers never enter logs.
|
|
77
|
+
console.error(JSON.stringify({ event: "wake_secrets_unreadable", level: "error" }));
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
41
80
|
|
|
42
81
|
function b64(bytes: Uint8Array): string {
|
|
43
82
|
let raw = "";
|
|
@@ -77,21 +116,24 @@ function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
|
|
|
77
116
|
return payload;
|
|
78
117
|
}
|
|
79
118
|
|
|
80
|
-
/** `
|
|
119
|
+
/** `v2.<kid>.<iv>.<ciphertext>` for keyrings; raw keys keep v1 writes until rollout completes. */
|
|
81
120
|
export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
|
|
82
|
-
const
|
|
121
|
+
const ring = tenantSecretsKeyring(ikm);
|
|
122
|
+
const key = ring ? await derive(ring.keys[ring.active], tenantId) : null;
|
|
83
123
|
const payload = payloadOf(secrets);
|
|
84
124
|
if (!key || !payload) return null;
|
|
85
125
|
const json = JSON.stringify(payload);
|
|
86
126
|
const encoded = new TextEncoder().encode(json);
|
|
87
127
|
if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
|
|
128
|
+
const prefix = ring!.legacy ? "v1" : `v2.${ring!.active}`;
|
|
129
|
+
const aad = ring!.legacy ? tenantId : `${prefix}\0${tenantId}`;
|
|
88
130
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
89
131
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
90
|
-
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(
|
|
132
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(aad) },
|
|
91
133
|
key,
|
|
92
134
|
encoded,
|
|
93
135
|
));
|
|
94
|
-
const sealed =
|
|
136
|
+
const sealed = `${prefix}.${b64(iv)}.${b64(ct)}`;
|
|
95
137
|
return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
|
|
96
138
|
}
|
|
97
139
|
|
|
@@ -101,28 +143,47 @@ export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: Wa
|
|
|
101
143
|
*/
|
|
102
144
|
export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
|
|
103
145
|
if (sealed == null || sealed === "") return {};
|
|
104
|
-
if (sealed.length > MAX_WAKE_SEALED) return
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
if (typeof sealed !== "string" || sealed.length > MAX_WAKE_SEALED) return unreadable();
|
|
147
|
+
const ring = tenantSecretsKeyring(ikm);
|
|
148
|
+
if (!ring || !tenantId) return unreadable();
|
|
149
|
+
const parts = sealed.split(".");
|
|
150
|
+
const version = parts[0];
|
|
151
|
+
const kid = version === "v2" ? parts[1] : "";
|
|
152
|
+
if (version !== "v1" && version !== "v2") return unreadable();
|
|
153
|
+
if (parts.length !== (version === "v2" ? 4 : 3)) return unreadable();
|
|
154
|
+
const [iv, ct] = parts.slice(version === "v2" ? 2 : 1);
|
|
155
|
+
if (!iv || !ct || (version === "v2" && (ring.legacy || !Object.hasOwn(ring.keys, kid)))) return unreadable();
|
|
156
|
+
const materials = version === "v2" ? [ring.keys[kid]] : tenantSecretsMaterials(ikm);
|
|
157
|
+
const aad = version === "v2" ? `v2.${kid}\0${tenantId}` : tenantId;
|
|
158
|
+
for (const material of materials) {
|
|
159
|
+
try {
|
|
160
|
+
const key = await derive(material, tenantId);
|
|
161
|
+
if (!key) continue;
|
|
162
|
+
const ivBytes = unb64(iv);
|
|
163
|
+
if (ivBytes.length !== 12) return unreadable();
|
|
164
|
+
const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
|
|
165
|
+
{ name: "AES-GCM", iv: ivBytes, additionalData: new TextEncoder().encode(aad) },
|
|
166
|
+
key,
|
|
167
|
+
unb64(ct),
|
|
168
|
+
));
|
|
169
|
+
if (plain.length > MAX_WAKE_PLAIN) return unreadable();
|
|
170
|
+
const parsed = JSON.parse(plain) as unknown;
|
|
171
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return unreadable();
|
|
172
|
+
const out: WakeSecrets = {};
|
|
173
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
174
|
+
const value = (parsed as Record<string, unknown>)[name];
|
|
175
|
+
if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
|
|
176
|
+
out[name] = value;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
} catch { /* Try the previous key only for an unlabelled v1 blob. */ }
|
|
127
180
|
}
|
|
181
|
+
return unreadable();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Pure maintenance helper. The caller must CAS the original blob and queue the new tenant config. */
|
|
185
|
+
export async function rewrapWakeSecrets(ikm: string, tenantId: string, sealed: string): Promise<string | null> {
|
|
186
|
+
if (!sealed) return null;
|
|
187
|
+
const secrets = await openWakeSecrets(ikm, tenantId, sealed);
|
|
188
|
+
return secrets ? sealWakeSecrets(ikm, tenantId, secrets) : null;
|
|
128
189
|
}
|
|
@@ -7,6 +7,7 @@ import { dispatch, runOwnerRpc } from "../src/index.ts";
|
|
|
7
7
|
import { redactExport, exportRows } from "../src/export.ts";
|
|
8
8
|
import { hostedTenantContext, type DoNamespace, type KvNamespace, type TenantContext, type WorkerBindings } from "../src/tenancy.ts";
|
|
9
9
|
import { d1 } from "./d1.ts";
|
|
10
|
+
import { openPeerTokenWithKey } from "../src/mcp.ts";
|
|
10
11
|
|
|
11
12
|
const MIGRATIONS = new URL("../migrations/", import.meta.url);
|
|
12
13
|
const ectx = { waitUntil(p: Promise<unknown>) { p.catch(() => {}); }, passThroughOnException() {} };
|
|
@@ -70,7 +71,14 @@ async function world(t: { after: (fn: () => void) => void }) {
|
|
|
70
71
|
};
|
|
71
72
|
const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
|
|
72
73
|
runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
|
|
73
|
-
|
|
74
|
+
const rotate = async (agent: typeof alice, secretsKey: string) => {
|
|
75
|
+
agent.ctx = hostedTenantContext({ ...env, TENANT_SECRETS_KEY: secretsKey }, {
|
|
76
|
+
id: agent.ctx.id, db: agent.db, publicUrl: agent.ctx.PUBLIC_URL,
|
|
77
|
+
ownerTokenHash: await sha256(agent.ownerToken), status: "active", config: { AGENT_NAME: "fixture" },
|
|
78
|
+
});
|
|
79
|
+
byId.set(agent.ctx.id, agent.ctx);
|
|
80
|
+
};
|
|
81
|
+
return { alice, bob, carol, call, owner, rotate };
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
async function pair(bob: { ctx: TenantContext; ownerToken: string }, call: (ctx: TenantContext, path: string, init?: RequestInit) => Promise<{ status: number; data: any }>, identity?: string) {
|
|
@@ -474,3 +482,53 @@ test("export omits the sealed pairing identity and a stored return token", async
|
|
|
474
482
|
assert.equal(JSON.stringify(redacted).includes(ident.privateKey), false);
|
|
475
483
|
assert.equal(JSON.stringify(redacted).includes("sealed-return"), false);
|
|
476
484
|
});
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
test("hosted key rotation preserves identity proofs, reverse tokens and direct replies", async (t) => {
|
|
488
|
+
const { alice, carol, call, owner, rotate } = await world(t as never);
|
|
489
|
+
const ident = await Addr.generateIdentity();
|
|
490
|
+
const putIdentity = () => call(alice.ctx, "/owner/identity", {
|
|
491
|
+
method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
|
|
492
|
+
body: JSON.stringify(ident),
|
|
493
|
+
});
|
|
494
|
+
assert.equal((await putIdentity()).status, 200);
|
|
495
|
+
const paired = await pair(carol, call, ident.publicKey);
|
|
496
|
+
assert.equal((await owner(alice.ctx, "acct-alice", "PUT", `/owner/outbound-peers/${paired.label}`, { url: carol.ctx.PUBLIC_URL, token: paired.access })).status, 200);
|
|
497
|
+
const announce = () => owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: paired.label });
|
|
498
|
+
const firstAnnouncement = await announce();
|
|
499
|
+
assert.equal(firstAnnouncement.body.results[0].status, "updated", JSON.stringify(firstAnnouncement.body));
|
|
500
|
+
const reverseBefore = await alice.db.prepare("SELECT token_hash, token_enc FROM peers WHERE label = ? AND source = 'reverse'").bind(paired.label).first() as { token_hash: string; token_enc: string };
|
|
501
|
+
assert.ok(reverseBefore.token_enc);
|
|
502
|
+
const identityBefore = await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first() as { value: string };
|
|
503
|
+
const ring = JSON.stringify({ v: 2, active: "current", keys: { previous: "platform-key", current: "new-platform-key" } });
|
|
504
|
+
await rotate(alice, ring);
|
|
505
|
+
await rotate(carol, ring);
|
|
506
|
+
const cardUrl = Addr.cardUrlFor(alice.ctx.PUBLIC_URL);
|
|
507
|
+
const card = await call(alice.ctx, "/.well-known/agent-card.json");
|
|
508
|
+
assert.equal(await Addr.cardProves(card.data, ident.publicKey, cardUrl), true, "the stored old-key identity still signs the card");
|
|
509
|
+
assert.deepEqual(await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first(), identityBefore, "a public read never rewrites identity storage");
|
|
510
|
+
const taskId = await send(carol.ctx, paired.access, "rotation question", "ctx-rotation");
|
|
511
|
+
const reply = await owner(carol.ctx, "acct-carol", "POST", "/owner/mcp/reply", { taskId, text: "rotation answer", state: "completed" });
|
|
512
|
+
assert.equal(reply.body.structuredContent.status, "delivered", JSON.stringify(reply.body));
|
|
513
|
+
assert.equal((await announce()).body.results[0].status, "updated", "the old-key identity and outbound token remain usable");
|
|
514
|
+
const reverseAfter = await alice.db.prepare("SELECT token_hash, token_enc FROM peers WHERE label = ? AND source = 'reverse'").bind(paired.label).first() as typeof reverseBefore;
|
|
515
|
+
assert.equal(reverseAfter.token_hash, reverseBefore.token_hash, "rotation must preserve the existing return token");
|
|
516
|
+
assert.notEqual(reverseAfter.token_enc, reverseBefore.token_enc);
|
|
517
|
+
assert.ok(await openPeerTokenWithKey((await alice.ctx.peerKey())!, paired.label, reverseAfter.token_enc), "authorized announcement reseals the same reverse token with the active key");
|
|
518
|
+
// Existing owner-only identity installation reseals the SAME validated identity; it never regenerates it.
|
|
519
|
+
const unauthorized = await call(alice.ctx, "/owner/identity", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(ident) });
|
|
520
|
+
assert.equal(unauthorized.status, 401);
|
|
521
|
+
const resealed = await putIdentity();
|
|
522
|
+
assert.equal(resealed.status, 200);
|
|
523
|
+
assert.equal(resealed.data.publicKey, ident.publicKey);
|
|
524
|
+
assert.equal(JSON.stringify(resealed.data).includes(ident.privateKey), false);
|
|
525
|
+
const onlyCurrent = JSON.stringify({ v: 2, active: "current", keys: { current: "new-platform-key" } });
|
|
526
|
+
await rotate(alice, onlyCurrent);
|
|
527
|
+
const retiredCard = await call(alice.ctx, "/.well-known/agent-card.json");
|
|
528
|
+
assert.equal(await Addr.cardProves(retiredCard.data, ident.publicKey, cardUrl), true, "owner reseal keeps the identity readable after retirement");
|
|
529
|
+
const identityAfter = await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first() as { value: string };
|
|
530
|
+
assert.notEqual(identityAfter.value, identityBefore.value);
|
|
531
|
+
const other = await Addr.generateIdentity();
|
|
532
|
+
const replacement = await call(alice.ctx, "/owner/identity", { method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" }, body: JSON.stringify(other) });
|
|
533
|
+
assert.equal(replacement.status, 409, "rotation never authorizes replacing the identity");
|
|
534
|
+
});
|
|
@@ -195,6 +195,8 @@ test("proxy mode: the served cards are the upstream's, rewritten to the public U
|
|
|
195
195
|
const c03 = (await s.call("GET", "/.well-known/agent.json")).data;
|
|
196
196
|
assert.equal(c03.url, BASE + "/");
|
|
197
197
|
assert.equal(c03.name, "Jean");
|
|
198
|
+
assert.deepEqual(Object.keys(c03.securitySchemes), ["bearer"], "0.3 façade omits unsupported pairing flow");
|
|
199
|
+
assert.deepEqual(c03.security, [{ bearer: [] }]);
|
|
198
200
|
assert.deepEqual(F.cardLeaks(c03, BASE, [TAILNET]), []);
|
|
199
201
|
// the card fetch carried the façade's upstream credentials (Access service token + bearer)
|
|
200
202
|
const cf = s.calls.find((c) => c.url.endsWith("/card-a.json"));
|
|
@@ -933,8 +933,10 @@ test("self-host workerd: human mode owner token approves, the device poll return
|
|
|
933
933
|
|
|
934
934
|
test("sealed upstream and wake Access credentials reach only their tenant context in workerd", async (t) => {
|
|
935
935
|
const received: { origin: string; bearer: string | null; id: string | null; secret: string | null }[] = [];
|
|
936
|
+
const keys = { previous: "platform-secret", current: "next-platform-secret" };
|
|
937
|
+
const keyring = (active: string) => JSON.stringify({ v: 2, active, keys });
|
|
936
938
|
const mf = await start({
|
|
937
|
-
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "
|
|
939
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: keyring("current") },
|
|
938
940
|
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
939
941
|
outboundService: async (request: Request) => {
|
|
940
942
|
received.push({ origin: new URL(request.url).origin, bearer: request.headers.get("authorization"), id: request.headers.get("cf-access-client-id"), secret: request.headers.get("cf-access-client-secret") });
|
|
@@ -966,9 +968,22 @@ test("sealed upstream and wake Access credentials reach only their tenant contex
|
|
|
966
968
|
assert.equal(preview.status, 200);
|
|
967
969
|
assert.equal((preview.body as { hasAccessServiceToken: boolean }).hasAccessServiceToken, true);
|
|
968
970
|
for (const value of Object.values(secrets)) assert.equal(JSON.stringify([facade.body, preview.body]).includes(value), false);
|
|
971
|
+
let version = 1;
|
|
972
|
+
for (const active of ["previous", "current"]) {
|
|
973
|
+
const v2 = await sealWakeSecrets(keyring(active), "sealed-tenant", secrets);
|
|
974
|
+
assert.ok(v2?.startsWith(`v2.${active}.`));
|
|
975
|
+
assert.equal((await push(++version, v2)).applied, true);
|
|
976
|
+
const facadeV2 = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/facade" });
|
|
977
|
+
assert.equal((facadeV2.body as { hasUpstreamToken: boolean }).hasUpstreamToken, true);
|
|
978
|
+
assert.equal(received.at(-1)?.bearer, "Bearer dummy-bearer");
|
|
979
|
+
const wakeV2 = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/wake/preview" });
|
|
980
|
+
assert.equal((wakeV2.body as { hasAccessServiceToken: boolean }).hasAccessServiceToken, true);
|
|
981
|
+
for (const value of Object.values(secrets)) assert.equal(JSON.stringify([facadeV2.body, wakeV2.body]).includes(value), false);
|
|
982
|
+
}
|
|
983
|
+
|
|
969
984
|
const otherTenantBlob = await sealWakeSecrets("platform-secret", "another-tenant", secrets);
|
|
970
985
|
assert.ok(otherTenantBlob);
|
|
971
|
-
assert.equal((await push(
|
|
986
|
+
assert.equal((await push(++version, otherTenantBlob)).applied, true);
|
|
972
987
|
const rejected = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/facade" });
|
|
973
988
|
assert.equal((rejected.body as { hasUpstreamToken: boolean }).hasUpstreamToken, false);
|
|
974
989
|
assert.equal((rejected.body as { hasUpstreamAccessServiceToken: boolean }).hasUpstreamAccessServiceToken, false);
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -851,6 +851,11 @@ test("parseHttpResponse: status, headers, content-length and chunked bodies; mal
|
|
|
851
851
|
});
|
|
852
852
|
|
|
853
853
|
test("inbox tool clears wakes only for the page it returns, and a later call can fetch the rest", async (t) => {
|
|
854
|
+
const inbox = M.TOOLS.find(tool => tool.name === "inbox")!;
|
|
855
|
+
assert.equal(inbox.annotations.readOnlyHint, false, "inbox acknowledges reads");
|
|
856
|
+
assert.equal(inbox.annotations.destructiveHint, false, "acknowledgement preserves tasks");
|
|
857
|
+
assert.match(inbox.description, /marks returned replies seen and clears matching pending wakes/);
|
|
858
|
+
assert.equal(M.TOOLS.find(tool => tool.name === "history")!.annotations.readOnlyHint, true);
|
|
854
859
|
const s = setup({ WAKE_DEBOUNCE_SECONDS: "3600" });
|
|
855
860
|
t.after(s.restore);
|
|
856
861
|
const a = await s.connect();
|
|
@@ -95,6 +95,17 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
|
95
95
|
assert.equal(viaHttp.text.includes("tok-alpha"), false);
|
|
96
96
|
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "from the owner token");
|
|
97
97
|
|
|
98
|
+
// A hosted rotation keeps stored outbound tokens readable through the actual owner send path.
|
|
99
|
+
const rotating = hostedTenantContext({ ...env, TENANT_SECRETS_KEY: JSON.stringify({
|
|
100
|
+
v: 2, active: "next", keys: { old: "platform-key", next: "next-platform-key" },
|
|
101
|
+
}) }, { id: "ten_carol", db: DB, publicUrl: BASE, ownerTokenHash: hash, status: "active" });
|
|
102
|
+
const afterRotation = await runOwnerRpc(rotating, ectx as any, {
|
|
103
|
+
accountId: "acct_carol", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "after rotation" },
|
|
104
|
+
});
|
|
105
|
+
assert.equal(afterRotation.status, 200);
|
|
106
|
+
assert.equal((afterRotation.body as any).isError, false, JSON.stringify(afterRotation.body));
|
|
107
|
+
assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
108
|
+
|
|
98
109
|
const withBody = await runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
99
110
|
assert.equal(withBody.status, 400);
|
|
100
111
|
assert.equal((withBody.body as { error?: string }).error, "this call takes no body");
|
|
@@ -65,6 +65,66 @@ function setup(over: Record<string, string> = {}) {
|
|
|
65
65
|
return { env, DB, wakes, call, owner, start, poll, decidePage, restore: () => { globalThis.fetch = origFetch; } };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
test("device code entry posts a CSRF-protected lookup without making a pairing decision", async (t) => {
|
|
69
|
+
const s = setup(); t.after(s.restore);
|
|
70
|
+
const d = (await s.start()).data;
|
|
71
|
+
const entry = await s.call("GET", "/device");
|
|
72
|
+
assert.match(entry.text, /<form method="post" action="\/device">/);
|
|
73
|
+
assert.doesNotMatch(entry.text, /method="get"|placeholder="WDJB-4827"|aria-invalid/);
|
|
74
|
+
assert.match(entry.text, /placeholder="XXXX-0000"/);
|
|
75
|
+
assert.match(entry.text, /spellcheck="false" autocomplete="off" autocapitalize="characters"/);
|
|
76
|
+
assert.match(entry.text, /name="decision" value="lookup"/);
|
|
77
|
+
const cookie = entry.headers.get("set-cookie")!.split(";")[0];
|
|
78
|
+
const csrf = /name="csrf" value="([^"]+)"/.exec(entry.text)![1];
|
|
79
|
+
const lookup = (code: string, overrides = {}) => s.call("POST", "/device", {
|
|
80
|
+
form: { csrf, decision: "lookup", user_code: code }, cookie, headers: { origin: BASE }, ...overrides,
|
|
81
|
+
});
|
|
82
|
+
const page = await lookup(d.user_code.toLowerCase());
|
|
83
|
+
assert.equal(page.status, 200, page.text);
|
|
84
|
+
assert.equal(page.headers.get("location"), null, "no redirect puts the code in a URL");
|
|
85
|
+
assert.match(page.text, /Barry Bot/);
|
|
86
|
+
assert.match(page.text, /name="decision" value="deny"/);
|
|
87
|
+
assert.doesNotMatch(page.text, /aria-invalid/);
|
|
88
|
+
assert.equal((await s.poll(d.device_code)).data.error, "authorization_pending");
|
|
89
|
+
assert.equal(s.DB.db.prepare("SELECT status FROM device_requests").get()!.status, "pending");
|
|
90
|
+
assert.equal((await lookup(d.user_code, { cookie: "" })).status, 403);
|
|
91
|
+
assert.equal((await lookup(d.user_code, { headers: { origin: "https://evil.example" } })).status, 403);
|
|
92
|
+
assert.equal((await lookup(d.user_code, { form: { decision: "lookup", user_code: d.user_code } })).status, 403);
|
|
93
|
+
const invalid = await lookup("<bad>");
|
|
94
|
+
assert.equal(invalid.status, 200);
|
|
95
|
+
assert.match(invalid.text, /value="<bad>"/);
|
|
96
|
+
assert.match(invalid.text, /<input id="uc"[^>]*aria-invalid="true" aria-describedby="device-error"/);
|
|
97
|
+
assert.match(invalid.text, /<p id="device-error" role="alert"[^>]*>Enter four consonants/);
|
|
98
|
+
assert.equal((await lookup("BCDF-2345")).status, 404);
|
|
99
|
+
advance(3601);
|
|
100
|
+
const expired = await lookup(d.user_code);
|
|
101
|
+
assert.equal(expired.status, 404);
|
|
102
|
+
assert.match(expired.text, /aria-describedby="device-error"/);
|
|
103
|
+
assert.match(expired.text, /expired/i);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("POST code lookups keep the existing IP limit", async (t) => {
|
|
107
|
+
const s = setup(); t.after(s.restore);
|
|
108
|
+
const g = await s.call("GET", "/device");
|
|
109
|
+
const cookie = g.headers.get("set-cookie")!.split(";")[0];
|
|
110
|
+
const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
|
|
111
|
+
const lookup = () => s.call("POST", "/device", { form: { csrf, decision: "lookup", user_code: "BCDF-2345" }, cookie, headers: { origin: BASE } });
|
|
112
|
+
for (let i = 0; i < P.LIMITS.lookupsPerIp; i++) assert.equal((await lookup()).status, 404);
|
|
113
|
+
const limited = await lookup();
|
|
114
|
+
assert.match(limited.text, /Too many code lookups/);
|
|
115
|
+
assert.match(limited.text, /<input id="uc"[^>]*aria-invalid="true" aria-describedby="device-error"/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("wrong approval passwords identify the password input and error text", async (t) => {
|
|
119
|
+
const s = setup(); t.after(s.restore);
|
|
120
|
+
assert.equal((await s.owner("PUT", "/owner/pairing/password", await passwordRecord())).status, 200);
|
|
121
|
+
const d = (await s.start()).data;
|
|
122
|
+
const page = await s.decidePage(d.user_code, "wrong password", "approve");
|
|
123
|
+
assert.equal(page.status, 403);
|
|
124
|
+
assert.match(page.text, /<input id="pw"[^>]*aria-invalid="true" aria-describedby="device-error"/);
|
|
125
|
+
assert.match(page.text, /<p id="device-error" role="alert"[^>]*>Wrong password/);
|
|
126
|
+
});
|
|
127
|
+
|
|
68
128
|
test("HEAD answers like GET with an empty body, and a self-hosted card stays cached for 5 minutes", async (t) => {
|
|
69
129
|
const s = setup();
|
|
70
130
|
t.after(s.restore);
|
|
@@ -208,8 +268,8 @@ test("/.well-known/agent.json serves an A2A 0.3 card (url, preferredTransport, p
|
|
|
208
268
|
assert.deepEqual([old.name, old.version], ["Test Inbox", "1.0.0"]);
|
|
209
269
|
assert.deepEqual(old.capabilities, { streaming: false, pushNotifications: true, stateTransitionHistory: false });
|
|
210
270
|
assert.deepEqual(old.securitySchemes.bearer, { type: "http", scheme: "bearer", description: old.securitySchemes.bearer.description });
|
|
211
|
-
assert.
|
|
212
|
-
assert.
|
|
271
|
+
assert.deepEqual(Object.keys(old.securitySchemes), ["bearer"], "0.3 has no device-code OAuth flow");
|
|
272
|
+
assert.match(old.securitySchemes.bearer.description, /device-flow pairing described at https:\/\/agent\.example\.com\/\.well-known\/agent-card\.json/);
|
|
213
273
|
assert.deepEqual(old.security, [{ bearer: [] }]);
|
|
214
274
|
assert.deepEqual(old.skills, [{ id: "s1", name: "S1", description: "d", tags: [] }], "0.3 requires tags");
|
|
215
275
|
assert.equal(old.supportedInterfaces, undefined, "no 1.0-only fields");
|
|
@@ -903,7 +963,7 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
|
|
|
903
963
|
const base = { agentName: "this A2A endpoint", mode: "human" as const, cli: "a2a", csrf: "c".repeat(24), passwordSet: true };
|
|
904
964
|
const bare = P.devicePage({ ...base, askCode: true }, "n");
|
|
905
965
|
assert.match(bare, /The connecting agent prints this code when it runs <code>a2a connect <url><\/code>/);
|
|
906
|
-
assert.match(bare, /
|
|
966
|
+
assert.match(bare, /four consonants, a hyphen, and four digits/);
|
|
907
967
|
assert.match(bare, /<title>Connect an agent to this A2A endpoint<\/title>/);
|
|
908
968
|
const req = { userCode: "WDJB4827", clientName: "Barry", clientId: "barry", agentCardUrl: "http://127.0.0.1/card", ip: "192.0.2.1", country: "", createdMs: now, expiresMs: now + 20_000 };
|
|
909
969
|
const page = P.devicePage({ ...base, request: req }, "n");
|
|
@@ -934,7 +994,7 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
|
|
|
934
994
|
const s = setup(); t.after(s.restore);
|
|
935
995
|
const empty = await s.call("GET", "/device");
|
|
936
996
|
assert.match(empty.text, /prints this code when it runs/);
|
|
937
|
-
assert.match(empty.text, /
|
|
997
|
+
assert.match(empty.text, /placeholder="XXXX-0000"/);
|
|
938
998
|
const named = setup({ AGENT_NAME: "" }); t.after(named.restore);
|
|
939
999
|
const title = await named.call("GET", "/device");
|
|
940
1000
|
assert.match(title.text, /Connect an agent to this A2A endpoint/);
|
|
@@ -268,3 +268,20 @@ test("fetch path: gates set or unset, the self-host Worker still answers from OW
|
|
|
268
268
|
const no = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer platform-key" });
|
|
269
269
|
assert.equal(no.status, 401);
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
test("hosted peer token keys follow the active ring key and keep the previous key for reads", async () => {
|
|
273
|
+
const make = (key: string, id = "ten_a") => hostedTenantContext(selfEnv({ TENANT_SECRETS_KEY: key }), { id, db: dummy, publicUrl: BASE });
|
|
274
|
+
const old = make("old-platform-key");
|
|
275
|
+
const ring = JSON.stringify({ v: 2, active: "current", keys: { previous: "old-platform-key", current: "new-platform-key" } });
|
|
276
|
+
const rotating = make(ring);
|
|
277
|
+
const oldToken = await sealPeerTokenWithKey((await old.peerKey())!, "alpha", "fixture-old-token");
|
|
278
|
+
const keys = await rotating.peerReadKeys();
|
|
279
|
+
assert.equal(keys.length, 2);
|
|
280
|
+
assert.equal(await openPeerTokenWithKey(keys[0], "alpha", oldToken), null);
|
|
281
|
+
assert.equal(await openPeerTokenWithKey(keys[1], "alpha", oldToken), "fixture-old-token");
|
|
282
|
+
const newToken = await sealPeerTokenWithKey((await rotating.peerKey())!, "alpha", "fixture-new-token");
|
|
283
|
+
assert.equal(await openPeerTokenWithKey((await make("new-platform-key").peerKey())!, "alpha", newToken), "fixture-new-token");
|
|
284
|
+
for (const key of await make(ring, "ten_b").peerReadKeys()) assert.equal(await openPeerTokenWithKey(key, "alpha", oldToken), null);
|
|
285
|
+
assert.deepEqual(await make('{"v":2}').peerReadKeys(), []);
|
|
286
|
+
assert.equal((await resolveTenant(selfEnv()).peerReadKeys()).length, 1);
|
|
287
|
+
});
|