a2a-exposed 0.8.8 → 0.8.10
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/commands.mjs +10 -2
- package/lib/deploy.mjs +71 -23
- package/lib/main.mjs +19 -4
- package/lib/pair.mjs +4 -1
- package/lib/status.mjs +4 -3
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +4 -1
- package/worker/deploy.env.example +6 -2
- package/worker/src/hosted.ts +6 -5
- package/worker/src/index.ts +73 -19
- package/worker/src/mcp.ts +1 -1
- package/worker/src/pairing-events.ts +120 -0
- package/worker/src/tenancy.ts +6 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-secrets.ts +13 -1
- package/worker/test/mcp.test.ts +3 -0
- package/worker/test/pairing-events.test.ts +263 -0
- package/worker/test/pairing.test.ts +16 -1
- package/worker/test/tenancy.test.ts +26 -0
- package/worker/test/wake-secrets.test.ts +15 -2
package/lib/commands.mjs
CHANGED
|
@@ -37,12 +37,19 @@ export async function inbox(o) {
|
|
|
37
37
|
// pending pairing requests: polling agents get no pairing wake, so the inbox shows them too (not with --context:
|
|
38
38
|
// that is one conversation). An older Worker without pairing: nothing to show.
|
|
39
39
|
let pairing = [];
|
|
40
|
+
let manageUrl = "";
|
|
40
41
|
if (!o.context) {
|
|
41
|
-
try {
|
|
42
|
+
try {
|
|
43
|
+
const r = await ownerTry("GET", "/owner/pairing");
|
|
44
|
+
if (r.status === 200 && r.data && Array.isArray(r.data.pending)) {
|
|
45
|
+
pairing = r.data.pending.map((p) => ({ ...p, approval: r.data.mode }));
|
|
46
|
+
if (typeof r.data.manageUrl === "string") manageUrl = r.data.manageUrl;
|
|
47
|
+
}
|
|
48
|
+
} catch { /* unreachable: the inbox call above already worked */ }
|
|
42
49
|
}
|
|
43
50
|
if (o.json) {
|
|
44
51
|
out(rows); // unchanged shape (an array of tasks); pairing requests go to stderr as one JSON line
|
|
45
|
-
if (pairing.length) console.error(JSON.stringify({ pendingPairingRequests: pairing.length, requests: pairing.map(({ userCode, clientName, clientId, verificationUriComplete, expiresAt, approval }) => ({ userCode, clientName: clientName || clientId || null, verificationUriComplete, expiresAt, approval })), instructions: PAIRING_NOTE }));
|
|
52
|
+
if (pairing.length) console.error(JSON.stringify({ pendingPairingRequests: pairing.length, ...(manageUrl ? { manageUrl } : {}), requests: pairing.map(({ userCode, clientName, clientId, verificationUriComplete, expiresAt, approval }) => ({ userCode, clientName: clientName || clientId || null, verificationUriComplete, expiresAt, approval })), instructions: PAIRING_NOTE }));
|
|
46
53
|
return;
|
|
47
54
|
}
|
|
48
55
|
if (!rows.length) console.log(pairing.length || o.context ? "(no unhandled tasks)" : "(no unhandled tasks, no pending pairing requests)");
|
|
@@ -58,6 +65,7 @@ export async function inbox(o) {
|
|
|
58
65
|
console.log(`--- code ${p.userCode} ${JSON.stringify(p.clientName || p.clientId || "(no name)")} (claimed, untrusted) expires ${p.expiresAt}${p.replacesLabel ? ` replaces token "${p.replacesLabel}"` : ""}`);
|
|
59
66
|
console.log(` link: ${p.verificationUriComplete}`);
|
|
60
67
|
}
|
|
68
|
+
if (manageUrl) console.log(` dashboard: ${manageUrl}`);
|
|
61
69
|
console.log(PAIRING_NOTE);
|
|
62
70
|
}
|
|
63
71
|
}
|
package/lib/deploy.mjs
CHANGED
|
@@ -34,7 +34,8 @@ export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_
|
|
|
34
34
|
export const UPSTREAM_SECRETS = ["UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
|
|
35
35
|
/** OpenID Connect client secret for approval. Environment only; never config.env. */
|
|
36
36
|
export const OIDC_SECRETS = ["APPROVAL_OIDC_CLIENT_SECRET"];
|
|
37
|
-
const
|
|
37
|
+
export const EVENT_SECRETS = ["EVENT_SECRET"];
|
|
38
|
+
const DEPLOY_SECRETS = [...WAKE_SECRETS, ...UPSTREAM_SECRETS, ...OIDC_SECRETS, ...EVENT_SECRETS];
|
|
38
39
|
|
|
39
40
|
// Non-secret deploy settings persisted in config.env and passed to cloudflare.config.ts as env vars.
|
|
40
41
|
const DEPLOY_KEYS = [
|
|
@@ -46,6 +47,7 @@ const DEPLOY_KEYS = [
|
|
|
46
47
|
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
|
|
47
48
|
"A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
|
|
48
49
|
"APPROVAL_OIDC_ISSUER", "APPROVAL_OIDC_CLIENT_ID", "APPROVAL_OIDC_ALLOWED_SUBJECTS", "APPROVAL_METHODS",
|
|
50
|
+
"MANAGE_URL", "EVENT_URL",
|
|
49
51
|
];
|
|
50
52
|
// init/deploy flag -> config key
|
|
51
53
|
const FLAG_KEYS = {
|
|
@@ -56,6 +58,7 @@ const FLAG_KEYS = {
|
|
|
56
58
|
"body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
|
|
57
59
|
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS", mcp: "A2A_MCP",
|
|
58
60
|
upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
|
|
61
|
+
"manage-url": "MANAGE_URL", "event-url": "EVENT_URL",
|
|
59
62
|
};
|
|
60
63
|
export const PAIRING_MODES = ["human", "agent", "off"];
|
|
61
64
|
export const DEPLOY_FLAGS = Object.keys(FLAG_KEYS);
|
|
@@ -171,7 +174,7 @@ export function wakeSecretsFromEnv() {
|
|
|
171
174
|
/** Secrets init/deploy upload when exported: the wake secrets plus, for proxy mode, the upstream credentials. */
|
|
172
175
|
export function deploySecretsFromEnv() {
|
|
173
176
|
const s = wakeSecretsFromEnv();
|
|
174
|
-
for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS]) if (process.env[k]) s[k] = process.env[k];
|
|
177
|
+
for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS, ...EVENT_SECRETS]) if (process.env[k]) s[k] = process.env[k];
|
|
175
178
|
return s;
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -242,6 +245,8 @@ function applyFlags(o) {
|
|
|
242
245
|
if (why) die(`--upstream-card-url ${card} ${why}` + (upd.A2A_UPSTREAM_CARD_URL === undefined
|
|
243
246
|
? ` (it is the saved A2A_UPSTREAM_CARD_URL; pass --upstream-card-url <URL on that origin>, or --upstream-card-url none for <upstream origin>/.well-known/agent-card.json)` : ""));
|
|
244
247
|
}
|
|
248
|
+
if (upd.MANAGE_URL !== undefined) upd.MANAGE_URL = httpsBinding("manage-url", upd.MANAGE_URL, 500);
|
|
249
|
+
if (upd.EVENT_URL !== undefined) upd.EVENT_URL = httpsBinding("event-url", upd.EVENT_URL, 2000);
|
|
245
250
|
if (upd.A2A_AGENT_SKILLS) {
|
|
246
251
|
try { if (!Array.isArray(JSON.parse(upd.A2A_AGENT_SKILLS))) throw 0; } catch { die("--agent-skills must be a JSON array of A2A AgentSkill objects"); }
|
|
247
252
|
}
|
|
@@ -249,6 +254,31 @@ function applyFlags(o) {
|
|
|
249
254
|
return switched;
|
|
250
255
|
}
|
|
251
256
|
|
|
257
|
+
/** https URL with no userinfo, or null for none/off. The saved value is the trimmed URL, so a fragment stays. */
|
|
258
|
+
function httpsBinding(flag, value, max) {
|
|
259
|
+
const raw = String(value).trim();
|
|
260
|
+
if (["", "none", "off"].includes(raw.toLowerCase())) return null;
|
|
261
|
+
if (raw.length > max) die(`--${flag} must be an https URL of at most ${max} characters`);
|
|
262
|
+
let u;
|
|
263
|
+
try { u = new URL(raw); } catch { die(`--${flag} must be an https URL`); }
|
|
264
|
+
if (u.protocol !== "https:" || u.username || u.password) die(`--${flag} must be an https URL with no user name or password`);
|
|
265
|
+
return raw;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* EVENT_SECRET from --event-secret-stdin. Uploaded on this deploy.
|
|
270
|
+
* The value is not printed and is not written to config.env.
|
|
271
|
+
*/
|
|
272
|
+
export async function eventSecretFromStdin(o) {
|
|
273
|
+
if (!o["event-secret-stdin"]) return {};
|
|
274
|
+
if (o["upstream-token-stdin"]) die("--event-secret-stdin reads stdin, so it cannot be combined with --upstream-token-stdin");
|
|
275
|
+
const raw = (process.stdin.isTTY ? await readHidden("EVENT_SECRET (not shown): ") : fs.readFileSync(0, "utf8")).replace(/\r?\n$/, "");
|
|
276
|
+
if (!raw || /[\u0000-\u001f]/.test(raw) || raw.length > 512) die("--event-secret-stdin: the secret must be a single non-empty line, at most 512 characters");
|
|
277
|
+
const fromEnv = process.env.EVENT_SECRET || "";
|
|
278
|
+
if (fromEnv && fromEnv !== raw) console.error("The secret on stdin differs from EVENT_SECRET. This deploy uploads the stdin value. Neither value was printed or saved in config.env.");
|
|
279
|
+
return { EVENT_SECRET: raw };
|
|
280
|
+
}
|
|
281
|
+
|
|
252
282
|
/** After a deploy that moved the public URL: what stops serving and what peers must update. */
|
|
253
283
|
function reportSwitch(sw) {
|
|
254
284
|
if (!sw) return;
|
|
@@ -400,6 +430,42 @@ async function deployAndLearn(dir, secrets, o) {
|
|
|
400
430
|
}
|
|
401
431
|
}
|
|
402
432
|
|
|
433
|
+
/**
|
|
434
|
+
* whoami before npm install. A previous install has node_modules/.bin/cf.
|
|
435
|
+
* A fresh init does not: `npx cf auth login` does not put `cf` on PATH, so this check uses `npx --yes cf`.
|
|
436
|
+
*/
|
|
437
|
+
function cfLoginJson(dir) {
|
|
438
|
+
const local = path.join(dir, "node_modules", ".bin", "cf");
|
|
439
|
+
const args = cfArgs(["auth", "whoami"]);
|
|
440
|
+
const r = fs.existsSync(local)
|
|
441
|
+
? run(local, args, { cwd: dir, capture: true })
|
|
442
|
+
: run("npx", ["--yes", "cf", ...args], { cwd: dir, capture: true });
|
|
443
|
+
try { return JSON.parse(r.stdout); } catch { die("unexpected output from cf auth whoami"); }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Cloudflare login, before init installs packages. Dies with the same advice as a later cf failure. */
|
|
447
|
+
function requireCfLogin(dir) {
|
|
448
|
+
const prof = cfProfile();
|
|
449
|
+
const local = fs.existsSync(path.join(dir, "node_modules", ".bin", "cf"));
|
|
450
|
+
step(`checking Cloudflare login (${local ? "cf" : "npx --yes cf"} auth whoami${prof ? ` --profile ${prof}` : ""})`);
|
|
451
|
+
const who = cfLoginJson(dir);
|
|
452
|
+
if (!who.authenticated) die((process.env.CLOUDFLARE_API_TOKEN
|
|
453
|
+
? "CLOUDFLARE_API_TOKEN is set but cf does not accept it (expired, revoked, or IP-restricted: an IP filter must allow both this host's IPv4 /32 and IPv6 /128). Check the token (setup skill, Troubleshooting: API token), then re-run init"
|
|
454
|
+
: prof
|
|
455
|
+
? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
|
|
456
|
+
: "not logged in to Cloudflare: run `npx cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
|
|
457
|
+
const accts = who.accounts || [];
|
|
458
|
+
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
459
|
+
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
460
|
+
die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
|
|
461
|
+
if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
|
|
462
|
+
C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
|
|
463
|
+
} else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
|
|
464
|
+
die(`account ${C.get("CLOUDFLARE_ACCOUNT_ID")} is not visible to the cf login${prof ? ` (profile "${prof}")` : ""}; pass --cf-profile for the right login or --account-id for one of:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
|
|
465
|
+
}
|
|
466
|
+
step(`Cloudflare account: ${accts.find((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))?.name || C.get("CLOUDFLARE_ACCOUNT_ID")}${prof ? ` (cf profile ${prof})` : ""}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
403
469
|
/** The deployment's public URL as saved by init/deploy (an exported A2A_BASE_URL does not change it; warned about). */
|
|
404
470
|
function deployedBase() {
|
|
405
471
|
const base = (C.fileConfig().A2A_BASE_URL || publicBase()).replace(/\/$/, "");
|
|
@@ -423,26 +489,8 @@ export async function init(o) {
|
|
|
423
489
|
|
|
424
490
|
step(`worker project -> ${dir}`);
|
|
425
491
|
syncTemplate(dir);
|
|
492
|
+
requireCfLogin(dir);
|
|
426
493
|
if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
|
|
427
|
-
|
|
428
|
-
const prof = cfProfile();
|
|
429
|
-
step(`checking Cloudflare login (cf auth whoami${prof ? ` --profile ${prof}` : ""})`);
|
|
430
|
-
const who = cfJson(dir, ["auth", "whoami"]);
|
|
431
|
-
if (!who.authenticated) die((process.env.CLOUDFLARE_API_TOKEN
|
|
432
|
-
? "CLOUDFLARE_API_TOKEN is set but cf does not accept it (expired, revoked, or IP-restricted: an IP filter must allow both this host's IPv4 /32 and IPv6 /128). Check the token (setup skill, Troubleshooting: API token), then re-run init"
|
|
433
|
-
: prof
|
|
434
|
-
? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
|
|
435
|
-
: "not logged in to Cloudflare: run `cf auth login --no-browser`, open the printed URL, enter the code, then re-run init") + `\n${LOGIN_403_HELP}`);
|
|
436
|
-
const accts = who.accounts || [];
|
|
437
|
-
if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
|
|
438
|
-
if (!accts.length && process.env.CLOUDFLARE_API_TOKEN)
|
|
439
|
-
die("the API token can't list accounts: add the permission User -> Memberships -> Read (or pass --account-id <id> / export CLOUDFLARE_ACCOUNT_ID; the id is in the dashboard URL)");
|
|
440
|
-
if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
|
|
441
|
-
C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
|
|
442
|
-
} else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
|
|
443
|
-
die(`account ${C.get("CLOUDFLARE_ACCOUNT_ID")} is not visible to the cf login${prof ? ` (profile "${prof}")` : ""}; pass --cf-profile for the right login or --account-id for one of:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
|
|
444
|
-
}
|
|
445
|
-
step(`Cloudflare account: ${accts.find((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))?.name || C.get("CLOUDFLARE_ACCOUNT_ID")}${prof ? ` (cf profile ${prof})` : ""}`);
|
|
446
494
|
const env = cfEnv();
|
|
447
495
|
|
|
448
496
|
const dbName = C.get("A2A_D1_NAME") || C.get("A2A_WORKER_NAME");
|
|
@@ -463,7 +511,7 @@ export async function init(o) {
|
|
|
463
511
|
if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
|
|
464
512
|
C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
|
|
465
513
|
|
|
466
|
-
const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv(), ...upstreamSecrets };
|
|
514
|
+
const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv(), ...upstreamSecrets, ...await eventSecretFromStdin(o) };
|
|
467
515
|
step(`deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})`);
|
|
468
516
|
await deployAndLearn(dir, secrets, o);
|
|
469
517
|
const base = deployedBase();
|
|
@@ -569,7 +617,7 @@ export async function deploy(o) {
|
|
|
569
617
|
if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
|
|
570
618
|
const env = cfEnv();
|
|
571
619
|
applyMigrations(dir, env);
|
|
572
|
-
const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets };
|
|
620
|
+
const secrets = { ...deploySecretsFromEnv(), ...upstreamSecrets, ...await eventSecretFromStdin(o) };
|
|
573
621
|
if (Object.keys(secrets).length && C.get("A2A_OWNER_TOKEN")) secrets.OWNER_TOKEN = C.get("A2A_OWNER_TOKEN");
|
|
574
622
|
step(Object.keys(secrets).length ? `deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})` : "deploying (existing secrets kept)");
|
|
575
623
|
await deployAndLearn(dir, secrets, o);
|
package/lib/main.mjs
CHANGED
|
@@ -31,6 +31,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
31
31
|
[--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
|
|
32
32
|
[--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
33
33
|
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
|
|
34
|
+
[--manage-url URL] [--event-url URL] [--event-secret-stdin]
|
|
34
35
|
[--rotate-owner-token]
|
|
35
36
|
[--upstream URL [--upstream-card-url URL] [--upstream-token-stdin | --no-upstream-token]] [--worker-dir DIR]
|
|
36
37
|
deploy the Worker + D1 to your account; wake secrets are read from
|
|
@@ -54,6 +55,16 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
54
55
|
Plugin manifests leave MCP unset: the URL differs per deploy.
|
|
55
56
|
When GET /owner/mcp reports enabled and the agent card is reachable,
|
|
56
57
|
\`status\` prints \`claude mcp add --transport http a2a-exposed <url>/mcp\`
|
|
58
|
+
--manage-url https URL of this agent's dashboard page (MANAGE_URL). A pairing
|
|
59
|
+
wake, the MCP inbox line, \`pair list\`, and \`inbox\` include it
|
|
60
|
+
when it is set. \`none\` clears it. No user name or password in the URL
|
|
61
|
+
--event-url https URL that receives a signed pairing event when a request is
|
|
62
|
+
created, approved, or denied (EVENT_URL). \`none\` clears it.
|
|
63
|
+
The post also needs EVENT_SECRET
|
|
64
|
+
--event-secret-stdin
|
|
65
|
+
read EVENT_SECRET from stdin (hidden prompt on a terminal).
|
|
66
|
+
Uploaded on this deploy. Not an argument, not printed, and not
|
|
67
|
+
saved in config.env. Or export EVENT_SECRET
|
|
57
68
|
--rotate-owner-token on init, replace the owner token; other settings come from config.env.
|
|
58
69
|
Then update A2A_OWNER_TOKEN wherever this A2A endpoint is called, and run \`peers sync\`
|
|
59
70
|
--upstream URL proxy / expose mode: a public façade for an agent that already
|
|
@@ -154,7 +165,8 @@ Status
|
|
|
154
165
|
|
|
155
166
|
Inbox (owner side)
|
|
156
167
|
inbox [--context C] [--all] [--json] unhandled inbound tasks (peer text is UNTRUSTED), then pending pairing
|
|
157
|
-
requests (tell your human;
|
|
168
|
+
requests (tell your human; the dashboard URL when MANAGE_URL is set;
|
|
169
|
+
--json: tasks on stdout, requests on stderr)
|
|
158
170
|
show <taskId> full task JSON
|
|
159
171
|
working <taskId> mark task working
|
|
160
172
|
reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
|
|
@@ -192,8 +204,9 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
192
204
|
deploy. --secret-stdin checks one line and discards it, so export the variable
|
|
193
205
|
before deploy. config.env keeps the issuer and client id.
|
|
194
206
|
Leave the issuer unset and approval stays the password page
|
|
195
|
-
pair list [--json] pending pairing requests (code, claimed name/card, address), the approval mode
|
|
196
|
-
when the approval password was set
|
|
207
|
+
pair list [--json] pending pairing requests (code, claimed name/card, address), the approval mode,
|
|
208
|
+
when the approval password was set, and the dashboard URL (manageUrl) when
|
|
209
|
+
MANAGE_URL is set
|
|
197
210
|
pair approve <code> approve with the owner token (human mode records the owner; in agent
|
|
198
211
|
mode, run it only after your human said yes)
|
|
199
212
|
pair deny <code> deny (any mode; on the /device page deny needs no password)
|
|
@@ -289,7 +302,7 @@ const S = { type: "string" }, B = { type: "boolean" };
|
|
|
289
302
|
const tunnelOpts = { "tunnel-hostname": S, "tunnel-zone": S, "tunnel-origin": S, "tunnel-path": S, "show-token": B, "zero-trust-org": S, dir: S, "worker-dir": S };
|
|
290
303
|
const deployOpts = {
|
|
291
304
|
...Object.fromEntries(dep.DEPLOY_FLAGS.map((f) => [f, S])), dir: S, "worker-dir": S, cron: B, "skip-install": B, "rotate-owner-token": B,
|
|
292
|
-
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B,
|
|
305
|
+
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
|
|
293
306
|
};
|
|
294
307
|
const SPEC = {
|
|
295
308
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
@@ -326,6 +339,8 @@ export async function main(argv) {
|
|
|
326
339
|
if (name === "tunnel") return console.log(HELP_TUNNEL);
|
|
327
340
|
return console.log(HELP);
|
|
328
341
|
}
|
|
342
|
+
if ((name === "init" || name === "deploy" || name === "wake") && rest.some((a) => /^--event-secret(=|$)/.test(a)))
|
|
343
|
+
throw new CliError("export EVENT_SECRET or pass --event-secret-stdin. The secret is not an argument. It is not printed and it is not saved in config.env");
|
|
329
344
|
if (name === "pair" && rest[0] === "set-oidc" && rest.some((a) => /^--(secret|client-secret)(=|$)/.test(a)))
|
|
330
345
|
throw new CliError("export APPROVAL_OIDC_CLIENT_SECRET or pass --secret-stdin. --secret-stdin checks the line and discards it. Deploy uploads only the environment variable");
|
|
331
346
|
if (name === "pair" && rest[0] === "set-password") {
|
package/lib/pair.mjs
CHANGED
|
@@ -156,10 +156,13 @@ export async function list(o) {
|
|
|
156
156
|
: `human (the owner approves with the owner token, or on ${d.verificationUri} with the approval password)`;
|
|
157
157
|
console.log(`approval: ${how}`);
|
|
158
158
|
if (d.mode !== "off") {
|
|
159
|
+
const setPassword = `run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal)`;
|
|
159
160
|
console.log(`approval password: ${d.passwordSet ? `set${d.passwordSetAt ? ` ${d.passwordSetAt}` : ""}${d.passwordSetVia ? ` (via ${d.passwordSetVia})` : ""}`
|
|
160
|
-
: `NOT SET
|
|
161
|
+
: d.manageUrl ? `NOT SET. Your human can approve on the dashboard (${d.manageUrl}). To use the /device page, ${setPassword}`
|
|
162
|
+
: `NOT SET: ${setPassword}`}`);
|
|
161
163
|
if (d.oidcIssuer) console.log(`OpenID Connect: ${(d.approvalMethods || []).join(" and ")} (${d.oidcIssuer})`);
|
|
162
164
|
if (d.setupLinkExpiresAt) console.log(`setup link: one is open until ${d.setupLinkExpiresAt} (a new \`pair set-password --web\` replaces it)`);
|
|
165
|
+
if (d.manageUrl) console.log(`dashboard: ${d.manageUrl}`);
|
|
163
166
|
}
|
|
164
167
|
if (!d.pending.length) return console.log("(no pending pairing requests)");
|
|
165
168
|
for (const p of d.pending) {
|
package/lib/status.mjs
CHANGED
|
@@ -118,7 +118,7 @@ export function nextStep(s) {
|
|
|
118
118
|
function nextStepCore(s) {
|
|
119
119
|
const fail = (text) => ({ ok: false, text, also: [] });
|
|
120
120
|
const done = (text, also = []) => ({ ok: true, text, also });
|
|
121
|
-
if (!s.deployed) return fail(`nothing is deployed from ${path.dirname(s.configFile)}. Another deployment? Set A2A_CONFIG_DIR.`);
|
|
121
|
+
if (!s.deployed) return fail(`nothing is deployed from ${path.dirname(s.configFile)}. Run \`${CLI} init\` (see the quick start). Another deployment? Set A2A_CONFIG_DIR.`);
|
|
122
122
|
if (s.baseUrlEnv)
|
|
123
123
|
return fail(`A2A_BASE_URL is exported as ${s.baseUrlEnv}, but ${s.configFile} has ${s.baseUrlSaved || "(none)"}: CLI commands (and push URLs sent to peers) use the exported value. Unset A2A_BASE_URL (the A2A endpoint URL is always the deployment's own)`);
|
|
124
124
|
if (!s.baseUrl || !s.hasOwnerToken)
|
|
@@ -239,7 +239,8 @@ export async function collect() {
|
|
|
239
239
|
if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
|
|
240
240
|
s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
|
|
241
241
|
setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
|
|
242
|
-
...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {})
|
|
242
|
+
...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
|
|
243
|
+
...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
|
|
243
244
|
} catch { /* older Worker (no pairing yet): leave unknown */ }
|
|
244
245
|
if (s.upstream) {
|
|
245
246
|
try { s.facade = await owner("GET", "/owner/facade"); }
|
|
@@ -357,7 +358,7 @@ export async function status(o) {
|
|
|
357
358
|
if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
|
|
358
359
|
const pg = s.pairing;
|
|
359
360
|
if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
|
|
360
|
-
: `${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.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
|
|
361
|
+
: `${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` : ""}`]);
|
|
361
362
|
}
|
|
362
363
|
if (mcpAdd) rows.push(["mcp", mcpAdd]);
|
|
363
364
|
rows.push(["next step", next.text]);
|
package/package.json
CHANGED
|
@@ -64,6 +64,8 @@ const plain: Record<string, string> = {
|
|
|
64
64
|
APPROVAL_METHODS: v("APPROVAL_METHODS"),
|
|
65
65
|
// Optional https URL of this agent's dashboard page. A pairing wake and the MCP inbox line include it as manageUrl.
|
|
66
66
|
MANAGE_URL: v("MANAGE_URL"),
|
|
67
|
+
// Optional https URL for a signed pairing event. EVENT_SECRET is uploaded as a secret. Both are required for a post.
|
|
68
|
+
EVENT_URL: v("EVENT_URL"),
|
|
67
69
|
};
|
|
68
70
|
const hosted = v("TENANCY") === "host";
|
|
69
71
|
// Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
|
|
@@ -103,7 +105,8 @@ for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY
|
|
|
103
105
|
const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
|
|
104
106
|
// Declared for `cf dev` only when a deploy asks for them, so a self-host .dev.vars without these still starts.
|
|
105
107
|
...(v("TENANCY") === "host" ? ["TENANT_SECRETS_KEY"] : []),
|
|
106
|
-
...(v("TENANCY") === "host" || v("APPROVAL_OIDC_ISSUER") ? ["APPROVAL_OIDC_CLIENT_SECRET"] : [])
|
|
108
|
+
...(v("TENANCY") === "host" || v("APPROVAL_OIDC_ISSUER") ? ["APPROVAL_OIDC_CLIENT_SECRET"] : []),
|
|
109
|
+
...(v("EVENT_URL") ? ["EVENT_SECRET"] : [])];
|
|
107
110
|
|
|
108
111
|
export default defineConfig((ctx) => ({
|
|
109
112
|
...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
|
|
@@ -44,9 +44,13 @@ WAKE_PRESET=generic
|
|
|
44
44
|
# APPROVAL_OIDC_ALLOWED_SUBJECTS=
|
|
45
45
|
# APPROVAL_METHODS=password
|
|
46
46
|
# Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET.
|
|
47
|
-
# Optional https URL of this agent's dashboard page. When set, a pairing wake
|
|
48
|
-
# include it as manageUrl.
|
|
47
|
+
# Optional https URL of this agent's dashboard page. When set, a pairing wake, the MCP inbox line,
|
|
48
|
+
# `pair list`, and `inbox` include it as manageUrl. Set it with --manage-url (none clears it).
|
|
49
49
|
# MANAGE_URL=https://example.com/app/agents/name#pairing
|
|
50
|
+
# Optional pairing event. When EVENT_URL is https and EVENT_SECRET is set, a new request and an
|
|
51
|
+
# approval or denial POST a signed JSON body to that URL. Set the URL with --event-url (none clears it).
|
|
52
|
+
# EVENT_URL=https://example.com/api/agent-events
|
|
53
|
+
# Secret: --event-secret-stdin, or EVENT_SECRET in the environment. Uploaded on deploy, never stored here, never printed.
|
|
50
54
|
# Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
|
|
51
55
|
# A2A_BACKUP_BUCKET=1
|
|
52
56
|
# A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
|
package/worker/src/hosted.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBo
|
|
|
20
20
|
import { hostedDailyBackup } from "./backup.ts";
|
|
21
21
|
import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
|
|
22
22
|
import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
|
|
23
|
-
import { WAKE_SECRET_KEYS, openWakeSecrets } from "./wake-secrets.ts";
|
|
23
|
+
import { WAKE_SECRET_KEYS, omitSealedSecrets, openWakeSecrets } from "./wake-secrets.ts";
|
|
24
24
|
|
|
25
25
|
export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
|
|
26
26
|
export type { DirectoryEntry };
|
|
@@ -279,19 +279,20 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
279
279
|
await this.#enqueueArm(false);
|
|
280
280
|
return { version: current.version, applied: false };
|
|
281
281
|
}
|
|
282
|
-
const config = requireObject(body.config, "config");
|
|
283
282
|
const limits = requireObject(body.limits, "limits");
|
|
284
283
|
const approval = requireObject(body.approval, "approval");
|
|
284
|
+
const secretsEnc = body.secretsEnc ?? null;
|
|
285
|
+
const storedConfig = omitSealedSecrets(requireObject(body.config, "config"), secretsEnc);
|
|
285
286
|
const next: StoredConfig = {
|
|
286
287
|
version: body.version,
|
|
287
288
|
tenantId: body.tenantId,
|
|
288
289
|
name: body.name,
|
|
289
290
|
status: body.status,
|
|
290
|
-
config: stringRecord(
|
|
291
|
+
config: stringRecord(storedConfig),
|
|
291
292
|
limits,
|
|
292
293
|
approval,
|
|
293
294
|
ownerTokenHash: body.ownerTokenHash ? body.ownerTokenHash.toLowerCase() : null,
|
|
294
|
-
secretsEnc
|
|
295
|
+
secretsEnc,
|
|
295
296
|
};
|
|
296
297
|
const now = new Date().toISOString();
|
|
297
298
|
this.#tx(() => {
|
|
@@ -304,7 +305,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
304
305
|
config_json = excluded.config_json, limits_json = excluded.limits_json, approval_json = excluded.approval_json,
|
|
305
306
|
owner_token_hash = excluded.owner_token_hash, secrets_enc = excluded.secrets_enc, updated_at = excluded.updated_at`,
|
|
306
307
|
next.version, next.tenantId, next.name, next.status,
|
|
307
|
-
JSON.stringify(
|
|
308
|
+
JSON.stringify(storedConfig), JSON.stringify(limits), JSON.stringify(approval),
|
|
308
309
|
next.ownerTokenHash, next.secretsEnc, now,
|
|
309
310
|
);
|
|
310
311
|
});
|
package/worker/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { fetchCheckedWake } from "./wake-target.ts";
|
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
13
13
|
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
14
|
+
import { pairingAgent, pairingEventTarget, postPairingEvent } from "./pairing-events.ts";
|
|
14
15
|
import { publicManageUrl, resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
15
16
|
import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql } from "./export.ts";
|
|
16
17
|
import { backupDatabase, DAILY_CRON } from "./backup.ts";
|
|
@@ -1058,7 +1059,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1058
1059
|
return json({ revoked: true, label });
|
|
1059
1060
|
}
|
|
1060
1061
|
}
|
|
1061
|
-
if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body, trusted);
|
|
1062
|
+
if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
|
|
1062
1063
|
if (seg[0] === "mcp" && !seg[1] && m === "GET") {
|
|
1063
1064
|
const rows = await ctx.DB.prepare("SELECT label, client_id, client_name, redirect_host, scope, created_at, last_used_at, revoked_at, refresh_expires_ms FROM mcp_grants ORDER BY created_at").all();
|
|
1064
1065
|
return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
|
|
@@ -1270,13 +1271,15 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
|
|
|
1270
1271
|
return oauthError("slow_down", "this A2A endpoint has too many pairing requests waiting; try again later", 429, retry);
|
|
1271
1272
|
}
|
|
1272
1273
|
const deviceCode = P.newDeviceCode();
|
|
1274
|
+
const deviceHash = await A.sha256(deviceCode);
|
|
1275
|
+
const country = clientCountry(req);
|
|
1273
1276
|
let userCode = "";
|
|
1274
1277
|
for (let i = 0; i < 5 && !userCode; i++) {
|
|
1275
1278
|
const c = P.newUserCode();
|
|
1276
1279
|
const r = await ctx.DB.prepare(
|
|
1277
1280
|
`INSERT INTO device_requests (device_hash, user_code, client_id, client_name, agent_card_url, ip, country, status, created_ms, expires_ms, interval_s)
|
|
1278
1281
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
|
|
1279
|
-
).bind(
|
|
1282
|
+
).bind(deviceHash, c, clientId || null, clientName || null, cardUrl || null, ip || null, country || null,
|
|
1280
1283
|
now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
|
|
1281
1284
|
if (r.meta.changes === 1) userCode = c;
|
|
1282
1285
|
}
|
|
@@ -1302,6 +1305,8 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
|
|
|
1302
1305
|
...(manage ? { manageUrl: manage } : {}),
|
|
1303
1306
|
...(replaces ? { replacesLabel: replaces.label } : {}) } });
|
|
1304
1307
|
})());
|
|
1308
|
+
publishPairingEvent(ctx, ectx, { type: "pairing_requested", requestId: deviceHash, code: shown, name: clientName, alias: clientId,
|
|
1309
|
+
ip, country, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + P.EXPIRES_S * 1000).toISOString() });
|
|
1305
1310
|
return json({ device_code: deviceCode, user_code: shown, verification_uri: u.page, verification_uri_complete: complete,
|
|
1306
1311
|
expires_in: P.EXPIRES_S, interval: P.INTERVAL_S, ...(replaces ? { replaces_label: replaces.label } : {}) }, 200, { pragma: "no-cache" });
|
|
1307
1312
|
}
|
|
@@ -1374,12 +1379,15 @@ async function freeLabel(ctx: TenantContext, base: string): Promise<string> {
|
|
|
1374
1379
|
const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json | null> =>
|
|
1375
1380
|
ctx.DB.prepare("SELECT * FROM device_requests WHERE user_code = ?").bind(code).first();
|
|
1376
1381
|
|
|
1382
|
+
const PAIRING_NOT_FOUND = "Not found. Ask the other agent to run connect again.";
|
|
1383
|
+
const PAIRING_EXPIRED = "Not found. The code expired. Ask the other agent to run connect again.";
|
|
1384
|
+
|
|
1377
1385
|
/** Approve or deny a pending request. Returns the label (approve) or an error message. */
|
|
1378
|
-
async function decide(ctx: TenantContext, code: string, approve: boolean, by: string): Promise<{ ok: boolean; label?: string; error?: string; request?: Json; replaced?: boolean }> {
|
|
1386
|
+
async function decide(ctx: TenantContext, ectx: ExecutionContext, code: string, approve: boolean, by: string): Promise<{ ok: boolean; label?: string; error?: string; request?: Json; replaced?: boolean }> {
|
|
1379
1387
|
const r = await pendingRequest(ctx, code);
|
|
1380
|
-
if (!r) return { ok: false, error:
|
|
1388
|
+
if (!r) return { ok: false, error: PAIRING_NOT_FOUND };
|
|
1381
1389
|
if (r.status !== "pending") return { ok: false, error: `that request was already ${r.status === "denied" ? "denied" : "approved"}` };
|
|
1382
|
-
if (Date.now() >= r.expires_ms) return { ok: false, error:
|
|
1390
|
+
if (Date.now() >= r.expires_ms) return { ok: false, error: PAIRING_EXPIRED };
|
|
1383
1391
|
const replacing = approve && r.replaces_label && r.replaces_hash
|
|
1384
1392
|
? !!(await ctx.DB.prepare("SELECT 1 FROM peers WHERE label = ? AND token_hash = ? AND revoked_at IS NULL").bind(r.replaces_label, r.replaces_hash).first()) : false;
|
|
1385
1393
|
const label = !approve ? null : replacing ? r.replaces_label : await freeLabel(ctx, P.labelBase(r.client_name || "", r.client_id || ""));
|
|
@@ -1387,9 +1395,46 @@ async function decide(ctx: TenantContext, code: string, approve: boolean, by: st
|
|
|
1387
1395
|
.bind(approve ? "approved" : "denied", label, Date.now(), by, r.device_hash).run();
|
|
1388
1396
|
if (u.meta.changes !== 1) return { ok: false, error: "that request was decided meanwhile" };
|
|
1389
1397
|
log(approve ? "pairing_approved" : "pairing_denied", { userCode: P.formatUserCode(code), by, label, ...(replacing ? { replaces: label } : {}) });
|
|
1398
|
+
publishPairingEvent(ctx, ectx, { type: "pairing_decided", requestId: String(r.device_hash), code: P.formatUserCode(code),
|
|
1399
|
+
name: r.client_name || "", alias: r.client_id || "", ip: r.ip || "", country: r.country || "",
|
|
1400
|
+
createdAt: new Date(r.created_ms).toISOString(), expiresAt: new Date(r.expires_ms).toISOString(),
|
|
1401
|
+
decision: approve ? "approved" : "denied" });
|
|
1390
1402
|
return { ok: true, label: label || undefined, request: r, replaced: replacing };
|
|
1391
1403
|
}
|
|
1392
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* Hand the open-count query and the EVENT_URL post to waitUntil.
|
|
1407
|
+
* The HTTP response returns before either of them runs. A missing URL or secret leaves the request as it was.
|
|
1408
|
+
*/
|
|
1409
|
+
function publishPairingEvent(ctx: TenantContext, ectx: ExecutionContext, event: {
|
|
1410
|
+
type: "pairing_requested" | "pairing_decided";
|
|
1411
|
+
requestId: string;
|
|
1412
|
+
code: string;
|
|
1413
|
+
name: string;
|
|
1414
|
+
alias: string;
|
|
1415
|
+
ip: string;
|
|
1416
|
+
country: string;
|
|
1417
|
+
createdAt: string;
|
|
1418
|
+
expiresAt: string;
|
|
1419
|
+
decision?: "approved" | "denied";
|
|
1420
|
+
}): void {
|
|
1421
|
+
const target = pairingEventTarget(ctx.EVENT_URL, ctx.EVENT_SECRET);
|
|
1422
|
+
if (!target) return;
|
|
1423
|
+
const agent = pairingAgent(ctx.PUBLIC_URL, ctx.id);
|
|
1424
|
+
try {
|
|
1425
|
+
ectx.waitUntil((async () => {
|
|
1426
|
+
try {
|
|
1427
|
+
const row: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(Date.now()).first();
|
|
1428
|
+
await postPairingEvent(target, { ...event, agent, pending: Number(row?.n ?? 0) });
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
|
|
1431
|
+
}
|
|
1432
|
+
})());
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1393
1438
|
const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, clientName: r.client_name || "", clientId: r.client_id || "",
|
|
1394
1439
|
agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
|
|
1395
1440
|
replacesLabel: r.replaces_label || undefined });
|
|
@@ -1439,7 +1484,7 @@ function cookie(req: Request, name: string): string {
|
|
|
1439
1484
|
}
|
|
1440
1485
|
|
|
1441
1486
|
/** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
|
|
1442
|
-
async function devicePage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1487
|
+
async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionContext, url: URL, head = false): Promise<Response> {
|
|
1443
1488
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1444
1489
|
const csrfName = "a2a_device_csrf";
|
|
1445
1490
|
let csrf = cookie(req, csrfName);
|
|
@@ -1452,8 +1497,11 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1452
1497
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1453
1498
|
return new Response(P.devicePage(model, nonce), { status, headers: h });
|
|
1454
1499
|
};
|
|
1500
|
+
// An unknown or expired code is 404. The empty form and a code that is not the WDJB-4827 shape stay 200.
|
|
1501
|
+
let lookupStatus = 200;
|
|
1455
1502
|
const show = async (raw: string | null, notice?: P.PageModel["notice"]) => {
|
|
1456
1503
|
model.notice = notice;
|
|
1504
|
+
lookupStatus = 200;
|
|
1457
1505
|
if (raw && await overLimit(ctx, `look:ip:${ip || "?"}`, P.LIMITS.lookupsWindowS, P.LIMITS.lookupsPerIp, head)) {
|
|
1458
1506
|
model.askCode = true;
|
|
1459
1507
|
model.notice = { kind: "error", text: "Too many code lookups from this address. Try again in a few minutes." };
|
|
@@ -1465,14 +1513,18 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1465
1513
|
else {
|
|
1466
1514
|
model.askCode = true;
|
|
1467
1515
|
model.codeValue = raw || "";
|
|
1468
|
-
if (raw && !notice)
|
|
1469
|
-
|
|
1470
|
-
|
|
1516
|
+
if (raw && !notice) {
|
|
1517
|
+
let text = "That is not a valid code (it looks like WDJB-4827).";
|
|
1518
|
+
if (code && !r) { lookupStatus = 404; text = PAIRING_NOT_FOUND; }
|
|
1519
|
+
else if (r && r.status !== "pending") text = `That request was already ${r.status === "denied" ? "denied" : "approved"}.`;
|
|
1520
|
+
else if (code && r) { lookupStatus = 404; text = PAIRING_EXPIRED; }
|
|
1521
|
+
model.notice = { kind: "error", text };
|
|
1522
|
+
}
|
|
1471
1523
|
}
|
|
1472
1524
|
};
|
|
1473
1525
|
if (req.method === "GET") {
|
|
1474
1526
|
await show(url.searchParams.get("user_code"));
|
|
1475
|
-
return send();
|
|
1527
|
+
return send(lookupStatus);
|
|
1476
1528
|
}
|
|
1477
1529
|
// POST: same-origin form only (Origin check + double-submit cookie); the approval password is the real gate
|
|
1478
1530
|
const origin = req.headers.get("origin");
|
|
@@ -1494,7 +1546,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1494
1546
|
}
|
|
1495
1547
|
const r0 = await pendingRequest(ctx, code);
|
|
1496
1548
|
if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
|
|
1497
|
-
const d0 = await decide(ctx, code, false, "page");
|
|
1549
|
+
const d0 = await decide(ctx, ectx, code, false, "page");
|
|
1498
1550
|
if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
|
|
1499
1551
|
model.settled = "denied";
|
|
1500
1552
|
model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
|
|
@@ -1534,14 +1586,14 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1534
1586
|
const left = P.LIMITS.wrongPerCode - (res?.failed_attempts ?? P.LIMITS.wrongPerCode);
|
|
1535
1587
|
log("pairing_wrong_password", { ip, userCode: P.formatUserCode(code), left });
|
|
1536
1588
|
if (left <= 0) {
|
|
1537
|
-
await decide(ctx, code, false, "lockout");
|
|
1589
|
+
await decide(ctx, ectx, code, false, "lockout");
|
|
1538
1590
|
await show(null, { kind: "error", text: "Wrong password. Too many wrong attempts for this code: the request was denied." });
|
|
1539
1591
|
return send(403);
|
|
1540
1592
|
}
|
|
1541
1593
|
await show(form.user_code, { kind: "error", text: `Wrong password. ${left} attempt${left === 1 ? "" : "s"} left for this code.` });
|
|
1542
1594
|
return send(403);
|
|
1543
1595
|
}
|
|
1544
|
-
const d = await decide(ctx, code, true, "human");
|
|
1596
|
+
const d = await decide(ctx, ectx, code, true, "human");
|
|
1545
1597
|
if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
|
|
1546
1598
|
const who = r.client_name || r.client_id || "the agent";
|
|
1547
1599
|
model.settled = "approved";
|
|
@@ -1550,7 +1602,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1550
1602
|
}
|
|
1551
1603
|
|
|
1552
1604
|
/** GET /device/oidc/callback: the identity provider returns here. State is the CSRF; the cookie is not re-checked. */
|
|
1553
|
-
async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1605
|
+
async function deviceOidcCallback(req: Request, ctx: TenantContext, ectx: ExecutionContext, url: URL, head = false): Promise<Response> {
|
|
1554
1606
|
const nonce = P.randomB64();
|
|
1555
1607
|
const pw = await approvalPassword(ctx);
|
|
1556
1608
|
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, timeZone: clientTimeZone(req), ...approvalPageBits(ctx) };
|
|
@@ -1593,7 +1645,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
|
|
|
1593
1645
|
model.notice = { kind: "error", text: done.error };
|
|
1594
1646
|
return send(400);
|
|
1595
1647
|
}
|
|
1596
|
-
const d = await decide(ctx, done.userCode || "", true, "oidc");
|
|
1648
|
+
const d = await decide(ctx, ectx, done.userCode || "", true, "oidc");
|
|
1597
1649
|
if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
|
|
1598
1650
|
const who = d.request.client_name || d.request.client_id || "the agent";
|
|
1599
1651
|
model.settled = "approved";
|
|
@@ -1686,7 +1738,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
|
|
|
1686
1738
|
}
|
|
1687
1739
|
|
|
1688
1740
|
/** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
|
|
1689
|
-
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
|
|
1741
|
+
async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
|
|
1690
1742
|
const mode = pairingMode(ctx);
|
|
1691
1743
|
if (!seg[0] && m === "GET") {
|
|
1692
1744
|
const now = Date.now();
|
|
@@ -1697,10 +1749,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1697
1749
|
// with pairing off, leftover requests can't be approved or redeemed (every pairing endpoint is 404): don't list them
|
|
1698
1750
|
const pending = mode === "off" ? [] : (rows.results as Json[]);
|
|
1699
1751
|
const policy = O.approvalPolicy(ctx.gates.approvalOidc);
|
|
1752
|
+
const manage = dashboardUrl(ctx);
|
|
1700
1753
|
return json({ mode, passwordSet: !!pw, passwordSetAt: pw?.setAt || null, passwordSetVia: pw ? pw.setVia || "terminal" : null,
|
|
1701
1754
|
passwordIterations: pw?.iterations || null, pbkdf2Iterations: P.pbkdf2Iterations(ctx.PBKDF2_ITERATIONS),
|
|
1702
1755
|
approvalMethods: [...(policy.password ? ["password"] : []), ...(policy.oidc ? ["oidc"] : [])],
|
|
1703
1756
|
oidcIssuer: policy.oidc ? O.issuerHost(ctx.gates.approvalOidc.issuer) : null,
|
|
1757
|
+
...(manage ? { manageUrl: manage } : {}),
|
|
1704
1758
|
setupLinkExpiresAt: linkLive ? new Date(setup!.rec.expiresMs).toISOString() : null, verificationUri: oauthUrls(ctx).page,
|
|
1705
1759
|
pending: pending.map((r) => ({ userCode: P.formatUserCode(r.user_code), clientName: r.client_name, clientId: r.client_id,
|
|
1706
1760
|
agentCardUrl: r.agent_card_url, ip: r.ip, country: r.country, createdAt: new Date(r.created_ms).toISOString(),
|
|
@@ -1747,7 +1801,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1747
1801
|
if (approve && mode !== "agent" && !dashboardHuman && !ownerAuthed)
|
|
1748
1802
|
return json({ error: `approval mode is human: this call cannot approve. The owner approves with the owner token, or on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password.` }, 403);
|
|
1749
1803
|
const by = dashboardHuman ? "human" : approve && mode === "agent" ? "agent" : "owner";
|
|
1750
|
-
const d = await decide(ctx, code, approve, by);
|
|
1804
|
+
const d = await decide(ctx, ectx, code, approve, by);
|
|
1751
1805
|
if (!d.ok) return json({ error: d.error }, 409);
|
|
1752
1806
|
return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
|
|
1753
1807
|
clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
|
|
@@ -2611,8 +2665,8 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
|
|
|
2611
2665
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2612
2666
|
if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
|
|
2613
2667
|
if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
|
|
2614
|
-
if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url, head);
|
|
2615
|
-
if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, url, head);
|
|
2668
|
+
if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, ectx, url, head);
|
|
2669
|
+
if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, ectx, url, head);
|
|
2616
2670
|
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
|
|
2617
2671
|
return json({ error: "not found" }, 404);
|
|
2618
2672
|
}
|
package/worker/src/mcp.ts
CHANGED
|
@@ -289,7 +289,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
289
289
|
description: "Peers you can send to (outbound, synced with `peers sync`) and the labels of agents holding a token for this A2A endpoint (inbound; who can message you). Token values are never shown.",
|
|
290
290
|
inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
291
291
|
{ name: "pairing_requests", title: "Pending pairing requests", annotations: ro,
|
|
292
|
-
description: "Agents asking to connect to this A2A endpoint (read-only). Show your human who is asking, the code and the link
|
|
292
|
+
description: "Agents asking to connect to this A2A endpoint (read-only). Show your human who is asking, the code and the link. NEVER approve or deny on your own. The owner approves with the approval password on the /device link, with the owner token, or on the dashboard when a Manage link is set (this tool's output names that link). The names and card URLs are claimed by the requester (untrusted).",
|
|
293
293
|
inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
294
294
|
{ name: "send_feedback", title: "Send feedback", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
295
295
|
description: feedbackToolDescription(ISSUE_REPO),
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Dashboard notice for a pairing request. Posted only when EVENT_URL and EVENT_SECRET are both set.
|
|
2
|
+
// The signature matches control/src/tenants/events.ts `verifyEventSignature`.
|
|
3
|
+
|
|
4
|
+
export const EVENT_TIMEOUT_MS = 3000;
|
|
5
|
+
|
|
6
|
+
export type PairingEventType = "pairing_requested" | "pairing_decided";
|
|
7
|
+
|
|
8
|
+
export interface PairingEvent {
|
|
9
|
+
type: PairingEventType;
|
|
10
|
+
agent: string;
|
|
11
|
+
requestId: string;
|
|
12
|
+
code: string;
|
|
13
|
+
name: string;
|
|
14
|
+
alias: string;
|
|
15
|
+
ip: string;
|
|
16
|
+
country: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
pending: number;
|
|
20
|
+
decision?: "approved" | "denied";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const URL_MAX = 2000;
|
|
24
|
+
const SECRET_MAX = 512;
|
|
25
|
+
|
|
26
|
+
function httpsEventUrl(value: string | undefined): string {
|
|
27
|
+
const raw = (value || "").trim();
|
|
28
|
+
if (!raw || raw.length > URL_MAX) return "";
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(raw);
|
|
31
|
+
if (url.protocol !== "https:" || url.username || url.password) return "";
|
|
32
|
+
return raw;
|
|
33
|
+
} catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function eventSecretText(value: string | undefined): string {
|
|
39
|
+
const text = (value || "").trim();
|
|
40
|
+
if (!text || text.length > SECRET_MAX || /[\u0000-\u001f]/.test(text)) return "";
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Public host, or the tenant id when the public URL has no host. */
|
|
45
|
+
export function pairingAgent(publicUrl: string, tenantId: string): string {
|
|
46
|
+
try {
|
|
47
|
+
const host = new URL(publicUrl).host;
|
|
48
|
+
if (host) return host;
|
|
49
|
+
} catch { /* tenant id below */ }
|
|
50
|
+
return tenantId || "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The post target, or null when either value is missing or unusable. */
|
|
54
|
+
export function pairingEventTarget(url: string | undefined, secret: string | undefined): { url: string; secret: string } | null {
|
|
55
|
+
const target = httpsEventUrl(url);
|
|
56
|
+
const key = eventSecretText(secret);
|
|
57
|
+
if (!target || !key) return null;
|
|
58
|
+
return { url: target, secret: key };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stable JSON. `decision` is present only on a decided request. */
|
|
62
|
+
export function serializePairingEvent(event: PairingEvent): string {
|
|
63
|
+
const body: Record<string, string | number> = {
|
|
64
|
+
type: event.type,
|
|
65
|
+
agent: event.agent,
|
|
66
|
+
requestId: event.requestId,
|
|
67
|
+
code: event.code,
|
|
68
|
+
name: event.name,
|
|
69
|
+
alias: event.alias,
|
|
70
|
+
ip: event.ip,
|
|
71
|
+
country: event.country,
|
|
72
|
+
createdAt: event.createdAt,
|
|
73
|
+
expiresAt: event.expiresAt,
|
|
74
|
+
pending: event.pending,
|
|
75
|
+
};
|
|
76
|
+
if (event.type === "pairing_decided" && (event.decision === "approved" || event.decision === "denied")) body.decision = event.decision;
|
|
77
|
+
return JSON.stringify(body);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function hmacHex(secret: string, message: string): Promise<string> {
|
|
81
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
82
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
83
|
+
return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const log = (event: string, kw: Record<string, unknown> = {}) => console.log(JSON.stringify({ ts: new Date().toISOString(), event, ...kw }));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* POST the event. One retry after a timeout or a failed response. The promise settles
|
|
90
|
+
* on its own: a network error stays inside this function.
|
|
91
|
+
*/
|
|
92
|
+
export async function postPairingEvent(
|
|
93
|
+
target: { url: string; secret: string },
|
|
94
|
+
event: PairingEvent,
|
|
95
|
+
opts: { fetchImpl?: typeof fetch; timeoutMs?: number; now?: number } = {},
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
try {
|
|
98
|
+
const body = serializePairingEvent(event);
|
|
99
|
+
const timestamp = String(Math.floor((opts.now ?? Date.now()) / 1000));
|
|
100
|
+
const signature = `sha256=${await hmacHex(target.secret, `${timestamp}.${body}`)}`;
|
|
101
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
102
|
+
const timeoutMs = opts.timeoutMs ?? EVENT_TIMEOUT_MS;
|
|
103
|
+
const headers = { "content-type": "application/json", "x-a2a-timestamp": timestamp, "x-a2a-signature": signature };
|
|
104
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetchImpl(target.url, { method: "POST", headers, body, redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
|
|
107
|
+
try { await res.body?.cancel(); } catch { /* the status is enough */ }
|
|
108
|
+
if (res.ok) {
|
|
109
|
+
log("pairing_event_sent", { type: event.type });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
log("pairing_event_failed", { type: event.type, attempt, status: res.status });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
log("pairing_event_failed", { type: event.type, attempt, error: String(error).slice(0, 200) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
|
|
119
|
+
}
|
|
120
|
+
}
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -41,6 +41,10 @@ export const CONFIG_KEYS = [
|
|
|
41
41
|
"MCP",
|
|
42
42
|
/** Optional https URL of this agent's dashboard page. Omitted from pairing output when unset or not https. */
|
|
43
43
|
"MANAGE_URL",
|
|
44
|
+
/** Optional https URL that receives a signed pairing event. Omitted from the post when the secret is absent. */
|
|
45
|
+
"EVENT_URL",
|
|
46
|
+
/** HMAC key for that post. A Worker secret on a self-hosted agent. On a hosted agent the sealed secrets blob supplies it. */
|
|
47
|
+
"EVENT_SECRET",
|
|
44
48
|
] as const;
|
|
45
49
|
|
|
46
50
|
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
@@ -383,6 +387,8 @@ export class TenantContext implements ConfigFields {
|
|
|
383
387
|
readonly UPSTREAM_ACCESS_CLIENT_SECRET?: string;
|
|
384
388
|
readonly MCP?: string;
|
|
385
389
|
readonly MANAGE_URL?: string;
|
|
390
|
+
readonly EVENT_URL?: string;
|
|
391
|
+
readonly EVENT_SECRET?: string;
|
|
386
392
|
|
|
387
393
|
/** Self-host only. Never set on a hosted context, so a platform OWNER_TOKEN cannot unlock a tenant. */
|
|
388
394
|
readonly #ownerToken?: string;
|
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.10";
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
const INFO = "tenant-wake-secrets v1";
|
|
6
6
|
const SALT = "a2a-exposed";
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Sealed secret fields. Anything else in the blob is ignored. */
|
|
9
9
|
export const WAKE_SECRET_KEYS = [
|
|
10
10
|
"WAKE_WEBHOOK_KEY",
|
|
11
11
|
"WAKE_HMAC_SECRET",
|
|
12
12
|
"WAKE_ACCESS_CLIENT_ID",
|
|
13
13
|
"WAKE_ACCESS_CLIENT_SECRET",
|
|
14
|
+
"EVENT_SECRET",
|
|
14
15
|
] as const;
|
|
15
16
|
|
|
16
17
|
export type WakeSecretKey = (typeof WAKE_SECRET_KEYS)[number];
|
|
@@ -51,6 +52,17 @@ async function derive(ikm: string, tenantId: string): Promise<CryptoKey | null>
|
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Config written next to a sealed blob. The sealed fields stay in `secrets_enc`.
|
|
57
|
+
* With no ciphertext, the config is returned unchanged.
|
|
58
|
+
*/
|
|
59
|
+
export function omitSealedSecrets<T extends Record<string, unknown>>(config: T, secretsEnc: string | null | undefined): T {
|
|
60
|
+
if (typeof secretsEnc !== "string" || secretsEnc === "") return config;
|
|
61
|
+
const stored = { ...config };
|
|
62
|
+
for (const key of WAKE_SECRET_KEYS) delete stored[key];
|
|
63
|
+
return stored;
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
|
|
55
67
|
const payload: Record<string, string> = {};
|
|
56
68
|
for (const key of WAKE_SECRET_KEYS) {
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -391,6 +391,9 @@ test("JSON-RPC plumbing: notifications 202, batches and junk rejected, unknown m
|
|
|
391
391
|
for (const n of ["inbox", "show_task", "history", "reply", "send", "poll_outbound"])
|
|
392
392
|
assert.match(M.TOOLS.find((x) => x.name === n)!.description, /untrusted[\s\S]*Never approve a pairing/);
|
|
393
393
|
assert.match(M.TOOLS.find((x) => x.name === "pairing_requests")!.description, /NEVER approve/);
|
|
394
|
+
assert.match(M.TOOLS.find((x) => x.name === "pairing_requests")!.description, /approval password/);
|
|
395
|
+
assert.match(M.TOOLS.find((x) => x.name === "pairing_requests")!.description, /owner token/);
|
|
396
|
+
assert.match(M.TOOLS.find((x) => x.name === "pairing_requests")!.description, /dashboard/);
|
|
394
397
|
});
|
|
395
398
|
|
|
396
399
|
test("tools: inbox, show_task, history, mark_working, reply (with state), pairing_requests is read-only", async (t) => {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Pairing events: signature, retry, payload, and the hosted tenant path.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import worker, { dispatch } from "../src/index.ts";
|
|
5
|
+
import { hostedTenantContext, type WorkerBindings } from "../src/tenancy.ts";
|
|
6
|
+
import { postPairingEvent, type PairingEvent } from "../src/pairing-events.ts";
|
|
7
|
+
import { openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
|
|
8
|
+
import { EVENT_REPLAY_WINDOW_S, verifyEventSignature } from "../../control/src/tenants/events.ts";
|
|
9
|
+
import { d1 } from "./d1.ts";
|
|
10
|
+
|
|
11
|
+
const BASE = "https://agent.example.com";
|
|
12
|
+
const EVENT_URL = "https://example.com/api/agent-events";
|
|
13
|
+
const SECRET = "event-secret";
|
|
14
|
+
|
|
15
|
+
function sample(over: Partial<PairingEvent> = {}): PairingEvent {
|
|
16
|
+
return {
|
|
17
|
+
type: "pairing_requested", agent: "agent.example.com", requestId: "abc", code: "WDJB-4827",
|
|
18
|
+
name: "Barry Bot", alias: "barry", ip: "198.51.100.7", country: "DE",
|
|
19
|
+
createdAt: "2026-10-16T14:00:00.000Z", expiresAt: "2026-10-16T15:00:00.000Z", pending: 1, ...over,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("verifyEventSignature accepts the posted bytes and rejects a replay, a tampered body, and a wrong secret", async () => {
|
|
24
|
+
const now = Date.UTC(2026, 9, 16, 14, 0, 0);
|
|
25
|
+
const seen: { body: string; headers: Headers }[] = [];
|
|
26
|
+
await postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
|
|
27
|
+
now,
|
|
28
|
+
fetchImpl: (async (url: string, init: RequestInit) => {
|
|
29
|
+
seen.push({ body: String(init.body), headers: new Headers(init.headers) });
|
|
30
|
+
assert.equal(url, EVENT_URL);
|
|
31
|
+
assert.equal(init.redirect, "manual");
|
|
32
|
+
return new Response("ok");
|
|
33
|
+
}) as typeof fetch,
|
|
34
|
+
});
|
|
35
|
+
assert.equal(seen.length, 1);
|
|
36
|
+
const { body, headers } = seen[0];
|
|
37
|
+
const parsed = JSON.parse(body);
|
|
38
|
+
assert.equal(parsed.type, "pairing_requested");
|
|
39
|
+
assert.equal(parsed.pending, 1);
|
|
40
|
+
assert.equal("decision" in parsed, false);
|
|
41
|
+
assert.equal(await verifyEventSignature(body, headers, SECRET, now), true);
|
|
42
|
+
assert.equal(await verifyEventSignature(body, headers, SECRET, now + EVENT_REPLAY_WINDOW_S * 1000), true);
|
|
43
|
+
assert.equal(await verifyEventSignature(body, headers, SECRET, now + (EVENT_REPLAY_WINDOW_S + 1) * 1000), false);
|
|
44
|
+
assert.equal(await verifyEventSignature(body + " ", headers, SECRET, now), false);
|
|
45
|
+
assert.equal(await verifyEventSignature(body, headers, "other-secret", now), false);
|
|
46
|
+
const shifted = new Headers(headers);
|
|
47
|
+
shifted.set("x-a2a-timestamp", String(Math.floor(now / 1000) + 1));
|
|
48
|
+
assert.equal(await verifyEventSignature(body, shifted, SECRET, now), false);
|
|
49
|
+
|
|
50
|
+
const decided = sample({ type: "pairing_decided", decision: "approved", pending: 0 });
|
|
51
|
+
let decidedBody = "";
|
|
52
|
+
await postPairingEvent({ url: EVENT_URL, secret: SECRET }, decided, {
|
|
53
|
+
now,
|
|
54
|
+
fetchImpl: (async (_url: string, init: RequestInit) => {
|
|
55
|
+
decidedBody = String(init.body);
|
|
56
|
+
return new Response("ok");
|
|
57
|
+
}) as typeof fetch,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(JSON.parse(decidedBody).decision, "approved");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a timeout is retried once and a failed post stays inside the function", async () => {
|
|
63
|
+
let calls = 0;
|
|
64
|
+
await postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
|
|
65
|
+
timeoutMs: 30,
|
|
66
|
+
fetchImpl: (async (_url: string, init: RequestInit) => {
|
|
67
|
+
calls++;
|
|
68
|
+
if (calls === 1) {
|
|
69
|
+
await new Promise((_resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(() => reject(new Error("the attempt was not aborted")), 1000);
|
|
71
|
+
init.signal?.addEventListener("abort", () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return new Response("ok");
|
|
78
|
+
}) as typeof fetch,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(calls, 2);
|
|
81
|
+
|
|
82
|
+
let failed = 0;
|
|
83
|
+
await postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
|
|
84
|
+
timeoutMs: 30,
|
|
85
|
+
fetchImpl: (async () => {
|
|
86
|
+
failed++;
|
|
87
|
+
return new Response("no", { status: 503 });
|
|
88
|
+
}) as typeof fetch,
|
|
89
|
+
});
|
|
90
|
+
assert.equal(failed, 2);
|
|
91
|
+
|
|
92
|
+
await assert.doesNotReject(postPairingEvent({ url: EVENT_URL, secret: SECRET }, sample(), {
|
|
93
|
+
timeoutMs: 30,
|
|
94
|
+
fetchImpl: (async () => { throw new Error("network down"); }) as typeof fetch,
|
|
95
|
+
}));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function env(over: Record<string, string> = {}) {
|
|
99
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
100
|
+
return { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", ...over };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function start(bindings: Record<string, string>, fetchImpl: typeof fetch) {
|
|
104
|
+
const pending: Promise<unknown>[] = [];
|
|
105
|
+
const bindingsEnv = env(bindings);
|
|
106
|
+
// Durable Object storage applies migrations on the first query. Run that query before the timed request
|
|
107
|
+
// so the race covers the pairing response.
|
|
108
|
+
await bindingsEnv.DB.prepare("SELECT 1").first();
|
|
109
|
+
const previous = globalThis.fetch;
|
|
110
|
+
globalThis.fetch = fetchImpl;
|
|
111
|
+
const req = new Request(BASE + "/oauth/device_authorization", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.7" },
|
|
114
|
+
body: new URLSearchParams({ client_name: "Barry Bot", client_id: "barry", agent_card_url: "https://barry.example.org/card" }).toString(),
|
|
115
|
+
});
|
|
116
|
+
(req as { cf?: unknown }).cf = { country: "DE" };
|
|
117
|
+
const response = worker.fetch(req, bindingsEnv as WorkerBindings, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as ExecutionContext);
|
|
118
|
+
return {
|
|
119
|
+
response, pending, restore: () => { globalThis.fetch = previous; },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test("the response returns before the event post, and the payload carries the open count", async (t) => {
|
|
124
|
+
let release: () => void = () => {};
|
|
125
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
126
|
+
let body = "";
|
|
127
|
+
let headers = new Headers();
|
|
128
|
+
let started = false;
|
|
129
|
+
const run = await start({ EVENT_URL, EVENT_SECRET: SECRET }, (async (url: string, init: RequestInit) => {
|
|
130
|
+
if (String(url) !== EVENT_URL) return new Response("ok");
|
|
131
|
+
started = true;
|
|
132
|
+
body = String(init?.body);
|
|
133
|
+
headers = new Headers(init?.headers);
|
|
134
|
+
await gate;
|
|
135
|
+
return new Response("ok");
|
|
136
|
+
}) as typeof fetch);
|
|
137
|
+
t.after(async () => {
|
|
138
|
+
release();
|
|
139
|
+
await run.response.catch(() => {});
|
|
140
|
+
await Promise.allSettled(run.pending);
|
|
141
|
+
run.restore();
|
|
142
|
+
});
|
|
143
|
+
const res = await Promise.race([
|
|
144
|
+
run.response,
|
|
145
|
+
new Promise<Response>((_resolve, reject) => setTimeout(() => reject(new Error("the pairing response waited for the event post")), 500)),
|
|
146
|
+
]);
|
|
147
|
+
assert.equal(started, false);
|
|
148
|
+
assert.equal(res.status, 200);
|
|
149
|
+
const data = await res.json() as { user_code: string };
|
|
150
|
+
const deadline = Date.now() + 2000;
|
|
151
|
+
while (!started && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
152
|
+
assert.equal(started, true);
|
|
153
|
+
release();
|
|
154
|
+
await Promise.all(run.pending);
|
|
155
|
+
const event = JSON.parse(body);
|
|
156
|
+
assert.equal(event.type, "pairing_requested");
|
|
157
|
+
assert.equal(event.agent, "agent.example.com");
|
|
158
|
+
assert.equal(event.code, data.user_code);
|
|
159
|
+
assert.equal(event.name, "Barry Bot");
|
|
160
|
+
assert.equal(event.alias, "barry");
|
|
161
|
+
assert.equal(event.ip, "198.51.100.7");
|
|
162
|
+
assert.equal(event.country, "DE");
|
|
163
|
+
assert.equal(event.pending, 1);
|
|
164
|
+
assert.match(event.requestId, /^[0-9a-f]{64}$/);
|
|
165
|
+
assert.equal(await verifyEventSignature(body, headers, SECRET), true);
|
|
166
|
+
|
|
167
|
+
const quiet: string[] = [];
|
|
168
|
+
const off = await start({}, (async (url: string) => {
|
|
169
|
+
quiet.push(String(url));
|
|
170
|
+
return new Response("ok");
|
|
171
|
+
}) as typeof fetch);
|
|
172
|
+
assert.equal((await off.response).status, 200);
|
|
173
|
+
await Promise.all(off.pending);
|
|
174
|
+
off.restore();
|
|
175
|
+
assert.equal(quiet.some((url) => url === EVENT_URL), false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a hosted tenant posts with the control plane's EVENT_URL and EVENT_SECRET", async () => {
|
|
179
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
180
|
+
await DB.prepare("SELECT 1").first();
|
|
181
|
+
const sealed = await sealWakeSecrets("platform-secret", "ten_north", { EVENT_SECRET: SECRET });
|
|
182
|
+
assert.ok(sealed);
|
|
183
|
+
assert.equal(sealed.includes(SECRET), false);
|
|
184
|
+
const opened = await openWakeSecrets("platform-secret", "ten_north", sealed);
|
|
185
|
+
const config: Record<string, string> = { EVENT_URL, EVENT_SECRET: "plaintext-copy" };
|
|
186
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
187
|
+
const value = opened?.[name];
|
|
188
|
+
if (value) config[name] = value;
|
|
189
|
+
else delete config[name];
|
|
190
|
+
}
|
|
191
|
+
const ctx = hostedTenantContext({ DB, TENANCY: "host", EVENT_URL: "https://platform.example/other", EVENT_SECRET: "platform" } as WorkerBindings, {
|
|
192
|
+
id: "ten_north", db: DB, publicUrl: "https://northwind.example.com",
|
|
193
|
+
config,
|
|
194
|
+
});
|
|
195
|
+
assert.equal(ctx.mode, "host");
|
|
196
|
+
assert.equal(ctx.EVENT_URL, EVENT_URL);
|
|
197
|
+
assert.equal(ctx.EVENT_SECRET, SECRET);
|
|
198
|
+
const posts: { body: string; headers: Headers }[] = [];
|
|
199
|
+
const previous = globalThis.fetch;
|
|
200
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
201
|
+
if (String(url) === EVENT_URL) posts.push({ body: String(init.body), headers: new Headers(init.headers) });
|
|
202
|
+
return new Response("ok");
|
|
203
|
+
}) as typeof fetch;
|
|
204
|
+
const pending: Promise<unknown>[] = [];
|
|
205
|
+
const req = new Request("https://northwind.example.com/oauth/device_authorization", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "203.0.113.9" },
|
|
208
|
+
body: new URLSearchParams({ client_name: "Claude", client_id: "claude" }).toString(),
|
|
209
|
+
});
|
|
210
|
+
(req as { cf?: unknown }).cf = { country: "US" };
|
|
211
|
+
const res = await dispatch(req, ctx, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as ExecutionContext);
|
|
212
|
+
await Promise.all(pending);
|
|
213
|
+
globalThis.fetch = previous;
|
|
214
|
+
assert.equal(res.status, 200);
|
|
215
|
+
assert.equal(posts.length, 1);
|
|
216
|
+
const event = JSON.parse(posts[0].body);
|
|
217
|
+
assert.equal(event.agent, "northwind.example.com");
|
|
218
|
+
assert.equal(event.name, "Claude");
|
|
219
|
+
assert.equal(event.alias, "claude");
|
|
220
|
+
assert.equal(event.country, "US");
|
|
221
|
+
assert.equal(event.pending, 1);
|
|
222
|
+
assert.equal(await verifyEventSignature(posts[0].body, posts[0].headers, SECRET), true);
|
|
223
|
+
|
|
224
|
+
const plain = hostedTenantContext({ DB, TENANCY: "host", EVENT_URL, EVENT_SECRET: SECRET } as WorkerBindings, {
|
|
225
|
+
id: "ten_plain", db: DB, publicUrl: "https://plain.example.com",
|
|
226
|
+
});
|
|
227
|
+
assert.equal(plain.EVENT_URL, undefined);
|
|
228
|
+
assert.equal(plain.EVENT_SECRET, undefined);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("approve posts pairing_decided with the remaining open count", async () => {
|
|
232
|
+
const bindings = env({ EVENT_URL, EVENT_SECRET: SECRET });
|
|
233
|
+
const posts: { body: string }[] = [];
|
|
234
|
+
const previous = globalThis.fetch;
|
|
235
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
236
|
+
if (String(url) === EVENT_URL) posts.push({ body: String(init.body) });
|
|
237
|
+
return new Response("ok");
|
|
238
|
+
}) as typeof fetch;
|
|
239
|
+
const pending: Promise<unknown>[] = [];
|
|
240
|
+
const ectx = { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as ExecutionContext;
|
|
241
|
+
const call = async (method: string, path: string, body?: string, headers: Record<string, string> = {}) => {
|
|
242
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers, body }), bindings as WorkerBindings, ectx);
|
|
243
|
+
await Promise.all(pending.splice(0));
|
|
244
|
+
return res;
|
|
245
|
+
};
|
|
246
|
+
const started = await call("POST", "/oauth/device_authorization", new URLSearchParams({ client_name: "Barry Bot", client_id: "barry" }).toString(), {
|
|
247
|
+
"content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.7",
|
|
248
|
+
});
|
|
249
|
+
const first = await started.json() as { user_code: string };
|
|
250
|
+
const secondRes = await call("POST", "/oauth/device_authorization", new URLSearchParams({ client_name: "Other", client_id: "other" }).toString(), {
|
|
251
|
+
"content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.8",
|
|
252
|
+
});
|
|
253
|
+
assert.equal(secondRes.status, 200);
|
|
254
|
+
const denied = await call("POST", `/owner/pairing/${first.user_code}/deny`, "{}", { authorization: "Bearer owner-secret", "content-type": "application/json" });
|
|
255
|
+
globalThis.fetch = previous;
|
|
256
|
+
assert.equal(denied.status, 200);
|
|
257
|
+
const decided = posts.map((post) => JSON.parse(post.body)).filter((event) => event.type === "pairing_decided");
|
|
258
|
+
assert.equal(decided.length, 1);
|
|
259
|
+
assert.equal(decided[0].decision, "denied");
|
|
260
|
+
assert.equal(decided[0].code, first.user_code);
|
|
261
|
+
assert.equal(decided[0].pending, 1);
|
|
262
|
+
assert.equal(decided[0].name, "Barry Bot");
|
|
263
|
+
});
|
|
@@ -452,7 +452,20 @@ test("labels are deduplicated; denied and expired requests; JSON bodies; bad req
|
|
|
452
452
|
const e = (await s.start(undefined, "203.0.113.4")).data;
|
|
453
453
|
advance(P.EXPIRES_S + 1);
|
|
454
454
|
assert.equal((await s.poll(e.device_code)).data.error, "expired_token");
|
|
455
|
-
|
|
455
|
+
const expired = await s.owner("POST", `/owner/pairing/${e.user_code}/approve`);
|
|
456
|
+
assert.equal(expired.status, 409);
|
|
457
|
+
assert.match(expired.data.error, /Not found\. The code expired\. Ask the other agent to run connect again\./);
|
|
458
|
+
const expiredPage = await s.call("GET", `/device?user_code=${e.user_code}`);
|
|
459
|
+
assert.equal(expiredPage.status, 404);
|
|
460
|
+
assert.match(expiredPage.text, /Not found\. The code expired\. Ask the other agent to run connect again\./);
|
|
461
|
+
const unknown = await s.owner("POST", "/owner/pairing/BCDF-2345/deny");
|
|
462
|
+
assert.equal(unknown.status, 409);
|
|
463
|
+
assert.match(unknown.data.error, /Not found\. Ask the other agent to run connect again\./);
|
|
464
|
+
const unknownPage = await s.call("GET", "/device?user_code=BCDF-2345");
|
|
465
|
+
assert.equal(unknownPage.status, 404);
|
|
466
|
+
assert.match(unknownPage.text, /Not found\. Ask the other agent to run connect again\./);
|
|
467
|
+
assert.equal((await s.call("GET", "/device")).status, 200, "the empty code form stays 200");
|
|
468
|
+
assert.equal((await s.call("GET", "/device?user_code=AEIO-0000")).status, 200, "a code that is not the WDJB-4827 shape stays 200");
|
|
456
469
|
advance(3600);
|
|
457
470
|
assert.equal((await s.poll(e.device_code)).data.error, "expired_token", "still expired_token an hour later (RFC 8628), not invalid_grant");
|
|
458
471
|
advance(86400);
|
|
@@ -940,6 +953,7 @@ test("approval page shows the visitor's clock, and an https MANAGE_URL is on the
|
|
|
940
953
|
assert.equal(page.text.includes(" UTC "), false);
|
|
941
954
|
const wake = s.wakes[0].body.pairing;
|
|
942
955
|
assert.equal(wake.manageUrl, pageUrl);
|
|
956
|
+
assert.equal((await s.owner("GET", "/owner/pairing")).data.manageUrl, pageUrl);
|
|
943
957
|
assert.equal(wake.expiresIn, 3600);
|
|
944
958
|
assert.ok(wake.instructions.includes(`The dashboard page for this request is ${pageUrl}`));
|
|
945
959
|
|
|
@@ -947,6 +961,7 @@ test("approval page shows the visitor's clock, and an https MANAGE_URL is on the
|
|
|
947
961
|
t.after(bare.restore);
|
|
948
962
|
const plain = await bare.start(undefined, "203.0.113.81");
|
|
949
963
|
assert.equal("manageUrl" in bare.wakes[0].body.pairing, false);
|
|
964
|
+
assert.equal("manageUrl" in (await bare.owner("GET", "/owner/pairing")).data, false);
|
|
950
965
|
const utc = await bare.call("GET", `/device?user_code=${plain.data.user_code}`, { ip: "203.0.113.81" });
|
|
951
966
|
assert.match(utc.text, /\d{2}:\d{2} UTC \(60 min\)/);
|
|
952
967
|
|
|
@@ -6,6 +6,7 @@ import worker from "../src/index.ts";
|
|
|
6
6
|
import { sha256 } from "../src/a2a.ts";
|
|
7
7
|
import { openPeerToken, openPeerTokenWithKey, sealPeerToken, sealPeerTokenWithKey } from "../src/mcp.ts";
|
|
8
8
|
import { hostedTenantContext, namespaceForRegion, parseGates, publicManageUrl, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
9
|
+
import { openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
|
|
9
10
|
import { d1 } from "./d1.ts";
|
|
10
11
|
|
|
11
12
|
const dummy = { prepare() { throw new Error("storage should not be touched"); } } as any;
|
|
@@ -35,6 +36,31 @@ test("publicManageUrl keeps an https dashboard page and drops anything else", ()
|
|
|
35
36
|
assert.equal(hostedUnset.MANAGE_URL, undefined);
|
|
36
37
|
});
|
|
37
38
|
|
|
39
|
+
test("EVENT_URL and EVENT_SECRET come from the tenant on a hosted agent", async () => {
|
|
40
|
+
const url = "https://example.com/api/agent-events";
|
|
41
|
+
assert.equal(resolveTenant(selfEnv({ EVENT_URL: url, EVENT_SECRET: "event-secret" })).EVENT_SECRET, "event-secret");
|
|
42
|
+
const sealed = await sealWakeSecrets("platform-secret", "ten_a", { EVENT_SECRET: "tenant-secret" });
|
|
43
|
+
assert.ok(sealed);
|
|
44
|
+
assert.equal(sealed.includes("tenant-secret"), false);
|
|
45
|
+
const opened = await openWakeSecrets("platform-secret", "ten_a", sealed);
|
|
46
|
+
const config: Record<string, string> = { EVENT_URL: url, EVENT_SECRET: "plaintext-copy" };
|
|
47
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
48
|
+
const value = opened?.[name];
|
|
49
|
+
if (value) config[name] = value;
|
|
50
|
+
else delete config[name];
|
|
51
|
+
}
|
|
52
|
+
const hosted = hostedTenantContext(selfEnv({ TENANCY: "host", EVENT_URL: "https://platform.example/other", EVENT_SECRET: "platform" }), {
|
|
53
|
+
id: "ten_a", db: dummy, publicUrl: BASE, config,
|
|
54
|
+
});
|
|
55
|
+
assert.equal(hosted.EVENT_URL, url);
|
|
56
|
+
assert.equal(hosted.EVENT_SECRET, "tenant-secret");
|
|
57
|
+
const hostedUnset = hostedTenantContext(selfEnv({ TENANCY: "host", EVENT_URL: url, EVENT_SECRET: "platform" }), {
|
|
58
|
+
id: "ten_a", db: dummy, publicUrl: BASE,
|
|
59
|
+
});
|
|
60
|
+
assert.equal(hostedUnset.EVENT_URL, undefined);
|
|
61
|
+
assert.equal(hostedUnset.EVENT_SECRET, undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
38
64
|
test("gates: unset is single-tenant and every reserved gate is off", () => {
|
|
39
65
|
const g = parseGates({ DB: dummy } as WorkerBindings).gates;
|
|
40
66
|
assert.equal(g.tenancy, "single");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { MAX_WAKE_SEALED, MAX_WAKE_VALUE, openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
|
|
3
|
+
import { MAX_WAKE_SEALED, MAX_WAKE_VALUE, omitSealedSecrets, openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
|
|
4
4
|
|
|
5
5
|
const key = "platform-secret";
|
|
6
6
|
const tenant = "ten_a";
|
|
@@ -30,7 +30,7 @@ test("a wake secret blob keeps only the known fields", async () => {
|
|
|
30
30
|
assert.equal(await sealWakeSecrets(key, tenant, { WAKE_WEBHOOK_KEY: "x".repeat(4097) }), null);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test("
|
|
33
|
+
test("every maximum-length sealed secret round-trips", async () => {
|
|
34
34
|
const value = "x".repeat(MAX_WAKE_VALUE);
|
|
35
35
|
const secrets = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, value]));
|
|
36
36
|
const sealed = await sealWakeSecrets(key, tenant, secrets);
|
|
@@ -47,3 +47,16 @@ test("four maximum-length wake secrets round-trip", async () => {
|
|
|
47
47
|
assert.ok(sealedWorst!.length <= MAX_WAKE_SEALED);
|
|
48
48
|
assert.deepEqual(await openWakeSecrets(key, tenant, sealedWorst), worst);
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
test("a sealed blob's fields are left out of the stored config", () => {
|
|
52
|
+
const url = "https://example.com/api/agent-events";
|
|
53
|
+
const stored = omitSealedSecrets({
|
|
54
|
+
AGENT_NAME: "northwind", EVENT_URL: url, EVENT_SECRET: "event-secret", WAKE_WEBHOOK_KEY: "hook-key",
|
|
55
|
+
}, "v1.iv.ct");
|
|
56
|
+
assert.equal(stored.EVENT_URL, url);
|
|
57
|
+
assert.equal(stored.AGENT_NAME, "northwind");
|
|
58
|
+
assert.equal("EVENT_SECRET" in stored, false);
|
|
59
|
+
assert.equal("WAKE_WEBHOOK_KEY" in stored, false);
|
|
60
|
+
assert.equal(omitSealedSecrets({ EVENT_SECRET: "event-secret" }, null).EVENT_SECRET, "event-secret");
|
|
61
|
+
assert.equal(omitSealedSecrets({ EVENT_SECRET: "event-secret" }, "").EVENT_SECRET, "event-secret");
|
|
62
|
+
});
|