a2a-exposed 0.7.12 → 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 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
- wake preview rendered wake request (partially masked) + sha256 fingerprints of the
72
- uploaded URL/key/HMAC secret; compared with local env values if exported
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"),
@@ -20,5 +20,8 @@
20
20
  "miniflare": "^5.20261006.0-alpha",
21
21
  "typescript": "^7.0.2",
22
22
  "vite": "^8.3.0"
23
+ },
24
+ "dependencies": {
25
+ "tunnelfetch": "1.13.0"
23
26
  }
24
27
  }
@@ -10,7 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
10
10
  import { fetchCheckedWake } from "./wake-target.ts";
11
11
  import * as P from "./pairing.ts";
12
12
  import * as M from "./mcp.ts";
13
- import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
13
+ 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
15
  import { exportRows, toSql } from "./export.ts";
16
16
  import { backupDatabase, DAILY_CRON } from "./backup.ts";
@@ -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
- const maxPerHour = (ctx: TenantContext) => Number(ctx.WAKE_MAX_PER_HOUR || defaultMaxPerHour(preset(ctx)));
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, partially masked, plus short sha256 fingerprints of the
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 };
@@ -1576,7 +1604,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
1576
1604
  }
1577
1605
 
1578
1606
  /** 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>): Promise<Response> {
1607
+ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: string[], body: () => Promise<Json>, trusted = false): Promise<Response> {
1580
1608
  const mode = pairingMode(ctx);
1581
1609
  if (!seg[0] && m === "GET") {
1582
1610
  const now = Date.now();
@@ -1627,9 +1655,12 @@ async function ownerPairing(req: Request, ctx: TenantContext, m: string, seg: st
1627
1655
  const code = P.normUserCode(decodeURIComponent(seg[0]));
1628
1656
  if (!code) return json({ error: "not a valid code (it looks like WDJB-4827)" }, 400);
1629
1657
  const approve = seg[1] === "approve";
1630
- if (approve && mode !== "agent")
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)
1631
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);
1632
- const d = await decide(ctx, code, approve, approve ? "agent" : "owner");
1663
+ const d = await decide(ctx, code, approve, dashboardHuman ? "human" : approve ? "agent" : "owner");
1633
1664
  if (!d.ok) return json({ error: d.error }, 409);
1634
1665
  return json({ ok: true, decision: approve ? "approved" : "denied", userCode: P.formatUserCode(code), label: d.label || null,
1635
1666
  clientName: d.request.client_name || d.request.client_id || null, replaced: !!d.replaced });
@@ -2098,13 +2129,19 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
2098
2129
  const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
2099
2130
  if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
2100
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");
2101
2136
  let origin = "https://tenant.invalid";
2102
2137
  try {
2103
2138
  const u = new URL(ctx.PUBLIC_URL);
2104
2139
  if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
2105
2140
  } catch { /* the handler reads the path, not this origin */ }
2106
2141
  const url = new URL(origin + matched.route.path + matched.route.search);
2107
- const req = new Request(url, { method: matched.route.method, headers: { "content-type": "application/json" } });
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 });
2108
2145
  let res: Response;
2109
2146
  try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
2110
2147
  catch (e: any) {
@@ -2114,7 +2151,7 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
2114
2151
  log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
2115
2152
  return { status, body: { error } };
2116
2153
  }
2117
- const data = await res.json().catch(() => null);
2154
+ const data = scrubOwnerClaims(await res.json().catch(() => null));
2118
2155
  if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
2119
2156
  log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
2120
2157
  return { status: res.status, body: data };
@@ -4,7 +4,7 @@
4
4
  // token, or dump credential material (export) are not here. 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}$/;
@@ -25,6 +25,12 @@ const SECRET_KEYS = new Set([
25
25
  "clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
26
26
  ]);
