@rubytech/create-realagent 1.0.618 → 1.0.620

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 (24) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
  3. package/payload/platform/lib/mcp-stderr-tee/dist/index.js +11 -5
  4. package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
  5. package/payload/platform/lib/mcp-stderr-tee/src/index.ts +10 -5
  6. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts.map +1 -1
  7. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +55 -6
  8. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js.map +1 -1
  9. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +11 -7
  10. package/payload/platform/plugins/cloudflare/PLUGIN.md +10 -13
  11. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +94 -1030
  12. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +62 -258
  14. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  15. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +297 -882
  16. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  17. package/payload/platform/plugins/cloudflare/mcp/package.json +3 -7
  18. package/payload/platform/plugins/cloudflare/references/setup-guide.md +51 -77
  19. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +23 -81
  20. package/payload/platform/plugins/docs/PLUGIN.md +1 -1
  21. package/payload/platform/plugins/docs/references/cloudflare.md +21 -30
  22. package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
  23. package/payload/server/server.js +161 -11
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -195
@@ -1,9 +1,8 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, unlinkSync, copyFileSync, } from "node:fs";
3
- import { randomBytes } from "node:crypto";
4
3
  import { join } from "node:path";
5
4
  import { homedir } from "node:os";
6
- import Cloudflare from "cloudflare";
5
+ import { Resolver } from "node:dns/promises";
7
6
  let cachedBrand = null;
