@rubytech/create-maxy 1.0.647 → 1.0.648

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.
@@ -0,0 +1,401 @@
1
+ // Raw CDP scrape of the operator's Cloudflare dashboard to discover the
2
+ // domains attached to the logged-in account. Output on stdout is a JSON
3
+ // `string[]`, sorted and deduped. Every failure mode exits non-zero with
4
+ // a `reason=<enum>` token on stderr tagged [list-cf-domains].
5
+ //
6
+ // Why raw CDP, not playwright:
7
+ // - `/json/new?about:blank` + WebSocket to the returned `webSocketDebuggerUrl`
8
+ // is ~120 LOC including retries and state machine.
9
+ // - Adds zero npm deps (Node 22 ships global `WebSocket`; no `ws`/`playwright`).
10
+ // - CDP is already bound to 127.0.0.1 by vnc.sh; the runtime surface is
11
+ // the same one setup-tunnel.sh uses for browser drive.
12
+ //
13
+ // Why URL-pattern extraction, not CSS selectors:
14
+ // - Cloudflare's SPA uses hashed, version-churning class names — a selector
15
+ // written today drifts within a dashboard redesign window.
16
+ // - The `/<accountId>/<domain>` URL shape is load-bearing for CF's own
17
+ // routing and cannot drift without breaking their entire dashboard; it
18
+ // is the most stable surface to key off.
19
+ //
20
+ // Contract with the caller (list-cf-domains.sh → route → form):
21
+ // stdout on exit 0: JSON `string[]` (empty array means signed-in-but-empty)
22
+ // stderr on any path: phase_line-formatted lines, `[list-cf-domains] …`
23
+ // exit 1: `reason=<enum>` on stderr; scrape-empty-but-no-error is exit 0
24
+ // with body dump (the enum exists so the caller can distinguish
25
+ // "selector drift" from "empty account" — both shapes arrive via
26
+ // stdout).
27
+
28
+ import { writeFile } from "node:fs/promises";
29
+ import { resolve } from "node:path";
30
+ import { homedir } from "node:os";
31
+
32
+ const CDP_HOST = "127.0.0.1";
33
+ const CDP_PORT = 9222;
34
+
35
+ // Phase budgets total 30s (enforced by the shell wrapper's `timeout`).
36
+ // Individual steps get sub-budgets so an early stall does not eat the
37
+ // whole envelope silently.
38
+ const CONNECT_TIMEOUT_MS = 2_000;
39
+ const NAVIGATE_TIMEOUT_MS = 15_000;
40
+ const SIGNED_IN_POLL_MS = 10_000;
41
+ const SCRAPE_POLL_MS = 10_000;
42
+ const POLL_INTERVAL_MS = 500;
43
+
44
+ type Reason =
45
+ | "cdp-unreachable"
46
+ | "target-create-failed"
47
+ | "ws-connect-failed"
48
+ | "not-signed-in"
49
+ | "navigate-failed"
50
+ | "table-wait-timeout"
51
+ | "runtime-error";
52
+
53
+ function logPhase(line: string): void {
54
+ // Written to stderr; shell wrapper tees to STREAM_LOG_PATH with timestamp.
55
+ // Keeping format parity with `_stream-log.sh phase_line` on the bash side
56
+ // means the tailer regex and grepping by `[list-cf-domains]` work uniformly.
57
+ process.stderr.write(`[list-cf-domains] ${line}\n`);
58
+ }
59
+
60
+ function die(reason: Reason, detail = ""): never {
61
+ logPhase(`phase=error reason=${reason}${detail ? ` detail="${detail.replace(/"/g, "'").slice(0, 300)}"` : ""}`);
62
+ process.exit(1);
63
+ }
64
+
65
+ async function cdpVersion(): Promise<void> {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), CONNECT_TIMEOUT_MS);
68
+ try {
69
+ const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, { signal: controller.signal });
70
+ if (!res.ok) die("cdp-unreachable", `HTTP ${res.status}`);
71
+ } catch (err) {
72
+ die("cdp-unreachable", err instanceof Error ? err.message : String(err));
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ interface CdpTarget {
79
+ id: string;
80
+ webSocketDebuggerUrl: string;
81
+ }
82
+
83
+ async function createTarget(): Promise<CdpTarget> {
84
+ // `PUT /json/new?<url>` returns JSON describing the target. The URL goes
85
+ // as a query-string argument (not a ?url= key), matching Chromium's CDP
86
+ // server. about:blank avoids a wasted navigation — the real navigate
87
+ // happens over WS after we attach.
88
+ const endpoint = `http://${CDP_HOST}:${CDP_PORT}/json/new?about:blank`;
89
+ let res: Response;
90
+ try {
91
+ res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
92
+ } catch (err) {
93
+ die("target-create-failed", err instanceof Error ? err.message : String(err));
94
+ }
95
+ if (!res.ok) die("target-create-failed", `HTTP ${res.status}`);
96
+ const target = (await res.json()) as Partial<CdpTarget>;
97
+ if (!target.id || !target.webSocketDebuggerUrl) {
98
+ die("target-create-failed", "response missing id or webSocketDebuggerUrl");
99
+ }
100
+ return target as CdpTarget;
101
+ }
102
+
103
+ async function closeTarget(id: string): Promise<void> {
104
+ try {
105
+ await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/close/${id}`, {
106
+ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
107
+ });
108
+ } catch {
109
+ // Best-effort on cleanup — a leaked target is recovered by the next vnc.sh
110
+ // restart; surfacing it as a failure would mask the real scrape error.
111
+ }
112
+ }
113
+
114
+ // Minimal CDP WebSocket client. We only need three commands: Page.enable,
115
+ // Runtime.enable, Page.navigate, Runtime.evaluate. A full client would
116
+ // manage sessions / targets / events — we just need request/response with
117
+ // per-id correlation and an optional event stream for Page.frameNavigated
118
+ // (which we do not actually need because Runtime.evaluate on location.href
119
+ // is the authoritative signal).
120
+ class CdpClient {
121
+ private ws: WebSocket;
122
+ private nextId = 1;
123
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
124
+
125
+ private constructor(ws: WebSocket) {
126
+ this.ws = ws;
127
+ this.ws.addEventListener("message", (ev) => this.onMessage(ev.data as string));
128
+ this.ws.addEventListener("error", () => {
129
+ for (const { reject } of this.pending.values()) reject(new Error("WebSocket error"));
130
+ this.pending.clear();
131
+ });
132
+ this.ws.addEventListener("close", () => {
133
+ for (const { reject } of this.pending.values()) reject(new Error("WebSocket closed"));
134
+ this.pending.clear();
135
+ });
136
+ }
137
+
138
+ static async connect(url: string): Promise<CdpClient> {
139
+ const ws = new WebSocket(url);
140
+ return await new Promise((resolvePromise, rejectPromise) => {
141
+ const timer = setTimeout(() => {
142
+ ws.close();
143
+ rejectPromise(new Error(`WebSocket connect timeout (${CONNECT_TIMEOUT_MS}ms)`));
144
+ }, CONNECT_TIMEOUT_MS);
145
+ ws.addEventListener("open", () => {
146
+ clearTimeout(timer);
147
+ resolvePromise(new CdpClient(ws));
148
+ }, { once: true });
149
+ ws.addEventListener("error", (ev) => {
150
+ clearTimeout(timer);
151
+ rejectPromise(new Error(`WebSocket error: ${(ev as ErrorEvent).message ?? "unknown"}`));
152
+ }, { once: true });
153
+ });
154
+ }
155
+
156
+ private onMessage(raw: string): void {
157
+ let msg: { id?: number; result?: unknown; error?: { message?: string } };
158
+ try {
159
+ msg = JSON.parse(raw);
160
+ } catch {
161
+ return;
162
+ }
163
+ if (typeof msg.id !== "number") return;
164
+ const waiter = this.pending.get(msg.id);
165
+ if (!waiter) return;
166
+ this.pending.delete(msg.id);
167
+ if (msg.error) {
168
+ waiter.reject(new Error(msg.error.message ?? "CDP error"));
169
+ } else {
170
+ waiter.resolve(msg.result);
171
+ }
172
+ }
173
+
174
+ send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
175
+ const id = this.nextId++;
176
+ return new Promise<T>((resolvePromise, rejectPromise) => {
177
+ this.pending.set(id, {
178
+ resolve: resolvePromise as (v: unknown) => void,
179
+ reject: rejectPromise,
180
+ });
181
+ this.ws.send(JSON.stringify({ id, method, params }));
182
+ });
183
+ }
184
+
185
+ close(): void {
186
+ try { this.ws.close(); } catch { /* ws may already be closing */ }
187
+ }
188
+ }
189
+
190
+ interface EvaluateResult {
191
+ result: { type: string; value?: unknown; description?: string };
192
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
193
+ }
194
+
195
+ async function evaluate<T = unknown>(cdp: CdpClient, expression: string): Promise<T> {
196
+ const res = await cdp.send<EvaluateResult>("Runtime.evaluate", {
197
+ expression,
198
+ returnByValue: true,
199
+ awaitPromise: false,
200
+ });
201
+ if (res.exceptionDetails) {
202
+ throw new Error(res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? "evaluate threw");
203
+ }
204
+ return res.result.value as T;
205
+ }
206
+
207
+ // Detect whether the dashboard has redirected to a signed-in account path
208
+ // (URL like `/<32-hex-accountId>/…`) or the unsigned-in `/login` flow.
209
+ // Returns the accountId if signed in, null if still loading, false if
210
+ // definitively signed-out.
211
+ const ACCOUNT_ID_REGEX = /^\/([a-f0-9]{32})(?:\/|$)/;
212
+
213
+ async function readAccountId(cdp: CdpClient): Promise<string | null | false> {
214
+ const pathname = await evaluate<string>(cdp, "location.pathname");
215
+ if (typeof pathname !== "string") return null;
216
+ if (pathname.startsWith("/login") || pathname.startsWith("/sign-in")) return false;
217
+ const match = pathname.match(ACCOUNT_ID_REGEX);
218
+ return match ? match[1] : null;
219
+ }
220
+
221
+ async function waitForSignedIn(cdp: CdpClient): Promise<string> {
222
+ const deadline = Date.now() + SIGNED_IN_POLL_MS;
223
+ while (Date.now() < deadline) {
224
+ const state = await readAccountId(cdp);
225
+ if (state === false) die("not-signed-in", "dashboard redirected to login");
226
+ if (typeof state === "string") {
227
+ logPhase(`phase=dashboard-nav-complete accountId=${state.slice(0, 8)}…`);
228
+ return state;
229
+ }
230
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
231
+ }
232
+ // Timed out without seeing either pattern — treat as not-signed-in to
233
+ // prompt `tunnel-login`; a genuinely-slow dashboard that later resolves
234
+ // would be a false-negative we accept in exchange for deterministic UX.
235
+ die("not-signed-in", "no /<accountId>/… path reached within budget");
236
+ }
237
+
238
+ // Extract every domain mentioned on the Domains Overview page via URL-
239
+ // pattern match, then merge with any FQDN-shaped text found in table cells.
240
+ // Two sources: (a) `/<accountId>/<hostname>` URL hrefs on the page; (b) any
241
+ // text node on the page matching a valid FQDN pattern — the overview page
242
+ // renders the zone name as both a link and a table cell, so either source
243
+ // catches it. The merge is conservative: we require the string look like a
244
+ // domain (2+ labels, last label 2-63 alpha) AND not be a cloudflare-owned
245
+ // marketing host.
246
+ const SCRAPE_EXPRESSION = `(function() {
247
+ const accountIdMatch = location.pathname.match(/^\\/([a-f0-9]{32})/);
248
+ if (!accountIdMatch) return { reason: 'no-account-id', domains: [] };
249
+ const accountId = accountIdMatch[1];
250
+
251
+ const out = new Set();
252
+ const pushIfDomain = (s) => {
253
+ if (typeof s !== 'string') return;
254
+ const t = s.trim().toLowerCase();
255
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(t)) return;
256
+ if (t.endsWith('.cloudflare.com') || t === 'cloudflare.com') return;
257
+ out.add(t);
258
+ };
259
+
260
+ // Source A: /<accountId>/<host> hrefs — the canonical CF routing pattern
261
+ // that the overview page uses to link each zone to its management screens.
262
+ const hrefPrefix = '/' + accountId + '/';
263
+ for (const a of document.querySelectorAll('a[href]')) {
264
+ const href = a.getAttribute('href') || '';
265
+ if (!href.startsWith(hrefPrefix)) continue;
266
+ const tail = href.slice(hrefPrefix.length).split('?')[0].split('#')[0];
267
+ const firstSegment = tail.split('/')[0];
268
+ pushIfDomain(firstSegment);
269
+ }
270
+
271
+ // Source B: explicit FQDN-shaped text in the main content region. Guards
272
+ // against a hypothetical CF redesign that drops the link tree — the zone
273
+ // name still appears as rendered text in the table row.
274
+ const main = document.querySelector('main') || document.body;
275
+ if (main) {
276
+ const treeWalker = document.createTreeWalker(main, NodeFilter.SHOW_TEXT);
277
+ let node;
278
+ while ((node = treeWalker.nextNode())) {
279
+ const text = (node.nodeValue || '').trim();
280
+ if (text.length < 4 || text.length > 253) continue;
281
+ pushIfDomain(text);
282
+ }
283
+ }
284
+
285
+ return { reason: 'ok', domains: Array.from(out).sort() };
286
+ })()`;
287
+
288
+ interface ScrapeOutcome {
289
+ reason: "ok" | "no-account-id";
290
+ domains: string[];
291
+ }
292
+
293
+ async function scrapeDomains(cdp: CdpClient): Promise<string[]> {
294
+ const deadline = Date.now() + SCRAPE_POLL_MS;
295
+ let lastOutcome: ScrapeOutcome | null = null;
296
+ while (Date.now() < deadline) {
297
+ try {
298
+ const outcome = await evaluate<ScrapeOutcome>(cdp, SCRAPE_EXPRESSION);
299
+ lastOutcome = outcome;
300
+ if (outcome.reason === "ok" && outcome.domains.length > 0) {
301
+ logPhase(`phase=dom-scrape-complete result=ok count=${outcome.domains.length}`);
302
+ return outcome.domains;
303
+ }
304
+ // `ok` with zero domains is ambiguous during SPA hydration — keep
305
+ // polling in case the table renders after a data fetch. The final
306
+ // iteration's zero result is the empty-account signal.
307
+ } catch (err) {
308
+ logPhase(`phase=scrape-retry err="${(err instanceof Error ? err.message : String(err)).slice(0, 120)}"`);
309
+ }
310
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
311
+ }
312
+ // Poll exhausted. Distinguish genuine empty account from selector drift by
313
+ // dumping the body HTML (bounded) for manual inspection, then reporting
314
+ // empty with the diagnostic enum. The script still exits 0 — the caller
315
+ // treats empty as a valid signal and shows the "add a domain" empty state.
316
+ try {
317
+ const html = await evaluate<string>(cdp, "document.documentElement.outerHTML.slice(0, 100000)");
318
+ // CONFIG_DIR is set by list-cf-domains.sh before the spawn. A silent
319
+ // fallback would dump logs into the wrong brand's directory on a Real
320
+ // Agent install — a silent-miswrite masking the wrapper-side break it
321
+ // came from. Per Task 473 doctrine: loud-fail on absent runtime-derived
322
+ // values.
323
+ const configDir = process.env.CONFIG_DIR;
324
+ if (!configDir) {
325
+ throw new Error("CONFIG_DIR env var not set by wrapper — refusing to guess brand log directory");
326
+ }
327
+ const logDir = resolve(homedir(), configDir, "logs");
328
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
329
+ const dumpPath = resolve(logDir, `list-cf-domains-${ts}.html`);
330
+ await writeFile(dumpPath, typeof html === "string" ? html : String(html), "utf-8");
331
+ logPhase(`phase=dom-scrape-complete result=empty-or-drift dump=${dumpPath} lastReason=${lastOutcome?.reason ?? "unknown"}`);
332
+ } catch (err) {
333
+ logPhase(`phase=dom-scrape-complete result=empty-or-drift dump=failed lastReason=${lastOutcome?.reason ?? "unknown"} err="${(err instanceof Error ? err.message : String(err)).slice(0, 120)}"`);
334
+ }
335
+ return [];
336
+ }
337
+
338
+ async function navigate(cdp: CdpClient, url: string): Promise<void> {
339
+ const result = await cdp.send<{ errorText?: string }>("Page.navigate", { url });
340
+ if (result?.errorText) die("navigate-failed", `Page.navigate errorText="${result.errorText}"`);
341
+ }
342
+
343
+ async function waitForDocumentReady(cdp: CdpClient): Promise<void> {
344
+ const deadline = Date.now() + NAVIGATE_TIMEOUT_MS;
345
+ while (Date.now() < deadline) {
346
+ const state = await evaluate<string>(cdp, "document.readyState");
347
+ if (state === "complete" || state === "interactive") return;
348
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
349
+ }
350
+ die("navigate-failed", "document.readyState did not reach complete/interactive");
351
+ }
352
+
353
+ async function main(): Promise<void> {
354
+ logPhase(`phase=script-start cdp=${CDP_HOST}:${CDP_PORT}`);
355
+
356
+ await cdpVersion();
357
+ logPhase(`phase=cdp-connect result=ok`);
358
+
359
+ const target = await createTarget();
360
+ logPhase(`phase=target-created targetId=${target.id.slice(0, 8)}…`);
361
+
362
+ let cdp: CdpClient;
363
+ try {
364
+ cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
365
+ } catch (err) {
366
+ await closeTarget(target.id);
367
+ die("ws-connect-failed", err instanceof Error ? err.message : String(err));
368
+ }
369
+
370
+ try {
371
+ await cdp.send("Page.enable");
372
+ await cdp.send("Runtime.enable");
373
+
374
+ logPhase(`phase=dashboard-nav-start url=https://dash.cloudflare.com/`);
375
+ await navigate(cdp, "https://dash.cloudflare.com/");
376
+ await waitForDocumentReady(cdp);
377
+
378
+ const accountId = await waitForSignedIn(cdp);
379
+
380
+ const overviewUrl = `https://dash.cloudflare.com/${accountId}/domains/overview`;
381
+ logPhase(`phase=table-wait url=${overviewUrl}`);
382
+ await navigate(cdp, overviewUrl);
383
+ await waitForDocumentReady(cdp);
384
+
385
+ const domains = await scrapeDomains(cdp);
386
+
387
+ logPhase(`phase=target-closed`);
388
+ process.stdout.write(JSON.stringify(domains) + "\n");
389
+ logPhase(`phase=script-exit code=0 count=${domains.length}`);
390
+ } catch (err) {
391
+ // Only runtime errors that escaped the typed-reason paths land here.
392
+ die("runtime-error", err instanceof Error ? err.message : String(err));
393
+ } finally {
394
+ cdp.close();
395
+ await closeTarget(target.id);
396
+ }
397
+ }
398
+
399
+ main().catch((err: unknown) => {
400
+ die("runtime-error", err instanceof Error ? err.message : String(err));
401
+ });
@@ -168,11 +168,73 @@ if [ ! -f "${CFG_DIR}/cert.pem" ]; then
168
168
  echo "ERROR: CDP PUT /json/new?<url> returned empty — Chromium rejected the navigate." >&2