27
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
+
28
34
  export interface OwnerRpcRequest {
29
35
  accountId: string;
30
36
  method: string;
@@ -33,6 +39,11 @@ export interface OwnerRpcRequest {
33
39
  query?: Record<string, string>;
34
40
  /** Refused. The owner HTTP API is where a body is accepted. */
35
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";
36
47
  }
37
48
 
38
49
  export interface OwnerRpcResult {
@@ -109,6 +120,7 @@ interface RouteDef {
109
120
 
110
121
  const ROUTES: RouteDef[] = [
111
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" },
112
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]}` },
113
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]}` },
114
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]}` },
@@ -147,12 +159,36 @@ function stringLeaks(value: string, key: string | null): boolean {
147
159
  return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
148
160
  }
149
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
+
150
186
  /** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
151
187
  function wakePreviewLeaks(value: object): boolean {
152
188
  const headers = (value as { method?: unknown; headers?: unknown }).headers;
153
189
  if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
154
190
  for (const [k, v] of Object.entries(headers)) {
155
- if (typeof v !== "string" || previewHeaderPlain(k) || v.endsWith("…")) continue;
191
+ if (typeof v !== "string" || previewHeaderPlain(k) || credentialIsMasked(v)) continue;
156
192
  return true;
157
193
  }
158
194
  return false;
@@ -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, net, parseHttpResponse, type PinnedSocket } from "./mcp.ts";
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 Connect = NonNullable<typeof net.connect>;
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?: 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
- /** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
73
- export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
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) ? { ip: bare } : { refused: "private or reserved address" };
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 { ip: ips[0] };
113
+ return { hostname: host, ips };
88
114
  }
89
115
 
90
- function headerEntries(headers: HeadersInit | undefined): [string, string][] {
91
- const out: [string, string][] = [];
92
- const list = new Headers(headers);
93
- list.forEach((value, key) => out.push([key, value]));
94
- return out;
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
- function requestBytes(method: string, url: URL, headers: HeadersInit | undefined, body: Uint8Array): Uint8Array {
98
- const lines = [`${method} ${url.pathname}${url.search} HTTP/1.1`, `host: ${url.host}`];
99
- for (const [key, value] of headerEntries(headers)) {
100
- if (key === "host" || key === "content-length" || key === "connection" || key === "transfer-encoding") continue;
101
- if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) throw new Error("invalid header");
102
- lines.push(`${key}: ${value}`);
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 readSocket(sock: PinnedSocket, cap: number): Promise<Uint8Array> {
114
- const reader = sock.readable.getReader();
115
- const chunks: Uint8Array[] = [];
116
- let n = 0;
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 `ip`, with TLS checked for the URL's hostname. The platform does not resolve the name again.
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 pinnedRequest(ip: string, url: string, method: string, headers: HeadersInit | undefined, body: Uint8Array, connect: Connect): Promise<Response> {
138
- const parsed = new URL(url);
139
- const sock = connect({ hostname: ip, port: Number(parsed.port || 443) }, { secureTransport: "starttls" }).startTls({ expectedServerHostname: parsed.hostname });
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 writer = sock.writable.getWriter();
142
- await writer.write(requestBytes(method, parsed, headers, body));
143
- writer.releaseLock();
144
- const raw = await readSocket(sock, 65536);
145
- const message = parseHttpResponse(raw);
146
- if (typeof message === "string") throw new Error(message);
147
- const responseHeaders = new Headers();
148
- for (const [key, value] of Object.entries(message.headers)) responseHeaders.set(key, value);
149
- const payload = message.status === 204 || message.status === 304 ? null : message.body;
150
- return new Response(payload, { status: message.status, headers: responseHeaders });
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 sock.close().catch(() => {});
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` on the address that passed the public-https check.
165
- * A redirect is followed only when it stays on the same origin, and that hop is checked and pinned again.
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 checkedWakeAddress(current, lookup);
226
+ const checked = await checkedWakeAddresses(current, lookup);
185
227
  if ("refused" in checked) return checked;
186
- const res = await pinnedRequest(checked.ip, current, method, init.headers, method === "GET" || method === "HEAD" ? new Uint8Array() : body, await connect());
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 checkedWakeAddress(next.href, lookup);
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")) {
@@ -51,7 +51,10 @@ export function defaultDebounceSeconds(preset: string): number {
51
51
  return preset === "claude-code" ? 20 : 10;
52
52
  }
53
53
 
54
- /** Default hourly wake cap per preset (0 = unlimited). Claude Code routines allow 30 fires/hour. */
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.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…");
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);
@@ -214,6 +214,10 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
214
214
  const aliceInbox = await (await call("https://alice.example.com/owner/inbox?all=1", "GET", `Bearer ${tokenA}`)).json() as { text: string }[];
215
215
  assert.equal(aliceInbox.length, 1);
216
216
  assert.match(aliceInbox[0]!.text, /hello alice/);
