diff --git a/CHANGELOG.md b/CHANGELOG.md index f987feeec35..4b94dca8be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. +- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. ## 2026.3.8 diff --git a/docs/help/faq.md b/docs/help/faq.md index 0ea9c4d92d5..7dad0548fd4 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2504,7 +2504,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): -- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage. +- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence. Fix: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index bbee9443b83..c96a91de0ba 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -27,7 +27,7 @@ Auth is supplied during the WebSocket handshake via: - `connect.params.auth.token` - `connect.params.auth.password` - The dashboard settings panel lets you store a token; passwords are not persisted. + The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. The onboarding wizard generates a gateway token by default, so paste it here on first connect. ## Device pairing (first connection) @@ -237,7 +237,7 @@ http://localhost:5173/?gatewayUrl=wss://:18789#token= ({ +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], + ); + }); }); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 815947d6972..28fb5271ecc 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -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[0]); + const bootstrapReady = loadControlUiBootstrapConfig(host); syncTabWithLocation(host as unknown as Parameters[0], true); syncThemeWithSettings(host as unknown as Parameters[0]); attachThemeListener(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 2c07fc0f80c..55dd59ace0d 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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"); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 69350b550c3..6467ca9e394 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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[0], { ...this.settings, gatewayUrl: nextGatewayUrl, + token: nextToken, }); this.connect(); } handleGatewayUrlCancel() { this.pendingGatewayUrl = null; + this.pendingGatewayToken = null; } // Sidebar handlers for tool output viewing diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 8dae3fc2a13..d9b5f3c7182 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -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( + '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("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, + ); + }); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 34563291fe3..a6f2d3d9790 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -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); }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index b413cf38eb5..078c9bccf47 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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 & { 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, diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index d6fda9475c4..e078b186203 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -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 = ""; }); } diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index b559fba4dab..6ebcb884ff6 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -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" />