169
169
  exit 1
170
170
  fi
171
- phase_line setup-tunnel step=browser-drive result=accepted
171
+ # Extract the CDP target id so _cdp-authorize.mjs can open a debugger
172
+ # WebSocket against the correct tab. The PUT response is a JSON object
173
+ # describing the newly-created target; `.id` is the only stable field
174
+ # across Chromium versions that works for `/json/list` lookup.
175
+ CDP_TARGET_ID="$(printf '%s' "${CDP_RESP}" | jq -r '.id // empty' 2>/dev/null || true)"
176
+ if [ -z "${CDP_TARGET_ID}" ]; then
177
+ kill "${CF_PIPELINE_PID}" 2>/dev/null || true
178
+ phase_line setup-tunnel step=browser-drive result=error reason=cdp-target-id-missing \
179
+ resp_head="$(printf '%s' "${CDP_RESP}" | head -c 200)"
180
+ echo "ERROR: CDP PUT /json/new?<url> returned a body without an .id field — Chromium protocol drift?" >&2
181
+ exit 1
182
+ fi
183
+ phase_line setup-tunnel step=browser-drive result=accepted target_id="${CDP_TARGET_ID}"
184
+
185
+ # Task 588: drive the Authorize click via CDP WebSocket instead of waiting
186
+ # for a human. The helper shares a directory with this script; same
187
+ # readlink-resolve pattern as _stream-log.sh so the symlink install path
188
+ # (packages/create-maxy installs ~/setup-tunnel.sh as a symlink) doesn't
189
+ # point dirname at $HOME.
190
+ AUTH_HELPER="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_cdp-authorize.mjs"
191
+ if [ ! -x "${AUTH_HELPER}" ]; then
192
+ kill "${CF_PIPELINE_PID}" 2>/dev/null || true
193
+ phase_line setup-tunnel step=browser-drive result=error reason=cdp-helper-missing \
194
+ path="${AUTH_HELPER}"
195
+ echo "ERROR: _cdp-authorize.mjs helper missing or not executable at ${AUTH_HELPER}" >&2
196
+ exit 1
197
+ fi
198
+ # tee_subprocess pipes the helper's cdp-authorize result= line into the
199
+ # stream log under the setup-tunnel:cdp-click tag so the chat UI renders
200
+ # the click outcome in real time. Exit code drives control flow.
201
+ if tee_subprocess setup-tunnel:cdp-click -- \
202
+ node "${AUTH_HELPER}" "${CDP_TARGET_ID}"; then
203
+ phase_line setup-tunnel step=oauth-login result=authorize-clicked \
204
+ target_id="${CDP_TARGET_ID}"
205
+ else
206
+ CLICK_RC=$?
207
+ kill "${CF_PIPELINE_PID}" 2>/dev/null || true
208
+ # Helper exit codes: 1=authorize-button-not-found (loud, expected failure
209
+ # mode when the VNC browser isn't signed into Cloudflare), 2=cdp-ws-unreachable,
210
+ # 3=target-not-found, 4=click-evaluate-threw, 5=protocol-error. All are
211
+ # final — no retry, no silent fallback. Map the exit code 1:1 to a reason
212
+ # string so the stream-log `reason=<…>` is greppable by exact cause; a
213
+ # single catch-all reason would defeat the observability contract.
214
+ case "${CLICK_RC}" in
215
+ 1) CLICK_REASON=authorize-button-not-found ;;
216
+ 2) CLICK_REASON=cdp-ws-unreachable ;;
217
+ 3) CLICK_REASON=target-not-found ;;
218
+ 4) CLICK_REASON=click-evaluate-threw ;;
219
+ 5) CLICK_REASON=protocol-error ;;
220
+ *) CLICK_REASON="unknown-exit-${CLICK_RC}" ;;
221
+ esac
222
+ phase_line setup-tunnel step=browser-drive result=error \
223
+ reason="${CLICK_REASON}" click_rc="${CLICK_RC}"
224
+ echo "ERROR: CDP-driven Authorize click failed (helper exit=${CLICK_RC}, reason=${CLICK_REASON})." >&2
225
+ echo " If the VNC browser is not signed into Cloudflare, sign in manually," >&2
226
+ echo " close the sign-in tab, and re-run setup — or run ~/reset-tunnel.sh first." >&2
227
+ exit 1
228
+ fi
172
229
 
