diff --git a/CHANGELOG.md b/CHANGELOG.md index 3391626d8..ca756ced0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Features - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. -- Control UI: support configurable base paths (`gateway.controlUi.basePath`) for hosting under URL prefixes. +- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Config: expose schema + UI hints for generic config forms (Web UI + future clients). diff --git a/README.md b/README.md index 2a9b1c48d..c7a49936c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only): - **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`. - **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection. - **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence. -- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge. +- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge. ### iOS app (apps/ios) - **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port. diff --git a/docs/configuration.md b/docs/configuration.md index ada50170f..7fa89ecf0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -645,13 +645,18 @@ Defaults: mode: "local", // or "remote" port: 18789, // WS + HTTP multiplex bind: "loopback", - // controlUi: { enabled: true } + // controlUi: { enabled: true, basePath: "/clawdis" } // auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access // tailscale: { mode: "off" | "serve" | "funnel" } } } ``` +Control UI base path: +- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. +- Examples: `"/ui"`, `"/clawdis"`, `"/apps/clawdis"`. +- Default: root (`/`) (unchanged). + Notes: - `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). diff --git a/docs/control-ui.md b/docs/control-ui.md index 5f1ed3c25..b748766a2 100644 --- a/docs/control-ui.md +++ b/docs/control-ui.md @@ -6,9 +6,10 @@ read_when: --- # Control UI (browser) -The Control UI is a small **Vite + Lit** single-page app served by the Gateway under: +The Control UI is a small **Vite + Lit** single-page app served by the Gateway: -- `http://:18789/` +- default: `http://:18789/` +- optional prefix: set `gateway.controlUi.basePath` (e.g. `/clawdis`) It speaks **directly to the Gateway WebSocket** on the same port. @@ -40,7 +41,7 @@ clawdis gateway --tailscale serve ``` Open: -- `https:///ui/` +- `https:///` (or your configured `gateway.controlUi.basePath`) By default, the gateway trusts Tailscale identity headers in serve mode. You can still set `CLAWDIS_GATEWAY_TOKEN` or `gateway.auth` if you want a shared secret instead. @@ -52,7 +53,7 @@ clawdis gateway --bind tailnet --token "$(openssl rand -hex 32)" ``` Then open: -- `http://:18789/ui/` +- `http://:18789/` (or your configured `gateway.controlUi.basePath`) Paste the token into the UI settings (sent as `connect.params.auth.token`). @@ -65,6 +66,12 @@ pnpm ui:install pnpm ui:build ``` +Optional absolute base (when you want fixed asset URLs): + +```bash +CLAWDIS_CONTROL_UI_BASE_PATH=/clawdis/ pnpm ui:build +``` + For local development (separate dev server): ```bash diff --git a/docs/dashboard.md b/docs/dashboard.md index 45d288a71..e859fb7c7 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -5,7 +5,8 @@ read_when: --- # Dashboard (Control UI) -The Gateway dashboard is the browser Control UI served at `/ui/`. +The Gateway dashboard is the browser Control UI served at `/` by default +(override with `gateway.controlUi.basePath`). Key references: - `docs/control-ui.md` for usage and UI capabilities. diff --git a/docs/tailscale.md b/docs/tailscale.md index 03408890f..1b6efb8c8 100644 --- a/docs/tailscale.md +++ b/docs/tailscale.md @@ -40,7 +40,7 @@ default unless you force `gateway.auth.mode` to `password` or set } ``` -Open: `https:///ui/` +Open: `https:///` (or your configured `gateway.controlUi.basePath`) ### Public internet (Funnel + shared password) diff --git a/docs/web.md b/docs/web.md index 60fea4f58..acc8b3699 100644 --- a/docs/web.md +++ b/docs/web.md @@ -8,7 +8,8 @@ read_when: The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket: -- `http://:18789/ui/` +- default: `http://:18789/` +- optional prefix: set `gateway.controlUi.basePath` (e.g. `/clawdis`) The UI talks directly to the Gateway WS and supports: - Chat (`chat.history`, `chat.send`, `chat.abort`) @@ -34,7 +35,7 @@ You can control it via config: ```json5 { gateway: { - controlUi: { enabled: true } // set false to disable /ui/ + controlUi: { enabled: true, basePath: "/clawdis" } // basePath optional } } ``` @@ -61,7 +62,7 @@ clawdis gateway ``` Open: -- `https:///ui/` +- `https:///` (or your configured `gateway.controlUi.basePath`) ### Tailnet bind + token (legacy) @@ -82,7 +83,7 @@ clawdis gateway ``` Open: -- `http://:18789/ui/` +- `http://:18789/` (or your configured `gateway.controlUi.basePath`) ### Public internet (Funnel) diff --git a/src/commands/configure.ts b/src/commands/configure.ts index dbaf037eb..7ee52b3c8 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -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); } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 3674631b0..227cc6dea 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -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}`, }; } diff --git a/src/config/config.ts b/src/config/config.ts index 0d9080a4d..99020540d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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 diff --git a/src/config/schema.ts b/src/config/schema.ts index 1407da3f8..e6ffeef47 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -81,6 +81,7 @@ const FIELD_LABELS: Record = { "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 = { "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 = { "gateway.remote.url": "ws://host:18789", + "gateway.controlUi.basePath": "/clawdis", }; const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 32bab8818..cd4c41f07 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -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 = ``; + if (html.includes("__CLAWDIS_CONTROL_UI_BASE_PATH__")) return html; + const headClose = html.indexOf(""); + 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; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 49c3a3690..565d05782 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1,10 +1,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; -import { - createServer as createHttpServer, - type Server as HttpServer, - type IncomingMessage, -} from "node:http"; +import { type Server as HttpServer } from "node:http"; import os from "node:os"; import path from "node:path"; import chalk from "chalk"; @@ -31,10 +27,7 @@ import { normalizeThinkLevel, normalizeVerboseLevel, } from "../auto-reply/thinking.js"; -import { - CANVAS_HOST_PATH, - handleA2uiHttpRequest, -} from "../canvas-host/a2ui.js"; +import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js"; import { type CanvasHostHandler, type CanvasHostServer, @@ -73,17 +66,11 @@ import { import { CronService } from "../cron/service.js"; import { resolveCronStorePath } from "../cron/store.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.js"; -import { - monitorDiscordProvider, - sendMessageDiscord, -} from "../discord/index.js"; +import { sendMessageDiscord } from "../discord/index.js"; import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; import { shouldLogVerbose } from "../globals.js"; import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; -import { - monitorIMessageProvider, - sendMessageIMessage, -} from "../imessage/index.js"; +import { sendMessageIMessage } from "../imessage/index.js"; import { type IMessageProbe, probeIMessage } from "../imessage/probe.js"; import { clearAgentRunContext, @@ -134,11 +121,7 @@ import { enableTailscaleServe, getTailnetHostname, } from "../infra/tailscale.js"; -import { - defaultVoiceWakeTriggers, - loadVoiceWakeConfig, - setVoiceWakeTriggers, -} from "../infra/voicewake.js"; +import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js"; import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaBridgeZone, @@ -152,16 +135,14 @@ import { } from "../logging.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { runExec } from "../process/exec.js"; -import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; +import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime } from "../runtime.js"; -import { monitorSignalProvider, sendMessageSignal } from "../signal/index.js"; +import { sendMessageSignal } from "../signal/index.js"; import { probeSignal, type SignalProbe } from "../signal/probe.js"; -import { monitorTelegramProvider } from "../telegram/monitor.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { sendMessageTelegram } from "../telegram/send.js"; import { resolveTelegramToken } from "../telegram/token.js"; import { normalizeE164, resolveUserPath } from "../utils.js"; -import type { WebProviderStatus } from "../web/auto-reply.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { sendMessageWhatsApp } from "../web/outbound.js"; import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; @@ -173,16 +154,8 @@ import { type ResolvedGatewayAuth, } from "./auth.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; -import { handleControlUiHttpRequest } from "./control-ui.js"; -import { - extractHookToken, - normalizeAgentPayload, - normalizeHookHeaders, - normalizeWakePayload, - readJsonBody, - resolveHooksConfig, -} from "./hooks.js"; -import { applyHookMappings } from "./hooks-mapping.js"; +import { normalizeControlUiBasePath } from "./control-ui.js"; +import { resolveHooksConfig } from "./hooks.js"; import { isLoopbackAddress, isLoopbackHost, @@ -199,19 +172,16 @@ import { type SessionsPatchResult, } from "./session-utils.js"; import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; +import { + attachGatewayUpgradeHandler, + createGatewayHttpServer, + createHooksRequestHandler, +} from "./server-http.js"; +import { createProviderManager } from "./server-providers.js"; +import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; ensureClawdisCliOnPath(); -function sendJson( - res: import("node:http").ServerResponse, - status: number, - body: unknown, -) { - res.statusCode = status; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify(body)); -} - const log = createSubsystemLogger("gateway"); const logCanvas = log.child("canvas"); const logBridge = log.child("bridge"); @@ -474,7 +444,7 @@ export type GatewayServerOptions = { */ host?: string; /** - * If false, do not serve the browser Control UI under /ui/. + * If false, do not serve the browser Control UI. * Default: config `gateway.controlUi.enabled` (or true when absent). */ controlUiEnabled?: boolean; @@ -538,34 +508,6 @@ type DedupeEntry = { error?: ErrorShape; }; -function normalizeVoiceWakeTriggers(input: unknown): string[] { - const raw = Array.isArray(input) ? input : []; - const cleaned = raw - .map((v) => (typeof v === "string" ? v.trim() : "")) - .filter((v) => v.length > 0) - .slice(0, 32) - .map((v) => v.slice(0, 64)); - return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); -} - -function formatError(err: unknown): string { - if (err instanceof Error) return err.message; - if (typeof err === "string") return err; - const statusValue = (err as { status?: unknown })?.status; - const codeValue = (err as { code?: unknown })?.code; - const statusText = - typeof statusValue === "string" || typeof statusValue === "number" - ? String(statusValue) - : undefined; - const codeText = - typeof codeValue === "string" || typeof codeValue === "number" - ? String(codeValue) - : undefined; - if (statusText || codeText) - return `status=${statusText ?? "unknown"} code=${codeText ?? "unknown"}`; - return JSON.stringify(err, null, 2); -} - async function refreshHealthSnapshot(_opts?: { probe?: boolean }) { if (!healthRefresh) { healthRefresh = (async () => { @@ -622,6 +564,9 @@ export async function startGatewayServer( } const controlUiEnabled = opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; + const controlUiBasePath = normalizeControlUiBasePath( + cfgAtStart.gateway?.controlUi?.basePath, + ); const authBase = cfgAtStart.gateway?.auth ?? {}; const authOverrides = opts.auth ?? {}; const authConfig = { @@ -715,6 +660,9 @@ export async function startGatewayServer( thinking?: string; timeoutSeconds?: number; }) => { + const sessionKey = value.sessionKey.trim() + ? value.sessionKey.trim() + : `hook:${randomUUID()}`; const jobId = randomUUID(); const now = Date.now(); const job: CronJob = { @@ -747,7 +695,7 @@ export async function startGatewayServer( deps, job, message: value.message, - sessionKey: value.sessionKey, + sessionKey, lane: "cron", }); const summary = @@ -792,153 +740,20 @@ export async function startGatewayServer( } } - const handleHooksRequest = async ( - req: IncomingMessage, - res: import("node:http").ServerResponse, - ): Promise => { - if (!hooksConfig) return false; - const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`); - const basePath = hooksConfig.basePath; - if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) { - return false; - } + const handleHooksRequest = createHooksRequestHandler({ + hooksConfig, + bindHost, + port, + logHooks, + dispatchAgentHook, + dispatchWakeHook, + }); - const token = extractHookToken(req, url); - if (!token || token !== hooksConfig.token) { - res.statusCode = 401; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Unauthorized"); - return true; - } - - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "POST"); - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Method Not Allowed"); - return true; - } - - const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); - if (!subPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - return true; - } - - const body = await readJsonBody(req, hooksConfig.maxBodyBytes); - if (!body.ok) { - const status = body.error === "payload too large" ? 413 : 400; - sendJson(res, status, { ok: false, error: body.error }); - return true; - } - - const payload = - typeof body.value === "object" && body.value !== null ? body.value : {}; - const headers = normalizeHookHeaders(req); - - if (subPath === "wake") { - const normalized = normalizeWakePayload( - payload as Record, - ); - if (!normalized.ok) { - sendJson(res, 400, { ok: false, error: normalized.error }); - return true; - } - dispatchWakeHook(normalized.value); - sendJson(res, 200, { ok: true, mode: normalized.value.mode }); - return true; - } - - if (subPath === "agent") { - const normalized = normalizeAgentPayload( - payload as Record, - ); - if (!normalized.ok) { - sendJson(res, 400, { ok: false, error: normalized.error }); - return true; - } - const runId = dispatchAgentHook(normalized.value); - sendJson(res, 202, { ok: true, runId }); - return true; - } - - if (hooksConfig.mappings.length > 0) { - try { - const mapped = await applyHookMappings(hooksConfig.mappings, { - payload: payload as Record, - headers, - url, - path: subPath, - }); - if (mapped) { - if (!mapped.ok) { - sendJson(res, 400, { ok: false, error: mapped.error }); - return true; - } - if (mapped.action === null) { - res.statusCode = 204; - res.end(); - return true; - } - if (mapped.action.kind === "wake") { - dispatchWakeHook({ - text: mapped.action.text, - mode: mapped.action.mode, - }); - sendJson(res, 200, { ok: true, mode: mapped.action.mode }); - return true; - } - const runId = dispatchAgentHook({ - message: mapped.action.message, - name: mapped.action.name ?? "Hook", - wakeMode: mapped.action.wakeMode, - sessionKey: mapped.action.sessionKey ?? `hook:${randomUUID()}`, - deliver: mapped.action.deliver === true, - channel: mapped.action.channel ?? "last", - to: mapped.action.to, - thinking: mapped.action.thinking, - timeoutSeconds: mapped.action.timeoutSeconds, - }); - sendJson(res, 202, { ok: true, runId }); - return true; - } - } catch (err) { - logHooks.warn(`hook mapping failed: ${String(err)}`); - sendJson(res, 500, { ok: false, error: "hook mapping failed" }); - return true; - } - } - - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - return true; - }; - - const httpServer: HttpServer = createHttpServer((req, res) => { - // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. - if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; - - void (async () => { - if (await handleHooksRequest(req, res)) return; - if (canvasHost) { - if (await handleA2uiHttpRequest(req, res)) return; - if (await canvasHost.handleHttpRequest(req, res)) return; - } - if (controlUiEnabled) { - if (handleControlUiHttpRequest(req, res)) return; - } - - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - })().catch((err) => { - res.statusCode = 500; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end(String(err)); - }); + const httpServer: HttpServer = createGatewayHttpServer({ + canvasHost, + controlUiEnabled, + controlUiBasePath, + handleHooksRequest, }); let bonjourStop: (() => Promise) | null = null; let bridge: Awaited> | null = null; @@ -989,12 +804,7 @@ export async function startGatewayServer( noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }); - httpServer.on("upgrade", (req, socket, head) => { - if (canvasHost?.handleUpgrade(req, socket, head)) return; - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req); - }); - }); + attachGatewayUpgradeHandler({ httpServer, wss, canvasHost }); let whatsappAbort: AbortController | null = null; let telegramAbort: AbortController | null = null; let discordAbort: AbortController | null = null; @@ -1005,68 +815,6 @@ export async function startGatewayServer( let discordTask: Promise | null = null; let signalTask: Promise | null = null; let imessageTask: Promise | null = null; - let whatsappRuntime: WebProviderStatus = { - running: false, - connected: false, - reconnectAttempts: 0, - lastConnectedAt: null, - lastDisconnect: null, - lastMessageAt: null, - lastEventAt: null, - lastError: null, - }; - let telegramRuntime: { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - mode?: "webhook" | "polling" | null; - } = { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - mode: null, - }; - let discordRuntime: { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - } = { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }; - let signalRuntime: { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - baseUrl?: string | null; - } = { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - baseUrl: null, - }; - let imessageRuntime: { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - cliPath?: string | null; - dbPath?: string | null; - } = { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - cliPath: null, - dbPath: null, - }; const clients = new Set(); let seq = 0; // Track per-run sequence to detect out-of-order/lost agent events. @@ -1190,494 +938,34 @@ export async function startGatewayServer( }, }); - const updateWhatsAppStatus = (next: WebProviderStatus) => { - whatsappRuntime = next; - }; - - const startWhatsAppProvider = async () => { - if (whatsappTask) return; - const cfg = loadConfig(); - if (cfg.web?.enabled === false) { - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: "disabled", - }; - logWhatsApp.info("skipping provider start (web.enabled=false)"); - return; - } - if (!(await webAuthExists())) { - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: "not linked", - }; - logWhatsApp.info("skipping provider start (no linked session)"); - return; - } - const { e164, jid } = readWebSelfId(); - const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; - logWhatsApp.info(`starting provider (${identity})`); - whatsappAbort = new AbortController(); - whatsappRuntime = { - ...whatsappRuntime, - running: true, - connected: false, - lastError: null, - }; - const task = monitorWebProvider( - shouldLogVerbose(), - undefined, - true, - undefined, - whatsappRuntimeEnv, - whatsappAbort.signal, - { statusSink: updateWhatsAppStatus }, - ) - .catch((err) => { - whatsappRuntime = { - ...whatsappRuntime, - lastError: formatError(err), - }; - logWhatsApp.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - whatsappAbort = null; - whatsappTask = null; - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - }; - }); - whatsappTask = task; - }; - - const stopWhatsAppProvider = async () => { - if (!whatsappAbort && !whatsappTask) return; - whatsappAbort?.abort(); - try { - await whatsappTask; - } catch { - // ignore - } - whatsappAbort = null; - whatsappTask = null; - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - }; - }; - - const startTelegramProvider = async () => { - if (telegramTask) return; - const cfg = loadConfig(); - if (cfg.telegram?.enabled === false) { - telegramRuntime = { - ...telegramRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logTelegram.debug( - "telegram provider disabled (telegram.enabled=false)", - ); - } - return; - } - const { token: telegramToken } = resolveTelegramToken(cfg, { - logMissingFile: (message) => logTelegram.warn(message), - }); - if (!telegramToken.trim()) { - telegramRuntime = { - ...telegramRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logTelegram.debug( - "telegram provider not configured (no TELEGRAM_BOT_TOKEN)", - ); - } - return; - } - let telegramBotLabel = ""; - try { - const probe = await probeTelegram( - telegramToken.trim(), - 2500, - cfg.telegram?.proxy, - ); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) telegramBotLabel = ` (@${username})`; - } catch (err) { - if (shouldLogVerbose()) { - logTelegram.debug(`bot probe failed: ${String(err)}`); - } - } - logTelegram.info( - `starting provider${telegramBotLabel}${cfg.telegram ? "" : " (no telegram config; token via env)"}`, - ); - telegramAbort = new AbortController(); - telegramRuntime = { - ...telegramRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - mode: cfg.telegram?.webhookUrl ? "webhook" : "polling", - }; - const task = monitorTelegramProvider({ - token: telegramToken.trim(), - runtime: telegramRuntimeEnv, - abortSignal: telegramAbort.signal, - useWebhook: Boolean(cfg.telegram?.webhookUrl), - webhookUrl: cfg.telegram?.webhookUrl, - webhookSecret: cfg.telegram?.webhookSecret, - webhookPath: cfg.telegram?.webhookPath, - }) - .catch((err) => { - telegramRuntime = { - ...telegramRuntime, - lastError: formatError(err), - }; - logTelegram.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - telegramAbort = null; - telegramTask = null; - telegramRuntime = { - ...telegramRuntime, - running: false, - lastStopAt: Date.now(), - }; - }); - telegramTask = task; - }; - - const stopTelegramProvider = async () => { - if (!telegramAbort && !telegramTask) return; - telegramAbort?.abort(); - try { - await telegramTask; - } catch { - // ignore - } - telegramAbort = null; - telegramTask = null; - telegramRuntime = { - ...telegramRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startDiscordProvider = async () => { - if (discordTask) return; - const cfg = loadConfig(); - if (cfg.discord?.enabled === false) { - discordRuntime = { - ...discordRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logDiscord.debug("discord provider disabled (discord.enabled=false)"); - } - return; - } - const discordToken = - process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; - if (!discordToken.trim()) { - discordRuntime = { - ...discordRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logDiscord.debug( - "discord provider not configured (no DISCORD_BOT_TOKEN)", - ); - } - return; - } - let discordBotLabel = ""; - try { - const probe = await probeDiscord(discordToken.trim(), 2500); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) discordBotLabel = ` (@${username})`; - } catch (err) { - if (shouldLogVerbose()) { - logDiscord.debug(`bot probe failed: ${String(err)}`); - } - } - logDiscord.info( - `starting provider${discordBotLabel}${cfg.discord ? "" : " (no discord config; token via env)"}`, - ); - discordAbort = new AbortController(); - discordRuntime = { - ...discordRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - }; - const task = monitorDiscordProvider({ - token: discordToken.trim(), - runtime: discordRuntimeEnv, - abortSignal: discordAbort.signal, - slashCommand: cfg.discord?.slashCommand, - mediaMaxMb: cfg.discord?.mediaMaxMb, - historyLimit: cfg.discord?.historyLimit, - }) - .catch((err) => { - discordRuntime = { - ...discordRuntime, - lastError: formatError(err), - }; - logDiscord.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - discordAbort = null; - discordTask = null; - discordRuntime = { - ...discordRuntime, - running: false, - lastStopAt: Date.now(), - }; - }); - discordTask = task; - }; - - const stopDiscordProvider = async () => { - if (!discordAbort && !discordTask) return; - discordAbort?.abort(); - try { - await discordTask; - } catch { - // ignore - } - discordAbort = null; - discordTask = null; - discordRuntime = { - ...discordRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startSignalProvider = async () => { - if (signalTask) return; - const cfg = loadConfig(); - if (!cfg.signal) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logSignal.debug("signal provider not configured (no signal config)"); - } - return; - } - if (cfg.signal?.enabled === false) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logSignal.debug("signal provider disabled (signal.enabled=false)"); - } - return; - } - const signalCfg = cfg.signal; - const signalMeaningfullyConfigured = Boolean( - signalCfg.account?.trim() || - signalCfg.httpUrl?.trim() || - signalCfg.cliPath?.trim() || - signalCfg.httpHost?.trim() || - typeof signalCfg.httpPort === "number" || - typeof signalCfg.autoStart === "boolean", - ); - if (!signalMeaningfullyConfigured) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logSignal.debug( - "signal provider not configured (signal config present but missing required fields)", - ); - } - return; - } - const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; - const port = cfg.signal?.httpPort ?? 8080; - const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; - logSignal.info(`starting provider (${baseUrl})`); - signalAbort = new AbortController(); - signalRuntime = { - ...signalRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - baseUrl, - }; - const task = monitorSignalProvider({ - baseUrl, - account: cfg.signal?.account, - cliPath: cfg.signal?.cliPath, - httpHost: cfg.signal?.httpHost, - httpPort: cfg.signal?.httpPort, - autoStart: cfg.signal?.autoStart, - receiveMode: cfg.signal?.receiveMode, - ignoreAttachments: cfg.signal?.ignoreAttachments, - ignoreStories: cfg.signal?.ignoreStories, - sendReadReceipts: cfg.signal?.sendReadReceipts, - allowFrom: cfg.signal?.allowFrom, - mediaMaxMb: cfg.signal?.mediaMaxMb, - runtime: signalRuntimeEnv, - abortSignal: signalAbort.signal, - }) - .catch((err) => { - signalRuntime = { - ...signalRuntime, - lastError: formatError(err), - }; - logSignal.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - signalAbort = null; - signalTask = null; - signalRuntime = { - ...signalRuntime, - running: false, - lastStopAt: Date.now(), - }; - }); - signalTask = task; - }; - - const stopSignalProvider = async () => { - if (!signalAbort && !signalTask) return; - signalAbort?.abort(); - try { - await signalTask; - } catch { - // ignore - } - signalAbort = null; - signalTask = null; - signalRuntime = { - ...signalRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startIMessageProvider = async () => { - if (imessageTask) return; - const cfg = loadConfig(); - if (!cfg.imessage) { - imessageRuntime = { - ...imessageRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logIMessage.debug( - "imessage provider not configured (no imessage config)", - ); - } - return; - } - if (cfg.imessage?.enabled === false) { - imessageRuntime = { - ...imessageRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logIMessage.debug( - "imessage provider disabled (imessage.enabled=false)", - ); - } - return; - } - const cliPath = cfg.imessage?.cliPath?.trim() || "imsg"; - const dbPath = cfg.imessage?.dbPath?.trim(); - logIMessage.info( - `starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, - ); - imessageAbort = new AbortController(); - imessageRuntime = { - ...imessageRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - cliPath, - dbPath: dbPath ?? null, - }; - const task = monitorIMessageProvider({ - cliPath, - dbPath, - allowFrom: cfg.imessage?.allowFrom, - includeAttachments: cfg.imessage?.includeAttachments, - mediaMaxMb: cfg.imessage?.mediaMaxMb, - runtime: imessageRuntimeEnv, - abortSignal: imessageAbort.signal, - }) - .catch((err) => { - imessageRuntime = { - ...imessageRuntime, - lastError: formatError(err), - }; - logIMessage.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - imessageAbort = null; - imessageTask = null; - imessageRuntime = { - ...imessageRuntime, - running: false, - lastStopAt: Date.now(), - }; - }); - imessageTask = task; - }; - - const stopIMessageProvider = async () => { - if (!imessageAbort && !imessageTask) return; - imessageAbort?.abort(); - try { - await imessageTask; - } catch { - // ignore - } - imessageAbort = null; - imessageTask = null; - imessageRuntime = { - ...imessageRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startProviders = async () => { - await startWhatsAppProvider(); - await startDiscordProvider(); - await startTelegramProvider(); - await startSignalProvider(); - await startIMessageProvider(); - }; + const providerManager = createProviderManager({ + loadConfig, + logWhatsApp, + logTelegram, + logDiscord, + logSignal, + logIMessage, + whatsappRuntimeEnv, + telegramRuntimeEnv, + discordRuntimeEnv, + signalRuntimeEnv, + imessageRuntimeEnv, + }); + const { + getRuntimeSnapshot, + startProviders, + startWhatsAppProvider, + stopWhatsAppProvider, + startTelegramProvider, + stopTelegramProvider, + startDiscordProvider, + stopDiscordProvider, + startSignalProvider, + stopSignalProvider, + startIMessageProvider, + stopIMessageProvider, + markWhatsAppLoggedOut, + } = providerManager; const broadcast = ( event: string, @@ -3775,6 +3063,7 @@ export async function startGatewayServer( const linked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); const self = readWebSelfId(); + const runtime = getRuntimeSnapshot(); respond( true, @@ -3785,54 +3074,54 @@ export async function startGatewayServer( linked, authAgeMs, self, - running: whatsappRuntime.running, - connected: whatsappRuntime.connected, - lastConnectedAt: whatsappRuntime.lastConnectedAt ?? null, - lastDisconnect: whatsappRuntime.lastDisconnect ?? null, - reconnectAttempts: whatsappRuntime.reconnectAttempts, - lastMessageAt: whatsappRuntime.lastMessageAt ?? null, - lastEventAt: whatsappRuntime.lastEventAt ?? null, - lastError: whatsappRuntime.lastError ?? null, + running: runtime.whatsapp.running, + connected: runtime.whatsapp.connected, + lastConnectedAt: runtime.whatsapp.lastConnectedAt ?? null, + lastDisconnect: runtime.whatsapp.lastDisconnect ?? null, + reconnectAttempts: runtime.whatsapp.reconnectAttempts, + lastMessageAt: runtime.whatsapp.lastMessageAt ?? null, + lastEventAt: runtime.whatsapp.lastEventAt ?? null, + lastError: runtime.whatsapp.lastError ?? null, }, telegram: { configured: telegramEnabled && Boolean(telegramToken), tokenSource, - running: telegramRuntime.running, - mode: telegramRuntime.mode ?? null, - lastStartAt: telegramRuntime.lastStartAt ?? null, - lastStopAt: telegramRuntime.lastStopAt ?? null, - lastError: telegramRuntime.lastError ?? null, + running: runtime.telegram.running, + mode: runtime.telegram.mode ?? null, + lastStartAt: runtime.telegram.lastStartAt ?? null, + lastStopAt: runtime.telegram.lastStopAt ?? null, + lastError: runtime.telegram.lastError ?? null, probe: telegramProbe, lastProbeAt, }, discord: { configured: discordEnabled && Boolean(discordToken), tokenSource: discordTokenSource, - running: discordRuntime.running, - lastStartAt: discordRuntime.lastStartAt ?? null, - lastStopAt: discordRuntime.lastStopAt ?? null, - lastError: discordRuntime.lastError ?? null, + running: runtime.discord.running, + lastStartAt: runtime.discord.lastStartAt ?? null, + lastStopAt: runtime.discord.lastStopAt ?? null, + lastError: runtime.discord.lastError ?? null, probe: discordProbe, lastProbeAt: discordLastProbeAt, }, signal: { configured: signalConfigured, baseUrl: signalBaseUrl, - running: signalRuntime.running, - lastStartAt: signalRuntime.lastStartAt ?? null, - lastStopAt: signalRuntime.lastStopAt ?? null, - lastError: signalRuntime.lastError ?? null, + running: runtime.signal.running, + lastStartAt: runtime.signal.lastStartAt ?? null, + lastStopAt: runtime.signal.lastStopAt ?? null, + lastError: runtime.signal.lastError ?? null, probe: signalProbe, lastProbeAt: signalLastProbeAt, }, imessage: { configured: imessageConfigured, - running: imessageRuntime.running, - lastStartAt: imessageRuntime.lastStartAt ?? null, - lastStopAt: imessageRuntime.lastStopAt ?? null, - lastError: imessageRuntime.lastError ?? null, - cliPath: imessageRuntime.cliPath ?? null, - dbPath: imessageRuntime.dbPath ?? null, + running: runtime.imessage.running, + lastStartAt: runtime.imessage.lastStartAt ?? null, + lastStopAt: runtime.imessage.lastStopAt ?? null, + lastError: runtime.imessage.lastError ?? null, + cliPath: runtime.imessage.cliPath ?? null, + dbPath: runtime.imessage.dbPath ?? null, probe: imessageProbe, lastProbeAt: imessageLastProbeAt, }, @@ -4361,12 +3650,7 @@ export async function startGatewayServer( try { await stopWhatsAppProvider(); const cleared = await logoutWeb(defaultRuntime); - whatsappRuntime = { - ...whatsappRuntime, - running: false, - connected: false, - lastError: cleared ? "logged out" : whatsappRuntime.lastError, - }; + markWhatsAppLoggedOut(cleared); respond(true, { cleared }, undefined); } catch (err) { respond( @@ -6363,8 +5647,9 @@ export async function startGatewayServer( } const host = await getTailnetHostname().catch(() => null); if (host) { + const uiPath = controlUiBasePath ? `${controlUiBasePath}/` : "/"; logTailscale.info( - `${tailscaleMode} enabled: https://${host}/ui/ (WS via wss://${host})`, + `${tailscaleMode} enabled: https://${host}${uiPath} (WS via wss://${host})`, ); } else { logTailscale.info(`${tailscaleMode} enabled`); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index eb49a3853..8f2ee1d87 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -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)}` diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9a2817a67..f89938f1a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -4,7 +4,14 @@ import { customElement, state } from "lit/decorators.js"; import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway"; import { loadSettings, saveSettings, type UiSettings } from "./storage"; import { renderApp } from "./app-render"; -import { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation"; +import { + inferBasePathFromPathname, + normalizeBasePath, + normalizePath, + pathForTab, + tabFromPath, + type Tab, +} from "./navigation"; import { resolveTheme, type ResolvedTheme, @@ -74,6 +81,12 @@ type EventLogEntry = { payload?: unknown; }; +declare global { + interface Window { + __CLAWDIS_CONTROL_UI_BASE_PATH__?: string; + } +} + const DEFAULT_CRON_FORM: CronFormState = { name: "", description: "", @@ -468,9 +481,11 @@ export class ClawdisApp extends LitElement { private inferBasePath() { if (typeof window === "undefined") return ""; - const path = window.location.pathname; - if (path === "/ui" || path.startsWith("/ui/")) return "/ui"; - return ""; + const configured = window.__CLAWDIS_CONTROL_UI_BASE_PATH__; + if (typeof configured === "string" && configured.trim()) { + return normalizeBasePath(configured); + } + return inferBasePathFromPathname(window.location.pathname); } private syncThemeWithSettings() { diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 43f182e3f..d940998aa 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -21,11 +21,13 @@ beforeEach(() => { ClawdisApp.prototype.connect = () => { // no-op: avoid real gateway WS connections in browser tests }; + window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = undefined; document.body.innerHTML = ""; }); afterEach(() => { ClawdisApp.prototype.connect = originalConnect; + window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = undefined; document.body.innerHTML = ""; }); @@ -47,6 +49,25 @@ describe("control UI routing", () => { expect(window.location.pathname).toBe("/ui/cron"); }); + it("infers nested base paths", async () => { + const app = mountApp("/apps/clawdis/cron"); + await app.updateComplete; + + expect(app.basePath).toBe("/apps/clawdis"); + expect(app.tab).toBe("cron"); + expect(window.location.pathname).toBe("/apps/clawdis/cron"); + }); + + it("honors explicit base path overrides", async () => { + window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = "/clawdis"; + const app = mountApp("/clawdis/sessions"); + await app.updateComplete; + + expect(app.basePath).toBe("/clawdis"); + expect(app.tab).toBe("sessions"); + expect(window.location.pathname).toBe("/clawdis/sessions"); + }); + it("updates the URL when clicking nav items", async () => { const app = mountApp("/chat"); await app.updateComplete; diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 77ac49571..54ae29701 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -37,7 +37,7 @@ const PATH_TO_TAB = new Map( Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]), ); -function normalizeBasePath(basePath: string): string { +export function normalizeBasePath(basePath: string): string { if (!basePath) return ""; let base = basePath.trim(); if (!base.startsWith("/")) base = `/${base}`; @@ -78,6 +78,24 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null { return PATH_TO_TAB.get(normalized) ?? null; } +export function inferBasePathFromPathname(pathname: string): string { + let normalized = normalizePath(pathname); + if (normalized.endsWith("/index.html")) { + normalized = normalizePath(normalized.slice(0, -"/index.html".length)); + } + if (normalized === "/") return ""; + const segments = normalized.split("/").filter(Boolean); + if (segments.length === 0) return ""; + for (let i = 0; i < segments.length; i++) { + const candidate = `/${segments.slice(i).join("/")}`.toLowerCase(); + if (PATH_TO_TAB.has(candidate)) { + const prefix = segments.slice(0, i); + return prefix.length ? `/${prefix.join("/")}` : ""; + } + } + return `/${segments.join("/")}`; +} + export function titleForTab(tab: Tab) { switch (tab) { case "overview": diff --git a/ui/vite.config.ts b/ui/vite.config.ts index dbb26cfbd..56a321b39 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -4,16 +4,28 @@ import { defineConfig } from "vite"; const here = path.dirname(fileURLToPath(import.meta.url)); -export default defineConfig({ - base: "/", - build: { - outDir: path.resolve(here, "../dist/control-ui"), - emptyOutDir: true, - sourcemap: true, - }, - server: { - host: true, - port: 5173, - strictPort: true, - }, +function normalizeBase(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return "/"; + if (trimmed === "./") return "./"; + if (trimmed.endsWith("/")) return trimmed; + return `${trimmed}/`; +} + +export default defineConfig(({ command }) => { + const envBase = process.env.CLAWDIS_CONTROL_UI_BASE_PATH?.trim(); + const base = envBase ? normalizeBase(envBase) : "/"; + return { + base, + build: { + outDir: path.resolve(here, "../dist/control-ui"), + emptyOutDir: true, + sourcemap: true, + }, + server: { + host: true, + port: 5173, + strictPort: true, + }, + }; });