217
+ const aliceSummary = await (await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA}`)).json() as { tasks: { submitted: number }; pairingPending: number };
218
+ assert.equal(aliceSummary.tasks.submitted, 1);
219
+ assert.equal(aliceSummary.pairingPending, 0);
220
+ assert.equal(JSON.stringify(aliceSummary).includes("hello alice"), false);
217
221
 
218
222
  // workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
219
223
  // namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
@@ -500,7 +504,7 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
500
504
  const ns = await mf.getDurableObjectNamespace("TENANT_DO");
501
505
  const hash = await sha256("owner-carol");
502
506
  type OwnerStub = {
503
- owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
507
+ owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown; actor?: string }): Promise<{ status: number; body: unknown }>;
504
508
  storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
505
509
  pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
506
510
  };
@@ -521,6 +525,14 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
521
525
  }
522
526
  return true;
523
527
  };
528
+ const sawAfter = async (from: number, part: string) => {
529
+ const start = Date.now();
530
+ while (!lines.slice(from).some((l) => l.includes(part))) {
531
+ if (Date.now() - start > 2000) return false;
532
+ await new Promise((r) => setTimeout(r, 20));
533
+ }
534
+ return true;
535
+ };
524
536
  const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
525
537
  const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
526
538
  assert.equal(missing.status, 404);
@@ -542,6 +554,18 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
542
554
  assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
543
555
  const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
544
556
  assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
557
+ const sentCarol = await http("POST", "/", `Bearer ${issued.token}`, {
558
+ jsonrpc: "2.0", id: 1, method: "message/send",
559
+ params: { message: { kind: "message", role: "user", messageId: "m-carol", parts: [{ kind: "text", text: "hello from carol peer" }] } },
560
+ });
561
+ assert.equal(sentCarol.status, 200, await sentCarol.clone().text());
562
+ const summary = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/summary" });
563
+ assert.equal(summary.status, 200);
564
+ const summaryBody = summary.body as { tasks?: { submitted?: number }; pairingPending?: number };
565
+ assert.equal(summaryBody.tasks?.submitted, 1);
566
+ assert.equal(summaryBody.pairingPending, 0);
567
+ assert.equal(JSON.stringify(summary.body).includes("hello from carol peer"), false);
568
+ assert.equal(await saw('"route":"GET /owner/summary"'), true);
545
569
 
546
570
  const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
547
571
  assert.equal(listed.status, 200);
@@ -597,11 +621,85 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
597
621
  assert.equal(wokenText.includes("platform-secret"), false);
598
622
  const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
599
623
  assert.equal(wokenBody.hasKey, true);
600
- assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer z9Qx…");
624
+ assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer ••••");
625
+ assert.equal(wokenText.includes("z9Qx"), false);
601
626
  const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
602
627
  assert.equal(httpPreview.hasKey, true);
603
628
  assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
604
629
 
630
+ const started = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
631
+ method: "POST",
632
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.20" },
633
+ body: "client_name=Barry+Bot&client_id=barry-alias&agent_card_url=https://barry.example/.well-known/agent-card.json",
634
+ });
635
+ const device = await started.json() as { user_code?: string };
636
+ assert.equal(started.status, 200, JSON.stringify(device));
637
+ const code = device.user_code ?? "";
638
+ assert.match(code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
639
+ const shapedName = "a2aow_" + "a".repeat(20);
640
+ const shapedCard = "https://card.example/card.json?t=" + "c".repeat(16);
641
+ const shapedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
642
+ method: "POST",
643
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.23" },
644
+ body: `client_name=${encodeURIComponent(shapedName)}&client_id=shaped-alias&agent_card_url=${encodeURIComponent(shapedCard)}`,
645
+ });
646
+ assert.equal(shapedStart.status, 200);
647
+ const shapedList = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
648
+ assert.equal(shapedList.status, 200);
649
+ const shapedText = JSON.stringify(shapedList.body);
650
+ assert.equal(shapedText.includes(shapedName), false);
651
+ assert.equal(shapedText.includes("t=" + "c".repeat(16)), false);
652
+ assert.match(shapedText, /Barry Bot/);
653
+ const pendingPairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
654
+ const pending = (pendingPairing.body as { pending?: { userCode?: string; clientName?: string; clientId?: string; agentCardUrl?: string }[] }).pending ?? [];
655
+ assert.equal(pending.some((row) => row.userCode === code && row.clientName === "Barry Bot" && row.clientId === "barry-alias" && row.agentCardUrl === "https://barry.example/.well-known/agent-card.json"), true);
656
+ const refused = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve` });
657
+ assert.equal(refused.status, 403);
658
+ const httpSpoof = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
659
+ method: "POST",
660
+ headers: { authorization: "Bearer owner-carol", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
661
+ });
662
+ assert.equal(httpSpoof.status, 403);
663
+ const approved = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve`, actor: "human" });
664
+ assert.equal(approved.status, 200);
665
+ assert.equal((approved.body as { decision?: string }).decision, "approved");
666
+ assert.equal(await saw('"event":"pairing_approved"'), true);
667
+ assert.equal(lines.some((line) => line.includes('"event":"pairing_approved"') && line.includes('"by":"human"')), true);
668
+
669
+ const deniedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
670
+ method: "POST",
671
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
672
+ body: "client_name=Deny+Bot&client_id=deny-alias",
673
+ });
674
+ const deniedDevice = await deniedStart.json() as { user_code?: string };
675
+ assert.equal(deniedStart.status, 200);
676
+ const ownerDenyAt = lines.length;
677
+ const ownerDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${deniedDevice.user_code}/deny` });
678
+ assert.equal(ownerDenied.status, 200);
679
+ assert.equal(await saw('"event":"pairing_denied"'), true);
680
+ const ownerDenyLine = lines.slice(ownerDenyAt).find((line) => line.includes('"event":"pairing_denied"')) ?? "";
681
+ assert.match(ownerDenyLine, /"by":"owner"/);
682
+ assert.equal(ownerDenyLine.includes('"by":"human"'), false);
683
+
684
+ const humanDenyStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
685
+ method: "POST",
686
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.22" },
687
+ body: "client_name=Human+Deny&client_id=human-deny",
688
+ });
689
+ const humanDenyDevice = await humanDenyStart.json() as { user_code?: string };
690
+ assert.equal(humanDenyStart.status, 200);
691
+ const humanDenyAt = lines.length;
692
+ const humanDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${humanDenyDevice.user_code}/deny`, actor: "human" });
693
+ assert.equal(humanDenied.status, 200);
694
+ assert.equal(await sawAfter(humanDenyAt, '"by":"human"'), true);
695
+ assert.equal(lines.slice(humanDenyAt).some((line) => line.includes('"event":"pairing_denied"') && line.includes('"by":"human"')), true);
696
+
697
+ const flagged = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", actor: "human" });
698
+ assert.equal(flagged.status, 400);
699
+ const badActor = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/WDJB-4827/approve", actor: "agent" });
700
+ assert.equal(badActor.status, 400);
701
+ assert.equal(await saw('"reason":"actor"'), true);
702
+
605
703
  await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
606
704
  const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
607
705
  assert.equal(suspended.status, 403);
@@ -1,7 +1,7 @@
1
1
  // Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
2
2
  import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { accountIdOf, matchOwnerRpc, ownerResponseLeaks } from "../src/owner-rpc.ts";
4
+ import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims } from "../src/owner-rpc.ts";
5
5
 
6
6
  test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
7
7
  const ok = (method: string, path: string, query?: Record<string, string>) => {
@@ -10,6 +10,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
10
10
  return m.ok ? m.route : null;
11
11
  };
12
12
  assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
13
+ assert.equal(ok("GET", "/owner/summary")?.name, "GET /owner/summary");
13
14
  assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
14
15
  assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
15
16
  assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
@@ -43,6 +44,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
43
44
  assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
44
45
  assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
45
46
  assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
47
+ assert.deepEqual(refused("GET", "/owner/summary", { all: "1" }), { ok: false, reason: "query" });
46
48
  assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
47
49
  assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
48
50
  assert.equal(accountIdOf("acct_carol"), "acct_carol");
@@ -60,13 +62,46 @@ test("owner RPC withholds peer tokens and decrypted secrets", () => {
60
62
  assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
61
63
  assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
62
64
  assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
63
- assert.equal(ownerResponseLeaks({ authorization: "Bearer abcd…" }), false);
65
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer abcd…" } }), true);
66
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer ••••" } }), false);
67
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { "x-credential": "••••" } }), false);
64
68
  assert.equal(ownerResponseLeaks({
65
69
  request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
66
70
  }), true);
67
71
  assert.equal(ownerResponseLeaks({
68
- request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supe…", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
72
+ request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "Bearer ••••", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
69
73
  }), false);
70
74
  assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
71
75
  assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
76
+ });
77
+
78
+ test("a token-shaped claim is blanked and the rest of the list is returned", () => {
79
+ const token = "a2aow_" + "a".repeat(20);
80
+ const setup = "https://card.example/card.json?t=" + "c".repeat(16);
81
+ const userinfo = "https://user:secret@card.example/card.json";
82
+ const raw = {
83
+ pending: [
84
+ { userCode: "WDJB-4827", clientName: token, clientId: "barry-alias", agentCardUrl: setup },
85
+ { userCode: "BCDF-2345", clientName: "Barry Bot", clientId: token, agentCardUrl: "https://barry.example/card.json" },
86
+ { client_name: token, agent_card_url: userinfo },
87
+ ],
88
+ token,
89
+ };
90
+ assert.equal(ownerResponseLeaks(raw), true);
91
+ const scrubbed = scrubOwnerClaims(raw) as { pending: { clientName: string; clientId: string; agentCardUrl: string; client_name?: string; agent_card_url?: string }[]; token: string };
92
+ assert.equal(scrubbed.pending[0]!.clientName, "");
93
+ assert.equal(scrubbed.pending[0]!.clientId, "barry-alias");
94
+ assert.equal(scrubbed.pending[0]!.agentCardUrl, "");
95
+ assert.equal(scrubbed.pending[1]!.clientName, "Barry Bot");
96
+ assert.equal(scrubbed.pending[1]!.clientId, "");
97
+ assert.equal(scrubbed.pending[1]!.agentCardUrl, "https://barry.example/card.json");
98
+ assert.equal(scrubbed.pending[2]!.client_name, "");
99
+ assert.equal(scrubbed.pending[2]!.agent_card_url, "");
100
+ assert.equal(scrubbed.token, token);
101
+ assert.equal(ownerResponseLeaks(scrubbed), true, "a real token under another key is still withheld");
102
+ const listed = scrubOwnerClaims({ pending: raw.pending });
103
+ assert.equal(ownerResponseLeaks(listed), false);
104
+ assert.equal(JSON.stringify(listed).includes(token), false);
105
+ assert.equal(JSON.stringify(listed).includes("t=" + "c".repeat(16)), false);
106
+ assert.equal(JSON.stringify(listed).includes("user:secret"), false);
72
107
  });
@@ -3,6 +3,7 @@
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import worker from "../src/index.ts";
6
+ import { DASHBOARD_ACTOR_HEADER } from "../src/owner-rpc.ts";
6
7
  import * as P from "../src/pairing.ts";
7
8
  import { wakeSummary, renderWake, type WakeEvent } from "../src/wake.ts";
8
9
  import { d1 } from "./d1.ts";
@@ -221,10 +222,13 @@ test("human approval: pending, slow_down, page approve with the password, single
221
222
  advance(16);
222
223
  assert.equal((await s.poll(d.device_code)).data.error, "authorization_pending");
223
224
 
224
- // human mode: the owner API (the agent) cannot approve
225
+ // human mode: the owner API (the agent) cannot approve, and the dashboard header does not change that
225
226
  const oa = await s.owner("POST", `/owner/pairing/${d.user_code}/approve`);
226
227
  assert.equal(oa.status, 403);
227
228
  assert.match(oa.data.error, /approval mode is human: the owner approves on https:\/\/agent\.example\.com\/device\?user_code=/);
229
+ const spoofed = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
230
+ assert.equal(spoofed.status, 403);
231
+ assert.match(spoofed.data.error, /approval mode is human/);
228
232
  const list = await s.owner("GET", "/owner/pairing");
229
233
  assert.deepEqual([list.data.mode, list.data.passwordSet, list.data.pending.length, list.data.pending[0].userCode], ["human", false, 1, d.user_code]);
230
234
 
@@ -289,10 +293,19 @@ test("human approval: pending, slow_down, page approve with the password, single
289
293
  });
290
294
 
291
295
  test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
296
+ const lines: string[] = [];
297
+ const orig = console.log;
298
+ console.log = (...args: unknown[]) => { lines.push(args.map((part) => String(part)).join(" ")); orig(...args); };
299
+ t.after(() => { console.log = orig; });
292
300
  const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
293
301
  const first = (await s.start({ client_name: "tester-alpha", client_id: "other-alias" }, "203.0.113.40")).data;
294
- const approved = await s.owner("POST", `/owner/pairing/${first.user_code}/approve`);
302
+ const approved = await s.call("POST", `/owner/pairing/${first.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
295
303
  assert.equal(approved.data.label, "tester-alpha");
304
+ const decided = s.DB.db.prepare("SELECT decided_by FROM device_requests WHERE user_code = ?").get(first.user_code.replace("-", "")) as { decided_by: string };
305
+ assert.equal(decided.decided_by, "agent");
306
+ const logged = lines.find((line) => line.includes("pairing_approved"));
307
+ assert.match(logged ?? "", /"by":"agent"/);
308
+ assert.equal((logged ?? "").includes('"by":"human"'), false);
296
309
  const token = (await s.poll(first.device_code)).data.access_token;
297
310
  const sent = await s.call("POST", "/", {
298
311
  json: { jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { role: "ROLE_USER", parts: [{ text: "hello" }] } } },
@@ -681,3 +694,31 @@ test("GET / with pairing off: no /device link, says to ask the operator", async
681
694
  assert.match(h.text, /ask this agent's operator for a bearer token/);
682
695
  assert.equal((await s.call("GET", "/")).data.pairing, undefined);
683
696
  });
697
+
698
+ test("owner summary counts tasks and pending pairing requests without their text", async (t) => {
699
+ const s = setup(); t.after(s.restore);
700
+ const started = await s.start();
701
+ assert.equal(started.status, 200);
702
+ const issued = await s.owner("POST", "/owner/peers", { label: "ada" });
703
+ assert.match(issued.data.token, /^a2aow_/);
704
+ const sent = await s.call("POST", "/", {
705
+ json: { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", parts: [{ kind: "text", text: "secret plan from ada" }] } } },
706
+ headers: { authorization: `Bearer ${issued.data.token}` },
707
+ });
708
+ assert.equal(sent.status, 200, JSON.stringify(sent.data));
709
+ const taskId = sent.data.result.id;
710
+ const pending = JSON.stringify({ taskIds: [taskId] });
711
+ const now = Date.now();
712
+ s.DB.db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?)").run("ctx-keep", now, pending, now);
713
+ const summary = await s.owner("GET", "/owner/summary");
714
+ assert.equal(summary.status, 200);
715
+ assert.equal(summary.data.tasks.submitted, 1);
716
+ assert.equal(summary.data.tasks.working, 0);
717
+ assert.equal(summary.data.pairingPending, 1);
718
+ const text = JSON.stringify(summary.data);
719
+ assert.equal(text.includes("secret plan"), false);
720
+ assert.equal(text.includes(started.data.user_code), false);
721
+ assert.equal(text.includes(started.data.device_code), false);
722
+ assert.equal(text.includes(taskId), false);
723
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, pending);
724
+ });
@@ -2,13 +2,17 @@
2
2
  // Unset keeps today's fetch. Run: npm test
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
+ import net from "node:net";
6
+ import { Duplex } from "node:stream";
5
7
  import worker from "../src/index.ts";
6
8
  import { d1 } from "./d1.ts";
7
9
  import * as M from "../src/mcp.ts";
8
- import { fetchCheckedWake, publicWakeProblem, type AddressLookup, type WakeDeps } from "../src/wake-target.ts";
10
+ import { dialChecked, fetchCheckedWake, publicWakeProblem, wakeNet, type AddressLookup, type CheckedDeliver, type WakeConnect } from "../src/wake-target.ts";
9
11
 
10
12
  const publicLookup: AddressLookup = async (host) => {
11
13
  if (host === "hook.example" || host === "also.example") return ["1.2.3.4"];
14
+ if (host === "round.example") return ["1.2.3.4", "1.2.3.5"];
15
+ if (host === "v6.example") return ["2606:4700:4700::1111"];
12
16
  if (host === "split.example") return ["1.2.3.4", "10.0.0.1"];
13
17
  if (host === "meta.example") return ["169.254.169.254"];
14
18
  if (host === "missing.example") return [];
@@ -46,65 +50,155 @@ test("public-https refuses private, metadata, and non-https URLs before any requ
46
50
  assert.equal(await publicWakeProblem("https://unknown.example/wake", publicLookup), "its address could not be checked");
47
51
  });
48
52
 
49
- function pinned(answer: (request: string) => { status: number; location?: string }) {
50
- const calls: { ip: string; sni: string; request: string }[] = [];
51
- const connect: NonNullable<WakeDeps["connect"]> = (addr) => ({
52
- startTls: ({ expectedServerHostname: sni }) => {
53
- let req = "";
54
- let respond!: (bytes: Uint8Array) => void;
55
- const ready = new Promise<Uint8Array>((resolve) => { respond = resolve; });
56
- const writable = new WritableStream<Uint8Array>({
57
- write(chunk) {
58
- req += new TextDecoder().decode(chunk);
59
- if (!req.includes("\r\n\r\n")) return;
60
- calls.push({ ip: addr.hostname, sni, request: req });
61
- const made = answer(req);
62
- const headers = [`HTTP/1.1 ${made.status} X`, "content-length: 0"];
63
- if (made.location) headers.push(`location: ${made.location}`);
64
- headers.push("connection: close", "", "");
65
- respond(new TextEncoder().encode(headers.join("\r\n")));
66
- },
67
- });
68
- const readable = new ReadableStream<Uint8Array>({
69
- async start(controller) {
70
- controller.enqueue(await ready);
71
- controller.close();
72
- },
73
- });
74
- return { readable, writable, close: async () => {} };
75
- },
76
- });
77
- return { connect, calls };
53
+ function recorded(answer: (call: { url: string }) => { status: number; location?: string }) {
54
+ const calls: { url: string; ip: string; hostname: string; authorization: string; body: string; redirect: string }[] = [];
55
+ const deliver: CheckedDeliver = async (url, init, target) => {
56
+ const headers = new Headers(init.headers);
57
+ const call = {
58
+ url,
59
+ ip: target.ip,
60
+ hostname: target.hostname,
61
+ authorization: headers.get("authorization") ?? "",
62
+ body: typeof init.body === "string" ? init.body : init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : "",
63
+ redirect: init.redirect ?? "",
64
+ };
65
+ calls.push(call);
66
+ const made = answer(call);
67
+ return new Response(null, { status: made.status, headers: made.location ? { location: made.location } : {} });
68
+ };
69
+ return { deliver, calls };
70
+ }
71
+
72
+ test("the dial uses the checked address and leaves TLS to name the host", () => {
73
+ const seen: { hostname: string; secure?: string }[] = [];
74
+ const base: WakeConnect = (addr, options) => {
75
+ seen.push({ hostname: addr.hostname, secure: options?.secureTransport });
76
+ return { readable: new ReadableStream(), writable: new WritableStream(), async close() {} };
77
+ };
78
+ dialChecked("1.2.3.4", base)({ hostname: "hook.example", port: 443 }, { secureTransport: "on" });
79
+ assert.deepEqual(seen, [{ hostname: "1.2.3.4", secure: "off" }]);
80
+ });
81
+
82
+ /** Server names from a TLS ClientHello. Empty when the bytes are not one. */
83
+ function clientHelloNames(buf: Buffer): string[] {
84
+ if (buf.length < 5 || buf[0] !== 0x16) return [];
85
+ const recordLen = buf.readUInt16BE(3);
86
+ if (buf.length < 5 + recordLen) return [];
87
+ const hs = buf.subarray(5, 5 + recordLen);
88
+ if (hs.length < 4 || hs[0] !== 1) return [];
89
+ let i = 4 + 2 + 32;
90
+ if (i >= hs.length) return [];
91
+ const sidLen = hs[i]!;
92
+ i += 1 + sidLen;
93
+ if (i + 2 > hs.length) return [];
94
+ const csLen = hs.readUInt16BE(i);
95
+ i += 2 + csLen;
96
+ if (i >= hs.length) return [];
97
+ const compLen = hs[i]!;
98
+ i += 1 + compLen;
99
+ if (i + 2 > hs.length) return [];
100
+ const extEnd = i + 2 + hs.readUInt16BE(i);
101
+ i += 2;
102
+ const names: string[] = [];
103
+ while (i + 4 <= extEnd && i + 4 <= hs.length) {
104
+ const type = hs.readUInt16BE(i);
105
+ const len = hs.readUInt16BE(i + 2);
106
+ i += 4;
107
+ if (i + len > hs.length) break;
108
+ if (type === 0 && len >= 2) {
109
+ const list = hs.subarray(i, i + len);
110
+ let j = 2;
111
+ while (j + 3 <= list.length) {
112
+ const nameType = list[j]!;
113
+ const nlen = list.readUInt16BE(j + 1);
114
+ j += 3;
115
+ if (j + nlen > list.length) break;
116
+ if (nameType === 0) names.push(list.subarray(j, j + nlen).toString());
117
+ j += nlen;
118
+ }
119
+ }
120
+ i += len;
121
+ }
122
+ return names;
78
123
  }
79
124
 
80
- test("a redirect to a private address is not connected, and the first hop uses the checked IP", async () => {
81
- const socket = pinned(() => ({ status: 302, location: "http://169.254.169.254/latest/meta-data" }));
82
- const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
83
- assert.equal(socket.calls.length, 1);
84
- assert.equal(socket.calls[0].ip, "1.2.3.4");
85
- assert.equal(socket.calls[0].sni, "hook.example");
86
- assert.match(socket.calls[0].request, /^POST \/wake HTTP\/1\.1/);
87
- assert.match(socket.calls[0].request, /authorization: Bearer secret/i);
125
+ test("delivery sends the hostname in the ClientHello on a socket to the checked address", async () => {
126
+ const chunks: Buffer[] = [];
127
+ const server = net.createServer((socket) => {
128
+ socket.on("data", (data) => {
129
+ chunks.push(data);
130
+ const hello = Buffer.concat(chunks);
131
+ if (hello.length >= 5 && hello[0] === 0x16 && hello.length >= 5 + hello.readUInt16BE(3)) socket.destroy();
132
+ });
133
+ });
134
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
135
+ const address = server.address();
136
+ if (!address || typeof address === "string") throw new Error("no port");
137
+ const dialed: { hostname: string; secure?: string }[] = [];
138
+ const connect: WakeConnect = (addr, options) => {
139
+ dialed.push({ hostname: addr.hostname, secure: options?.secureTransport });
140
+ const socket = net.connect({ host: "127.0.0.1", port: address.port });
141
+ const web = Duplex.toWeb(socket);
142
+ return {
143
+ readable: web.readable,
144
+ writable: web.writable,
145
+ opened: new Promise((resolve, reject) => {
146
+ socket.once("connect", () => resolve({ remoteAddress: "127.0.0.1" }));
147
+ socket.once("error", reject);
148
+ }),
149
+ async close() { socket.destroy(); },
150
+ };
151
+ };
152
+ try {
153
+ await assert.rejects(
154
+ () => fetchCheckedWake("https://hook.example/wake", {
155
+ method: "POST",
156
+ headers: { authorization: "Bearer secret" },
157
+ body: "{}",
158
+ }, { lookup: publicLookup, connect }),
159
+ (err: unknown) => err instanceof Error && err.message === "TLS Handshake Failed.",
160
+ );
161
+ } finally {
162
+ server.close();
163
+ }
164
+ const wire = Buffer.concat(chunks);
165
+ assert.deepEqual(dialed, [{ hostname: "1.2.3.4", secure: "off" }]);
166
+ assert.deepEqual(clientHelloNames(wire), ["hook.example"]);
167
+ assert.equal(wire.includes(Buffer.from("POST /wake")), false);
168
+ assert.equal(wire.includes(Buffer.from("secret")), false);
169
+ });
170
+
171
+ test("a redirect to a private address is not requested, and the first hop is pinned to the checked address", async () => {
172
+ const hop = recorded(() => ({ status: 302, location: "http://169.254.169.254/latest/meta-data" }));
173
+ const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, deliver: hop.deliver });
174
+ assert.equal(hop.calls.length, 1);
175
+ assert.equal(hop.calls[0].url, "https://hook.example/wake");
176
+ assert.equal(hop.calls[0].ip, "1.2.3.4");
177
+ assert.equal(hop.calls[0].hostname, "hook.example");
178
+ assert.equal(hop.calls[0].redirect, "manual");
179
+ assert.equal(hop.calls[0].authorization, "Bearer secret");
88
180
  assert.equal("refused" in result && result.refused, "must be https");
89
181
  });
90
182
 
91
183
  test("a cross-origin redirect is not followed, so credentials stay on the first host", async () => {
92
- const socket = pinned(() => ({ status: 307, location: "https://also.example/wake" }));
93
- const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
94
- assert.equal(socket.calls.length, 1);
95
- assert.equal(socket.calls[0].ip, "1.2.3.4");
184
+ const hop = recorded(() => ({ status: 307, location: "https://also.example/wake" }));
185
+ const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, deliver: hop.deliver });
186
+ assert.equal(hop.calls.length, 1);
187
+ assert.equal(hop.calls[0].ip, "1.2.3.4");
96
188
  assert.equal("refused" in result && result.refused, "cross-origin redirect");
97
189
  });