173
230
  # Wait for cert.pem to land — cloudflared writes to ~/.cloudflared/cert.pem
174
- # regardless of --origincert, so watch the canonical location.
175
- LOGIN_TIMEOUT="${SETUP_TUNNEL_LOGIN_TIMEOUT:-180}"
231
+ # regardless of --origincert, so watch the canonical location. Task 588
232
+ # collapses the pre-click 180 s human-latency window to a 20 s deterministic
233
+ # round-trip window (Cloudflare → cloudflared webhook after consent). The
234
+ # 2-second heartbeat inside the loop is the observability contract for
235
+ # this bounded wait — no form-spawned script is allowed a silent poll of
236
+ # more than ~2 s per criterion 3 of Task 588.
237
+ LOGIN_TIMEOUT="${SETUP_TUNNEL_LOGIN_TIMEOUT:-20}"
176
238
  LOGIN_WAIT=0
177
239
  while [ ! -f "${HOME}/.cloudflared/cert.pem" ]; do
178
240
  if ! kill -0 "${CF_PIPELINE_PID}" 2>/dev/null; then
@@ -192,6 +254,14 @@ if [ ! -f "${CFG_DIR}/cert.pem" ]; then
192
254
  echo "ERROR: Timed out after ${LOGIN_WAIT}s waiting for cert.pem to land." >&2
