@rubytech/create-realagent 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.
- package/dist/index.js +11 -1
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +1 -11
- package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -2
- package/payload/platform/plugins/cloudflare/scripts/_cdp-authorize.mjs +274 -0
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +69 -0
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +401 -0
- package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +73 -3
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +4 -2
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -0
- package/payload/server/public/assets/{admin-ITLuG4BN.js → admin-uiqCo17I.js} +3 -3
- package/payload/server/public/index.html +1 -1
- package/payload/server/server.js +169 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|