@rubytech/create-realagent 1.0.615 → 1.0.617
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +23 -13
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js +86 -89
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/src/index.ts +86 -101
- package/payload/platform/package-lock.json +1547 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js +33 -2
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +22 -8
- package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +195 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +160 -214
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +203 -42
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +623 -195
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
- package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +10 -0
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +26 -30
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +28 -4
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +51 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
- package/payload/platform/scripts/logs-read.sh +114 -54
- package/payload/platform/templates/specialists/agents/personal-assistant.md +12 -8
- package/payload/server/server.js +387 -70
|
@@ -12,134 +12,80 @@ const server = new McpServer({
|
|
|
12
12
|
version: "0.2.0",
|
|
13
13
|
});
|
|
14
14
|
// ===================================================================
|
|
15
|
-
//
|
|
15
|
+
// Authentication — cert.pem only
|
|
16
|
+
//
|
|
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.
|
|
16
21
|
// ===================================================================
|
|
17
|
-
server.tool("
|
|
18
|
-
token: z
|
|
19
|
-
.string()
|
|
20
|
-
.describe("The Cloudflare API token to store"),
|
|
21
|
-
}, async ({ token }) => {
|
|
22
|
-
const trimmed = token.trim();
|
|
23
|
-
if (!trimmed) {
|
|
24
|
-
return {
|
|
25
|
-
content: [
|
|
26
|
-
{
|
|
27
|
-
type: "text",
|
|
28
|
-
text: "No token provided. Paste an existing Cloudflare API token as the token argument. If the user does not have a token, use tunnel-login instead — it handles authentication automatically.",
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
isError: true,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
const result = await cloudflared.validateToken(trimmed);
|
|
36
|
-
if (!result.valid) {
|
|
37
|
-
return {
|
|
38
|
-
content: [
|
|
39
|
-
{
|
|
40
|
-
type: "text",
|
|
41
|
-
text: `Token validation failed: ${result.error}`,
|
|
42
|
-
},
|
|
43
|
-
],
|
|
44
|
-
isError: true,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
if (!result.accountId) {
|
|
48
|
-
return {
|
|
49
|
-
content: [
|
|
50
|
-
{
|
|
51
|
-
type: "text",
|
|
52
|
-
text: `${result.error}`,
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
isError: true,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
cloudflared.writeToken(trimmed, result.accountId);
|
|
59
|
-
return {
|
|
60
|
-
content: [
|
|
61
|
-
{
|
|
62
|
-
type: "text",
|
|
63
|
-
text: `API token validated and saved.\n\n Account: ${result.accountName ?? result.accountId}\n Token stored: ~/{configDir}/cloudflare/api-token (mode 0600)\n\nReady for tunnel operations.`,
|
|
64
|
-
},
|
|
65
|
-
],
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
return {
|
|
70
|
-
content: [
|
|
71
|
-
{
|
|
72
|
-
type: "text",
|
|
73
|
-
text: `Token validation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
isError: true,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if already authenticated, reports status; (2) if cert.pem exists, derives API credentials; (3) if no cert.pem, spawns login process and returns auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete stored credentials and restart authentication (use when the current token has insufficient permissions).", {
|
|
22
|
+
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.", {
|
|
81
23
|
force: z
|
|
82
24
|
.boolean()
|
|
83
25
|
.optional()
|
|
84
|
-
.describe("Delete
|
|
26
|
+
.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)."),
|
|
85
27
|
}, async ({ force }) => {
|
|
86
28
|
try {
|
|
87
|
-
// Force: delete
|
|
29
|
+
// Force: delete cert.pem (both paths) and binding before restarting
|
|
88
30
|
if (force) {
|
|
89
|
-
console.error("[tunnel-login] force=true —
|
|
31
|
+
console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
|
|
90
32
|
cloudflared.resetAuth();
|
|
91
33
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (auth.
|
|
95
|
-
console.error(
|
|
34
|
+
const auth = cloudflared.validateAuth();
|
|
35
|
+
// Phase: Already bound — cert + binding present and accountIds agree
|
|
36
|
+
if (auth.bound) {
|
|
37
|
+
console.error(`[tunnel-login] short-circuit: cert and binding agree on account ${auth.boundAccountId}`);
|
|
96
38
|
return {
|
|
97
39
|
content: [
|
|
98
40
|
{
|
|
99
41
|
type: "text",
|
|
100
|
-
text: `Already authenticated.
|
|
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
43
|
},
|
|
102
44
|
],
|
|
103
45
|
};
|
|
104
46
|
}
|
|
105
|
-
// Phase: cert.pem exists — derive credentials
|
|
106
|
-
|
|
47
|
+
// Phase: cert.pem exists — derive credentials and either materialize
|
|
48
|
+
// or reconcile the binding.
|
|
49
|
+
if (auth.hasCert) {
|
|
107
50
|
const creds = cloudflared.parseCertPem();
|
|
108
51
|
if (!creds) {
|
|
109
52
|
return {
|
|
110
53
|
content: [
|
|
111
54
|
{
|
|
112
55
|
type: "text",
|
|
113
|
-
text: `cert.pem exists but could not extract API credentials from it. The file format may have changed.\n\
|
|
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.`,
|
|
114
57
|
},
|
|
115
58
|
],
|
|
116
59
|
isError: true,
|
|
117
60
|
};
|
|
118
61
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
if (!auth.hasBinding) {
|
|
66
|
+
const binding = cloudflared.materializeBinding("migration");
|
|
122
67
|
return {
|
|
123
68
|
content: [
|
|
124
69
|
{
|
|
125
70
|
type: "text",
|
|
126
|
-
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.`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Drift: cert says account X, binding says account Y — refuse with
|
|
77
|
+
// recovery instruction (force=true will clear both).
|
|
78
|
+
if (auth.certAccountId !== auth.boundAccountId) {
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
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()}`,
|
|
127
84
|
},
|
|
128
85
|
],
|
|
129
86
|
isError: true,
|
|
130
87
|
};
|
|
131
88
|
}
|
|
132
|
-
// Use the account ID from validation (more reliable) or fall back to cert
|
|
133
|
-
const accountId = validation.accountId ?? creds.accountId;
|
|
134
|
-
cloudflared.writeToken(creds.apiToken, accountId);
|
|
135
|
-
return {
|
|
136
|
-
content: [
|
|
137
|
-
{
|
|
138
|
-
type: "text",
|
|
139
|
-
text: `Credentials derived from cert.pem and validated.\n\n Account: ${validation.accountName ?? accountId}\n Token stored: ~/{configDir}/cloudflare/api-token (mode 0600)\n\nReady for tunnel operations.`,
|
|
140
|
-
},
|
|
141
|
-
],
|
|
142
|
-
};
|
|
143
89
|
}
|
|
144
90
|
// Phase: Login process already running — wait for authorization
|
|
145
91
|
const activeLogin = cloudflared.getActiveLogin();
|
|
@@ -225,22 +171,11 @@ server.tool("cf-zone-status", "List all zones (domains) on the Cloudflare accoun
|
|
|
225
171
|
};
|
|
226
172
|
}
|
|
227
173
|
});
|
|
228
|
-
server.tool("cf-add-zone", "Add a domain to the Cloudflare account via the official API.
|
|
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.", {
|
|
229
175
|
domain: z
|
|
230
176
|
.string()
|
|
231
|
-
.describe("
|
|
177
|
+
.describe("Registrable domain to add to the bound Cloudflare account (e.g. 'mybusiness.com')."),
|
|
232
178
|
}, async ({ domain }) => {
|
|
233
|
-
if (!cloudflared.hasToken()) {
|
|
234
|
-
return {
|
|
235
|
-
content: [
|
|
236
|
-
{
|
|
237
|
-
type: "text",
|
|
238
|
-
text: "No API token configured. Run cf-set-token first.",
|
|
239
|
-
},
|
|
240
|
-
],
|
|
241
|
-
isError: true,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
179
|
try {
|
|
245
180
|
const start = Date.now();
|
|
246
181
|
const result = await cloudflared.createZone(domain);
|
|
@@ -264,35 +199,23 @@ server.tool("cf-add-zone", "Add a domain to the Cloudflare account via the offic
|
|
|
264
199
|
};
|
|
265
200
|
}
|
|
266
201
|
catch (err) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const lower = msg.toLowerCase();
|
|
271
|
-
if (lower.includes("403") || lower.includes("permission") || lower.includes("forbidden")) {
|
|
272
|
-
const tokenPrefix = cloudflared.getTokenPrefix();
|
|
273
|
-
const isCertDerived = tokenPrefix?.startsWith("cfut_") ?? false;
|
|
274
|
-
console.error(`[cf-add-zone] permission denied for ${domain} — token prefix=${tokenPrefix ?? "unknown"} (${isCertDerived ? "cert-derived, limited scope" : "API token"})`);
|
|
275
|
-
const scopeExplanation = isCertDerived
|
|
276
|
-
? "The current token was derived from cloudflared's cert.pem (cfut_* prefix). Cert-derived tokens carry Zone:Zone:Read but NOT Zone:Zone:Edit — they cannot create zones or manage DNS. This is a Cloudflare limitation, not a configuration error."
|
|
277
|
-
: "The current API token does not have the Zone:Zone:Edit scope required to add domains.";
|
|
278
|
-
const recoveryNote = isCertDerived
|
|
279
|
-
? "\n\nDo NOT re-run tunnel-login — it produces the same cert-derived token with the same limited scope."
|
|
280
|
-
: "\n\nAlternatively, call tunnel-login with force=true to re-authenticate and derive new credentials.";
|
|
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) {
|
|
281
205
|
return {
|
|
282
|
-
content: [
|
|
283
|
-
{
|
|
284
|
-
type: "text",
|
|
285
|
-
text: `Zone creation failed — insufficient permissions.\n\n${scopeExplanation}\n\nRecovery options:\n 1. Use cf-set-token with a user-created API token that has these scopes: Zone:Zone:Edit, Zone:DNS:Edit, Account:Cloudflare Tunnel:Edit. The user can create one at dash.cloudflare.com → My Profile → API Tokens.\n 2. Add the domain directly through the Cloudflare dashboard in the VNC browser.${recoveryNote}`,
|
|
286
|
-
},
|
|
287
|
-
],
|
|
206
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
288
207
|
isError: true,
|
|
289
208
|
};
|
|
290
209
|
}
|
|
210
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
211
|
+
console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
|
|
291
212
|
return {
|
|
292
213
|
content: [
|
|
293
214
|
{
|
|
294
215
|
type: "text",
|
|
295
|
-
text: `Zone creation failed: ${msg}
|
|
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()}`,
|
|
296
219
|
},
|
|
297
220
|
],
|
|
298
221
|
isError: true,
|
|
@@ -412,17 +335,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
412
335
|
.optional()
|
|
413
336
|
.describe("Human-readable tunnel name (default: derived from domain)"),
|
|
414
337
|
}, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
|
|
415
|
-
if (!cloudflared.hasCert()) {
|
|
416
|
-
return {
|
|
417
|
-
content: [
|
|
418
|
-
{
|
|
419
|
-
type: "text",
|
|
420
|
-
text: "Not authenticated. Run tunnel-login first.",
|
|
421
|
-
},
|
|
422
|
-
],
|
|
423
|
-
isError: true,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
338
|
// Validate: admin and public subdomains must differ
|
|
427
339
|
if (publicSubdomain && adminSubdomain === publicSubdomain) {
|
|
428
340
|
return {
|
|
@@ -443,32 +355,9 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
443
355
|
const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
|
|
444
356
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
445
357
|
console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
// check fails fast before any Argo tunnel resource is created; (b) the
|
|
450
|
-
// authoritative choke-point check inside routeDnsCli guards all three
|
|
451
|
-
// callers including tunnel-add-hostname's CLI fallback. The duplicate
|
|
452
|
-
// listZones() call is intentional — the inner check is the guarantee,
|
|
453
|
-
// this outer check is the UX optimisation.
|
|
454
|
-
const accountZones = await cloudflared.listZones();
|
|
455
|
-
for (const h of hostnames) {
|
|
456
|
-
const match = cloudflared.verifyZoneOnAccount(h, accountZones);
|
|
457
|
-
if (!match.zone) {
|
|
458
|
-
const availableList = match.availableZones.length > 0
|
|
459
|
-
? match.availableZones.join(", ")
|
|
460
|
-
: "none";
|
|
461
|
-
return {
|
|
462
|
-
content: [
|
|
463
|
-
{
|
|
464
|
-
type: "text",
|
|
465
|
-
text: `Cannot create tunnel for ${h} — the zone that owns this hostname is not on the Cloudflare account bound to this device.\n\n Best guess at missing zone: "${match.missingParent}" (derived from the last two labels of ${h}; for multi-label TLDs such as .co.uk the real zone may be longer)\n Available zones on this account: ${availableList}\n\nRecovery: run tunnel-login while signed into the Cloudflare account that owns the zone for ${h}. This rebinds cert.pem to the correct account. Do not use cf-set-token with a token from a different account — zone routing uses cert.pem, not the API token.`,
|
|
466
|
-
},
|
|
467
|
-
],
|
|
468
|
-
isError: true,
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
}
|
|
358
|
+
// Live account-zone check happens inside routeDnsCli (Step 4 below).
|
|
359
|
+
// getClient() inside routeDnsCli enforces auth pre-conditions
|
|
360
|
+
// (cert + binding + accountId match).
|
|
472
361
|
// Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
|
|
473
362
|
const tunnel = cloudflared.createTunnelCli(name);
|
|
474
363
|
// Step 2: Collision guard — check each hostname before creating DNS records
|
|
@@ -540,6 +429,12 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
540
429
|
};
|
|
541
430
|
}
|
|
542
431
|
catch (err) {
|
|
432
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
433
|
+
return {
|
|
434
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
435
|
+
isError: true,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
543
438
|
return {
|
|
544
439
|
content: [
|
|
545
440
|
{
|
|
@@ -651,13 +546,17 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
651
546
|
isError: true,
|
|
652
547
|
};
|
|
653
548
|
}
|
|
654
|
-
// GATE 3: cert
|
|
655
|
-
|
|
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.
|
|
552
|
+
const enableAuth = cloudflared.validateAuth();
|
|
553
|
+
if (!enableAuth.bound) {
|
|
554
|
+
const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
|
|
656
555
|
return {
|
|
657
556
|
content: [
|
|
658
557
|
{
|
|
659
558
|
type: "text",
|
|
660
|
-
text: `REFUSED:
|
|
559
|
+
text: `REFUSED: Cannot start tunnel — ${missing} is missing or mismatched. ${cloudflared.recoveryMessage()}`,
|
|
661
560
|
},
|
|
662
561
|
],
|
|
663
562
|
isError: true,
|
|
@@ -757,6 +656,12 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
757
656
|
};
|
|
758
657
|
}
|
|
759
658
|
catch (err) {
|
|
659
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
660
|
+
return {
|
|
661
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
662
|
+
isError: true,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
760
665
|
return {
|
|
761
666
|
content: [
|
|
762
667
|
{
|
|
@@ -800,45 +705,16 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
|
|
|
800
705
|
.string()
|
|
801
706
|
.describe("The tunnel UUID. Get this from tunnel-status."),
|
|
802
707
|
}, async ({ hostname, tunnelId }) => {
|
|
803
|
-
if (!cloudflared.hasToken()) {
|
|
804
|
-
return {
|
|
805
|
-
content: [
|
|
806
|
-
{
|
|
807
|
-
type: "text",
|
|
808
|
-
text: "No API token configured. Run cf-set-token first.",
|
|
809
|
-
},
|
|
810
|
-
],
|
|
811
|
-
isError: true,
|
|
812
|
-
};
|
|
813
|
-
}
|
|
814
708
|
try {
|
|
815
|
-
//
|
|
709
|
+
// getZoneId enforces auth pre-conditions and refuses if the alias
|
|
710
|
+
// domain isn't a zone on the bound account.
|
|
816
711
|
const zoneId = await cloudflared.getZoneId(hostname);
|
|
817
712
|
// Step 2: Add hostname to tunnel ingress (read-then-append)
|
|
818
713
|
const ingress = await cloudflared.addHostnameToIngress(tunnelId, hostname);
|
|
819
|
-
// Step 3: Create CNAME
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}
|
|
824
|
-
catch (dnsErr) {
|
|
825
|
-
const dnsMsg = dnsErr instanceof Error ? dnsErr.message : String(dnsErr);
|
|
826
|
-
const isPermissionError = dnsMsg.toLowerCase().includes("403") ||
|
|
827
|
-
dnsMsg.toLowerCase().includes("permission") ||
|
|
828
|
-
dnsMsg.toLowerCase().includes("forbidden");
|
|
829
|
-
if (isPermissionError && cloudflared.hasCert()) {
|
|
830
|
-
console.error(`[tunnel-add-hostname] SDK DNS failed (${dnsMsg}), falling back to cloudflared CLI`);
|
|
831
|
-
const cliResult = await cloudflared.routeDnsCli(tunnelId, hostname);
|
|
832
|
-
dns = {
|
|
833
|
-
created: cliResult.created,
|
|
834
|
-
existing: !cliResult.created,
|
|
835
|
-
updated: false,
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
else {
|
|
839
|
-
throw dnsErr;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
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);
|
|
842
718
|
// Step 4: Persist alias domain for web server recognition
|
|
843
719
|
cloudflared.saveAliasDomain(hostname);
|
|
844
720
|
// Build response
|
|
@@ -860,6 +736,12 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
|
|
|
860
736
|
};
|
|
861
737
|
}
|
|
862
738
|
catch (err) {
|
|
739
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
740
|
+
return {
|
|
741
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
742
|
+
isError: true,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
863
745
|
return {
|
|
864
746
|
content: [
|
|
865
747
|
{
|
|
@@ -963,6 +845,67 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
963
845
|
};
|
|
964
846
|
}
|
|
965
847
|
});
|
|
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
|
+
});
|
|
966
909
|
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
967
910
|
function sanitizedDomain(domain) {
|
|
968
911
|
return domain.replace(/\./g, "-");
|
|
@@ -1015,32 +958,32 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
|
|
|
1015
958
|
}
|
|
1016
959
|
// ── Step 2: Authentication ──────────────────────────────────────
|
|
1017
960
|
log("checking authentication");
|
|
1018
|
-
const auth =
|
|
1019
|
-
if (auth.
|
|
1020
|
-
log(
|
|
961
|
+
const auth = cloudflared.validateAuth();
|
|
962
|
+
if (auth.bound) {
|
|
963
|
+
log(`authenticated — cert and binding agree on account ${auth.boundAccountId}`);
|
|
1021
964
|
}
|
|
1022
|
-
else if (
|
|
1023
|
-
// cert.pem exists —
|
|
1024
|
-
|
|
965
|
+
else if (auth.hasCert) {
|
|
966
|
+
// cert.pem exists — materialize binding (migration path) or surface
|
|
967
|
+
// drift if the binding disagrees with the cert.
|
|
1025
968
|
const creds = cloudflared.parseCertPem();
|
|
1026
969
|
if (!creds) {
|
|
1027
970
|
log("cert.pem exists but could not extract credentials");
|
|
1028
971
|
return result({
|
|
1029
972
|
status: "error",
|
|
1030
|
-
message: "Found a Cloudflare certificate but could not extract credentials from it.
|
|
973
|
+
message: "Found a Cloudflare certificate but could not extract credentials from it. Sign in again — I'll clear the old certificate and start fresh.",
|
|
1031
974
|
});
|
|
1032
975
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
log(`cert
|
|
976
|
+
if (!auth.hasBinding) {
|
|
977
|
+
const binding = cloudflared.materializeBinding("migration");
|
|
978
|
+
log(`materialized binding from existing cert — account ${binding.accountId}`);
|
|
979
|
+
}
|
|
980
|
+
else if (auth.certAccountId !== auth.boundAccountId) {
|
|
981
|
+
log(`account drift: cert=${auth.certAccountId} binding=${auth.boundAccountId}`);
|
|
1036
982
|
return result({
|
|
1037
983
|
status: "error",
|
|
1038
|
-
message: `
|
|
984
|
+
message: `Your Cloudflare certificate is bound to a different account than this device's recorded binding. ${cloudflared.recoveryMessage()}`,
|
|
1039
985
|
});
|
|
1040
986
|
}
|
|
1041
|
-
const accountId = validation.accountId ?? creds.accountId;
|
|
1042
|
-
cloudflared.writeToken(creds.apiToken, accountId);
|
|
1043
|
-
log(`credentials derived and stored — account: ${validation.accountName ?? accountId}`);
|
|
1044
987
|
}
|
|
1045
988
|
else {
|
|
1046
989
|
// No cert, no token — need login
|
|
@@ -1310,6 +1253,9 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
|
|
|
1310
1253
|
const adminHostname = `${resolvedAdminLabel}.${domain}`;
|
|
1311
1254
|
const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
|
|
1312
1255
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
1256
|
+
// Account-zone scope check happens inside routeDnsCli (Step 6) — the
|
|
1257
|
+
// domain was picked from the live account zone list, so by construction
|
|
1258
|
+
// it is on the bound account. No pre-flight needed here.
|
|
1313
1259
|
// ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
|
|
1314
1260
|
let tunnelId = existingState?.tunnelId ?? null;
|
|
1315
1261
|
let tunnelName = existingState?.tunnelName ?? null;
|