Files
openclaw/ui/src/app-route-paths.ts
Peter Steinberger ea06d72e85 feat(secrets): manage team secrets in Control UI (#121724)
* feat(secrets): add gateway store settings

* perf(control-ui): trim secrets startup copy

* perf(control-ui): reduce secrets startup payload

* fix(secrets): harden store mutation refresh

* perf(control-ui): meet secrets startup budget

* test(control-ui): update secrets navigation copy

* fix(ui): pluralize secret-detection count and drop duplicated dialog hint

* chore(protocol): regenerate gateway clients and SDK baseline after rebase

* fix(gateway): merge secrets store methods after project RPCs in advertised order

* chore: leave changelog to release generation

* test(gateway): retain desktop launch train coverage
2026-08-11 07:20:38 +00:00

384 lines
14 KiB
TypeScript

import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
import type { RouteLocation } from "@openclaw/uirouter";
import { isValidWorkboardBoardId } from "@openclaw/workboard-contract";
import { DEFAULT_AGENT_PANEL, isAgentsPanel, type AgentsPanel } from "./lib/agents/panels.ts";
import type { BoardFace } from "./lib/board/settings.ts";
export const INTERNAL_AGENT_PATH_PARAM = "__openclawAgentPath";
export const INTERNAL_SESSION_PATH_PARAM = "__openclawSessionPath";
export const INTERNAL_MEMORY_PATH_PARAM = "__openclawMemoryPath";
export const INTERNAL_PLUGINS_PATH_PARAM = "__openclawPluginsPath";
export const INTERNAL_WORKBOARD_PATH_PARAM = "__openclawWorkboardPath";
export type MemoryRouteTab = "overview" | "memories" | "dreams" | "settings";
export type PluginsHubRouteTab = "installed" | "discover";
type AgentRoutePath = {
agentId: string;
panel: AgentsPanel;
panelSegment: AgentsPanel | null;
invalidPanel: boolean;
};
const APP_ROUTE_DEFINITIONS = {
chat: { path: "/chat" },
dashboard: { path: "/dashboard" },
dashboards: { path: "/dashboards" },
custodian: { path: "/custodian" },
"new-session": { path: "/new" },
activity: { path: "/activity" },
apps: { path: "/apps" },
agents: { path: "/settings/agents", aliases: ["/agents"] },
channels: { path: "/settings/channels", aliases: ["/channels"] },
connection: { path: "/settings/connection" },
config: { path: "/settings/general", aliases: ["/config"] },
profile: { path: "/settings/profile", aliases: ["/profile"] },
communications: { path: "/settings/communications", aliases: ["/communications"] },
appearance: { path: "/settings/appearance", aliases: ["/appearance"] },
lobsterdex: { path: "/settings/lobsterdex", aliases: ["/lobsterdex"] },
notifications: { path: "/settings/notifications" },
security: { path: "/settings/security" },
secrets: { path: "/settings/secrets" },
advanced: { path: "/settings/advanced" },
approvals: { path: "/settings/approvals" },
automation: { path: "/settings/automation", aliases: ["/automation"] },
mcp: { path: "/settings/mcp", aliases: ["/mcp"] },
memory: { path: "/settings/memory" },
talk: { path: "/settings/talk" },
infrastructure: { path: "/settings/infrastructure", aliases: ["/infrastructure"] },
labs: { path: "/settings/labs" },
updates: { path: "/settings/updates" },
about: { path: "/settings/about" },
"ai-agents": { path: "/settings/ai-agents", aliases: ["/ai-agents"] },
"model-setup": { path: "/settings/model-setup", aliases: ["/model-setup"] },
"model-providers": { path: "/settings/model-providers", aliases: ["/model-providers"] },
// Memory import, sessions, and worktrees are workspace destinations; the
// /settings/* aliases keep pre-restructure bookmarks and deep links working.
"memory-import": { path: "/memory-import", aliases: ["/settings/memory-import"] },
workboard: { path: "/workboard" },
worktrees: { path: "/worktrees", aliases: ["/settings/worktrees"] },
sessions: { path: "/sessions", aliases: ["/settings/sessions"] },
usage: { path: "/usage" },
debug: { path: "/debug" },
logs: { path: "/logs" },
"skill-workshop": { path: "/skills/workshop" },
skills: { path: "/skills" },
plugins: { path: "/settings/plugins" },
// Automations is the product name; /cron stays as a legacy alias for
// pre-rename bookmarks and deep links.
cron: { path: "/automations", aliases: ["/cron"] },
tasks: { path: "/tasks" },
devices: { path: "/settings/devices", aliases: ["/nodes"] },
plugin: { path: "/plugin" },
} as const;
export type RouteId = keyof typeof APP_ROUTE_DEFINITIONS;
export const APP_ROUTE_IDS = Object.keys(APP_ROUTE_DEFINITIONS) as RouteId[];
export function isRouteId(routeId: string): routeId is RouteId {
return routeId in APP_ROUTE_DEFINITIONS;
}
// Single source for page definitions: ui/src/pages/*/route.ts spreads this
// into definePage so router matching can never drift from the table that
// drives routeIdFromPath and base-path inference.
export function routePageSpec<Id extends RouteId>(
routeId: Id,
): { id: Id; path: string; aliases?: readonly string[] } {
const definition = APP_ROUTE_DEFINITIONS[routeId];
return "aliases" in definition
? { id: routeId, path: definition.path, aliases: definition.aliases }
: { id: routeId, path: definition.path };
}
export function normalizeBasePath(basePath: string): string {
return normalizeRouteBasePath(basePath);
}
function normalizePath(path: string): string {
return normalizeRoutePath(path);
}
export function pathForRoute(routeId: RouteId, basePath = ""): string {
const normalizedBasePath = normalizeBasePath(basePath);
const path = APP_ROUTE_DEFINITIONS[routeId].path;
return normalizedBasePath ? `${normalizedBasePath}${path}` : path;
}
export function pathForWorkboardBoard(boardId: string, basePath = ""): string {
if (!isValidWorkboardBoardId(boardId)) {
throw new Error("Invalid Workboard board id.");
}
const encodedBoardId = encodeURIComponent(boardId).replaceAll(".", "%2E");
return `${pathForRoute("workboard", basePath)}/${encodedBoardId}`;
}
export function pathForAgentPanel(
agentId: string,
panel: AgentsPanel | null = null,
basePath = "",
): string {
if (!agentId || agentId.includes("/") || agentId === "." || agentId === "..") {
throw new Error("Invalid agent id for a route path.");
}
const encodedAgentId = encodeURIComponent(agentId).replaceAll(".", "%2E");
const agentPath = `${pathForRoute("agents", basePath)}/${encodedAgentId}`;
return panel ? `${agentPath}/${panel}` : agentPath;
}
export function agentRouteFromPath(pathname: string, basePath = ""): AgentRoutePath | null {
const normalizedPath = normalizePath(pathname);
const agentsPath = pathForRoute("agents", basePath);
const prefix = `${agentsPath}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const segments = normalizedPath.slice(prefix.length).split("/");
if (segments.length > 2 || !segments[0]) {
return null;
}
let agentId: string;
try {
agentId = decodeURIComponent(segments[0]);
} catch {
return null;
}
if (!agentId || agentId.includes("/") || agentId === "." || agentId === "..") {
return null;
}
const rawPanel = segments[1] ?? null;
const panelSegment = rawPanel && isAgentsPanel(rawPanel) ? rawPanel : null;
return {
agentId,
panel: panelSegment ?? DEFAULT_AGENT_PANEL,
panelSegment,
invalidPanel: rawPanel !== null && panelSegment === null,
};
}
export function pathForMemoryTab(tab: MemoryRouteTab, basePath = ""): string {
const memoryPath = pathForRoute("memory", basePath);
return tab === "overview" ? memoryPath : `${memoryPath}/${tab}`;
}
export function memoryTabFromPath(pathname: string, basePath = ""): MemoryRouteTab | null {
const normalizedPath = normalizePath(pathname);
const memoryPath = pathForRoute("memory", basePath);
if (normalizedPath === memoryPath) {
return "overview";
}
const prefix = `${memoryPath}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const segment = normalizedPath.slice(prefix.length);
return segment === "memories" || segment === "dreams" || segment === "settings" ? segment : null;
}
export function pathForPluginsHubTab(tab: PluginsHubRouteTab, basePath = ""): string {
const pluginsPath = pathForRoute("plugins", basePath);
return tab === "installed" ? pluginsPath : `${pluginsPath}/discover`;
}
export function pluginsHubTabFromPath(pathname: string, basePath = ""): PluginsHubRouteTab | null {
const normalizedPath = normalizePath(pathname);
const pluginsPath = pathForRoute("plugins", basePath);
if (normalizedPath === pluginsPath) {
return "installed";
}
return normalizedPath === `${pluginsPath}/discover` ? "discover" : null;
}
export function isSessionRouteId(routeId: string | null | undefined): routeId is BoardFace {
return routeId === "chat" || routeId === "dashboard";
}
export function sessionRouteNamespaceFromPath(pathname: string, basePath = ""): BoardFace | null {
const normalizedPath = normalizePath(pathname);
const normalizedBasePath = normalizeBasePath(basePath);
if (
normalizedBasePath &&
normalizedPath !== normalizedBasePath &&
!normalizedPath.startsWith(`${normalizedBasePath}/`)
) {
return null;
}
const routePath = normalizedPath.slice(normalizedBasePath.length);
return routePath.startsWith("/chat/")
? "chat"
: routePath.startsWith("/dashboard/")
? "dashboard"
: null;
}
export function workboardBoardIdFromPath(pathname: string, basePath = ""): string | null {
const normalizedPath = normalizePath(pathname);
const workboardPath = pathForRoute("workboard", basePath);
const prefix = `${workboardPath}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const encodedBoardId = normalizedPath.slice(prefix.length);
if (!encodedBoardId || encodedBoardId.includes("/")) {
return null;
}
try {
const boardId = decodeURIComponent(encodedBoardId);
return isValidWorkboardBoardId(boardId) ? boardId : null;
} catch {
return null;
}
}
export function routeIdFromPath(pathname: string, basePath = ""): RouteId | null {
const normalizedPath = normalizePath(pathname);
const normalizedBasePath = normalizeBasePath(basePath);
const isWithinBasePath =
!normalizedBasePath ||
normalizedPath === normalizedBasePath ||
normalizedPath.startsWith(`${normalizedBasePath}/`);
if (!isWithinBasePath) {
return null;
}
const routePath = normalizedBasePath
? normalizedPath.slice(normalizedBasePath.length) || "/"
: normalizedPath;
if (agentRouteFromPath(normalizedPath, normalizedBasePath)) {
return "agents";
}
if (workboardBoardIdFromPath(normalizedPath, normalizedBasePath)) {
return "workboard";
}
if (memoryTabFromPath(normalizedPath, normalizedBasePath)) {
return "memory";
}
if (pluginsHubTabFromPath(normalizedPath, normalizedBasePath)) {
return "plugins";
}
const sessionNamespace = sessionRouteNamespaceFromPath(normalizedPath, normalizedBasePath);
if (sessionNamespace) {
return sessionNamespace;
}
for (const routeId of APP_ROUTE_IDS) {
const definition = APP_ROUTE_DEFINITIONS[routeId];
const paths: readonly string[] =
"aliases" in definition ? [definition.path, ...definition.aliases] : [definition.path];
if (paths.some((candidate) => normalizePath(candidate) === routePath)) {
return routeId;
}
}
return null;
}
function collectRoutePaths(): string[] {
return APP_ROUTE_IDS.flatMap((routeId) => {
const definition = APP_ROUTE_DEFINITIONS[routeId];
const paths: string[] = [definition.path];
if ("aliases" in definition) {
paths.push(...definition.aliases);
}
return paths;
});
}
// A candidate mount base that is a registered route ("/custodian"), or that
// sits at or below a multi-segment route namespace ("/settings", including
// "/settings/other"), is really a root-mounted deep link whose suffix happens
// to match a route path or alias. Descendants of leaf routes stay valid mount
// directories so "/apps/openclaw" keeps working. Inference is a last-resort
// fallback for pages served without the injected base path (vite dev, static
// hosting); accepted tradeoff: namespaces nested under a real mount prefix
// ("/ui/settings/other/config") are not rescued here.
function isRouteOwnedBasePath(basePath: string): boolean {
const routePaths = collectRoutePaths().map((path) => normalizePath(path));
if (routePaths.includes(basePath)) {
return true;
}
const segments = basePath.split("/").filter(Boolean);
for (let count = 1; count <= segments.length; count += 1) {
const ancestor = `/${segments.slice(0, count).join("/")}`;
if (routePaths.some((path) => path.startsWith(`${ancestor}/`))) {
return true;
}
}
return false;
}
export function inferBasePathFromPathname(pathname: string): string {
const isMountRoot = pathname.trim().endsWith("/");
const normalizedPath = normalizePath(pathname);
if (normalizedPath.toLowerCase().endsWith("/index.html")) {
return normalizeBasePath(normalizedPath.slice(0, -"/index.html".length));
}
if (normalizedPath === "/") {
return "";
}
const segments = normalizedPath.split("/").filter(Boolean);
const routePaths = collectRoutePaths();
for (let index = 0; index < segments.length; index += 1) {
const candidate = `/${segments.slice(index).join("/")}`;
const routePath = routePaths.find((path) => normalizePath(path) === candidate);
const dynamicAgentRoute = agentRouteFromPath(candidate) !== null;
const dynamicWorkboardRoute = workboardBoardIdFromPath(candidate) !== null;
const dynamicMemoryRoute = memoryTabFromPath(candidate) !== null;
const dynamicPluginsRoute = pluginsHubTabFromPath(candidate) !== null;
const sessionNamespace = sessionRouteNamespaceFromPath(candidate);
const dynamicSessionRoute = sessionNamespace !== null;
if (
!routePath &&
!dynamicAgentRoute &&
!dynamicWorkboardRoute &&
!dynamicMemoryRoute &&
!dynamicPluginsRoute &&
!dynamicSessionRoute
) {
continue;
}
const previousSegment = segments[index - 1];
const dynamicRoutePath = dynamicAgentRoute
? APP_ROUTE_DEFINITIONS.agents.path
: dynamicWorkboardRoute
? APP_ROUTE_DEFINITIONS.workboard.path
: dynamicMemoryRoute
? APP_ROUTE_DEFINITIONS.memory.path
: dynamicPluginsRoute
? APP_ROUTE_DEFINITIONS.plugins.path
: sessionNamespace
? APP_ROUTE_DEFINITIONS[sessionNamespace].path
: null;
const firstRouteSegment = (routePath ?? dynamicRoutePath ?? "").split("/").find(Boolean);
if (
index > 0 &&
previousSegment === firstRouteSegment &&
(candidate === routePath ||
dynamicAgentRoute ||
dynamicWorkboardRoute ||
dynamicMemoryRoute ||
dynamicPluginsRoute ||
dynamicSessionRoute)
) {
return "";
}
if (index === 0) {
return "";
}
const basePath = `/${segments.slice(0, index).join("/")}`;
// Mis-inferring a route-owned base ("/settings/config" -> "/settings" via
// the "/config" alias) rescopes stored gateway settings and asset URLs, so
// a connected browser deep-links straight into the login gate.
return isRouteOwnedBasePath(basePath) ? "" : basePath;
}
if (!isMountRoot || segments.length === 0) {
return "";
}
const mountRootBase = `/${segments.join("/")}`;
return isRouteOwnedBasePath(mountRootBase) ? "" : mountRootBase;
}
export function locationForRoute(routeId: RouteId, basePath: string): RouteLocation {
return {
pathname: pathForRoute(routeId, basePath),
search: "",
hash: "",
};
}