193
255
  exit 1
194
256
  fi
257
+ # Heartbeat every 2 s after the click. t=0 is the authorize-clicked
258
+ # phase line above; the first heartbeat fires at t=2. Without this line
259
+ # the tailer sees silence for the full 1-20 s round-trip — the exact
260
+ # state Task 588 forbids.
261
+ if [ "${LOGIN_WAIT}" -gt 0 ] && [ $((LOGIN_WAIT % 2)) -eq 0 ]; then
262
+ phase_line setup-tunnel step=oauth-login result=awaiting-cert \
263
+ elapsed="${LOGIN_WAIT}s" timeout="${LOGIN_TIMEOUT}s"
264
+ fi
195
265
  sleep 1
196
266
  LOGIN_WAIT=$((LOGIN_WAIT + 1))
197
267
  done
@@ -84,7 +84,9 @@ Example:
84
84
 
85
85
  ## 4. Dashboard guidance — `references/dashboard-guide.md`
86
86
 
87
- Use this when the operator needs to do something only the Cloudflare dashboard can do: sign in, switch accounts, add a site, edit an apex CNAME, verify zone nameservers, delete a tunnel after stopping its replicas. The guide has one numbered click-path per operation. Quote the relevant click-path verbatim — the operator follows it in the browser; no agent browser automation is involved.
87
+ Use this when the operator needs to do something only the Cloudflare dashboard can do: sign in, switch accounts, add a site, edit an apex CNAME, verify zone nameservers, delete a tunnel after stopping its replicas. The guide has one numbered click-path per operation. Quote the relevant click-path verbatim — the operator follows it in the browser. The agent does not drive dashboard mutations via Playwright or Chrome DevTools.
88
+
89
+ The single exception is `list-cf-domains.sh` (Task 589), which reads the domains attached to the logged-in account to populate the `cloudflare-setup-form` dropdowns. That script is deterministic (bash + raw CDP, no LLM in the decision path), invoked only by the `/api/admin/cloudflare/domains` route, and produces only a JSON `string[]` on stdout; no dashboard state is changed. Any dashboard scrape that is not this exact script is forbidden — the agent does not extend this carve-out to new scripts it writes, hypothesises, or finds. Adding a new sanctioned scrape surface requires a code change reviewed as a doctrine change, not an inline agent decision.
88
90
 
