@rubytech/create-realagent 1.0.616 → 1.0.618

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/config/brand.json +0 -4
  3. package/payload/platform/package-lock.json +1547 -1
  4. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  5. package/payload/platform/plugins/admin/hooks/webfetch-preflight.mjs +363 -0
  6. package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +4 -1
  7. package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -2
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -1
  9. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +158 -99
  10. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  11. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +103 -70
  12. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  13. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +300 -529
  14. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  15. package/payload/platform/plugins/cloudflare/references/setup-guide.md +10 -13
  16. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +18 -14
  17. package/payload/platform/plugins/docs/references/cloudflare.md +32 -49
  18. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  19. package/payload/platform/scripts/seed-neo4j.sh +12 -0
  20. package/payload/platform/templates/agents/admin/IDENTITY.md +2 -0
  21. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -6
  22. package/payload/server/public/assets/{admin-Df1liz4Y.js → admin-D7LRdkYB.js} +30 -30
  23. package/payload/server/public/index.html +1 -1
  24. package/payload/server/server.js +88 -23
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +0 -81
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +0 -65
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +0 -70
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +0 -124
@@ -67,3 +67,4 @@ Tools are available via the `admin` MCP server.
67
67
 
68
68
  - `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
69
69
  - `hooks/playwright-file-guard.sh` — intercepts file:// URLs with actionable guidance
70
+ - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook for WebFetch — detects JS-SPA shells before Claude Code's
3
+ // internal extraction pipeline burns its 60-second axios timeout on an HTML
4
+ // shell that has no server-rendered content. WebFetch is HTTP-only — when it
5
+ // gets a body like `<div id="root"></div>` plus a single `<script>`, there
6
+ // is nothing to extract, and waiting 60s changes nothing.
7
+ //
8
+ // Wired in account_settings.json hooks.PreToolUse[matcher=WebFetch] (see
9
+ // platform/scripts/seed-neo4j.sh). Reads Claude Code hook protocol JSON on
10
+ // stdin: { tool_name, tool_input: { url, prompt } }.
11
+ //
12
+ // Outcomes:
13
+ // spa-shell → exit 2, stderr = WEBFETCH_CANNOT_READ_JS_SPA: <directive>
14
+ // Claude Code synthesises a tool_result with is_error=true
15
+ // and stderr as content, the agent surfaces a loud failure
16
+ // to the user (per IDENTITY.md "Tool Failure Discipline").
17
+ // ok → exit 0, allow the real WebFetch to run.
18
+ // inconclusive → exit 0, allow the real WebFetch to run. Anything that
19
+ // isn't a high-confidence positive shell detection passes
20
+ // through — fail-open is the only safe posture for a
21
+ // preflight gate. Blocking false positives would be worse
22
+ // than the 60-second stall this hook exists to prevent.
23
+ //
24
+ // Every branch writes one verdict line to
25
+ // ${ACCOUNT_DIR}/logs/webfetch-preflight.log so the decision trail is
26
+ // retrievable per-account without correlating across conversation logs.
27
+ //
28
+ // Cache: ${ACCOUNT_DIR}/cache/webfetch-preflight/<sha256(url)>.json with
29
+ // a 10-minute mtime TTL. The verdict is intrinsic to the URL's response
30
+ // shape, not to any conversation's context — account-scoped sharing
31
+ // eliminates duplicate probes when the same URL recurs across sessions.
32
+
33
+ import { createHash } from "node:crypto";
34
+ import { mkdirSync, readFileSync, writeFileSync, statSync, appendFileSync } from "node:fs";
35
+ import { resolve, dirname } from "node:path";
36
+
37
+ const PROBE_TIMEOUT_MS = 2000;
38
+ const MAX_BODY_BYTES = 64 * 1024;
39
+ const CACHE_TTL_MS = 10 * 60 * 1000;
40
+ const SHELL_BYTE_THRESHOLD = 5 * 1024;
41
+ const TEXT_BYTE_THRESHOLD = 20;
42
+
43
+ const accountDir = process.env.ACCOUNT_DIR || "";
44
+ const logPath = accountDir ? resolve(accountDir, "logs", "webfetch-preflight.log") : null;
45
+
46
+ function ts() {
47
+ return new Date().toISOString();
48
+ }
49
+
50
+ function logLine(line) {
51
+ if (!logPath) return;
52
+ try {
53
+ mkdirSync(dirname(logPath), { recursive: true });
54
+ appendFileSync(logPath, `[${ts()}] ${line}\n`);
55
+ } catch {
56
+ // Logging failure must never block the tool. The verdict still emits to
57
+ // stderr where Claude Code's own hook-stderr capture will catch it.
58
+ }
59
+ }
60
+
61
+ function emitVerdict(fields, allowFlag) {
62
+ const line = `[webfetch-preflight] ${fields.join(" ")}`;
63
+ logLine(line);
64
+ // Stderr trail so a reader inspecting Claude Code's hook stderr capture
65
+ // sees the same verdict line (independent of the per-account log file).
66
+ process.stderr.write(line + "\n");
67
+ if (allowFlag === "block") {
68
+ // Caller adds the structured WEBFETCH_CANNOT_READ_JS_SPA message before
69
+ // exiting — this function only emits the diagnostic line.
70
+ return;
71
+ }
72
+ process.exit(0);
73
+ }
74
+
75
+ async function readStdin() {
76
+ return new Promise((resolvePromise, rejectPromise) => {
77
+ const chunks = [];
78
+ let total = 0;
79
+ const limit = 64 * 1024;
80
+ process.stdin.on("data", (c) => {
81
+ chunks.push(c);
82
+ total += c.length;
83
+ if (total > limit) {
84
+ // Destroy the stream before rejecting so the `end` listener can't fire
85
+ // a no-op resolve on the rejected Promise (and so we don't allocate
86
+ // a large Buffer.concat over already-discarded chunks).
87
+ process.stdin.destroy();
88
+ rejectPromise(new Error("stdin-too-large"));
89
+ }
90
+ });
91
+ process.stdin.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
92
+ process.stdin.on("error", rejectPromise);
93
+ });
94
+ }
95
+
96
+ function readBodyCapped(res, maxBytes) {
97
+ return new Promise(async (resolvePromise, rejectPromise) => {
98
+ try {
99
+ const reader = res.body.getReader();
100
+ const chunks = [];
101
+ let total = 0;
102
+ while (true) {
103
+ const { value, done } = await reader.read();
104
+ if (done) break;
105
+ chunks.push(Buffer.from(value));
106
+ total += value.byteLength;
107
+ if (total >= maxBytes) {
108
+ reader.cancel().catch(() => {});
109
+ break;
110
+ }
111
+ }
112
+ resolvePromise(Buffer.concat(chunks, Math.min(total, maxBytes)));
113
+ } catch (err) {
114
+ rejectPromise(err);
115
+ }
116
+ });
117
+ }
118
+
119
+ function detectShape(bodyStr) {
120
+ // Strip <script>, <style>, comments, then all tags. What remains is the
121
+ // user-visible text. <20 chars after this strip = the page renders no
122
+ // meaningful content server-side.
123
+ const stripped = bodyStr
124
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
125
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
126
+ .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, "")
127
+ .replace(/<!--[\s\S]*?-->/g, "")
128
+ .replace(/<[^>]+>/g, "")
129
+ .replace(/\s+/g, " ")
130
+ .trim();
131
+
132
+ const hasRootDiv =
133
+ /<div\s+id=["']root["']\s*>\s*<\/div>/i.test(bodyStr) ||
134
+ /<div\s+id=["']app["']\s*>\s*<\/div>/i.test(bodyStr) ||
135
+ /<div\s+id=["']__next["']\s*>\s*<\/div>/i.test(bodyStr);
136
+
137
+ // <body> contains only <script>/<link>/<meta> + whitespace + comments.
138
+ // Common pattern for Vite/CRA shells without a labelled root div.
139
+ const bodyMatch = bodyStr.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
140
+ let bodyOnlyScripts = false;
141
+ if (bodyMatch) {
142
+ const inner = bodyMatch[1].replace(/<!--[\s\S]*?-->/g, "");
143
+ const tagless = inner
144
+ .replace(/<(script|link|meta)\b[^>]*>[\s\S]*?<\/\1>/gi, "")
145
+ .replace(/<(script|link|meta)\b[^>]*\/?>/gi, "")
146
+ .trim();
147
+ bodyOnlyScripts = tagless.length === 0 && /<script\b/i.test(inner);
148
+ }
149
+
150
+ return { textBytes: stripped.length, hasRootDiv, bodyOnlyScripts };
151
+ }
152
+
153
+ function classifyFetchError(err) {
154
+ const name = err && err.name ? err.name : "";
155
+ const code = err && err.cause && err.cause.code ? err.cause.code : err && err.code ? err.code : "";
156
+ if (name === "AbortError" || name === "TimeoutError") return "timeout";
157
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "dns-fail";
158
+ if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EHOSTUNREACH") return "tcp-fail";
159
+ if (code === "CERT_HAS_EXPIRED" || code === "DEPTH_ZERO_SELF_SIGNED_CERT") return "tls-fail";
160
+ return `fetch-err-${code || name || "unknown"}`;
161
+ }
162
+
163
+ async function main() {
164
+ let raw;
165
+ try {
166
+ raw = await readStdin();
167
+ } catch (err) {
168
+ emitVerdict([`verdict=inconclusive`, `reason=stdin-${err.message || "unknown"}`]);
169
+ return;
170
+ }
171
+
172
+ let payload;
173
+ try {
174
+ payload = JSON.parse(raw);
175
+ } catch {
176
+ emitVerdict([`verdict=inconclusive`, `reason=stdin-not-json`]);
177
+ return;
178
+ }
179
+
180
+ const toolInput = payload && typeof payload === "object" ? payload.tool_input : null;
181
+ const url = toolInput && typeof toolInput.url === "string" ? toolInput.url : "";
182
+ if (!url) {
183
+ emitVerdict([`verdict=inconclusive`, `reason=no-url-in-tool-input`]);
184
+ return;
185
+ }
186
+
187
+ let parsed;
188
+ try {
189
+ parsed = new URL(url);
190
+ } catch {
191
+ emitVerdict([`verdict=inconclusive`, `reason=unparseable-url`, `url=${JSON.stringify(url)}`]);
192
+ return;
193
+ }
194
+
195
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
196
+ emitVerdict([
197
+ `verdict=inconclusive`,
198
+ `reason=disallowed-scheme`,
199
+ `scheme=${parsed.protocol.replace(":", "")}`,
200
+ ]);
201
+ return;
202
+ }
203
+
204
+ // Cache key — strip fragment, normalise. Same URL across conversations
205
+ // hits the same cache entry; verdict is intrinsic to the response shape.
206
+ parsed.hash = "";
207
+ const cacheKey = parsed.toString();
208
+ const cacheHash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 32);
209
+ const cacheDir = accountDir ? resolve(accountDir, "cache", "webfetch-preflight") : null;
210
+ const cachePath = cacheDir ? resolve(cacheDir, `${cacheHash}.json`) : null;
211
+
212
+ if (cachePath) {
213
+ try {
214
+ const stat = statSync(cachePath);
215
+ const ageMs = Date.now() - stat.mtimeMs;
216
+ if (ageMs < CACHE_TTL_MS) {
217
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
218
+ if (cached && typeof cached.verdict === "string") {
219
+ const ageSec = Math.round(ageMs / 1000);
220
+ const fields = [
221
+ `verdict=${cached.verdict}`,
222
+ `body_bytes=${cached.body_bytes ?? 0}`,
223
+ `text_bytes=${cached.text_bytes ?? 0}`,
224
+ `cache=hit`,
225
+ `cache_age_s=${ageSec}`,
226
+ `url=${JSON.stringify(cacheKey)}`,
227
+ ];
228
+ if (cached.reason) fields.push(`reason=${cached.reason}`);
229
+ if (cached.verdict === "spa-shell") {
230
+ emitVerdict(fields, "block");
231
+ const bytes = cached.body_bytes ?? 0;
232
+ process.stderr.write(buildShortCircuitMessage(cacheKey, bytes) + "\n");
233
+ process.exit(2);
234
+ }
235
+ emitVerdict(fields);
236
+ return;
237
+ }
238
+ }
239
+ } catch {
240
+ // Cache miss or unreadable — continue to live probe.
241
+ }
242
+ }
243
+
244
+ let res;
245
+ try {
246
+ res = await fetch(parsed.toString(), {
247
+ headers: { Accept: "text/html,application/xhtml+xml,*/*;q=0.5" },
248
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
249
+ redirect: "follow",
250
+ });
251
+ } catch (err) {
252
+ emitVerdict([
253
+ `verdict=inconclusive`,
254
+ `reason=${classifyFetchError(err)}`,
255
+ `url=${JSON.stringify(cacheKey)}`,
256
+ ]);
257
+ return;
258
+ }
259
+
260
+ if (res.status >= 500) {
261
+ emitVerdict([
262
+ `verdict=inconclusive`,
263
+ `reason=http-${res.status}`,
264
+ `url=${JSON.stringify(cacheKey)}`,
265
+ ]);
266
+ return;
267
+ }
268
+
269
+ const contentType = (res.headers.get("content-type") || "").toLowerCase();
270
+ if (contentType && !contentType.includes("text/html") && !contentType.includes("application/xhtml")) {
271
+ emitVerdict([
272
+ `verdict=inconclusive`,
273
+ `reason=non-html-content-type`,
274
+ `ct=${JSON.stringify(contentType.slice(0, 60))}`,
275
+ `url=${JSON.stringify(cacheKey)}`,
276
+ ]);
277
+ return;
278
+ }
279
+
280
+ let body;
281
+ try {
282
+ body = await readBodyCapped(res, MAX_BODY_BYTES);
283
+ } catch (err) {
284
+ emitVerdict([
285
+ `verdict=inconclusive`,
286
+ `reason=body-read-${classifyFetchError(err)}`,
287
+ `url=${JSON.stringify(cacheKey)}`,
288
+ ]);
289
+ return;
290
+ }
291
+
292
+ const bodyBytes = body.length;
293
+ const bodyStr = body.toString("utf8");
294
+ const { textBytes, hasRootDiv, bodyOnlyScripts } = detectShape(bodyStr);
295
+
296
+ const isShell =
297
+ bodyBytes <= SHELL_BYTE_THRESHOLD &&
298
+ textBytes < TEXT_BYTE_THRESHOLD &&
299
+ (hasRootDiv || bodyOnlyScripts);
300
+
301
+ const verdict = isShell ? "spa-shell" : "ok";
302
+ const reason = isShell ? "js-spa-no-server-content" : undefined;
303
+
304
+ if (cachePath && cacheDir) {
305
+ try {
306
+ mkdirSync(cacheDir, { recursive: true });
307
+ writeFileSync(
308
+ cachePath,
309
+ JSON.stringify({
310
+ verdict,
311
+ body_bytes: bodyBytes,
312
+ text_bytes: textBytes,
313
+ reason,
314
+ url: cacheKey,
315
+ fetched_at: Date.now(),
316
+ }),
317
+ );
318
+ } catch {
319
+ // Best-effort. A failed cache write means the next probe re-runs;
320
+ // not a correctness issue.
321
+ }
322
+ }
323
+
324
+ const fields = [
325
+ `verdict=${verdict}`,
326
+ `body_bytes=${bodyBytes}`,
327
+ `text_bytes=${textBytes}`,
328
+ `cache=miss`,
329
+ `url=${JSON.stringify(cacheKey)}`,
330
+ ];
331
+ if (isShell) {
332
+ fields.push(`has_root_div=${hasRootDiv}`, `body_only_scripts=${bodyOnlyScripts}`, `reason=${reason}`);
333
+ emitVerdict(fields, "block");
334
+ process.stderr.write(buildShortCircuitMessage(cacheKey, bodyBytes) + "\n");
335
+ process.exit(2);
336
+ }
337
+ emitVerdict(fields);
338
+ }
339
+
340
+ function buildShortCircuitMessage(url, bodyBytes) {
341
+ // Wording is the actual content the LLM sees as tool_result. Names the
342
+ // failure class (machine-readable prefix), the reason in plain English,
343
+ // and the two concrete user actions that unblock the task. The directive
344
+ // not to silently retry is explicit because the model's instinct is to
345
+ // try Playwright next — that hides the failure from the user.
346
+ return (
347
+ `WEBFETCH_CANNOT_READ_JS_SPA: The HTML response at ${url} is a JavaScript ` +
348
+ `single-page-application shell (${bodyBytes} bytes, no server-rendered text). ` +
349
+ `WebFetch does not execute JavaScript, so it has no content to extract. ` +
350
+ `Do NOT retry WebFetch on this URL and do NOT silently dispatch another tool ` +
351
+ `(Playwright, research-assistant, memory-search) to substitute — that hides ` +
352
+ `the failure from the user. Stop, tell the user plainly that you cannot ` +
353
+ `read this URL because it renders content with JavaScript, and ask them to ` +
354
+ `paste the relevant text or send a screenshot.`
355
+ );
356
+ }
357
+
358
+ main().catch((err) => {
359
+ // Top-level safety net — fail-open. A crash in the preflight must never
360
+ // block a tool dispatch; the worst case is we lose the optimisation and
361
+ // pay the existing 60s timeout cost.
362
+ emitVerdict([`verdict=inconclusive`, `reason=preflight-crash`, `err=${JSON.stringify(String(err).slice(0, 80))}`]);
363
+ });
@@ -52,9 +52,12 @@ The `conversationId` is visible in the admin UI and appears on every `[spawn]` /
52
52
  - `[tool-wait]` lines every 5 s between `[tool-use]` and `[tool-result]` — distinguishes API silence from tool-internal stall.
53
53
  - `[tool-wait-diag]` at 15 / 30 / 45 / 60 s — confirms DNS/TCP/HTTP health across a stall window (not just at the timeout moment).
54
54
  - `[tool-wait-proc]` at 30 s — subprocess open FDs, sockets by TCP state, RSS.
55
- - `[subproc-stderr]` — subprocess stderr teed in, includes `NODE_DEBUG=http,http2,net,tls,undici,dns` network traces. Missing UNDICI/HTTP write lines during a tool-wait window = tool never sent the request.
55
+ - `[subproc-stderr]` — line from the main Claude Code subprocess's stderr. Today the CLI is a bundled Bun binary that ignores Node's `NODE_DEBUG`, so this channel is normally silent see `[subproc-debug-unavailable]` below. MCP server stderr lines arrive separately as `[mcp:<server>]`.
56
+ - `[subproc-stderr-tee-attached]` / `[subproc-stderr-tee-detached]` (Task 535) — lifecycle of the main-subprocess stderr tee. `bytes=0 lines=0` on detach means the tee worked but the subprocess emitted nothing. Absence of both markers next to a `[spawn]` means the tee infrastructure is broken — escalate.
57
+ - `[subproc-debug-unavailable]` (Task 535) — one line per spawn, `reason=bundled-bun-binary-ignores-node-debug`. This is the documented reason `[subproc-stderr]` lines are normally absent for the main subprocess. Treat its absence as a regression, not its presence as a problem.
56
58
  - `[tool-failure-diag]` — one-shot probe on the failure path (Task 530); complements the mid-flight `[tool-wait-diag]` with end-of-wait network state.
57
59
  - `[mcp-tee-attach]` / `[mcp-tee-skip]` / `[mcp:<server>]` — MCP server stderr routed into the stream log; a missing attach marker explains missing server diagnostics.
60
+ - `[tool-result] error=true output="WEBFETCH_CANNOT_READ_JS_SPA: …"` — a WebFetch dispatch was short-circuited by the SPA preflight hook (Task 536). The URL is a JS-SPA shell that WebFetch cannot extract content from. The agent should have responded to the user naming the failure and asking for a paste or screenshot; if instead an `[agent-dispatch]` (Playwright, research-assistant) follows immediately, the loud-failure guidance in IDENTITY.md did not land. The hook's per-invocation decision trail lives in `{accountDir}/logs/webfetch-preflight.log` (one `[webfetch-preflight] verdict=…` line per probe).
58
61
 
59
62
  ## Boundaries
60
63
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cloudflare
3
- description: Cloudflare Tunnel setup and management — declarative brand-zone scope, deterministic recovery
3
+ description: Cloudflare Tunnel setup and management — bound account is the universe, agent owns it absolutely
4
4
  tools:
5
5
  - cloudflare-setup
6
6
  - cf-add-zone
@@ -19,7 +19,7 @@ tools:
19
19
 
20
20
  # Cloudflare Tunnel Setup
21
21
 
22
- The brand declares its Cloudflare zones in `brand.json` (`cloudflare.zones`) at build time. Every tool refuses operations against hostnames whose registrable parent is not in that list declared scope is authoritative. `cloudflare-setup` is the single onboarding orchestrator: a UI-driven state machine that prompts for tunnel/zone selections and admin/public addresses via rendered components (`single-select`, `tunnel-route-picker`). Tunnel names are unique per customer (derived from chosen admin label + domain) so multiple customers can coexist on a shared zone. The agent never synthesises subdomains from free text every label comes from a component submission. Authentication is OAuth-only via `tunnel-login`; on first success the device records an `account-binding.json` so any later cert rotation under a different Cloudflare account is detected and refused. `cf-verify` is the non-mutating audit; `cf-rebuild` is the deterministic recovery.
22
+ Each installation has its own Cloudflare account, owned by the user, accessed via `cert.pem` from `tunnel-login` (OAuth the only auth path). The bound account is the entire universe of routable zones; the agent has absolute authority over it (add, delete, restructure). Anything on the account that doesn't belong to the user's current intended state is pollution `cf-rebuild` deletes it, with `reason=no-intent` refusal when intent is unstated. `cloudflare-setup` is the onboarding orchestrator: a UI-driven state machine that surfaces what's on the account, asks the user to pick a domain, requires explicit cleanup confirmation for any pollution, then creates the tunnel. `cf-verify` is the non-mutating audit (account state + device state + default pollution view). The device records `account-binding.json` on first login so cert rotation under a different account is detected and refused that's the only inherited identity check.
23
23
 
24
24
  ## When to activate
25
25
 
@@ -50,7 +50,6 @@ beforeEach(() => {
50
50
  writeBrand({
51
51
  productName: "Maxy",
52
52
  configDir: ".maxy",
53
- cloudflare: { zones: ["maxy.bot"] },
54
53
  });
55
54
  });
56
55