a2a-exposed 0.7.12 → 0.7.14
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 +16 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/export.ts +67 -2
- package/worker/src/index.ts +87 -12
- package/worker/src/owner-rpc.ts +53 -5
- package/worker/src/wake-target.ts +124 -70
- package/worker/src/wake.ts +15 -2
- package/worker/test/alarms.test.ts +17 -0
- package/worker/test/export.test.ts +117 -1
- package/worker/test/hosted.test.ts +143 -4
- package/worker/test/owner-rpc.test.ts +46 -4
- package/worker/test/pairing.test.ts +75 -2
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +6 -4
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)
|
|
@@ -179,8 +182,17 @@ Control plane account (only when that deployment's login is on)
|
|
|
179
182
|
browser can follow the redirect. Buttons say Continue with that
|
|
180
183
|
provider. Until the check finishes they stay muted and the page says
|
|
181
184
|
Waiting for the check…. A reused token says The check expired. Please try again.
|
|
182
|
-
After sign-in, /app shows the account and its agents.
|
|
183
|
-
|
|
185
|
+
After sign-in, /app shows the account and its agents. Each name opens
|
|
186
|
+
that agent's page: MCP connectors (revoke needs a recent sign-in), a
|
|
187
|
+
message log that does not mark tasks seen, and a SQL download that
|
|
188
|
+
replaces a pasted owner token and leaves other credential material out. Peer times are labeled UTC. A pairing
|
|
189
|
+
token shows its code. An empty outbound list notes that peers the CLI
|
|
190
|
+
added locally are not shown. Sign out is on that
|
|
191
|
+
page. Creating an agent shows the owner token once. The agent page rotates
|
|
192
|
+
that token after a confirmation and a recent sign-in. The new token is
|
|
193
|
+
shown once on that agent's page. The previous one stops working immediately.
|
|
194
|
+
The token is shown only when the stored hash is still that token.
|
|
195
|
+
That page is not stored.
|
|
184
196
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
185
197
|
Unset, there is no limit. The database rejects another insert.
|
|
186
198
|
SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
|
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/export.ts
CHANGED
|
@@ -92,15 +92,80 @@ export async function exportRows(db: SqlDb, now = new Date()): Promise<ExportFil
|
|
|
92
92
|
return { format: "a2a-exposed-export", version: 1, exportedAt: now.toISOString(), tables };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/**
|
|
96
|
-
export
|
|
95
|
+
/** Live-credential tables. A dashboard SQL copy skips them so the script does not delete rows it does not return. */
|
|
96
|
+
export const REDACTED_SQL_SKIP = ["oauth_codes", "oidc_txns"] as const;
|
|
97
|
+
|
|
98
|
+
const OMIT_COLUMNS: Record<string, readonly string[]> = {
|
|
99
|
+
tasks: ["push_configs_json"],
|
|
100
|
+
outbound: ["push_token_hash"],
|
|
101
|
+
mcp_grants: ["access_hash", "refresh_hash"],
|
|
102
|
+
outbound_peers: ["token_enc"],
|
|
103
|
+
device_requests: ["replaces_hash"],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const SETUP_SUB = /([?&])t=[A-Za-z0-9_-]{16,}/g;
|
|
107
|
+
const USERINFO_SUB = /([a-z][a-z0-9+.-]*:\/\/)[^/?\s]*@/gi;
|
|
108
|
+
|
|
109
|
+
/** Replace a peer token, a hosted owner token (`a2aot_`), a setup secret, or URL userinfo. The rest of the text stays. */
|
|
110
|
+
export function scrubSecretText(value: string): string {
|
|
111
|
+
const tokens = /(?:a2aow_|s2a_|a2amcp_|a2amcpr_|a2aot_)[A-Za-z0-9_-]{20,}/g;
|
|
112
|
+
return value.replace(tokens, "[redacted]").replace(SETUP_SUB, "$1t=[redacted]").replace(USERINFO_SUB, "$1[redacted]@");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function placeholder(table: string, row: Record<string, unknown>, index: number): string {
|
|
116
|
+
const label = table === "peers" ? row.label : row.user_code;
|
|
117
|
+
const suffix = typeof label === "string" && label ? label : String(index);
|
|
118
|
+
return `redacted:${suffix}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A copy of `file` safe to show on the dashboard.
|
|
123
|
+
* Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace).
|
|
124
|
+
* `peers.token_hash` and `device_requests.device_hash` are NOT NULL, so they become `redacted:…` (not the hash, and not a token that authenticates).
|
|
125
|
+
* `oauth_codes` and `oidc_txns` are emptied. The approval-password row and the password-setup row (its value is the hash of the live setup token) are removed.
|
|
126
|
+
* Token-shaped text, including a hosted owner token (`a2aot_`), is scrubbed. The input is not changed: the CLI dump stays complete.
|
|
127
|
+
*/
|
|
128
|
+
export function redactExport(file: ExportFile): ExportFile {
|
|
129
|
+
if (file.format !== "a2a-exposed-export" || file.version !== 1) throw new ExportError("export: bad file");
|
|
130
|
+
if (!/^[\dTZ:.-]+$/.test(file.exportedAt)) throw new ExportError("export: bad timestamp");
|
|
131
|
+
const tables: ExportFile["tables"] = {};
|
|
132
|
+
for (const name of EXPORT_TABLES) {
|
|
133
|
+
if ((REDACTED_SQL_SKIP as readonly string[]).includes(name)) {
|
|
134
|
+
tables[name] = [];
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const omit = new Set(OMIT_COLUMNS[name] ?? []);
|
|
138
|
+
const rows: Record<string, unknown>[] = [];
|
|
139
|
+
for (const [index, row] of (file.tables[name] ?? []).entries()) {
|
|
140
|
+
if (name === "settings" && (row.key === "approval_password" || row.key === "password_setup")) continue;
|
|
141
|
+
const out: Record<string, unknown> = {};
|
|
142
|
+
for (const [key, value] of Object.entries(row)) {
|
|
143
|
+
if (omit.has(key) || key.toLowerCase() === "replaces_hash") continue;
|
|
144
|
+
if ((name === "peers" && key === "token_hash") || (name === "device_requests" && key === "device_hash")) {
|
|
145
|
+
out[key] = placeholder(name, row, index);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
out[key] = typeof value === "string" ? scrubSecretText(value) : value;
|
|
149
|
+
}
|
|
150
|
+
delete out.replaces_hash;
|
|
151
|
+
rows.push(out);
|
|
152
|
+
}
|
|
153
|
+
tables[name] = rows;
|
|
154
|
+
}
|
|
155
|
+
return { format: "a2a-exposed-export", version: 1, exportedAt: file.exportedAt, tables };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** DELETE then INSERT for every allowlisted table. Safe to run on a database that already has the schema. `skip` leaves those tables out of the script. */
|
|
159
|
+
export function toSql(file: ExportFile, options?: { skip?: readonly string[] }): string {
|
|
97
160
|
if (file.format !== "a2a-exposed-export" || file.version !== 1) throw new ExportError("export: bad file");
|
|
98
161
|
if (!/^[\dTZ:.-]+$/.test(file.exportedAt)) throw new ExportError("export: bad timestamp");
|
|
162
|
+
const skip = new Set(options?.skip ?? []);
|
|
99
163
|
const lines = [
|
|
100
164
|
`-- a2a-exposed-export ${file.version}`,
|
|
101
165
|
`-- exported_at ${file.exportedAt}`,
|
|
102
166
|
];
|
|
103
167
|
for (const name of EXPORT_TABLES) {
|
|
168
|
+
if (skip.has(name)) continue;
|
|
104
169
|
lines.push(`DELETE FROM ${quoteIdent(name)};`);
|
|
105
170
|
for (const row of file.tables[name] ?? []) {
|
|
106
171
|
const cols = Object.keys(row);
|
package/worker/src/index.ts
CHANGED
|
@@ -10,9 +10,9 @@ 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
|
+
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
14
14
|
import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
15
|
-
import { exportRows, toSql } from "./export.ts";
|
|
15
|
+
import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql } from "./export.ts";
|
|
16
16
|
import { backupDatabase, DAILY_CRON } from "./backup.ts";
|
|
17
17
|
import * as O from "./approval-oidc.ts";
|
|
18
18
|
type Json = any;
|
|
@@ -83,7 +83,17 @@ function wakeConfig(ctx: TenantContext): WakeConfig {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const debounceMs = (ctx: TenantContext) => Number(ctx.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(ctx))) * 1000;
|
|
86
|
-
|
|
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
|
+
}
|
|
87
97
|
|
|
88
98
|
async function wakeBudgetOk(ctx: TenantContext): Promise<boolean> {
|
|
89
99
|
const cap = maxPerHour(ctx);
|
|
@@ -118,7 +128,10 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
118
128
|
try {
|
|
119
129
|
let r: Response;
|
|
120
130
|
if (ctx.gates.wakeTargetPolicy === "public-https") {
|
|
121
|
-
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
|
+
});
|
|
122
135
|
if ("refused" in checked) {
|
|
123
136
|
log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
|
|
124
137
|
return { status: null, info: `refused: ${checked.refused}` };
|
|
@@ -906,6 +919,21 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
906
919
|
const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
|
|
907
920
|
const body = async () => { const raw = await readBody(req, ctx); return raw ? JSON.parse(raw) : {}; };
|
|
908
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
|
+
}
|
|
909
937
|
if (seg[0] === "inbox" && m === "GET") {
|
|
910
938
|
const contextFilter = url.searchParams.get("context");
|
|
911
939
|
const all = url.searchParams.get("all") === "1";
|
|
@@ -1019,7 +1047,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1019
1047
|
return json({ revoked: true, label });
|
|
1020
1048
|
}
|
|
1021
1049
|
}
|
|
1022
|
-
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);
|
|
1023
1051
|
if (seg[0] === "mcp" && !seg[1] && m === "GET") {
|
|
1024
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();
|
|
1025
1053
|
return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
|
|
@@ -1079,7 +1107,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1079
1107
|
return json({ mode: "proxy", ...(await verifyUpstream(ctx)) });
|
|
1080
1108
|
}
|
|
1081
1109
|
if (seg[0] === "wake" && seg[1] === "preview" && m === "GET") {
|
|
1082
|
-
// 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
|
|
1083
1111
|
// uploaded secrets so the owner can compare them with local values without revealing them
|
|
1084
1112
|
const ev: WakeEvent = { contextId: "ctx-preview", taskId: "task-preview", taskIds: ["task-preview"], from: "example-peer",
|
|
1085
1113
|
preview: "Example message preview", kind: "test", publicUrl: ctx.PUBLIC_URL };
|
|
@@ -1118,6 +1146,27 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1118
1146
|
const rows = await ctx.DB.prepare("SELECT context_id, COUNT(*) AS entries, MAX(ts) AS last FROM history GROUP BY context_id ORDER BY last DESC LIMIT 100").all();
|
|
1119
1147
|
return json(rows.results);
|
|
1120
1148
|
}
|
|
1149
|
+
if (seg[0] === "log" && !seg[1] && m === "GET") {
|
|
1150
|
+
// Recent history, newest first. Unlike GET /owner/inbox this does not clear a pending wake, and it does not return data_json.
|
|
1151
|
+
const clip = (v: unknown, max: number) => typeof v === "string" ? scrubSecretText(v).slice(0, max) : null;
|
|
1152
|
+
const rows = await ctx.DB.prepare(
|
|
1153
|
+
"SELECT seq, context_id, ts, dir, peer, task_id, role, state, event, text FROM history ORDER BY seq DESC LIMIT 100",
|
|
1154
|
+
).all();
|
|
1155
|
+
return json({
|
|
1156
|
+
entries: (rows.results as Json[]).map((r) => ({
|
|
1157
|
+
seq: typeof r.seq === "number" ? r.seq : Number(r.seq),
|
|
1158
|
+
contextId: clip(r.context_id, 200),
|
|
1159
|
+
ts: clip(r.ts, 40),
|
|
1160
|
+
dir: clip(r.dir, 16),
|
|
1161
|
+
peer: clip(r.peer, 200),
|
|
1162
|
+
taskId: clip(r.task_id, 200),
|
|
1163
|
+
role: clip(r.role, 32),
|
|
1164
|
+
state: clip(r.state, 32),
|
|
1165
|
+
event: clip(r.event, 64),
|
|
1166
|
+
text: clip(r.text, 2000),
|
|
1167
|
+
})),
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1121
1170
|
if (seg[0] === "export" && !seg[1] && m === "GET") {
|
|
1122
1171
|
const file = await exportRows(ctx.DB);
|
|
1123
1172
|
if (url.searchParams.get("format") === "sql") {
|
|
@@ -1576,7 +1625,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
|
|
|
1576
1625
|
}
|
|
1577
1626
|
|
|
1578
1627
|
/** Owner API: /owner/pairing (list, mode), /owner/pairing/password (set), /owner/pairing/<code>/approve|deny. */
|
|
1579
|
-
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json
|
|
1628
|
+
async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
|
|
1580
1629
|
const mode = pairingMode(ctx);
|
|
1581
1630
|
if (!seg[0] && m === "GET") {
|
|
1582
1631
|
const now = Date.now();
|
|
@@ -1627,9 +1676,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
|
|
|
1627
1676
|
const code = P.normUserCode(decodeURIComponent(seg[0]));
|
|
1628
1677
|
if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
|
|
1629
1678
|
const approve = seg[1] === "approve";
|
|
1630
|
-
|
|
1679
|
+
// The header is set only by runOwnerRpc for actor "human". A network request is not trusted, so the same
|
|
1680
|
+
// header on /owner/* does not approve and does not change who is logged.
|
|
1681
|
+
const dashboardHuman = trusted && req.headers.get(DASHBOARD_ACTOR_HEADER) === "human";
|
|
1682
|
+
if (approve && mode !== "agent" && !dashboardHuman)
|
|
1631
1683
|
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);
|
|
1632
|
-
const d = await decide(ctx, code, approve, approve ? "agent" : "owner");
|
|
1684
|
+
const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
|
|
1633
1685
|
if (!d.ok) return json({ error: d.error }, 409);
|
|
1634
1686
|
return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
|
|
1635
1687
|
clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
|
|
@@ -2098,13 +2150,21 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2098
2150
|
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2099
2151
|
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2100
2152
|
if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
|
|
2153
|
+
const actor = (raw as { actor?: unknown } | null)?.actor;
|
|
2154
|
+
if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
|
|
2155
|
+
const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
|
|
2156
|
+
if (actor === "human" && !pairingDecision) return refuse(400, "actor is invalid", "actor");
|
|
2101
2157
|
let origin = "https://tenant.invalid";
|
|
2102
2158
|
try {
|
|
2103
2159
|
const u = new URL(ctx.PUBLIC_URL);
|
|
2104
2160
|
if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
|
|
2105
2161
|
} catch { /* the handler reads the path, not this origin */ }
|
|
2106
|
-
|
|
2107
|
-
const
|
|
2162
|
+
// format=sql is text/plain on the HTTP API. The RPC always reads the JSON dump, then redacts, then builds SQL.
|
|
2163
|
+
const search = matched.route.path === "/owner/export" ? "" : matched.route.search;
|
|
2164
|
+
const url = new URL(origin + matched.route.path + search);
|
|
2165
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2166
|
+
if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
|
|
2167
|
+
const req = new Request(url, { method: matched.route.method, headers });
|
|
2108
2168
|
let res: Response;
|
|
2109
2169
|
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2110
2170
|
catch (e: any) {
|
|
@@ -2114,7 +2174,22 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2114
2174
|
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
|
|
2115
2175
|
return { status, body: { error } };
|
|
2116
2176
|
}
|
|
2117
|
-
|
|
2177
|
+
let data = scrubOwnerClaims(await res.json().catch(() => null));
|
|
2178
|
+
if (matched.route.path === "/owner/export" && res.ok && data && typeof data === "object") {
|
|
2179
|
+
try {
|
|
2180
|
+
const file = redactExport(data as Parameters<typeof redactExport>[0]);
|
|
2181
|
+
data = matched.route.search === "?format=sql"
|
|
2182
|
+
? {
|
|
2183
|
+
exportedAt: file.exportedAt,
|
|
2184
|
+
sql: "-- Dashboard copy. This is not a backup. Do not load it onto a live inbox.\n"
|
|
2185
|
+
+ "-- Stored token hashes are replaced with redacted: values that do not authenticate, or left out. Ciphertext, push tokens, re-pair hashes, OAuth codes, the approval-password hash, password-setup hashes, and OpenID Connect sign-in rows are omitted.\n"
|
|
2186
|
+
+ toSql(file, { skip: REDACTED_SQL_SKIP }),
|
|
2187
|
+
}
|
|
2188
|
+
: file;
|
|
2189
|
+
} catch {
|
|
2190
|
+
return refuse(500, "response withheld", "secret");
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2118
2193
|
if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
|
|
2119
2194
|
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
|
|
2120
2195
|
return { status: res.status, body: data };
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Hosted owner RPC allowlist. The control plane calls TenantStore.owner() after it has checked that
|
|
2
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
|
|
3
|
+
// the owner HTTP API: routes that mint a peer token, return a setup secret, or accept a plaintext peer
|
|
4
|
+
// token are not here. GET /owner/export is here only because runOwnerRpc redacts it first. Self-host never calls this.
|
|
5
5
|
|
|
6
6
|
import { ID_RE } from "./a2a.ts";
|
|
7
|
-
import { previewHeaderPlain } from "./wake.ts";
|
|
7
|
+
import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
|
|
8
8
|
|
|
9
9
|
/** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
|
|
10
10
|
export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
@@ -15,7 +15,9 @@ const ALIAS = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
|
15
15
|
/** Display form WDJB-4827 or the stored form WDJB4827. Vowels and 0/1 are not in a real code. */
|
|
16
16
|
const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-?[2-9]{4}$/i;
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** Peer tokens, MCP tokens, and the hosted owner token (`a2aot_`). */
|
|
19
|
+
export const TOKEN_PREFIXES = ["a2aow_", "s2a_", "a2amcp_", "a2amcpr_", "a2aot_"] as const;
|
|
20
|
+
const TOKEN_RE = new RegExp(`^(?:${TOKEN_PREFIXES.join("|")})[A-Za-z0-9_-]{20,}$`);
|
|
19
21
|
const SETUP_RE = /(?:\?|&)t=[A-Za-z0-9_-]{16,}/;
|
|
20
22
|
const USERINFO_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?\s]*@/i;
|
|
21
23
|
/** A non-empty string under one of these keys is a secret, even when it is not a token shape. */
|
|
@@ -25,6 +27,12 @@ const SECRET_KEYS = new Set([
|
|
|
25
27
|
"clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
|
|
26
28
|
]);
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Header `runOwnerRpc` sets when `actor` is `human`. `handleOwner` honors it only on an in-process call.
|
|
32
|
+
* A request from the network cannot become that decision by sending the header.
|
|
33
|
+
*/
|
|
34
|
+
export const DASHBOARD_ACTOR_HEADER = "x-a2a-dashboard-actor";
|
|
35
|
+
|
|
28
36
|
export interface OwnerRpcRequest {
|
|
29
37
|
accountId: string;
|
|
30
38
|
method: string;
|
|
@@ -33,6 +41,11 @@ export interface OwnerRpcRequest {
|
|
|
33
41
|
query?: Record<string, string>;
|
|
34
42
|
/** Refused. The owner HTTP API is where a body is accepted. */
|
|
35
43
|
body?: unknown;
|
|
44
|
+
/**
|
|
45
|
+
* Dashboard approve or deny after the control plane has checked a recent sign-in.
|
|
46
|
+
* Any other value is refused. Absent keeps the owner HTTP API's actor (`agent` or `owner`).
|
|
47
|
+
*/
|
|
48
|
+
actor?: "human";
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
export interface OwnerRpcResult {
|
|
@@ -99,6 +112,14 @@ function historyQuery(q: Record<string, string>): string | null {
|
|
|
99
112
|
return `?n=${q.n}`;
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
/** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
|
|
116
|
+
function exportQuery(q: Record<string, string>): string | null {
|
|
117
|
+
for (const k of Object.keys(q)) if (k !== "format") return null;
|
|
118
|
+
if (q.format === undefined) return "";
|
|
119
|
+
if (q.format !== "sql") return null;
|
|
120
|
+
return "?format=sql";
|
|
121
|
+
}
|
|
122
|
+
|
|
102
123
|
interface RouteDef {
|
|
103
124
|
method: "GET" | "POST" | "DELETE";
|
|
104
125
|
name: string;
|
|
@@ -109,6 +130,7 @@ interface RouteDef {
|
|
|
109
130
|
|
|
110
131
|
const ROUTES: RouteDef[] = [
|
|
111
132
|
{ method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
|
|
133
|
+
{ method: "GET", name: "GET /owner/summary", take: (s) => s.length === 2 && s[1] === "summary", search: noQuery, path: () => "/owner/summary" },
|
|
112
134
|
{ 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
135
|
{ 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
136
|
{ 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]}` },
|
|
@@ -126,6 +148,8 @@ const ROUTES: RouteDef[] = [
|
|
|
126
148
|
{ 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
149
|
{ 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
150
|
{ method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
|
|
151
|
+
{ method: "GET", name: "GET /owner/log", take: (s) => s.length === 2 && s[1] === "log", search: noQuery, path: () => "/owner/log" },
|
|
152
|
+
{ method: "GET", name: "GET /owner/export", take: (s) => s.length === 2 && s[1] === "export", search: exportQuery, path: () => "/owner/export" },
|
|
129
153
|
];
|
|
130
154
|
|
|
131
155
|
/** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
|
|
@@ -147,12 +171,36 @@ function stringLeaks(value: string, key: string | null): boolean {
|
|
|
147
171
|
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
|
148
172
|
}
|
|
149
173
|
|
|
174
|
+
/** Claimed display fields. A token-shaped value is blanked so one request cannot withhold the list. */
|
|
175
|
+
const CLAIM_KEYS = new Set(["clientname", "client_name", "clientid", "client_id", "agentcardurl", "agent_card_url"]);
|
|
176
|
+
|
|
177
|
+
function claimLeaks(value: string): boolean {
|
|
178
|
+
return TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Replace a claimed name, alias, or card that is shaped like a peer token, a setup secret, or URL
|
|
183
|
+
* userinfo with an empty string. Other keys are left for `ownerResponseLeaks`.
|
|
184
|
+
*/
|
|
185
|
+
export function scrubOwnerClaims(value: unknown, depth = 0): unknown {
|
|
186
|
+
if (value == null || depth > 20) return value;
|
|
187
|
+
if (Array.isArray(value)) return value.map((item) => scrubOwnerClaims(item, depth + 1));
|
|
188
|
+
if (typeof value !== "object") return value;
|
|
189
|
+
const out: Record<string, unknown> = {};
|
|
190
|
+
for (const [key, item] of Object.entries(value)) {
|
|
191
|
+
out[key] = typeof item === "string" && CLAIM_KEYS.has(key.toLowerCase()) && claimLeaks(item)
|
|
192
|
+
? ""
|
|
193
|
+
: scrubOwnerClaims(item, depth + 1);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
/** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
|
|
151
199
|
function wakePreviewLeaks(value: object): boolean {
|
|
152
200
|
const headers = (value as { method?: unknown; headers?: unknown }).headers;
|
|
153
201
|
if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
|
|
154
202
|
for (const [k, v] of Object.entries(headers)) {
|
|
155
|
-
if (typeof v !== "string" || previewHeaderPlain(k) || v
|
|
203
|
+
if (typeof v !== "string" || previewHeaderPlain(k) || credentialIsMasked(v)) continue;
|
|
156
204
|
return true;
|
|
157
205
|
}
|
|
158
206
|
return false;
|