a2a-exposed 0.8.8 → 0.8.9
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/deploy.mjs +3 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +4 -1
- package/worker/deploy.env.example +4 -0
- package/worker/src/hosted.ts +6 -5
- package/worker/src/index.ts +55 -13
- 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/pairing-events.test.ts +263 -0
- package/worker/test/tenancy.test.ts +26 -0
- package/worker/test/wake-secrets.test.ts +15 -2
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 = [
|
|
@@ -171,7 +172,7 @@ export function wakeSecretsFromEnv() {
|
|
|
171
172
|
/** Secrets init/deploy upload when exported: the wake secrets plus, for proxy mode, the upstream credentials. */
|
|
172
173
|
export function deploySecretsFromEnv() {
|
|
173
174
|
const s = wakeSecretsFromEnv();
|
|
174
|
-
for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS]) if (process.env[k]) s[k] = process.env[k];
|
|
175
|
+
for (const k of [...UPSTREAM_SECRETS, ...OIDC_SECRETS, ...EVENT_SECRETS]) if (process.env[k]) s[k] = process.env[k];
|
|
175
176
|
return s;
|
|
176
177
|
}
|
|
177
178
|
|
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") } : {}),
|
|
@@ -47,6 +47,10 @@ WAKE_PRESET=generic
|
|
|
47
47
|
# Optional https URL of this agent's dashboard page. When set, a pairing wake and the MCP inbox line
|
|
48
48
|
# include it as manageUrl.
|
|
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. The secret is uploaded on deploy, not stored here.
|
|
52
|
+
# EVENT_URL=https://example.com/api/agent-events
|
|
53
|
+
# Secret (same secrets file, never here): EVENT_SECRET.
|
|
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
|
}
|
|
@@ -1375,7 +1380,7 @@ const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json |
|
|
|
1375
1380
|
ctx.DB.prepare("SELECT * FROM device_requests WHERE user_code = ?").bind(code).first();
|
|
1376
1381
|
|
|
1377
1382
|
/** 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 }> {
|
|
1383
|
+
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
1384
|
const r = await pendingRequest(ctx, code);
|
|
1380
1385
|
if (!r) return { ok: false, error: "no pairing request with that code" };
|
|
1381
1386
|
if (r.status !== "pending") return { ok: false, error: `that request was already ${r.status === "denied" ? "denied" : "approved"}` };
|
|
@@ -1387,9 +1392,46 @@ async function decide(ctx: TenantContext, code: string, approve: boolean, by: st
|
|
|
1387
1392
|
.bind(approve ? "approved" : "denied", label, Date.now(), by, r.device_hash).run();
|
|
1388
1393
|
if (u.meta.changes !== 1) return { ok: false, error: "that request was decided meanwhile" };
|
|
1389
1394
|
log(approve ? "pairing_approved" : "pairing_denied", { userCode: P.formatUserCode(code), by, label, ...(replacing ? { replaces: label } : {}) });
|
|
1395
|
+
publishPairingEvent(ctx, ectx, { type: "pairing_decided", requestId: String(r.device_hash), code: P.formatUserCode(code),
|
|
1396
|
+
name: r.client_name || "", alias: r.client_id || "", ip: r.ip || "", country: r.country || "",
|
|
1397
|
+
createdAt: new Date(r.created_ms).toISOString(), expiresAt: new Date(r.expires_ms).toISOString(),
|
|
1398
|
+
decision: approve ? "approved" : "denied" });
|
|
1390
1399
|
return { ok: true, label: label || undefined, request: r, replaced: replacing };
|
|
1391
1400
|
}
|
|
1392
1401
|
|
|
1402
|
+
/**
|
|
1403
|
+
* Hand the open-count query and the EVENT_URL post to waitUntil.
|
|
1404
|
+
* The HTTP response returns before either of them runs. A missing URL or secret leaves the request as it was.
|
|
1405
|
+
*/
|
|
1406
|
+
function publishPairingEvent(ctx: TenantContext, ectx: ExecutionContext, event: {
|
|
1407
|
+
type: "pairing_requested" | "pairing_decided";
|
|
1408
|
+
requestId: string;
|
|
1409
|
+
code: string;
|
|
1410
|
+
name: string;
|
|
1411
|
+
alias: string;
|
|
1412
|
+
ip: string;
|
|
1413
|
+
country: string;
|
|
1414
|
+
createdAt: string;
|
|
1415
|
+
expiresAt: string;
|
|
1416
|
+
decision?: "approved" | "denied";
|
|
1417
|
+
}): void {
|
|
1418
|
+
const target = pairingEventTarget(ctx.EVENT_URL, ctx.EVENT_SECRET);
|
|
1419
|
+
if (!target) return;
|
|
1420
|
+
const agent = pairingAgent(ctx.PUBLIC_URL, ctx.id);
|
|
1421
|
+
try {
|
|
1422
|
+
ectx.waitUntil((async () => {
|
|
1423
|
+
try {
|
|
1424
|
+
const row: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(Date.now()).first();
|
|
1425
|
+
await postPairingEvent(target, { ...event, agent, pending: Number(row?.n ?? 0) });
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
|
|
1428
|
+
}
|
|
1429
|
+
})());
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
log("pairing_event_failed", { type: event.type, error: String(error).slice(0, 200) });
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1393
1435
|
const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, clientName: r.client_name || "", clientId: r.client_id || "",
|
|
1394
1436
|
agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
|
|
1395
1437
|
replacesLabel: r.replaces_label || undefined });
|
|
@@ -1439,7 +1481,7 @@ function cookie(req: Request, name: string): string {
|
|
|
1439
1481
|
}
|
|
1440
1482
|
|
|
1441
1483
|
/** 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> {
|
|
1484
|
+
async function devicePage(req: Request, ctx: TenantContext, ectx: ExecutionContext, url: URL, head = false): Promise<Response> {
|
|
1443
1485
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1444
1486
|
const csrfName = "a2a_device_csrf";
|
|
1445
1487
|
let csrf = cookie(req, csrfName);
|
|
@@ -1494,7 +1536,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1494
1536
|
}
|
|
1495
1537
|
const r0 = await pendingRequest(ctx, code);
|
|
1496
1538
|
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");
|
|
1539
|
+
const d0 = await decide(ctx, ectx, code, false, "page");
|
|
1498
1540
|
if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
|
|
1499
1541
|
model.settled = "denied";
|
|
1500
1542
|
model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
|
|
@@ -1534,14 +1576,14 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1534
1576
|
const left = P.LIMITS.wrongPerCode - (res?.failed_attempts ?? P.LIMITS.wrongPerCode);
|
|
1535
1577
|
log("pairing_wrong_password", { ip, userCode: P.formatUserCode(code), left });
|
|
1536
1578
|
if (left <= 0) {
|
|
1537
|
-
await decide(ctx, code, false, "lockout");
|
|
1579
|
+
await decide(ctx, ectx, code, false, "lockout");
|
|
1538
1580
|
await show(null, { kind: "error", text: "Wrong password. Too many wrong attempts for this code: the request was denied." });
|
|
1539
1581
|
return send(403);
|
|
1540
1582
|
}
|
|
1541
1583
|
await show(form.user_code, { kind: "error", text: `Wrong password. ${left} attempt${left === 1 ? "" : "s"} left for this code.` });
|
|
1542
1584
|
return send(403);
|
|
1543
1585
|
}
|
|
1544
|
-
const d = await decide(ctx, code, true, "human");
|
|
1586
|
+
const d = await decide(ctx, ectx, code, true, "human");
|
|
1545
1587
|
if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
|
|
1546
1588
|
const who = r.client_name || r.client_id || "the agent";
|
|
1547
1589
|
model.settled = "approved";
|
|
@@ -1550,7 +1592,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1550
1592
|
}
|
|
1551
1593
|
|
|
1552
1594
|
/** 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> {
|
|
1595
|
+
async function deviceOidcCallback(req: Request, ctx: TenantContext, ectx: ExecutionContext, url: URL, head = false): Promise<Response> {
|
|
1554
1596
|
const nonce = P.randomB64();
|
|
1555
1597
|
const pw = await approvalPassword(ctx);
|
|
1556
1598
|
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 +1635,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
|
|
|
1593
1635
|
model.notice = { kind: "error", text: done.error };
|
|
1594
1636
|
return send(400);
|
|
1595
1637
|
}
|
|
1596
|
-
const d = await decide(ctx, done.userCode || "", true, "oidc");
|
|
1638
|
+
const d = await decide(ctx, ectx, done.userCode || "", true, "oidc");
|
|
1597
1639
|
if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
|
|
1598
1640
|
const who = d.request.client_name || d.request.client_id || "the agent";
|
|
1599
1641
|
model.settled = "approved";
|
|
@@ -1686,7 +1728,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
|
|
|
1686
1728
|
}
|
|
1687
1729
|
|
|
1688
1730
|
/** 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> {
|
|
1731
|
+
async function ownerPairing(req: Request, ctx: TenantContext, ectx: ExecutionContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
|
|
1690
1732
|
const mode = pairingMode(ctx);
|
|
1691
1733
|
if (!seg[0] && m === "GET") {
|
|
1692
1734
|
const now = Date.now();
|
|
@@ -1747,7 +1789,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1747
1789
|
if (approve && mode !== "agent" && !dashboardHuman && !ownerAuthed)
|
|
1748
1790
|
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
1791
|
const by = dashboardHuman ? "human" : approve && mode === "agent" ? "agent" : "owner";
|
|
1750
|
-
const d = await decide(ctx, code, approve, by);
|
|
1792
|
+
const d = await decide(ctx, ectx, code, approve, by);
|
|
1751
1793
|
if (!d.ok) return json({ error: d.error }, 409);
|
|
1752
1794
|
return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
|
|
1753
1795
|
clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
|
|
@@ -2611,8 +2653,8 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
|
|
|
2611
2653
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2612
2654
|
if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
|
|
2613
2655
|
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);
|
|
2656
|
+
if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, ectx, url, head);
|
|
2657
|
+
if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, ectx, url, head);
|
|
2616
2658
|
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
|
|
2617
2659
|
return json({ error: "not found" }, 404);
|
|
2618
2660
|
}
|
|
@@ -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.9";
|
|
@@ -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) {
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|