@rubytech/create-realagent 1.0.612 → 1.0.614
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/plugins/cloudflare/PLUGIN.md +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +370 -96
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +16 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +41 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +48 -161
- package/payload/platform/plugins/docs/references/deployment.md +3 -1
- package/payload/platform/templates/agents/admin/SOUL.md +20 -0
- package/payload/platform/templates/agents/public/SOUL.md +12 -0
- package/payload/server/public/assets/{admin-DOSuO0gX.js → admin-Df1liz4Y.js} +4 -4
- package/payload/server/public/assets/{public-Ca1REKj1.js → public-ZM0fHAOE.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-DdQbdD_u.css → useVoiceRecorder-CaFVzk8y.css} +1 -1
- package/payload/server/public/index.html +3 -3
- package/payload/server/public/public.html +3 -3
- package/payload/platform/knowledge/maxy.md +0 -794
- /package/payload/server/public/assets/{useVoiceRecorder-DdYY7cm9.js → useVoiceRecorder-OB_Gtr0e.js} +0 -0
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@ tools:
|
|
|
18
18
|
|
|
19
19
|
# Cloudflare Tunnel Setup
|
|
20
20
|
|
|
21
|
-
Guides through setting up a Cloudflare Tunnel so customers can reach this assistant via a custom domain (e.g., `chat.mybusiness.com`).
|
|
21
|
+
Guides through setting up a Cloudflare Tunnel so customers can reach this assistant via a custom domain (e.g., `chat.mybusiness.com`). `cloudflare-setup` is the single orchestrator — a UI-driven state machine that prompts the user for tunnel/zone selections and admin/public addresses via rendered components (`single-select`, `tunnel-route-picker`). Tunnel names are unique per customer (derived from the chosen admin label + domain) so multiple customers can coexist on a shared zone like `maxy.bot`. The agent never synthesises subdomains from free text — every label comes from a component submission. Auth via `tunnel-login` (one-click OAuth) or `cf-set-token` (paste token). Browser-specialist is navigation-only — no dashboard forms.
|
|
22
22
|
|
|
23
23
|
## When to activate
|
|
24
24
|
|
|
@@ -3,7 +3,9 @@ initStderrTee("cloudflare");
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import {
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { hostname, homedir } from "node:os";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
7
9
|
import * as cloudflared from "./lib/cloudflared.js";
|
|
8
10
|
const server = new McpServer({
|
|
9
11
|
name: "cloudflare",
|
|
@@ -933,23 +935,35 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
|
|
|
933
935
|
};
|
|
934
936
|
}
|
|
935
937
|
});
|
|
936
|
-
|
|
937
|
-
|
|
938
|
+
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
939
|
+
function sanitizedDomain(domain) {
|
|
940
|
+
return domain.replace(/\./g, "-");
|
|
941
|
+
}
|
|
942
|
+
function deriveTunnelName(adminLabel, domain) {
|
|
943
|
+
return `${adminLabel}-${sanitizedDomain(domain)}`;
|
|
944
|
+
}
|
|
945
|
+
function hasBrandCredsFor(tunnelId) {
|
|
946
|
+
const path = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
|
|
947
|
+
return existsSync(path);
|
|
948
|
+
}
|
|
949
|
+
server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Cloudflare Tunnel setup. Call with no parameters to begin; the tool evaluates current state and returns a structured result describing what to render next. When a returned status begins with 'awaiting_', read `data.render` and call render-component with its contents verbatim — do not paraphrase, do not prompt the user for values. When the user submits a component (single-select, tunnel-route-picker), the agent's sole job is to relay the submission's fields (selectedTunnelId, selectedZoneName, adminLabel, publicLabel) back into this tool unchanged. The agent never synthesises subdomains or domain fragments from free text.", {
|
|
950
|
+
selectedTunnelId: z
|
|
938
951
|
.string()
|
|
939
|
-
.
|
|
940
|
-
|
|
952
|
+
.optional()
|
|
953
|
+
.describe("Tunnel ID from the single-select submission. The literal string '__new__' means the user chose to create a new tunnel. Pass only when the last component rendered was the tunnel selection list."),
|
|
954
|
+
selectedZoneName: z
|
|
941
955
|
.string()
|
|
942
|
-
.min(1)
|
|
943
956
|
.optional()
|
|
944
|
-
.describe("
|
|
945
|
-
|
|
957
|
+
.describe("Zone name from the single-select submission. Pass only when the last component rendered was the zone selection list."),
|
|
958
|
+
adminLabel: z
|
|
959
|
+
.string()
|
|
960
|
+
.optional()
|
|
961
|
+
.describe("Admin DNS label from the tunnel-route-picker submission. DNS-label-safe (a-z, 0-9, hyphen, no leading/trailing hyphen). Pass only when the last component rendered was tunnel-route-picker."),
|
|
962
|
+
publicLabel: z
|
|
946
963
|
.string()
|
|
947
|
-
.min(1)
|
|
948
964
|
.optional()
|
|
949
|
-
.describe("
|
|
950
|
-
}, async ({
|
|
951
|
-
const adminSubdomain = adminSubParam ?? "admin";
|
|
952
|
-
const publicSubdomain = publicSubParam ?? "public";
|
|
965
|
+
.describe("Public DNS label from the tunnel-route-picker submission. Optional; omit to skip the public endpoint. DNS-label-safe. Pass only when the last component rendered was tunnel-route-picker."),
|
|
966
|
+
}, async ({ selectedTunnelId, selectedZoneName, adminLabel, publicLabel }) => {
|
|
953
967
|
const log = (msg) => console.error(`[cloudflare-setup] ${msg}`);
|
|
954
968
|
try {
|
|
955
969
|
// ── Step 1: cloudflared binary ──────────────────────────────────
|
|
@@ -1034,116 +1048,373 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
|
|
|
1034
1048
|
data: { authUrl },
|
|
1035
1049
|
});
|
|
1036
1050
|
}
|
|
1037
|
-
// ── Step 3:
|
|
1038
|
-
|
|
1039
|
-
|
|
1051
|
+
// ── Step 3: Discover state — tunnel, zone, domain ───────────────
|
|
1052
|
+
const existingState = cloudflared.getPersistedState();
|
|
1053
|
+
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
1054
|
+
// If state already has a tunnel identity with hostnames, labels are
|
|
1055
|
+
// reconstructed from the persisted hostnames on every re-entry where
|
|
1056
|
+
// the agent doesn't supply fresh labels. This is how the flow
|
|
1057
|
+
// survives the awaiting_password round-trip.
|
|
1058
|
+
const stateAdminLabel = existingState?.adminHostname && existingState.domain
|
|
1059
|
+
? existingState.adminHostname.replace(`.${existingState.domain}`, "")
|
|
1060
|
+
: null;
|
|
1061
|
+
const statePublicLabel = existingState?.publicHostname && existingState?.domain
|
|
1062
|
+
? existingState.publicHostname.replace(`.${existingState.domain}`, "")
|
|
1063
|
+
: null;
|
|
1064
|
+
let allTunnels = [];
|
|
1065
|
+
let allZones = [];
|
|
1040
1066
|
try {
|
|
1041
|
-
|
|
1067
|
+
[allTunnels, allZones] = await Promise.all([
|
|
1068
|
+
cloudflared.listTunnelsOnAccount(),
|
|
1069
|
+
cloudflared.listZones(),
|
|
1070
|
+
]);
|
|
1042
1071
|
}
|
|
1043
1072
|
catch (err) {
|
|
1044
1073
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1045
|
-
log(`
|
|
1046
|
-
return result({
|
|
1074
|
+
log(`discovery failed: ${msg}`);
|
|
1075
|
+
return result({
|
|
1076
|
+
status: "error",
|
|
1077
|
+
message: `Could not read your Cloudflare account: ${msg}`,
|
|
1078
|
+
});
|
|
1047
1079
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1080
|
+
log(`entry tunnelsOnAccount=${allTunnels.length} zonesOnAccount=${allZones.length}`);
|
|
1081
|
+
const activeZones = allZones.filter((z) => z.status === "active");
|
|
1082
|
+
const pendingZones = allZones.filter((z) => z.status === "pending");
|
|
1083
|
+
// Resolve domain — precedence: persisted state > tunnel selection > zone selection > auto-only-active
|
|
1084
|
+
let domain = existingState?.domain ?? null;
|
|
1085
|
+
if (!domain && selectedTunnelId && selectedTunnelId !== "__new__") {
|
|
1086
|
+
const tunnel = allTunnels.find((t) => t.id === selectedTunnelId);
|
|
1087
|
+
if (!tunnel) {
|
|
1088
|
+
log(`selectedTunnelId ${selectedTunnelId} not found on account`);
|
|
1089
|
+
return result({
|
|
1090
|
+
status: "error",
|
|
1091
|
+
message: "That tunnel is no longer on your account. Let me check again.",
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
const zoneName = await cloudflared.findZoneHostingTunnel(selectedTunnelId);
|
|
1095
|
+
if (!zoneName) {
|
|
1096
|
+
log(`selected tunnel ${selectedTunnelId} has no zone hosting it — falling back to zone selection`);
|
|
1097
|
+
// Fall through to zone-selection branch below
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
domain = zoneName;
|
|
1101
|
+
log(`branch=existing-tunnel tunnelId=${selectedTunnelId} domain=${domain}`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (!domain && selectedZoneName) {
|
|
1105
|
+
const zone = activeZones.find((z) => z.name === selectedZoneName);
|
|
1106
|
+
if (!zone) {
|
|
1107
|
+
log(`selectedZoneName ${selectedZoneName} not active on account`);
|
|
1108
|
+
return result({
|
|
1109
|
+
status: "error",
|
|
1110
|
+
message: "That domain is no longer Active on your account. Let me check again.",
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
domain = selectedZoneName;
|
|
1114
|
+
}
|
|
1115
|
+
// No prior state, no selection — prompt the user for a tunnel
|
|
1116
|
+
// (if account has any) or a zone (if account has >1 active zone).
|
|
1117
|
+
if (!domain) {
|
|
1118
|
+
if (allTunnels.length > 0 && !selectedTunnelId) {
|
|
1119
|
+
log(`awaiting_tunnel_selection tunnelsOnAccount=${allTunnels.length}`);
|
|
1120
|
+
const options = [
|
|
1121
|
+
{ value: "__new__", label: "Create a new tunnel (recommended)" },
|
|
1122
|
+
...allTunnels.map((t) => ({ value: t.id, label: t.name })),
|
|
1123
|
+
];
|
|
1124
|
+
return result({
|
|
1125
|
+
status: "awaiting_tunnel_selection",
|
|
1126
|
+
message: "Pick a tunnel for this device. For a fresh setup, choose Create a new tunnel.",
|
|
1127
|
+
data: {
|
|
1128
|
+
tunnels: allTunnels.map((t) => ({ id: t.id, name: t.name })),
|
|
1129
|
+
render: {
|
|
1130
|
+
name: "single-select",
|
|
1131
|
+
data: {
|
|
1132
|
+
title: "Tunnel",
|
|
1133
|
+
description: "Each device uses its own tunnel. Creating a new one is the normal choice.",
|
|
1134
|
+
options,
|
|
1135
|
+
submitLabel: "Use this tunnel",
|
|
1136
|
+
submitMessage: '{"selectedTunnelId":"{{value}}"}',
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
// From here: selectedTunnelId is "__new__" or undefined (no tunnels on account).
|
|
1143
|
+
if (activeZones.length === 0) {
|
|
1144
|
+
if (pendingZones.length > 0) {
|
|
1145
|
+
const z = pendingZones[0];
|
|
1146
|
+
log(`awaiting_nameservers zone=${z.name}`);
|
|
1056
1147
|
return result({
|
|
1057
1148
|
status: "awaiting_nameservers",
|
|
1058
|
-
message: `Your domain
|
|
1059
|
-
data: { nameservers:
|
|
1149
|
+
message: `Your domain ${z.name} is on Cloudflare but not yet active. Update the nameservers at your registrar to:\n\n• ${z.nameservers[0]}\n• ${z.nameservers[1]}\n\nLet me know when you've updated them.`,
|
|
1150
|
+
data: { nameservers: z.nameservers },
|
|
1060
1151
|
});
|
|
1061
1152
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1153
|
+
log(`branch=zone-add-fallback reason=noZones`);
|
|
1154
|
+
return result({
|
|
1155
|
+
status: "awaiting_zone_add_dashboard",
|
|
1156
|
+
message: "No domain on your Cloudflare account yet. Sign in at cloudflare.com, click Add a site, and add your domain. When it appears on the account, let me know and I'll continue.",
|
|
1157
|
+
data: { dashboardUrl: "https://dash.cloudflare.com" },
|
|
1158
|
+
});
|
|
1064
1159
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
log(`zone creation failed: ${msg}`);
|
|
1160
|
+
if (activeZones.length > 1) {
|
|
1161
|
+
log(`awaiting_zone_selection activeZones=${activeZones.length}`);
|
|
1068
1162
|
return result({
|
|
1069
|
-
status: "
|
|
1070
|
-
message:
|
|
1163
|
+
status: "awaiting_zone_selection",
|
|
1164
|
+
message: "Multiple domains on your Cloudflare account. Pick the one to use for this device.",
|
|
1165
|
+
data: {
|
|
1166
|
+
zones: activeZones.map((z) => ({ name: z.name, status: z.status })),
|
|
1167
|
+
render: {
|
|
1168
|
+
name: "single-select",
|
|
1169
|
+
data: {
|
|
1170
|
+
title: "Domain",
|
|
1171
|
+
options: activeZones.map((z) => ({ value: z.name, label: z.name })),
|
|
1172
|
+
submitLabel: "Use this domain",
|
|
1173
|
+
submitMessage: '{"selectedZoneName":"{{value}}"}',
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1071
1177
|
});
|
|
1072
1178
|
}
|
|
1179
|
+
// Exactly one active zone — use it.
|
|
1180
|
+
domain = activeZones[0].name;
|
|
1181
|
+
log(`auto-selected only active zone: ${domain}`);
|
|
1073
1182
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1183
|
+
// Verify the resolved zone is active (guard against state with a since-pending zone)
|
|
1184
|
+
const zone = allZones.find((z) => z.name === domain);
|
|
1185
|
+
if (zone && zone.status === "pending") {
|
|
1186
|
+
log(`zone ${domain} is pending — returning awaiting_nameservers`);
|
|
1076
1187
|
return result({
|
|
1077
1188
|
status: "awaiting_nameservers",
|
|
1078
|
-
message: `Your domain is
|
|
1189
|
+
message: `Your domain ${domain} is not yet active on Cloudflare. Update the nameservers at your registrar to:\n\n• ${zone.nameservers[0]}\n• ${zone.nameservers[1]}\n\nLet me know when you've updated them.`,
|
|
1079
1190
|
data: { nameservers: zone.nameservers },
|
|
1080
1191
|
});
|
|
1081
1192
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1193
|
+
// ── Step 4: Labels ──────────────────────────────────────────────
|
|
1194
|
+
// Resolution precedence: args > persisted state hostnames.
|
|
1195
|
+
const resolvedAdminLabel = adminLabel ?? stateAdminLabel;
|
|
1196
|
+
const resolvedPublicLabel = publicLabel ?? statePublicLabel ?? null;
|
|
1197
|
+
if (!resolvedAdminLabel) {
|
|
1198
|
+
log(`ui=tunnel-route-picker rendered tunnelDomain=${domain}`);
|
|
1199
|
+
return result({
|
|
1200
|
+
status: "awaiting_labels",
|
|
1201
|
+
message: `Pick a short name for your admin address on ${domain}. You can also pick one for a public address, or leave it empty.`,
|
|
1202
|
+
data: {
|
|
1203
|
+
domain,
|
|
1204
|
+
render: {
|
|
1205
|
+
name: "tunnel-route-picker",
|
|
1206
|
+
data: {
|
|
1207
|
+
domain,
|
|
1208
|
+
title: "Pick your addresses",
|
|
1209
|
+
description: "Your admin address is required. Public is optional and can be added later.",
|
|
1210
|
+
submitLabel: "Set up addresses",
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1084
1215
|
}
|
|
1085
|
-
//
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1216
|
+
// Defense-in-depth label validation (UI already filters, but tool
|
|
1217
|
+
// is authoritative — an agent misroute must fail loudly, not silently).
|
|
1218
|
+
if (!LABEL_RE.test(resolvedAdminLabel)) {
|
|
1219
|
+
log(`invalid admin label: ${resolvedAdminLabel}`);
|
|
1220
|
+
return result({
|
|
1221
|
+
status: "label-taken",
|
|
1222
|
+
message: "Admin label is not a valid DNS label. Pick something that is lowercase letters, numbers, and hyphens.",
|
|
1223
|
+
data: {
|
|
1224
|
+
domain,
|
|
1225
|
+
field: "admin",
|
|
1226
|
+
render: {
|
|
1227
|
+
name: "tunnel-route-picker",
|
|
1228
|
+
data: {
|
|
1229
|
+
domain,
|
|
1230
|
+
title: "Pick your addresses",
|
|
1231
|
+
defaultAdmin: "",
|
|
1232
|
+
defaultPublic: resolvedPublicLabel ?? "",
|
|
1233
|
+
error: { field: "admin", message: "Not a valid address — use lowercase letters, numbers, and hyphens." },
|
|
1234
|
+
submitLabel: "Set up addresses",
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
if (resolvedPublicLabel !== null && resolvedPublicLabel !== "" && !LABEL_RE.test(resolvedPublicLabel)) {
|
|
1241
|
+
log(`invalid public label: ${resolvedPublicLabel}`);
|
|
1242
|
+
return result({
|
|
1243
|
+
status: "label-taken",
|
|
1244
|
+
message: "Public label is not a valid DNS label.",
|
|
1245
|
+
data: {
|
|
1246
|
+
domain,
|
|
1247
|
+
field: "public",
|
|
1248
|
+
render: {
|
|
1249
|
+
name: "tunnel-route-picker",
|
|
1250
|
+
data: {
|
|
1251
|
+
domain,
|
|
1252
|
+
defaultAdmin: resolvedAdminLabel,
|
|
1253
|
+
defaultPublic: "",
|
|
1254
|
+
error: { field: "public", message: "Not a valid address." },
|
|
1255
|
+
submitLabel: "Set up addresses",
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
},
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
if (resolvedPublicLabel && resolvedAdminLabel === resolvedPublicLabel) {
|
|
1262
|
+
log(`admin and public labels identical: ${resolvedAdminLabel}`);
|
|
1263
|
+
return result({
|
|
1264
|
+
status: "label-taken",
|
|
1265
|
+
message: "Admin and public addresses must differ.",
|
|
1266
|
+
data: {
|
|
1267
|
+
domain,
|
|
1268
|
+
field: "public",
|
|
1269
|
+
render: {
|
|
1270
|
+
name: "tunnel-route-picker",
|
|
1271
|
+
data: {
|
|
1272
|
+
domain,
|
|
1273
|
+
defaultAdmin: resolvedAdminLabel,
|
|
1274
|
+
defaultPublic: "",
|
|
1275
|
+
error: { field: "public", message: "Public must differ from admin." },
|
|
1276
|
+
submitLabel: "Set up addresses",
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
const adminHostname = `${resolvedAdminLabel}.${domain}`;
|
|
1283
|
+
const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
|
|
1092
1284
|
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
1285
|
+
// ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
|
|
1286
|
+
let tunnelId = existingState?.tunnelId ?? null;
|
|
1287
|
+
let tunnelName = existingState?.tunnelName ?? null;
|
|
1288
|
+
let credentialsPath = existingState?.credentialsPath ?? null;
|
|
1289
|
+
if (!tunnelId) {
|
|
1290
|
+
if (selectedTunnelId && selectedTunnelId !== "__new__") {
|
|
1291
|
+
// User picked an existing tunnel — reuse its identity.
|
|
1292
|
+
const selected = allTunnels.find((t) => t.id === selectedTunnelId);
|
|
1293
|
+
if (!selected) {
|
|
1294
|
+
return result({
|
|
1295
|
+
status: "error",
|
|
1296
|
+
message: "Selected tunnel not found on account.",
|
|
1297
|
+
});
|
|
1108
1298
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1299
|
+
if (!hasBrandCredsFor(selected.id)) {
|
|
1300
|
+
// Cannot reuse without credentials — this tunnel belongs to
|
|
1301
|
+
// another device on the same account. Surface structured error.
|
|
1302
|
+
log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${selected.id}`);
|
|
1303
|
+
return result({
|
|
1304
|
+
status: "tunnel-name-taken",
|
|
1305
|
+
message: `The tunnel "${selected.name}" belongs to another device. Pick a different label for this one.`,
|
|
1306
|
+
data: {
|
|
1307
|
+
suggestedLabel: `${resolvedAdminLabel}2`,
|
|
1308
|
+
existingTunnelId: selected.id,
|
|
1309
|
+
render: {
|
|
1310
|
+
name: "tunnel-route-picker",
|
|
1311
|
+
data: {
|
|
1312
|
+
domain,
|
|
1313
|
+
defaultAdmin: "",
|
|
1314
|
+
defaultPublic: resolvedPublicLabel ?? "",
|
|
1315
|
+
error: { field: "admin", message: `That address is already taken. Pick a different one.` },
|
|
1316
|
+
submitLabel: "Set up addresses",
|
|
1317
|
+
},
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1122
1321
|
}
|
|
1123
|
-
|
|
1322
|
+
tunnelId = selected.id;
|
|
1323
|
+
tunnelName = selected.name;
|
|
1124
1324
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1325
|
+
else {
|
|
1326
|
+
// Create new — tunnel name is derived from admin label + domain.
|
|
1327
|
+
const derivedName = deriveTunnelName(resolvedAdminLabel, domain);
|
|
1328
|
+
log(`branch=new-tunnel zoneName=${domain} tunnelName=${derivedName}`);
|
|
1329
|
+
const accountExisting = allTunnels.find((t) => t.name === derivedName);
|
|
1330
|
+
if (accountExisting && !hasBrandCredsFor(accountExisting.id)) {
|
|
1331
|
+
log(`collision type=tunnel-name label=${resolvedAdminLabel} existingTunnelId=${accountExisting.id}`);
|
|
1332
|
+
return result({
|
|
1333
|
+
status: "tunnel-name-taken",
|
|
1334
|
+
message: `The address ${resolvedAdminLabel}.${domain} is already in use by another device. Pick a different one.`,
|
|
1335
|
+
data: {
|
|
1336
|
+
domain,
|
|
1337
|
+
suggestedLabel: `${resolvedAdminLabel}2`,
|
|
1338
|
+
existingTunnelId: accountExisting.id,
|
|
1339
|
+
field: "admin",
|
|
1340
|
+
render: {
|
|
1341
|
+
name: "tunnel-route-picker",
|
|
1342
|
+
data: {
|
|
1343
|
+
domain,
|
|
1344
|
+
defaultAdmin: "",
|
|
1345
|
+
defaultPublic: resolvedPublicLabel ?? "",
|
|
1346
|
+
error: { field: "admin", message: `That address is already taken — pick a different one.` },
|
|
1347
|
+
submitLabel: "Set up addresses",
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
try {
|
|
1354
|
+
const created = cloudflared.createTunnelCli(derivedName);
|
|
1355
|
+
tunnelId = created.tunnelId;
|
|
1356
|
+
tunnelName = created.tunnelName;
|
|
1357
|
+
credentialsPath = created.credentialsPath;
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1361
|
+
log(`tunnel creation failed: ${msg}`);
|
|
1362
|
+
return result({ status: "error", message: `Could not create the tunnel: ${msg}` });
|
|
1363
|
+
}
|
|
1129
1364
|
}
|
|
1130
1365
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1366
|
+
if (!tunnelId || !tunnelName) {
|
|
1367
|
+
return result({ status: "error", message: "Tunnel identity could not be resolved." });
|
|
1368
|
+
}
|
|
1369
|
+
// Fallback credentials path when reusing a tunnel picked from the UI:
|
|
1370
|
+
// createTunnelCli already copies from ~/.cloudflared to the brand dir,
|
|
1371
|
+
// but that runs only in the create branch. For the "reuse existing"
|
|
1372
|
+
// branch, derive the same brand-dir path here.
|
|
1373
|
+
if (!credentialsPath) {
|
|
1374
|
+
credentialsPath = join(homedir(), cloudflared.loadBrand().configDir, "cloudflared", `${tunnelId}.json`);
|
|
1375
|
+
}
|
|
1376
|
+
// ── Step 6: DNS routing + config (idempotent every call) ────────
|
|
1377
|
+
for (const h of hostnames) {
|
|
1378
|
+
const check = await cloudflared.checkDnsCollision(h, tunnelId);
|
|
1379
|
+
if (check.collision) {
|
|
1380
|
+
const offendingField = h === adminHostname ? "admin" : "public";
|
|
1381
|
+
log(`collision type=label hostname=${h} existingTunnelId=${check.existingTunnelId}`);
|
|
1382
|
+
return result({
|
|
1383
|
+
status: "label-taken",
|
|
1384
|
+
message: `The address ${h} already points to a different tunnel. Pick a different ${offendingField} label.`,
|
|
1385
|
+
data: {
|
|
1386
|
+
domain,
|
|
1387
|
+
field: offendingField,
|
|
1388
|
+
existingTunnelId: check.existingTunnelId ?? undefined,
|
|
1389
|
+
render: {
|
|
1390
|
+
name: "tunnel-route-picker",
|
|
1391
|
+
data: {
|
|
1392
|
+
domain,
|
|
1393
|
+
defaultAdmin: offendingField === "admin" ? "" : resolvedAdminLabel,
|
|
1394
|
+
defaultPublic: offendingField === "public" ? "" : (resolvedPublicLabel ?? ""),
|
|
1395
|
+
error: { field: offendingField, message: `That address is already in use by another device.` },
|
|
1396
|
+
submitLabel: "Set up addresses",
|
|
1397
|
+
},
|
|
1398
|
+
},
|
|
1399
|
+
},
|
|
1143
1400
|
});
|
|
1144
1401
|
}
|
|
1145
1402
|
}
|
|
1146
|
-
|
|
1403
|
+
const configPath = cloudflared.writeLocalConfig(tunnelId, credentialsPath, hostnames, platformPort);
|
|
1404
|
+
cloudflared.saveTunnelIdentity({
|
|
1405
|
+
tunnelId,
|
|
1406
|
+
tunnelName,
|
|
1407
|
+
domain,
|
|
1408
|
+
configPath,
|
|
1409
|
+
credentialsPath,
|
|
1410
|
+
adminHostname,
|
|
1411
|
+
publicHostname,
|
|
1412
|
+
});
|
|
1413
|
+
for (const h of hostnames) {
|
|
1414
|
+
cloudflared.routeDnsCli(tunnelId, h);
|
|
1415
|
+
}
|
|
1416
|
+
log(`ui=tunnel-route-picker submitted adminLabel=${resolvedAdminLabel} publicLabel=${resolvedPublicLabel ?? "null"}`);
|
|
1417
|
+
// ── Step 7: Remote auth check ───────────────────────────────────
|
|
1147
1418
|
// scrypt hashing on Pi ARM takes 2-5s after form submission —
|
|
1148
1419
|
// wait before checking to avoid a false negative on every first attempt
|
|
1149
1420
|
log("waiting 3s for scrypt hash before checking remote auth");
|
|
@@ -1172,7 +1443,7 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
|
|
|
1172
1443
|
message: "Could not verify remote access password — the platform may not be running. Make sure it's started and try again.",
|
|
1173
1444
|
});
|
|
1174
1445
|
}
|
|
1175
|
-
// ── Step
|
|
1446
|
+
// ── Step 8: Tunnel enable ───────────────────────────────────────
|
|
1176
1447
|
const tunnelState = cloudflared.getPersistedState();
|
|
1177
1448
|
if (!tunnelState) {
|
|
1178
1449
|
return result({ status: "error", message: "Tunnel state lost — please try again." });
|
|
@@ -1204,6 +1475,7 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
|
|
|
1204
1475
|
// Verify admin URL is reachable through Cloudflare edge
|
|
1205
1476
|
const verifyUrl = `https://${adminHostname}`;
|
|
1206
1477
|
let verified = false;
|
|
1478
|
+
let lastStatus = null;
|
|
1207
1479
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1208
1480
|
if (attempt > 0)
|
|
1209
1481
|
await new Promise((r) => setTimeout(r, 5000));
|
|
@@ -1212,8 +1484,10 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
|
|
|
1212
1484
|
signal: AbortSignal.timeout(10000),
|
|
1213
1485
|
redirect: "manual",
|
|
1214
1486
|
});
|
|
1487
|
+
lastStatus = res.status;
|
|
1215
1488
|
if (res.status !== 530) {
|
|
1216
1489
|
verified = true;
|
|
1490
|
+
log(`verified url=${verifyUrl} status=${res.status}`);
|
|
1217
1491
|
break;
|
|
1218
1492
|
}
|
|
1219
1493
|
}
|
|
@@ -1222,7 +1496,7 @@ server.tool("cloudflare-setup", "Deterministic state machine for full Cloudflare
|
|
|
1222
1496
|
}
|
|
1223
1497
|
}
|
|
1224
1498
|
if (!verified) {
|
|
1225
|
-
log(`
|
|
1499
|
+
log(`verified url=${verifyUrl} status=${lastStatus ?? "timeout"} result=unreachable`);
|
|
1226
1500
|
return result({
|
|
1227
1501
|
status: "error",
|
|
1228
1502
|
message: `The tunnel is running but the admin URL is not reachable yet. This usually resolves in a few minutes as DNS propagates. Try again shortly.`,
|