@rubytech/create-realagent 1.0.614 → 1.0.616
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +42 -8
- package/package.json +1 -1
- package/payload/platform/config/brand.json +4 -0
- 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/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/mcp/dist/lib/review-tools.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +2 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.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 +196 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +81 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +65 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +70 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +124 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +232 -183
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +181 -30
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +938 -154
- 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 +32 -27
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +25 -3
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +68 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
- package/payload/platform/plugins/docs/references/troubleshooting.md +2 -0
- package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts +9 -2
- package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/providers.js +545 -92
- package/payload/platform/plugins/email/mcp/dist/lib/providers.js.map +1 -1
- package/payload/platform/scripts/logs-read.sh +114 -54
- package/payload/platform/templates/agents/admin/IDENTITY.md +6 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +1 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +4 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +16 -8
- package/payload/platform/templates/specialists/agents/project-manager.md +4 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +4 -0
- package/payload/server/server.js +714 -125
|
@@ -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,19 +171,27 @@ 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
|
|
174
|
+
server.tool("cf-add-zone", "Add a declared brand zone to the Cloudflare account via the official API. The domain MUST appear in this brand's `cloudflare.zones` manifest entry — the tool refuses domains outside that scope. Idempotent: returns existing zone info if already on the account. Requires a successful tunnel-login (cert.pem + account-binding.json present and matching).", {
|
|
229
175
|
domain: z
|
|
230
176
|
.string()
|
|
231
|
-
.describe("
|
|
177
|
+
.describe("Registrable domain to add. Must already be declared in brand.json `cloudflare.zones`."),
|
|
232
178
|
}, async ({ domain }) => {
|
|
233
|
-
|
|
179
|
+
// Manifest-scope guard: the brand's declared zones are the only zones
|
|
180
|
+
// the plugin will create. Refusing here also covers the typo case
|
|
181
|
+
// (operator typed "maxy.bo" instead of "maxy.bot").
|
|
182
|
+
const brand = cloudflared.loadBrand();
|
|
183
|
+
const scope = cloudflared.matchManifestZone(domain, brand.cloudflare.zones);
|
|
184
|
+
if (!scope.ok || scope.matchedZone !== domain) {
|
|
185
|
+
const detail = {
|
|
186
|
+
reason: "scope-mismatch",
|
|
187
|
+
message: `cf-add-zone refuses ${domain} — it is not in this brand's declared zones ` +
|
|
188
|
+
`(${brand.cloudflare.zones.join(", ")}). Add the zone to brand.json and republish, ` +
|
|
189
|
+
`or use one of the declared zones.`,
|
|
190
|
+
fields: { requestedDomain: domain, manifestZones: brand.cloudflare.zones },
|
|
191
|
+
};
|
|
192
|
+
cloudflared.logRefuse(detail);
|
|
234
193
|
return {
|
|
235
|
-
content: [
|
|
236
|
-
{
|
|
237
|
-
type: "text",
|
|
238
|
-
text: "No API token configured. Run cf-set-token first.",
|
|
239
|
-
},
|
|
240
|
-
],
|
|
194
|
+
content: [{ type: "text", text: detail.message }],
|
|
241
195
|
isError: true,
|
|
242
196
|
};
|
|
243
197
|
}
|
|
@@ -264,35 +218,23 @@ server.tool("cf-add-zone", "Add a domain to the Cloudflare account via the offic
|
|
|
264
218
|
};
|
|
265
219
|
}
|
|
266
220
|
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.";
|
|
221
|
+
// Refusal-shaped errors thrown by getClient() carry structured detail —
|
|
222
|
+
// surface them as agent-facing refusals with the same single recovery path.
|
|
223
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
281
224
|
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
|
-
],
|
|
225
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
288
226
|
isError: true,
|
|
289
227
|
};
|
|
290
228
|
}
|
|
229
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
230
|
+
console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
|
|
291
231
|
return {
|
|
292
232
|
content: [
|
|
293
233
|
{
|
|
294
234
|
type: "text",
|
|
295
|
-
text: `Zone creation failed: ${msg}
|
|
235
|
+
text: `Zone creation failed: ${msg}\n\n` +
|
|
236
|
+
`If this looks like a permissions error, the cert.pem account may not own ` +
|
|
237
|
+
`this brand's zones. ${cloudflared.recoveryMessage()}`,
|
|
296
238
|
},
|
|
297
239
|
],
|
|
298
240
|
isError: true,
|
|
@@ -412,17 +354,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
412
354
|
.optional()
|
|
413
355
|
.describe("Human-readable tunnel name (default: derived from domain)"),
|
|
414
356
|
}, 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
357
|
// Validate: admin and public subdomains must differ
|
|
427
358
|
if (publicSubdomain && adminSubdomain === publicSubdomain) {
|
|
428
359
|
return {
|
|
@@ -443,6 +374,31 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
443
374
|
const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
|
|
444
375
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
445
376
|
console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
|
|
377
|
+
// Manifest-scope pre-flight: refuse before creating any tunnel resource
|
|
378
|
+
// when any requested hostname falls outside brand.cloudflare.zones.
|
|
379
|
+
// The authoritative scope check ALSO runs inside routeDnsCli (defence
|
|
380
|
+
// in depth), but failing fast here avoids creating an orphan tunnel.
|
|
381
|
+
// getClient() inside routeDnsCli additionally enforces auth pre-conditions
|
|
382
|
+
// (cert + binding + accountId match).
|
|
383
|
+
const brand = cloudflared.loadBrand();
|
|
384
|
+
for (const h of hostnames) {
|
|
385
|
+
const scope = cloudflared.matchManifestZone(h, brand.cloudflare.zones);
|
|
386
|
+
if (!scope.ok) {
|
|
387
|
+
const detail = {
|
|
388
|
+
reason: "scope-mismatch",
|
|
389
|
+
message: `Cannot create tunnel for ${h} — its registrable parent is not in this brand's ` +
|
|
390
|
+
`declared Cloudflare zones (${brand.cloudflare.zones.join(", ")}). ` +
|
|
391
|
+
`Either pick a hostname inside the declared zones, or add the parent zone to ` +
|
|
392
|
+
`brand.json and republish the brand package.`,
|
|
393
|
+
fields: { requestedHostname: h, manifestZones: brand.cloudflare.zones },
|
|
394
|
+
};
|
|
395
|
+
cloudflared.logRefuse(detail);
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: "text", text: detail.message }],
|
|
398
|
+
isError: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
446
402
|
// Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
|
|
447
403
|
const tunnel = cloudflared.createTunnelCli(name);
|
|
448
404
|
// Step 2: Collision guard — check each hostname before creating DNS records
|
|
@@ -476,9 +432,11 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
476
432
|
// Step 4: Route DNS via CLI for each hostname
|
|
477
433
|
const dnsResults = [];
|
|
478
434
|
for (const h of hostnames) {
|
|
479
|
-
const route = cloudflared.routeDnsCli(tunnel.tunnelId, h);
|
|
435
|
+
const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
|
|
480
436
|
dnsResults.push({
|
|
481
437
|
hostname: h,
|
|
438
|
+
fqdn: route.fqdn,
|
|
439
|
+
zone: route.zone,
|
|
482
440
|
note: route.created ? "created" : "already existed",
|
|
483
441
|
});
|
|
484
442
|
}
|
|
@@ -500,7 +458,7 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
500
458
|
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.`;
|
|
501
459
|
}
|
|
502
460
|
const dnsLines = dnsResults
|
|
503
|
-
.map((r) => ` DNS: ${r.hostname} → tunnel (${r.note})`)
|
|
461
|
+
.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}` : ""}`)
|
|
504
462
|
.join("\n");
|
|
505
463
|
return {
|
|
506
464
|
content: [
|
|
@@ -512,6 +470,12 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
|
|
|
512
470
|
};
|
|
513
471
|
}
|
|
514
472
|
catch (err) {
|
|
473
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
476
|
+
isError: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
515
479
|
return {
|
|
516
480
|
content: [
|
|
517
481
|
{
|
|
@@ -623,13 +587,17 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
623
587
|
isError: true,
|
|
624
588
|
};
|
|
625
589
|
}
|
|
626
|
-
// GATE 3: cert
|
|
627
|
-
|
|
590
|
+
// GATE 3: auth pre-conditions — cert + binding + accountId match.
|
|
591
|
+
// We use validateAuth (non-throwing) so we can produce a uniform
|
|
592
|
+
// refusal message regardless of which condition failed.
|
|
593
|
+
const enableAuth = cloudflared.validateAuth();
|
|
594
|
+
if (!enableAuth.bound) {
|
|
595
|
+
const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
|
|
628
596
|
return {
|
|
629
597
|
content: [
|
|
630
598
|
{
|
|
631
599
|
type: "text",
|
|
632
|
-
text: `REFUSED:
|
|
600
|
+
text: `REFUSED: Cannot start tunnel — ${missing} is missing or mismatched. ${cloudflared.recoveryMessage()}`,
|
|
633
601
|
},
|
|
634
602
|
],
|
|
635
603
|
isError: true,
|
|
@@ -729,6 +697,12 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
|
|
|
729
697
|
};
|
|
730
698
|
}
|
|
731
699
|
catch (err) {
|
|
700
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
701
|
+
return {
|
|
702
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
703
|
+
isError: true,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
732
706
|
return {
|
|
733
707
|
content: [
|
|
734
708
|
{
|
|
@@ -772,45 +746,37 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
|
|
|
772
746
|
.string()
|
|
773
747
|
.describe("The tunnel UUID. Get this from tunnel-status."),
|
|
774
748
|
}, async ({ hostname, tunnelId }) => {
|
|
775
|
-
|
|
749
|
+
// Manifest-scope guard: alias hostnames are bound by the same brand
|
|
750
|
+
// declaration as primary hostnames. routeDnsCli enforces this at the
|
|
751
|
+
// CLI boundary too; checking here gives the operator a fast refusal
|
|
752
|
+
// before any ingress mutation.
|
|
753
|
+
const aliasBrand = cloudflared.loadBrand();
|
|
754
|
+
const aliasScope = cloudflared.matchManifestZone(hostname, aliasBrand.cloudflare.zones);
|
|
755
|
+
if (!aliasScope.ok) {
|
|
756
|
+
const detail = {
|
|
757
|
+
reason: "scope-mismatch",
|
|
758
|
+
message: `Cannot add ${hostname} as an alias — its registrable parent is not in this brand's ` +
|
|
759
|
+
`declared Cloudflare zones (${aliasBrand.cloudflare.zones.join(", ")}). ` +
|
|
760
|
+
`Add the parent zone to brand.json and republish to make this alias routable.`,
|
|
761
|
+
fields: { requestedHostname: hostname, manifestZones: aliasBrand.cloudflare.zones },
|
|
762
|
+
};
|
|
763
|
+
cloudflared.logRefuse(detail);
|
|
776
764
|
return {
|
|
777
|
-
content: [
|
|
778
|
-
{
|
|
779
|
-
type: "text",
|
|
780
|
-
text: "No API token configured. Run cf-set-token first.",
|
|
781
|
-
},
|
|
782
|
-
],
|
|
765
|
+
content: [{ type: "text", text: detail.message }],
|
|
783
766
|
isError: true,
|
|
784
767
|
};
|
|
785
768
|
}
|
|
786
769
|
try {
|
|
787
|
-
// Step 1:
|
|
770
|
+
// Step 1: Resolve zoneId. getClient() inside getZoneId enforces
|
|
771
|
+
// cert + binding + accountId-match — refusal-shaped errors propagate
|
|
772
|
+
// through this catch block.
|
|
788
773
|
const zoneId = await cloudflared.getZoneId(hostname);
|
|
789
774
|
// Step 2: Add hostname to tunnel ingress (read-then-append)
|
|
790
775
|
const ingress = await cloudflared.addHostnameToIngress(tunnelId, hostname);
|
|
791
|
-
// Step 3: Create CNAME
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
796
|
-
catch (dnsErr) {
|
|
797
|
-
const dnsMsg = dnsErr instanceof Error ? dnsErr.message : String(dnsErr);
|
|
798
|
-
const isPermissionError = dnsMsg.toLowerCase().includes("403") ||
|
|
799
|
-
dnsMsg.toLowerCase().includes("permission") ||
|
|
800
|
-
dnsMsg.toLowerCase().includes("forbidden");
|
|
801
|
-
if (isPermissionError && cloudflared.hasCert()) {
|
|
802
|
-
console.error(`[tunnel-add-hostname] SDK DNS failed (${dnsMsg}), falling back to cloudflared CLI`);
|
|
803
|
-
const cliResult = cloudflared.routeDnsCli(tunnelId, hostname);
|
|
804
|
-
dns = {
|
|
805
|
-
created: cliResult.created,
|
|
806
|
-
existing: !cliResult.created,
|
|
807
|
-
updated: false,
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
else {
|
|
811
|
-
throw dnsErr;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
776
|
+
// Step 3: Create CNAME via SDK. The cert-bound credential carries
|
|
777
|
+
// Zone:DNS:Edit for zones owned by the bound account. On any error,
|
|
778
|
+
// surface as failure — no fallback path remains.
|
|
779
|
+
const dns = await cloudflared.createDnsRecord(zoneId, hostname, tunnelId);
|
|
814
780
|
// Step 4: Persist alias domain for web server recognition
|
|
815
781
|
cloudflared.saveAliasDomain(hostname);
|
|
816
782
|
// Build response
|
|
@@ -832,6 +798,12 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
|
|
|
832
798
|
};
|
|
833
799
|
}
|
|
834
800
|
catch (err) {
|
|
801
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
802
|
+
return {
|
|
803
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
804
|
+
isError: true,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
835
807
|
return {
|
|
836
808
|
content: [
|
|
837
809
|
{
|
|
@@ -935,6 +907,59 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
935
907
|
};
|
|
936
908
|
}
|
|
937
909
|
});
|
|
910
|
+
// ===================================================================
|
|
911
|
+
// cf-verify / cf-rebuild — declarative audit + reconstruction
|
|
912
|
+
// ===================================================================
|
|
913
|
+
server.tool("cf-verify", "Audit every Cloudflare artefact relevant to this brand and return a structured report tagging each as IN-SCOPE, OUT-OF-SCOPE, or MISSING against `brand.cloudflare.zones` and the recorded account binding. Non-mutating. Runs in all states including fresh-install-before-login (reports everything as MISSING with no error). Use this to diagnose any Cloudflare misbehaviour before reaching for `cf-rebuild`.", {}, async () => {
|
|
914
|
+
try {
|
|
915
|
+
const report = await cloudflared.cfVerifyCore();
|
|
916
|
+
return {
|
|
917
|
+
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
return {
|
|
922
|
+
content: [
|
|
923
|
+
{
|
|
924
|
+
type: "text",
|
|
925
|
+
text: `cf-verify failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
926
|
+
},
|
|
927
|
+
],
|
|
928
|
+
isError: true,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
server.tool("cf-rebuild", "Discard every OUT-OF-SCOPE Cloudflare artefact and deterministically reconstruct the declared state. Idempotent — running twice on a clean state is a no-op. Refuses to delete cert.pem when bound to the wrong account; instead halts with an actionable instruction to run tunnel-login force=true. Pass dryRun=true to plan without mutating.", {
|
|
933
|
+
dryRun: z
|
|
934
|
+
.boolean()
|
|
935
|
+
.optional()
|
|
936
|
+
.describe("If true, report what cf-rebuild WOULD do without performing any mutations. Use this to preview a rebuild before committing."),
|
|
937
|
+
}, async ({ dryRun }) => {
|
|
938
|
+
try {
|
|
939
|
+
const report = await cloudflared.cfRebuildCore({ dryRun: dryRun ?? false });
|
|
940
|
+
return {
|
|
941
|
+
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
942
|
+
isError: report.halted,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
947
|
+
return {
|
|
948
|
+
content: [{ type: "text", text: err.refusal.message }],
|
|
949
|
+
isError: true,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
return {
|
|
953
|
+
content: [
|
|
954
|
+
{
|
|
955
|
+
type: "text",
|
|
956
|
+
text: `cf-rebuild failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
isError: true,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
});
|
|
938
963
|
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
939
964
|
function sanitizedDomain(domain) {
|
|
940
965
|
return domain.replace(/\./g, "-");
|
|
@@ -987,32 +1012,32 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
|
|
|
987
1012
|
}
|
|
988
1013
|
// ── Step 2: Authentication ──────────────────────────────────────
|
|
989
1014
|
log("checking authentication");
|
|
990
|
-
const auth =
|
|
991
|
-
if (auth.
|
|
992
|
-
log(
|
|
1015
|
+
const auth = cloudflared.validateAuth();
|
|
1016
|
+
if (auth.bound) {
|
|
1017
|
+
log(`authenticated — cert and binding agree on account ${auth.boundAccountId}`);
|
|
993
1018
|
}
|
|
994
|
-
else if (
|
|
995
|
-
// cert.pem exists —
|
|
996
|
-
|
|
1019
|
+
else if (auth.hasCert) {
|
|
1020
|
+
// cert.pem exists — materialize binding (migration path) or surface
|
|
1021
|
+
// drift if the binding disagrees with the cert.
|
|
997
1022
|
const creds = cloudflared.parseCertPem();
|
|
998
1023
|
if (!creds) {
|
|
999
1024
|
log("cert.pem exists but could not extract credentials");
|
|
1000
1025
|
return result({
|
|
1001
1026
|
status: "error",
|
|
1002
|
-
message: "Found a Cloudflare certificate but could not extract credentials from it.
|
|
1027
|
+
message: "Found a Cloudflare certificate but could not extract credentials from it. Sign in again — I'll clear the old certificate and start fresh.",
|
|
1003
1028
|
});
|
|
1004
1029
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
log(`cert
|
|
1030
|
+
if (!auth.hasBinding) {
|
|
1031
|
+
const binding = cloudflared.materializeBinding("migration");
|
|
1032
|
+
log(`materialized binding from existing cert — account ${binding.accountId}`);
|
|
1033
|
+
}
|
|
1034
|
+
else if (auth.certAccountId !== auth.boundAccountId) {
|
|
1035
|
+
log(`account drift: cert=${auth.certAccountId} binding=${auth.boundAccountId}`);
|
|
1008
1036
|
return result({
|
|
1009
1037
|
status: "error",
|
|
1010
|
-
message: `
|
|
1038
|
+
message: `Your Cloudflare certificate is bound to a different account than this device's recorded binding. ${cloudflared.recoveryMessage()}`,
|
|
1011
1039
|
});
|
|
1012
1040
|
}
|
|
1013
|
-
const accountId = validation.accountId ?? creds.accountId;
|
|
1014
|
-
cloudflared.writeToken(creds.apiToken, accountId);
|
|
1015
|
-
log(`credentials derived and stored — account: ${validation.accountName ?? accountId}`);
|
|
1016
1041
|
}
|
|
1017
1042
|
else {
|
|
1018
1043
|
// No cert, no token — need login
|
|
@@ -1282,6 +1307,30 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
|
|
|
1282
1307
|
const adminHostname = `${resolvedAdminLabel}.${domain}`;
|
|
1283
1308
|
const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
|
|
1284
1309
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
1310
|
+
// Manifest-scope pre-flight: domain came from live account state
|
|
1311
|
+
// (zone selection, auto-only-active zone, or persisted state). The
|
|
1312
|
+
// account may hold zones outside this brand's declared scope; refuse
|
|
1313
|
+
// before creating any tunnel/state so we don't write orphan local
|
|
1314
|
+
// state. routeDnsCli will refuse later anyway, but only after the
|
|
1315
|
+
// tunnel is created and config.yml is written.
|
|
1316
|
+
const setupBrand = cloudflared.loadBrand();
|
|
1317
|
+
for (const h of hostnames) {
|
|
1318
|
+
const scopeCheck = cloudflared.matchManifestZone(h, setupBrand.cloudflare.zones);
|
|
1319
|
+
if (!scopeCheck.ok) {
|
|
1320
|
+
const detail = {
|
|
1321
|
+
reason: "scope-mismatch",
|
|
1322
|
+
message: `Cannot set up ${h} — its registrable parent is not in this brand's declared ` +
|
|
1323
|
+
`Cloudflare zones (${setupBrand.cloudflare.zones.join(", ")}). ` +
|
|
1324
|
+
`The selected domain ${domain} is on the bound Cloudflare account but outside ` +
|
|
1325
|
+
`the brand's manifest. Either pick a domain inside the declared scope, or add ` +
|
|
1326
|
+
`${domain} to brand.json and republish.`,
|
|
1327
|
+
fields: { requestedHostname: h, requestedDomain: domain, manifestZones: setupBrand.cloudflare.zones },
|
|
1328
|
+
};
|
|
1329
|
+
cloudflared.logRefuse(detail);
|
|
1330
|
+
log(`scope-mismatch: hostname=${h} not in declared zones`);
|
|
1331
|
+
return result({ status: "error", message: detail.message });
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1285
1334
|
// ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
|
|
1286
1335
|
let tunnelId = existingState?.tunnelId ?? null;
|
|
1287
1336
|
let tunnelName = existingState?.tunnelName ?? null;
|
|
@@ -1411,7 +1460,7 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
|
|
|
1411
1460
|
publicHostname,
|
|
1412
1461
|
});
|
|
1413
1462
|
for (const h of hostnames) {
|
|
1414
|
-
cloudflared.routeDnsCli(tunnelId, h);
|
|
1463
|
+
await cloudflared.routeDnsCli(tunnelId, h);
|
|
1415
1464
|
}
|
|
1416
1465
|
log(`ui=tunnel-route-picker submitted adminLabel=${resolvedAdminLabel} publicLabel=${resolvedPublicLabel ?? "null"}`);
|
|
1417
1466
|
// ── Step 7: Remote auth check ───────────────────────────────────
|