First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
const parseCookies = (header) => {
|
||||
const raw = typeof header === "string" ? header : "";
|
||||
if (!raw.trim()) return {};
|
||||
const out = {};
|
||||
for (const part of raw.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = part.slice(0, idx).trim();
|
||||
const value = part.slice(idx + 1).trim();
|
||||
if (!key) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function createAccessGate(options) {
|
||||
const token = String(options?.token ?? "").trim();
|
||||
const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access";
|
||||
|
||||
const enabled = Boolean(token);
|
||||
|
||||
const isAuthorized = (req) => {
|
||||
if (!enabled) return true;
|
||||
const cookieHeader = req.headers?.cookie;
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
return cookies[cookieName] === token;
|
||||
};
|
||||
|
||||
const handleHttp = (req, res) => {
|
||||
if (!enabled) return false;
|
||||
if (String(req.url || "/").startsWith("/api/")) {
|
||||
if (!isAuthorized(req)) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Studio access token required. Send the configured Studio access cookie and retry.",
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const allowUpgrade = (req) => {
|
||||
if (!enabled) return true;
|
||||
return isAuthorized(req);
|
||||
};
|
||||
|
||||
return { enabled, handleHttp, allowUpgrade };
|
||||
}
|
||||
|
||||
module.exports = { createAccessGate };
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
const { WebSocket, WebSocketServer } = require("ws");
|
||||
|
||||
const buildErrorResponse = (id, code, message) => {
|
||||
return {
|
||||
type: "res",
|
||||
id,
|
||||
ok: false,
|
||||
error: { code, message },
|
||||
};
|
||||
};
|
||||
|
||||
const isObject = (value) => Boolean(value && typeof value === "object");
|
||||
|
||||
const safeJsonParse = (raw) => {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePathname = (url) => {
|
||||
const raw = typeof url === "string" ? url : "";
|
||||
const idx = raw.indexOf("?");
|
||||
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
|
||||
};
|
||||
|
||||
const injectAuthToken = (params, token) => {
|
||||
const next = isObject(params) ? { ...params } : {};
|
||||
const auth = isObject(next.auth) ? { ...next.auth } : {};
|
||||
auth.token = token;
|
||||
next.auth = auth;
|
||||
return next;
|
||||
};
|
||||
|
||||
const resolveOriginForUpstream = (upstreamUrl) => {
|
||||
const url = new URL(upstreamUrl);
|
||||
const proto = url.protocol === "wss:" ? "https:" : "http:";
|
||||
const hostname =
|
||||
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
|
||||
? "localhost"
|
||||
: url.hostname;
|
||||
const host = url.port ? `${hostname}:${url.port}` : hostname;
|
||||
return `${proto}//${host}`;
|
||||
};
|
||||
|
||||
const hasNonEmptyToken = (params) => {
|
||||
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.token : "";
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
};
|
||||
|
||||
const hasNonEmptyPassword = (params) => {
|
||||
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.password : "";
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
};
|
||||
|
||||
const hasNonEmptyDeviceToken = (params) => {
|
||||
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.deviceToken : "";
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
};
|
||||
|
||||
const hasCompleteDeviceAuth = (params) => {
|
||||
const device = params && isObject(params) && isObject(params.device) ? params.device : null;
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
const id = typeof device.id === "string" ? device.id.trim() : "";
|
||||
const publicKey = typeof device.publicKey === "string" ? device.publicKey.trim() : "";
|
||||
const signature = typeof device.signature === "string" ? device.signature.trim() : "";
|
||||
const nonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||
const signedAt = device.signedAt;
|
||||
return (
|
||||
id.length > 0 &&
|
||||
publicKey.length > 0 &&
|
||||
signature.length > 0 &&
|
||||
nonce.length > 0 &&
|
||||
Number.isFinite(signedAt) &&
|
||||
signedAt >= 0
|
||||
);
|
||||
};
|
||||
|
||||
function createGatewayProxy(options) {
|
||||
const {
|
||||
loadUpstreamSettings,
|
||||
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
|
||||
log = () => {},
|
||||
logError = (msg, err) => console.error(msg, err),
|
||||
} = options || {};
|
||||
|
||||
if (typeof loadUpstreamSettings !== "function") {
|
||||
throw new Error("createGatewayProxy requires loadUpstreamSettings().");
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
wss.on("connection", (browserWs) => {
|
||||
let upstreamWs = null;
|
||||
let upstreamReady = false;
|
||||
let upstreamUrl = "";
|
||||
let upstreamToken = "";
|
||||
let connectRequestId = null;
|
||||
let connectResponseSent = false;
|
||||
let pendingConnectFrame = null;
|
||||
let pendingUpstreamSetupError = null;
|
||||
let closed = false;
|
||||
|
||||
const closeBoth = (code, reason) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
browserWs.close(code, reason);
|
||||
} catch {}
|
||||
try {
|
||||
upstreamWs?.close(code, reason);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const sendToBrowser = (frame) => {
|
||||
if (browserWs.readyState !== WebSocket.OPEN) return;
|
||||
browserWs.send(JSON.stringify(frame));
|
||||
};
|
||||
|
||||
const sendConnectError = (code, message) => {
|
||||
if (connectRequestId && !connectResponseSent) {
|
||||
connectResponseSent = true;
|
||||
sendToBrowser(buildErrorResponse(connectRequestId, code, message));
|
||||
}
|
||||
closeBoth(1011, "connect failed");
|
||||
};
|
||||
|
||||
const forwardConnectFrame = (frame) => {
|
||||
const browserHasAuth =
|
||||
hasNonEmptyToken(frame.params) ||
|
||||
hasNonEmptyPassword(frame.params) ||
|
||||
hasNonEmptyDeviceToken(frame.params) ||
|
||||
hasCompleteDeviceAuth(frame.params);
|
||||
|
||||
if (!upstreamToken && !browserHasAuth) {
|
||||
sendConnectError(
|
||||
"studio.gateway_token_missing",
|
||||
"Upstream gateway token is not configured on the Studio host."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const connectFrame = browserHasAuth
|
||||
? frame
|
||||
: {
|
||||
...frame,
|
||||
params: injectAuthToken(frame.params, upstreamToken),
|
||||
};
|
||||
upstreamWs.send(JSON.stringify(connectFrame));
|
||||
};
|
||||
|
||||
const maybeForwardPendingConnect = () => {
|
||||
if (!pendingConnectFrame || !upstreamReady || upstreamWs?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const frame = pendingConnectFrame;
|
||||
pendingConnectFrame = null;
|
||||
forwardConnectFrame(frame);
|
||||
};
|
||||
|
||||
const startUpstream = async () => {
|
||||
try {
|
||||
const settings = await loadUpstreamSettings();
|
||||
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
|
||||
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
|
||||
} catch (err) {
|
||||
logError("Failed to load upstream gateway settings.", err);
|
||||
pendingUpstreamSetupError = {
|
||||
code: "studio.settings_load_failed",
|
||||
message: "Failed to load Studio gateway settings.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!upstreamUrl) {
|
||||
pendingUpstreamSetupError = {
|
||||
code: "studio.gateway_url_missing",
|
||||
message: "Upstream gateway URL is not configured on the Studio host.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamOrigin = "";
|
||||
try {
|
||||
upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
|
||||
} catch {
|
||||
pendingUpstreamSetupError = {
|
||||
code: "studio.gateway_url_invalid",
|
||||
message: "Upstream gateway URL is invalid on the Studio host.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin });
|
||||
|
||||
upstreamWs.on("open", () => {
|
||||
upstreamReady = true;
|
||||
maybeForwardPendingConnect();
|
||||
});
|
||||
|
||||
upstreamWs.on("message", (upRaw) => {
|
||||
const upParsed = safeJsonParse(String(upRaw ?? ""));
|
||||
if (upParsed && isObject(upParsed) && upParsed.type === "res") {
|
||||
const resId = typeof upParsed.id === "string" ? upParsed.id : "";
|
||||
if (resId && connectRequestId && resId === connectRequestId) {
|
||||
connectResponseSent = true;
|
||||
}
|
||||
}
|
||||
if (browserWs.readyState === WebSocket.OPEN) {
|
||||
browserWs.send(String(upRaw ?? ""));
|
||||
}
|
||||
});
|
||||
|
||||
upstreamWs.on("close", (ev) => {
|
||||
const reason = typeof ev?.reason === "string" ? ev.reason : "";
|
||||
if (!connectResponseSent && connectRequestId) {
|
||||
sendToBrowser(
|
||||
buildErrorResponse(
|
||||
connectRequestId,
|
||||
"studio.upstream_closed",
|
||||
`Upstream gateway closed (${ev.code}): ${reason}`
|
||||
)
|
||||
);
|
||||
}
|
||||
closeBoth(1012, "upstream closed");
|
||||
});
|
||||
|
||||
upstreamWs.on("error", (err) => {
|
||||
logError("Upstream gateway WebSocket error.", err);
|
||||
sendConnectError(
|
||||
"studio.upstream_error",
|
||||
"Failed to connect to upstream gateway WebSocket."
|
||||
);
|
||||
});
|
||||
|
||||
log("proxy connected");
|
||||
};
|
||||
|
||||
void startUpstream();
|
||||
|
||||
browserWs.on("message", async (raw) => {
|
||||
const parsed = safeJsonParse(String(raw ?? ""));
|
||||
if (!parsed || !isObject(parsed)) {
|
||||
closeBoth(1003, "invalid json");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connectRequestId) {
|
||||
if (parsed.type !== "req" || parsed.method !== "connect") {
|
||||
closeBoth(1008, "connect required");
|
||||
return;
|
||||
}
|
||||
const id = typeof parsed.id === "string" ? parsed.id : "";
|
||||
if (!id) {
|
||||
closeBoth(1008, "connect id required");
|
||||
return;
|
||||
}
|
||||
connectRequestId = id;
|
||||
if (pendingUpstreamSetupError) {
|
||||
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
||||
return;
|
||||
}
|
||||
pendingConnectFrame = parsed;
|
||||
maybeForwardPendingConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!upstreamReady || upstreamWs.readyState !== WebSocket.OPEN) {
|
||||
closeBoth(1013, "upstream not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "req" && parsed.method === "connect" && !connectResponseSent) {
|
||||
pendingConnectFrame = null;
|
||||
forwardConnectFrame(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
upstreamWs.send(JSON.stringify(parsed));
|
||||
});
|
||||
|
||||
browserWs.on("close", () => {
|
||||
closeBoth(1000, "client closed");
|
||||
});
|
||||
|
||||
browserWs.on("error", (err) => {
|
||||
logError("Browser WebSocket error.", err);
|
||||
closeBoth(1011, "client error");
|
||||
});
|
||||
});
|
||||
|
||||
const handleUpgrade = (req, socket, head) => {
|
||||
if (!allowWs(req)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
};
|
||||
|
||||
return { wss, handleUpgrade };
|
||||
}
|
||||
|
||||
module.exports = { createGatewayProxy };
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
const http = require("node:http");
|
||||
const next = require("next");
|
||||
|
||||
const { createAccessGate } = require("./access-gate");
|
||||
const { createGatewayProxy } = require("./gateway-proxy");
|
||||
const { assertPublicHostAllowed, resolveHosts } = require("./network-policy");
|
||||
const { loadUpstreamGatewaySettings } = require("./studio-settings");
|
||||
|
||||
const resolvePort = () => {
|
||||
const raw = process.env.PORT?.trim() || "3000";
|
||||
const port = Number(raw);
|
||||
if (!Number.isFinite(port) || port <= 0) return 3000;
|
||||
return port;
|
||||
};
|
||||
|
||||
const resolvePathname = (url) => {
|
||||
const raw = typeof url === "string" ? url : "";
|
||||
const idx = raw.indexOf("?");
|
||||
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const dev = process.argv.includes("--dev");
|
||||
const hostnames = Array.from(new Set(resolveHosts(process.env)));
|
||||
const hostname = hostnames[0] ?? "127.0.0.1";
|
||||
const port = resolvePort();
|
||||
for (const host of hostnames) {
|
||||
assertPublicHostAllowed({
|
||||
host,
|
||||
studioAccessToken: process.env.STUDIO_ACCESS_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
const app = next({
|
||||
dev,
|
||||
hostname,
|
||||
port,
|
||||
...(dev ? { webpack: true } : null),
|
||||
});
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
const accessGate = createAccessGate({
|
||||
token: process.env.STUDIO_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => {
|
||||
const settings = loadUpstreamGatewaySettings(process.env);
|
||||
return { url: settings.url, token: settings.token };
|
||||
},
|
||||
allowWs: (req) => {
|
||||
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
||||
if (!accessGate.allowUpgrade(req)) return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await app.prepare();
|
||||
const handleUpgrade = app.getUpgradeHandler();
|
||||
const handleServerUpgrade = (req, socket, head) => {
|
||||
if (resolvePathname(req.url) === "/api/gateway/ws") {
|
||||
proxy.handleUpgrade(req, socket, head);
|
||||
return;
|
||||
}
|
||||
handleUpgrade(req, socket, head);
|
||||
};
|
||||
|
||||
const createServer = () =>
|
||||
http.createServer((req, res) => {
|
||||
if (accessGate.handleHttp(req, res)) return;
|
||||
handle(req, res);
|
||||
});
|
||||
|
||||
const servers = hostnames.map(() => createServer());
|
||||
|
||||
const attachUpgradeHandlers = (server) => {
|
||||
server.on("upgrade", handleServerUpgrade);
|
||||
server.on("newListener", (eventName, listener) => {
|
||||
if (eventName !== "upgrade") return;
|
||||
if (listener === handleServerUpgrade) return;
|
||||
process.nextTick(() => {
|
||||
server.removeListener("upgrade", listener);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const server of servers) {
|
||||
attachUpgradeHandlers(server);
|
||||
}
|
||||
|
||||
const listenOnHost = (server, host) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const onError = (err) => {
|
||||
server.off("error", onError);
|
||||
reject(err);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const closeServer = (server) =>
|
||||
new Promise((resolve) => {
|
||||
if (!server.listening) return resolve();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(servers.map((server, index) => listenOnHost(server, hostnames[index])));
|
||||
} catch (err) {
|
||||
await Promise.all(servers.map((server) => closeServer(server)));
|
||||
throw err;
|
||||
}
|
||||
|
||||
const hostForBrowser = hostnames.some((value) => value === "127.0.0.1" || value === "::1")
|
||||
? "localhost"
|
||||
: hostname === "0.0.0.0" || hostname === "::"
|
||||
? "localhost"
|
||||
: hostname;
|
||||
|
||||
const browserUrl = `http://${hostForBrowser}:${port}`;
|
||||
console.info(`Open in browser: ${browserUrl}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const net = require("node:net");
|
||||
|
||||
const normalizeHost = (host) => {
|
||||
let raw = String(host ?? "").trim().toLowerCase();
|
||||
if (!raw) return "";
|
||||
|
||||
if (raw.startsWith("[")) {
|
||||
const end = raw.indexOf("]");
|
||||
if (end !== -1) {
|
||||
return raw.slice(1, end).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const colonCount = (raw.match(/:/g) || []).length;
|
||||
if (colonCount === 1) {
|
||||
const idx = raw.lastIndexOf(":");
|
||||
const maybePort = raw.slice(idx + 1);
|
||||
if (/^\d+$/.test(maybePort)) {
|
||||
raw = raw.slice(0, idx);
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
};
|
||||
|
||||
const resolveHosts = (env = process.env) => {
|
||||
const host = String(env.HOST ?? "").trim();
|
||||
if (host) return [host];
|
||||
return ["127.0.0.1", "::1"];
|
||||
};
|
||||
|
||||
const resolveHost = (env = process.env) => {
|
||||
const hosts = resolveHosts(env);
|
||||
return hosts[0] ?? "127.0.0.1";
|
||||
};
|
||||
|
||||
const isIpv4Loopback = (value) => value.startsWith("127.");
|
||||
|
||||
const isIpv6Loopback = (value) => {
|
||||
if (value === "::1" || value === "0:0:0:0:0:0:0:1") return true;
|
||||
if (!value.startsWith("::ffff:")) return false;
|
||||
const mapped = value.slice("::ffff:".length);
|
||||
return net.isIP(mapped) === 4 && isIpv4Loopback(mapped);
|
||||
};
|
||||
|
||||
const isPublicHost = (host) => {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!normalized) return false;
|
||||
|
||||
if (normalized === "localhost") return false;
|
||||
if (normalized === "0.0.0.0" || normalized === "::") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipVersion = net.isIP(normalized);
|
||||
if (ipVersion === 4) {
|
||||
return !isIpv4Loopback(normalized);
|
||||
}
|
||||
if (ipVersion === 6) {
|
||||
return !isIpv6Loopback(normalized);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const assertPublicHostAllowed = ({ host, studioAccessToken }) => {
|
||||
if (!isPublicHost(host)) return;
|
||||
|
||||
const token = String(studioAccessToken ?? "").trim();
|
||||
if (token) return;
|
||||
|
||||
const normalized = normalizeHost(host) || String(host ?? "").trim() || "(unknown)";
|
||||
throw new Error(
|
||||
`Refusing to bind Studio to public host "${normalized}" without STUDIO_ACCESS_TOKEN. ` +
|
||||
"Set STUDIO_ACCESS_TOKEN or bind HOST to 127.0.0.1/::1/localhost."
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
resolveHosts,
|
||||
resolveHost,
|
||||
isPublicHost,
|
||||
assertPublicHostAllowed,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"];
|
||||
const NEW_STATE_DIRNAME = ".openclaw";
|
||||
|
||||
const resolveUserPath = (input) => {
|
||||
const trimmed = String(input ?? "").trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
};
|
||||
|
||||
const resolveDefaultHomeDir = () => {
|
||||
const home = os.homedir();
|
||||
if (home) {
|
||||
try {
|
||||
if (fs.existsSync(home)) return home;
|
||||
} catch {}
|
||||
}
|
||||
return os.tmpdir();
|
||||
};
|
||||
|
||||
const resolveStateDir = (env = process.env) => {
|
||||
const override =
|
||||
env.OPENCLAW_STATE_DIR?.trim() ||
|
||||
env.MOLTBOT_STATE_DIR?.trim() ||
|
||||
env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) return resolveUserPath(override);
|
||||
|
||||
const home = resolveDefaultHomeDir();
|
||||
const newDir = path.join(home, NEW_STATE_DIRNAME);
|
||||
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(home, dir));
|
||||
try {
|
||||
if (fs.existsSync(newDir)) return newDir;
|
||||
} catch {}
|
||||
for (const dir of legacyDirs) {
|
||||
try {
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
} catch {}
|
||||
}
|
||||
return newDir;
|
||||
};
|
||||
|
||||
const resolveStudioSettingsPath = (env = process.env) => {
|
||||
return path.join(resolveStateDir(env), "claw3d", "settings.json");
|
||||
};
|
||||
|
||||
const readJsonFile = (filePath) => {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "ws://localhost:18789";
|
||||
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
|
||||
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
||||
|
||||
const isRecord = (value) => Boolean(value && typeof value === "object");
|
||||
|
||||
const isLocalGatewayUrl = (value) => {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readOpenclawGatewayDefaults = (env = process.env) => {
|
||||
try {
|
||||
const stateDir = resolveStateDir(env);
|
||||
const configPath = path.join(stateDir, OPENCLAW_CONFIG_FILENAME);
|
||||
const parsed = readJsonFile(configPath);
|
||||
if (!isRecord(parsed)) return null;
|
||||
const gateway = isRecord(parsed.gateway) ? parsed.gateway : null;
|
||||
if (!gateway) return null;
|
||||
const auth = isRecord(gateway.auth) ? gateway.auth : null;
|
||||
const token = typeof auth?.token === "string" ? auth.token.trim() : "";
|
||||
const port =
|
||||
typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null;
|
||||
if (!token) return null;
|
||||
const url = port ? `ws://localhost:${port}` : "";
|
||||
if (!url) return null;
|
||||
return { url, token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||
const settingsPath = resolveStudioSettingsPath(env);
|
||||
const parsed = readJsonFile(settingsPath);
|
||||
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
|
||||
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
||||
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
||||
if (!token && (!url || isLocalGatewayUrl(url))) {
|
||||
const defaults = readOpenclawGatewayDefaults(env);
|
||||
if (defaults) {
|
||||
return {
|
||||
url: url || defaults.url,
|
||||
token: defaults.token,
|
||||
settingsPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: url || DEFAULT_GATEWAY_URL,
|
||||
token,
|
||||
settingsPath,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
resolveStateDir,
|
||||
resolveStudioSettingsPath,
|
||||
loadUpstreamGatewaySettings,
|
||||
};
|
||||
Reference in New Issue
Block a user