@rubytech/create-maxy 1.0.619 → 1.0.621

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 (23) 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/skills/onboarding/SKILL.md +11 -7
  7. package/payload/platform/plugins/cloudflare/PLUGIN.md +10 -13
  8. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +181 -1033
  9. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  10. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +117 -261
  11. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  12. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +379 -903
  13. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  14. package/payload/platform/plugins/cloudflare/mcp/package.json +3 -7
  15. package/payload/platform/plugins/cloudflare/references/setup-guide.md +70 -76
  16. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +34 -82
  17. package/payload/platform/plugins/docs/PLUGIN.md +1 -1
  18. package/payload/platform/plugins/docs/references/cloudflare.md +21 -30
  19. package/payload/platform/templates/agents/admin/IDENTITY.md +8 -0
  20. package/payload/platform/templates/agents/public/IDENTITY.md +8 -0
  21. package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
  22. package/payload/server/server.js +85 -9
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -195
@@ -3,21 +3,82 @@ initStderrTee("cloudflare");
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
- import { join } from "node:path";
7
- import { hostname, homedir } from "node:os";
8
- import { existsSync } from "node:fs";
6
+ import { Resolver } from "node:dns/promises";
9
7
  import * as cloudflared from "./lib/cloudflared.js";
10
8
  const server = new McpServer({
11
9
  name: "cloudflare",
12
- version: "0.2.0",
10
+ version: "0.3.0",
13
11
  });
12
+ // ---------------------------------------------------------------------------
13
+ // tunnel-status trailing guidance — deterministic 1:1 mapping from status
14
+ // enum → single prescribed recovery sentence.
15
+ //
16
+ // The trailing string is a prompt for the agent's next turn. Task 541 closed
17
+ // the recurrence loop where a "check in the browser" sentence primed a 44-
18
+ // tool-call dashboard cascade. The mapping here is exhaustive for every
19
+ // reachable `unhealthyReason`; there is no generic fallback branch. Each
20
+ // sentence names exactly one next action so the agent cannot fork on it.
21
+ // ---------------------------------------------------------------------------
22
+ const BOUND_ACCOUNT_MISMATCH_SENTENCE = (hostname) => `Sign in to Cloudflare at dash.cloudflare.com in the account that owns ${hostname} — ` +
23
+ `the account with the little orange cloud next to it in your browser tab — then tell me ` +
24
+ `and I'll restart the tunnel login.`;
25
+ function buildTunnelStatusGuidance(status) {
26
+ if (!status.unhealthyReason)
27
+ return "";
28
+ if (status.unhealthyReason === "not-running") {
29
+ return `\n\nThe tunnel process is not running. Ask the user if they want me to enable it.`;
30
+ }
31
+ if (status.unhealthyReason === "no-tunnel-configured") {
32
+ return `\n\nNo tunnel is configured on this laptop yet. Run tunnel-create to set one up.`;
33
+ }
34
+ if (status.unhealthyReason === "bound-account-does-not-own-hostname") {
35
+ // Use the first configured hostname as the anchor — matches how the
36
+ // operator thinks about the setup. If configuredHostnames is empty
37
+ // (unreachable here because the enum is only set when probes ran)
38
+ // fall back to domain.
39
+ const anchor = status.configuredHostnames[0] ??
40
+ status.persistedHostnames[0] ??
41
+ status.domain ??
42
+ "your domain";
43
+ return `\n\n${BOUND_ACCOUNT_MISMATCH_SENTENCE(anchor)}`;
44
+ }
45
+ if (status.unhealthyReason === "hostname-probes-failed") {
46
+ // Dominant per-hostname mode drives the sentence. All failing probes
47
+ // share one mode in the expected failure classes — when they diverge,
48
+ // the first failure wins (the per-hostname list is already printed in
49
+ // the JSON above; the sentence names the one action the agent takes).
50
+ const firstFailure = status.probes.find((p) => p.failureMode !== "ok");
51
+ const mode = firstFailure?.failureMode ?? "edge-unreachable";
52
+ if (mode === "tunnel-not-matched" || mode === "cname-points-elsewhere") {
53
+ return `\n\nDNS is pointing at a different tunnel. I'll re-point it.`;
54
+ }
55
+ if (mode === "dns-missing") {
56
+ return `\n\nDNS is not set up yet. I'll create it.`;
57
+ }
58
+ // edge-unreachable — transient Cloudflare edge or connectivity; the
59
+ // agent should not immediately re-probe or open the dashboard.
60
+ return `\n\nThe tunnel is not reachable through Cloudflare right now. This usually clears in a minute.`;
61
+ }
62
+ // Exhaustiveness guard — a new enum value must be added to this switch
63
+ // before it ever appears in a tool result. Returning empty string would
64
+ // let a novel failure mode silently emit JSON-only output. The bare
65
+ // assignment (no `as never` cast) is the point: TypeScript narrows
66
+ // `status.unhealthyReason` to `never` after every branch above returns,
67
+ // so a new `TunnelUnhealthyReason` value introduced without a branch
68
+ // here fails at compile time.
69
+ const _exhaustive = status.unhealthyReason;
70
+ console.error(`[cloudflare:tunnel-status:unknown-reason] reason=${String(_exhaustive)}`);
71
+ return "";
72
+ }
14
73
  // ===================================================================
15
- // Authentication — cert.pem only
74
+ // Authentication — OAuth cert.pem only
16
75
  //
17
- // The plugin recognises exactly one CF account identity: the one
18
- // cert.pem was bound to via OAuth. `tunnel-login` is the only auth
19
- // path; the binding written on success is the device's record of
20
- // which account it currently speaks to.
76
+ // The plugin recognises exactly one identity: the Cloudflare account
77
+ // cert.pem was bound to via `cloudflared tunnel login`. No API token
78
+ // handling, no SDK, no read of account state by any code path. The
79
+ // operator's logged-in Cloudflare dashboard session is the source of
80
+ // truth for which domains exist and who owns them; the agent's role
81
+ // is to instruct the operator where to click.
21
82
  // ===================================================================
22
83
  server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if cert.pem exists and matches the recorded account binding, reports status; (2) if cert.pem exists but no binding has been recorded, materializes the binding from the cert (migration path); (3) if no cert.pem, spawns login process and returns the auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete cert.pem (both brand-specific and legacy paths) and account-binding.json before re-authenticating — required when switching Cloudflare accounts.", {
23
84
  force: z
@@ -26,26 +87,22 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
26
87
  .describe("Delete cert.pem (brand path + legacy ~/.cloudflared/cert.pem) and account-binding.json before re-authenticating. Required when switching Cloudflare accounts (e.g. after the prior account was scrubbed or the cert was rotated under a different account)."),
27
88
  }, async ({ force }) => {
28
89
  try {
29
- // Force: delete cert.pem (both paths) and binding before restarting
30
90
  if (force) {
31
91
  console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
32
92
  cloudflared.resetAuth();
33
93
  }
34
94
  const auth = cloudflared.validateAuth();
35
- // Phase: Already bound — cert + binding present and accountIds agree
36
95
  if (auth.bound) {
37
- console.error(`[tunnel-login] short-circuit: cert and binding agree on account ${auth.boundAccountId}`);
96
+ console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.boundAccountId}`);
38
97
  return {
39
98
  content: [
40
99
  {
41
100
  type: "text",
42
- text: `Already authenticated. cert.pem and account binding agree on account ${auth.boundAccountId}.\n\nTo switch Cloudflare accounts, call tunnel-login with force=true. This deletes cert.pem (both paths) and the account binding, then restarts the authentication flow.`,
101
+ text: `Sign-in complete.`,
43
102
  },
44
103
  ],
45
104
  };
46
105
  }
47
- // Phase: cert.pem exists — derive credentials and either materialize
48
- // or reconcile the binding.
49
106
  if (auth.hasCert) {
50
107
  const creds = cloudflared.parseCertPem();
51
108
  if (!creds) {
@@ -53,210 +110,138 @@ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunne
53
110
  content: [
54
111
  {
55
112
  type: "text",
56
- text: `cert.pem exists but could not extract API credentials from it. The file format may have changed.\n\nRecovery: run tunnel-login with force=true to clear cert.pem and re-authenticate.`,
113
+ text: `Sign-in file exists but its contents are unreadable. Ask me to run tunnel-login with force=true so the file is cleared and a fresh sign-in can start.`,
57
114
  },
58
115
  ],
59
116
  isError: true,
60
117
  };
61
118
  }
62
- // Migration path: cert exists, no binding yet — silently materialize
63
- // (the operator already trusted this cert by completing the prior
64
- // OAuth flow; we don't re-confirm).
65
119
  if (!auth.hasBinding) {
66
- const binding = cloudflared.materializeBinding("migration");
120
+ cloudflared.materializeBinding("migration");
121
+ console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.certAccountId} source=migration`);
67
122
  return {
68
123
  content: [
69
124
  {
70
125
  type: "text",
71
- text: `Account binding materialized from existing cert.pem.\n\n Account: ${binding.accountId}\n Bound at: ${binding.boundAt}\n\nReady for tunnel operations.`,
126
+ text: `Sign-in complete.`,
72
127
  },
73
128
  ],
74
129
  };
75
130
  }
76
- // Drift: cert says account X, binding says account Y — refuse with
77
- // recovery instruction (force=true will clear both).
78
131
  if (auth.certAccountId !== auth.boundAccountId) {
79
132
  return {
80
133
  content: [
81
134
  {
82
135
  type: "text",
83
- text: `cert.pem is bound to account ${auth.certAccountId} but the recorded binding is account ${auth.boundAccountId}. The cert was rotated under a different Cloudflare account since the binding was established.\n\n${cloudflared.recoveryMessage()}`,
136
+ text: `The Cloudflare sign-in on this laptop has changed since it was first set up. ${cloudflared.recoveryMessage()}`,
84
137
  },
85
138
  ],
86
139
  isError: true,
87
140
  };
88
141
  }