8
7
  export function loadBrand() {
9
8
  if (cachedBrand)
@@ -31,7 +30,7 @@ export function _resetBrandCache() {
31
30
  cachedBrand = null;
32
31
  }
33
32
  // ---------------------------------------------------------------------------
34
- // Binary detection (unchanged — cloudflared binary still needed for daemon)
33
+ // Binary detection
35
34
  // ---------------------------------------------------------------------------
36
35
  let cachedBinaryPath = null;
37
36
  const SEARCH_PATHS = [
@@ -42,7 +41,6 @@ const SEARCH_PATHS = [
42
41
  export function findBinary() {
43
42
  if (cachedBinaryPath)
44
43
  return cachedBinaryPath;
45
- // Try `which` first
46
44
  try {
47
45
  cachedBinaryPath = execFileSync("which", ["cloudflared"], {
48
46
  encoding: "utf-8",
@@ -106,10 +104,8 @@ export function readAccountBinding() {
106
104
  /**
107
105
  * @internal — production code MUST go through `materializeBinding` so the
108
106
  * source-of-truth lifecycle (fresh-login vs migration) is preserved and
109
- * logged. This function is exported only so unit tests can pre-seed
110
- * bindings without re-implementing the file format. A new tool handler
111
- * that calls `writeAccountBinding` directly is a code-review block —
112
- * silent overwrites mask account drift, defeating the four-step guard.
107
+ * logged. Exported only so unit tests can pre-seed bindings without
108
+ * re-implementing the file format.
113
109
  */
114
110
  export function writeAccountBinding(accountId) {
115
111
  const dir = bindingDir();
@@ -118,12 +114,6 @@ export function writeAccountBinding(accountId) {
118
114
  writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
119
115
  return binding;
120
116
  }
121
- /**
122
- * Reset the binding by unlinking the file. Only force-reset paths
123
- * (tunnel-login force=true, cf-rebuild after wrong-account detection)
124
- * call this. Tools must never overwrite the binding on a write path —
125
- * an overwrite would mask account drift, defeating the guard.
126
- */
127
117
  function resetAccountBinding() {
128
118
  const path = bindingFile();
129
119
  try {
@@ -139,16 +129,6 @@ function resetAccountBinding() {
139
129
  return false;
140
130
  }
141
131
  }
142
- export function matchAccountZone(hostname, accountZones) {
143
- const active = accountZones.filter((z) => z.status === "active").map((z) => z.name);
144
- const lc = hostname.toLowerCase();
145
- const candidates = active.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
146
- if (candidates.length === 0) {
147
- return { ok: false, matchedZone: null, accountZones: active };
148
- }
149
- candidates.sort((a, b) => b.length - a.length);
150
- return { ok: true, matchedZone: candidates[0], accountZones: active };
151
- }
152
132
  export function logRefuse(detail) {
153
133
  const brand = (() => {
154
134
  try {
@@ -162,7 +142,6 @@ export function logRefuse(detail) {
162
142
  const fields = {
163
143
  reason: detail.reason,
164
144
  brand: brand?.productName ?? "unknown",
165
- accountZones: f.accountZones ?? null,
166
145
  boundAccountId: f.boundAccountId ?? null,
167
146
  certAccountId: f.certAccountId ?? null,
168
147
  requestedDomain: f.requestedDomain ?? null,
@@ -173,14 +152,21 @@ export function logRefuse(detail) {
173
152
  console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
174
153
  }
175
154
  /**
176
- * Single recovery instruction. Every refusal instructs the same path:
177
- * tunnel-login under the Cloudflare account that owns the target zone.
155
+ * The only recovery instruction every refusal that involves the wrong
156
+ * Cloudflare account points here. Single source of truth so the four
157
+ * surfaces that quote it (tunnel-status, tunnel-add-hostname, personal
158
+ * assistant template, setup-tunnel skill) cannot drift.
178
159
  */
160
+ export const DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION = "Open Cloudflare in your browser. Look at the name in the top-left — " +
161
+ "that is the Cloudflare account this laptop will talk to. If it is not " +
162
+ "the account that owns the domain you want to use, switch accounts using " +
163
+ "the top-left dropdown. Once the correct account is showing, tell me and " +
164
+ "I will start the sign-in again.";
179
165
  export function recoveryMessage() {
180
- return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
181
- `the target zone. If you recently rotated the cert under a different account, ` +
182
- `pass force=true to clear the existing cert.pem and account binding before ` +
183
- `re-authenticating.`);
166
+ return ("Recovery: confirm you are signed into the correct Cloudflare account " +
167
+ "in your browser, then ask me to run tunnel-login. If you recently switched " +
168
+ "accounts, ask me to run tunnel-login with force=true so the current sign-in " +
169
+ "is cleared before the new one starts.");
184
170
  }
185
171
  // ---------------------------------------------------------------------------
186
172
  // Reset auth — unlinks cert.pem (both paths) and the account binding
@@ -261,10 +247,6 @@ function legacyCertPath() {
261
247
  * file would survive `tunnel-login force=true` (which only deletes brand
262
248
  * paths) and silently re-import on the next read — defeating the recovery
263
249
  * path operators rely on after switching Cloudflare accounts.
264
- *
265
- * Failure to unlink the legacy file after a successful copy is non-fatal:
266
- * the read still succeeds against the brand path, and the warning surfaces
267
- * the residual stale file.
268
250
  */
269
251
  function findCert() {
270
252
  const brandPath = certPath();
@@ -290,9 +272,10 @@ export function hasCert() {
290
272
  return findCert() !== null;
291
273
  }
292
274
  /**
293
- * Parse the ARGO TUNNEL TOKEN PEM block from cert.pem to extract
294
- * APIToken and AccountTag. Returns null if cert.pem is missing,
295
- * the token block is absent, or the format is unexpected.
275
+ * Parse the ARGO TUNNEL TOKEN PEM block from cert.pem to extract the
276
+ * AccountTag. Only the account ID is returned — nothing else in cert.pem
277
+ * is used by this codebase. The cert itself stays on disk; `cloudflared`
278
+ * shell-outs read it via `--origincert` for tunnel CRUD and DNS routing.
296
279
  */
297
280
  export function parseCertPem() {
298
281
  try {
@@ -311,26 +294,15 @@ export function parseCertPem() {
311
294
  .replace(/\s+/g, "");
312
295
  const json = Buffer.from(b64, "base64").toString("utf-8");
313
296
  const data = JSON.parse(json);
314
- const apiToken = data.APIToken ?? data.apiToken;
315
297
  const accountId = data.AccountTag ?? data.accountTag ?? data.AccountID ?? data.accountID ?? data.accountId;
316
- if (!apiToken || !accountId)
298
+ if (!accountId)
317
299
  return null;
318
- return { apiToken, accountId };
300
+ return { accountId };
319
301
  }
320
302
  catch {
321
303
  return null;
322
304
  }
323
305
  }
324
- // ---------------------------------------------------------------------------
325
- // SDK client
326
- //
327
- // Two entry points: getClient() for mutating operations enforces the auth
328
- // pre-conditions (cert present, binding present, accountId-from-cert
329
- // matches binding) — uncircumventable for any code path that needs an SDK
330
- // instance. getReadOnlyClient() for cf-verify allows operating on an
331
- // unbound device so the audit can report MISSING artefacts without
332
- // throwing. Both rely on cert.pem as the single account identity source.
333
- // ---------------------------------------------------------------------------
334
306
  /** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
335
307
  export class CloudflareRefusalError extends Error {
336
308
  refusal;
@@ -340,70 +312,6 @@ export class CloudflareRefusalError extends Error {
340
312
  this.refusal = detail;
341
313
  }
342
314
  }
343
- /**
344
- * Build an SDK client for mutating operations. Throws CloudflareRefusalError
345
- * with a structured `refusal` field if any auth pre-condition fails:
346
- * - cert.pem absent or unparseable → unbound-device
347
- * - account-binding.json absent → unbound-device
348
- * - cert.pem accountId !== binding.accountId → account-drift
349
- *
350
- * The refusal is logged with [cloudflare:refuse] before the throw, so the
351
- * single greppable signal exists regardless of how the caller handles the
352
- * exception.
353
- */
354
- export function getClient() {
355
- const creds = parseCertPem();
356
- if (!creds) {
357
- const detail = {
358
- reason: "unbound-device",
359
- message: `No cert.pem found — this device has not completed Cloudflare authentication. ` +
360
- recoveryMessage(),
361
- fields: { boundAccountId: readAccountBinding()?.accountId ?? null },
362
- };
363
- logRefuse(detail);
364
- throw new CloudflareRefusalError(detail);
365
- }
366
- const binding = readAccountBinding();
367
- if (!binding) {
368
- const detail = {
369
- reason: "unbound-device",
370
- message: `No account binding recorded — tunnel-login must complete a fresh authentication ` +
371
- `to bind this device to a Cloudflare account before any other tool can run. ` +
372
- recoveryMessage(),
373
- fields: { certAccountId: creds.accountId },
374
- };
375
- logRefuse(detail);
376
- throw new CloudflareRefusalError(detail);
377
- }
378
- if (creds.accountId !== binding.accountId) {
379
- const detail = {
380
- reason: "account-drift",
381
- message: `cert.pem is bound to account ${creds.accountId}, but this device's recorded ` +
382
- `binding is account ${binding.accountId}. The cert was rotated under a different ` +
383
- `Cloudflare account since the binding was established. ` +
384
- recoveryMessage(),
385
- fields: {
386
- boundAccountId: binding.accountId,
387
- certAccountId: creds.accountId,
388
- },
389
- };
390
- logRefuse(detail);
391
- throw new CloudflareRefusalError(detail);
392
- }
393
- return {
394
- client: new Cloudflare({ apiToken: creds.apiToken }),
395
- accountId: binding.accountId,
396
- };
397
- }
398
- export function getReadOnlyClient() {
399
- const creds = parseCertPem();
400
- const binding = readAccountBinding();
401
- const certAccountId = creds?.accountId ?? null;
402
- const boundAccountId = binding?.accountId ?? null;
403
- const client = creds ? new Cloudflare({ apiToken: creds.apiToken }) : null;
404
- const bindingMatches = !!(creds && binding && creds.accountId === binding.accountId);
405
- return { client, certAccountId, boundAccountId, bindingMatches };
406
- }
407
315
  export function validateAuth() {
408
316
  const creds = parseCertPem();
409
317
  const binding = readAccountBinding();
@@ -417,14 +325,10 @@ export function validateAuth() {
417
325
  }
418
326
  /**
419
327
  * Establish the device-local account binding from cert.pem. Used by:
420
- * (1) tunnel-login fresh-success path — first time creds are derived
328
+ * (1) tunnel-login fresh-success path — first time creds are derived.
421
329
  * (2) migration path — when cert.pem exists from a prior install but no
422
330
  * binding has yet been written (silent materialization, since the
423
- * cert was already trust-established by the operator's prior login)
424
- *
425
- * Caller is responsible for declared-zone visibility validation BEFORE
426
- * calling this — bindings should never be written to an account that
427
- * doesn't own the brand's declared zones (Task 201 write-after-confirm).
331
+ * cert was already trust-established by the operator's prior login).
428
332
  */
429
333
  export function materializeBinding(source) {
430
334
  const creds = parseCertPem();
@@ -435,228 +339,6 @@ export function materializeBinding(source) {
435
339
  console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
436
340
  return binding;
437
341
  }
438
- export async function listZones() {
439
- const { client } = getClient();
440
- const zones = [];
441
- for await (const zone of client.zones.list()) {
442
- zones.push({
443
- id: zone.id,
444
- name: zone.name,
445
- status: zone.status ?? "unknown",
446
- nameservers: zone.name_servers ?? [],
447
- account: {
448
- id: zone.account.id ?? "",
449
- name: zone.account.name ?? "",
450
- },
451
- });
452
- }
453
- return zones;
454
- }
455
- export async function getZoneId(domain) {
456
- const { client } = getClient();
457
- const zones = await client.zones.list({ name: domain });
458
- const zone = zones.result?.[0];
459
- if (!zone) {
460
- const all = await listZones();
461
- const available = all.map((z) => z.name).join(", ");
462
- throw new Error(`Zone "${domain}" not found on this account. Available zones: ${available || "none"}`);
463
- }
464
- return zone.id;
465
- }
466
- export async function createZone(domain) {
467
- const { client, accountId } = getClient();
468
- // Idempotent — check if zone already exists on this account
469
- const existing = await client.zones.list({ name: domain });
470
- const match = existing.result?.[0];
471
- if (match) {
472
- return {
473
- id: match.id,
474
- name: match.name,
475
- status: match.status ?? "unknown",
476
- nameservers: match.name_servers ?? [],
477
- existing: true,
478
- };
479
- }
480
- const zone = await client.zones.create({
481
- name: domain,
482
- type: "full",
483
- account: { id: accountId },
484
- });
485
- return {
486
- id: zone.id,
487
- name: zone.name,
488
- status: zone.status ?? "pending",
489
- nameservers: zone.name_servers ?? [],
490
- existing: false,
491
- };
492
- }
493
- export async function createTunnel(name) {
494
- const { client, accountId } = getClient();
495
- // Check for existing tunnel with this name
496
- const existing = await client.zeroTrust.tunnels.cloudflared.list({
497
- account_id: accountId,
498
- name,
499
- is_deleted: false,
500
- });
501
- const match = existing.result?.find((t) => t.name === name);
502
- if (match?.id) {
503
- return { tunnelId: match.id, tunnelName: match.name ?? name };
504
- }
505
- // Create new tunnel — config_src: "cloudflare" means remotely-managed
506
- const tunnel = await client.zeroTrust.tunnels.cloudflared.create({
507
- account_id: accountId,
508
- name,
509
- config_src: "cloudflare",
510
- tunnel_secret: generateTunnelSecret(),
511
- });
512
- if (!tunnel.id) {
513
- throw new Error("Tunnel created but no ID returned from Cloudflare API");
514
- }
515
- return { tunnelId: tunnel.id, tunnelName: tunnel.name ?? name };
516
- }
517
- function generateTunnelSecret() {
518
- return randomBytes(32).toString("base64");
519
- }
520
- export async function listTunnelsOnAccount() {
521
- const { client, accountId } = getClient();
522
- const summaries = [];
523
- const result = await client.zeroTrust.tunnels.cloudflared.list({
524
- account_id: accountId,
525
- is_deleted: false,
526
- });
527
- for (const t of result.result ?? []) {
528
- if (!t.id || !t.name)
529
- continue;
530
- summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
531
- }
532
- return summaries;
533
- }
534
- /**
535
- * Find a zone whose existing CNAME records already point to the given tunnel.
536
- * Used when the user selects an existing tunnel — we reuse the zone context
537
- * rather than asking them to pick a zone again.
538
- *
539
- * Returns null when no zone routes to this tunnel. This is a legitimate
540
- * outcome (e.g. tunnel created but never routed) — caller must handle it
541
- * explicitly and fall back to zone selection, never substitute a default.
542
- */
543
- export async function findZoneHostingTunnel(tunnelId) {
544
- const { client } = getClient();
545
- const expectedTarget = `${tunnelId}.cfargotunnel.com`;
546
- const zones = await listZones();
547
- for (const zone of zones) {
548
- if (zone.status !== "active")
549
- continue;
550
- for await (const record of client.dns.records.list({
551
- zone_id: zone.id,
552
- type: "CNAME",
553
- })) {
554
- const content = record.content ?? "";
555
- if (content === expectedTarget)
556
- return zone.name;
557
- }
558
- }
559
- return null;
560
- }
561
- export async function configureTunnel(tunnelId, hostnames, port) {
562
- const { client, accountId } = getClient();
563
- const ingress = [
564
- ...hostnames.map((h) => ({ hostname: h, service: `http://localhost:${port}` })),
565
- { hostname: "", service: "http_status:404" },
566
- ];
567
- await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
568
- account_id: accountId,
569
- config: { ingress },
570
- });
571
- }
572
- export async function getTunnelIngress(tunnelId) {
573
- const { client, accountId } = getClient();
574
- const config = await client.zeroTrust.tunnels.cloudflared.configurations.get(tunnelId, { account_id: accountId });
575
- const ingress = config?.config?.ingress;
576
- if (!Array.isArray(ingress))
577
- return [];
578
- return ingress.map((r) => ({
579
- hostname: r.hostname ?? "",
580
- service: r.service ?? "",
581
- }));
582
- }
583
- export async function addHostnameToIngress(tunnelId, hostname, port) {
584
- const effectivePort = port ?? parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
585
- const rules = await getTunnelIngress(tunnelId);
586
- // Idempotent — skip if hostname already has an ingress rule
587
- if (rules.some((r) => r.hostname === hostname)) {
588
- return { added: false, alreadyPresent: true };
589
- }
590
- // Separate catch-all (hostname empty or missing) from named rules
591
- const namedRules = rules
592
- .filter((r) => r.hostname)
593
- .map((r) => ({ hostname: r.hostname, service: r.service }));
594
- // Rebuild: existing named rules + new rule + catch-all
595
- const { client, accountId } = getClient();
596
- await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
597
- account_id: accountId,
598
- config: {
599
- ingress: [
600
- ...namedRules,
601
- { hostname, service: `http://localhost:${effectivePort}` },
602
- { hostname: "", service: "http_status:404" },
603
- ],
604
- },
605
- });
606
- return { added: true, alreadyPresent: false };
607
- }
608
- // ---------------------------------------------------------------------------
609
- // Ingress port verification — read-modify-write (preserves all hostnames)
610
- //
611
- // Reads current ingress rules, checks if any named rule's service port
612
- // differs from expectedPort, and rewrites all rules with the correct port.
613
- // Preserves all hostnames including aliases — unlike configureTunnel()
614
- // which destructively sets only admin/public.
615
- // ---------------------------------------------------------------------------
616
- function extractPort(serviceUrl) {
617
- try {
618
- const url = new URL(serviceUrl);
619
- return url.port ? parseInt(url.port, 10) : null;
620
- }
621
- catch {
622
- return null;
623
- }
624
- }
625
- export async function fixIngressPort(tunnelId, expectedPort) {
626
- const rules = await getTunnelIngress(tunnelId);
627
- const namedRules = rules.filter((r) => r.hostname);
628
- if (namedRules.length === 0) {
629
- return { corrected: false };
630
- }
631
- // Check if any named rule has a mismatched port
632
- let mismatchPort = null;
633
- for (const rule of namedRules) {
634
- const port = extractPort(rule.service);
635
- if (port !== null && port !== expectedPort) {
636
- mismatchPort = port;
637
- break;
638
- }
639
- }
640
- if (mismatchPort === null) {
641
- return { corrected: false };
642
- }
643
- // Rewrite all named rules with the correct port, preserving hostnames
644
- const correctedRules = namedRules.map((r) => ({
645
- hostname: r.hostname,
646
- service: `http://localhost:${expectedPort}`,
647
- }));
648
- const { client, accountId } = getClient();
649
- await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
650
- account_id: accountId,
651
- config: {
652
- ingress: [
653
- ...correctedRules,
654
- { hostname: "", service: "http_status:404" },
655
- ],
656
- },
657
- });
658
- return { corrected: true, fromPort: mismatchPort };
659
- }
660
342
  // ---------------------------------------------------------------------------
661
343
  // Alias domain persistence — ~/{configDir}/alias-domains.json
662
344
  // ---------------------------------------------------------------------------
@@ -689,98 +371,13 @@ export function saveAliasDomain(hostname) {
689
371
  mkdirSync(dir, { recursive: true });
690
372
  writeFileSync(aliasDomainPath(), JSON.stringify([...existing], null, 2), "utf-8");
691
373
  }
692
- export async function getConnectorToken(tunnelId) {
693
- const { client, accountId } = getClient();
694
- const response = await client.zeroTrust.tunnels.cloudflared.token.get(tunnelId, { account_id: accountId });
695
- // Response is the token string directly
696
- const token = typeof response === "string" ? response : String(response);
697
- if (!token) {
698
- throw new Error("Received empty connector token from Cloudflare API");
699
- }
700
- return token;
701
- }
702
- /**
703
- * Check whether a hostname's CNAME points to a different tunnel.
704
- * Returns collision=true if the CNAME target is *.cfargotunnel.com but
705
- * for a different tunnel ID. Returns collision=false if:
706
- * - No CNAME exists (NXDOMAIN/ENODATA)
707
- * - CNAME points to the same tunnel (idempotent)
708
- * - DNS lookup fails (can't prove collision — proceed with caution)
709
- */
710
- export async function checkDnsCollision(hostname, tunnelId) {
711
- const dns = await import("node:dns/promises");
712
- const resolver = new dns.Resolver();
713
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
714
- try {
715
- const cnames = await resolver.resolveCname(hostname);
716
- if (cnames.length === 0) {
717
- return { hostname, collision: false, existingTunnelId: null, error: null };
718
- }
719
- const cname = cnames[0];
720
- const cfTunnelMatch = cname.match(/^([0-9a-f-]+)\.cfargotunnel\.com$/i);
721
- if (!cfTunnelMatch) {
722
- // CNAME exists but doesn't point to a Cloudflare tunnel — not our concern
723
- return { hostname, collision: false, existingTunnelId: null, error: null };
724
- }
725
- const existingTunnelId = cfTunnelMatch[1];
726
- if (existingTunnelId === tunnelId) {
727
- // Same tunnel — idempotent, no collision
728
- return { hostname, collision: false, existingTunnelId, error: null };
729
- }
730
- // Different tunnel — collision
731
- console.error(`[tunnel-create] collision hostname=${hostname} existingTunnel=${existingTunnelId} requestedTunnel=${tunnelId}`);
732
- return { hostname, collision: true, existingTunnelId, error: null };
733
- }
734
- catch (err) {
735
- const code = err.code;
736
- if (code === "ENODATA" || code === "ENOTFOUND") {
737
- // No CNAME record exists — safe to create
738
- return { hostname, collision: false, existingTunnelId: null, error: null };
739
- }
740
- // DNS lookup failed for other reasons — can't prove collision, proceed
741
- const msg = err instanceof Error ? err.message : String(err);
742
- console.error(`[tunnel-create] DNS collision check failed for ${hostname}: ${msg} — proceeding (cannot confirm collision)`);
743
- return { hostname, collision: false, existingTunnelId: null, error: msg };
744
- }
745
- }
746
- // ---------------------------------------------------------------------------
747
- // DNS record management via SDK
748
- // ---------------------------------------------------------------------------
749
- export async function createDnsRecord(zoneId, subdomain, tunnelId) {
750
- const { client } = getClient();
751
- const cnameTarget = `${tunnelId}.cfargotunnel.com`;
752
- // Check if record already exists
753
- const existing = await client.dns.records.list({
754
- zone_id: zoneId,
755
- name: { exact: subdomain },
756
- type: "CNAME",
757
- });
758
- if (existing.result?.length && existing.result.length > 0) {
759
- const record = existing.result[0];
760
- if (record.content === cnameTarget) {
761
- return { created: false, existing: true, updated: false };
762
- }
763
- // CNAME points to a different tunnel — refuse to overwrite (collision guard)
764
- const existingTarget = record.content ?? "(unknown)";
765
- throw new Error(`DNS collision: ${subdomain} already has a CNAME pointing to ${existingTarget}, which belongs to a different tunnel. ` +
766
- `Refusing to overwrite. To reclaim this hostname, delete the existing CNAME record first.`);
767
- }
768
- await client.dns.records.create({
769
- zone_id: zoneId,
770
- name: subdomain,
771
- type: "CNAME",
772
- content: cnameTarget,
773
- proxied: true,
774
- ttl: 1, // 1 = automatic
775
- });
776
- return { created: true, existing: false, updated: false };
777
- }
778
374
  // ---------------------------------------------------------------------------
779
375
  // CLI-based tunnel operations
780
376
  //
781
- // `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS management.
782
- // We use it (rather than the SDK) for tunnel-create / route-dns because the
783
- // cert-bound credential carries the Argo Tunnel permissions needed in one step.
377
+ // `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS routing.
378
+ // Shelling out to the CLI is architecturally indistinguishable from the
379
+ // operator clicking the same action in the dashboard both use the
380
+ // operator's OAuth-bound cert, nothing more.
784
381
  // ---------------------------------------------------------------------------
785
382
  export function createTunnelCli(name) {
786
383
  const bin = findBinary();
@@ -796,7 +393,6 @@ export function createTunnelCli(name) {
796
393
  const existing = tunnels.find((t) => t.name === name && !t.deleted_at);
797
394
  if (existing) {
798
395
  console.error(`[tunnel-create] CLI: tunnel "${name}" already exists (${existing.id})`);
799
- // Credentials file may exist at either location
800
396
  const brandCredPath = join(homedir(), loadBrand().configDir, "cloudflared", `${existing.id}.json`);
801
397
  const defaultCredPath = join(homedir(), ".cloudflared", `${existing.id}.json`);
802
398
  if (!existsSync(brandCredPath) && existsSync(defaultCredPath)) {
@@ -875,39 +471,119 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
875
471
  return configPath;
876
472
  }
877
473
  // ---------------------------------------------------------------------------
878
- // CLI-based DNS routing guarded by live account-zone check + post-flight
474
+ // Parse configured hostnames from config.yml
879
475
  //
880
- // `cloudflared tunnel route dns` resolves the target zone from cert.pem's
881
- // account. If the hostname's registrable parent zone is not on that account,
882
- // cloudflared silently writes a CNAME under a different zone (the original
883
- // joelsmalley.xyz wrong-routing bug). Defence in depth:
476
+ // `tunnel-status`'s end-to-end probe reads the hostnames from config.yml
477
+ // rather than from tunnel.state config.yml is the authoritative source
478
+ // for what `cloudflared` is actually ingressing. State-file hostnames may
479
+ // drift from on-disk config; the running tunnel only honours config.yml.
480
+ // ---------------------------------------------------------------------------
481
+ export function parseConfiguredHostnames(configYmlPath) {
482
+ try {
483
+ if (!existsSync(configYmlPath))
484
+ return [];
485
+ const yaml = readFileSync(configYmlPath, "utf-8");
486
+ const hostnames = [];
487
+ for (const line of yaml.split("\n")) {
488
+ const m = line.match(/^\s*-\s*hostname:\s*(\S+)\s*$/);
489
+ if (m)
490
+ hostnames.push(m[1]);
491
+ }
492
+ return hostnames;
493
+ }
494
+ catch (err) {
495
+ console.error(`[tunnel-status] failed to parse ${configYmlPath}: ${err}`);
496
+ return [];
497
+ }
498
+ }
499
+ // ---------------------------------------------------------------------------
500
+ // Nameserver pre-flight — zone-parent routability without any API
501
+ //
502
+ // Before shelling `cloudflared tunnel route dns`, confirm the hostname's
503
+ // registrable parent has its NS records pointing at Cloudflare. When they
504
+ // don't, `cloudflared` silently falls through to whichever zone the cert's
505
+ // account does own and creates a malformed CNAME (the historical
506
+ // `admin.maxy.bot.joelsmalley.xyz` failure mode). Refusing pre-flight is
507
+ // the only way to prevent that drift from inside this codebase.
508
+ //
509
+ // Heuristic for registrable parent: take the last two labels. This fails
510
+ // for multi-label public suffixes (e.g. `.co.uk`, `.com.au`) — not worth
511
+ // correctness here because Cloudflare's own sign-up flow rejects those
512
+ // at zone-add time, so the NS lookup will also fail for them, giving the
513
+ // same "not on Cloudflare yet" message.
514
+ // ---------------------------------------------------------------------------
515
+ function registrableParent(hostname) {
516
+ const labels = hostname.toLowerCase().split(".");
517
+ if (labels.length <= 2)
518
+ return hostname.toLowerCase();
519
+ return labels.slice(-2).join(".");
520
+ }
521
+ export async function checkZoneParentOnCloudflare(hostname) {
522
+ const zoneParent = registrableParent(hostname);
523
+ const resolver = new Resolver();
524
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
525
+ try {
526
+ const ns = await resolver.resolveNs(zoneParent);
527
+ const onCloudflare = ns.some((n) => /\.ns\.cloudflare\.com\.?$/i.test(n));
528
+ return { zoneParent, nameservers: ns, onCloudflare };
529
+ }
530
+ catch (err) {
531
+ const msg = err instanceof Error ? err.message : String(err);
532
+ return { zoneParent, nameservers: [], onCloudflare: false, error: msg };
533
+ }
534
+ }
535
+ // ---------------------------------------------------------------------------
536
+ // CLI-based DNS routing — shells `cloudflared tunnel route dns` with defence
537
+ // in depth against the known malformed-CNAME fall-through.
884
538
  //
885
- // (1) Live account-zone check: list the bound account's zones, refuse if
886
- // hostname's registrable parent is not active on that account.
887
- // (2) Post-flight FQDN assertion: the FQDN cloudflared wrote must equal
888
- // the requested hostname. Mismatch reverse-and-refuse.
539
+ // (1) Pre-flight: zone-parent NS records must point to Cloudflare.
540
+ // Refusal reason `hostname-zone-not-routable` tells the operator
541
+ // to add the domain in the dashboard before coming back.
542
+ // (2) Post-flight: the FQDN `cloudflared` wrote must equal the requested
543
+ // hostname. A mismatch means `cloudflared` routed to a zone owned by
544
+ // the currently-bound account instead of the intended zone — usually
545
+ // because the device signed into the wrong Cloudflare account.
546
+ // Refusal reason `post-flight-fqdn-mismatch` tells the operator to
547
+ // check the dashboard account name and re-login if wrong.
889
548
  //
890
- // `getClient()` runs first to enforce auth pre-conditions.
549
+ // Authentication pre-condition is just "cert.pem + binding + they agree" —
550
+ // enforced by validateAuth().bound. No API client is constructed anywhere.
891
551
  // ---------------------------------------------------------------------------
892
552
  export async function routeDnsCli(tunnelId, hostname) {
893
553
  const bin = findBinary();
894
554
  if (!bin)
895
555
  throw new Error("cloudflared is not installed");
896
- const { client, accountId: boundAccountId } = getClient();
897
- // (1) Live account-zone check.
898
- const accountZones = await listZones();
899
- const scope = matchAccountZone(hostname, accountZones);
900
- if (!scope.ok) {
556
+ const auth = validateAuth();
557
+ if (!auth.bound) {
558
+ const reason = auth.hasCert && auth.hasBinding
559
+ ? "account-drift"
560
+ : "unbound-device";
561
+ const detail = {
562
+ reason,
563
+ message: `This laptop is not signed into Cloudflare yet${auth.hasCert ? " (sign-in has drifted since it was set up)" : ""}. ` +
564
+ `${DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`,
565
+ fields: {
566
+ boundAccountId: auth.boundAccountId,
567
+ certAccountId: auth.certAccountId,
568
+ requestedHostname: hostname,
569
+ },
570
+ };
571
+ logRefuse(detail);
572
+ throw new CloudflareRefusalError(detail);
573
+ }
574
+ // (1) Pre-flight zone-parent routability.
575
+ console.error(`[cloudflare:tunnel-add-hostname:preflight] hostname=${hostname}`);
576
+ const preflight = await checkZoneParentOnCloudflare(hostname);
577
+ if (!preflight.onCloudflare) {
901
578
  const detail = {
902
- reason: "scope-mismatch",
903
- message: `Cannot route ${hostname} no active zone on the bound Cloudflare account ` +
904
- `owns this hostname's registrable parent. Active zones on this account: ` +
905
- `${scope.accountZones.join(", ") || "none"}. Add the parent zone to the account ` +
906
- `(cf-add-zone or via the Cloudflare dashboard), or pick a hostname under one ` +
907
- `of the existing zones.`,
579
+ reason: "hostname-zone-not-routable",
580
+ message: `The domain ${preflight.zoneParent} is not on Cloudflare yet this laptop cannot ` +
581
+ `reach it. Open Cloudflare in your browser and add ${preflight.zoneParent} under the ` +
582
+ `Cloudflare account you want to use (Websites Add a site). When the domain shows as ` +
583
+ `Active, tell me and I will add the address again.`,
908
584
  fields: {
909
585
  requestedHostname: hostname,
910
- accountZones: scope.accountZones,
586
+ requestedDomain: preflight.zoneParent,
911
587
  },
912
588
  };
913
589
  logRefuse(detail);
@@ -915,16 +591,21 @@ export async function routeDnsCli(tunnelId, hostname) {
915
591
  }
916
592
  const cert = findCert();
917
593
  if (!cert)
918
- throw new Error("No cert.pem found — getClient() should have refused first");
594
+ throw new Error("No cert.pem found — validateAuth() should have refused first");
919
595
  let output;
920
596
  try {
921
597
  output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
922
598
  }
923
599
  catch (err) {
924
- const msg = err instanceof Error ? err.stderr ?? err.message : String(err);
925
- if (typeof msg === "string" && msg.includes("already exists")) {
600
+ const errObj = err;
601
+ const rawStderr = errObj.stderr;
602
+ const stderrMsg = typeof rawStderr === "string"
603
+ ? rawStderr
604
+ : Buffer.isBuffer(rawStderr) ? rawStderr.toString("utf-8") : "";
605
+ const msg = stderrMsg || errObj.message || String(err);
606
+ if (msg.includes("already exists")) {
926
607
  console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
927
- return { created: false, output: msg, fqdn: hostname, zone: scope.matchedZone };
608
+ return { created: false, output: msg, fqdn: hostname };
928
609
  }
929
610
  throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
930
611
  }
@@ -932,74 +613,44 @@ export async function routeDnsCli(tunnelId, hostname) {
932
613
  const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
933
614
  if (!fqdnMatch) {
934
615
  console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
935
- return {
936
- created: true,
937
- output: output.trim(),
938
- fqdn: hostname,
939
- zone: scope.matchedZone,
940
- };
616
+ return { created: true, output: output.trim(), fqdn: hostname };
941
617
  }
942
618
  const actualFqdn = fqdnMatch[1];
943
619
  if (actualFqdn !== hostname) {
944
- // cloudflared wrote a CNAME under a different FQDN than requested
945
- // somehow the live-zone check passed but the routing landed elsewhere.
946
- // Log evidence, attempt to delete the wrong CNAME, refuse.
620
+ // cloudflared wrote the address under a different domain than requested.
621
+ // That means the laptop is signed into a Cloudflare account that does
622
+ // not own the target domain, and `cloudflared` fell through to whichever
623
+ // domain the signed-in account does own. The agent cannot clean this up
624
+ // (no API access by design) — the operator cleans it up in the dashboard.
947
625
  console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
948
626
  requestedHostname: hostname,
949
627
  actualFqdn,
950
628
  tunnelId,
951
- boundAccountId,
952
- })}`);
953
- let cleanupResult = "failed";
954
- try {
955
- const owningZone = accountZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
956
- if (owningZone) {
957
- const records = await client.dns.records.list({
958
- zone_id: owningZone.id,
959
- name: { exact: actualFqdn },
960
- type: "CNAME",
961
- });
962
- for (const r of records.result ?? []) {
963
- if (r.id)
964
- await client.dns.records.delete(r.id, { zone_id: owningZone.id });
965
- }
966
- cleanupResult = "ok";
967
- }
968
- }
969
- catch (cleanupErr) {
970
- console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
971
- }
972
- console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
973
- requestedHostname: hostname,
974
- actualFqdn,
975
- result: cleanupResult,
629
+ boundAccountId: auth.boundAccountId,
976
630
  })}`);
977
631
  const detail = {
978
632
  reason: "post-flight-fqdn-mismatch",
979
- message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
980
- `Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
633
+ message: `Cloudflare created the address under ${actualFqdn} instead of the requested ${hostname}. ` +
634
+ `This laptop is signed into a Cloudflare account that does not own ${registrableParent(hostname)}. ` +
635
+ `Two things to do: (a) open Cloudflare in your browser, sign into the account that does own ` +
636
+ `${registrableParent(hostname)}, and tell me so I can re-login; (b) in the same browser, delete the ` +
637
+ `stray record at ${actualFqdn} from the other account so it does not confuse anything.`,
981
638
  fields: {
982
639
  requestedHostname: hostname,
983
640
  actualFqdn,
984
641
  tunnelId,
985
- boundAccountId,
642
+ boundAccountId: auth.boundAccountId,
986
643
  },
987
644
  };
988
645
  logRefuse(detail);
989
646
  throw new CloudflareRefusalError(detail);
990
647
  }
991
- console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
992
- return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
648
+ console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn}`);
649
+ return { created: true, output: output.trim(), fqdn: actualFqdn };
993
650
  }
994
651
  // ---------------------------------------------------------------------------
995
652
  // tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
996
- //
997
- // Uses a log file fd (not pipes) for child stdio — eliminates SIGPIPE.
998
- // Polls the log file for the auth URL instead of streaming from pipes.
999
- // Tracks the login PID in a state file so subsequent calls detect an
1000
- // existing process instead of spawning duplicates.
1001
653
  // ---------------------------------------------------------------------------
1002
- // Login state under ~/{configDir}/cloudflared/ — brand-isolated like tunnel state.
1003
654
  function loginStatePath() {
1004
655
  return join(homedir(), loadBrand().configDir, "cloudflared/login.state");
1005
656
  }
@@ -1049,20 +700,15 @@ export function tunnelLogin() {
1049
700
  const bin = findBinary();
1050
701
  if (!bin)
1051
702
  throw new Error("cloudflared is not installed");
1052
- // Check for an existing login process
1053
703
  const existing = getActiveLogin();
1054
704
  if (existing) {
1055
705
  console.error(`[tunnel-login] login process already running (PID ${existing.pid}), returning existing auth URL`);
1056
706
  return Promise.resolve({ authUrl: existing.authUrl, alreadyRunning: true });
1057
707
  }
1058
- // Log to ~/{configDir}/logs/ — matches startTunnel() pattern
1059
708
  const logDir = join(homedir(), loadBrand().configDir, "logs");
1060
709
  mkdirSync(logDir, { recursive: true });
1061
710
  const logPath = join(logDir, "cloudflared-login.log");
1062
711
  const logFd = openSync(logPath, "a");
1063
- // Direct cert.pem output to the brand-specific cloudflared directory.
1064
- // Without --origincert, cloudflared writes to ~/.cloudflared/cert.pem —
1065
- // but hasCert()/parseCertPem() now look at ~/{configDir}/cloudflared/.
1066
712
  const certDir = join(homedir(), loadBrand().configDir, "cloudflared");
1067
713
  mkdirSync(certDir, { recursive: true });
1068
714
  const originCert = join(certDir, "cert.pem");
@@ -1076,7 +722,6 @@ export function tunnelLogin() {
1076
722
  throw new Error("Failed to spawn cloudflared tunnel login — no PID returned");
1077
723
  }
1078
724
  console.error(`[tunnel-login] spawned PID ${child.pid}`);
1079
- // Poll the log file for the auth URL
1080
725
  const urlPattern = /https:\/\/dash\.cloudflare\.com[^\s]*/;
1081
726
  return new Promise((resolve, reject) => {
1082
727
  let elapsed = 0;
@@ -1084,7 +729,6 @@ export function tunnelLogin() {
1084
729
  const timeout = 15000;
1085
730
  const poller = setInterval(() => {
1086
731
  elapsed += interval;
1087
- // Check if process died before producing a URL
1088
732
  if (!isProcessAlive(child.pid)) {
1089
733
  clearInterval(poller);
1090
734
  console.error(`[tunnel-login] PID ${child.pid} exited before producing auth URL`);
@@ -1098,7 +742,6 @@ export function tunnelLogin() {
1098
742
  }
1099
743
  return;
1100
744
  }
1101
- // Poll log file for the URL
1102
745
  try {
1103
746
  const content = readFileSync(logPath, "utf-8");
1104
747
  const match = content.match(urlPattern);
@@ -1140,11 +783,7 @@ export function tunnelLogin() {
1140
783
  }
1141
784
  // ---------------------------------------------------------------------------
1142
785
  // Process management — detached, survives MCP server exit
1143
- //
1144
- // Tunnel state stored at ~/{configDir}/cloudflared/tunnel.state so each
1145
- // brand manages its own tunnel independently on multi-brand devices.
1146
786
  // ---------------------------------------------------------------------------
1147
- // Function (not const) because loadBrand() requires PLATFORM_ROOT at runtime.
1148
787
  function statePath() {
1149
788
  return join(homedir(), loadBrand().configDir, "cloudflared/tunnel.state");
1150
789
  }
@@ -1184,10 +823,6 @@ export function getPersistedDomain() {
1184
823
  export function getPersistedState() {
1185
824
  return readState();
1186
825
  }
1187
- /**
1188
- * Save tunnel identity to state file (pid/startedAt null until tunnel-enable).
1189
- * Called by tunnel-create so tunnel-enable can find configPath and credentialsPath.
1190
- */
1191
826
  export function saveTunnelIdentity(params) {
1192
827
  writeState({
1193
828
  tunnelId: params.tunnelId,
@@ -1202,32 +837,162 @@ export function saveTunnelIdentity(params) {
1202
837
  });
1203
838
  }
1204
839
  /**
1205
- * Return the actual hostnames from persisted state.
1206
- * Backward compat: old state files without hostname fields derive from domain.
840
+ * Return the actual hostnames from persisted state (or derive from domain
841
+ * for pre-521 state files).
1207
842
  */
1208
843
  export function getPersistedHostnames() {
1209
844
  const state = readState();
1210
845
  if (!state)
1211
846
  return [];
1212
- // New state files have explicit hostname fields
1213
847
  if (state.adminHostname) {
1214
848
  return state.publicHostname
1215
849
  ? [state.adminHostname, state.publicHostname]
1216
850
  : [state.adminHostname];
1217
851
  }
1218
- // Backward compat: pre-521 state files without hostname fields
1219
852
  if (state.domain) {
1220
853
  console.error(`[tunnel] backward-compat: deriving hostnames from domain=${state.domain} (no adminHostname in state)`);
1221
854
  return [`admin.${state.domain}`, `public.${state.domain}`];
1222
855
  }
1223
856
  return [];
1224
857
  }
1225
- export function getStatus(domain) {
858
+ export async function probeHostname(hostname, expectedTunnelId) {
859
+ const resolver = new Resolver();
860
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
861
+ let cname = null;
862
+ try {
863
+ const cnames = await resolver.resolveCname(hostname);
864
+ cname = cnames[0] ?? null;
865
+ }
866
+ catch (err) {
867
+ const code = err.code;
868
+ if (code === "ENODATA") {
869
+ // Proxied records can flatten — try A records as a secondary signal
870
+ // that the name exists, but without a CNAME we cannot confirm this
871
+ // tunnel. Treat as cname-points-elsewhere (name exists but does not
872
+ // point here) only when an A record is present; otherwise dns-missing.
873
+ try {
874
+ await resolver.resolve4(hostname);
875
+ cname = null; // flattened — still need edge probe to confirm
876
+ }
877
+ catch {
878
+ return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
879
+ }
880
+ }
881
+ else if (code === "ENOTFOUND" || code === "ESERVFAIL") {
882
+ return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
883
+ }
884
+ else {
885
+ console.error(`[cloudflare:tunnel-status:probe] unexpected DNS error for ${hostname}: ${err}`);
886
+ return { hostname, resolves: false, cname: null, httpStatus: null, failureMode: "dns-missing" };
887
+ }
888
+ }
889
+ if (expectedTunnelId && cname) {
890
+ const cnameLc = cname.toLowerCase().replace(/\.$/, "");
891
+ const expected = `${expectedTunnelId}.cfargotunnel.com`.toLowerCase();
892
+ if (cnameLc !== expected) {
893
+ return { hostname, resolves: true, cname, httpStatus: null, failureMode: "cname-points-elsewhere" };
894
+ }
895
+ }
896
+ // HTTPS probe — any response proves edge routing is wired up.
897
+ let httpStatus = null;
898
+ try {
899
+ const controller = new AbortController();
900
+ const to = setTimeout(() => controller.abort(), 10000);
901
+ try {
902
+ const res = await fetch(`https://${hostname}/`, {
903
+ method: "GET",
904
+ redirect: "manual",
905
+ signal: controller.signal,
906
+ });
907
+ httpStatus = res.status;
908
+ }
909
+ finally {
910
+ clearTimeout(to);
911
+ }
912
+ }
913
+ catch (err) {
914
+ console.error(`[cloudflare:tunnel-status:probe] HTTPS probe failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
915
+ return { hostname, resolves: true, cname, httpStatus: null, failureMode: "edge-unreachable" };
916
+ }
917
+ // Cloudflare edge error page status codes that indicate the tunnel is
918
+ // either not connected (530) or the hostname is not served by any tunnel
919
+ // (1033/1016 surface as 530 or 502 at the HTTP layer; Cloudflare sometimes
920
+ // uses 502/503 for unreachable origins). Any 5xx from the edge with
921
+ // missing CNAME match means the tunnel-edge wiring is wrong.
922
+ if (httpStatus >= 500 && httpStatus < 600) {
923
+ return { hostname, resolves: true, cname, httpStatus, failureMode: "tunnel-not-matched" };
924
+ }
925
+ return { hostname, resolves: true, cname, httpStatus, failureMode: "ok" };
926
+ }
927
+ export async function getStatus(domain) {
1226
928
  const auth = validateAuth();
1227
929
  const state = readState();
1228
930
  const running = state !== null && isProcessAlive(state.pid);
1229
931
  const effectiveDomain = domain ?? state?.domain ?? null;
1230
- const hostnames = getPersistedHostnames();
932
+ const persistedHostnames = getPersistedHostnames();
933
+ const configuredHostnames = state?.configPath
934
+ ? parseConfiguredHostnames(state.configPath)
935
+ : [];
936
+ // Probe the authoritative set (config.yml). If config.yml is absent, fall
937
+ // back to persisted hostnames so partial-setup states still surface.
938
+ const hostnamesToProbe = configuredHostnames.length > 0
939
+ ? configuredHostnames
940
+ : persistedHostnames;
941
+ let probes = [];
942
+ let boundAccountOwnsHostnames = false;
943
+ let healthy = false;
944
+ let unhealthyReason = null;
945
+ if (!running || !state?.tunnelId || hostnamesToProbe.length === 0) {
946
+ unhealthyReason = !running
947
+ ? "not-running"
948
+ : "no-tunnel-configured";
949
+ }
950
+ else {
951
+ probes = await Promise.all(hostnamesToProbe.map((h) => probeHostname(h, state.tunnelId)));
952
+ // A probe reaching the edge with a non-5xx response proves the signed-in
953
+ // account owns the domain: the edge only routes to this tunnel's origin
954
+ // when the domain's DNS on this account points at this tunnel. The CNAME
955
+ // check inside probeHostname already short-circuits the "points to a
956
+ // different tunnel" case to `cname-points-elsewhere` — so here we trust
957
+ // `ok`. We do not gate on `p.cname !== null` because Cloudflare can flatten
958
+ // CNAMEs (zone-apex records, some proxy configurations) — in that case
959
+ // cname is null but the HTTPS response is still authoritative.
960
+ boundAccountOwnsHostnames = probes.some((p) => p.failureMode === "ok");
961
+ healthy = probes.every((p) => p.failureMode === "ok");
962
+ if (!healthy) {
963
+ // Distinguishing "wrong account" from "right account, tunnel broken":
964
+ // a probe that resolved DNS at all is evidence the domain lives on
965
+ // some Cloudflare account (the NS is Cloudflare's). A probe that
966
+ // resolved AND doesn't point elsewhere is evidence this laptop's
967
+ // account owns it — any other failure mode (edge-unreachable,
968
+ // tunnel-not-matched, or a healthy flattened probe) indicates the
969
+ // DNS points at *something* on the signed-in account, just not
970
+ // working. Only when every probe is `dns-missing` or
971
+ // `cname-points-elsewhere` do we conclude the bound account does
972
+ // not own the hostnames.
973
+ const anyCnamePointsHere = probes.some((p) => p.resolves &&
974
+ p.failureMode !== "cname-points-elsewhere" &&
975
+ p.failureMode !== "dns-missing");
976
+ unhealthyReason = anyCnamePointsHere
977
+ ? "hostname-probes-failed"
978
+ : "bound-account-does-not-own-hostname";
979
+ }
980
+ }
981
+ if (unhealthyReason === "bound-account-does-not-own-hostname") {
982
+ // Surface the structured refusal reason so the review-detector rule
983
+ // fires on the next admin turn — same channel the agent-facing text
984
+ // uses, but keyed on the enum.
985
+ logRefuse({
986
+ reason: "bound-account-does-not-own-hostname",
987
+ message: "tunnel-status probe: no configured hostname's DNS lands on this tunnel. " +
988
+ `${DASHBOARD_SWITCH_ACCOUNTS_INSTRUCTION}`,
989
+ fields: {
990
+ boundAccountId: auth.boundAccountId,
991
+ certAccountId: auth.certAccountId,
992
+ tunnelId: state?.tunnelId ?? undefined,
993
+ },
994
+ });
995
+ }
1231
996
  return {
1232
997
  installed: isInstalled(),
1233
998
  version: version(),
@@ -1240,11 +1005,15 @@ export function getStatus(domain) {
1240
1005
  pid: running ? state.pid : null,
1241
1006
  tunnelId: state?.tunnelId ?? null,
1242
1007
  domain: effectiveDomain,
1243
- hostnames,
1008
+ configuredHostnames,
1009
+ persistedHostnames,
1244
1010
  adminHostname: state?.adminHostname ?? (effectiveDomain ? `admin.${effectiveDomain}` : null),
1245
1011
  publicHostname: state?.publicHostname ?? (effectiveDomain ? `public.${effectiveDomain}` : null),
1246
1012
  upSince: running ? state.startedAt : null,
1247
- restartCount: 0,
1013
+ probes,
1014
+ boundAccountOwnsHostnames,
1015
+ healthy,
1016
+ unhealthyReason,
1248
1017
  };
1249
1018
  }
1250
1019
  export function startTunnel(params) {
@@ -1254,17 +1023,14 @@ export function startTunnel(params) {
1254
1023
  const cert = findCert();
1255
1024
  if (!cert)
1256
1025
  throw new Error("No cert.pem found — run tunnel-login first");
1257
- // Check if already running via state file
1258
1026
  const state = readState();
1259
1027
  if (state && isProcessAlive(state.pid)) {
1260
1028
  throw new Error(`Tunnel is already running (PID ${state.pid})`);
1261
1029
  }
1262
- // Log to ~/{configDir}/logs/ so crashes are diagnosable
1263
1030
  const logDir = join(homedir(), loadBrand().configDir, "logs");
1264
1031
  mkdirSync(logDir, { recursive: true });
1265
1032
  const logPath = join(logDir, "cloudflared.log");
1266
1033
  const logFd = openSync(logPath, "a");
1267
- // Single deterministic command: config.yml + cert.pem
1268
1034
  const child = spawn(bin, ["--origincert", cert, "--config", params.configPath, "tunnel", "run"], {
1269
1035
  stdio: ["ignore", logFd, logFd],
1270
1036
  detached: true,
@@ -1273,7 +1039,6 @@ export function startTunnel(params) {
1273
1039
  if (!child.pid)
1274
1040
  throw new Error("Failed to spawn tunnel process");
1275
1041
  console.error(`[tunnel-enable] started PID ${child.pid} with config ${params.configPath}`);
1276
- // Preserve hostname fields from existing state when starting the tunnel
1277
1042
  const existingState = readState();
1278
1043
  writeState({
1279
1044
  pid: child.pid,
@@ -1299,7 +1064,6 @@ export function stopTunnel() {
1299
1064
  catch {
1300
1065
  // process may have exited between check and kill
1301
1066
  }
1302
- // Force kill after 5 seconds
1303
1067
  setTimeout(() => {
1304
1068
  if (isProcessAlive(pid)) {
1305
1069
  try {
@@ -1311,355 +1075,6 @@ export function stopTunnel() {
1311
1075
  }
1312
1076
  }, 5000);
1313
1077
  }
1314
- // Preserve tunnel identity, clear process lifecycle
1315
1078
  writeState({ ...state, pid: null, startedAt: null });
1316
1079
  }
1317
- /**
1318
- * cf-verify: read everything, tag nothing as good or bad. The bound
1319
- * Cloudflare account is the universe; the agent decides what is pollution
1320
- * from the operator's stated intent in the current conversation.
1321
- *
1322
- * The `pollution` field returned here is the default no-intent view —
1323
- * account artefacts the device's persisted `tunnel.state` + `alias-domains.json`
1324
- * don't reference. The orchestrator recomputes against explicit intent
1325
- * when the operator picks a zone.
1326
- */
1327
- export async function cfVerifyCore() {
1328
- const brand = loadBrand();
1329
- console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
1330
- const device = readDeviceSnapshot();
1331
- const ro = getReadOnlyClient();
1332
- let account = null;
1333
- if (ro.client && ro.certAccountId) {
1334
- try {
1335
- const [zones, tunnels] = await Promise.all([
1336
- listZonesViaClient(ro.client),
1337
- listTunnelsViaClient(ro.client, ro.certAccountId),
1338
- ]);
1339
- const cnames = [];
1340
- for (const z of zones) {
1341
- if (z.status !== "active")
1342
- continue;
1343
- try {
1344
- const recs = await listCnamesUnderZone(ro.client, z.id);
1345
- for (const r of recs) {
1346
- cnames.push({ zone: z.name, recordId: r.id, name: r.name, content: r.content });
1347
- }
1348
- }
1349
- catch (err) {
1350
- console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
1351
- }
1352
- }
1353
- account = {
1354
- accountId: ro.certAccountId,
1355
- zones: zones.map((z) => ({ id: z.id, name: z.name, status: z.status })),
1356
- tunnels: tunnels.map((t) => ({ id: t.id, name: t.name })),
1357
- cnames,
1358
- };
1359
- }
1360
- catch (err) {
1361
- console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
1362
- }
1363
- }
1364
- const pollution = computePollution(account, device);
1365
- console.error(`[cloudflare:verify-complete] ${JSON.stringify({
1366
- brand: brand.productName,
1367
- accountZones: account?.zones.length ?? 0,
1368
- accountTunnels: account?.tunnels.length ?? 0,
1369
- accountCnames: account?.cnames.length ?? 0,
1370
- pollutionTunnels: pollution.tunnels.length,
1371
- pollutionCnames: pollution.cnames.length,
1372
- pollutionZones: pollution.zones.length,
1373
- })}`);
1374
- return { brand: brand.productName, device, account, pollution };
1375
- }
1376
- function readDeviceSnapshot() {
1377
- const auth = validateAuth();
1378
- const state = readState();
1379
- const aliases = [...loadAliasDomains()];
1380
- return {
1381
- certPath: certPath(),
1382
- certPresent: auth.hasCert,
1383
- bindingPath: bindingFile(),
1384
- bindingPresent: auth.hasBinding,
1385
- bindingMatchesCert: auth.bound,
1386
- certAccountId: auth.certAccountId,
1387
- boundAccountId: auth.boundAccountId,
1388
- tunnelStatePath: statePath(),
1389
- tunnelState: state,
1390
- configYmlPath: state?.configPath ?? null,
1391
- configYmlPresent: !!(state?.configPath && existsSync(state.configPath)),
1392
- aliasDomains: aliases,
1393
- };
1394
- }
1395
- function computePollution(account, device) {
1396
- if (!account)
1397
- return { tunnels: [], cnames: [], zones: [] };
1398
- const intendedTunnelId = device.tunnelState?.tunnelId ?? null;
1399
- const intendedHostnames = new Set();
1400
- if (device.tunnelState?.adminHostname)
1401
- intendedHostnames.add(device.tunnelState.adminHostname.toLowerCase());
1402
- if (device.tunnelState?.publicHostname)
1403
- intendedHostnames.add(device.tunnelState.publicHostname.toLowerCase());
1404
- for (const a of device.aliasDomains)
1405
- intendedHostnames.add(a.toLowerCase());
1406
- // Zones the intended hostnames live under — those are "in use".
1407
- const inUseZones = new Set();
1408
- for (const h of intendedHostnames) {
1409
- for (const z of account.zones) {
1410
- if (h === z.name.toLowerCase() || h.endsWith("." + z.name.toLowerCase())) {
1411
- inUseZones.add(z.name.toLowerCase());
1412
- break;
1413
- }
1414
- }
1415
- }
1416
- const pollutionTunnels = account.tunnels.filter((t) => t.id !== intendedTunnelId);
1417
- const pollutionCnames = account.cnames.filter((c) => !intendedHostnames.has(c.name.toLowerCase()));
1418
- const pollutionZones = account.zones.filter((z) => !inUseZones.has(z.name.toLowerCase()));
1419
- return { tunnels: pollutionTunnels, cnames: pollutionCnames, zones: pollutionZones };
1420
- }
1421
- async function listZonesViaClient(client) {
1422
- const zones = [];
1423
- for await (const zone of client.zones.list()) {
1424
- zones.push({
1425
- id: zone.id,
1426
- name: zone.name,
1427
- status: zone.status ?? "unknown",
1428
- nameservers: zone.name_servers ?? [],
1429
- account: { id: zone.account.id ?? "", name: zone.account.name ?? "" },
1430
- });
1431
- }
1432
- return zones;
1433
- }
1434
- async function listTunnelsViaClient(client, accountId) {
1435
- const summaries = [];
1436
- const result = await client.zeroTrust.tunnels.cloudflared.list({
1437
- account_id: accountId,
1438
- is_deleted: false,
1439
- });
1440
- for (const t of result.result ?? []) {
1441
- if (!t.id || !t.name)
1442
- continue;
1443
- summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
1444
- }
1445
- return summaries;
1446
- }
1447
- async function listCnamesUnderZone(client, zoneId) {
1448
- const out = [];
1449
- for await (const rec of client.dns.records.list({ zone_id: zoneId, type: "CNAME" })) {
1450
- if (!rec.id || !rec.name)
1451
- continue;
1452
- out.push({ id: rec.id, name: rec.name, content: rec.content ?? "" });
1453
- }
1454
- return out;
1455
- }
1456
- export async function cfRebuildCore(opts = {}) {
1457
- const dryRun = opts.dryRun ?? false;
1458
- const brand = loadBrand();
1459
- console.error(`[cloudflare:rebuild-start] ${JSON.stringify({ brand: brand.productName, dryRun })}`);
1460
- // Auth gate. We need a client to mutate the account.
1461
- let client;
1462
- let accountId;
1463
- try {
1464
- const c = getClient();
1465
- client = c.client;
1466
- accountId = c.accountId;
1467
- }
1468
- catch (err) {
1469
- if (err instanceof CloudflareRefusalError) {
1470
- return {
1471
- brand: brand.productName,
1472
- dryRun,
1473
- preserve: { zones: [], tunnelIds: [], cnames: null },
1474
- actions: [],
1475
- halted: true,
1476
- haltReason: err.refusal.message,
1477
- };
1478
- }
1479
- throw err;
1480
- }
1481
- // Snapshot the account before mutating.
1482
- const verify = await cfVerifyCore();
1483
- if (!verify.account) {
1484
- return {
1485
- brand: brand.productName,
1486
- dryRun,
1487
- preserve: { zones: [], tunnelIds: [], cnames: null },
1488
- actions: [],
1489
- halted: true,
1490
- haltReason: "Could not read account state (cf-verify returned no account snapshot).",
1491
- };
1492
- }
1493
- // No-intent refusal. If the caller passed no preserve AND the device
1494
- // has no persisted intent (no tunnel.state, no alias domains), refuse
1495
- // rather than guess — an empty preserve set would nuke the account.
1496
- // An explicit `{ preserve: { zones: [], tunnelIds: [] } }` is valid
1497
- // stated intent ("nuke everything") and proceeds.
1498
- const deviceHasIntent = Boolean(verify.device.tunnelState || verify.device.aliasDomains.length > 0);
1499
- if (opts.preserve === undefined && !deviceHasIntent) {
1500
- logRefuse({
1501
- reason: "no-intent",
1502
- message: "cf-rebuild refused: no `preserve` was supplied and this device has no persisted tunnel.state or alias-domains. " +
1503
- "Stating explicit intent is required before destructive operations — " +
1504
- "pass `preserve: { zones: [...], tunnelIds: [...] }` (an empty array is a valid 'nuke everything' intent), " +
1505
- "or use `cloudflare-setup` which derives intent from the conversation.",
1506
- });
1507
- return {
1508
- brand: brand.productName,
1509
- dryRun,
1510
- preserve: { zones: [], tunnelIds: [], cnames: null },
1511
- actions: [],
1512
- halted: true,
1513
- haltReason: "no-intent",
1514
- };
1515
- }
1516
- // Resolve the preserve set. Defaults from the device's intended state.
1517
- const intendedTunnelId = verify.device.tunnelState?.tunnelId ?? null;
1518
- const intendedHostnames = [];
1519
- if (verify.device.tunnelState?.adminHostname)
1520
- intendedHostnames.push(verify.device.tunnelState.adminHostname);
1521
- if (verify.device.tunnelState?.publicHostname)
1522
- intendedHostnames.push(verify.device.tunnelState.publicHostname);
1523
- for (const a of verify.device.aliasDomains)
1524
- intendedHostnames.push(a);
1525
- const inferredZones = new Set();
1526
- for (const h of intendedHostnames) {
1527
- const lc = h.toLowerCase();
1528
- for (const z of verify.account.zones) {
1529
- if (lc === z.name.toLowerCase() || lc.endsWith("." + z.name.toLowerCase())) {
1530
- inferredZones.add(z.name);
1531
- break;
1532
- }
1533
- }
1534
- }
1535
- const preserveZones = (opts.preserve?.zones ?? [...inferredZones]).map((s) => s.toLowerCase());
1536
- const preserveTunnelIds = opts.preserve?.tunnelIds ?? (intendedTunnelId ? [intendedTunnelId] : []);
1537
- const preserveCnames = opts.preserve?.cnames
1538
- ? opts.preserve.cnames.map((c) => ({ zone: c.zone.toLowerCase(), name: c.name.toLowerCase() }))
1539
- : null;
1540
- // Identify tunnels that will be deleted — their `.cfargotunnel.com`
1541
- // CNAMEs on preserved zones must also be scrubbed (else the zone
1542
- // keeps a stale pointer to a dead tunnel).
1543
- const deletedTunnelIds = new Set(verify.account.tunnels.filter((t) => !preserveTunnelIds.includes(t.id)).map((t) => t.id));
1544
- const isStaleTunnelPointer = (content) => {
1545
- const lc = content.toLowerCase().trim();
1546
- const m = lc.match(/^([0-9a-f-]{36})\.cfargotunnel\.com\.?$/);
1547
- return m ? deletedTunnelIds.has(m[1]) : false;
1548
- };
1549
- const actions = [];
1550
- // (a) Delete tunnels not in preserve.
1551
- for (const t of verify.account.tunnels) {
1552
- if (preserveTunnelIds.includes(t.id))
1553
- continue;
1554
- const action = {
1555
- op: "delete-tunnel",
1556
- type: "tunnel",
1557
- id: t.id,
1558
- name: t.name,
1559
- result: dryRun ? "planned" : "ok",
1560
- };
1561
- if (!dryRun) {
1562
- try {
1563
- await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
1564
- }
1565
- catch (err) {
1566
- action.result = "failed";
1567
- action.detail = err instanceof Error ? err.message : String(err);
1568
- }
1569
- }
1570
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "tunnel", id: t.id, name: t.name, planned: dryRun, result: action.result })}`);
1571
- actions.push(action);
1572
- }
1573
- // (b) Delete CNAMEs not in preserve.
1574
- // If preserve.cnames is set, ONLY those exact records survive.
1575
- // Otherwise: CNAMEs on preserved zones survive, EXCEPT those pointing
1576
- // to a `<tunnelId>.cfargotunnel.com` target where the tunnel is being
1577
- // deleted (stale tunnel pointers on otherwise-preserved zones would
1578
- // resolve to dead DNS — always scrub).
1579
- for (const c of verify.account.cnames) {
1580
- const zoneLc = c.zone.toLowerCase();
1581
- const nameLc = c.name.toLowerCase();
1582
- let keep = false;
1583
- if (preserveCnames) {
1584
- keep = preserveCnames.some((p) => p.zone === zoneLc && p.name === nameLc);
1585
- }
1586
- else {
1587
- keep = preserveZones.includes(zoneLc) && !isStaleTunnelPointer(c.content);
1588
- }
1589
- if (keep)
1590
- continue;
1591
- const action = {
1592
- op: "delete-cname",
1593
- type: "cname",
1594
- id: c.recordId,
1595
- name: c.name,
1596
- result: dryRun ? "planned" : "ok",
1597
- detail: `${c.name} → ${c.content} under zone ${c.zone}`,
1598
- };
1599
- if (!dryRun) {
1600
- const zoneRec = verify.account.zones.find((z) => z.name.toLowerCase() === zoneLc);
1601
- if (!zoneRec) {
1602
- action.result = "failed";
1603
- action.detail = `zone ${c.zone} not found in snapshot`;
1604
- }
1605
- else {
1606
- try {
1607
- await client.dns.records.delete(c.recordId, { zone_id: zoneRec.id });
1608
- }
1609
- catch (err) {
1610
- action.result = "failed";
1611
- action.detail = err instanceof Error ? err.message : String(err);
1612
- }
1613
- }
1614
- }
1615
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
1616
- actions.push(action);
1617
- }
1618
- // (c) Delete zones not in preserve.
1619
- for (const z of verify.account.zones) {
1620
- if (preserveZones.includes(z.name.toLowerCase()))
1621
- continue;
1622
- const action = {
1623
- op: "delete-zone",
1624
- type: "zone",
1625
- id: z.id,
1626
- name: z.name,
1627
- result: dryRun ? "planned" : "ok",
1628
- };
1629
- if (!dryRun) {
1630
- try {
1631
- await client.zones.delete({ zone_id: z.id });
1632
- }
1633
- catch (err) {
1634
- // Zone deletion may fail (permissions, registrar lock). Surface but
1635
- // do not halt — orphan zones are informational, not blocking.
1636
- action.result = "failed";
1637
- action.detail = err instanceof Error ? err.message : String(err);
1638
- }
1639
- }
1640
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "zone", id: z.id, name: z.name, planned: dryRun, result: action.result })}`);
1641
- actions.push(action);
1642
- }
1643
- let finalVerify;
1644
- if (!dryRun) {
1645
- finalVerify = await cfVerifyCore();
1646
- }
1647
- console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
1648
- brand: brand.productName,
1649
- dryRun,
1650
- actionCount: actions.length,
1651
- })}`);
1652
- return {
1653
- brand: brand.productName,
1654
- dryRun,
1655
- preserve: {
1656
- zones: preserveZones,
1657
- tunnelIds: preserveTunnelIds,
1658
- cnames: preserveCnames,
1659
- },
1660
- actions,
1661
- finalVerify,
1662
- halted: false,
1663
- };
1664
- }
1665
1080
  //# sourceMappingURL=cloudflared.js.map