a2a-exposed 0.7.11 → 0.7.13
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/main.mjs +5 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/hosted.ts +46 -8
- package/worker/src/index.ts +92 -8
- package/worker/src/owner-rpc.ts +211 -0
- package/worker/src/tenancy.ts +3 -2
- package/worker/src/wake-secrets.ts +113 -0
- package/worker/src/wake-target.ts +124 -70
- package/worker/src/wake.ts +29 -3
- package/worker/test/alarms.test.ts +17 -0
- package/worker/test/hosted.test.ts +228 -0
- package/worker/test/owner-rpc.test.ts +107 -0
- package/worker/test/pairing.test.ts +43 -2
- package/worker/test/wake-secrets.test.ts +49 -0
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +20 -2
package/lib/main.mjs
CHANGED
|
@@ -68,8 +68,11 @@ CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting)
|
|
|
68
68
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
69
69
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
70
70
|
save wake settings, upload wake secrets from env, and redeploy (one step)
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
72
|
+
which defaults to 25 when this is unset
|
|
73
|
+
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
74
|
+
+ sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
|
|
75
|
+
local env values if exported
|
|
73
76
|
wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* in the local environment
|
|
74
77
|
wake test send a test wake now and print the HTTP status
|
|
75
78
|
wake unset remove wake secrets (fall back to polling)
|
package/package.json
CHANGED
|
@@ -49,7 +49,7 @@ const plain: Record<string, string> = {
|
|
|
49
49
|
// Hosted multi-tenant gates. All empty by default, and empty is omitted, so a self-host deploy is unchanged.
|
|
50
50
|
// TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
|
|
51
51
|
// QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are parsed and reserved.
|
|
52
|
-
// WAKE_TARGET_POLICY=public-https refuses a wake that is not public https (see wake-target.ts).
|
|
52
|
+
// WAKE_TARGET_POLICY=public-https refuses a wake that is not public https. The socket opens to an address from that check, and TLS names the hostname (see wake-target.ts).
|
|
53
53
|
// APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
|
|
54
54
|
TENANCY: v("TENANCY"),
|
|
55
55
|
TENANT_DOMAIN: v("TENANT_DOMAIN"),
|
package/worker/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, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, 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";
|
|
@@ -82,7 +83,17 @@ function wakeConfig(ctx: TenantContext): WakeConfig {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
const debounceMs = (ctx: TenantContext) => Number(ctx.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(ctx))) * 1000;
|
|
85
|
-
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hourly wake cap. 0 means unlimited, including an explicit "0".
|
|
89
|
+
* An empty or unset value uses the preset default (unlimited, or 25 for Claude Code).
|
|
90
|
+
*/
|
|
91
|
+
function maxPerHour(ctx: TenantContext): number {
|
|
92
|
+
const raw = (ctx.WAKE_MAX_PER_HOUR ?? "").trim();
|
|
93
|
+
const n = raw === "" ? defaultMaxPerHour(preset(ctx)) : Number(raw);
|
|
94
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
95
|
+
return Math.floor(n);
|
|
96
|
+
}
|
|
86
97
|
|
|
87
98
|
async function wakeBudgetOk(ctx: TenantContext): Promise<boolean> {
|
|
88
99
|
const cap = maxPerHour(ctx);
|
|
@@ -117,7 +128,10 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
117
128
|
try {
|
|
118
129
|
let r: Response;
|
|
119
130
|
if (ctx.gates.wakeTargetPolicy === "public-https") {
|
|
120
|
-
const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body }
|
|
131
|
+
const checked = await fetchCheckedWake(req.url, { method: "POST", headers: req.headers, body: req.body }, {
|
|
132
|
+
localHost: (() => { try { return new URL(ctx.PUBLIC_URL).host; } catch { return ""; } })(),
|
|
133
|
+
local: (url, init) => doFetch(ctx, ectx, url, init),
|
|
134
|
+
});
|
|
121
135
|
if ("refused" in checked) {
|
|
122
136
|
log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
|
|
123
137
|
return { status: null, info: `refused: ${checked.refused}` };
|
|
@@ -898,12 +912,28 @@ function ownerTaskView(t: Json): Json {
|
|
|
898
912
|
}
|
|
899
913
|
|
|
900
914
|
async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
|
|
901
|
-
// trusted: an in-process call
|
|
915
|
+
// trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
|
|
916
|
+
// owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
|
|
902
917
|
if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
|
|
903
918
|
const m = req.method;
|
|
904
919
|
const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
|
|
905
920
|
const body = async () => { const raw = await readBody(req, ctx); return raw ? JSON.parse(raw) : {}; };
|
|
906
921
|
|
|
922
|
+
if (seg[0] === "summary" && !seg[1] && m === "GET") {
|
|
923
|
+
// Counts only. Unlike GET /owner/inbox this does not return message text or clear a pending wake.
|
|
924
|
+
const known = ["submitted", "working", "input-required", "completed", "failed", "rejected", "canceled"];
|
|
925
|
+
const tasks: Record<string, number> = {};
|
|
926
|
+
for (const state of known) tasks[state] = 0;
|
|
927
|
+
const rows = await ctx.DB.prepare("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all();
|
|
928
|
+
for (const r of rows.results as Json[]) {
|
|
929
|
+
if (typeof r.state !== "string" || !known.includes(r.state)) continue;
|
|
930
|
+
const n = Number(r.n);
|
|
931
|
+
if (Number.isSafeInteger(n) && n >= 0) tasks[r.state] = n;
|
|
932
|
+
}
|
|
933
|
+
const pending: Json = await ctx.DB.prepare("SELECT COUNT(*) AS n FROM device_requests WHERE status = 'pending' AND expires_ms > ?").bind(Date.now()).first();
|
|
934
|
+
const pairing = Number(pending?.n ?? 0);
|
|
935
|
+
return json({ tasks, pairingPending: Number.isSafeInteger(pairing) && pairing >= 0 ? pairing : 0 });
|
|
936
|
+
}
|
|
907
937
|
if (seg[0] === "inbox" && m === "GET") {
|
|
908
938
|
const contextFilter = url.searchParams.get("context");
|
|
909
939
|
const all = url.searchParams.get("all") === "1";
|
|
@@ -1017,7 +1047,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1017
1047
|
return json({ revoked: true, label });
|
|
1018
1048
|
}
|
|
1019
1049
|
}
|
|
1020
|
-
if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body);
|
|
1050
|
+
if (seg[0] === "pairing") return await ownerPairing(req, ctx, m, seg.slice(1), body, trusted);
|
|
1021
1051
|
if (seg[0] === "mcp" && !seg[1] && m === "GET") {
|
|
1022
1052
|
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();
|
|
1023
1053
|
return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
|
|
@@ -1077,7 +1107,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1077
1107
|
return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
|
|
1078
1108
|
}
|
|
1079
1109
|
if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
|
|
1080
|
-
// rendered wake request for a sample event,
|
|
1110
|
+
// rendered wake request for a sample event, credentials shown as Bearer •••• or ••••, plus short sha256 fingerprints of the
|
|
1081
1111
|
// uploaded secrets so the owner can compare them with local values without revealing them
|
|
1082
1112
|
const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
|
|
1083
1113
|
preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
|
|
@@ -1574,7 +1604,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
|
|
|
1574
1604
|
}
|
|
1575
1605
|
|
|
1576
1606
|
/** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
|
|
1577
|
-
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json
|
|
1607
|
+
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
|
|
1578
1608
|
const mode = pairingMode(ctx);
|
|
1579
1609
|
if (!seg[0] && m === "GET") {
|
|
1580
1610
|
const now = Date.now();
|
|
@@ -1625,9 +1655,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1625
1655
|
const code = P.normUserCode(decodeURIComponent(seg[0]));
|
|
1626
1656
|
if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
|
|
1627
1657
|
const approve = seg[1] === "approve";
|
|
1628
|
-
|
|
1658
|
+
// The header is set only by runOwnerRpc for actor "human". A network request is not trusted, so the same
|
|
1659
|
+
// header on /owner/* does not approve and does not change who is logged.
|
|
1660
|
+
const dashboardHuman = trusted && req.headers.get(DASHBOARD_ACTOR_HEADER) === "human";
|
|
1661
|
+
if (approve && mode !== "agent" && !dashboardHuman)
|
|
1629
1662
|
return json({ error: `approval mode is human: the owner approves on ${oauthUrls(ctx).page}?user_code=${P.formatUserCode(code)} with the approval password. \`pair approve\` works only after \`${cliCommand(ctx)} deploy --pairing-approval agent\`, and only if your human wants the agent to approve. \`pair deny\` works in every mode.` }, 403);
|
|
1630
|
-
const d = await decide(ctx, code, approve, approve ? "agent" : "owner");
|
|
1663
|
+
const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
|
|
1631
1664
|
if (!d.ok) return json({ error: d.error }, 409);
|
|
1632
1665
|
return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
|
|
1633
1666
|
clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
|
|
@@ -2073,6 +2106,57 @@ async function mcpGrantOf(ctx: TenantContext, header: string | null): Promise<Js
|
|
|
2073
2106
|
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
2107
|
}
|
|
2075
2108
|
|
|
2109
|
+
/**
|
|
2110
|
+
* Hosted owner RPC. The control plane has already checked that `accountId` owns this tenant.
|
|
2111
|
+
* Only an allowlisted path runs, and the response is withheld when it contains a peer token or a secret.
|
|
2112
|
+
* Self-host (`mode` single) is refused here; its owner API stays the bearer check in `handleOwner`.
|
|
2113
|
+
*/
|
|
2114
|
+
export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, input: OwnerRpcRequest): Promise<OwnerRpcResult> {
|
|
2115
|
+
const raw = input && typeof input === "object" ? input as OwnerRpcRequest & { body?: unknown } : null;
|
|
2116
|
+
const accountId = accountIdOf(raw?.accountId);
|
|
2117
|
+
const tenant = ctx.id || null;
|
|
2118
|
+
const refuse = (status: number, error: string, reason: string): OwnerRpcResult => {
|
|
2119
|
+
log("owner_rpc_refused", { tenant, ...(accountId ? { accountId } : {}), reason });
|
|
2120
|
+
return { status, body: { error } };
|
|
2121
|
+
};
|
|
2122
|
+
if (!accountId) return refuse(400, "accountId is invalid", "account");
|
|
2123
|
+
if (ctx.mode !== "host") return refuse(404, "not found", "tenancy");
|
|
2124
|
+
if (ctx.status !== "active") {
|
|
2125
|
+
const status = ctx.status === "suspended" ? 403 : ctx.status === "deleting" ? 410 : 404;
|
|
2126
|
+
const error = ctx.status === "suspended" ? "this agent is suspended" : ctx.status === "deleting" ? "this agent is gone" : "no such agent";
|
|
2127
|
+
return refuse(status, error, "status");
|
|
2128
|
+
}
|
|
2129
|
+
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2130
|
+
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2131
|
+
if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
|
|
2132
|
+
const actor = (raw as { actor?: unknown } | null)?.actor;
|
|
2133
|
+
if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
|
|
2134
|
+
const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
|
|
2135
|
+
if (actor === "human" && !pairingDecision) return refuse(400, "actor is invalid", "actor");
|
|
2136
|
+
let origin = "https://tenant.invalid";
|
|
2137
|
+
try {
|
|
2138
|
+
const u = new URL(ctx.PUBLIC_URL);
|
|
2139
|
+
if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
|
|
2140
|
+
} catch { /* the handler reads the path, not this origin */ }
|
|
2141
|
+
const url = new URL(origin + matched.route.path + matched.route.search);
|
|
2142
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2143
|
+
if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
|
|
2144
|
+
const req = new Request(url, { method: matched.route.method, headers });
|
|
2145
|
+
let res: Response;
|
|
2146
|
+
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2147
|
+
catch (e: any) {
|
|
2148
|
+
const status = e instanceof HttpError ? e.status : 400;
|
|
2149
|
+
const error = String(e?.message || "bad request").slice(0, 200);
|
|
2150
|
+
if (ownerResponseLeaks(error)) return refuse(500, "response withheld", "secret");
|
|
2151
|
+
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
|
|
2152
|
+
return { status, body: { error } };
|
|
2153
|
+
}
|
|
2154
|
+
const data = scrubOwnerClaims(await res.json().catch(() => null));
|
|
2155
|
+
if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
|
|
2156
|
+
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
|
|
2157
|
+
return { status: res.status, body: data };
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2076
2160
|
/** Call an owner API route in-process (MCP tools only; the paths are fixed in the tool code). */
|
|
2077
2161
|
async function ownerCall(ctx: TenantContext, ectx: ExecutionContext, method: string, path: string, body?: Json): Promise<{ status: number; data: Json }> {
|
|
2078
2162
|
const url = new URL(ctx.PUBLIC_URL.replace(/\/$/, "") + path);
|
|
@@ -0,0 +1,211 @@
|
|
|
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 { credentialIsMasked, 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
|
+
/**
|
|
29
|
+
* Header `runOwnerRpc` sets when `actor` is `human`. `handleOwner` honors it only on an in-process call.
|
|
30
|
+
* A request from the network cannot become that decision by sending the header.
|
|
31
|
+
*/
|
|
32
|
+
export const DASHBOARD_ACTOR_HEADER = "x-a2a-dashboard-actor";
|
|
33
|
+
|
|
34
|
+
export interface OwnerRpcRequest {
|
|
35
|
+
accountId: string;
|
|
36
|
+
method: string;
|
|
37
|
+
path: string;
|
|
38
|
+
/** Allowlisted query parameters only. */
|
|
39
|
+
query?: Record<string, string>;
|
|
40
|
+
/** Refused. The owner HTTP API is where a body is accepted. */
|
|
41
|
+
body?: unknown;
|
|
42
|
+
/**
|
|
43
|
+
* Dashboard approve or deny after the control plane has checked a recent sign-in.
|
|
44
|
+
* Any other value is refused. Absent keeps the owner HTTP API's actor (`agent` or `owner`).
|
|
45
|
+
*/
|
|
46
|
+
actor?: "human";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface OwnerRpcResult {
|
|
50
|
+
status: number;
|
|
51
|
+
body: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OwnerRoute {
|
|
55
|
+
method: "GET" | "POST" | "DELETE";
|
|
56
|
+
path: string;
|
|
57
|
+
search: string;
|
|
58
|
+
/** Log name. Ids and codes are not included. */
|
|
59
|
+
name: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type OwnerMatch = { ok: true; route: OwnerRoute } | { ok: false; reason: "path" | "query" };
|
|
63
|
+
|
|
64
|
+
export function accountIdOf(v: unknown): string | null {
|
|
65
|
+
return typeof v === "string" && ACCOUNT_ID_RE.test(v) ? v : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function segments(path: unknown): string[] | null {
|
|
69
|
+
if (typeof path !== "string" || path.length > 300 || !path.startsWith("/owner/")) return null;
|
|
70
|
+
if (path.includes("%") || path.includes("\\") || path.includes("?") || path.includes("#") || path.includes("//")) return null;
|
|
71
|
+
const seg = path.split("/").filter(Boolean);
|
|
72
|
+
if (seg[0] !== "owner" || seg.some((s) => s === "." || s === "..")) return null;
|
|
73
|
+
return seg;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function queryRecord(query: unknown): Record<string, string> | null {
|
|
77
|
+
if (query == null) return {};
|
|
78
|
+
if (typeof query !== "object" || Array.isArray(query)) return null;
|
|
79
|
+
const out: Record<string, string> = {};
|
|
80
|
+
for (const [k, v] of Object.entries(query)) {
|
|
81
|
+
if (typeof v !== "string" || v.length > 128) return null;
|
|
82
|
+
out[k] = v;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function idOk(s: string): boolean {
|
|
88
|
+
return ID_RE.test(s) && s !== "." && s !== "..";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function noQuery(q: Record<string, string>): string | null {
|
|
92
|
+
return Object.keys(q).length === 0 ? "" : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function inboxQuery(q: Record<string, string>): string | null {
|
|
96
|
+
for (const k of Object.keys(q)) if (k !== "context" && k !== "all") return null;
|
|
97
|
+
if (q.context !== undefined && !idOk(q.context)) return null;
|
|
98
|
+
if (q.all !== undefined && q.all !== "1") return null;
|
|
99
|
+
const sp = new URLSearchParams();
|
|
100
|
+
if (q.context) sp.set("context", q.context);
|
|
101
|
+
if (q.all) sp.set("all", "1");
|
|
102
|
+
const s = sp.toString();
|
|
103
|
+
return s ? `?${s}` : "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function historyQuery(q: Record<string, string>): string | null {
|
|
107
|
+
for (const k of Object.keys(q)) if (k !== "n") return null;
|
|
108
|
+
if (q.n === undefined) return "";
|
|
109
|
+
if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
|
|
110
|
+
return `?n=${q.n}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RouteDef {
|
|
114
|
+
method: "GET" | "POST" | "DELETE";
|
|
115
|
+
name: string;
|
|
116
|
+
take: (seg: string[]) => boolean;
|
|
117
|
+
search: (q: Record<string, string>) => string | null;
|
|
118
|
+
path: (seg: string[]) => string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ROUTES: RouteDef[] = [
|
|
122
|
+
{ method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
|
|
123
|
+
{ method: "GET", name: "GET /owner/summary", take: (s) => s.length === 2 && s[1] === "summary", search: noQuery, path: () => "/owner/summary" },
|
|
124
|
+
{ 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]}` },
|
|
125
|
+
{ 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]}` },
|
|
126
|
+
{ 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]}` },
|
|
127
|
+
{ method: "GET", name: "GET /owner/peers", take: (s) => s.length === 2 && s[1] === "peers", search: noQuery, path: () => "/owner/peers" },
|
|
128
|
+
{ 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]}` },
|
|
129
|
+
{ method: "GET", name: "GET /owner/pairing", take: (s) => s.length === 2 && s[1] === "pairing", search: noQuery, path: () => "/owner/pairing" },
|
|
130
|
+
{ 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` },
|
|
131
|
+
{ 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` },
|
|
132
|
+
{ method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
|
|
133
|
+
{ 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]}` },
|
|
134
|
+
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
135
|
+
{ 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]}` },
|
|
136
|
+
{ method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
|
|
137
|
+
{ method: "POST", name: "POST /owner/facade/verify", take: (s) => s.length === 3 && s[1] === "facade" && s[2] === "verify", search: noQuery, path: () => "/owner/facade/verify" },
|
|
138
|
+
{ method: "GET", name: "GET /owner/wake/preview", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "preview", search: noQuery, path: () => "/owner/wake/preview" },
|
|
139
|
+
{ method: "POST", name: "POST /owner/wake/test", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "test", search: noQuery, path: () => "/owner/wake/test" },
|
|
140
|
+
{ method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
|
|
144
|
+
export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): OwnerMatch {
|
|
145
|
+
if (method !== "GET" && method !== "POST" && method !== "DELETE") return { ok: false, reason: "path" };
|
|
146
|
+
const seg = segments(path);
|
|
147
|
+
if (!seg) return { ok: false, reason: "path" };
|
|
148
|
+
const def = ROUTES.find((r) => r.method === method && r.take(seg));
|
|
149
|
+
if (!def) return { ok: false, reason: "path" };
|
|
150
|
+
const q = queryRecord(query);
|
|
151
|
+
if (!q) return { ok: false, reason: "query" };
|
|
152
|
+
const search = def.search(q);
|
|
153
|
+
if (search === null) return { ok: false, reason: "query" };
|
|
154
|
+
return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stringLeaks(value: string, key: string | null): boolean {
|
|
158
|
+
if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
|
|
159
|
+
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Claimed display fields. A token-shaped value is blanked so one request cannot withhold the list. */
|
|
163
|
+
const CLAIM_KEYS = new Set(["clientname", "client_name", "clientid", "client_id", "agentcardurl", "agent_card_url"]);
|
|
164
|
+
|
|
165
|
+
function claimLeaks(value: string): boolean {
|
|
166
|
+
return TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Replace a claimed name, alias, or card that is shaped like a peer token, a setup secret, or URL
|
|
171
|
+
* userinfo with an empty string. Other keys are left for `ownerResponseLeaks`.
|
|
172
|
+
*/
|
|
173
|
+
export function scrubOwnerClaims(value: unknown, depth = 0): unknown {
|
|
174
|
+
if (value == null || depth > 20) return value;
|
|
175
|
+
if (Array.isArray(value)) return value.map((item) => scrubOwnerClaims(item, depth + 1));
|
|
176
|
+
if (typeof value !== "object") return value;
|
|
177
|
+
const out: Record<string, unknown> = {};
|
|
178
|
+
for (const [key, item] of Object.entries(value)) {
|
|
179
|
+
out[key] = typeof item === "string" && CLAIM_KEYS.has(key.toLowerCase()) && claimLeaks(item)
|
|
180
|
+
? ""
|
|
181
|
+
: scrubOwnerClaims(item, depth + 1);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
|
|
187
|
+
function wakePreviewLeaks(value: object): boolean {
|
|
188
|
+
const headers = (value as { method?: unknown; headers?: unknown }).headers;
|
|
189
|
+
if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
|
|
190
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
191
|
+
if (typeof v !== "string" || previewHeaderPlain(k) || credentialIsMasked(v)) continue;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when a JSON value carries a peer token, a setup secret, a URL userinfo, or a secret field. */
|
|
198
|
+
export function ownerResponseLeaks(value: unknown, depth = 0, key: string | null = null): boolean {
|
|
199
|
+
if (depth > 20) return true;
|
|
200
|
+
if (typeof value === "string") return stringLeaks(value, key);
|
|
201
|
+
if (typeof value === "number" || typeof value === "boolean" || value == null) return false;
|
|
202
|
+
if (Array.isArray(value)) return value.some((v) => ownerResponseLeaks(v, depth + 1, null));
|
|
203
|
+
if (typeof value === "object") {
|
|
204
|
+
if (wakePreviewLeaks(value)) return true;
|
|
205
|
+
for (const [k, v] of Object.entries(value)) {
|
|
206
|
+
if (ownerResponseLeaks(v, depth + 1, k)) return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
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
|
|