Skip to content

Commit f2f561f

Browse files
authored
fix(ui): preserve control-ui auth across refresh (#40892)
Merged via squash. Prepared head SHA: f9b2375 Co-authored-by: velvet-shark <[email protected]> Co-authored-by: velvet-shark <[email protected]> Reviewed-by: @velvet-shark
1 parent f6d0712 commit f2f561f

File tree

13 files changed

+312
-26
lines changed

13 files changed

+312
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
### Fixes
1212

1313
- 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.
14+
- 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.
1415

1516
## 2026.3.8
1617

docs/help/faq.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2504,7 +2504,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
25042504

25052505
Facts (from code):
25062506

2507-
- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage.
2507+
- 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.
25082508

25092509
Fix:
25102510

docs/web/control-ui.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Auth is supplied during the WebSocket handshake via:
2727

2828
- `connect.params.auth.token`
2929
- `connect.params.auth.password`
30-
The dashboard settings panel lets you store a token; passwords are not persisted.
30+
The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted.
3131
The onboarding wizard generates a gateway token by default, so paste it here on first connect.
3232

3333
## Device pairing (first connection)
@@ -237,7 +237,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
237237
Notes:
238238

239239
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
240-
- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage.
240+
- `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.
241241
- `password` is kept in memory only.
242242
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
243243
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.

docs/web/dashboard.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
2424
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
2525

2626
Security note: the Control UI is an **admin surface** (chat, config, exec approvals).
27-
Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab
28-
and strips them from the URL after load.
27+
Do not expose it publicly. The UI keeps dashboard URL tokens in sessionStorage
28+
for the current browser tab session and selected gateway URL, and strips them from the URL after load.
2929
Prefer localhost, Tailscale Serve, or an SSH tunnel.
3030

3131
## Fast path (recommended)
@@ -37,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
3737
## Token basics (local vs remote)
3838

3939
- **Localhost**: open `http://127.0.0.1:18789/`.
40-
- **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.
40+
- **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.
4141
- 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.
4242
- 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.
4343
- **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).

ui/src/ui/app-lifecycle-connect.node.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const { connectGatewayMock, loadBootstrapMock } = vi.hoisted(() => ({
3+
const { applySettingsFromUrlMock, connectGatewayMock, loadBootstrapMock } = vi.hoisted(() => ({
4+
applySettingsFromUrlMock: vi.fn(),
45
connectGatewayMock: vi.fn(),
56
loadBootstrapMock: vi.fn(),
67
}));
@@ -14,7 +15,7 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
1415
}));
1516

