fix(ui): preserve control-ui auth across refresh (#40892)

Merged via squash.

Prepared head SHA: f9b2375892
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
This commit is contained in:
Radek Sienkiewicz
2026-03-09 12:50:47 +01:00
committed by GitHub
parent f6d0712f50
commit f2f561fab1
13 changed files with 312 additions and 26 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { connectGatewayMock, loadBootstrapMock } = vi.hoisted(() => ({
const { applySettingsFromUrlMock, connectGatewayMock, loadBootstrapMock } = vi.hoisted(() => ({
applySettingsFromUrlMock: vi.fn(),
connectGatewayMock: vi.fn(),
loadBootstrapMock: vi.fn(),
}));
@@ -14,7 +15,7 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
}));
vi.mock("./app-settings.ts", () => ({
applySettingsFromUrl: vi.fn(),
applySettingsFromUrl: applySettingsFromUrlMock,
attachThemeListener: vi.fn(),
detachThemeListener: vi.fn(),
inferBasePath: vi.fn(() => "/"),
@@ -65,6 +66,12 @@ function createHost() {
}
describe("handleConnected", () => {
beforeEach(() => {
applySettingsFromUrlMock.mockReset();
connectGatewayMock.mockReset();
loadBootstrapMock.mockReset();
});
it("waits for bootstrap load before first gateway connect", async () => {
let resolveBootstrap!: () => void;
loadBootstrapMock.mockReturnValueOnce(
@@ -102,4 +109,17 @@ describe("handleConnected", () => {
expect(connectGatewayMock).not.toHaveBeenCalled();
});
it("scrubs URL settings before starting the bootstrap fetch", () => {
loadBootstrapMock.mockResolvedValueOnce(undefined);
const host = createHost();
handleConnected(host as never);
expect(applySettingsFromUrlMock).toHaveBeenCalledTimes(1);
expect(loadBootstrapMock).toHaveBeenCalledTimes(1);
expect(applySettingsFromUrlMock.mock.invocationCallOrder[0]).toBeLessThan(
loadBootstrapMock.mock.invocationCallOrder[0],
);
});
});

View File

@@ -45,8 +45,8 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) {
const connectGeneration = ++host.connectGeneration;
host.basePath = inferBasePath();
const bootstrapReady = loadControlUiBootstrapConfig(host);
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
const bootstrapReady = loadControlUiBootstrapConfig(host);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);

View File

@@ -59,6 +59,7 @@ type SettingsHost = {
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
pendingGatewayToken?: string | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -94,18 +95,26 @@ export function applySettingsFromUrl(host: SettingsHost) {
const params = new URLSearchParams(url.search);
const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
const tokenRaw = params.get("token") ?? hashParams.get("token");
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
const nextGatewayUrl = gatewayUrlRaw?.trim() ?? "";
const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl);
const tokenRaw = hashParams.get("token");
const passwordRaw = params.get("password") ?? hashParams.get("password");
const sessionRaw = params.get("session") ?? hashParams.get("session");
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
let shouldCleanUrl = false;
if (params.has("token")) {
params.delete("token");
shouldCleanUrl = true;
}
if (tokenRaw != null) {
const token = tokenRaw.trim();
if (token && token !== host.settings.token) {
if (token && gatewayUrlChanged) {
host.pendingGatewayToken = token;
} else if (token && token !== host.settings.token) {
applySettings(host, { ...host.settings, token });
}
params.delete("token");
hashParams.delete("token");
shouldCleanUrl = true;
}
@@ -130,9 +139,14 @@ export function applySettingsFromUrl(host: SettingsHost) {
}
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
host.pendingGatewayUrl = gatewayUrl;
if (gatewayUrlChanged) {
host.pendingGatewayUrl = nextGatewayUrl;
if (!tokenRaw?.trim()) {
host.pendingGatewayToken = null;
}
} else {
host.pendingGatewayUrl = null;
host.pendingGatewayToken = null;
}
params.delete("gatewayUrl");
hashParams.delete("gatewayUrl");

View File

@@ -178,6 +178,7 @@ export class OpenClawApp extends LitElement {
@state() execApprovalBusy = false;
@state() execApprovalError: string | null = null;
@state() pendingGatewayUrl: string | null = null;
pendingGatewayToken: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@@ -573,16 +574,20 @@ export class OpenClawApp extends LitElement {
if (!nextGatewayUrl) {
return;
}
const nextToken = this.pendingGatewayToken?.trim() || "";
this.pendingGatewayUrl = null;
this.pendingGatewayToken = null;
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
...this.settings,
gatewayUrl: nextGatewayUrl,
token: nextToken,
});
this.connect();
}
handleGatewayUrlCancel() {
this.pendingGatewayUrl = null;
this.pendingGatewayToken = null;
}
// Sidebar handlers for tool output viewing

View File

@@ -146,11 +146,11 @@ describe("control UI routing", () => {
expect(container.scrollTop).toBe(maxScroll);
});
it("hydrates token from URL params and strips it", async () => {
it("strips query token params without importing them", async () => {
const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
expect(app.settings.token).toBe("");
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
undefined,
);
@@ -167,12 +167,12 @@ describe("control UI routing", () => {
expect(window.location.search).toBe("");
});
it("hydrates token from URL params even when settings already set", async () => {
it("hydrates token from URL hash when settings already set", async () => {
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }),
);
const app = mountApp("/ui/overview?token=abc123");
const app = mountApp("/ui/overview#token=abc123");
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
@@ -183,7 +183,7 @@ describe("control UI routing", () => {
undefined,
);
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("");
});
it("hydrates token from URL hash and strips it", async () => {
@@ -197,4 +197,56 @@ describe("control UI routing", () => {
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.hash).toBe("");
});
it("clears the current token when the gateway URL changes", async () => {
const app = mountApp("/ui/overview#token=abc123");
await app.updateComplete;
const gatewayUrlInput = app.querySelector<HTMLInputElement>(
'input[placeholder="ws://100.x.y.z:18789"]',
);
expect(gatewayUrlInput).not.toBeNull();
gatewayUrlInput!.value = "wss://other-gateway.example/openclaw";
gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true }));
await app.updateComplete;
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
expect(app.settings.token).toBe("");
});
it("keeps a hash token pending until the gateway URL change is confirmed", async () => {
const app = mountApp(
"/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw#token=abc123",
);
await app.updateComplete;
expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw");
expect(app.settings.token).toBe("");
const confirmButton = Array.from(app.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Confirm",
);
expect(confirmButton).not.toBeUndefined();
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await app.updateComplete;
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
expect(app.settings.token).toBe("abc123");
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("");
});
it("restores the token after a same-tab refresh", async () => {
const first = mountApp("/ui/overview#token=abc123");
await first.updateComplete;
first.remove();
const refreshed = mountApp("/ui/overview");
await refreshed.updateComplete;
expect(refreshed.settings.token).toBe("abc123");
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
undefined,
);
});
});

View File

@@ -66,8 +66,10 @@ describe("loadSettings default gateway URL derivation", () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
localStorage.clear();
sessionStorage.clear();
setControlUiBasePath(undefined);
});
@@ -106,6 +108,7 @@ describe("loadSettings default gateway URL derivation", () => {
host: "gateway.example:8443",
pathname: "/",
});
sessionStorage.setItem("openclaw.control.token.v1", "legacy-session-token");
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
@@ -132,6 +135,76 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navGroupsCollapsed: {},
});
expect(sessionStorage.length).toBe(0);
});
it("loads the current-tab token from sessionStorage", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "session-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "session-token",
});
});
it("does not reuse a session token for a different gatewayUrl", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "gateway-a-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "wss://other-gateway.example:8443/openclaw",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
}),
);
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://other-gateway.example:8443/openclaw",
token: "",
});
});
it("does not persist gateway tokens when saving settings", async () => {
@@ -141,7 +214,7 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
const { saveSettings } = await import("./storage.ts");
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "memory-only-token",
@@ -154,6 +227,10 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navGroupsCollapsed: {},
});
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "memory-only-token",
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
@@ -166,5 +243,43 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navGroupsCollapsed: {},
});
expect(sessionStorage.length).toBe(1);
});
it("clears the current-tab token when saving an empty token", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "stale-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});
expect(loadSettings().token).toBe("");
expect(sessionStorage.length).toBe(0);
});
});

