fix(dashboard): keep gateway tokens out of URL storage

This commit is contained in:
Peter Steinberger
2026-03-07 18:33:19 +00:00
parent f966dde476
commit 10d0e3f3ca
10 changed files with 187 additions and 26 deletions

View File

@@ -151,6 +151,9 @@ describe("control UI routing", () => {
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
undefined,
);
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
});
@@ -167,12 +170,18 @@ describe("control UI routing", () => {
it("hydrates token from URL params even when settings already set", async () => {
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({ token: "existing-token" }),
JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }),
);
const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({
gatewayUrl: "wss://gateway.example/openclaw",
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
undefined,
);
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
});
@@ -182,6 +191,9 @@ describe("control UI routing", () => {
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
undefined,
);
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.hash).toBe("");
});

View File

@@ -24,40 +24,147 @@ function createStorageMock(): Storage {
};
}
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState({}, "", params.pathname);
return;
}
vi.stubGlobal("location", {
protocol: params.protocol,
host: params.host,
pathname: params.pathname,
} as Location);
}
function setControlUiBasePath(value: string | undefined) {
if (typeof window === "undefined") {
vi.stubGlobal(
"window",
value == null
? ({} as Window & typeof globalThis)
: ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis),
);
return;
}
if (value == null) {
delete window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
return;
}
Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", {
value,
writable: true,
configurable: true,
});
}
function expectedGatewayUrl(basePath: string): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${basePath}`;
}
describe("loadSettings default gateway URL derivation", () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
localStorage.clear();
setControlUiBasePath(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
setControlUiBasePath(undefined);
vi.unstubAllGlobals();
});
it("uses configured base path and normalizes trailing slash", async () => {
vi.stubGlobal("location", {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/ignored/path",
} as Location);
vi.stubGlobal("window", { __OPENCLAW_CONTROL_UI_BASE_PATH__: " /openclaw/ " } as Window &
typeof globalThis);
});
setControlUiBasePath(" /openclaw/ ");
const { loadSettings } = await import("./storage.ts");
expect(loadSettings().gatewayUrl).toBe("wss://gateway.example:8443/openclaw");
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/openclaw"));
});
it("infers base path from nested pathname when configured base path is not set", async () => {
vi.stubGlobal("location", {
setTestLocation({
protocol: "http:",
host: "gateway.example:18789",
pathname: "/apps/openclaw/chat",
} as Location);
vi.stubGlobal("window", {} as Window & typeof globalThis);
});
const { loadSettings } = await import("./storage.ts");
expect(loadSettings().gatewayUrl).toBe("ws://gateway.example:18789/apps/openclaw");
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw"));
});
it("ignores and scrubs legacy persisted tokens", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "persisted-token",
sessionKey: "agent",
}),
);
const { loadSettings } = await import("./storage.ts");
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "agent",
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
sessionKey: "agent",
lastActiveSessionKey: "agent",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
});
it("does not persist gateway tokens when saving settings", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "memory-only-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
});
});

View File

@@ -1,5 +1,7 @@
const KEY = "openclaw.control.settings.v1";
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
import { isSupportedLocale } from "../i18n/index.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import type { ThemeMode } from "./theme.ts";
@@ -50,12 +52,13 @@ export function loadSettings(): UiSettings {
return defaults;
}
const parsed = JSON.parse(raw) as Partial<UiSettings>;
return {
const settings = {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: defaults.token,
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
@@ -89,11 +92,31 @@ export function loadSettings(): UiSettings {
: defaults.navGroupsCollapsed,
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
};
if ("token" in parsed) {
persistSettings(settings);
}
return settings;
} catch {
return defaults;
}
}
export function saveSettings(next: UiSettings) {
localStorage.setItem(KEY, JSON.stringify(next));
persistSettings(next);
}
function persistSettings(next: UiSettings) {
const persisted: PersistedUiSettings = {
gatewayUrl: next.gatewayUrl,
sessionKey: next.sessionKey,
lastActiveSessionKey: next.lastActiveSessionKey,
theme: next.theme,
chatFocusMode: next.chatFocusMode,
chatShowThinking: next.chatShowThinking,
splitRatio: next.splitRatio,
navCollapsed: next.navCollapsed,
navGroupsCollapsed: next.navGroupsCollapsed,
...(next.locale ? { locale: next.locale } : {}),
};
localStorage.setItem(KEY, JSON.stringify(persisted));
}