a2a-exposed 0.8.7 → 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/lib/main.mjs +1 -1
- package/lib/pair.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +6 -1
- package/worker/deploy.env.example +7 -0
- package/worker/src/hosted.ts +6 -5
- package/worker/src/index.ts +71 -20
- package/worker/src/pairing-events.ts +120 -0
- package/worker/src/pairing.ts +29 -6
- package/worker/src/tenancy.ts +22 -0
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-secrets.ts +13 -1
- package/worker/src/wake.ts +7 -2
- package/worker/test/mcp.test.ts +25 -1
- package/worker/test/pairing-events.test.ts +263 -0
- package/worker/test/pairing.test.ts +53 -11
- package/worker/test/tenancy.test.ts +47 -1
- 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/lib/main.mjs
CHANGED
|
@@ -168,7 +168,7 @@ Inbox (owner side)
|
|
|
168
168
|
Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens into chat)
|
|
169
169
|
connect <base-or-card-url> [--alias A] [--name N] [--card-url URL] [--replace] [--no-wait] [--json]
|
|
170
170
|
ask another A2A endpoint for a token: prints a code + link for your human (the code lasts
|
|
171
|
-
|
|
171
|
+
60 minutes; they confirm the code with that A2A endpoint's owner), waits for approval, stores
|
|
172
172
|
the token as outbound peer A
|
|
173
173
|
(never printed). --no-wait: print the code and exit; run the same command again to
|
|
174
174
|
check (same code). An expired code exits 1 (nothing new is requested silently).
|
package/lib/pair.mjs
CHANGED
|
@@ -364,7 +364,7 @@ A different agent at the same URL? Pass another --alias.`);
|
|
|
364
364
|
}
|
|
365
365
|
st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
|
|
366
366
|
verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
|
|
367
|
-
interval: Math.max(1, Number(r.data.interval) || 5), expiresAt: Date.now() + (Number(r.data.expires_in) ||
|
|
367
|
+
interval: Math.max(1, Number(r.data.interval) || 5), expiresAt: Date.now() + (Number(r.data.expires_in) || 3600) * 1000,
|
|
368
368
|
replacesLabel: r.data.replaces_label || null, replacing: replace && !!oldToken };
|
|
369
369
|
saveState(alias, st); // chmod 600: the device code is a bearer secret until it is redeemed
|
|
370
370
|
const mins = Math.round((st.expiresAt - Date.now()) / 60000);
|
package/package.json
CHANGED
|
@@ -62,6 +62,10 @@ const plain: Record<string, string> = {
|
|
|
62
62
|
APPROVAL_OIDC_CLIENT_ID: v("APPROVAL_OIDC_CLIENT_ID"),
|
|
63
63
|
APPROVAL_OIDC_ALLOWED_SUBJECTS: v("APPROVAL_OIDC_ALLOWED_SUBJECTS"),
|
|
64
64
|
APPROVAL_METHODS: v("APPROVAL_METHODS"),
|
|
65
|
+
// Optional https URL of this agent's dashboard page. A pairing wake and the MCP inbox line include it as manageUrl.
|
|
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"),
|
|
65
69
|
};
|
|
66
70
|
const hosted = v("TENANCY") === "host";
|
|
67
71
|
// Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
|
|
@@ -101,7 +105,8 @@ for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY
|
|
|
101
105
|
const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
|
|
102
106
|
// Declared for `cf dev` only when a deploy asks for them, so a self-host .dev.vars without these still starts.
|
|
103
107
|
...(v("TENANCY") === "host" ? ["TENANT_SECRETS_KEY"] : []),
|
|
104
|
-
...(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"] : [])];
|
|
105
110
|
|
|
106
111
|
export default defineConfig((ctx) => ({
|
|
107
112
|
...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
|
|
@@ -44,6 +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 and the MCP inbox line
|
|
48
|
+
# include it as manageUrl.
|
|
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.
|
|
47
54
|
# Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
|
|
48
55
|
# A2A_BACKUP_BUCKET=1
|
|
49
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,7 +11,8 @@ 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 {
|
|
14
|
+
import { pairingAgent, pairingEventTarget, postPairingEvent } from "./pairing-events.ts";
|
|
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";
|
|
17
18
|
import * as O from "./approval-oidc.ts";
|
|
@@ -159,7 +160,7 @@ const WAKE_RETRIES = 3;
|
|
|
159
160
|
/** The wake endpoint said 429 / 503 (e.g. a Claude Code routine over its fire limit): keep the inbound wake pending and
|
|
160
161
|
* flush it once Retry-After (30 s - 1 h) has passed, on the next request to the Worker or the optional cron. At most
|
|
161
162
|
* WAKE_RETRIES times per wake; a newer message for the conversation merges in and starts over. Pairing and test wakes
|
|
162
|
-
* are not retried (a pairing code
|
|
163
|
+
* are not retried (a pairing code lasts 60 minutes). */
|
|
163
164
|
async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json, retryAfter: string | null) {
|
|
164
165
|
if (payload.kind !== "inbound") return;
|
|
165
166
|
const attempts = (Number(payload.attempts) || 0) + 1;
|
|
@@ -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,
|
|
@@ -1241,6 +1242,11 @@ async function bump(ctx: TenantContext, key: string, windowS: number, peek = fal
|
|
|
1241
1242
|
|
|
1242
1243
|
const clientIp = (req: Request) => req.headers.get("cf-connecting-ip") || "";
|
|
1243
1244
|
const clientCountry = (req: Request) => String(((req as Json).cf || {}).country || "");
|
|
1245
|
+
const clientTimeZone = (req: Request) => {
|
|
1246
|
+
const zone = ((req as Json).cf || {}).timezone;
|
|
1247
|
+
return typeof zone === "string" ? zone : "";
|
|
1248
|
+
};
|
|
1249
|
+
const dashboardUrl = (ctx: TenantContext) => publicManageUrl(ctx.MANAGE_URL);
|
|
1244
1250
|
|
|
1245
1251
|
async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: ExecutionContext): Promise<Response> {
|
|
1246
1252
|
const now = Date.now(), ip = clientIp(req);
|
|
@@ -1265,13 +1271,15 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
|
|
|
1265
1271
|
return oauthError("slow_down", "this A2A endpoint has too many pairing requests waiting; try again later", 429, retry);
|
|
1266
1272
|
}
|
|
1267
1273
|
const deviceCode = P.newDeviceCode();
|
|
1274
|
+
const deviceHash = await A.sha256(deviceCode);
|
|
1275
|
+
const country = clientCountry(req);
|
|
1268
1276
|
let userCode = "";
|
|
1269
1277
|
for (let i = 0; i < 5 && !userCode; i++) {
|
|
1270
1278
|
const c = P.newUserCode();
|
|
1271
1279
|
const r = await ctx.DB.prepare(
|
|
1272
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)
|
|
1273
1281
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?) ON CONFLICT DO NOTHING`,
|
|
1274
|
-
).bind(
|
|
1282
|
+
).bind(deviceHash, c, clientId || null, clientName || null, cardUrl || null, ip || null, country || null,
|
|
1275
1283
|
now, now + P.EXPIRES_S * 1000, P.INTERVAL_S).run();
|
|
1276
1284
|
if (r.meta.changes === 1) userCode = c;
|
|
1277
1285
|
}
|
|
@@ -1288,13 +1296,17 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
|
|
|
1288
1296
|
const complete = `${u.page}?user_code=${shown}`;
|
|
1289
1297
|
log("pairing_requested", { ip, userCode: shown, clientName, clientId, ...(replaces ? { replaces: replaces.label } : {}) });
|
|
1290
1298
|
const mode = pairingMode(ctx) as "human" | "agent";
|
|
1299
|
+
const manage = dashboardUrl(ctx);
|
|
1291
1300
|
ectx.waitUntil((async () => {
|
|
1292
1301
|
if (!(await wakeBudgetOk(ctx))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
|
|
1293
1302
|
await sendWake(ctx, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
|
|
1294
1303
|
kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
|
|
1295
1304
|
...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}), expiresIn: P.EXPIRES_S,
|
|
1305
|
+
...(manage ? { manageUrl: manage } : {}),
|
|
1296
1306
|
...(replaces ? { replacesLabel: replaces.label } : {}) } });
|
|
1297
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() });
|
|
1298
1310
|
return json({ device_code: deviceCode, user_code: shown, verification_uri: u.page, verification_uri_complete: complete,
|
|
1299
1311
|
expires_in: P.EXPIRES_S, interval: P.INTERVAL_S, ...(replaces ? { replaces_label: replaces.label } : {}) }, 200, { pragma: "no-cache" });
|
|
1300
1312
|
}
|
|
@@ -1368,7 +1380,7 @@ const pendingRequest = async (ctx: TenantContext, code: string): Promise<Json |
|
|
|
1368
1380
|
ctx.DB.prepare("SELECT * FROM device_requests WHERE user_code = ?").bind(code).first();
|
|
1369
1381
|
|
|
1370
1382
|
/** Approve or deny a pending request. Returns the label (approve) or an error message. */
|
|
1371
|
-
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 }> {
|
|
1372
1384
|
const r = await pendingRequest(ctx, code);
|
|
1373
1385
|
if (!r) return { ok: false, error: "no pairing request with that code" };
|
|
1374
1386
|
if (r.status !== "pending") return { ok: false, error: `that request was already ${r.status === "denied" ? "denied" : "approved"}` };
|
|
@@ -1380,9 +1392,46 @@ async function decide(ctx: TenantContext, code: string, approve: boolean, by: st
|
|
|
1380
1392
|
.bind(approve ? "approved" : "denied", label, Date.now(), by, r.device_hash).run();
|
|
1381
1393
|
if (u.meta.changes !== 1) return { ok: false, error: "that request was decided meanwhile" };
|
|
1382
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" });
|
|
1383
1399
|
return { ok: true, label: label || undefined, request: r, replaced: replacing };
|
|
1384
1400
|
}
|
|
1385
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
|
+
|
|
1386
1435
|
const pageRequest = (r: Json): P.PageRequest => ({ userCode: r.user_code, clientName: r.client_name || "", clientId: r.client_id || "",
|
|
1387
1436
|
agentCardUrl: r.agent_card_url || "", ip: r.ip || "", country: r.country || "", createdMs: r.created_ms, expiresMs: r.expires_ms,
|
|
1388
1437
|
replacesLabel: r.replaces_label || undefined });
|
|
@@ -1432,14 +1481,14 @@ function cookie(req: Request, name: string): string {
|
|
|
1432
1481
|
}
|
|
1433
1482
|
|
|
1434
1483
|
/** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
|
|
1435
|
-
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> {
|
|
1436
1485
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1437
1486
|
const csrfName = "a2a_device_csrf";
|
|
1438
1487
|
let csrf = cookie(req, csrfName);
|
|
1439
1488
|
const fresh = !/^[A-Za-z0-9_-]{24}$/.test(csrf);
|
|
1440
1489
|
if (fresh) csrf = P.randomB64();
|
|
1441
1490
|
const pw = await approvalPassword(ctx);
|
|
1442
|
-
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1491
|
+
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, timeZone: clientTimeZone(req), ...approvalPageBits(ctx) };
|
|
1443
1492
|
const send = (status = 200) => {
|
|
1444
1493
|
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1445
1494
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
@@ -1487,7 +1536,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1487
1536
|
}
|
|
1488
1537
|
const r0 = await pendingRequest(ctx, code);
|
|
1489
1538
|
if (!r0 || r0.status !== "pending" || Date.now() >= r0.expires_ms) { await show(form.user_code); return send(404); }
|
|
1490
|
-
const d0 = await decide(ctx, code, false, "page");
|
|
1539
|
+
const d0 = await decide(ctx, ectx, code, false, "page");
|
|
1491
1540
|
if (!d0.ok) { await show(null, { kind: "error", text: d0.error! }); return send(409); }
|
|
1492
1541
|
model.settled = "denied";
|
|
1493
1542
|
model.notice = { kind: "ok", text: `Denied. ${r0.client_name || r0.client_id || "the agent"} gets no token.` };
|
|
@@ -1527,14 +1576,14 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1527
1576
|
const left = P.LIMITS.wrongPerCode - (res?.failed_attempts ?? P.LIMITS.wrongPerCode);
|
|
1528
1577
|
log("pairing_wrong_password", { ip, userCode: P.formatUserCode(code), left });
|
|
1529
1578
|
if (left <= 0) {
|
|
1530
|
-
await decide(ctx, code, false, "lockout");
|
|
1579
|
+
await decide(ctx, ectx, code, false, "lockout");
|
|
1531
1580
|
await show(null, { kind: "error", text: "Wrong password. Too many wrong attempts for this code: the request was denied." });
|
|
1532
1581
|
return send(403);
|
|
1533
1582
|
}
|
|
1534
1583
|
await show(form.user_code, { kind: "error", text: `Wrong password. ${left} attempt${left === 1 ? "" : "s"} left for this code.` });
|
|
1535
1584
|
return send(403);
|
|
1536
1585
|
}
|
|
1537
|
-
const d = await decide(ctx, code, true, "human");
|
|
1586
|
+
const d = await decide(ctx, ectx, code, true, "human");
|
|
1538
1587
|
if (!d.ok) { await show(null, { kind: "error", text: d.error! }); return send(409); }
|
|
1539
1588
|
const who = r.client_name || r.client_id || "the agent";
|
|
1540
1589
|
model.settled = "approved";
|
|
@@ -1543,10 +1592,10 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL, head = fal
|
|
|
1543
1592
|
}
|
|
1544
1593
|
|
|
1545
1594
|
/** GET /device/oidc/callback: the identity provider returns here. State is the CSRF; the cookie is not re-checked. */
|
|
1546
|
-
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> {
|
|
1547
1596
|
const nonce = P.randomB64();
|
|
1548
1597
|
const pw = await approvalPassword(ctx);
|
|
1549
|
-
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A endpoint", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
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) };
|
|
1550
1599
|
const send = (status = 200) => {
|
|
1551
1600
|
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1552
1601
|
h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
@@ -1586,7 +1635,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, he
|
|
|
1586
1635
|
model.notice = { kind: "error", text: done.error };
|
|
1587
1636
|
return send(400);
|
|
1588
1637
|
}
|
|
1589
|
-
const d = await decide(ctx, done.userCode || "", true, "oidc");
|
|
1638
|
+
const d = await decide(ctx, ectx, done.userCode || "", true, "oidc");
|
|
1590
1639
|
if (!d.ok) { model.notice = { kind: "error", text: d.error! }; return send(409); }
|
|
1591
1640
|
const who = d.request.client_name || d.request.client_id || "the agent";
|
|
1592
1641
|
model.settled = "approved";
|
|
@@ -1679,7 +1728,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL, head = fals
|
|
|
1679
1728
|
}
|
|
1680
1729
|
|
|
1681
1730
|
/** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
|
|
1682
|
-
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> {
|
|
1683
1732
|
const mode = pairingMode(ctx);
|
|
1684
1733
|
if (!seg[0] && m === "GET") {
|
|
1685
1734
|
const now = Date.now();
|
|
@@ -1740,7 +1789,7 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1740
1789
|
if (approve && mode !== "agent" && !dashboardHuman && !ownerAuthed)
|
|
1741
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);
|
|
1742
1791
|
const by = dashboardHuman ? "human" : approve && mode === "agent" ? "agent" : "owner";
|
|
1743
|
-
const d = await decide(ctx, code, approve, by);
|
|
1792
|
+
const d = await decide(ctx, ectx, code, approve, by);
|
|
1744
1793
|
if (!d.ok) return json({ error: d.error }, 409);
|
|
1745
1794
|
return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
|
|
1746
1795
|
clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
|
|
@@ -2344,7 +2393,8 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2344
2393
|
if (a.all === true) qs.set("all", "1");
|
|
2345
2394
|
const tasks = must(await ownerCall(ctx, ectx, "GET", `/owner/inbox${qs.size ? `?${qs}` : ""}`));
|
|
2346
2395
|
const pairing = pairingOn(ctx) ? must(await ownerCall(ctx, ectx, "GET", "/owner/pairing")).pending.length : 0;
|
|
2347
|
-
const
|
|
2396
|
+
const manage = pairing ? dashboardUrl(ctx) : "";
|
|
2397
|
+
const head = `${tasks.length} task(s)${a.all ? "" : " waiting"}${pairing ? `; ${pairing} pending pairing request(s): see pairing_requests (never approve them yourself)${manage ? `. Approve or deny at ${manage}` : ""}` : ""}. ${UNTRUSTED}`;
|
|
2348
2398
|
return M.toolText(head, tasks);
|
|
2349
2399
|
}
|
|
2350
2400
|
case "show_task":
|
|
@@ -2409,9 +2459,10 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2409
2459
|
case "pairing_requests": {
|
|
2410
2460
|
if (!pairingOn(ctx)) return "Device-flow pairing is off on this A2A endpoint (PAIRING_APPROVAL=off): there are no pairing requests.";
|
|
2411
2461
|
const d = must(await ownerCall(ctx, ectx, "GET", "/owner/pairing"));
|
|
2412
|
-
|
|
2462
|
+
const manage = dashboardUrl(ctx);
|
|
2463
|
+
return M.toolText(`${d.pending.length} pending pairing request(s). Show your human who asks, the code and the link.${manage ? ` Approve or deny at ${manage}.` : ""} NEVER approve or deny yourself: your human decides on the link with the approval password. Names and card URLs are claimed by the requester (untrusted).`,
|
|
2413
2464
|
d.pending.map((r: Json) => ({ code: r.userCode, clientName: r.clientName, clientId: r.clientId, agentCardUrl: r.agentCardUrl, country: r.country,
|
|
2414
|
-
expiresAt: r.expiresAt, link: r.verificationUriComplete, replacesLabel: r.replacesLabel })));
|
|
2465
|
+
expiresAt: r.expiresAt, link: r.verificationUriComplete, ...(manage ? { manageUrl: manage } : {}), replacesLabel: r.replacesLabel })));
|
|
2415
2466
|
}
|
|
2416
2467
|
case "send_feedback": {
|
|
2417
2468
|
// The owner authorized this client on the consent page. A peer token and the owner token never arrive here.
|
|
@@ -2602,8 +2653,8 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext,
|
|
|
2602
2653
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2603
2654
|
if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
|
|
2604
2655
|
if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
|
|
2605
|
-
if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url, head);
|
|
2606
|
-
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);
|
|
2607
2658
|
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
|
|
2608
2659
|
return json({ error: "not found" }, 404);
|
|
2609
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/pairing.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { pageStyle } from "./theme.ts";
|
|
|
8
8
|
export const DEFAULT_CLI = "npx -y a2a-exposed@latest";
|
|
9
9
|
|
|
10
10
|
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
11
|
-
export const EXPIRES_S =
|
|
11
|
+
export const EXPIRES_S = 3600; // device / user code lifetime (60 minutes)
|
|
12
12
|
export const INTERVAL_S = 5; // minimum polling interval (slow_down adds 5 s each time)
|
|
13
13
|
export const LIMITS = {
|
|
14
14
|
maxPending: 10, // outstanding (unexpired, undecided) requests, globally: caps approval prompts
|
|
@@ -173,12 +173,33 @@ export async function verifyPassword(password: string, rec: PasswordRecord): Pro
|
|
|
173
173
|
// ------------------------------------------------------------------ /device page (no scripts, no external assets)
|
|
174
174
|
export const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
175
175
|
|
|
176
|
-
/** "Expires" cell.
|
|
177
|
-
export function expiresText(expiresMs: number, now = Date.now()): string {
|
|
176
|
+
/** "Expires" cell. Clock time in the visitor's time zone, then how long is left. An unknown zone uses UTC. */
|
|
177
|
+
export function expiresText(expiresMs: number, now = Date.now(), timeZone?: string): string {
|
|
178
178
|
const left = expiresMs - now;
|
|
179
179
|
if (left <= 0) return "expired";
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
const relative = left < 60_000 ? "less than a minute" : `${Math.round(left / 60000)} min`;
|
|
181
|
+
const { zone, suffix } = clockZone(timeZone);
|
|
182
|
+
return `${clockTime(expiresMs, zone)}${suffix} (${relative})`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function clockZone(timeZone: string | undefined): { zone: string; suffix: string } {
|
|
186
|
+
const zone = (timeZone || "").trim();
|
|
187
|
+
if (!zone || zone.toUpperCase() === "UTC" || zone === "Etc/UTC" || zone === "Etc/GMT") return { zone: "UTC", suffix: " UTC" };
|
|
188
|
+
try {
|
|
189
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: zone }).format(0);
|
|
190
|
+
return { zone, suffix: "" };
|
|
191
|
+
} catch {
|
|
192
|
+
return { zone: "UTC", suffix: " UTC" };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function clockTime(ms: number, timeZone: string): string {
|
|
197
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
198
|
+
hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone,
|
|
199
|
+
}).formatToParts(new Date(ms));
|
|
200
|
+
const hour = parts.find((part) => part.type === "hour")?.value ?? "00";
|
|
201
|
+
const minute = parts.find((part) => part.type === "minute")?.value ?? "00";
|
|
202
|
+
return `${hour}:${minute}`;
|
|
182
203
|
}
|
|
183
204
|
|
|
184
205
|
export type PageRequest = { userCode: string; clientName: string; clientId: string; agentCardUrl: string; ip: string; country: string; createdMs: number; expiresMs: number; replacesLabel?: string; tokenLabel?: string };
|
|
@@ -196,6 +217,8 @@ export type PageModel = {
|
|
|
196
217
|
oidc?: { label: string };
|
|
197
218
|
/** False hides the password field. Absent means the password field is shown as before. */
|
|
198
219
|
passwordEnabled?: boolean;
|
|
220
|
+
/** IANA time zone from the request, when Cloudflare provides one. The expiry clock uses it. */
|
|
221
|
+
timeZone?: string;
|
|
199
222
|
/** Set after Approve or Deny: the page shows that result and no code form. */
|
|
200
223
|
settled?: "approved" | "denied";
|
|
201
224
|
};
|
|
@@ -213,7 +236,7 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
213
236
|
...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
|
|
214
237
|
["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this A2A endpoint can't check it; informational only)" : "") : "(none given)"],
|
|
215
238
|
["Requested from", `${[r.ip || "unknown address", r.country].filter(Boolean).join(", ")} (IP address of the machine that asked to connect)`],
|
|
216
|
-
["Expires", expiresText(r.expiresMs)],
|
|
239
|
+
["Expires", expiresText(r.expiresMs, Date.now(), m.timeZone)],
|
|
217
240
|
...(r.replacesLabel ? [["Replaces", `the active token "${r.replacesLabel}" (the requester proved it holds it); approving issues a new token under the same label and the old one stops working`]] : []),
|
|
218
241
|
] : [];
|
|
219
242
|
const tokenLabel = r?.tokenLabel || "<label>";
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -39,6 +39,12 @@ export const CONFIG_KEYS = [
|
|
|
39
39
|
"PAIRING_APPROVAL", "PBKDF2_ITERATIONS",
|
|
40
40
|
"UPSTREAM_URL", "UPSTREAM_CARD_URL", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
|
|
41
41
|
"MCP",
|
|
42
|
+
/** Optional https URL of this agent's dashboard page. Omitted from pairing output when unset or not https. */
|
|
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",
|
|
42
48
|
] as const;
|
|
43
49
|
|
|
44
50
|
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
@@ -205,6 +211,19 @@ export async function derivePeerKey(ikm: string, info = PEER_KEY_INFO): Promise<
|
|
|
205
211
|
);
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
/** https dashboard URL with no userinfo, at most 500 characters. Any other value returns "". */
|
|
215
|
+
export function publicManageUrl(value: string | undefined): string {
|
|
216
|
+
const s = (value || "").trim();
|
|
217
|
+
if (!s || s.length > 500) return "";
|
|
218
|
+
try {
|
|
219
|
+
const u = new URL(s);
|
|
220
|
+
if (u.protocol !== "https:" || u.username || u.password) return "";
|
|
221
|
+
return s;
|
|
222
|
+
} catch {
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
208
227
|
function csv(v: string | undefined): string[] {
|
|
209
228
|
return (v || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
210
229
|
}
|
|
@@ -367,6 +386,9 @@ export class TenantContext implements ConfigFields {
|
|
|
367
386
|
readonly UPSTREAM_ACCESS_CLIENT_ID?: string;
|
|
368
387
|
readonly UPSTREAM_ACCESS_CLIENT_SECRET?: string;
|
|
369
388
|
readonly MCP?: string;
|
|
389
|
+
readonly MANAGE_URL?: string;
|
|
390
|
+
readonly EVENT_URL?: string;
|
|
391
|
+
readonly EVENT_SECRET?: string;
|
|
370
392
|
|
|
371
393
|
/** Self-host only. Never set on a hosted context, so a platform OWNER_TOKEN cannot unlock a tenant. */
|
|
372
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) {
|
package/worker/src/wake.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type PairingInfo = {
|
|
|
23
23
|
agentCardUrl: string;
|
|
24
24
|
agentCardPrivate?: boolean; // the claimed card is on a private network (Tailnet, LAN): informational, not reachable
|
|
25
25
|
expiresIn: number;
|
|
26
|
+
/** Dashboard page for this agent. Present only when MANAGE_URL is an https URL. */
|
|
27
|
+
manageUrl?: string;
|
|
26
28
|
replacesLabel?: string; // re-pairing: the requester proved it holds this active token; approval replaces it
|
|
27
29
|
};
|
|
28
30
|
|
|
@@ -78,11 +80,14 @@ export function wakePayload(ev: WakeEvent, cli: string): Record<string, unknown>
|
|
|
78
80
|
|
|
79
81
|
/** What the woken agent must do with a pairing request: ask its human, never approve on its own. */
|
|
80
82
|
export function pairingInstructions(p: PairingInfo, cli: string): string {
|
|
83
|
+
const dash = p.manageUrl ? ` The dashboard page for this request is ${p.manageUrl}.` : "";
|
|
81
84
|
return p.approval === "human"
|
|
82
85
|
? `Ask your human; never approve on your own. Show them the code ${p.userCode} and give them this link: ${p.verificationUriComplete} . ` +
|
|
83
|
-
`They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.`
|
|
86
|
+
`They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.` +
|
|
87
|
+
dash
|
|
84
88
|
: `Ask your human; never approve on your own. Show them the code ${p.userCode}, who is asking, and the link ${p.verificationUriComplete} . ` +
|
|
85
|
-
`Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}
|
|
89
|
+
`Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}\`.` +
|
|
90
|
+
dash;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
function hintFor(ev: WakeEvent, cli: string): string {
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -421,10 +421,34 @@ test("tools: inbox, show_task, history, mark_working, reply (with state), pairin
|
|
|
421
421
|
assert.match(pr.content[0].text, /NEVER approve/);
|
|
422
422
|
assert.match(pr.content[0].text, new RegExp(st.data.user_code));
|
|
423
423
|
assert.ok(!pr.content[0].text.includes('"ip"'), "requester IPs aren't handed to the model");
|
|
424
|
-
|
|
424
|
+
const pendingLine = (await s.tool(a.access_token, "inbox")).content[0].text;
|
|
425
|
+
assert.match(pendingLine, /1 pending pairing request/);
|
|
426
|
+
assert.equal(pendingLine.includes("manageUrl"), false);
|
|
427
|
+
assert.equal(pendingLine.includes("Approve or deny at"), false);
|
|
425
428
|
assert.equal((s.DB.db.prepare("SELECT status FROM device_requests").get() as any).status, "pending");
|
|
426
429
|
});
|
|
427
430
|
|
|
431
|
+
test("MANAGE_URL is on the MCP inbox line and pairing_requests for a pending request", async (t) => {
|
|
432
|
+
const pageUrl = "https://example.com/app/agents/test#pairing";
|
|
433
|
+
const s = setup({ MANAGE_URL: pageUrl });
|
|
434
|
+
t.after(s.restore);
|
|
435
|
+
const a = await s.connect();
|
|
436
|
+
await s.call("POST", "/oauth/device_authorization", { form: { client_name: "Mallory", client_id: "m" } });
|
|
437
|
+
const inbox = (await s.tool(a.access_token, "inbox")).content[0].text;
|
|
438
|
+
assert.match(inbox, new RegExp(`1 pending pairing request\\(s\\): see pairing_requests \\(never approve them yourself\\)\\. Approve or deny at ${pageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
439
|
+
const listed = (await s.tool(a.access_token, "pairing_requests")).content[0].text;
|
|
440
|
+
assert.match(listed, new RegExp(`Approve or deny at ${pageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
441
|
+
assert.match(listed, new RegExp(`"manageUrl": "${pageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
|
|
442
|
+
|
|
443
|
+
const off = setup({ MANAGE_URL: "not a url" });
|
|
444
|
+
t.after(off.restore);
|
|
445
|
+
const grant = await off.connect();
|
|
446
|
+
await off.call("POST", "/oauth/device_authorization", { form: { client_name: "Mallory", client_id: "m" } });
|
|
447
|
+
const quiet = (await off.tool(grant.access_token, "pairing_requests")).content[0].text;
|
|
448
|
+
assert.equal(quiet.includes("manageUrl"), false);
|
|
449
|
+
assert.equal(quiet.includes("Approve or deny at"), false);
|
|
450
|
+
});
|
|
451
|
+
|
|
428
452
|
test("tools: send / poll_outbound through synced peers; each peer's token stays with that peer; tokens never shown", async (t) => {
|
|
429
453
|
const s = setup(); t.after(s.restore);
|
|
430
454
|
const a = await s.connect();
|
|
@@ -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
|
+
});
|
|
@@ -35,14 +35,16 @@ function setup(over: Record<string, string> = {}) {
|
|
|
35
35
|
wakes.push({ url: String(url), body: JSON.parse(init.body) });
|
|
36
36
|
return new Response("ok");
|
|
37
37
|
}) as any;
|
|
38
|
-
const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; ip?: string; cookie?: string } = {}) => {
|
|
38
|
+
const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; ip?: string; cookie?: string; cf?: Record<string, unknown> } = {}) => {
|
|
39
39
|
const headers: Record<string, string> = { "cf-connecting-ip": o.ip || "198.51.100.7", ...(o.headers || {}) };
|
|
40
40
|
let body: string | undefined;
|
|
41
41
|
if (o.form) { headers["content-type"] = "application/x-www-form-urlencoded"; body = new URLSearchParams(o.form).toString(); }
|
|
42
42
|
if (o.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(o.json); }
|
|
43
43
|
if (o.cookie) headers.cookie = o.cookie;
|
|
44
44
|
const pending: Promise<unknown>[] = [];
|
|
45
|
-
const
|
|
45
|
+
const req = new Request(BASE + path, { method, headers, body }) as any;
|
|
46
|
+
if (o.cf) req.cf = o.cf;
|
|
47
|
+
const res = await worker.fetch(req, env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
46
48
|
await Promise.allSettled(pending);
|
|
47
49
|
const text = await res.text();
|
|
48
50
|
let data: any = text;
|
|
@@ -255,7 +257,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
255
257
|
assert.match(d.user_code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
|
|
256
258
|
assert.equal(d.verification_uri, `${BASE}/device`);
|
|
257
259
|
assert.equal(d.verification_uri_complete, `${BASE}/device?user_code=${d.user_code}`);
|
|
258
|
-
assert.deepEqual([d.expires_in, d.interval], [
|
|
260
|
+
assert.deepEqual([d.expires_in, d.interval], [3600, 5]);
|
|
259
261
|
// the device code is stored hashed only
|
|
260
262
|
const row: any = s.DB.db.prepare("SELECT * FROM device_requests").get();
|
|
261
263
|
assert.notEqual(row.device_hash, d.device_code);
|
|
@@ -268,6 +270,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
268
270
|
assert.equal(w.hint, "npx -y a2a-exposed@latest pair list");
|
|
269
271
|
assert.deepEqual([w.pairing.userCode, w.pairing.verificationUriComplete, w.pairing.approval, w.pairing.clientId], [d.user_code, d.verification_uri_complete, "human", "barry"]);
|
|
270
272
|
assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
|
|
273
|
+
assert.equal("manageUrl" in w.pairing, false);
|
|
271
274
|
assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
|
|
272
275
|
|
|
273
276
|
let p = await s.poll(d.device_code);
|
|
@@ -447,7 +450,7 @@ test("labels are deduplicated; denied and expired requests; JSON bodies; bad req
|
|
|
447
450
|
assert.equal((await s.poll(c.device_code)).data.error, "invalid_grant", "reported once, then gone");
|
|
448
451
|
|
|
449
452
|
const e = (await s.start(undefined, "203.0.113.4")).data;
|
|
450
|
-
advance(
|
|
453
|
+
advance(P.EXPIRES_S + 1);
|
|
451
454
|
assert.equal((await s.poll(e.device_code)).data.error, "expired_token");
|
|
452
455
|
assert.equal((await s.owner("POST", `/owner/pairing/${e.user_code}/approve`)).status, 409);
|
|
453
456
|
advance(3600);
|
|
@@ -500,7 +503,7 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
|
|
|
500
503
|
assert.equal(over.status, 429);
|
|
501
504
|
assert.match(over.data.error_description, /too many pairing requests waiting/);
|
|
502
505
|
// expiry frees the slots
|
|
503
|
-
advance(
|
|
506
|
+
advance(P.EXPIRES_S + 1);
|
|
504
507
|
assert.equal((await s.start(undefined, "198.51.100.99")).status, 200);
|
|
505
508
|
// the page: code lookups per IP are capped (no enumerating pending requests)
|
|
506
509
|
for (let i = 0; i < 30; i++) assert.doesNotMatch((await s.call("GET", "/device?user_code=BCDF-2345", { ip: "192.0.2.77" })).text, /Too many code lookups/);
|
|
@@ -510,9 +513,9 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
|
|
|
510
513
|
|
|
511
514
|
test("pairing wakes: summary for each preset; requester text only where previews are allowed", async () => {
|
|
512
515
|
const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
|
|
513
|
-
pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn:
|
|
516
|
+
pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 3600 } };
|
|
514
517
|
const sum = wakeSummary(ev, "npx -y a2a-exposed@latest");
|
|
515
|
-
assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your A2A endpoint \(code WDJB-2345, expires in
|
|
518
|
+
assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your A2A endpoint \(code WDJB-2345, expires in 60 minutes\)\./);
|
|
516
519
|
assert.match(sum, /never approve on your own\. Show them the code WDJB-2345 and give them this link: https:\/\/agent\.example\.com\/device\?user_code=WDJB-2345/);
|
|
517
520
|
const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
|
|
518
521
|
assert.match(agentSum, /Only if they say yes, run `a2a pair approve WDJB-2345`; if they say no \(or don't answer\), `a2a pair deny WDJB-2345`/);
|
|
@@ -874,10 +877,13 @@ test("owner log lists history without data_json and does not clear a pending wak
|
|
|
874
877
|
});
|
|
875
878
|
|
|
876
879
|
test("device page copy: code hint, expiry, token scope, settled result, A2A endpoint", async (t) => {
|
|
880
|
+
const at = Date.UTC(2026, 9, 16, 14, 32, 0);
|
|
881
|
+
assert.equal(P.expiresText(at, at - 30_000), "14:32 UTC (less than a minute)");
|
|
882
|
+
assert.equal(P.expiresText(at, at - 90_000), "14:32 UTC (2 min)");
|
|
883
|
+
assert.equal(P.expiresText(at, at - 90_000, "America/New_York"), "10:32 (2 min)");
|
|
884
|
+
assert.equal(P.expiresText(at, at - 90_000, "Not/AZone"), "14:32 UTC (2 min)");
|
|
885
|
+
assert.equal(P.expiresText(at, at + 1), "expired");
|
|
877
886
|
const now = Date.now();
|
|
878
|
-
assert.equal(P.expiresText(now + 30_000, now), "less than a minute");
|
|
879
|
-
assert.equal(P.expiresText(now + 90_000, now), "2 min");
|
|
880
|
-
assert.equal(P.expiresText(now - 1, now), "expired");
|
|
881
887
|
const base = { agentName: "this A2A endpoint", mode: "human" as const, cli: "a2a", csrf: "c".repeat(24), passwordSet: true };
|
|
882
888
|
const bare = P.devicePage({ ...base, askCode: true }, "n");
|
|
883
889
|
assert.match(bare, /The connecting agent prints this code when it runs <code>a2a connect <url><\/code>/);
|
|
@@ -888,7 +894,13 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
|
|
|
888
894
|
assert.match(page, /so this A2A endpoint can't check it/);
|
|
889
895
|
assert.match(page, /send messages and trigger wakes/);
|
|
890
896
|
assert.match(page, /Message text from peers is untrusted/);
|
|
891
|
-
assert.match(page,
|
|
897
|
+
assert.match(page, /\d{2}:\d{2} UTC \(less than a minute\)/);
|
|
898
|
+
const localExpiry = now + 10 * 24 * 3600 * 1000;
|
|
899
|
+
const localParts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "America/New_York" }).formatToParts(new Date(localExpiry));
|
|
900
|
+
const localClock = `${localParts.find((part) => part.type === "hour")?.value}:${localParts.find((part) => part.type === "minute")?.value}`;
|
|
901
|
+
const localPage = P.devicePage({ ...base, timeZone: "America/New_York", request: { ...req, expiresMs: localExpiry } }, "n");
|
|
902
|
+
assert.match(localPage, new RegExp(`${localClock} \\((?:less than a minute|\\d+ min)\\)`));
|
|
903
|
+
assert.equal(localPage.includes("UTC"), false);
|
|
892
904
|
assert.match(page, /Revoke the token <label> any time with <code>a2a token revoke <label><\/code>/);
|
|
893
905
|
const namedToken = P.devicePage({ ...base, request: { ...req, tokenLabel: "barry" } }, "n");
|
|
894
906
|
assert.match(namedToken, /Revoke the token barry any time with <code>a2a token revoke barry<\/code>/);
|
|
@@ -913,3 +925,33 @@ test("device page copy: code hint, expiry, token scope, settled result, A2A endp
|
|
|
913
925
|
const home = await s.call("GET", "/", { headers: { accept: "text/html" } });
|
|
914
926
|
assert.match(home.text, /Messages are stored for its owner, and the agent is woken/);
|
|
915
927
|
});
|
|
928
|
+
|
|
929
|
+
test("approval page shows the visitor's clock, and an https MANAGE_URL is on the pairing wake", async (t) => {
|
|
930
|
+
const pageUrl = "https://example.com/app/agents/test#pairing";
|
|
931
|
+
const s = setup({ MANAGE_URL: pageUrl });
|
|
932
|
+
t.after(s.restore);
|
|
933
|
+
const st = await s.start(undefined, "203.0.113.80");
|
|
934
|
+
assert.equal(st.status, 200, JSON.stringify(st.data));
|
|
935
|
+
const row = s.DB.db.prepare("SELECT expires_ms FROM device_requests").get() as { expires_ms: number };
|
|
936
|
+
const parts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "America/New_York" }).formatToParts(new Date(row.expires_ms));
|
|
937
|
+
const clock = `${parts.find((part) => part.type === "hour")?.value}:${parts.find((part) => part.type === "minute")?.value}`;
|
|
938
|
+
const page = await s.call("GET", `/device?user_code=${st.data.user_code}`, { cf: { timezone: "America/New_York" }, ip: "203.0.113.80" });
|
|
939
|
+
assert.match(page.text, new RegExp(`${clock.replace(":", "\\:")} \\(60 min\\)`));
|
|
940
|
+
assert.equal(page.text.includes(" UTC "), false);
|
|
941
|
+
const wake = s.wakes[0].body.pairing;
|
|
942
|
+
assert.equal(wake.manageUrl, pageUrl);
|
|
943
|
+
assert.equal(wake.expiresIn, 3600);
|
|
944
|
+
assert.ok(wake.instructions.includes(`The dashboard page for this request is ${pageUrl}`));
|
|
945
|
+
|
|
946
|
+
const bare = setup();
|
|
947
|
+
t.after(bare.restore);
|
|
948
|
+
const plain = await bare.start(undefined, "203.0.113.81");
|
|
949
|
+
assert.equal("manageUrl" in bare.wakes[0].body.pairing, false);
|
|
950
|
+
const utc = await bare.call("GET", `/device?user_code=${plain.data.user_code}`, { ip: "203.0.113.81" });
|
|
951
|
+
assert.match(utc.text, /\d{2}:\d{2} UTC \(60 min\)/);
|
|
952
|
+
|
|
953
|
+
const bad = setup({ MANAGE_URL: "http://example.com/app" });
|
|
954
|
+
t.after(bad.restore);
|
|
955
|
+
await bad.start(undefined, "203.0.113.82");
|
|
956
|
+
assert.equal("manageUrl" in bad.wakes[0].body.pairing, false);
|
|
957
|
+
});
|
|
@@ -5,7 +5,8 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
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
|
-
import { hostedTenantContext, namespaceForRegion, parseGates, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
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;
|
|
@@ -15,6 +16,51 @@ function selfEnv(over: Record<string, unknown> = {}): WorkerBindings {
|
|
|
15
16
|
return { DB: dummy, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", ...over } as WorkerBindings;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
test("publicManageUrl keeps an https dashboard page and drops anything else", () => {
|
|
20
|
+
const page = "https://example.com/app/agents/test#pairing";
|
|
21
|
+
assert.equal(publicManageUrl(page), page);
|
|
22
|
+
assert.equal(publicManageUrl(` ${page} `), page);
|
|
23
|
+
assert.equal(publicManageUrl(""), "");
|
|
24
|
+
assert.equal(publicManageUrl(undefined), "");
|
|
25
|
+
assert.equal(publicManageUrl("http://example.com/app"), "");
|
|
26
|
+
assert.equal(publicManageUrl("https://user:pw@example.com/app"), "");
|
|
27
|
+
assert.equal(resolveTenant(selfEnv({ MANAGE_URL: page })).MANAGE_URL, page);
|
|
28
|
+
assert.equal(resolveTenant(selfEnv()).MANAGE_URL, undefined);
|
|
29
|
+
const hosted = hostedTenantContext(selfEnv({ TENANCY: "host", MANAGE_URL: "https://platform.example/other" }), {
|
|
30
|
+
id: "ten_a", db: dummy, publicUrl: BASE, config: { MANAGE_URL: page },
|
|
31
|
+
});
|
|
32
|
+
assert.equal(hosted.MANAGE_URL, page);
|
|
33
|
+
const hostedUnset = hostedTenantContext(selfEnv({ TENANCY: "host", MANAGE_URL: page }), {
|
|
34
|
+
id: "ten_a", db: dummy, publicUrl: BASE,
|
|
35
|
+
});
|
|
36
|
+
assert.equal(hostedUnset.MANAGE_URL, undefined);
|
|
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
|
+
|
|
18
64
|
test("gates: unset is single-tenant and every reserved gate is off", () => {
|
|
19
65
|
const g = parseGates({ DB: dummy } as WorkerBindings).gates;
|
|
20
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
|
+
});
|