feat: configurable control ui base path
This commit is contained in:
@@ -618,7 +618,11 @@ export async function runConfigureWizard(
|
||||
note(
|
||||
(() => {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
|
||||
"\n",
|
||||
);
|
||||
@@ -635,7 +639,11 @@ export async function runConfigureWizard(
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
await openUrl(links.httpUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -221,6 +222,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
export function resolveControlUiLinks(params: {
|
||||
port: number;
|
||||
bind?: "auto" | "lan" | "tailnet" | "loopback";
|
||||
basePath?: string;
|
||||
}): { httpUrl: string; wsUrl: string } {
|
||||
const port = params.port;
|
||||
const bind = params.bind ?? "loopback";
|
||||
@@ -229,8 +231,11 @@ export function resolveControlUiLinks(params: {
|
||||
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
||||
? (tailnetIPv4 ?? "127.0.0.1")
|
||||
: "127.0.0.1";
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const uiPath = basePath ? `${basePath}/` : "/";
|
||||
const wsPath = basePath ? basePath : "";
|
||||
return {
|
||||
httpUrl: `http://${host}:${port}/`,
|
||||
wsUrl: `ws://${host}:${port}`,
|
||||
httpUrl: `http://${host}:${port}${uiPath}`,
|
||||
wsUrl: `ws://${host}:${port}${wsPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -402,8 +402,10 @@ export type TalkConfig = {
|
||||
};
|
||||
|
||||
export type GatewayControlUiConfig = {
|
||||
/** If false, the Gateway will not serve the Control UI (/). Default: true. */
|
||||
/** If false, the Gateway will not serve the Control UI (default /). */
|
||||
enabled?: boolean;
|
||||
/** Optional base path prefix for the Control UI (e.g. "/clawdis"). */
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
@@ -1269,6 +1271,7 @@ export const ClawdisSchema = z.object({
|
||||
controlUi: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
basePath: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
auth: z
|
||||
|
||||
@@ -81,6 +81,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.remote.password": "Remote Gateway Password",
|
||||
"gateway.auth.token": "Gateway Token",
|
||||
"gateway.auth.password": "Gateway Password",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"agent.workspace": "Workspace",
|
||||
"agent.model": "Default Model",
|
||||
"ui.seamColor": "Accent Color",
|
||||
@@ -97,10 +98,13 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"gateway.auth.token":
|
||||
"Required for multi-machine access or non-loopback binds.",
|
||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||
"gateway.controlUi.basePath":
|
||||
"Optional URL prefix where the Control UI is served (e.g. /clawdis).",
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||
"gateway.remote.url": "ws://host:18789",
|
||||
"gateway.controlUi.basePath": "/clawdis",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||
|
||||
@@ -3,9 +3,22 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const _UI_PREFIX = "/ui/";
|
||||
const ROOT_PREFIX = "/";
|
||||
|
||||
export type ControlUiRequestOptions = {
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
export function normalizeControlUiBasePath(basePath?: string): string {
|
||||
if (!basePath) return "";
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized) return "";
|
||||
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
||||
if (normalized === "/") return "";
|
||||
if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveControlUiRoot(): string | null {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const execDir = (() => {
|
||||
@@ -73,6 +86,29 @@ function serveFile(res: ServerResponse, filePath: string) {
|
||||
res.end(fs.readFileSync(filePath));
|
||||
}
|
||||
|
||||
function injectControlUiBasePath(html: string, basePath: string): string {
|
||||
const script = `<script>window.__CLAWDIS_CONTROL_UI_BASE_PATH__=${JSON.stringify(
|
||||
basePath,
|
||||
)};</script>`;
|
||||
if (html.includes("__CLAWDIS_CONTROL_UI_BASE_PATH__")) return html;
|
||||
const headClose = html.indexOf("</head>");
|
||||
if (headClose !== -1) {
|
||||
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
||||
}
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
function serveIndexHtml(
|
||||
res: ServerResponse,
|
||||
indexPath: string,
|
||||
basePath: string,
|
||||
) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
const raw = fs.readFileSync(indexPath, "utf8");
|
||||
res.end(injectControlUiBasePath(raw, basePath));
|
||||
}
|
||||
|
||||
function isSafeRelativePath(relPath: string) {
|
||||
if (!relPath) return false;
|
||||
const normalized = path.posix.normalize(relPath);
|
||||
@@ -84,6 +120,7 @@ function isSafeRelativePath(relPath: string) {
|
||||
export function handleControlUiHttpRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts?: ControlUiRequestOptions,
|
||||
): boolean {
|
||||
const urlRaw = req.url;
|
||||
if (!urlRaw) return false;
|
||||
@@ -95,10 +132,24 @@ export function handleControlUiHttpRequest(
|
||||
}
|
||||
|
||||
const url = new URL(urlRaw, "http://localhost");
|
||||
const basePath = normalizeControlUiBasePath(opts?.basePath);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (url.pathname === "/ui" || url.pathname.startsWith("/ui/")) {
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
if (!basePath) {
|
||||
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (basePath) {
|
||||
if (pathname === basePath) {
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", `${basePath}/${url.search}`);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (!pathname.startsWith(`${basePath}/`)) return false;
|
||||
}
|
||||
|
||||
const root = resolveControlUiRoot();
|
||||
@@ -111,10 +162,15 @@ export function handleControlUiHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const uiPath =
|
||||
basePath && pathname.startsWith(`${basePath}/`)
|
||||
? pathname.slice(basePath.length)
|
||||
: pathname;
|
||||
const rel = (() => {
|
||||
if (url.pathname === ROOT_PREFIX) return "";
|
||||
if (url.pathname.startsWith("/assets/")) return url.pathname.slice(1);
|
||||
return url.pathname.slice(1);
|
||||
if (uiPath === ROOT_PREFIX) return "";
|
||||
const assetsIndex = uiPath.indexOf("/assets/");
|
||||
if (assetsIndex >= 0) return uiPath.slice(assetsIndex + 1);
|
||||
return uiPath.slice(1);
|
||||
})();
|
||||
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
|
||||
const fileRel = requested || "index.html";
|
||||
@@ -130,6 +186,10 @@ export function handleControlUiHttpRequest(
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
if (path.basename(filePath) === "index.html") {
|
||||
serveIndexHtml(res, filePath, basePath);
|
||||
return true;
|
||||
}
|
||||
serveFile(res, filePath);
|
||||
return true;
|
||||
}
|
||||
@@ -137,7 +197,7 @@ export function handleControlUiHttpRequest(
|
||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||
const indexPath = path.join(root, "index.html");
|
||||
if (fs.existsSync(indexPath)) {
|
||||
serveFile(res, indexPath);
|
||||
serveIndexHtml(res, indexPath, basePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -501,7 +501,11 @@ export async function runOnboardingWizard(
|
||||
|
||||
await prompter.note(
|
||||
(() => {
|
||||
const links = resolveControlUiLinks({ bind, port });
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port,
|
||||
basePath: config.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
@@ -523,7 +527,11 @@ export async function runOnboardingWizard(
|
||||
initialValue: true,
|
||||
});
|
||||
if (wantsOpen) {
|
||||
const links = resolveControlUiLinks({ bind, port });
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port,
|
||||
basePath: config.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const tokenParam =
|
||||
authMode === "token" && gatewayToken
|
||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||
|
||||
Reference in New Issue
Block a user