89
142
  }
90
- // Phase: Login process already running — wait for authorization
91
- const activeLogin = cloudflared.getActiveLogin();
92
- if (activeLogin) {
143
+ if (!cloudflared.isInstalled()) {
93
144
  return {
94
145
  content: [
95
146
  {
96
147
  type: "text",
97
- text: `Authorization in progress — a login process is already running (PID ${activeLogin.pid}, started ${new Date(activeLogin.startedAt).toISOString()}).\n\nAuth URL: ${activeLogin.authUrl}\n\nAsk the user to complete authorization in the browser. Call tunnel-login again after the user has authorized — it will detect cert.pem and derive API credentials automatically.`,
148
+ text: "cloudflared is not installed. Run tunnel-install first.",
98
149
  },
99
150
  ],
151
+ isError: true,
100
152
  };
101
153
  }
102
- // Phase: No cert.pem, no active login spawn cloudflared tunnel login
103
- if (!cloudflared.isInstalled()) {
154
+ // Three-state login machine. `complete` is already handled above via
155
+ // `auth.bound` / migration path, so here we branch on in-progress vs.
156
+ // the two restart cases (log-visible failure, PID-dead without cert).
157
+ const loginStatus = cloudflared.getLoginStatus();
158
+ if (loginStatus.state === "failed") {
159
+ console.error(`[cloudflare:tunnel-login:failed] reason=${loginStatus.reason} ` +
160
+ `pid=${loginStatus.loginState?.pid ?? "none"} marker=${JSON.stringify(loginStatus.marker)}`);
161
+ cloudflared.resetAuth();
162
+ const restarted = await cloudflared.tunnelLogin();
104
163
  return {
105
164
  content: [
106
165
  {
107
166
  type: "text",
108
- text: "cloudflared is not installed. Run tunnel-install first.",
167
+ text: `Sign-in failed the browser didn't open on the laptop. Restarting.\n\n` +
168
+ `Sign-in URL: ${restarted.authUrl}\n\n` +
169
+ `Ask the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. ` +
170
+ `Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
109
171
  },
110
172
  ],
111
- isError: true,
112
173
  };
113
174
  }
114
- const result = await cloudflared.tunnelLogin();
115
- const alreadyNote = result.alreadyRunning
116
- ? " (reusing existing login process)"
117
- : "";
118
- return {
119
- content: [
120
- {
121
- type: "text",
122
- text: `Cloudflare login started${alreadyNote}. Open this URL to authorize:\n\n${result.authUrl}\n\nThe user must open this URL and click Authorize. After authorization, call tunnel-login again — it will detect the authorization and derive API credentials automatically.`,
123
- },
124
- ],
125
- };
126
- }
127
- catch (err) {
128
- return {
129
- content: [
130
- {
131
- type: "text",
132
- text: `Login failed: ${err instanceof Error ? err.message : String(err)}`,
133
- },
134
- ],
135
- isError: true,
136
- };
137
- }
138
- });
139
- server.tool("cf-zone-status", "List all zones (domains) on the Cloudflare account with their activation status and assigned nameservers.", {}, async () => {
140
- try {
141
- const zones = await cloudflared.listZones();
142
- if (zones.length === 0) {
175
+ if (loginStatus.state === "exited-no-cert") {
176
+ console.error(`[cloudflare:tunnel-login:failed] reason=login-process-exited-without-cert ` +
177
+ `pid=${loginStatus.loginState?.pid ?? "none"}`);
178
+ cloudflared.resetAuth();
179
+ const restarted = await cloudflared.tunnelLogin();
143
180
  return {
144
181
  content: [
145
182
  {
146
183
  type: "text",
147
- text: "No zones found on this account. Add a domain to Cloudflare first.",
184
+ text: `Sign-in ended without saving the cert. Restarting.\n\n` +
185
+ `Sign-in URL: ${restarted.authUrl}\n\n` +
186
+ `Ask the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. ` +
187
+ `Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
148
188
  },
149
189
  ],
150
190
  };
151
191
  }
152
- const lines = zones.map((z) => ` ${z.name} ${z.status}${z.nameservers.length > 0 ? `\n Nameservers: ${z.nameservers.join(", ")}` : ""}`);
153
- return {
154
- content: [
155
- {
156
- type: "text",
157
- text: `Zones on this account:\n\n${lines.join("\n\n")}`,
158
- },
159
- ],
160
- };
161
- }
162
- catch (err) {
163
- return {
164
- content: [
165
- {
166
- type: "text",
167
- text: `Failed: ${err instanceof Error ? err.message : String(err)}`,
168
- },
169
- ],
170
- isError: true,
171
- };
172
- }
173
- });
174
- server.tool("cf-add-zone", "Add a domain to the bound Cloudflare account via the official API. Idempotent: returns existing zone info if already on the account. Requires a successful tunnel-login (cert.pem + account-binding.json present and matching). The bound account must have permission to create zones; if not, the operator must add the zone via the Cloudflare dashboard.", {
175
- domain: z
176
- .string()
177
- .describe("Registrable domain to add to the bound Cloudflare account (e.g. 'mybusiness.com')."),
178
- }, async ({ domain }) => {
179
- try {
180
- const start = Date.now();
181
- const result = await cloudflared.createZone(domain);
182
- const elapsed = Date.now() - start;
183
- const action = result.existing ? "already on this account" : "added";
184
- const nsLines = result.nameservers.length > 0
185
- ? `\n Nameservers: ${result.nameservers.join(", ")}`
186
- : "";
187
- console.error(`[cf-add-zone] ${domain} ${action} (${result.status}) in ${elapsed}ms`);
192
+ if (loginStatus.state === "in-progress") {
193
+ console.error(`[cloudflare:tunnel-login:waiting] pid=${loginStatus.loginState.pid} authUrl=${loginStatus.loginState.authUrl}`);
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: `Sign-in in progress — a browser window is already open (started ${new Date(loginStatus.loginState.startedAt).toISOString()}).\n\n` +
199
+ `Sign-in URL: ${loginStatus.loginState.authUrl}\n\n` +
200
+ `Ask the user to finish the sign-in in their browser and click Authorize. ` +
201
+ `Call tunnel-login again once the user confirms — it will detect the completed sign-in automatically.`,
202
+ },
203
+ ],
204
+ };
205
+ }
206
+ // state === "idle" — spawn fresh.
207
+ const result = await cloudflared.tunnelLogin();
188
208
  return {
189
209
  content: [
190
210
  {
191
211
  type: "text",
192
- text: `Domain ${action}.\n\n Domain: ${result.name}\n Status: ${result.status}${nsLines}\n\n${result.status === "pending"
193
- ? "The domain is pending activation — update the nameservers at your registrar to the Cloudflare-assigned nameservers shown above. Once nameservers propagate (minutes to 24 hours), the status changes to Active."
194
- : result.status === "active"
195
- ? "The domain is active on Cloudflare."
196
- : `Current status: ${result.status}.`}`,
212
+ text: `Cloudflare sign-in started. Open this URL to authorize:\n\n${result.authUrl}\n\n` +
213
+ `Ask the user to open the URL, pick the Cloudflare account they want this laptop signed into, and click Authorize. ` +
214
+ `Call tunnel-login again once they confirm — it will detect the completed sign-in automatically.`,
197
215
  },
198
216
  ],
199
217
  };
200
218
  }