89
91
  ---
90
92
 
@@ -96,6 +98,6 @@ When the operator's request touches Cloudflare, the agent's permitted actions ar
96
98
  - Quote `references/manual-setup.md`, `references/reset-guide.md`, or `references/dashboard-guide.md`.
97
99
  - Verify reachability via plain HTTP (`curl -I https://<hostname>`).
98
100
 
99
- The agent does not drive Cloudflare's dashboard via Playwright or Chrome DevTools. The agent does not synthesise `cloudflared` flag combinations from web search or prior training. The agent does not call Cloudflare API or SDK from any language. The agent does not write or mutate `cert.pem`, `tunnel.state`, `config.yml`, or `alias-domains.json` directly — `setup-tunnel.sh` and the operator manage those files.
101
+ The agent does not drive Cloudflare dashboard mutations via Playwright or Chrome DevTools. The single sanctioned read-only scrape is `list-cf-domains.sh`, invoked only by the `/api/admin/cloudflare/domains` route — the LLM is not in its decision path. No other dashboard-automation surface is permitted; the agent does not generalise this exception to new scripts. The agent does not synthesise `cloudflared` flag combinations from web search or prior training. The agent does not call Cloudflare API or SDK from any language. The agent does not write or mutate `cert.pem`, `tunnel.state`, `config.yml`, or `alias-domains.json` directly — `setup-tunnel.sh` and the operator manage those files.
100
102
 
