@rubytech/create-maxy 1.0.623 → 1.0.624

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 (22) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/mcp/dist/index.js +1 -1
  3. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  4. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +9 -12
  5. package/payload/platform/plugins/cloudflare/PLUGIN.md +31 -44
  6. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +13 -875
  7. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  8. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  9. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +1 -0
  10. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  11. package/payload/platform/plugins/cloudflare/references/dashboard-guide.md +108 -0
  12. package/payload/platform/plugins/cloudflare/references/manual-setup.md +445 -0
  13. package/payload/platform/plugins/cloudflare/references/reset-guide.md +118 -0
  14. package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +65 -0
  15. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +244 -0
  16. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +96 -5
  17. package/payload/platform/plugins/docs/references/cloudflare.md +91 -34
  18. package/payload/platform/templates/agents/admin/IDENTITY.md +10 -4
  19. package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
  20. package/payload/server/server.js +187 -299
  21. package/payload/platform/config/cloudflared.yml +0 -17
  22. package/payload/platform/plugins/cloudflare/references/setup-guide.md +0 -132
@@ -2,883 +2,21 @@ import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
2
2
  initStderrTee("cloudflare");
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { z } from "zod";
6
- import { Resolver } from "node:dns/promises";
7
- import { hostname as osHostname } from "node:os";
8
- import * as cloudflared from "./lib/cloudflared.js";
9
- import * as orchestrator from "./lib/setup-orchestrator.js";
10
- import { deviceUrlBlock } from "../../../../lib/device-url/dist/index.js";
11
- /** Sign-in is a device-bound flow — Cloudflare's OAuth callback lands on
12
- * cloudflared's local HTTP server on the device, so the operator must
13
- * complete the sign-in from the device's browser. Emit the auth URL as
14
- * a maxy-device-url fenced block so the chat UI renders a click-to-open
15
- * button rather than a plain link. See Task 546 and .docs/web-chat.md. */
16
- function cloudflareSignInBlock(authUrl) {
17
- return deviceUrlBlock({
18
- url: authUrl,
19
- intent: "Sign in to Cloudflare",
20
- hostname: osHostname(),
21
- });
22
- }
5
+ // Task 554: the Cloudflare plugin exposes zero agent-facing tools. Every
6
+ // Cloudflare operation is driven by shell scripts (setup-tunnel.sh,
7
+ // reset-tunnel.sh) invoked via Bash, plus dashboard click-paths relayed
8
+ // verbatim from the skill's reference files. The MCP server process
9
+ // still spawns for lifecycle and stderr-tee consistency with other
10
+ // plugins, but its tool list is empty by design.
11
+ //
12
+ // See platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md and
13
+ // platform/plugins/cloudflare/references/ for the operator-facing
14
+ // surface. `lib/cloudflared.ts` and `lib/setup-orchestrator.ts` are
15
+ // retained as private implementation layers that nothing currently
16
+ // imports; a follow-up task will delete them once that is confirmed.
23
17
  const server = new McpServer({
24
18
  name: "cloudflare",
25
- version: "0.3.0",
26
- });
27
- // ---------------------------------------------------------------------------
28
- // tunnel-status trailing guidance — deterministic 1:1 mapping from status
29
- // enum → single prescribed recovery sentence.
30
- //
31
- // The trailing string is a prompt for the agent's next turn. Task 541 closed
32
- // the recurrence loop where a "check in the browser" sentence primed a 44-
33
- // tool-call dashboard cascade. The mapping here is exhaustive for every
34
- // reachable `unhealthyReason`; there is no generic fallback branch. Each
35
- // sentence names exactly one next action so the agent cannot fork on it.
36
- // ---------------------------------------------------------------------------
37
- const BOUND_ACCOUNT_MISMATCH_SENTENCE = (hostname) => `Sign in to Cloudflare at dash.cloudflare.com in the account that owns ${hostname} — ` +
38
- `the account with the little orange cloud next to it in your browser tab — then tell me ` +
39
- `and I'll restart the tunnel login.`;
40
- function buildTunnelStatusGuidance(status) {
41
- if (!status.unhealthyReason)
42
- return "";
43
- if (status.unhealthyReason === "not-running") {
44
- return `\n\nThe tunnel process is not running. Ask the user if they want me to enable it.`;
45
- }
46
- if (status.unhealthyReason === "no-tunnel-configured") {
47
- return `\n\nNo tunnel is configured on this laptop yet. Run tunnel-create to set one up.`;
48
- }
49
- if (status.unhealthyReason === "bound-account-does-not-own-hostname") {
50
- // Use the first configured hostname as the anchor — matches how the
51
- // operator thinks about the setup. If configuredHostnames is empty
52
- // (unreachable here because the enum is only set when probes ran)
53
- // fall back to domain.
54
- const anchor = status.configuredHostnames[0] ??
55
- status.persistedHostnames[0] ??
56
- status.domain ??
57
- "your domain";
58
- return `\n\n${BOUND_ACCOUNT_MISMATCH_SENTENCE(anchor)}`;
59
- }
60
- if (status.unhealthyReason === "hostname-probes-failed") {
61
- // Dominant per-hostname mode drives the sentence. All failing probes
62
- // share one mode in the expected failure classes — when they diverge,
63
- // the first failure wins (the per-hostname list is already printed in
64
- // the JSON above; the sentence names the one action the agent takes).
65
- const firstFailure = status.probes.find((p) => p.failureMode !== "ok");
66
- const mode = firstFailure?.failureMode ?? "edge-unreachable";
67
- if (mode === "tunnel-not-matched" || mode === "cname-points-elsewhere") {
68
- return `\n\nDNS is pointing at a different tunnel. I'll re-point it.`;
69
- }
70
- if (mode === "dns-missing") {
71
- return `\n\nDNS is not set up yet. I'll create it.`;
72
- }
73
- // edge-unreachable — transient Cloudflare edge or connectivity; the
74
- // agent should not immediately re-probe or open the dashboard.
75
- return `\n\nThe tunnel is not reachable through Cloudflare right now. This usually clears in a minute.`;
76
- }
77
- // Exhaustiveness guard — a new enum value must be added to this switch
78
- // before it ever appears in a tool result. Returning empty string would
79
- // let a novel failure mode silently emit JSON-only output. The bare
80
- // assignment (no `as never` cast) is the point: TypeScript narrows
81
- // `status.unhealthyReason` to `never` after every branch above returns,
82
- // so a new `TunnelUnhealthyReason` value introduced without a branch
83
- // here fails at compile time.
84
- const _exhaustive = status.unhealthyReason;
85
- console.error(`[cloudflare:tunnel-status:unknown-reason] reason=${String(_exhaustive)}`);
86
- return "";
87
- }
88
- // ===================================================================
89
- // Authentication — OAuth cert.pem only
90
- //
91
- // The plugin recognises exactly one identity: the Cloudflare account
92
- // cert.pem was bound to via `cloudflared tunnel login`. No API token
93
- // handling, no SDK, no read of account state by any code path. The
94
- // operator's logged-in Cloudflare dashboard session is the source of
95
- // truth for which domains exist and who owns them; the agent's role
96
- // is to instruct the operator where to click.
97
- // ===================================================================
98
- 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.", {
99
- force: z
100
- .boolean()
101
- .optional()
102
- .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)."),
103
- }, async ({ force }) => {
104
- try {
105
- if (force) {
106
- console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
107
- cloudflared.resetAuth();
108
- }
109
- const auth = cloudflared.validateAuth();
110
- if (auth.bound) {
111
- console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.boundAccountId}`);
112
- return {
113
- content: [
114
- {
115
- type: "text",
116
- text: `Sign-in complete.`,
117
- },
118
- ],
119
- };
120
- }
121
- if (auth.hasCert) {
122
- const creds = cloudflared.parseCertPem();
123
- if (!creds) {
124
- return {
125
- content: [
126
- {
127
- type: "text",
128
- 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.`,
129
- },
130
- ],
131
- isError: true,
132
- };
133
- }
134
- if (!auth.hasBinding) {
135
- cloudflared.materializeBinding("migration");
136
- console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.certAccountId} source=migration`);
137
- return {
138
- content: [
139
- {
140
- type: "text",
141
- text: `Sign-in complete.`,
142
- },
143
- ],
144
- };
145
- }
146
- if (auth.certAccountId !== auth.boundAccountId) {
147
- return {
148
- content: [
149
- {
150
- type: "text",
151
- text: `The Cloudflare sign-in on this laptop has changed since it was first set up. ${cloudflared.recoveryMessage()}`,
152
- },
153
- ],
154
- isError: true,
155
- };
156
- }
157
- }
158
- if (!cloudflared.isInstalled()) {
159
- return {
160
- content: [
161
- {
162
- type: "text",
163
- text: "cloudflared is not installed. Run tunnel-install first.",
164
- },
165
- ],
166
- isError: true,
167
- };
168
- }
169
- // Three-state login machine. `complete` is already handled above via
170
- // `auth.bound` / migration path, so here we branch on in-progress vs.
171
- // terminal failure (PID dead + cert absent, regardless of why). Both
172
- // branches emit a `maxy-device-url` fenced block so the chat UI
173
- // renders a click-to-open-on-device button — cloudflared's courtesy
174
- // browser-launch failing no longer matters to the operator because
175
- // the button drives the device's VNC browser directly.
176
- const loginStatus = cloudflared.getLoginStatus();
177
- if (loginStatus.state === "failed") {
178
- console.error(`[cloudflare:tunnel-login:failed] reason=${loginStatus.reason} ` +
179
- `pid=${loginStatus.loginState?.pid ?? "none"}`);
180
- cloudflared.resetAuth();
181
- const restarted = await cloudflared.tunnelLogin();
182
- return {
183
- content: [
184
- {
185
- type: "text",
186
- text: `Sign-in ended without saving the cert. Restarting.\n\n` +
187
- `${cloudflareSignInBlock(restarted.authUrl)}\n\n` +
188
- `Click the button to open the Cloudflare sign-in page on the device's browser. ` +
189
- `Pick the Cloudflare account you want this laptop signed into, then click Authorize. ` +
190
- `Call tunnel-login again once you confirm — it will detect the completed sign-in automatically.`,
191
- },
192
- ],
193
- };
194
- }
195
- if (loginStatus.state === "in-progress") {
196
- console.error(`[cloudflare:tunnel-login:waiting] pid=${loginStatus.loginState.pid} authUrl=${loginStatus.loginState.authUrl}`);
197
- if (loginStatus.browserLaunchFailed) {
198
- // Task 545 observability: cloudflared's browser-launch subcommand
199
- // failed but the OAuth callback loop is still alive. Task 546
200
- // resolves the user-facing half of this — the device-URL block
201
- // below renders as a click-to-open-on-device button in the chat
202
- // UI, so the operator does not need to know cloudflared's
203
- // launcher misbehaved. The log line remains for diagnostics.
204
- console.error(`[cloudflare:tunnel-login:browser-launch-failed] pid=${loginStatus.loginState.pid}`);
205
- }
206
- return {
207
- content: [
208
- {
209
- type: "text",
210
- text: `Sign-in in progress — a browser window is already open on the device (started ${new Date(loginStatus.loginState.startedAt).toISOString()}).\n\n` +
211
- `${cloudflareSignInBlock(loginStatus.loginState.authUrl)}\n\n` +
212
- `Finish the sign-in on the device's browser and click Authorize. ` +
213
- `Call tunnel-login again once you confirm — it will detect the completed sign-in automatically.`,
214
- },
215
- ],
216
- };
217
- }
218
- // state === "idle" — spawn fresh.
219
- const result = await cloudflared.tunnelLogin();
220
- return {
221
- content: [
222
- {
223
- type: "text",
224
- text: `Cloudflare sign-in started on the device.\n\n` +
225
- `${cloudflareSignInBlock(result.authUrl)}\n\n` +
226
- `Click the button to open the sign-in page on the device's browser. ` +
227
- `Pick the Cloudflare account you want this laptop signed into, then click Authorize. ` +
228
- `Call tunnel-login again once you confirm — it will detect the completed sign-in automatically.`,
229
- },
230
- ],
231
- };
232
- }
233
- catch (err) {
234
- return {
235
- content: [
236
- {
237
- type: "text",
238
- text: `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
239
- },
240
- ],
241
- isError: true,
242
- };
243
- }
244
- });
245
- 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.", {
246
- domain: z
247
- .string()
248
- .optional()
249
- .describe("The bare domain (e.g. 'maxy.bot'). If omitted, derived from persisted state."),
250
- }, async ({ domain }) => {
251
- try {
252
- const status = await cloudflared.getStatus(domain);
253
- const guidance = buildTunnelStatusGuidance(status);
254
- return {
255
- content: [
256
- {
257
- type: "text",
258
- text: JSON.stringify(status, null, 2) + guidance,
259
- },
260
- ],
261
- };
262
- }
263
- catch (err) {
264
- return {
265
- content: [
266
- {
267
- type: "text",
268
- text: `Failed: ${err instanceof Error ? err.message : String(err)}`,
269
- },
270
- ],
271
- isError: true,
272
- };
273
- }
274
- });
275
- server.tool("tunnel-install", "Install the cloudflared binary if not already present.", {}, async () => {
276
- const { execFileSync } = await import("node:child_process");
277
- if (cloudflared.isInstalled()) {
278
- const v = cloudflared.version();
279
- return {
280
- content: [
281
- {
282
- type: "text",
283
- text: `cloudflared is already installed (v${v}).`,
284
- },
285
- ],
286
- };
287
- }
288
- try {
289
- const arch = process.arch === "arm64" ? "arm64" : "amd64";
290
- const debUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`;
291
- execFileSync("curl", ["-fsSL", debUrl, "-o", "/tmp/cloudflared.deb"], {
292
- timeout: 60000,
293
- });
294
- execFileSync("sudo", ["dpkg", "-i", "/tmp/cloudflared.deb"], {
295
- timeout: 30000,
296
- });
297
- execFileSync("rm", ["-f", "/tmp/cloudflared.deb"]);
298
- return {
299
- content: [
300
- {
301
- type: "text",
302
- text: `cloudflared installed (v${cloudflared.version()}).`,
303
- },
304
- ],
305
- };
306
- }
307
- catch (err) {
308
- return {
309
- content: [
310
- {
311
- type: "text",
312
- text: `Installation failed: ${err instanceof Error ? err.message : String(err)}`,
313
- },
314
- ],
315
- isError: true,
316
- };
317
- }
318
- });
319
- 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.", {
320
- domain: z
321
- .string()
322
- .describe("The bare domain (e.g. 'maxy.bot')."),
323
- adminSubdomain: z
324
- .string()
325
- .min(1)
326
- .describe("Subdomain for the admin interface (e.g. 'admin', 'joel'). Creates {adminSubdomain}.{domain}."),
327
- publicSubdomain: z
328
- .string()
329
- .min(1)
330
- .optional()
331
- .describe("Subdomain for the public chat (e.g. 'public', 'shop'). Creates {publicSubdomain}.{domain}. Omit to skip the public endpoint."),
332
- tunnelName: z
333
- .string()
334
- .optional()
335
- .describe("Human-readable tunnel name (default: derived from domain)"),
336
- }, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
337
- if (publicSubdomain && adminSubdomain === publicSubdomain) {
338
- return {
339
- content: [
340
- {
341
- type: "text",
342
- text: `Invalid: adminSubdomain and publicSubdomain are both "${adminSubdomain}". They must be different.`,
343
- },
344
- ],
345
- isError: true,
346
- };
347
- }
348
- try {
349
- const auth = cloudflared.validateAuth();
350
- if (!auth.bound) {
351
- return {
352
- content: [
353
- {
354
- type: "text",
355
- text: `REFUSED: this laptop is not signed into Cloudflare yet. ${cloudflared.recoveryMessage()}`,
356
- },
357
- ],
358
- isError: true,
359
- };
360
- }
361
- const name = tunnelName ?? domain.replace(/\./g, "-");
362
- const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
363
- const adminHostname = `${adminSubdomain}.${domain}`;
364
- const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
365
- const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
366
- console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
367
- // Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
368
- const tunnel = cloudflared.createTunnelCli(name);
369
- // Step 2: Write local config.yml with ingress rules
370
- const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
371
- // Persist tunnel identity with actual hostnames
372
- cloudflared.saveTunnelIdentity({
373
- tunnelId: tunnel.tunnelId,
374
- tunnelName: tunnel.tunnelName,
375
- domain,
376
- configPath,
377
- credentialsPath: tunnel.credentialsPath,
378
- adminHostname,
379
- publicHostname,
380
- });
381
- // Step 3: Route DNS via CLI for each hostname (pre-flight + post-flight in routeDnsCli)
382
- const dnsResults = [];
383
- for (const h of hostnames) {
384
- const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
385
- dnsResults.push({
386
- hostname: h,
387
- fqdn: route.fqdn,
388
- note: route.created ? "created" : "already existed",
389
- });
390
- }
391
- // Step 4: Verify hostnames resolve via public DNS
392
- const resolver = new Resolver();
393
- resolver.setServers(["1.1.1.1", "8.8.8.8"]);
394
- const dnsWarnings = [];
395
- for (const h of hostnames) {
396
- try {
397
- await resolver.resolve4(h);
398
- }
399
- catch {
400
- dnsWarnings.push(h);
401
- }
402
- }
403
- let dnsNote = "";
404
- if (dnsWarnings.length > 0) {
405
- 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.`;
406
- }
407
- const dnsLines = dnsResults
408
- .map((r) => ` ${r.hostname} → this tunnel (${r.note})`)
409
- .join("\n");
410
- return {
411
- content: [
412
- {
413
- type: "text",
414
- text: `Tunnel created.\n\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
415
- },
416
- ],
417
- };
418
- }
419
- catch (err) {
420
- if (err instanceof cloudflared.CloudflareRefusalError) {
421
- return {
422
- content: [{ type: "text", text: err.refusal.message }],
423
- isError: true,
424
- };
425
- }
426
- return {
427
- content: [
428
- {
429
- type: "text",
430
- text: `Create failed: ${err instanceof Error ? err.message : String(err)}`,
431
- },
432
- ],
433
- isError: true,
434
- };
435
- }
436
- });
437
- server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel must be created first via tunnel-create. Reads hostnames from persisted state for DNS verification and reachability checks. If tunnelId and domain are omitted, reads them from persisted tunnel state.", {
438
- tunnelId: z.string().optional().describe("Tunnel UUID from tunnel-create. If omitted, reads from persisted tunnel state."),
439
- domain: z.string().optional().describe("The bare domain (e.g. 'maxy.bot'). If omitted, reads from persisted tunnel state."),
440
- }, async ({ tunnelId: tunnelIdParam, domain: domainParam }) => {
441
- try {
442
- const tunnelId = tunnelIdParam ?? cloudflared.getPersistedTunnelId();
443
- const domain = domainParam ?? cloudflared.getPersistedDomain();
444
- if (!tunnelId) {
445
- return {
446
- content: [
447
- {
448
- type: "text",
449
- text: "No tunnel found on this laptop. Run tunnel-create first.",
450
- },
451
- ],
452
- isError: true,
453
- };
454
- }
455
- if (!domain) {
456
- return {
457
- content: [
458
- {
459
- type: "text",
460
- text: "No domain recorded for this tunnel. Run tunnel-create first to set one up with a domain.",
461
- },
462
- ],
463
- isError: true,
464
- };
465
- }
466
- const hostnames = cloudflared.getPersistedHostnames();
467
- if (hostnames.length === 0) {
468
- return {
469
- content: [
470
- {
471
- type: "text",
472
- text: "No addresses recorded for this tunnel. Run tunnel-create first.",
473
- },
474
- ],
475
- isError: true,
476
- };
477
- }
478
- console.error(`[tunnel-enable] checking hostnames=${JSON.stringify(hostnames)}`);
479
- // GATE 1: Check if actual hostnames resolve via public DNS
480
- const resolver = new Resolver();
481
- resolver.setServers(["1.1.1.1", "8.8.8.8"]);
482
- const unresolvedSubs = [];
483
- for (const h of hostnames) {
484
- try {
485
- await resolver.resolve4(h);
486
- }
487
- catch {
488
- unresolvedSubs.push(h);
489
- }
490
- }
491
- if (unresolvedSubs.length > 0) {
492
- return {
493
- content: [
494
- {
495
- type: "text",
496
- 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.`,
497
- },
498
- ],
499
- isError: true,
500
- };
501
- }
502
- const brand = cloudflared.loadBrand();
503
- const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
504
- console.error(`[tunnel-enable] using PLATFORM_PORT=${platformPort}`);
505
- // GATE 2: Remote auth must be configured
506
- try {
507
- const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
508
- signal: AbortSignal.timeout(5000),
509
- });
510
- const body = await res.json();
511
- if (!body.configured) {
512
- const setupUrl = `http://${osHostname()}.local:${platformPort}/__remote-auth/setup`;
513
- return {
514
- content: [
515
- {
516
- type: "text",
517
- text: `REFUSED: Remote access password is not configured. The admin interface would be exposed to the internet without authentication.\n\n` +
518
- `${deviceUrlBlock({ url: setupUrl, intent: "Set remote access password", hostname: osHostname() })}\n\n` +
519
- `Click the button to open the setup page on the device's browser, set the password, then ask me to enable the tunnel again. Do NOT collect the password in chat.`,
520
- },
521
- ],
522
- isError: true,
523
- };
524
- }
525
- }
526
- catch {
527
- return {
528
- content: [
529
- {
530
- type: "text",
531
- text: `REFUSED: Cannot verify remote authentication — the web server at http://127.0.0.1:${platformPort} is not reachable. The admin interface cannot be exposed without a running auth gate.\n\nEnsure the ${brand.productName} platform is running before enabling the tunnel.`,
532
- },
533
- ],
534
- isError: true,
535
- };
536
- }
537
- // GATE 3: cert.pem + binding
538
- const enableAuth = cloudflared.validateAuth();
539
- if (!enableAuth.bound) {
540
- return {
541
- content: [
542
- {
543
- type: "text",
544
- text: `REFUSED: Cannot start tunnel — this laptop is not signed into Cloudflare or the sign-in has drifted. ${cloudflared.recoveryMessage()}`,
545
- },
546
- ],
547
- isError: true,
548
- };
549
- }
550
- // GATE 4: config.yml must exist
551
- const tunnelState = cloudflared.getPersistedState();
552
- const configPath = tunnelState?.configPath;
553
- const credentialsPath = tunnelState?.credentialsPath;
554
- const tunnelName = tunnelState?.tunnelName ?? tunnelId;
555
- if (!configPath) {
556
- return {
557
- content: [
558
- {
559
- type: "text",
560
- text: `REFUSED: No tunnel configuration found. Run tunnel-create first — it writes the config.yml needed to start the tunnel.`,
561
- },
562
- ],
563
- isError: true,
564
- };
565
- }
566
- cloudflared.startTunnel({
567
- tunnelId,
568
- tunnelName,
569
- domain,
570
- configPath,
571
- credentialsPath: credentialsPath ?? "",
572
- });
573
- const logHint = `~/${brand.configDir}/logs/cloudflared.log`;
574
- for (let i = 0; i < 10; i++) {
575
- await new Promise((r) => setTimeout(r, 500));
576
- const status = await cloudflared.getStatus(domain);
577
- if (!status.running) {
578
- return {
579
- content: [
580
- {
581
- type: "text",
582
- text: `Tunnel process exited immediately after starting. Check ${logHint} for details.`,
583
- },
584
- ],
585
- isError: true,
586
- };
587
- }
588
- }
589
- const status = await cloudflared.getStatus(domain);
590
- const verifyUrl = `https://${hostnames[0]}`;
591
- let verified = false;
592
- console.error(`[tunnel-enable] verifying url=${verifyUrl}`);
593
- for (let attempt = 0; attempt < 6; attempt++) {
594
- if (attempt > 0)
595
- await new Promise((r) => setTimeout(r, 5000));
596
- try {
597
- const res = await fetch(verifyUrl, {
598
- signal: AbortSignal.timeout(10000),
599
- redirect: "manual",
600
- });
601
- console.error(`[tunnel-enable] verified url=${verifyUrl} status=${res.status}`);
602
- if (res.status !== 530) {
603
- verified = true;
604
- break;
605
- }
606
- }
607
- catch {
608
- // network error — retry
609
- }
610
- }
611
- if (!verified) {
612
- return {
613
- content: [
614
- {
615
- type: "text",
616
- 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.`,
617
- },
618
- ],
619
- isError: true,
620
- };
621
- }
622
- const urlLines = hostnames.map((h, i) => {
623
- const label = i === 0 ? "Admin" : "Public";
624
- return ` ${label}: https://${h}`;
625
- }).join("\n");
626
- const reachableNote = hostnames.length > 1
627
- ? "All URLs are reachable through Cloudflare."
628
- : "The admin URL is reachable through Cloudflare.";
629
- return {
630
- content: [
631
- {
632
- type: "text",
633
- text: `Tunnel started and verified.\n\n PID: ${status.pid}\n${urlLines}\n\n${reachableNote} The tunnel auto-starts on device reboot.`,
634
- },
635
- ],
636
- };
637
- }
638
- catch (err) {
639
- if (err instanceof cloudflared.CloudflareRefusalError) {
640
- return {
641
- content: [{ type: "text", text: err.refusal.message }],
642
- isError: true,
643
- };
644
- }
645
- return {
646
- content: [
647
- {
648
- type: "text",
649
- text: `Enable failed: ${err instanceof Error ? err.message : String(err)}`,
650
- },
651
- ],
652
- isError: true,
653
- };
654
- }
655
- });
656
- server.tool("tunnel-disable", "Stop the Cloudflare Tunnel process. Config is preserved for re-enabling.", {}, async () => {
657
- try {
658
- cloudflared.stopTunnel();
659
- return {
660
- content: [
661
- {
662
- type: "text",
663
- text: "Tunnel stopped. Config preserved — use tunnel-enable to restart.",
664
- },
665
- ],
666
- };
667
- }
668
- catch (err) {
669
- return {
670
- content: [
671
- {
672
- type: "text",
673
- text: `Disable failed: ${err instanceof Error ? err.message : String(err)}`,
674
- },
675
- ],
676
- isError: true,
677
- };
678
- }
679
- });
680
- 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.", {
681
- hostname: z
682
- .string()
683
- .describe("The address to add (e.g. 'maxy.chat'). The domain must be on the Cloudflare account this laptop is signed into."),
684
- tunnelId: z
685
- .string()
686
- .describe("The tunnel UUID. Get this from tunnel-status."),
687
- }, async ({ hostname, tunnelId }) => {
688
- try {
689
- const route = await cloudflared.routeDnsCli(tunnelId, hostname);
690
- cloudflared.saveAliasDomain(hostname);
691
- const routeNote = route.created
692
- ? "created"
693
- : "already existed";
694
- return {
695
- content: [
696
- {
697
- type: "text",
698
- 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.`,
699
- },
700
- ],
701
- };
702
- }
703
- catch (err) {
704
- if (err instanceof cloudflared.CloudflareRefusalError) {
705
- return {
706
- content: [{ type: "text", text: err.refusal.message }],
707
- isError: true,
708
- };
709
- }
710
- return {
711
- content: [
712
- {
713
- type: "text",
714
- text: `Failed to add address: ${err instanceof Error ? err.message : String(err)}`,
715
- },
716
- ],
717
- isError: true,
718
- };
719
- }
720
- });
721
- server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslookup (not installed on Pi). Supports A, AAAA, CNAME, MX, TXT, NS, and SOA record types.", {
722
- hostname: z
723
- .string()
724
- .describe("The hostname to resolve (e.g. 'admin.maxy.bot')"),
725
- type: z
726
- .enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA"])
727
- .optional()
728
- .describe("Record type (default: A)"),
729
- nameserver: z
730
- .string()
731
- .optional()
732
- .describe("Custom nameserver IP (default: Cloudflare 1.1.1.1 + Google 8.8.8.8)"),
733
- }, async ({ hostname, type, nameserver }) => {
734
- try {
735
- const resolver = new Resolver();
736
- resolver.setServers(nameserver ? [nameserver] : ["1.1.1.1", "8.8.8.8"]);
737
- const recordType = type ?? "A";
738
- const results = {
739
- hostname,
740
- type: recordType,
741
- };
742
- switch (recordType) {
743
- case "A": {
744
- results.addresses = await resolver.resolve4(hostname);
745
- break;
746
- }
747
- case "AAAA": {
748
- results.addresses = await resolver.resolve6(hostname);
749
- break;
750
- }
751
- case "CNAME": {
752
- try {
753
- results.cnames = await resolver.resolveCname(hostname);
754
- }
755
- catch (cnameErr) {
756
- const cnameCode = cnameErr.code;
757
- if (cnameCode === "ENODATA") {
758
- try {
759
- const aRecords = await resolver.resolve4(hostname);
760
- results.note = "CNAME lookup returned ENODATA — record is likely flattened to A";
761
- results.addresses = aRecords;
762
- }
763
- catch {
764
- throw cnameErr;
765
- }
766
- }
767
- else {
768
- throw cnameErr;
769
- }
770
- }
771
- break;
772
- }
773
- case "MX": {
774
- results.exchanges = await resolver.resolveMx(hostname);
775
- break;
776
- }
777
- case "TXT": {
778
- results.records = await resolver.resolveTxt(hostname);
779
- break;
780
- }
781
- case "NS": {
782
- results.nameservers = await resolver.resolveNs(hostname);
783
- break;
784
- }
785
- case "SOA": {
786
- results.soa = await resolver.resolveSoa(hostname);
787
- break;
788
- }
789
- }
790
- return {
791
- content: [
792
- { type: "text", text: JSON.stringify(results, null, 2) },
793
- ],
794
- };
795
- }
796
- catch (err) {
797
- const msg = err instanceof Error ? err.message : String(err);
798
- const code = err.code;
799
- return {
800
- content: [
801
- {
802
- type: "text",
803
- text: `DNS lookup failed: ${msg}${code ? ` (${code})` : ""}\n\nCommon causes:\n- ENOTFOUND: hostname does not exist\n- ENODATA: no records of this type for hostname\n- ETIMEOUT: nameserver unreachable`,
804
- },
805
- ],
806
- isError: true,
807
- };
808
- }
809
- });
810
- // ===================================================================
811
- // Setup orchestrator — deterministic multi-step flow (Task 547)
812
- //
813
- // `cloudflare-setup-run` is the single operator-facing entry point for
814
- // tunnel setup. It drives the full login → create → enable → verify
815
- // sequence in code, using the enum output of `tunnel-status` to decide
816
- // each transition. The LLM never picks between tunnel-login/create/enable
817
- // during an active setup — those tools are removed from its menu by the
818
- // tool-surface filter (platform/ui/app/lib/tool-surface-filter.ts) while
819
- // `phase !== 'idle' && phase !== 'healthy' && phase !== 'unhealthy'`.
820
- //
821
- // See platform/plugins/cloudflare/mcp/src/lib/setup-orchestrator.ts for
822
- // the state machine.
823
- // ===================================================================
824
- server.tool("cloudflare-setup-run", "Run the full Cloudflare tunnel setup to completion. Single operator-facing entry point — handles sign-in, tunnel creation, DNS routing, and verification deterministically. Every sub-step is chosen in code, not by the LLM. Pass `domain` on the first call. Pass `adminSubdomain` (and optional `publicSubdomain`) once sign-in is complete. Pass `confirmed: true` to acknowledge the operator has finished a device-bound step (signed in, switched accounts). Re-entrant — safe to invoke repeatedly; persisted state resumes at the correct phase.", {
825
- domain: z
826
- .string()
827
- .optional()
828
- .describe("The bare domain (e.g. 'maxy.bot'). Required on the first call; ignored afterwards."),
829
- adminSubdomain: z
830
- .string()
831
- .optional()
832
- .describe("Admin subdomain label (e.g. 'admin' → admin.{domain}). Provide when the tool asks for subdomains."),
833
- publicSubdomain: z
834
- .string()
835
- .optional()
836
- .describe("Optional public chat subdomain label. Leave unset to skip the public endpoint."),
837
- confirmed: z
838
- .boolean()
839
- .optional()
840
- .describe("Set to true to signal the operator has completed a device-bound action (signed in, switched Cloudflare accounts). Ignored in phases where no such action is pending."),
841
- }, async ({ domain, adminSubdomain, publicSubdomain, confirmed }) => {
842
- try {
843
- const result = await orchestrator.runOrchestrator({
844
- domain,
845
- adminSubdomain,
846
- publicSubdomain,
847
- confirmed,
848
- });
849
- return {
850
- content: [{ type: "text", text: result.text }],
851
- isError: !result.healthy && (result.phase === "unhealthy"),
852
- };
853
- }
854
- catch (err) {
855
- const msg = err instanceof Error ? err.message : String(err);
856
- console.error(`[cloudflare:setup-run:threw] ${msg}`);
857
- return {
858
- content: [{ type: "text", text: `Tunnel setup encountered an unexpected error: ${msg}` }],
859
- isError: true,
860
- };
861
- }
862
- });
863
- server.tool("cloudflare-setup-status", "Report the current tunnel-setup orchestrator phase without advancing it. Use this for diagnostics — to answer 'where is the setup flow right now?' — not as a routine pre-check before `cloudflare-setup-run` (the run tool reads and reconciles state on every call).", {}, async () => {
864
- const state = orchestrator.readOrchestratorState();
865
- return {
866
- content: [
867
- {
868
- type: "text",
869
- text: JSON.stringify({
870
- phase: state.phase,
871
- domain: state.domain,
872
- adminSubdomain: state.adminSubdomain,
873
- publicSubdomain: state.publicSubdomain,
874
- tunnelId: state.tunnelId,
875
- phaseEnteredAt: state.phaseEnteredAt,
876
- unhealthyReason: state.unhealthyReason,
877
- setupActive: orchestrator.isSetupActive(state),
878
- }, null, 2),
879
- },
880
- ],
881
- };
19
+ version: "0.4.0",
882
20
  });
883
21
  async function main() {
884
22
  const transport = new StdioServerTransport();