201
219
  catch (err) {
202
- // Refusal-shaped errors thrown by getClient() carry structured detail —
203
- // surface them as agent-facing refusals with the same single recovery path.
204
- if (err instanceof cloudflared.CloudflareRefusalError) {
205
- return {
206
- content: [{ type: "text", text: err.refusal.message }],
207
- isError: true,
208
- };
209
- }
210
- const msg = err instanceof Error ? err.message : String(err);
211
- console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
212
220
  return {
213
221
  content: [
214
222
  {
215
223
  type: "text",
216
- text: `Zone creation failed: ${msg}\n\n` +
217
- `If this looks like a permissions error, the cert.pem account may not own ` +
218
- `this brand's zones. ${cloudflared.recoveryMessage()}`,
224
+ text: `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
219
225
  },
220
226
  ],
221
227
  isError: true,
222
228
  };
223
229
  }
224
230
  });
225
- // ===================================================================
226
- // Cloudflare Tunnel tools
227
- // ===================================================================
228
- server.tool("tunnel-status", "Full Cloudflare Tunnel status: installed, version, token valid, running, domain, hostnames.", {
231
+ server.tool("tunnel-status", "End-to-end Cloudflare Tunnel status. Reads the tunnel process state, parses configured hostnames from config.yml, and probes each hostname (DNS + HTTPS via Cloudflare's edge) to confirm traffic actually reaches this laptop from the internet. `healthy: true` requires every configured hostname to probe `ok`. `boundAccountOwnsHostnames: false` means the laptop is running a tunnel that nothing on the internet can reach — almost always because it is signed into a Cloudflare account that does not own the domain.", {
229
232
  domain: z
230
233
  .string()
231
234
  .optional()
232
- .describe("The bare domain (e.g. 'maxy.bot'). If omitted, domain info is excluded."),
235
+ .describe("The bare domain (e.g. 'maxy.bot'). If omitted, derived from persisted state."),
233
236
  }, async ({ domain }) => {
234
237
  try {
235
238
  const status = await cloudflared.getStatus(domain);
236
- // Check if the actual hostnames resolve (from state, not hardcoded)
237
- let dnsWarning = "";
238
- if (status.hostnames.length > 0) {
239
- const dns = await import("node:dns/promises");
240
- const resolver = new dns.Resolver();
241
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
242
- const checks = [];
243
- for (const h of status.hostnames) {
244
- try {
245
- await resolver.resolve4(h);
246
- }
247
- catch {
248
- checks.push(h);
249
- }
250
- }
251
- if (checks.length > 0) {
252
- dnsWarning = `\n\nDNS NOT RESOLVING: ${checks.join(", ")} do not resolve (checked via Google DNS 8.8.8.8). Possible causes: (1) CNAME records not created — run tunnel-create to create them; (2) nameservers not pointing to Cloudflare — check cf-zone-status; (3) DNS propagation in progress — wait a few minutes and re-check.`;
253
- }
254
- }
239
+ const guidance = buildTunnelStatusGuidance(status);
255
240
  return {
256
241
  content: [
257
242
  {
258
243
  type: "text",
259
- text: JSON.stringify(status, null, 2) + dnsWarning,
244
+ text: JSON.stringify(status, null, 2) + guidance,
260
245
  },
261
246
  ],
262
247
  };
@@ -317,7 +302,7 @@ server.tool("tunnel-install", "Install the cloudflared binary if not already pre
317
302
  };
318
303
  }
319
304
  });
320
- server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint. Collision guard: refuses to create DNS records that would overwrite another tunnel's hostnames. Uses cloudflared CLI with cert.pem requires tunnel-login first. Idempotent — reuses existing tunnel if name matches.", {
305
+ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint. Shells out to `cloudflared` which uses the signed-in cert.pem; refuses when the configured domain is not on the signed-in Cloudflare account. Idempotent — reuses existing tunnel if name matches.", {
321
306
  domain: z
322
307
  .string()
323
308
  .describe("The bare domain (e.g. 'maxy.bot')."),
@@ -335,7 +320,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
335
320
  .optional()
336
321
  .describe("Human-readable tunnel name (default: derived from domain)"),
337
322
  }, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
338
- // Validate: admin and public subdomains must differ
339
323
  if (publicSubdomain && adminSubdomain === publicSubdomain) {
340
324
  return {
341
325
  content: [
@@ -348,35 +332,27 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
348
332
  };
349
333
  }
350
334
  try {
335
+ const auth = cloudflared.validateAuth();
336
+ if (!auth.bound) {
337
+ return {
338
+ content: [
339
+ {
340
+ type: "text",
341
+ text: `REFUSED: this laptop is not signed into Cloudflare yet. ${cloudflared.recoveryMessage()}`,
342
+ },
343
+ ],
344
+ isError: true,
345
+ };
346
+ }
351
347
  const name = tunnelName ?? domain.replace(/\./g, "-");
352
348
  const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
353
- // Build hostname list from subdomains
354
349
  const adminHostname = `${adminSubdomain}.${domain}`;
355
350
  const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
356
351
  const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
357
352
  console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
358
- // Live account-zone check happens inside routeDnsCli (Step 4 below).
359
- // getClient() inside routeDnsCli enforces auth pre-conditions
360
- // (cert + binding + accountId match).
361
353
  // Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
362
354
  const tunnel = cloudflared.createTunnelCli(name);
363
- // Step 2: Collision guard check each hostname before creating DNS records
364
- for (const h of hostnames) {
365
- const check = await cloudflared.checkDnsCollision(h, tunnel.tunnelId);
366
- if (check.collision) {
367
- return {
368
- content: [
369
- {
370
- type: "text",
371
- text: `DNS collision: ${h} already points to a different tunnel (${check.existingTunnelId}). ` +
372
- `This hostname belongs to another device. Choose a different subdomain or remove the existing DNS record first.`,
373
- },
374
- ],
375
- isError: true,
376
- };
377
- }
378
- }
379
- // Step 3: Write local config.yml with ingress rules
355
+ // Step 2: Write local config.yml with ingress rules
380
356
  const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
381
357
  // Persist tunnel identity with actual hostnames
382
358
  cloudflared.saveTunnelIdentity({
@@ -388,21 +364,19 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
388
364
  adminHostname,
389
365
  publicHostname,
390
366
  });
391
- // Step 4: Route DNS via CLI for each hostname
367
+ // Step 3: Route DNS via CLI for each hostname (pre-flight + post-flight in routeDnsCli)
392
368
  const dnsResults = [];
393
369
  for (const h of hostnames) {
394
370
  const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
395
371
  dnsResults.push({
396
372
  hostname: h,
397
373
  fqdn: route.fqdn,
398
- zone: route.zone,
399
374
  note: route.created ? "created" : "already existed",
400
375
  });
401
376
  }
402
- // Step 5: Verify hostnames resolve via Google DNS
403
- const dns = await import("node:dns/promises");
404
- const resolver = new dns.Resolver();
405
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
377
+ // Step 4: Verify hostnames resolve via public DNS
378
+ const resolver = new Resolver();
379
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
406
380
  const dnsWarnings = [];
407
381
  for (const h of hostnames) {
408
382
  try {
@@ -414,16 +388,16 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
414
388
  }
415
389
  let dnsNote = "";
416
390
  if (dnsWarnings.length > 0) {
417
- dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via Google DNS (8.8.8.8). This may be propagation delay (wait a few minutes and re-check) or the domain may not be Active on Cloudflare — run cf-zone-status to check.`;
391
+ dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via public DNS yet. DNS propagation typically takes 1-5 minutes. Re-run tunnel-status in a few minutes to confirm.`;
418
392
  }
419
393
  const dnsLines = dnsResults
420
- .map((r) => ` DNS: ${r.hostname} → ${tunnel.tunnelId}.cfargotunnel.com under zone ${r.zone} (${r.note})${r.fqdn !== r.hostname ? ` — WARNING: CNAME created as ${r.fqdn}` : ""}`)
394
+ .map((r) => ` ${r.hostname} → this tunnel (${r.note})`)
421
395
  .join("\n");
422
396
  return {
423
397
  content: [
424
398
  {
425
399
  type: "text",
426
- text: `Tunnel created.\n\n ID: ${tunnel.tunnelId}\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
400
+ text: `Tunnel created.\n\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
427
401
  },
428
402
  ],
429
403
  };
@@ -451,7 +425,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
451
425
  domain: z.string().optional().describe("The bare domain (e.g. 'maxy.bot'). If omitted, reads from persisted tunnel state."),
452
426
  }, async ({ tunnelId: tunnelIdParam, domain: domainParam }) => {
453
427
  try {
454
- // Resolve tunnelId and domain from params or persisted state
455
428
  const tunnelId = tunnelIdParam ?? cloudflared.getPersistedTunnelId();
456
429
  const domain = domainParam ?? cloudflared.getPersistedDomain();
457
430
  if (!tunnelId) {
@@ -459,7 +432,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
459
432
  content: [
460
433
  {
461
434
  type: "text",
462
- text: "No tunnel ID provided and none found in persisted state. Run tunnel-create first to create a tunnel.",
435
+ text: "No tunnel found on this laptop. Run tunnel-create first.",
463
436
  },
464
437
  ],
465
438
  isError: true,
@@ -470,30 +443,28 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
470
443
  content: [
471
444
  {
472
445
  type: "text",
473
- text: "No domain provided and none found in persisted state. Run tunnel-create first to create a tunnel with a domain.",
446
+ text: "No domain recorded for this tunnel. Run tunnel-create first to set one up with a domain.",
474
447
  },
475
448
  ],
476
449
  isError: true,
477
450
  };
478
451
  }
479
- // Read actual hostnames from persisted state (backward compat: derives from domain)
480
452
  const hostnames = cloudflared.getPersistedHostnames();
481
453
  if (hostnames.length === 0) {
482
454
  return {
483
455
  content: [
484
456
  {
485
457
  type: "text",
486
- text: "No hostnames found in persisted state. Run tunnel-create first.",
458
+ text: "No addresses recorded for this tunnel. Run tunnel-create first.",
487
459
  },
488
460
  ],
489
461
  isError: true,
490
462
  };
491
463
  }
492
464
  console.error(`[tunnel-enable] checking hostnames=${JSON.stringify(hostnames)}`);
493
- // GATE 1: Check if actual hostnames resolve (via Google DNS)
494
- const dns = await import("node:dns/promises");
495
- const resolver = new dns.Resolver();
496
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
465
+ // GATE 1: Check if actual hostnames resolve via public DNS
466
+ const resolver = new Resolver();
467
+ resolver.setServers(["1.1.1.1", "8.8.8.8"]);
497
468
  const unresolvedSubs = [];
498
469
  for (const h of hostnames) {
499
470
  try {
@@ -508,7 +479,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
508
479
  content: [
509
480
  {
510
481
  type: "text",
511
- text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve (checked via Google DNS). The tunnel URLs will not work.\n\nThis usually means the CNAME records have not been created. Run tunnel-create first it creates the CNAME records in Cloudflare.\n\nIf tunnel-create has already been run, this may be DNS propagation (wait a few minutes) or the domain may not be Active on Cloudflare. Run cf-zone-status to check.\n\nDo not call tunnel-enable until the hostnames resolve.`,
482
+ text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve via public DNS. The tunnel's URLs will not work.\n\nUsually this means the DNS records were never created or are still propagating. Run tunnel-create first. If that has already run, wait 1-5 minutes for propagation or ask the user to confirm in the Cloudflare dashboard that the addresses exist on the correct domain.\n\nDo not call tunnel-enable until the addresses resolve.`,
512
483
  },
513
484
  ],
514
485
  isError: true,
@@ -517,7 +488,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
517
488
  const brand = cloudflared.loadBrand();
518
489
  const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
519
490
  console.error(`[tunnel-enable] using PLATFORM_PORT=${platformPort}`);
520
- // GATE 2: Remote auth must be configured (verified via live API, not file check)
491
+ // GATE 2: Remote auth must be configured
521
492
  try {
522
493
  const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
523
494
  signal: AbortSignal.timeout(5000),
@@ -546,23 +517,20 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
546
517
  isError: true,
547
518
  };
548
519
  }
549
- // GATE 3: auth pre-conditions — cert + binding + accountId match.
550
- // We use validateAuth (non-throwing) so we can produce a uniform
551
- // refusal message regardless of which condition failed.
520
+ // GATE 3: cert.pem + binding
552
521
  const enableAuth = cloudflared.validateAuth();
553
522
  if (!enableAuth.bound) {
554
- const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
555
523
  return {
556
524
  content: [
557
525
  {
558
526
  type: "text",
559
- text: `REFUSED: Cannot start tunnel — ${missing} is missing or mismatched. ${cloudflared.recoveryMessage()}`,
527
+ text: `REFUSED: Cannot start tunnel — this laptop is not signed into Cloudflare or the sign-in has drifted. ${cloudflared.recoveryMessage()}`,
560
528
  },
561
529
  ],
562
530
  isError: true,
563
531
  };
564
532
  }
565
- // GATE 4: config.yml must exist (written by tunnel-create)
533
+ // GATE 4: config.yml must exist
566
534
  const tunnelState = cloudflared.getPersistedState();
567
535
  const configPath = tunnelState?.configPath;
568
536
  const credentialsPath = tunnelState?.credentialsPath;
@@ -578,7 +546,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
578
546
  isError: true,
579
547
  };