101
103
  When a sanctioned surface fails, the agent reports the failure with the exact output, cites the recovery step from `references/reset-guide.md`, and stops. Improvisation — "let me try a different flag" or "let me check the dashboard myself" — is the behaviour this rule exists to prevent. See IDENTITY.md § Cloudflare operations for the unconditional form.
@@ -8,6 +8,7 @@ Each installation has its own Cloudflare account. Sign-in is OAuth in the device
8
8
  |------|--------|
9
9
  | **Product identity** (Maxy vs Real Agent) | `brand.json` (`productName`, `configDir`) — known at install. |
10
10
  | **Cloudflare account identity** | `cert.pem` from OAuth. One account per brand per device. |
11
+ | **Domain scope** (which zones the operator can route) | Live Cloudflare dashboard at form-render time via `list-cf-domains.sh`, not `brand.json`. Brand identity has no authority over which domains the operator's CF account holds. |
11
12
  | **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `tunnel.state`, `alias-domains.json`. |
12
13
 
13
14
  There is no token-based auth for the operator-owned path (Mode A). To switch Cloudflare accounts, run `reset-tunnel.sh` (which deletes the cert and every tunnel on the current account), then run `setup-tunnel.sh` again — `cloudflared tunnel login` inside the setup script will pick a fresh account when you sign in.