a2a-exposed 0.7.11 → 0.7.12
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/package.json +1 -1
- package/worker/src/hosted.ts +46 -8
- package/worker/src/index.ts +48 -1
- package/worker/src/owner-rpc.ts +175 -0
- package/worker/src/tenancy.ts +3 -2
- package/worker/src/wake-secrets.ts +113 -0
- package/worker/src/wake.ts +15 -2
- package/worker/test/hosted.test.ts +130 -0
- package/worker/test/owner-rpc.test.ts +72 -0
- package/worker/test/wake-secrets.test.ts +49 -0
- package/worker/test/wake.test.ts +16 -0
package/package.json
CHANGED
package/worker/src/hosted.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
// Usage push is not in this change. Approval OIDC, when configured (self-host settings, or the pushed tenant
|
|
14
14
|
// `approval` object), uses the same pages as self-host.
|
|
15
15
|
import { DurableObject } from "cloudflare:workers";
|
|
16
|
-
import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite } from "./index.ts";
|
|
16
|
+
import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite, runOwnerRpc } from "./index.ts";
|
|
17
|
+
import { accountIdOf, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
17
18
|
import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "./storage.ts";
|
|
18
19
|
import { MIGRATIONS } from "./migrations.ts";
|
|
19
20
|
import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBookmark, restartAfterResult, toSql, ExportError, type BookmarkStorage } from "./export.ts";
|
|
20
21
|
import { hostedDailyBackup } from "./backup.ts";
|
|
21
22
|
import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
|
|
22
23
|
import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
|
|
24
|
+
import { WAKE_SECRET_KEYS, openWakeSecrets } from "./wake-secrets.ts";
|
|
23
25
|
|
|
24
26
|
export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
|
|
25
27
|
export type { DirectoryEntry };
|
|
@@ -32,7 +34,7 @@ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410,
|
|
|
32
34
|
|
|
33
35
|
const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
34
36
|
|
|
35
|
-
/** Versioned config pushed by the control plane. `secretsEnc` is
|
|
37
|
+
/** Versioned config pushed by the control plane. `secretsEnc` is decrypted into the wake credentials when the context is built. */
|
|
36
38
|
export interface TenantConfigPush {
|
|
37
39
|
version: number;
|
|
38
40
|
tenantId: string;
|
|
@@ -151,14 +153,29 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
151
153
|
});
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
#tenant(fallbackOrigin = "") {
|
|
156
|
+
async #tenant(fallbackOrigin = "") {
|
|
155
157
|
const row = this.#row;
|
|
156
158
|
if (!row) return null;
|
|
157
|
-
const
|
|
159
|
+
const parsed = parseGates(this.env);
|
|
160
|
+
const domain = parsed.gates.tenantDomain;
|
|
158
161
|
const publicUrl = domain ? `https://${row.name}.${domain}` : (fallbackOrigin || this.env.PUBLIC_URL || "");
|
|
162
|
+
const config = { ...row.config };
|
|
163
|
+
// Once a ciphertext is stored, the secret wake fields come only from it. A plaintext copy in
|
|
164
|
+
// config_json is ignored, and a blob that cannot be opened leaves those fields unset.
|
|
165
|
+
if (row.secretsEnc) {
|
|
166
|
+
const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
|
|
167
|
+
if (!opened) {
|
|
168
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
|
|
169
|
+
}
|
|
170
|
+
for (const key of WAKE_SECRET_KEYS) {
|
|
171
|
+
const value = opened?.[key];
|
|
172
|
+
if (value) config[key] = value;
|
|
173
|
+
else delete config[key];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
159
176
|
return hostedTenantContext(this.env, {
|
|
160
177
|
id: row.tenantId, db: this.#db, publicUrl, ownerTokenHash: row.ownerTokenHash,
|
|
161
|
-
config
|
|
178
|
+
config, limits: row.limits, approval: row.approval, status: row.status,
|
|
162
179
|
});
|
|
163
180
|
}
|
|
164
181
|
|
|
@@ -198,7 +215,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
198
215
|
await storage.deleteAlarm();
|
|
199
216
|
return;
|
|
200
217
|
}
|
|
201
|
-
const tenant = this.#tenant();
|
|
218
|
+
const tenant = await this.#tenant();
|
|
202
219
|
if (!tenant) return;
|
|
203
220
|
const at = await nextCronAt(tenant);
|
|
204
221
|
if (at == null || !Number.isFinite(at)) {
|
|
@@ -226,7 +243,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
async #runAlarm(): Promise<void> {
|
|
229
|
-
const tenant = this.#row?.status === "active" ? this.#tenant() : null;
|
|
246
|
+
const tenant = this.#row?.status === "active" ? await this.#tenant() : null;
|
|
230
247
|
if (tenant) {
|
|
231
248
|
try { await cronFlush(tenant, this.#ectx()); }
|
|
232
249
|
catch (e) { console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_failed", error: String(e).slice(0, 200) })); }
|
|
@@ -337,6 +354,27 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
337
354
|
return out;
|
|
338
355
|
}
|
|
339
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Control-plane owner call. The caller has already checked that `accountId` owns this tenant.
|
|
359
|
+
* The path is an allowlist, the account id is logged, and a response that contains a peer token
|
|
360
|
+
* or a decrypted secret is withheld. The network owner API is unchanged and still needs the owner token.
|
|
361
|
+
* An object with no config row answers 404 and writes nothing.
|
|
362
|
+
*/
|
|
363
|
+
async owner(input: OwnerRpcRequest): Promise<OwnerRpcResult> {
|
|
364
|
+
if (!this.#row) {
|
|
365
|
+
const accountId = accountIdOf(input && typeof input === "object" ? (input as { accountId?: unknown }).accountId : undefined);
|
|
366
|
+
if (!accountId) {
|
|
367
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, reason: "account" }));
|
|
368
|
+
return { status: 400, body: { error: "accountId is invalid" } };
|
|
369
|
+
}
|
|
370
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, accountId, reason: "absent" }));
|
|
371
|
+
return { status: 404, body: { error: "no such agent" } };
|
|
372
|
+
}
|
|
373
|
+
const tenant = await this.#tenant();
|
|
374
|
+
if (!tenant) return { status: 404, body: { error: "no such agent" } };
|
|
375
|
+
return runOwnerRpc(tenant, this.#ectx(), input);
|
|
376
|
+
}
|
|
377
|
+
|
|
340
378
|
async fetch(req: Request): Promise<Response> {
|
|
341
379
|
const row = this.#row;
|
|
342
380
|
if (!row) return notFound();
|
|
@@ -346,7 +384,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
346
384
|
if (row.status === "deleting") return gone();
|
|
347
385
|
return notFound();
|
|
348
386
|
}
|
|
349
|
-
const tenant = this.#tenant(new URL(req.url).origin);
|
|
387
|
+
const tenant = await this.#tenant(new URL(req.url).origin);
|
|
350
388
|
if (!tenant) return notFound();
|
|
351
389
|
// Do not arm from the rows visible here. sendWake runs in waitUntil and, on 429/503, inserts the retry
|
|
352
390
|
// after this response returns. requeueWake arms in that insert. Arm again when those tasks finish so a
|
package/worker/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
|
|
|
10
10
|
import { fetchCheckedWake } from "./wake-target.ts";
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
13
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
13
14
|
import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
14
15
|
import { exportRows, toSql } from "./export.ts";
|
|
15
16
|
import { backupDatabase, DAILY_CRON } from "./backup.ts";
|
|
@@ -898,7 +899,8 @@ function ownerTaskView(t: Json): Json {
|
|
|
898
899
|
}
|
|
899
900
|
|
|
900
901
|
async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
|
|
901
|
-
// trusted: an in-process call
|
|
902
|
+
// trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
|
|
903
|
+
// owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
|
|
902
904
|
if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
|
|
903
905
|
const m = req.method;
|
|
904
906
|
const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
|
|
@@ -2073,6 +2075,51 @@ async function mcpGrantOf(ctx: TenantContext, header: string | null): Promise<Js
|
|
|
2073
2075
|
return await ctx.DB.prepare("SELECT * FROM mcp_grants WHERE access_hash = ? AND revoked_at IS NULL AND access_expires_ms > ?").bind(await A.sha256(m[1]), Date.now()).first();
|
|
2074
2076
|
}
|
|
2075
2077
|
|
|
2078
|
+
/**
|
|
2079
|
+
* Hosted owner RPC. The control plane has already checked that `accountId` owns this tenant.
|
|
2080
|
+
* Only an allowlisted path runs, and the response is withheld when it contains a peer token or a secret.
|
|
2081
|
+
* Self-host (`mode` single) is refused here; its owner API stays the bearer check in `handleOwner`.
|
|
2082
|
+
*/
|
|
2083
|
+
export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, input: OwnerRpcRequest): Promise<OwnerRpcResult> {
|
|
2084
|
+
const raw = input && typeof input === "object" ? input as OwnerRpcRequest & { body?: unknown } : null;
|
|
2085
|
+
const accountId = accountIdOf(raw?.accountId);
|
|
2086
|
+
const tenant = ctx.id || null;
|
|
2087
|
+
const refuse = (status: number, error: string, reason: string): OwnerRpcResult => {
|
|
2088
|
+
log("owner_rpc_refused", { tenant, ...(accountId ? { accountId } : {}), reason });
|
|
2089
|
+
return { status, body: { error } };
|
|
2090
|
+
};
|
|
2091
|
+
if (!accountId) return refuse(400, "accountId is invalid", "account");
|
|
2092
|
+
if (ctx.mode !== "host") return refuse(404, "not found", "tenancy");
|
|
2093
|
+
if (ctx.status !== "active") {
|
|
2094
|
+
const status = ctx.status === "suspended" ? 403 : ctx.status === "deleting" ? 410 : 404;
|
|
2095
|
+
const error = ctx.status === "suspended" ? "this agent is suspended" : ctx.status === "deleting" ? "this agent is gone" : "no such agent";
|
|
2096
|
+
return refuse(status, error, "status");
|
|
2097
|
+
}
|
|
2098
|
+
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2099
|
+
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2100
|
+
if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
|
|
2101
|
+
let origin = "https://tenant.invalid";
|
|
2102
|
+
try {
|
|
2103
|
+
const u = new URL(ctx.PUBLIC_URL);
|
|
2104
|
+
if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
|
|
2105
|
+
} catch { /* the handler reads the path, not this origin */ }
|
|
2106
|
+
const url = new URL(origin + matched.route.path + matched.route.search);
|
|
2107
|
+
const req = new Request(url, { method: matched.route.method, headers: { "content-type": "application/json" } });
|
|
2108
|
+
let res: Response;
|
|
2109
|
+
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2110
|
+
catch (e: any) {
|
|
2111
|
+
const status = e instanceof HttpError ? e.status : 400;
|
|
2112
|
+
const error = String(e?.message || "bad request").slice(0, 200);
|
|
2113
|
+
if (ownerResponseLeaks(error)) return refuse(500, "response withheld", "secret");
|
|
2114
|
+
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
|
|
2115
|
+
return { status, body: { error } };
|
|
2116
|
+
}
|
|
2117
|
+
const data = await res.json().catch(() => null);
|
|
2118
|
+
if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
|
|
2119
|
+
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
|
|
2120
|
+
return { status: res.status, body: data };
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2076
2123
|
/** Call an owner API route in-process (MCP tools only; the paths are fixed in the tool code). */
|
|
2077
2124
|
async function ownerCall(ctx: TenantContext, ectx: ExecutionContext, method: string, path: string, body?: Json): Promise<{ status: number; data: Json }> {
|
|
2078
2125
|
const url = new URL(ctx.PUBLIC_URL.replace(/\/$/, "") + path);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Hosted owner RPC allowlist. The control plane calls TenantStore.owner() after it has checked that
|
|
2
|
+
// the account owns the tenant. This list is the only paths that call may run, and it is stricter than
|
|
3
|
+
// the owner HTTP API: routes that mint a peer token, return a setup secret, accept a plaintext peer
|
|
4
|
+
// token, or dump credential material (export) are not here. Self-host never calls this.
|
|
5
|
+
|
|
6
|
+
import { ID_RE } from "./a2a.ts";
|
|
7
|
+
import { previewHeaderPlain } from "./wake.ts";
|
|
8
|
+
|
|
9
|
+
/** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
|
|
10
|
+
export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
11
|
+
|
|
12
|
+
const LABEL = /^[A-Za-z0-9_-]{1,32}$/;
|
|
13
|
+
const MCP_LABEL = /^[A-Za-z0-9_-]{1,64}$/;
|
|
14
|
+
const ALIAS = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
15
|
+
/** Display form WDJB-4827 or the stored form WDJB4827. Vowels and 0/1 are not in a real code. */
|
|
16
|
+
const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-?[2-9]{4}$/i;
|
|
17
|
+
|
|
18
|
+
const TOKEN_RE = /^(?:a2aow_|s2a_|a2amcp_|a2amcpr_)[A-Za-z0-9_-]{20,}$/;
|
|
19
|
+
const SETUP_RE = /(?:\?|&)t=[A-Za-z0-9_-]{16,}/;
|
|
20
|
+
const USERINFO_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?\s]*@/i;
|
|
21
|
+
/** A non-empty string under one of these keys is a secret, even when it is not a token shape. */
|
|
22
|
+
const SECRET_KEYS = new Set([
|
|
23
|
+
"token", "access_token", "refresh_token", "device_code",
|
|
24
|
+
"ownertoken", "owner_token", "secretsenc", "secrets_enc",
|
|
25
|
+
"clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export interface OwnerRpcRequest {
|
|
29
|
+
accountId: string;
|
|
30
|
+
method: string;
|
|
31
|
+
path: string;
|
|
32
|
+
/** Allowlisted query parameters only. */
|
|
33
|
+
query?: Record<string, string>;
|
|
34
|
+
/** Refused. The owner HTTP API is where a body is accepted. */
|
|
35
|
+
body?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OwnerRpcResult {
|
|
39
|
+
status: number;
|
|
40
|
+
body: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OwnerRoute {
|
|
44
|
+
method: "GET" | "POST" | "DELETE";
|
|
45
|
+
path: string;
|
|
46
|
+
search: string;
|
|
47
|
+
/** Log name. Ids and codes are not included. */
|
|
48
|
+
name: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type OwnerMatch = { ok: true; route: OwnerRoute } | { ok: false; reason: "path" | "query" };
|
|
52
|
+
|
|
53
|
+
export function accountIdOf(v: unknown): string | null {
|
|
54
|
+
return typeof v === "string" && ACCOUNT_ID_RE.test(v) ? v : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function segments(path: unknown): string[] | null {
|
|
58
|
+
if (typeof path !== "string" || path.length > 300 || !path.startsWith("/owner/")) return null;
|
|
59
|
+
if (path.includes("%") || path.includes("\\") || path.includes("?") || path.includes("#") || path.includes("//")) return null;
|
|
60
|
+
const seg = path.split("/").filter(Boolean);
|
|
61
|
+
if (seg[0] !== "owner" || seg.some((s) => s === "." || s === "..")) return null;
|
|
62
|
+
return seg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function queryRecord(query: unknown): Record<string, string> | null {
|
|
66
|
+
if (query == null) return {};
|
|
67
|
+
if (typeof query !== "object" || Array.isArray(query)) return null;
|
|
68
|
+
const out: Record<string, string> = {};
|
|
69
|
+
for (const [k, v] of Object.entries(query)) {
|
|
70
|
+
if (typeof v !== "string" || v.length > 128) return null;
|
|
71
|
+
out[k] = v;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function idOk(s: string): boolean {
|
|
77
|
+
return ID_RE.test(s) && s !== "." && s !== "..";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function noQuery(q: Record<string, string>): string | null {
|
|
81
|
+
return Object.keys(q).length === 0 ? "" : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inboxQuery(q: Record<string, string>): string | null {
|
|
85
|
+
for (const k of Object.keys(q)) if (k !== "context" && k !== "all") return null;
|
|
86
|
+
if (q.context !== undefined && !idOk(q.context)) return null;
|
|
87
|
+
if (q.all !== undefined && q.all !== "1") return null;
|
|
88
|
+
const sp = new URLSearchParams();
|
|
89
|
+
if (q.context) sp.set("context", q.context);
|
|
90
|
+
if (q.all) sp.set("all", "1");
|
|
91
|
+
const s = sp.toString();
|
|
92
|
+
return s ? `?${s}` : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function historyQuery(q: Record<string, string>): string | null {
|
|
96
|
+
for (const k of Object.keys(q)) if (k !== "n") return null;
|
|
97
|
+
if (q.n === undefined) return "";
|
|
98
|
+
if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
|
|
99
|
+
return `?n=${q.n}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface RouteDef {
|
|
103
|
+
method: "GET" | "POST" | "DELETE";
|
|
104
|
+
name: string;
|
|
105
|
+
take: (seg: string[]) => boolean;
|
|
106
|
+
search: (q: Record<string, string>) => string | null;
|
|
107
|
+
path: (seg: string[]) => string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ROUTES: RouteDef[] = [
|
|
111
|
+
{ method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
|
|
112
|
+
{ method: "GET", name: "GET /owner/tasks/:id", take: (s) => s.length === 3 && s[1] === "tasks" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/tasks/${s[2]}` },
|
|
113
|
+
{ method: "GET", name: "GET /owner/history/:id", take: (s) => s.length === 3 && s[1] === "history" && idOk(s[2]!), search: historyQuery, path: (s) => `/owner/history/${s[2]}` },
|
|
114
|
+
{ method: "GET", name: "GET /owner/outbound/:id", take: (s) => s.length === 3 && s[1] === "outbound" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/outbound/${s[2]}` },
|
|
115
|
+
{ method: "GET", name: "GET /owner/peers", take: (s) => s.length === 2 && s[1] === "peers", search: noQuery, path: () => "/owner/peers" },
|
|
116
|
+
{ method: "DELETE", name: "DELETE /owner/peers/:label", take: (s) => s.length === 3 && s[1] === "peers" && LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/peers/${s[2]}` },
|
|
117
|
+
{ method: "GET", name: "GET /owner/pairing", take: (s) => s.length === 2 && s[1] === "pairing", search: noQuery, path: () => "/owner/pairing" },
|
|
118
|
+
{ method: "POST", name: "POST /owner/pairing/:code/approve", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "approve", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/approve` },
|
|
119
|
+
{ method: "POST", name: "POST /owner/pairing/:code/deny", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "deny", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/deny` },
|
|
120
|
+
{ method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
|
|
121
|
+
{ method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
|
|
122
|
+
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
123
|
+
{ method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
|
|
124
|
+
{ method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
|
|
125
|
+
{ method: "POST", name: "POST /owner/facade/verify", take: (s) => s.length === 3 && s[1] === "facade" && s[2] === "verify", search: noQuery, path: () => "/owner/facade/verify" },
|
|
126
|
+
{ method: "GET", name: "GET /owner/wake/preview", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "preview", search: noQuery, path: () => "/owner/wake/preview" },
|
|
127
|
+
{ method: "POST", name: "POST /owner/wake/test", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "test", search: noQuery, path: () => "/owner/wake/test" },
|
|
128
|
+
{ method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
|
|
132
|
+
export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): OwnerMatch {
|
|
133
|
+
if (method !== "GET" && method !== "POST" && method !== "DELETE") return { ok: false, reason: "path" };
|
|
134
|
+
const seg = segments(path);
|
|
135
|
+
if (!seg) return { ok: false, reason: "path" };
|
|
136
|
+
const def = ROUTES.find((r) => r.method === method && r.take(seg));
|
|
137
|
+
if (!def) return { ok: false, reason: "path" };
|
|
138
|
+
const q = queryRecord(query);
|
|
139
|
+
if (!q) return { ok: false, reason: "query" };
|
|
140
|
+
const search = def.search(q);
|
|
141
|
+
if (search === null) return { ok: false, reason: "query" };
|
|
142
|
+
return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stringLeaks(value: string, key: string | null): boolean {
|
|
146
|
+
if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
|
|
147
|
+
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
|
|
151
|
+
function wakePreviewLeaks(value: object): boolean {
|
|
152
|
+
const headers = (value as { method?: unknown; headers?: unknown }).headers;
|
|
153
|
+
if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
|
|
154
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
155
|
+
if (typeof v !== "string" || previewHeaderPlain(k) || v.endsWith("…")) continue;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when a JSON value carries a peer token, a setup secret, a URL userinfo, or a secret field. */
|
|
162
|
+
export function ownerResponseLeaks(value: unknown, depth = 0, key: string | null = null): boolean {
|
|
163
|
+
if (depth > 20) return true;
|
|
164
|
+
if (typeof value === "string") return stringLeaks(value, key);
|
|
165
|
+
if (typeof value === "number" || typeof value === "boolean" || value == null) return false;
|
|
166
|
+
if (Array.isArray(value)) return value.some((v) => ownerResponseLeaks(v, depth + 1, null));
|
|
167
|
+
if (typeof value === "object") {
|
|
168
|
+
if (wakePreviewLeaks(value)) return true;
|
|
169
|
+
for (const [k, v] of Object.entries(value)) {
|
|
170
|
+
if (ownerResponseLeaks(v, depth + 1, k)) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -47,8 +47,9 @@ export type ConfigFields = { [K in ConfigKey]?: string };
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Non-secret platform defaults a hosted tenant inherits when its pushed config omits them.
|
|
50
|
-
* Wake secrets, upstream secrets and OWNER_TOKEN are never inherited
|
|
51
|
-
*
|
|
50
|
+
* Wake secrets, upstream secrets and OWNER_TOKEN are never inherited. In hosted mode the secret wake
|
|
51
|
+
* fields come from decrypting `secrets_enc` (see the tenant object). A plaintext copy in the pushed
|
|
52
|
+
* config is used only when that ciphertext is absent.
|
|
52
53
|
*/
|
|
53
54
|
const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
|
|
54
55
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Wake secrets sealed for one hosted tenant. The control plane writes `secrets_enc`; the tenant
|
|
2
|
+
// object decrypts it when it builds the tenant context. Self-host does not use this.
|
|
3
|
+
// Keep control/src/tenants/wake-secrets.ts in step with this file.
|
|
4
|
+
|
|
5
|
+
const INFO = "tenant-wake-secrets v1";
|
|
6
|
+
const SALT = "a2a-exposed";
|
|
7
|
+
|
|
8
|
+
/** Secret wake fields. Anything else in the blob is ignored. */
|
|
9
|
+
export const WAKE_SECRET_KEYS = [
|
|
10
|
+
"WAKE_WEBHOOK_KEY",
|
|
11
|
+
"WAKE_HMAC_SECRET",
|
|
12
|
+
"WAKE_ACCESS_CLIENT_ID",
|
|
13
|
+
"WAKE_ACCESS_CLIENT_SECRET",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type WakeSecretKey = (typeof WAKE_SECRET_KEYS)[number];
|
|
17
|
+
export type WakeSecrets = Partial<Record<WakeSecretKey, string>>;
|
|
18
|
+
|
|
19
|
+
/** One secret. The form uses the same cap. */
|
|
20
|
+
export const MAX_WAKE_VALUE = 4096;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Largest JSON payload `sealWakeSecrets` will write. A character can become `\u0000` (six characters),
|
|
24
|
+
* and every known field may be present. `openWakeSecrets` accepts this and nothing larger.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_WAKE_PLAIN = (() => {
|
|
27
|
+
let n = 2;
|
|
28
|
+
for (let i = 0; i < WAKE_SECRET_KEYS.length; i++) {
|
|
29
|
+
if (i) n += 1;
|
|
30
|
+
n += WAKE_SECRET_KEYS[i].length + 5 + MAX_WAKE_VALUE * 6;
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
/** `v1.` + base64(12-byte iv) + `.` + base64(utf-8 plaintext + 16-byte tag). */
|
|
36
|
+
export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
37
|
+
|
|
38
|
+
function b64(bytes: Uint8Array): string {
|
|
39
|
+
let raw = "";
|
|
40
|
+
for (let i = 0; i < bytes.length; i += 0x8000) raw += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
41
|
+
return btoa(raw);
|
|
42
|
+
}
|
|
43
|
+
const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
44
|
+
|
|
45
|
+
async function derive(ikm: string, tenantId: string): Promise<CryptoKey | null> {
|
|
46
|
+
if (!ikm || !tenantId) return null;
|
|
47
|
+
const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(ikm), "HKDF", false, ["deriveKey"]);
|
|
48
|
+
return crypto.subtle.deriveKey(
|
|
49
|
+
{ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(SALT), info: new TextEncoder().encode(`${INFO}\0${tenantId}`) },
|
|
50
|
+
base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
|
|
55
|
+
const payload: Record<string, string> = {};
|
|
56
|
+
for (const key of WAKE_SECRET_KEYS) {
|
|
57
|
+
const value = secrets[key];
|
|
58
|
+
if (value == null || value === "") continue;
|
|
59
|
+
if (typeof value !== "string" || value.length > MAX_WAKE_VALUE) return null;
|
|
60
|
+
payload[key] = value;
|
|
61
|
+
}
|
|
62
|
+
return payload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `v1.<iv>.<ciphertext>`, or null when the key or a value is unusable. */
|
|
66
|
+
export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
|
|
67
|
+
const key = await derive(ikm, tenantId);
|
|
68
|
+
const payload = payloadOf(secrets);
|
|
69
|
+
if (!key || !payload) return null;
|
|
70
|
+
const json = JSON.stringify(payload);
|
|
71
|
+
const encoded = new TextEncoder().encode(json);
|
|
72
|
+
if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
|
|
73
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
74
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
75
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(tenantId) },
|
|
76
|
+
key,
|
|
77
|
+
encoded,
|
|
78
|
+
));
|
|
79
|
+
const sealed = `v1.${b64(iv)}.${b64(ct)}`;
|
|
80
|
+
return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The sealed fields, `{}` when there is nothing to open, or null when the blob cannot be read.
|
|
85
|
+
* A null result must not be treated as "no secrets": the caller keeps the previous ciphertext.
|
|
86
|
+
*/
|
|
87
|
+
export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
|
|
88
|
+
if (sealed == null || sealed === "") return {};
|
|
89
|
+
if (sealed.length > MAX_WAKE_SEALED) return null;
|
|
90
|
+
const key = await derive(ikm, tenantId);
|
|
91
|
+
if (!key) return null;
|
|
92
|
+
const [version, iv, ct, extra] = sealed.split(".");
|
|
93
|
+
if (version !== "v1" || !iv || !ct || extra !== undefined) return null;
|
|
94
|
+
try {
|
|
95
|
+
const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
|
|
96
|
+
{ name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(tenantId) },
|
|
97
|
+
key,
|
|
98
|
+
unb64(ct),
|
|
99
|
+
));
|
|
100
|
+
if (plain.length > MAX_WAKE_PLAIN) return null;
|
|
101
|
+
const parsed = JSON.parse(plain) as unknown;
|
|
102
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
103
|
+
const out: WakeSecrets = {};
|
|
104
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
105
|
+
const value = (parsed as Record<string, unknown>)[name];
|
|
106
|
+
if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
|
|
107
|
+
out[name] = value;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/worker/src/wake.ts
CHANGED
|
@@ -203,7 +203,11 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
|
|
|
203
203
|
break;
|
|
204
204
|
case "generic":
|
|
205
205
|
default: {
|
|
206
|
-
|
|
206
|
+
// A header the preview shows in full must not carry the key.
|
|
207
|
+
if (cfg.key) {
|
|
208
|
+
const requested = (cfg.keyHeader || "authorization").toLowerCase();
|
|
209
|
+
headers[previewHeaderPlain(requested) ? "authorization" : requested] = (cfg.keyPrefix ?? "Bearer ") + cfg.key;
|
|
210
|
+
}
|
|
207
211
|
headers["x-request-id"] = requestId;
|
|
208
212
|
body = cfg.bodyTemplate ? renderTemplate(cfg.bodyTemplate, ev, cli) : JSON.stringify(wakePayload(ev, cli));
|
|
209
213
|
if (cfg.hmacSecret) await hermesSign(headers, cfg.hmacSecret, body, nowMs);
|
|
@@ -243,11 +247,20 @@ export function cloudflareErrorHint(status: number, body: string): string {
|
|
|
243
247
|
return "";
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
/** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
|
|
251
|
+
export const PREVIEW_PLAIN_HEADERS = new Set([
|
|
252
|
+
"content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
export function previewHeaderPlain(name: string): boolean {
|
|
256
|
+
return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
|
|
257
|
+
}
|
|
258
|
+
|
|
246
259
|
/** Copy of a rendered request with credentials masked (for previews/logs). */
|
|
247
260
|
export function redact(req: WakeRequest): WakeRequest {
|
|
248
261
|
const h: Record<string, string> = {};
|
|
249
262
|
for (const [k, v] of Object.entries(req.headers))
|
|
250
|
-
h[k] =
|
|
263
|
+
h[k] = previewHeaderPlain(k) ? v : v.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…");
|
|
251
264
|
let url = req.url;
|
|
252
265
|
try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
|
|
253
266
|
return { ...req, url, headers: h };
|
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
10
10
|
import worker from "../src/index.ts";
|
|
11
11
|
import { sha256 } from "../src/a2a.ts";
|
|
12
|
+
import { sealWakeSecrets } from "../src/wake-secrets.ts";
|
|
12
13
|
import { d1 } from "./d1.ts";
|
|
13
14
|
|
|
14
15
|
const require = createRequire(new URL("../package.json", import.meta.url));
|
|
@@ -481,3 +482,132 @@ test("an apex issuer is fetched with that Host, and a control worker that does n
|
|
|
481
482
|
assert.equal(stalledPost.status, 502);
|
|
482
483
|
assert.match(await stalledPost.text(), /The identity provider could not be reached/);
|
|
483
484
|
});
|
|
485
|
+
|
|
486
|
+
test("TenantStore.owner runs an allowlisted owner call and does not return a peer token", async (t) => {
|
|
487
|
+
const mf = await start({
|
|
488
|
+
bindings: {
|
|
489
|
+
TENANCY: "host",
|
|
490
|
+
TENANT_DOMAIN: "example.com",
|
|
491
|
+
TENANT_SECRETS_KEY: "platform-secret",
|
|
492
|
+
OWNER_TOKEN: "platform-owner",
|
|
493
|
+
},
|
|
494
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-owner-rpc" },
|
|
495
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
496
|
+
});
|
|
497
|
+
t.after(() => mf.dispose());
|
|
498
|
+
await mf.ready;
|
|
499
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
500
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
501
|
+
const hash = await sha256("owner-carol");
|
|
502
|
+
type OwnerStub = {
|
|
503
|
+
owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
|
|
504
|
+
storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
|
|
505
|
+
pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
|
|
506
|
+
};
|
|
507
|
+
const lines: string[] = [];
|
|
508
|
+
const orig = console.log;
|
|
509
|
+
console.log = (...args: unknown[]) => {
|
|
510
|
+
lines.push(args.map((a) => String(a)).join(" "));
|
|
511
|
+
orig(...args);
|
|
512
|
+
};
|
|
513
|
+
t.after(() => { console.log = orig; });
|
|
514
|
+
// Workerd delivers console.log on a later turn. A busy runner (the storage-backend jobs) can
|
|
515
|
+
// return the RPC before that line is in this process.
|
|
516
|
+
const saw = async (part: string) => {
|
|
517
|
+
const start = Date.now();
|
|
518
|
+
while (!lines.some((l) => l.includes(part))) {
|
|
519
|
+
if (Date.now() - start > 2000) return false;
|
|
520
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
};
|
|
524
|
+
const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
|
|
525
|
+
const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
526
|
+
assert.equal(missing.status, 404);
|
|
527
|
+
const emptyStatus = await empty.storageStatus();
|
|
528
|
+
assert.equal(emptyStatus.configured, false);
|
|
529
|
+
assert.deepEqual(emptyStatus.tables, []);
|
|
530
|
+
assert.equal(await saw('"reason":"absent"'), true);
|
|
531
|
+
|
|
532
|
+
const stub = ns.get(ns.idFromName("id-carol")) as unknown as OwnerStub;
|
|
533
|
+
await stub.pushConfig({
|
|
534
|
+
version: 1, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
|
|
535
|
+
approval: { issuer: "https://idp.example", clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["acct_carol"] },
|
|
536
|
+
config: { AGENT_NAME: "Carol" },
|
|
537
|
+
});
|
|
538
|
+
await kv.put("tenant:carol", JSON.stringify({ id: "id-carol", status: "active", region: "", version: 1 }));
|
|
539
|
+
const http = (method: string, path: string, auth: string, json?: unknown) => mf.dispatchFetch(`https://carol.example.com${path}`, {
|
|
540
|
+
method, headers: { authorization: auth, "content-type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json),
|
|
541
|
+
});
|
|
542
|
+
assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
|
|
543
|
+
const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
|
|
544
|
+
assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
|
|
545
|
+
|
|
546
|
+
const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
547
|
+
assert.equal(listed.status, 200);
|
|
548
|
+
const peers = listed.body as { label: string; token?: string }[];
|
|
549
|
+
assert.deepEqual(peers.map((p) => p.label), ["ada"]);
|
|
550
|
+
assert.equal(JSON.stringify(listed.body).includes("a2aow_"), false);
|
|
551
|
+
assert.equal(await saw('"route":"GET /owner/peers"'), true);
|
|
552
|
+
|
|
553
|
+
const minted = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers", body: { label: "bob" } });
|
|
554
|
+
assert.equal(minted.status, 404);
|
|
555
|
+
assert.equal(JSON.stringify(minted.body).includes("a2aow_"), false);
|
|
556
|
+
const withBody = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
557
|
+
assert.equal(withBody.status, 400);
|
|
558
|
+
const again = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers" });
|
|
559
|
+
assert.equal(again.status, 404);
|
|
560
|
+
const still = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
561
|
+
assert.deepEqual((still.body as { label: string }[]).map((p) => p.label), ["ada"], "a refused call does not mint a token");
|
|
562
|
+
|
|
563
|
+
const setup = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/password-link" });
|
|
564
|
+
assert.equal(setup.status, 404);
|
|
565
|
+
const dumped = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export" });
|
|
566
|
+
assert.equal(dumped.status, 404);
|
|
567
|
+
const oddQuery = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/inbox", query: { format: "sql" } });
|
|
568
|
+
assert.equal(oddQuery.status, 400);
|
|
569
|
+
const pairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
|
|
570
|
+
assert.equal(pairing.status, 200);
|
|
571
|
+
assert.equal(JSON.stringify(pairing.body).includes("tenant-secret-value"), false);
|
|
572
|
+
const preview = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
|
|
573
|
+
assert.equal(preview.status, 200);
|
|
574
|
+
assert.equal(JSON.stringify(preview.body).includes("platform-secret"), false);
|
|
575
|
+
assert.equal(JSON.stringify(preview.body).includes("tenant-secret-value"), false);
|
|
576
|
+
|
|
577
|
+
const wakeKey = "z9Qx-secret-value-0199";
|
|
578
|
+
const sealed = await sealWakeSecrets("platform-secret", "id-carol", { WAKE_WEBHOOK_KEY: wakeKey });
|
|
579
|
+
assert.ok(sealed);
|
|
580
|
+
assert.equal(sealed!.includes(wakeKey), false);
|
|
581
|
+
await stub.pushConfig({
|
|
582
|
+
version: 2, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
|
|
583
|
+
secretsEnc: sealed,
|
|
584
|
+
config: {
|
|
585
|
+
AGENT_NAME: "Carol",
|
|
586
|
+
WAKE_WEBHOOK_URL: "https://hooks.example/wake",
|
|
587
|
+
WAKE_PRESET: "generic",
|
|
588
|
+
WAKE_KEY_HEADER: "X-Credential",
|
|
589
|
+
WAKE_WEBHOOK_KEY: "plaintext-key-should-not-appear",
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
const woken = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
|
|
593
|
+
assert.equal(woken.status, 200);
|
|
594
|
+
const wokenText = JSON.stringify(woken.body);
|
|
595
|
+
assert.equal(wokenText.includes(wakeKey), false);
|
|
596
|
+
assert.equal(wokenText.includes("plaintext-key-should-not-appear"), false);
|
|
597
|
+
assert.equal(wokenText.includes("platform-secret"), false);
|
|
598
|
+
const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
|
|
599
|
+
assert.equal(wokenBody.hasKey, true);
|
|
600
|
+
assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer z9Qx…");
|
|
601
|
+
const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
|
|
602
|
+
assert.equal(httpPreview.hasKey, true);
|
|
603
|
+
assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
|
|
604
|
+
|
|
605
|
+
await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
|
|
606
|
+
const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
607
|
+
assert.equal(suspended.status, 403);
|
|
608
|
+
assert.equal(await saw('"reason":"status"'), true);
|
|
609
|
+
const badAccount = await stub.owner({ accountId: "../acct", method: "GET", path: "/owner/peers" });
|
|
610
|
+
assert.equal(badAccount.status, 400);
|
|
611
|
+
assert.equal(await saw('"reason":"account"'), true);
|
|
612
|
+
assert.equal(lines.some((l) => l.includes("../acct")), false);
|
|
613
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks } from "../src/owner-rpc.ts";
|
|
5
|
+
|
|
6
|
+
test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
|
|
7
|
+
const ok = (method: string, path: string, query?: Record<string, string>) => {
|
|
8
|
+
const m = matchOwnerRpc(method, path, query);
|
|
9
|
+
assert.equal(m.ok, true, `${method} ${path}`);
|
|
10
|
+
return m.ok ? m.route : null;
|
|
11
|
+
};
|
|
12
|
+
assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
|
|
13
|
+
assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
|
|
14
|
+
assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
|
|
15
|
+
assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
|
|
16
|
+
assert.equal(ok("GET", "/owner/outbound/task-1")?.name, "GET /owner/outbound/:id");
|
|
17
|
+
assert.equal(ok("GET", "/owner/peers")?.name, "GET /owner/peers");
|
|
18
|
+
assert.equal(ok("DELETE", "/owner/peers/ada")?.path, "/owner/peers/ada");
|
|
19
|
+
assert.equal(ok("GET", "/owner/pairing")?.name, "GET /owner/pairing");
|
|
20
|
+
assert.equal(ok("POST", "/owner/pairing/WDJB-4827/approve")?.name, "POST /owner/pairing/:code/approve");
|
|
21
|
+
assert.equal(ok("POST", "/owner/pairing/wdjb4827/deny")?.name, "POST /owner/pairing/:code/deny");
|
|
22
|
+
assert.equal(ok("GET", "/owner/mcp")?.name, "GET /owner/mcp");
|
|
23
|
+
assert.equal(ok("DELETE", "/owner/mcp/mcp-claude")?.path, "/owner/mcp/mcp-claude");
|
|
24
|
+
assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
|
|
25
|
+
assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
|
|
26
|
+
assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
|
|
27
|
+
assert.equal(ok("POST", "/owner/facade/verify")?.name, "POST /owner/facade/verify");
|
|
28
|
+
assert.equal(ok("GET", "/owner/wake/preview")?.name, "GET /owner/wake/preview");
|
|
29
|
+
assert.equal(ok("POST", "/owner/wake/test")?.name, "POST /owner/wake/test");
|
|
30
|
+
assert.equal(ok("GET", "/owner/contexts")?.name, "GET /owner/contexts");
|
|
31
|
+
|
|
32
|
+
const refused = (method: string, path: string, query?: Record<string, string>) => matchOwnerRpc(method, path, query);
|
|
33
|
+
assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
|
|
34
|
+
assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
|
|
35
|
+
assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
|
|
36
|
+
assert.deepEqual(refused("PUT", "/owner/outbound-peers/ada"), { ok: false, reason: "path" });
|
|
37
|
+
assert.deepEqual(refused("GET", "/owner/export"), { ok: false, reason: "path" });
|
|
38
|
+
assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
|
|
39
|
+
assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
|
|
40
|
+
assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
|
|
41
|
+
assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
|
|
42
|
+
assert.deepEqual(refused("GET", "/owner/%2e%2e/peers"), { ok: false, reason: "path" });
|
|
43
|
+
assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
|
|
44
|
+
assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
|
|
45
|
+
assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
|
|
46
|
+
assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
|
|
47
|
+
assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
|
|
48
|
+
assert.equal(accountIdOf("acct_carol"), "acct_carol");
|
|
49
|
+
assert.equal(accountIdOf("../acct"), null);
|
|
50
|
+
assert.equal(accountIdOf(""), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("owner RPC withholds peer tokens and decrypted secrets", () => {
|
|
54
|
+
const token = "a2aow_" + "a".repeat(43);
|
|
55
|
+
assert.equal(ownerResponseLeaks({ label: "ada", revoked_at: null }), false);
|
|
56
|
+
assert.equal(ownerResponseLeaks([{ text: "hello" }]), false);
|
|
57
|
+
assert.equal(ownerResponseLeaks({ token }), true);
|
|
58
|
+
assert.equal(ownerResponseLeaks({ nested: { access_token: "a2amcp_" + "b".repeat(30) } }), true);
|
|
59
|
+
assert.equal(ownerResponseLeaks({ url: "https://agent.example/device/setup?t=" + "c".repeat(20) }), true);
|
|
60
|
+
assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
|
|
61
|
+
assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
|
|
62
|
+
assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
|
|
63
|
+
assert.equal(ownerResponseLeaks({ authorization: "Bearer abcd…" }), false);
|
|
64
|
+
assert.equal(ownerResponseLeaks({
|
|
65
|
+
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
|
|
66
|
+
}), true);
|
|
67
|
+
assert.equal(ownerResponseLeaks({
|
|
68
|
+
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supe…", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
|
|
69
|
+
}), false);
|
|
70
|
+
assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
|
|
71
|
+
assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
|
|
72
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
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";
|
|
4
|
+
|
|
5
|
+
const key = "platform-secret";
|
|
6
|
+
const tenant = "ten_a";
|
|
7
|
+
|
|
8
|
+
test("wake secrets round-trip for one tenant and stay unreadable for another", async () => {
|
|
9
|
+
const secrets = { WAKE_WEBHOOK_KEY: "z9Qx-secret-value-0199", WAKE_HMAC_SECRET: "hmac-value" };
|
|
10
|
+
const sealed = await sealWakeSecrets(key, tenant, secrets);
|
|
11
|
+
assert.ok(sealed);
|
|
12
|
+
assert.equal(sealed!.includes("z9Qx-secret-value-0199"), false);
|
|
13
|
+
assert.equal(sealed!.startsWith("v1."), true);
|
|
14
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
|
|
15
|
+
assert.equal(await openWakeSecrets(key, "ten_b", sealed), null);
|
|
16
|
+
assert.equal(await openWakeSecrets("other-key", tenant, sealed), null);
|
|
17
|
+
assert.equal(await openWakeSecrets("", tenant, sealed), null);
|
|
18
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, null), {});
|
|
19
|
+
assert.equal(await openWakeSecrets(key, tenant, sealed + ".extra"), null);
|
|
20
|
+
assert.equal(await openWakeSecrets(key, tenant, "v1.not-base64.nope"), null);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a wake secret blob keeps only the known fields", async () => {
|
|
24
|
+
const sealed = await sealWakeSecrets(key, tenant, {
|
|
25
|
+
WAKE_WEBHOOK_KEY: "hook-key",
|
|
26
|
+
WAKE_ACCESS_CLIENT_SECRET: "access-secret",
|
|
27
|
+
});
|
|
28
|
+
const opened = await openWakeSecrets(key, tenant, sealed);
|
|
29
|
+
assert.deepEqual(opened, { WAKE_WEBHOOK_KEY: "hook-key", WAKE_ACCESS_CLIENT_SECRET: "access-secret" });
|
|
30
|
+
assert.equal(await sealWakeSecrets(key, tenant, { WAKE_WEBHOOK_KEY: "x".repeat(4097) }), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("four maximum-length wake secrets round-trip", async () => {
|
|
34
|
+
const value = "x".repeat(MAX_WAKE_VALUE);
|
|
35
|
+
const secrets = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, value]));
|
|
36
|
+
const sealed = await sealWakeSecrets(key, tenant, secrets);
|
|
37
|
+
assert.ok(sealed);
|
|
38
|
+
assert.ok(sealed!.length > 16384);
|
|
39
|
+
assert.ok(sealed!.length <= MAX_WAKE_SEALED);
|
|
40
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
|
|
41
|
+
assert.equal(await openWakeSecrets(key, tenant, "v1." + "a".repeat(MAX_WAKE_SEALED)), null);
|
|
42
|
+
|
|
43
|
+
const escaped = "\u0000".repeat(MAX_WAKE_VALUE);
|
|
44
|
+
const worst = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, escaped]));
|
|
45
|
+
const sealedWorst = await sealWakeSecrets(key, tenant, worst);
|
|
46
|
+
assert.ok(sealedWorst);
|
|
47
|
+
assert.ok(sealedWorst!.length <= MAX_WAKE_SEALED);
|
|
48
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, sealedWorst), worst);
|
|
49
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -116,6 +116,22 @@ test("generic custom key header is rendered with prefix and redacted in previews
|
|
|
116
116
|
assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
test("a key header the preview shows in full is not used for the key", async () => {
|
|
120
|
+
const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "Content-Type", keyPrefix: "" }, ev))!;
|
|
121
|
+
assert.equal(req.headers["authorization"], "supersecretvalue");
|
|
122
|
+
assert.equal(req.headers["content-type"], "application/json");
|
|
123
|
+
assert.equal(redact(req).headers["authorization"], "supe…");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("generic key header is redacted even when its name is not a secret word", async () => {
|
|
127
|
+
const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "X-Credential", keyPrefix: "" }, ev))!;
|
|
128
|
+
assert.equal(req.headers["x-credential"], "supersecretvalue");
|
|
129
|
+
const red = redact(req);
|
|
130
|
+
assert.equal(red.headers["x-credential"], "supe…");
|
|
131
|
+
assert.equal(red.headers["content-type"], "application/json");
|
|
132
|
+
assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
|
|
133
|
+
});
|
|
134
|
+
|
|
119
135
|
// ---------------------------------------------------------------- Cloudflare Access service token (tunnel wakes)
|
|
120
136
|
const ACCESS = { accessClientId: "abc123.access", accessClientSecret: "s3cr3t-value-0123456789" };
|
|
121
137
|
|