98
190
 
99
- test("a same-origin redirect is pinned again and keeps the wake headers", async () => {
100
- const socket = pinned((request) => request.startsWith("POST /wake ")
191
+ test("a same-origin redirect is checked again and keeps the wake headers", async () => {
192
+ const hop = recorded((call) => call.url.endsWith("/wake")
101
193
  ? { status: 307, location: "https://hook.example/next" }
102
194
  : { status: 200 });
103
- const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, connect: socket.connect });
104
- assert.equal(socket.calls.length, 2);
105
- assert.deepEqual(socket.calls.map((call) => call.ip), ["1.2.3.4", "1.2.3.4"]);
106
- assert.match(socket.calls[1].request, /^POST \/next HTTP\/1\.1/);
107
- assert.match(socket.calls[1].request, /authorization: Bearer secret/i);
195
+ const result = await fetchCheckedWake("https://hook.example/wake", { method: "POST", headers: { authorization: "Bearer secret" }, body: "{}" }, { lookup: publicLookup, deliver: hop.deliver });
196
+ assert.deepEqual(hop.calls.map((call) => [call.url, call.ip]), [
197
+ ["https://hook.example/wake", "1.2.3.4"],
198
+ ["https://hook.example/next", "1.2.3.4"],
199
+ ]);
200
+ assert.equal(hop.calls[1].authorization, "Bearer secret");
201
+ assert.equal(hop.calls[1].body, "{}");
108
202
  assert.equal("response" in result && result.response.status, 200);
109
203
  });
110
204
 
@@ -168,20 +262,33 @@ test("public-https does not request a metadata address, and does post to a check
168
262
  assert.equal(requested.some((url) => url.includes("169.254.169.254")), false);
169
263
 
170
264
  requested.length = 0;
171
- const socket = pinned(() => ({ status: 200 }));
172
- M.net.connect = socket.connect;
265
+ const hop = recorded(() => ({ status: 200 }));
266
+ // The runtime startTls pin sends no SNI. Delivery must dial the checked IP and name the host itself.
267
+ M.net.connect = () => { throw new Error("wake delivery must not use startTls on the resolved IP"); };
268
+ wakeNet.deliver = hop.deliver;
173
269
  try {
174
270
  const allowed = await wakeTest({
175
271
  DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
176
272
  WAKE_WEBHOOK_URL: "https://hook.example/wake", WAKE_WEBHOOK_KEY: "hook-key", WAKE_TARGET_POLICY: "public-https",
177
273
  }, fetchImpl);
178
274
  assert.equal(allowed.status, 200);
179
- assert.equal(requested.every((url) => url.startsWith("https://cloudflare-dns.com/")), true);
180
- assert.equal(socket.calls.length, 1);
181
- assert.equal(socket.calls[0].ip, "1.2.3.4");
182
- assert.equal(socket.calls[0].sni, "hook.example");
183
- assert.match(socket.calls[0].request, /authorization: Bearer hook-key/i);
275
+ assert.equal(hop.calls.length, 1);
276
+ assert.equal(hop.calls[0].ip, "1.2.3.4");
277
+ assert.equal(hop.calls[0].hostname, "hook.example");
278
+ assert.equal(hop.calls[0].authorization, "Bearer hook-key");
279
+ assert.equal(requested.some((url) => new URL(url).hostname === "hook.example"), false);
280
+ assert.equal(requested.some((url) => url.startsWith("https://cloudflare-dns.com/dns-query")), true);
184
281
  } finally {
185
282
  M.net.connect = null;
283
+ wakeNet.deliver = null;
186
284
  }
285
+
286
+ wakeNet.deliver = async () => { throw new Error("TLS Handshake Failed."); };
287
+ const tls = await wakeTest({
288
+ DB: db(), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
289
+ WAKE_WEBHOOK_URL: "https://hook.example/wake", WAKE_WEBHOOK_KEY: "hook-key", WAKE_TARGET_POLICY: "public-https",
290
+ }, fetchImpl);
291
+ wakeNet.deliver = null;
292
+ assert.equal(tls.status, null);
293
+ assert.equal(tls.info, "Error: TLS Handshake Failed.");
187
294
  });