580
548
  }
581
- // Start the tunnel daemon — config.yml + cert.pem, deterministic
582
549
  cloudflared.startTunnel({
583
550
  tunnelId,
584
551
  tunnelName,
@@ -586,7 +553,6 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
586
553
  configPath,
587
554
  credentialsPath: credentialsPath ?? "",
588
555
  });
589
- // Wait up to 5 seconds to verify the process didn't immediately crash
590
556
  const logHint = `~/${brand.configDir}/logs/cloudflared.log`;
591
557
  for (let i = 0; i < 10; i++) {
592
558
  await new Promise((r) => setTimeout(r, 500));
@@ -604,9 +570,7 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
604
570
  }
605
571
  }
606
572
  const status = await cloudflared.getStatus(domain);
607
- // Programmatic verification — HTTP-check the admin URL (always present)
608
- // via Cloudflare's edge. If public URL exists, check that too.
609
- const verifyUrl = `https://${hostnames[0]}`; // admin hostname is always first
573
+ const verifyUrl = `https://${hostnames[0]}`;
610
574
  let verified = false;
611
575
  console.error(`[tunnel-enable] verifying url=${verifyUrl}`);
612
576
  for (let attempt = 0; attempt < 6; attempt++) {
@@ -632,20 +596,19 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
632
596
  content: [
633
597
  {
634
598
  type: "text",
635
- text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned HTTP 530 after 30s of retries. Cloudflare's edge cannot route traffic to this tunnel.\n\nCommon causes:\n 1. Stale DNS CNAME records pointing to a previous tunnel — run tunnel-create again to fix\n 2. UDP buffer too small for QUIC — check ${logHint}\n 3. Firewall blocking outbound QUIC to Cloudflare edge\n\nThe tunnel is NOT working. Do not report success.`,
599
+ text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned 530 after 30s of retries. Cloudflare's edge cannot reach this tunnel.\n\nCommon causes:\n 1. Stale DNS records pointing to a previous tunnel — run tunnel-create again to fix\n 2. UDP buffer too small for QUIC — check ${logHint}\n 3. Firewall blocking outbound QUIC to Cloudflare's edge\n\nThe tunnel is NOT working. Do not report success.`,
636
600
  },
637
601
  ],
638
602
  isError: true,
639
603
  };
640
604
  }
641
- // Build URL list for output
642
605
  const urlLines = hostnames.map((h, i) => {
643
606
  const label = i === 0 ? "Admin" : "Public";
644
607
  return ` ${label}: https://${h}`;
645
608
  }).join("\n");
646
609
  const reachableNote = hostnames.length > 1
647
- ? "All URLs are reachable through Cloudflare's edge."
648
- : "The admin URL is reachable through Cloudflare's edge.";
610
+ ? "All URLs are reachable through Cloudflare."
611
+ : "The admin URL is reachable through Cloudflare.";
649
612
  return {
650
613
  content: [
651
614
  {
@@ -697,40 +660,25 @@ server.tool("tunnel-disable", "Stop the Cloudflare Tunnel process. Config is pre
697
660
  };
698
661
  }
699
662
  });
700
- server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudflare Tunnel for direct serving. Reads the current tunnel ingress config, appends the new hostname, creates a CNAME in the alias domain's zone pointing to the tunnel, and registers the alias so the web server treats it as a public hostname. The web server hot-reloads alias domains automatically — the new domain is active within seconds, no restart needed. Idempotent — safe to call again with the same hostname.", {
663
+ server.tool("tunnel-add-hostname", "Add an additional address to an existing Cloudflare Tunnel. Refuses pre-flight when the hostname's domain is not on Cloudflare, and refuses post-flight when `cloudflared` routes the address under a different domain (meaning the laptop is signed into the wrong Cloudflare account). Idempotent — safe to call again with the same hostname.", {
701
664
  hostname: z
702
665
  .string()
703
- .describe("The alias domain to add (e.g. 'maxy.chat'). Must be an Active zone on the Cloudflare account."),
666
+ .describe("The address to add (e.g. 'maxy.chat'). The domain must be on the Cloudflare account this laptop is signed into."),
704
667
  tunnelId: z
705
668
  .string()
706
669
  .describe("The tunnel UUID. Get this from tunnel-status."),
707
670
  }, async ({ hostname, tunnelId }) => {
708
671
  try {
709
- // getZoneId enforces auth pre-conditions and refuses if the alias
710
- // domain isn't a zone on the bound account.
711
- const zoneId = await cloudflared.getZoneId(hostname);
712
- // Step 2: Add hostname to tunnel ingress (read-then-append)
713
- const ingress = await cloudflared.addHostnameToIngress(tunnelId, hostname);
714
- // Step 3: Create CNAME via SDK. The cert-bound credential carries
715
- // Zone:DNS:Edit for zones owned by the bound account. On any error,
716
- // surface as failure — no fallback path remains.
717
- const dns = await cloudflared.createDnsRecord(zoneId, hostname, tunnelId);
718
- // Step 4: Persist alias domain for web server recognition
672
+ const route = await cloudflared.routeDnsCli(tunnelId, hostname);
719
673
  cloudflared.saveAliasDomain(hostname);
720
- // Build response
721
- const ingressNote = ingress.alreadyPresent
722
- ? "already in tunnel ingress"
723
- : "added to tunnel ingress";
724
- const dnsNote = dns.updated
725
- ? "updated — was pointing to wrong tunnel"
726
- : dns.existing
727
- ? "already existed"
728
- : "created";
674
+ const routeNote = route.created
675
+ ? "created"
676
+ : "already existed";
729
677
  return {
730
678
  content: [
731
679
  {
732
680
  type: "text",
733
- text: `Alias domain configured for direct serving.\n\n Hostname: ${hostname}\n Tunnel ingress: ${ingressNote}\n DNS CNAME: ${dnsNote} (→ ${tunnelId}.cfargotunnel.com)\n Web server: registered in alias-domains.json (hot-reloaded automatically)\n\nhttps://${hostname} is now active the web server picks up the new alias domain within seconds. It serves the public chat directly — no redirect, URL stays as ${hostname}. Verify the URL to confirm.`,
681
+ text: `Address configured.\n\n Address: ${hostname}\n Routing: ${routeNote}\n Web server: registered in alias-domains.json (hot-reloaded automatically)\n\nhttps://${hostname} will be active once DNS propagates (1-5 minutes). Run tunnel-status to confirm.`,
734
682
  },
735
683
  ],
736
684
  };
@@ -746,7 +694,7 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
746
694
  content: [
747
695
  {
748
696
  type: "text",
749
- text: `Failed to add alias hostname: ${err instanceof Error ? err.message : String(err)}`,
697
+ text: `Failed to add address: ${err instanceof Error ? err.message : String(err)}`,
750
698
  },
751
699
  ],
752
700
  isError: true,
@@ -764,12 +712,11 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
764
712
  nameserver: z
765
713
  .string()
766
714
  .optional()
767
- .describe("Custom nameserver IP (default: Google 8.8.8.8 + Cloudflare 1.1.1.1)"),
715
+ .describe("Custom nameserver IP (default: Cloudflare 1.1.1.1 + Google 8.8.8.8)"),
768
716
  }, async ({ hostname, type, nameserver }) => {
769
717
  try {
770
- const dns = await import("node:dns/promises");
771
- const resolver = new dns.Resolver();
772
- resolver.setServers(nameserver ? [nameserver] : ["8.8.8.8", "1.1.1.1"]);
718
+ const resolver = new Resolver();
719
+ resolver.setServers(nameserver ? [nameserver] : ["1.1.1.1", "8.8.8.8"]);
773
720
  const recordType = type ?? "A";
774
721
  const results = {
775
722
  hostname,
@@ -791,15 +738,13 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
791
738
  catch (cnameErr) {
792
739
  const cnameCode = cnameErr.code;
793
740
  if (cnameCode === "ENODATA") {
794
- // Cloudflare-proxied CNAMEs are flattened to A records — resolveCname
795
- // returns ENODATA even though the hostname resolves. Fall back to A.
796
741
  try {
797
742
  const aRecords = await resolver.resolve4(hostname);
798
- results.note = "CNAME lookup returned ENODATA — record is likely Cloudflare-proxied (flattened to A)";
743
+ results.note = "CNAME lookup returned ENODATA — record is likely flattened to A";
799
744
  results.addresses = aRecords;
800
745
  }
801
746
  catch {
802
- throw cnameErr; // A also failed — rethrow original ENODATA
747
+ throw cnameErr;
803
748
  }
804
749
  }
805
750
  else {
@@ -845,809 +790,12 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
845
790
  };
846
791
  }
847
792
  });
848
- // ===================================================================
849
- // cf-verify / cf-rebuild — account-state report + nuclear cleanup
850
- // ===================================================================
851
- server.tool("cf-verify", "Read the bound Cloudflare account and the device's local state, return a JSON snapshot. Account section: zones, tunnels, CNAMEs (grouped by zone). Device section: cert.pem, account-binding.json, tunnel.state, config.yml, alias-domains.json. Orphans section: account artefacts not referenced by the device's current intended state (tunnels, CNAMEs, zones not used by any intended hostname). Non-mutating. Runs in any state including fresh-install-before-login.", {}, async () => {
852
- try {
853
- const report = await cloudflared.cfVerifyCore();
854
- return {
855
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
856
- };
857
- }
858
- catch (err) {
859
- return {
860
- content: [
861
- {
862
- type: "text",
863
- text: `cf-verify failed: ${err instanceof Error ? err.message : String(err)}`,
864
- },
865
- ],
866
- isError: true,
867
- };
868
- }
869
- });
870
- server.tool("cf-rebuild", "Nuclear cleanup of the bound Cloudflare account. Deletes every tunnel, CNAME, and (where the account permits) zone NOT in the `preserve` set. With no preserve args, infers what to keep from the device's `tunnel.state` and `alias-domains.json`. Pass `preserve.zones`, `preserve.tunnelIds`, or `preserve.cnames` to override. `dryRun=true` plans without mutating. The agent owns the bound account absolutely — anything not in preserve is treated as junk.", {
871
- dryRun: z
872
- .boolean()
873
- .optional()
874
- .describe("If true, report what cf-rebuild WOULD delete without performing any mutations."),
875
- preserve: z
876
- .object({
877
- zones: z.array(z.string()).optional().describe("Zone names to keep (whole zones; all CNAMEs under them survive unless preserve.cnames overrides)."),
878
- tunnelIds: z.array(z.string()).optional().describe("Tunnel UUIDs to keep."),
879
- cnames: z.array(z.object({ zone: z.string(), name: z.string() })).optional().describe("Per-CNAME preserve list. When set, ONLY these CNAMEs survive within preserved zones."),
880
- })
881
- .optional()
882
- .describe("Preservation set. Anything on the account not listed here is deleted. Defaults to whatever the device's tunnel.state and alias-domains.json reference."),
883
- }, async ({ dryRun, preserve }) => {
884
- try {
885
- const report = await cloudflared.cfRebuildCore({ dryRun: dryRun ?? false, preserve });
886
- return {
887
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
888
- isError: report.halted,
889
- };
890
- }
891
- catch (err) {
892
- if (err instanceof cloudflared.CloudflareRefusalError) {
893
- return {
894
- content: [{ type: "text", text: err.refusal.message }],
895
- isError: true,
896
- };
897
- }
898
- return {
899
- content: [
900
- {
901
- type: "text",
902
- text: `cf-rebuild failed: ${err instanceof Error ? err.message : String(err)}`,
903
- },
904
- ],
905
- isError: true,
906
- };
907
- }
908
- });
909
- const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
910
- function sanitizedDomain(domain) {
911
- return domain.replace(/\./g, "-");
793
+ async function main() {
794
+ const transport = new StdioServerTransport();
795
+ await server.connect(transport);
912
796
  }
913
- function deriveTunnelName(adminLabel, domain) {
914
- return `${adminLabel}-${sanitizedDomain(domain)}`;
915
- }
916
- function hasBrandCredsFor(tunnelId) {
917
- const path = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
918
- return existsSync(path);
919
- }
920
- server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Cloudflare Tunnel setup. Call with no parameters to begin; the tool evaluates current state and returns a structured result describing what to render next. When a returned status begins with 'awaiting_', read `data.render` and call render-component with its contents verbatim — do not paraphrase, do not prompt the user for values. When the user submits a component (single-select, tunnel-route-picker), the agent's sole job is to relay the submission's fields (selectedTunnelId, selectedZoneName, adminLabel, publicLabel) back into this tool unchanged. The agent never synthesises subdomains or domain fragments from free text.", {
921
- selectedTunnelId: z
922
- .string()
923
- .optional()
924
- .describe("Tunnel ID from the single-select submission. The literal string '__new__' means the user chose to create a new tunnel. Pass only when the last component rendered was the tunnel selection list."),
925
- selectedZoneName: z
926
- .string()
927
- .optional()
928
- .describe("Zone name from the single-select submission. Pass only when the last component rendered was the zone selection list."),
929
- adminLabel: z
930
- .string()
931
- .optional()
932
- .describe("Admin DNS label from the tunnel-route-picker submission. DNS-label-safe (a-z, 0-9, hyphen, no leading/trailing hyphen). Pass only when the last component rendered was tunnel-route-picker."),
933
- publicLabel: z
934
- .string()
935
- .optional()
936
- .describe("Public DNS label from the tunnel-route-picker submission. Optional; omit to skip the public endpoint. DNS-label-safe. Pass only when the last component rendered was tunnel-route-picker."),
937
- cleanupConfirmed: z
938
- .boolean()
939
- .optional()
940
- .describe("Operator confirmation to delete pollution on the bound Cloudflare account (zones, tunnels, and CNAMEs not matching the chosen domain). Pass only when the last component rendered was the cleanup confirmation — comes from the component's submitMessage verbatim. Never synthesise from free text."),
941
- }, async ({ selectedTunnelId, selectedZoneName, adminLabel, publicLabel, cleanupConfirmed }) => {
942
- const log = (msg) => console.error(`[cloudflare-setup] ${msg}`);
943
- try {
944
- // ── Step 1: cloudflared binary ──────────────────────────────────
945
- log("checking cloudflared installation");
946
- if (!cloudflared.isInstalled()) {
947
- log("cloudflared not installed — installing");
948
- const { execFileSync } = await import("node:child_process");
949
- const arch = process.arch === "arm64" ? "arm64" : "amd64";
950
- const debUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`;
951
- try {
952
- execFileSync("curl", ["-fsSL", debUrl, "-o", "/tmp/cloudflared.deb"], { timeout: 60000 });
953
- execFileSync("sudo", ["dpkg", "-i", "/tmp/cloudflared.deb"], { timeout: 30000 });
954
- execFileSync("rm", ["-f", "/tmp/cloudflared.deb"]);
955
- log("cloudflared installed successfully");
956
- }
957
- catch (err) {
958
- const msg = err instanceof Error ? err.message : String(err);
959
- log(`installation failed: ${msg}`);
960
- return result({ status: "error", message: `Could not install cloudflared: ${msg}` });
961
- }
962
- }
963
- // ── Step 2: Authentication ──────────────────────────────────────
964
- log("checking authentication");
965
- const auth = cloudflared.validateAuth();
966
- if (auth.bound) {
967
- log(`authenticated — cert and binding agree on account ${auth.boundAccountId}`);
968
- }
969
- else if (auth.hasCert) {
970
- // cert.pem exists — materialize binding (migration path) or surface
971
- // drift if the binding disagrees with the cert.
972
- const creds = cloudflared.parseCertPem();
973
- if (!creds) {
974
- log("cert.pem exists but could not extract credentials");
975
- return result({
976
- status: "error",
977
- message: "Found a Cloudflare certificate but could not extract credentials from it. Sign in again — I'll clear the old certificate and start fresh.",
978
- });
979
- }
980
- if (!auth.hasBinding) {
981
- const binding = cloudflared.materializeBinding("migration");
982
- log(`materialized binding from existing cert — account ${binding.accountId}`);
983
- }
984
- else if (auth.certAccountId !== auth.boundAccountId) {
985
- log(`account drift: cert=${auth.certAccountId} binding=${auth.boundAccountId}`);
986
- return result({
987
- status: "error",
988
- message: `Your Cloudflare certificate is bound to a different account than this device's recorded binding. ${cloudflared.recoveryMessage()}`,
989
- });
990
- }
991
- }
992
- else {
993
- // No cert, no token — need login
994
- log("no authentication found — starting login");
995
- // Get auth URL from existing login process or spawn a fresh one
996
- let authUrl;
997
- const activeLogin = cloudflared.getActiveLogin();
998
- if (activeLogin) {
999
- log(`login process already running (PID ${activeLogin.pid})`);
1000
- authUrl = activeLogin.authUrl;
1001
- }
1002
- else {
1003
- try {
1004
- const loginResult = await cloudflared.tunnelLogin();
1005
- log(`login spawned — auth URL: ${loginResult.authUrl}`);
1006
- authUrl = loginResult.authUrl;
1007
- }
1008
- catch (err) {
1009
- const msg = err instanceof Error ? err.message : String(err);
1010
- log(`login failed: ${msg}`);
1011
- return result({
1012
- status: "error",
1013
- message: `Could not start Cloudflare sign-in: ${msg}`,
1014
- });
1015
- }
1016
- }
1017
- // Return immediately — the agent shows the URL, user authorizes,
1018
- // then re-calls cloudflare-setup. hasCert() at line 1082 handles re-entry.
1019
- log(`returning awaiting_auth — auth URL: ${authUrl}`);
1020
- return result({
1021
- status: "awaiting_auth",
1022
- message: "Open the authorization URL in your browser and click Authorize. Once done, let me know and I'll continue the setup.",
1023
- data: { authUrl },
1024
- });
1025
- }
1026
- // ── Step 3: Discover state — tunnel, zone, domain ───────────────
1027
- const existingState = cloudflared.getPersistedState();
1028
- const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
1029
- // If state already has a tunnel identity with hostnames, labels are
1030
- // reconstructed from the persisted hostnames on every re-entry where
1031
- // the agent doesn't supply fresh labels. This is how the flow
1032
- // survives the awaiting_password round-trip.
1033
- const stateAdminLabel = existingState?.adminHostname && existingState.domain
1034
- ? existingState.adminHostname.replace(`.${existingState.domain}`, "")
1035
- : null;
1036
- const statePublicLabel = existingState?.publicHostname && existingState?.domain
1037
- ? existingState.publicHostname.replace(`.${existingState.domain}`, "")
1038
- : null;
1039
- let allTunnels = [];
1040
- let allZones = [];
1041
- try {
1042
- [allTunnels, allZones] = await Promise.all([
1043
- cloudflared.listTunnelsOnAccount(),
1044
- cloudflared.listZones(),
1045
- ]);
1046
- }
1047
- catch (err) {
1048
- const msg = err instanceof Error ? err.message : String(err);
1049
- log(`discovery failed: ${msg}`);
1050
- return result({
1051
- status: "error",
1052
- message: `Could not read your Cloudflare account: ${msg}`,
1053
- });
1054
- }
1055
- log(`entry tunnelsOnAccount=${allTunnels.length} zonesOnAccount=${allZones.length}`);
1056
- const activeZones = allZones.filter((z) => z.status === "active");
1057
- const pendingZones = allZones.filter((z) => z.status === "pending");
1058
- // Resolve domain — precedence: persisted state > tunnel selection > zone selection > auto-only-active
1059
- let domain = existingState?.domain ?? null;
1060
- if (!domain && selectedTunnelId && selectedTunnelId !== "__new__") {
1061
- const tunnel = allTunnels.find((t) => t.id === selectedTunnelId);
1062
- if (!tunnel) {
1063
- log(`selectedTunnelId ${selectedTunnelId} not found on account`);
1064
- return result({
1065
- status: "error",
1066
- message: "That tunnel is no longer on your account. Let me check again.",
1067
- });
1068
- }
1069
- const zoneName = await cloudflared.findZoneHostingTunnel(selectedTunnelId);
1070
- if (!zoneName) {
1071
- log(`selected tunnel ${selectedTunnelId} has no zone hosting it — falling back to zone selection`);
1072
- // Fall through to zone-selection branch below
1073
- }
1074
- else {
1075
- domain = zoneName;
1076
- log(`branch=existing-tunnel tunnelId=${selectedTunnelId} domain=${domain}`);
1077
- }
1078
- }
1079
- if (!domain && selectedZoneName) {
1080
- const zone = activeZones.find((z) => z.name === selectedZoneName);
1081
- if (!zone) {
1082
- log(`selectedZoneName ${selectedZoneName} not active on account`);
1083
- return result({
1084
- status: "error",
1085
- message: "That domain is no longer Active on your account. Let me check again.",
1086
- });
1087
- }
1088
- domain = selectedZoneName;
1089
- }
1090
- // No prior state, no selection — prompt the user for a tunnel
1091
- // (if account has any) or a zone (if account has >1 active zone).
1092
- if (!domain) {
1093
- if (allTunnels.length > 0 && !selectedTunnelId) {
1094
- log(`awaiting_tunnel_selection tunnelsOnAccount=${allTunnels.length}`);
1095
- const options = [
1096
- { value: "__new__", label: "Create a new tunnel (recommended)" },
1097
- ...allTunnels.map((t) => ({ value: t.id, label: t.name })),
1098
- ];
1099
- return result({
1100
- status: "awaiting_tunnel_selection",
1101
- message: "Pick a tunnel for this device. For a fresh setup, choose Create a new tunnel.",
1102
- data: {
1103
- tunnels: allTunnels.map((t) => ({ id: t.id, name: t.name })),
1104
- render: {
1105
- name: "single-select",
1106
- data: {
1107
- title: "Tunnel",
1108
- description: "Each device uses its own tunnel. Creating a new one is the normal choice.",
1109
- options,
1110
- submitLabel: "Use this tunnel",
1111
- submitMessage: '{"selectedTunnelId":"{{value}}"}',
1112
- },
1113
- },
1114
- },
1115
- });
1116
- }
1117
- // From here: selectedTunnelId is "__new__" or undefined (no tunnels on account).
1118
- if (activeZones.length === 0) {
1119
- if (pendingZones.length > 0) {
1120
- const z = pendingZones[0];
1121
- log(`awaiting_nameservers zone=${z.name}`);
1122
- return result({
1123
- status: "awaiting_nameservers",
1124
- message: `Your domain ${z.name} is on Cloudflare but not yet active. Update the nameservers at your registrar to:\n\n• ${z.nameservers[0]}\n• ${z.nameservers[1]}\n\nLet me know when you've updated them.`,
1125
- data: { nameservers: z.nameservers },
1126
- });
1127
- }
1128
- log(`branch=zone-add-fallback reason=noZones`);
1129
- return result({
1130
- status: "awaiting_zone_add_dashboard",
1131
- message: "No domain on your Cloudflare account yet. Sign in at cloudflare.com, click Add a site, and add your domain. When it appears on the account, let me know and I'll continue.",
1132
- data: { dashboardUrl: "https://dash.cloudflare.com" },
1133
- });
1134
- }
1135
- if (activeZones.length > 1) {
1136
- log(`awaiting_zone_selection activeZones=${activeZones.length}`);
1137
- return result({
1138
- status: "awaiting_zone_selection",
1139
- message: "Multiple domains on your Cloudflare account. Pick the one to use for this device.",
1140
- data: {
1141
- zones: activeZones.map((z) => ({ name: z.name, status: z.status })),
1142
- render: {
1143
- name: "single-select",
1144
- data: {
1145
- title: "Domain",
1146
- options: activeZones.map((z) => ({ value: z.name, label: z.name })),
1147
- submitLabel: "Use this domain",
1148
- submitMessage: '{"selectedZoneName":"{{value}}"}',
1149
- },
1150
- },
1151
- },
1152
- });
1153
- }
1154
- // Exactly one active zone — use it.
1155
- domain = activeZones[0].name;
1156
- log(`auto-selected only active zone: ${domain}`);
1157
- }
1158
- // Verify the resolved zone is active (guard against state with a since-pending zone)
1159
- const zone = allZones.find((z) => z.name === domain);
1160
- if (zone && zone.status === "pending") {
1161
- log(`zone ${domain} is pending — returning awaiting_nameservers`);
1162
- return result({
1163
- status: "awaiting_nameservers",
1164
- message: `Your domain ${domain} is not yet active on Cloudflare. Update the nameservers at your registrar to:\n\n• ${zone.nameservers[0]}\n• ${zone.nameservers[1]}\n\nLet me know when you've updated them.`,
1165
- data: { nameservers: zone.nameservers },
1166
- });
1167
- }
1168
- // ── Step 3b: Cleanup — bound account is the universe ────────────
1169
- // The chosen zone + any currently-persisted tunnel are intent;
1170
- // everything else on the account is pollution. Surface it, require
1171
- // explicit operator confirmation, then delete before creating the
1172
- // new tunnel (so the label-collision check runs against a clean
1173
- // zone, not one littered with stale `<deadTunnel>.cfargotunnel.com`
1174
- // pointers).
1175
- const preservedTunnelIds = new Set();
1176
- if (existingState?.tunnelId)
1177
- preservedTunnelIds.add(existingState.tunnelId);
1178
- if (selectedTunnelId && selectedTunnelId !== "__new__") {
1179
- preservedTunnelIds.add(selectedTunnelId);
1180
- }
1181
- const domainLc = domain.toLowerCase();
1182
- const pollutionZonesList = allZones.filter((z) => z.status === "active" && z.name.toLowerCase() !== domainLc);
1183
- const pollutionTunnelsList = allTunnels.filter((t) => !preservedTunnelIds.has(t.id));
1184
- // Only surface CNAMEs on the PRESERVED zone that point to deleted
1185
- // tunnels — those need explicit disclosure because the zone itself
1186
- // survives. CNAMEs under pollution zones are cascade-deleted when
1187
- // the zone is deleted; listing them separately would duplicate
1188
- // the same destruction in the confirm UI.
1189
- let pollutionCnamesList = [];
1190
- if (pollutionTunnelsList.length > 0) {
1191
- try {
1192
- const verify = await cloudflared.cfVerifyCore();
1193
- if (verify.account) {
1194
- const pollutionTunnelIdSet = new Set(pollutionTunnelsList.map((t) => t.id));
1195
- pollutionCnamesList = verify.account.cnames
1196
- .filter((c) => {
1197
- if (c.zone.toLowerCase() !== domainLc)
1198
- return false;
1199
- const m = c.content.toLowerCase().trim().match(/^([0-9a-f-]{36})\.cfargotunnel\.com\.?$/);
1200
- return Boolean(m && pollutionTunnelIdSet.has(m[1]));
1201
- })
1202
- .map((c) => ({ zone: c.zone, name: c.name, content: c.content }));
1203
- }
1204
- }
1205
- catch (err) {
1206
- const msg = err instanceof Error ? err.message : String(err);
1207
- log(`cfVerifyCore during cleanup check failed: ${msg}`);
1208
- // Continue with zone/tunnel pollution only — conservative.
1209
- }
1210
- }
1211
- const pollutionTotal = pollutionZonesList.length +
1212
- pollutionTunnelsList.length +
1213
- pollutionCnamesList.length;
1214
- if (pollutionTotal > 0) {
1215
- if (!cleanupConfirmed) {
1216
- log(`awaiting_cleanup_confirmation pollutionZones=${pollutionZonesList.length} pollutionTunnels=${pollutionTunnelsList.length} pollutionCnames=${pollutionCnamesList.length}`);
1217
- const items = [
1218
- ...pollutionZonesList.map((z) => ({ label: "Domain (zone)", value: z.name })),
1219
- ...pollutionTunnelsList.map((t) => ({ label: "Tunnel", value: t.name })),
1220
- ...pollutionCnamesList.map((c) => ({
1221
- label: "DNS record",
1222
- value: `${c.name} → ${c.content}`,
1223
- })),
1224
- ];
1225
- const confirmPayload = {
1226
- cleanupConfirmed: true,
1227
- selectedZoneName: domain,
1228
- };
1229
- if (selectedTunnelId)
1230
- confirmPayload.selectedTunnelId = selectedTunnelId;
1231
- return result({
1232
- status: "awaiting_cleanup_confirmation",
1233
- message: `Your Cloudflare account has artefacts from prior work that don't belong to the ${domain} setup. Confirm to clean them up before creating the tunnel.`,
1234
- data: {
1235
- domain,
1236
- pollution: {
1237
- zones: pollutionZonesList.map((z) => ({ name: z.name })),
1238
- tunnels: pollutionTunnelsList.map((t) => ({ id: t.id, name: t.name })),
1239
- cnames: pollutionCnamesList,
1240
- },
1241
- render: {
1242
- name: "confirm",
1243
- data: {
1244
- title: `Clean up before setting up ${domain}?`,
1245
- description: `Keeping ${domain}. Everything below will be deleted.`,
1246
- items,
1247
- confirmLabel: "Clean up and continue",
1248
- rejectLabel: "Cancel",
1249
- confirmMessage: JSON.stringify(confirmPayload),
1250
- rejectMessage: "I don't want to clean up.",
1251
- },
1252
- },
1253
- },
1254
- });
1255
- }
1256
- // Cleanup confirmed — run the rebuild with explicit intent.
1257
- log(`cleanup confirmed — cfRebuildCore preserve.zones=[${domain}] preserve.tunnelIds=[${[...preservedTunnelIds].join(",")}]`);
1258
- try {
1259
- const rebuild = await cloudflared.cfRebuildCore({
1260
- preserve: {
1261
- zones: [domain],
1262
- tunnelIds: [...preservedTunnelIds],
1263
- },
1264
- });
1265
- if (rebuild.halted) {
1266
- return result({
1267
- status: "error",
1268
- message: `Cleanup halted: ${rebuild.haltReason ?? "unknown reason"}. ${cloudflared.recoveryMessage()}`,
1269
- });
1270
- }
1271
- const failedActions = rebuild.actions.filter((a) => a.result === "failed");
1272
- if (failedActions.length > 0) {
1273
- return result({
1274
- status: "error",
1275
- message: `Cleanup completed with ${failedActions.length} failed action(s) of ${rebuild.actions.length}. First: ${failedActions[0].detail ?? "(no detail)"}. Run cf-verify to see current state.`,
1276
- });
1277
- }
1278
- log(`cleanup complete actions=${rebuild.actions.length}`);
1279
- // Refresh tunnels snapshot so label collision checks run against
1280
- // the post-cleanup account state.
1281
- try {
1282
- allTunnels = await cloudflared.listTunnelsOnAccount();
1283
- }
1284
- catch (err) {
1285
- const msg = err instanceof Error ? err.message : String(err);
1286
- log(`post-cleanup tunnel refresh failed: ${msg}`);
1287
- }
1288
- }
1289
- catch (err) {
1290
- const msg = err instanceof Error ? err.message : String(err);
1291
- log(`cleanup failed: ${msg}`);
1292
- return result({
1293
- status: "error",
1294
- message: `Could not clean up your Cloudflare account: ${msg}`,
1295
- });
1296
- }
1297
- }
1298
- // ── Step 4: Labels ──────────────────────────────────────────────
1299
- // Resolution precedence: args > persisted state hostnames.
1300
- const resolvedAdminLabel = adminLabel ?? stateAdminLabel;
1301
- const resolvedPublicLabel = publicLabel ?? statePublicLabel ?? null;
1302
- if (!resolvedAdminLabel) {
1303
- log(`ui=tunnel-route-picker rendered tunnelDomain=${domain}`);
1304
- return result({
1305
- status: "awaiting_labels",
1306
- message: `Pick a short name for your admin address on ${domain}. You can also pick one for a public address, or leave it empty.`,
1307
- data: {
1308
- domain,
1309
- render: {
1310
- name: "tunnel-route-picker",
1311
- data: {
1312
- domain,
1313
- title: "Pick your addresses",
1314
- description: "Your admin address is required. Public is optional and can be added later.",
1315
- submitLabel: "Set up addresses",
1316
- },
1317
- },
1318
- },
1319
- });
1320
- }
1321
- // Defense-in-depth label validation (UI already filters, but tool
1322
- // is authoritative — an agent misroute must fail loudly, not silently).
1323
- if (!LABEL_RE.test(resolvedAdminLabel)) {
1324
- log(`invalid admin label: ${resolvedAdminLabel}`);
1325
- return result({
1326
- status: "label-taken",
1327
- message: "Admin label is not a valid DNS label. Pick something that is lowercase letters, numbers, and hyphens.",
1328
- data: {
1329
- domain,
1330
- field: "admin",
1331
- render: {
1332
- name: "tunnel-route-picker",
1333
- data: {
1334
- domain,
1335
- title: "Pick your addresses",
1336
- defaultAdmin: "",
1337
- defaultPublic: resolvedPublicLabel ?? "",
1338
- error: { field: "admin", message: "Not a valid address — use lowercase letters, numbers, and hyphens." },
1339
- submitLabel: "Set up addresses",
1340
- },
1341
- },
1342
- },
1343
- });
1344
- }
1345
- if (resolvedPublicLabel !== null && resolvedPublicLabel !== "" && !LABEL_RE.test(resolvedPublicLabel)) {
1346
- log(`invalid public label: ${resolvedPublicLabel}`);
1347
- return result({
1348
- status: "label-taken",
1349
- message: "Public label is not a valid DNS label.",
1350
- data: {
1351
- domain,
1352
- field: "public",
1353
- render: {
1354
- name: "tunnel-route-picker",
1355
- data: {
1356
- domain,
1357
- defaultAdmin: resolvedAdminLabel,
1358
- defaultPublic: "",
1359
- error: { field: "public", message: "Not a valid address." },
1360
- submitLabel: "Set up addresses",
1361
- },
1362
- },
1363
- },
1364
- });
1365
- }
1366
- if (resolvedPublicLabel && resolvedAdminLabel === resolvedPublicLabel) {
1367
- log(`admin and public labels identical: ${resolvedAdminLabel}`);
1368
- return result({
1369
- status: "label-taken",
1370
- message: "Admin and public addresses must differ.",
1371
- data: {
1372
- domain,
1373
- field: "public",
1374
- render: {
1375
- name: "tunnel-route-picker",
1376
- data: {
1377
- domain,
1378
- defaultAdmin: resolvedAdminLabel,
1379
- defaultPublic: "",
1380
- error: { field: "public", message: "Public must differ from admin." },
1381
- submitLabel: "Set up addresses",
1382
- },
1383
- },
1384
- },
1385
- });
1386
- }
1387
- const adminHostname = `${resolvedAdminLabel}.${domain}`;
1388
- const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
1389
- const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
1390
- // Account-zone scope check happens inside routeDnsCli (Step 6) — the
1391
- // domain was picked from the live account zone list, so by construction
1392
- // it is on the bound account. No pre-flight needed here.
1393
- // ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
1394
- let tunnelId = existingState?.tunnelId ?? null;
1395
- let tunnelName = existingState?.tunnelName ?? null;
1396
- let credentialsPath = existingState?.credentialsPath ?? null;
1397
- if (!tunnelId) {
1398
- if (selectedTunnelId && selectedTunnelId !== "__new__") {
1399
- // User picked an existing tunnel — reuse its identity.
1400
- const selected = allTunnels.find((t) => t.id === selectedTunnelId);
1401
- if (!selected) {
1402
- return result({
1403
- status: "error",
1404
- message: "Selected tunnel not found on account.",
1405
- });
1406
- }
1407
- if (!hasBrandCredsFor(selected.id)) {
1408
- // Cannot reuse without credentials — this tunnel belongs to
1409
- // another device on the same account. Surface structured error.
1410
- log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${selected.id}`);
1411
- return result({
1412
- status: "tunnel-name-taken",
1413
- message: `The tunnel "${selected.name}" belongs to another device. Pick a different label for this one.`,
1414
- data: {
1415
- suggestedLabel: `${resolvedAdminLabel}2`,
1416
- existingTunnelId: selected.id,
1417
- render: {
1418
- name: "tunnel-route-picker",
1419
- data: {
1420
- domain,
1421
- defaultAdmin: "",
1422
- defaultPublic: resolvedPublicLabel ?? "",
1423
- error: { field: "admin", message: `That address is already taken. Pick a different one.` },
1424
- submitLabel: "Set up addresses",
1425
- },
1426
- },
1427
- },
1428
- });
1429
- }
1430
- tunnelId = selected.id;
1431
- tunnelName = selected.name;
1432
- }
1433
- else {
1434
- // Create new — tunnel name is derived from admin label + domain.
1435
- const derivedName = deriveTunnelName(resolvedAdminLabel, domain);
1436
- log(`branch=new-tunnel zoneName=${domain} tunnelName=${derivedName}`);
1437
- const accountExisting = allTunnels.find((t) => t.name === derivedName);
1438
- if (accountExisting && !hasBrandCredsFor(accountExisting.id)) {
1439
- log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${accountExisting.id}`);
1440
- return result({
1441
- status: "tunnel-name-taken",
1442
- message: `The address ${resolvedAdminLabel}.${domain} is already in use by another device. Pick a different one.`,
1443
- data: {
1444
- domain,
1445
- suggestedLabel: `${resolvedAdminLabel}2`,
1446
- existingTunnelId: accountExisting.id,
1447
- field: "admin",
1448
- render: {
1449
- name: "tunnel-route-picker",
1450
- data: {
1451
- domain,
1452
- defaultAdmin: "",
1453
- defaultPublic: resolvedPublicLabel ?? "",
1454
- error: { field: "admin", message: `That address is already taken — pick a different one.` },
1455
- submitLabel: "Set up addresses",
1456
- },
1457
- },
1458
- },
1459
- });
1460
- }
1461
- try {
1462
- const created = cloudflared.createTunnelCli(derivedName);
1463
- tunnelId = created.tunnelId;
1464
- tunnelName = created.tunnelName;
1465
- credentialsPath = created.credentialsPath;
1466
- }
1467
- catch (err) {
1468
- const msg = err instanceof Error ? err.message : String(err);
1469
- log(`tunnel creation failed: ${msg}`);
1470
- return result({ status: "error", message: `Could not create the tunnel: ${msg}` });
1471
- }
1472
- }
1473
- }
1474
- if (!tunnelId || !tunnelName) {
1475
- return result({ status: "error", message: "Tunnel identity could not be resolved." });
1476
- }
1477
- // Fallback credentials path when reusing a tunnel picked from the UI:
1478
- // createTunnelCli already copies from ~/.cloudflared to the brand dir,
1479
- // but that runs only in the create branch. For the "reuse existing"
1480
- // branch, derive the same brand-dir path here.
1481
- if (!credentialsPath) {
1482
- credentialsPath = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
1483
- }
1484
- // ── Step 6: DNS routing + config (idempotent every call) ────────
1485
- for (const h of hostnames) {
1486
- const check = await cloudflared.checkDnsCollision(h, tunnelId);
1487
- if (check.collision) {
1488
- const offendingField = h === adminHostname ? "admin" : "public";
1489
- log(`collision type=label hostname=${h} existingTunnelId=${check.existingTunnelId}`);
1490
- return result({
1491
- status: "label-taken",
1492
- message: `The address ${h} already points to a different tunnel. Pick a different ${offendingField} label.`,
1493
- data: {
1494
- domain,
1495
- field: offendingField,
1496
- existingTunnelId: check.existingTunnelId ?? undefined,
1497
- render: {
1498
- name: "tunnel-route-picker",
1499
- data: {
1500
- domain,
1501
- defaultAdmin: offendingField === "admin" ? "" : resolvedAdminLabel,
1502
- defaultPublic: offendingField === "public" ? "" : (resolvedPublicLabel ?? ""),
1503
- error: { field: offendingField, message: `That address is already in use by another device.` },
1504
- submitLabel: "Set up addresses",
1505
- },
1506
- },
1507
- },
1508
- });
1509
- }
1510
- }
1511
- const configPath = cloudflared.writeLocalConfig(tunnelId, credentialsPath, hostnames, platformPort);
1512
- cloudflared.saveTunnelIdentity({
1513
- tunnelId,
1514
- tunnelName,
1515
- domain,
1516
- configPath,
1517
- credentialsPath,
1518
- adminHostname,
1519
- publicHostname,
1520
- });
1521
- for (const h of hostnames) {
1522
- await cloudflared.routeDnsCli(tunnelId, h);
1523
- }
1524
- log(`ui=tunnel-route-picker submitted adminLabel=${resolvedAdminLabel} publicLabel=${resolvedPublicLabel ?? "null"}`);
1525
- // ── Step 7: Remote auth check ───────────────────────────────────
1526
- // scrypt hashing on Pi ARM takes 2-5s after form submission —
1527
- // wait before checking to avoid a false negative on every first attempt
1528
- log("waiting 3s for scrypt hash before checking remote auth");
1529
- await new Promise((r) => setTimeout(r, 3000));
1530
- log("checking remote auth");
1531
- try {
1532
- const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
1533
- signal: AbortSignal.timeout(5000),
1534
- });
1535
- const body = (await res.json());
1536
- if (!body.configured) {
1537
- const setupUrl = `http://${hostname()}.local:${platformPort}/__remote-auth/setup`;
1538
- log("remote auth not configured");
1539
- return result({
1540
- status: "awaiting_password",
1541
- message: `Before enabling remote access, you need to set a password that protects your admin interface over the internet.\n\nOpen this URL in your own browser (not the one in the chat) and set a password:\n\`${setupUrl}\`\n\nChoose something strong — at least 8 characters with a number and a special character. Let me know when you've submitted it.`,
1542
- data: { setupUrl },
1543
- });
1544
- }
1545
- log("remote auth configured");
1546
- }
1547
- catch {
1548
- log("remote auth check failed — platform may not be running");
1549
- return result({
1550
- status: "error",
1551
- message: "Could not verify remote access password — the platform may not be running. Make sure it's started and try again.",
1552
- });
1553
- }
1554
- // ── Step 8: Tunnel enable ───────────────────────────────────────
1555
- const tunnelState = cloudflared.getPersistedState();
1556
- if (!tunnelState) {
1557
- return result({ status: "error", message: "Tunnel state lost — please try again." });
1558
- }
1559
- const status = await cloudflared.getStatus(domain);
1560
- if (!status.running) {
1561
- log("tunnel not running — starting");
1562
- try {
1563
- cloudflared.startTunnel({
1564
- tunnelId: tunnelState.tunnelId,
1565
- tunnelName: tunnelState.tunnelName,
1566
- domain,
1567
- configPath: tunnelState.configPath,
1568
- credentialsPath: tunnelState.credentialsPath,
1569
- });
1570
- // Wait to confirm it didn't crash immediately
1571
- for (let i = 0; i < 10; i++) {
1572
- await new Promise((r) => setTimeout(r, 500));
1573
- const check = await cloudflared.getStatus(domain);
1574
- if (!check.running) {
1575
- const brand = cloudflared.loadBrand();
1576
- log("tunnel process exited immediately");
1577
- return result({
1578
- status: "error",
1579
- message: `The tunnel started but exited immediately. Check ~/${brand.configDir}/logs/cloudflared.log for details.`,
1580
- });
1581
- }
1582
- }
1583
- // Verify admin URL is reachable through Cloudflare edge
1584
- const verifyUrl = `https://${adminHostname}`;
1585
- let verified = false;
1586
- let lastStatus = null;
1587
- for (let attempt = 0; attempt < 6; attempt++) {
1588
- if (attempt > 0)
1589
- await new Promise((r) => setTimeout(r, 5000));
1590
- try {
1591
- const res = await fetch(verifyUrl, {
1592
- signal: AbortSignal.timeout(10000),
1593
- redirect: "manual",
1594
- });
1595
- lastStatus = res.status;
1596
- if (res.status !== 530) {
1597
- verified = true;
1598
- log(`verified url=${verifyUrl} status=${res.status}`);
1599
- break;
1600
- }
1601
- }
1602
- catch {
1603
- // retry
1604
- }
1605
- }
1606
- if (!verified) {
1607
- log(`verified url=${verifyUrl} status=${lastStatus ?? "timeout"} result=unreachable`);
1608
- return result({
1609
- status: "error",
1610
- message: `The tunnel is running but the admin URL is not reachable yet. This usually resolves in a few minutes as DNS propagates. Try again shortly.`,
1611
- });
1612
- }
1613
- log("tunnel started and verified");
1614
- }
1615
- catch (err) {
1616
- const msg = err instanceof Error ? err.message : String(err);
1617
- log(`tunnel start failed: ${msg}`);
1618
- return result({ status: "error", message: `Could not start the tunnel: ${msg}` });
1619
- }
1620
- }
1621
- else {
1622
- log(`tunnel already running (PID ${status.pid})`);
1623
- }
1624
- // ── Complete ────────────────────────────────────────────────────
1625
- const adminUrl = `https://${adminHostname}`;
1626
- const publicUrl = publicHostname ? `https://${publicHostname}` : undefined;
1627
- log(`complete — admin: ${adminUrl}${publicUrl ? `, public: ${publicUrl}` : ""}`);
1628
- const urlLines = publicUrl
1629
- ? `Admin: ${adminUrl}\nPublic: ${publicUrl}`
1630
- : `Admin: ${adminUrl}`;
1631
- const reachableNote = publicUrl
1632
- ? "Both URLs are live and reachable."
1633
- : "The admin URL is live and reachable.";
1634
- return result({
1635
- status: "complete",
1636
- message: `Remote access is set up and verified.\n\n${urlLines}\n\n${reachableNote} The tunnel auto-starts on device reboot.`,
1637
- data: { adminUrl, publicUrl },
1638
- });
1639
- }
1640
- catch (err) {
1641
- const msg = err instanceof Error ? err.message : String(err);
1642
- log(`unexpected error: ${msg}`);
1643
- return result({ status: "error", message: `Setup failed: ${msg}` });
1644
- }
797
+ main().catch((err) => {
798
+ console.error(`[cloudflare-mcp] fatal: ${err}`);
799
+ process.exit(1);
1645
800
  });
1646
- function result(r) {
1647
- return {
1648
- content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
1649
- };
1650
- }
1651
- const transport = new StdioServerTransport();
1652
- await server.connect(transport);
1653
801
  //# sourceMappingURL=index.js.map