1617
vi.mock("./app-settings.ts", () => ({
17-
applySettingsFromUrl: vi.fn(),
18+
applySettingsFromUrl: applySettingsFromUrlMock,
1819
attachThemeListener: vi.fn(),
1920
detachThemeListener: vi.fn(),
2021
inferBasePath: vi.fn(() => "/"),
@@ -65,6 +66,12 @@ function createHost() {
6566
}
6667

6768
describe("handleConnected", () => {
69+
beforeEach(() => {
70+
applySettingsFromUrlMock.mockReset();
71+
connectGatewayMock.mockReset();
72+
loadBootstrapMock.mockReset();
73+
});
74+
6875
it("waits for bootstrap load before first gateway connect", async () => {
6976
let resolveBootstrap!: () => void;
7077
loadBootstrapMock.mockReturnValueOnce(
@@ -102,4 +109,17 @@ describe("handleConnected", () => {
102109

103110
expect(connectGatewayMock).not.toHaveBeenCalled();
104111
});
112+
113+
it("scrubs URL settings before starting the bootstrap fetch", () => {
114+
loadBootstrapMock.mockResolvedValueOnce(undefined);
115+
const host = createHost();
116+
117+
handleConnected(host as never);
118+
119+
expect(applySettingsFromUrlMock).toHaveBeenCalledTimes(1);
120+
expect(loadBootstrapMock).toHaveBeenCalledTimes(1);
121+
expect(applySettingsFromUrlMock.mock.invocationCallOrder[0]).toBeLessThan(
122+
loadBootstrapMock.mock.invocationCallOrder[0],
123+
);
124+
});
105125
});

ui/src/ui/app-lifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ type LifecycleHost = {
4545
export function handleConnected(host: LifecycleHost) {
4646
const connectGeneration = ++host.connectGeneration;
4747
host.basePath = inferBasePath();
48-
const bootstrapReady = loadControlUiBootstrapConfig(host);
4948
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
49+
const bootstrapReady = loadControlUiBootstrapConfig(host);
5050
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
5151
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
5252
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);

ui/src/ui/app-settings.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type SettingsHost = {
5959
themeMedia: MediaQueryList | null;
6060
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
6161
pendingGatewayUrl?: string | null;
62+
pendingGatewayToken?: string | null;
6263
};
6364

6465
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -94,18 +95,26 @@ export function applySettingsFromUrl(host: SettingsHost) {
9495
const params = new URLSearchParams(url.search);
9596
const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
9697

97-
const tokenRaw = params.get("token") ?? hashParams.get("token");
98+
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
99+
const nextGatewayUrl = gatewayUrlRaw?.trim() ?? "";
100+
const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl);
101+
const tokenRaw = hashParams.get("token");
98102
const passwordRaw = params.get("password") ?? hashParams.get("password");
99103
const sessionRaw = params.get("session") ?? hashParams.get("session");
100-
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
101104
let shouldCleanUrl = false;
102105

106+
if (params.has("token")) {
107+
params.delete("token");
108+
shouldCleanUrl = true;
109+
}
110+
103111
if (tokenRaw != null) {
104112
const token = tokenRaw.trim();
105-
if (token && token !== host.settings.token) {
113+
if (token && gatewayUrlChanged) {
114+
host.pendingGatewayToken = token;
115+
} else if (token && token !== host.settings.token) {
106116
applySettings(host, { ...host.settings, token });
107117
}
108-
params.delete("token");
109118
hashParams.delete("token");
110119
shouldCleanUrl = true;
111120
}
@@ -130,9 +139,14 @@ export function applySettingsFromUrl(host: SettingsHost) {
130139
}
131140

132141
if (gatewayUrlRaw != null) {
133-
const gatewayUrl = gatewayUrlRaw.trim();
134-
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
135-
host.pendingGatewayUrl = gatewayUrl;
142+
if (gatewayUrlChanged) {
143+
host.pendingGatewayUrl = nextGatewayUrl;
144+
if (!tokenRaw?.trim()) {
145+
host.pendingGatewayToken = null;
146+
}
147+
} else {
148+
host.pendingGatewayUrl = null;
149+
host.pendingGatewayToken = null;
136150
}
137151
params.delete("gatewayUrl");
138152
hashParams.delete("gatewayUrl");

ui/src/ui/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export class OpenClawApp extends LitElement {
178178
@state() execApprovalBusy = false;
179179
@state() execApprovalError: string | null = null;
180180
@state() pendingGatewayUrl: string | null = null;
181+
pendingGatewayToken: string | null = null;
181182

182183
@state() configLoading = false;
183184
@state() configRaw = "{\n}\n";
@@ -573,16 +574,20 @@ export class OpenClawApp extends LitElement {
573574
if (!nextGatewayUrl) {
574575
return;
575576
}
577+
const nextToken = this.pendingGatewayToken?.trim() || "";
576578
this.pendingGatewayUrl = null;
579+
this.pendingGatewayToken = null;
577580
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
578581
...this.settings,
579582
gatewayUrl: nextGatewayUrl,
583+
token: nextToken,
580584
});
581585
this.connect();
582586
}
583587

584588
handleGatewayUrlCancel() {
585589
this.pendingGatewayUrl = null;
590+
this.pendingGatewayToken = null;
586591
}
587592

588593
// Sidebar handlers for tool output viewing

ui/src/ui/navigation.browser.test.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,11 @@ describe("control UI routing", () => {
146146
expect(container.scrollTop).toBe(maxScroll);
147147
});
148148

149-
it("hydrates token from URL params and strips it", async () => {
149+
it("strips query token params without importing them", async () => {
150150
const app = mountApp("/ui/overview?token=abc123");
151151
await app.updateComplete;
152152

153-
expect(app.settings.token).toBe("abc123");
153+
expect(app.settings.token).toBe("");
154154
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
155155
undefined,
156156
);
@@ -167,12 +167,12 @@ describe("control UI routing", () => {
167167
expect(window.location.search).toBe("");
168168
});
169169

170-
it("hydrates token from URL params even when settings already set", async () => {
170+
it("hydrates token from URL hash when settings already set", async () => {
171171
localStorage.setItem(
172172
"openclaw.control.settings.v1",
173173
JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }),
174174
);
175-
const app = mountApp("/ui/overview?token=abc123");
175+
const app = mountApp("/ui/overview#token=abc123");
176176
await app.updateComplete;
177177

178178
expect(app.settings.token).toBe("abc123");
@@ -183,7 +183,7 @@ describe("control UI routing", () => {
183183
undefined,
184184
);
185185
expect(window.location.pathname).toBe("/ui/overview");
186-
expect(window.location.search).toBe("");
186+
expect(window.location.hash).toBe("");
187187
});
188188

189189
it("hydrates token from URL hash and strips it", async () => {
@@ -197,4 +197,56 @@ describe("control UI routing", () => {
197197
expect(window.location.pathname).toBe("/ui/overview");
198198
expect(window.location.hash).toBe("");
199199
});
200+
201+
it("clears the current token when the gateway URL changes", async () => {
202+
const app = mountApp("/ui/overview#token=abc123");
203+
await app.updateComplete;
204+
205+
const gatewayUrlInput = app.querySelector<HTMLInputElement>(
206+
'input[placeholder="ws://100.x.y.z:18789"]',
207+
);
208+
expect(gatewayUrlInput).not.toBeNull();
209+
gatewayUrlInput!.value = "wss://other-gateway.example/openclaw";
210+
gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true }));
211+
await app.updateComplete;
212+
213+
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
214+
expect(app.settings.token).toBe("");
215+
});
216+
217+
it("keeps a hash token pending until the gateway URL change is confirmed", async () => {
218+
const app = mountApp(
219+
"/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw#token=abc123",
220+
);
221+
await app.updateComplete;
222+
223+
expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw");
224+
expect(app.settings.token).toBe("");
225+
226+
const confirmButton = Array.from(app.querySelectorAll<HTMLButtonElement>("button")).find(
227+
(button) => button.textContent?.trim() === "Confirm",
228+
);
229+
expect(confirmButton).not.toBeUndefined();
230+
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
231+
await app.updateComplete;
232+
233+
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
234+
expect(app.settings.token).toBe("abc123");
235+
expect(window.location.search).toBe("");
236+
expect(window.location.hash).toBe("");
237+
});
238+
239+
it("restores the token after a same-tab refresh", async () => {
240+
const first = mountApp("/ui/overview#token=abc123");
241+
await first.updateComplete;
242+
first.remove();
243+
244+
const refreshed = mountApp("/ui/overview");
245+
await refreshed.updateComplete;
246+
247+
expect(refreshed.settings.token).toBe("abc123");
248+
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
249+
undefined,
250+
);
251+
});
200252
});

0 commit comments

Comments
 (0)