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 Vincent Koc
parent 57b90adbf2
commit 6fac513119
13 changed files with 312 additions and 26 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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://<gateway-host>:18789#token=<gateway-toke
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage.
- `token` is imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; it is not stored in localStorage.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.

View File

@@ -24,8 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
Security note: the Control UI is an **admin surface** (chat, config, exec approvals).
Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab
and strips them from the URL after load.
Do not expose it publicly. The UI keeps dashboard URL tokens in sessionStorage
for the current browser tab session and selected gateway URL, and strips them from the URL after load.
Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Fast path (recommended)
@@ -37,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Token basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, but the Control UI does not persist gateway tokens in localStorage.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, and the Control UI keeps it in sessionStorage for the current browser tab session and selected gateway URL instead of localStorage.
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance.
- **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).

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"
/>