@@ -120,14 +120,15 @@ test("a key header the preview shows in full is not used for the key", async ()
120
120
  const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "Content-Type", keyPrefix: "" }, ev))!;
121
121
  assert.equal(req.headers["authorization"], "supersecretvalue");
122
122
  assert.equal(req.headers["content-type"], "application/json");
123
- assert.equal(redact(req).headers["authorization"], "supe…");
123
+ assert.equal(redact(req).headers["authorization"], "••••");
124
+ assert.equal(JSON.stringify(redact(req)).includes("supe"), false);
124
125
  });
125
126
 
126
127
  test("generic key header is redacted even when its name is not a secret word", async () => {
127
128
  const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "X-Credential", keyPrefix: "" }, ev))!;
128
129
  assert.equal(req.headers["x-credential"], "supersecretvalue");
129
130
  const red = redact(req);
130
- assert.equal(red.headers["x-credential"], "supe…");
131
+ assert.equal(red.headers["x-credential"], "••••");
131
132
  assert.equal(red.headers["content-type"], "application/json");
132
133
  assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
133
134
  });
@@ -160,8 +161,9 @@ test("hermes HMAC is computed over the body only, unaffected by Access headers",
160
161
 
161
162
  test("redact masks both Access headers", async () => {
162
163
  const r = redact((await renderWake({ preset: "openclaw-wake", url: "https://wake-x.example.com/hooks/wake", key: "hooks-token", ...ACCESS }, ev))!);
163
- assert.equal(r.headers["CF-Access-Client-Id"], "abc1…");
164
- assert.equal(r.headers["CF-Access-Client-Secret"], "s3cr…");
164
+ assert.equal(r.headers["CF-Access-Client-Id"], "••••");
165
+ assert.equal(r.headers["CF-Access-Client-Secret"], "••••");
166
+ assert.equal(r.headers["authorization"], "Bearer ••••");
165
167
  assert.ok(!JSON.stringify(r).includes("s3cr3t-value"));
166
168
  });
167
169