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
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// Wake URL checks for WAKE_TARGET_POLICY=public-https. Unset stays the previous fetch
|
|
2
2
|
// (any URL the operator configured, redirects followed by the platform).
|
|
3
|
+
//
|
|
4
|
+
// The TCP connection is to an address from the DNS check. TLS is not the runtime's
|
|
5
|
+
// startTls({ expectedServerHostname }): on the Workers edge that sends no SNI when the
|
|
6
|
+
// socket was opened to an IP (cloudflare/workerd#6903), so the peer aborts before HTTP.
|
|
7
|
+
// The handshake runs in-process, with the URL hostname as SNI, over that pinned socket.
|
|
8
|
+
// A socket still cannot open a Cloudflare address, so a *.workers.dev target does not connect.
|
|
9
|
+
import { CertificateError, Client, TlsError } from "tunnelfetch";
|
|
3
10
|
import { isPrivateHost } from "./a2a.ts";
|
|
4
|
-
import { isPublicIp
|
|
11
|
+
import { isPublicIp } from "./mcp.ts";
|
|
5
12
|
|
|
6
13
|
/** DNS-over-HTTPS resolver. Same endpoint the client-metadata fetch uses. */
|
|
7
14
|
export const DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
@@ -9,14 +16,33 @@ export const DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
|
9
16
|
/** Original request plus this many redirects. Each hop is checked before it is requested. */
|
|
10
17
|
const MAX_REDIRECTS = 3;
|
|
11
18
|
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
12
21
|
/** Host to addresses. `null` means the lookup failed and the caller must fail closed. */
|
|
13
22
|
export type AddressLookup = (host: string) => Promise<string[] | null>;
|
|
14
23
|
|
|
15
|
-
type
|
|
24
|
+
type WakeSocket = {
|
|
25
|
+
readable: ReadableStream<Uint8Array>;
|
|
26
|
+
writable: WritableStream<Uint8Array>;
|
|
27
|
+
opened?: Promise<{ remoteAddress?: string | null }>;
|
|
28
|
+
close?: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Raw TCP. Production uses `cloudflare:sockets`. Tests replace it. */
|
|
32
|
+
export type WakeConnect = (address: { hostname: string; port: number }, options?: { secureTransport?: "off" | "on" | "starttls"; allowHalfOpen?: boolean }) => WakeSocket;
|
|
33
|
+
|
|
34
|
+
/** One checked hop. `ip` is the address the socket must use. `hostname` is the TLS name. */
|
|
35
|
+
export type CheckedDeliver = (url: string, init: RequestInit, target: { ip: string; hostname: string }) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
export const wakeNet: { connect: null | WakeConnect; deliver: null | CheckedDeliver } = { connect: null, deliver: null };
|
|
16
38
|
|
|
17
39
|
export type WakeDeps = {
|
|
18
40
|
lookup?: AddressLookup;
|
|
19
|
-
connect?:
|
|
41
|
+
connect?: WakeConnect;
|
|
42
|
+
deliver?: CheckedDeliver;
|
|
43
|
+
/** Deliver a URL on this host in-process. Used for a wake aimed at this inbox. */
|
|
44
|
+
localHost?: string;
|
|
45
|
+
local?: (url: string, init: RequestInit) => Promise<Response>;
|
|
20
46
|
};
|
|
21
47
|
|
|
22
48
|
function bareHost(host: string): string {
|
|
@@ -69,8 +95,8 @@ export async function publicWakeProblem(url: string, lookup: AddressLookup = loo
|
|
|
69
95
|
return "refused" in checked ? checked.refused : "";
|
|
70
96
|
}
|
|
71
97
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
98
|
+
/** Every public address from one lookup. The socket uses the first. The TLS name is the URL host. */
|
|
99
|
+
async function checkedWakeAddresses(url: string, lookup: AddressLookup): Promise<{ hostname: string; ips: string[] } | { refused: string }> {
|
|
74
100
|
let parsed: URL;
|
|
75
101
|
try { parsed = new URL(url); } catch { return { refused: "not a URL" }; }
|
|
76
102
|
if (parsed.protocol !== "https:") return { refused: "must be https" };
|
|
@@ -78,79 +104,99 @@ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lo
|
|
|
78
104
|
const host = parsed.hostname;
|
|
79
105
|
if (!host || isPrivateHost(host)) return { refused: "private or internal host" };
|
|
80
106
|
const bare = bareHost(host);
|
|
81
|
-
if (isIpLiteral(host)) return isPublicIp(bare) ? {
|
|
107
|
+
if (isIpLiteral(host)) return isPublicIp(bare) ? { hostname: bare, ips: [bare] } : { refused: "private or reserved address" };
|
|
82
108
|
let ips: string[] | null;
|
|
83
109
|
try { ips = await lookup(host); } catch { return { refused: "its address could not be checked" }; }
|
|
84
110
|
if (ips == null) return { refused: "its address could not be checked" };
|
|
85
111
|
if (!ips.length) return { refused: "the host has no address" };
|
|
86
112
|
if (!ips.every((ip) => isPublicIp(ip))) return { refused: "resolves to a private or reserved address" };
|
|
87
|
-
return {
|
|
113
|
+
return { hostname: host, ips };
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
return
|
|
116
|
+
/** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
|
|
117
|
+
export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
|
|
118
|
+
const checked = await checkedWakeAddresses(url, lookup);
|
|
119
|
+
if ("refused" in checked) return checked;
|
|
120
|
+
return { ip: checked.ips[0]! };
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
if (body.byteLength) lines.push(`content-length: ${body.byteLength}`);
|
|
105
|
-
lines.push("connection: close");
|
|
106
|
-
const head = new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n");
|
|
107
|
-
const raw = new Uint8Array(head.byteLength + body.byteLength);
|
|
108
|
-
raw.set(head);
|
|
109
|
-
raw.set(body, head.byteLength);
|
|
110
|
-
return raw;
|
|
123
|
+
/**
|
|
124
|
+
* Dial `ip`, ignoring the hostname the TLS stack passes in. The handshake names that hostname
|
|
125
|
+
* itself; this function only chooses the TCP address.
|
|
126
|
+
*/
|
|
127
|
+
export function dialChecked(ip: string, connect: WakeConnect): WakeConnect {
|
|
128
|
+
return (addr) => connect({ hostname: ip, port: addr.port }, { secureTransport: "off", allowHalfOpen: false });
|
|
111
129
|
}
|
|
112
130
|
|
|
113
|
-
async function
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
for (;;) {
|
|
118
|
-
const { done, value } = await reader.read();
|
|
119
|
-
if (done) break;
|
|
120
|
-
n += value.byteLength;
|
|
121
|
-
if (n > cap) break;
|
|
122
|
-
chunks.push(value);
|
|
123
|
-
}
|
|
124
|
-
const raw = new Uint8Array(Math.min(n, cap));
|
|
125
|
-
let o = 0;
|
|
126
|
-
for (const chunk of chunks) {
|
|
127
|
-
const take = Math.min(chunk.byteLength, raw.byteLength - o);
|
|
128
|
-
raw.set(chunk.subarray(0, take), o);
|
|
129
|
-
o += take;
|
|
130
|
-
}
|
|
131
|
-
return raw;
|
|
131
|
+
async function runtimeConnect(): Promise<WakeConnect> {
|
|
132
|
+
if (wakeNet.connect) return wakeNet.connect;
|
|
133
|
+
const sockets = await import("cloudflare:sockets");
|
|
134
|
+
return sockets.connect as unknown as WakeConnect;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/** Enough of an error body to recognise a Cloudflare status page. A success body is not kept. */
|
|
138
|
+
const HINT_BYTES = 4096;
|
|
139
|
+
|
|
134
140
|
/**
|
|
135
|
-
* HTTPS to
|
|
141
|
+
* HTTPS to the checked IP, with SNI and certificate verification for the URL host.
|
|
142
|
+
* `forceTunnel` keeps the platform from fetching the hostname again.
|
|
143
|
+
* The body is copied before the client closes, because closing drops the socket.
|
|
136
144
|
*/
|
|
137
|
-
async function
|
|
138
|
-
const
|
|
139
|
-
|
|
145
|
+
async function deliverPinned(url: string, init: RequestInit, target: { ip: string; hostname: string }, connect: WakeConnect): Promise<Response> {
|
|
146
|
+
const client = new Client({
|
|
147
|
+
connect: dialChecked(target.ip, connect),
|
|
148
|
+
forceTunnel: true,
|
|
149
|
+
maxRedirects: 0,
|
|
150
|
+
maxBodyBytes: Infinity,
|
|
151
|
+
http2: false,
|
|
152
|
+
keepAlive: false,
|
|
153
|
+
timeouts: { totalMs: REQUEST_TIMEOUT_MS },
|
|
154
|
+
});
|
|
140
155
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
const res = await client.fetch(url, {
|
|
157
|
+
method: init.method,
|
|
158
|
+
headers: init.headers,
|
|
159
|
+
body: init.body,
|
|
160
|
+
redirect: "manual",
|
|
161
|
+
});
|
|
162
|
+
return await responseForCaller(res);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (e instanceof TlsError || e instanceof CertificateError) throw new Error("TLS Handshake Failed.");
|
|
165
|
+
throw e;
|
|
151
166
|
} finally {
|
|
152
|
-
await
|
|
167
|
+
await client.close().catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Status and headers, plus the start of an error body. The live socket is not returned. */
|
|
172
|
+
async function responseForCaller(res: Response): Promise<Response> {
|
|
173
|
+
const headers = new Headers(res.headers);
|
|
174
|
+
if (res.ok || !res.body) {
|
|
175
|
+
await res.body?.cancel().catch(() => {});
|
|
176
|
+
return new Response(null, { status: res.status, headers });
|
|
177
|
+
}
|
|
178
|
+
const reader = res.body.getReader();
|
|
179
|
+
const chunks: Uint8Array[] = [];
|
|
180
|
+
let total = 0;
|
|
181
|
+
try {
|
|
182
|
+
while (total < HINT_BYTES) {
|
|
183
|
+
const next = await reader.read();
|
|
184
|
+
if (next.done || !next.value) break;
|
|
185
|
+
chunks.push(next.value);
|
|
186
|
+
total += next.value.byteLength;
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
await reader.cancel().catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
const merged = new Uint8Array(Math.min(total, HINT_BYTES));
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const chunk of chunks) {
|
|
194
|
+
const take = Math.min(chunk.byteLength, merged.byteLength - offset);
|
|
195
|
+
if (take <= 0) break;
|
|
196
|
+
merged.set(chunk.subarray(0, take), offset);
|
|
197
|
+
offset += take;
|
|
153
198
|
}
|
|
199
|
+
return new Response(merged, { status: res.status, headers });
|
|
154
200
|
}
|
|
155
201
|
|
|
156
202
|
function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
|
|
@@ -161,8 +207,9 @@ function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
|
|
|
161
207
|
}
|
|
162
208
|
|
|
163
209
|
/**
|
|
164
|
-
* POST (or whatever `init` says) to `url`
|
|
165
|
-
*
|
|
210
|
+
* POST (or whatever `init` says) to `url` after the public-https check.
|
|
211
|
+
* The socket opens to an address from that check. TLS uses the hostname.
|
|
212
|
+
* A redirect is followed only when it stays on the same origin, and that hop is checked again.
|
|
166
213
|
* A cross-origin redirect is refused, so wake credentials are not sent to another host.
|
|
167
214
|
* 301, 302, and 303 drop the body. 307 and 308 keep it.
|
|
168
215
|
*/
|
|
@@ -172,24 +219,31 @@ export async function fetchCheckedWake(
|
|
|
172
219
|
deps: WakeDeps = {},
|
|
173
220
|
): Promise<{ response: Response } | { refused: string }> {
|
|
174
221
|
const lookup = deps.lookup ?? lookupAddresses;
|
|
175
|
-
const connect = async (): Promise<Connect> => {
|
|
176
|
-
if (deps.connect) return deps.connect;
|
|
177
|
-
if (net.connect) return net.connect;
|
|
178
|
-
return (await import("cloudflare:sockets")).connect as unknown as Connect;
|
|
179
|
-
};
|
|
180
222
|
let current = url;
|
|
181
223
|
let method = (init.method || "GET").toUpperCase();
|
|
182
224
|
let body = bodyBytes(method === "GET" || method === "HEAD" ? undefined : init.body);
|
|
183
225
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
184
|
-
const checked = await
|
|
226
|
+
const checked = await checkedWakeAddresses(current, lookup);
|
|
185
227
|
if ("refused" in checked) return checked;
|
|
186
|
-
const
|
|
228
|
+
const nextInit: RequestInit = {
|
|
229
|
+
method,
|
|
230
|
+
headers: init.headers,
|
|
231
|
+
body: method === "GET" || method === "HEAD" || body.byteLength === 0 ? undefined : body,
|
|
232
|
+
redirect: "manual",
|
|
233
|
+
};
|
|
234
|
+
const target = { ip: checked.ips[0]!, hostname: checked.hostname };
|
|
235
|
+
let res: Response;
|
|
236
|
+
if (deps.local && deps.localHost && new URL(current).host === deps.localHost) res = await deps.local(current, nextInit);
|
|
237
|
+
else if (deps.deliver) res = await deps.deliver(current, nextInit, target);
|
|
238
|
+
else if (wakeNet.deliver) res = await wakeNet.deliver(current, nextInit, target);
|
|
239
|
+
else res = await deliverPinned(current, nextInit, target, deps.connect ?? wakeNet.connect ?? await runtimeConnect());
|
|
187
240
|
const location = res.headers.get("location");
|
|
188
241
|
if (res.status < 300 || res.status >= 400 || !location) return { response: res };
|
|
242
|
+
await res.body?.cancel().catch(() => {});
|
|
189
243
|
if (hop === MAX_REDIRECTS) return { refused: "too many redirects" };
|
|
190
244
|
let next: URL;
|
|
191
245
|
try { next = new URL(location, current); } catch { return { refused: "not a URL" }; }
|
|
192
|
-
const ahead = await
|
|
246
|
+
const ahead = await checkedWakeAddresses(next.href, lookup);
|
|
193
247
|
if ("refused" in ahead) return ahead;
|
|
194
248
|
if (next.origin !== new URL(current).origin) return { refused: "cross-origin redirect" };
|
|
195
249
|
if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) {
|
package/worker/src/wake.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function defaultDebounceSeconds(preset: string): number {
|
|
|
51
51
|
return preset === "claude-code" ? 20 : 10;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Default hourly wake cap per preset. 0 means unlimited. Claude Code routines allow 30 fires/hour,
|
|
56
|
+
* so that preset defaults to 25 only when WAKE_MAX_PER_HOUR is unset. An explicit 0 stays unlimited.
|
|
57
|
+
*/
|
|
55
58
|
export function defaultMaxPerHour(preset: string): number {
|
|
56
59
|
return preset === "claude-code" ? 25 : 0;
|
|
57
60
|
}
|
|
@@ -256,11 +259,21 @@ export function previewHeaderPlain(name: string): boolean {
|
|
|
256
259
|
return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
|
|
257
260
|
}
|
|
258
261
|
|
|
262
|
+
/** A credential header safe to show. No character of the secret is kept. */
|
|
263
|
+
export function maskedCredential(value: string): string {
|
|
264
|
+
return /^bearer\s/i.test(value) ? "Bearer ••••" : "••••";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** True only for the exact value `maskedCredential` returns. A prefix plus an ellipsis is still a secret. */
|
|
268
|
+
export function credentialIsMasked(value: string): boolean {
|
|
269
|
+
return value === "••••" || value === "Bearer ••••";
|
|
270
|
+
}
|
|
271
|
+
|
|
259
272
|
/** Copy of a rendered request with credentials masked (for previews/logs). */
|
|
260
273
|
export function redact(req: WakeRequest): WakeRequest {
|
|
261
274
|
const h: Record<string, string> = {};
|
|
262
275
|
for (const [k, v] of Object.entries(req.headers))
|
|
263
|
-
h[k] = previewHeaderPlain(k) ? v : v
|
|
276
|
+
h[k] = previewHeaderPlain(k) ? v : maskedCredential(v);
|
|
264
277
|
let url = req.url;
|
|
265
278
|
try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
|
|
266
279
|
return { ...req, url, headers: h };
|
|
@@ -41,6 +41,23 @@ test("nextCronAt is the earliest wake, rate row, or expiry", async () => {
|
|
|
41
41
|
assert.equal(await nextCronAt(tenant, now), now + 801);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("zero and an unset generic cap stay unlimited; an unset Claude cap is 25", async () => {
|
|
45
|
+
const now = 1_700_000_000_000;
|
|
46
|
+
const hour = Math.floor(now / 3600000);
|
|
47
|
+
const due = now - 60_000;
|
|
48
|
+
const open = due + 10_000;
|
|
49
|
+
async function arm(over: Record<string, string>, count: number) {
|
|
50
|
+
const { DB, tenant } = ctx(over);
|
|
51
|
+
await DB.prepare("INSERT INTO wake_budget (hour, count) VALUES (?, ?)").bind(hour, count).run();
|
|
52
|
+
await DB.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('c', ?, '{}', ?)").bind(due, now).run();
|
|
53
|
+
return nextCronAt(tenant, now);
|
|
54
|
+
}
|
|
55
|
+
assert.equal(await arm({ WAKE_MAX_PER_HOUR: "0" }, 100), open);
|
|
56
|
+
assert.equal(await arm({ WAKE_MAX_PER_HOUR: "" }, 100), open);
|
|
57
|
+
assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "0" }, 100), open);
|
|
58
|
+
assert.equal(await arm({ WAKE_PRESET: "claude-code", WAKE_MAX_PER_HOUR: "" }, 25), (hour + 1) * 3600000);
|
|
59
|
+
});
|
|
60
|
+
|
|
44
61
|
test("an hourly cap holds an already-due wake until the next hour", async () => {
|
|
45
62
|
const now = 1_700_000_000_000;
|
|
46
63
|
const hour = Math.floor(now / 3600000);
|
|
@@ -10,7 +10,8 @@ import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
|
10
10
|
import worker from "../src/index.ts";
|
|
11
11
|
import { d1 } from "./d1.ts";
|
|
12
12
|
import { sha256 } from "../src/a2a.ts";
|
|
13
|
-
import { exportRows, toSql, bookmarkForTime, restoreBookmark, restartAfterResult, ExportError, EXPORT_TABLES, RESTORE_RESTART_DELAY_MS } from "../src/export.ts";
|
|
13
|
+
import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql, bookmarkForTime, restoreBookmark, restartAfterResult, ExportError, EXPORT_TABLES, RESTORE_RESTART_DELAY_MS } from "../src/export.ts";
|
|
14
|
+
import { ownerResponseLeaks, TOKEN_PREFIXES } from "../src/owner-rpc.ts";
|
|
14
15
|
import { doSqlD1, type SqlStorageLike, type TxRunner } from "../src/storage.ts";
|
|
15
16
|
import { backupDatabase, backupDirectory, exportTenantSql, hostedDailyBackup, olderThanRetention, pruneSnapshots, snapshotKey, DAILY_CRON, type R2BucketLike } from "../src/backup.ts";
|
|
16
17
|
import { namespaceForRegion, type DoNamespace } from "../src/tenancy.ts";
|
|
@@ -70,6 +71,121 @@ test("export SQL writes a NUL byte as char(0) so the script stays valid", () =>
|
|
|
70
71
|
assert.equal(hex.h, Buffer.from(raw, "utf8").toString("hex").toUpperCase());
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
test("redactExport drops credential material and leaves the full dump unchanged", async () => {
|
|
75
|
+
const DB = db();
|
|
76
|
+
const now = "2026-10-11T00:00:00.000Z";
|
|
77
|
+
const token = "a2aow_" + "a".repeat(20);
|
|
78
|
+
const owner = "a2aot_" + "Q".repeat(43);
|
|
79
|
+
const hash = "ab".repeat(32);
|
|
80
|
+
const replaces = "cd".repeat(32);
|
|
81
|
+
const setupHash = "ef".repeat(32);
|
|
82
|
+
const setup = "c".repeat(16);
|
|
83
|
+
await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', ?, ?)").bind(hash, now).run();
|
|
84
|
+
await DB.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES ('ctx-1', ?, 'in', ?)").bind(now, `see ${token} and ${owner} at https://user:secret@hooks.example/wake?t=${setup}`).run();
|
|
85
|
+
await DB.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES ('ctx-owner', ?, 'in', ?)").bind(now, owner).run();
|
|
86
|
+
await DB.prepare("INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, push_configs_json) VALUES ('task-1', 'ctx-1', 'ada', 'submitted', '1.0', ?, ?, '{}', '[]', ?)").bind(now, now, JSON.stringify([{ token }])).run();
|
|
87
|
+
await DB.prepare("INSERT INTO outbound (task_id, context_id, peer, push_token_hash, sent_at, task_json) VALUES ('task-1', 'ctx-1', 'ada', 'push-hash-secret', ?, ?)").bind(now, JSON.stringify({ token })).run();
|
|
88
|
+
await DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?)").bind(JSON.stringify({ hash: "x".repeat(40) })).run();
|
|
89
|
+
await DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?)").bind(JSON.stringify({ hash: setupHash, createdMs: 1, expiresMs: 2, failures: 0 })).run();
|
|
90
|
+
await DB.prepare("INSERT INTO settings (key, value) VALUES ('theme', 'kept')").run();
|
|
91
|
+
await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?)").bind(replaces).run();
|
|
92
|
+
await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('code-hash', 'client', 'https://client.example/cb', 'challenge-secret-value', 'mcp', 'https://agent.example/mcp', 1, 2)").run();
|
|
93
|
+
await DB.prepare("INSERT INTO mcp_grants (label, client_id, scope, access_hash, refresh_hash, created_at) VALUES ('mcp-claude', 'client', 'mcp', 'access-hash-secret', 'refresh-hash-secret', ?)").bind(now).run();
|
|
94
|
+
await DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('ada', 'https://ada.example', 'ciphertext-secret-value', ?, ?)").bind(now, now).run();
|
|
95
|
+
await DB.prepare("INSERT INTO oidc_txns (state, nonce, verifier, kind, csrf, payload_json, expires_ms) VALUES ('state-1', 'nonce-1', 'verifier-secret-value', 'device', 'csrf-secret-value', '{}', 1)").run();
|
|
96
|
+
const file = await exportRows(DB, new Date(now));
|
|
97
|
+
const redacted = redactExport(file);
|
|
98
|
+
assert.equal(file.tables.peers?.[0]?.token_hash, hash, "the CLI dump still has the hash");
|
|
99
|
+
assert.equal(file.tables.device_requests?.[0]?.replaces_hash, replaces);
|
|
100
|
+
assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
|
|
101
|
+
assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
|
|
102
|
+
assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
|
|
103
|
+
assert.equal(redacted.tables.peers?.[0]?.label, "ada");
|
|
104
|
+
assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
|
|
105
|
+
assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
|
|
106
|
+
assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
|
|
107
|
+
assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
|
|
108
|
+
assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
|
|
109
|
+
assert.equal(redacted.tables.outbound?.[0]?.push_token_hash, undefined);
|
|
110
|
+
assert.equal(redacted.tables.outbound?.[0]?.task_json, JSON.stringify({ token: "[redacted]" }));
|
|
111
|
+
assert.equal(redacted.tables.mcp_grants?.[0]?.access_hash, undefined);
|
|
112
|
+
assert.equal(redacted.tables.mcp_grants?.[0]?.refresh_hash, undefined);
|
|
113
|
+
assert.equal(redacted.tables.mcp_grants?.[0]?.label, "mcp-claude");
|
|
114
|
+
assert.equal(redacted.tables.outbound_peers?.[0]?.token_enc, undefined);
|
|
115
|
+
assert.equal(redacted.tables.outbound_peers?.[0]?.alias, "ada");
|
|
116
|
+
assert.deepEqual(redacted.tables.oauth_codes, []);
|
|
117
|
+
assert.deepEqual(redacted.tables.oidc_txns, []);
|
|
118
|
+
assert.deepEqual(redacted.tables.settings?.map((row) => row.key), ["theme"]);
|
|
119
|
+
const history = redacted.tables.history ?? [];
|
|
120
|
+
assert.equal(history.find((row) => row.context_id === "ctx-1")?.text, "see [redacted] and [redacted] at https://[redacted]@hooks.example/wake?t=[redacted]");
|
|
121
|
+
assert.equal(history.find((row) => row.context_id === "ctx-owner")?.text, "[redacted]");
|
|
122
|
+
const dumped = JSON.stringify(redacted);
|
|
123
|
+
for (const secret of [token, owner, hash, replaces, setupHash, "device-hash-secret", "ciphertext-secret-value", "verifier-secret-value", "csrf-secret-value", "challenge-secret-value", "access-hash-secret", "refresh-hash-secret", "push-hash-secret", "approval_password", "password_setup", "user:secret", `t=${setup}`]) {
|
|
124
|
+
assert.equal(dumped.includes(secret), false, secret);
|
|
125
|
+
}
|
|
126
|
+
assert.equal(ownerResponseLeaks(owner), true);
|
|
127
|
+
assert.equal(ownerResponseLeaks(redacted), false, "a pasted hosted owner token is redacted, so the dashboard export is returned");
|
|
128
|
+
const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
|
|
129
|
+
assert.match(sql, /redacted:ada/);
|
|
130
|
+
assert.equal(sql.includes("oauth_codes"), false);
|
|
131
|
+
assert.equal(sql.includes("oidc_txns"), false);
|
|
132
|
+
assert.equal(sql.includes("replaces_hash"), false);
|
|
133
|
+
assert.equal(sql.includes("password_setup"), false);
|
|
134
|
+
assert.equal(ownerResponseLeaks({ sql }), false);
|
|
135
|
+
for (const secret of [token, owner, hash, replaces, setupHash]) {
|
|
136
|
+
assert.equal(sql.includes(secret), false, secret);
|
|
137
|
+
}
|
|
138
|
+
assert.match(toSql(file), /verifier-secret-value/);
|
|
139
|
+
assert.match(toSql(file), new RegExp(hash));
|
|
140
|
+
assert.match(toSql(file), /replaces_hash/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
|
|
144
|
+
assert.ok(TOKEN_PREFIXES.includes("a2aot_"));
|
|
145
|
+
for (const prefix of TOKEN_PREFIXES) {
|
|
146
|
+
const token = prefix + "Q".repeat(43);
|
|
147
|
+
assert.equal(scrubSecretText(`see ${token} end`), "see [redacted] end", prefix);
|
|
148
|
+
assert.equal(scrubSecretText(token), "[redacted]", prefix);
|
|
149
|
+
assert.equal(ownerResponseLeaks(token), true, prefix);
|
|
150
|
+
}
|
|
151
|
+
const owner = "a2aot_" + "Q".repeat(43);
|
|
152
|
+
const redacted = redactExport({
|
|
153
|
+
format: "a2a-exposed-export",
|
|
154
|
+
version: 1,
|
|
155
|
+
exportedAt: "2026-10-11T00:00:00.000Z",
|
|
156
|
+
tables: { history: [{ context_id: "ctx-owner", ts: "2026-10-11T00:00:00.000Z", dir: "in", text: owner }] },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(redacted.tables.history?.[0]?.text, "[redacted]");
|
|
159
|
+
assert.equal(toSql(redacted, { skip: REDACTED_SQL_SKIP }).includes(owner), false);
|
|
160
|
+
assert.equal(ownerResponseLeaks(redacted), false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("redactExport removes device_requests.replaces_hash", () => {
|
|
164
|
+
const replaces = "cd".repeat(32);
|
|
165
|
+
const redacted = redactExport({
|
|
166
|
+
format: "a2a-exposed-export",
|
|
167
|
+
version: 1,
|
|
168
|
+
exportedAt: "2026-10-11T00:00:00.000Z",
|
|
169
|
+
tables: {
|
|
170
|
+
device_requests: [{
|
|
171
|
+
device_hash: "device-hash-secret",
|
|
172
|
+
user_code: "WDJB4827",
|
|
173
|
+
status: "pending",
|
|
174
|
+
replaces_label: "ada",
|
|
175
|
+
replaces_hash: replaces,
|
|
176
|
+
}],
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const row = redacted.tables.device_requests?.[0] ?? {};
|
|
180
|
+
assert.equal(Object.hasOwn(row, "replaces_hash"), false);
|
|
181
|
+
assert.equal(row.replaces_label, "ada");
|
|
182
|
+
assert.equal(row.device_hash, "redacted:WDJB4827");
|
|
183
|
+
const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
|
|
184
|
+
assert.equal(sql.includes("replaces_hash"), false);
|
|
185
|
+
assert.equal(sql.includes(replaces), false);
|
|
186
|
+
assert.equal(JSON.stringify(redacted).includes(replaces), false);
|
|
187
|
+
});
|
|
188
|
+
|
|
73
189
|
test("exportRows reads every application table in one batch", async () => {
|
|
74
190
|
const DB = db();
|
|
75
191
|
await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
|