View File

@@ -1,4 +1,6 @@
const KEY = "openclaw.control.settings.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
@@ -20,6 +22,72 @@ export type UiSettings = {
locale?: string;
};
function getSessionStorage(): Storage | null {
if (typeof window !== "undefined" && window.sessionStorage) {
return window.sessionStorage;
}
if (typeof sessionStorage !== "undefined") {
return sessionStorage;
}
return null;
}
function normalizeGatewayTokenScope(gatewayUrl: string): string {
const trimmed = gatewayUrl.trim();
if (!trimmed) {
return "default";
}
try {
const base =
typeof location !== "undefined"
? `${location.protocol}//${location.host}${location.pathname || "/"}`
: undefined;
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
const pathname =
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
return `${parsed.protocol}//${parsed.host}${pathname}`;
} catch {
return trimmed;
}
}
function tokenSessionKeyForGateway(gatewayUrl: string): string {
return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
}
function loadSessionToken(gatewayUrl: string): string {
try {
const storage = getSessionStorage();
if (!storage) {
return "";
}
storage.removeItem(LEGACY_TOKEN_SESSION_KEY);
const token = storage.getItem(tokenSessionKeyForGateway(gatewayUrl)) ?? "";
return token.trim();
} catch {
return "";
}
}
function persistSessionToken(gatewayUrl: string, token: string) {
try {
const storage = getSessionStorage();
if (!storage) {
return;
}
storage.removeItem(LEGACY_TOKEN_SESSION_KEY);
const key = tokenSessionKeyForGateway(gatewayUrl);
const normalized = token.trim();
if (normalized) {
storage.setItem(key, normalized);
return;
}
storage.removeItem(key);
} catch {
// best-effort
}
}
export function loadSettings(): UiSettings {
const defaultUrl = (() => {
const proto = location.protocol === "https:" ? "wss" : "ws";
@@ -35,7 +103,7 @@ export function loadSettings(): UiSettings {
const defaults: UiSettings = {
gatewayUrl: defaultUrl,
token: "",
token: loadSessionToken(defaultUrl),
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
@@ -58,7 +126,11 @@ export function loadSettings(): UiSettings {
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: defaults.token,
token: loadSessionToken(
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
),
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
@@ -106,6 +178,7 @@ export function saveSettings(next: UiSettings) {
}
function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token);
const persisted: PersistedUiSettings = {
gatewayUrl: next.gatewayUrl,
sessionKey: next.sessionKey,

View File

@@ -16,12 +16,14 @@ export function registerAppMountHooks() {
beforeEach(() => {
window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
sessionStorage.clear();
document.body.innerHTML = "";
});
afterEach(() => {
window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
sessionStorage.clear();
document.body.innerHTML = "";
});
}

View File

@@ -205,7 +205,11 @@ export function renderOverview(props: OverviewProps) {
.value=${props.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, gatewayUrl: v });
props.onSettingsChange({
...props.settings,
gatewayUrl: v,
token: v.trim() === props.settings.gatewayUrl.trim() ? props.settings.token : "",
});
}}
placeholder="ws://100.x.y.z:18789"
/>