mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:00:41 +00:00
fix(ui): preserve control-ui auth across refresh
This commit is contained in:
@@ -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.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
||||
@@ -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, so same-tab refreshes keep working without restoring long-lived localStorage token persistence.
|
||||
|
||||
Fix:
|
||||
|
||||
|
||||
@@ -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; 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 stored in sessionStorage for the current browser tab session 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.
|
||||
|
||||
@@ -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 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 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).
|
||||
|
||||
@@ -151,6 +151,7 @@ describe("control UI routing", () => {
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123");
|
||||
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||
undefined,
|
||||
);
|
||||
@@ -179,6 +180,7 @@ describe("control UI routing", () => {
|
||||
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({
|
||||
gatewayUrl: "wss://gateway.example/openclaw",
|
||||
});
|
||||
expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123");
|
||||
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||
undefined,
|
||||
);
|
||||
@@ -191,10 +193,26 @@ describe("control UI routing", () => {
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(sessionStorage.getItem("openclaw.control.token.v1")).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("");
|
||||
});
|
||||
|
||||
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(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123");
|
||||
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -132,6 +134,22 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
});
|
||||
expect(sessionStorage.getItem("openclaw.control.token.v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads the current-tab token from sessionStorage", async () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
sessionStorage.setItem("openclaw.control.token.v1", "session-token");
|
||||
|
||||
const { loadSettings } = await import("./storage.ts");
|
||||
expect(loadSettings()).toMatchObject({
|
||||
gatewayUrl: "wss://gateway.example:8443",
|
||||
token: "session-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist gateway tokens when saving settings", async () => {
|
||||
@@ -166,5 +184,31 @@ describe("loadSettings default gateway URL derivation", () => {
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
});
|
||||
expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("memory-only-token");
|
||||
});
|
||||
|
||||
it("clears the current-tab token when saving an empty token", async () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
sessionStorage.setItem("openclaw.control.token.v1", "stale-token");
|
||||
|
||||
const { saveSettings } = await import("./storage.ts");
|
||||
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(sessionStorage.getItem("openclaw.control.token.v1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const KEY = "openclaw.control.settings.v1";
|
||||
const TOKEN_SESSION_KEY = "openclaw.control.token.v1";
|
||||
|
||||
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
|
||||
|
||||
@@ -20,6 +21,43 @@ 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 loadSessionToken(): string {
|
||||
try {
|
||||
const storage = getSessionStorage();
|
||||
const token = storage?.getItem(TOKEN_SESSION_KEY) ?? "";
|
||||
return token.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function persistSessionToken(token: string) {
|
||||
try {
|
||||
const storage = getSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
const normalized = token.trim();
|
||||
if (normalized) {
|
||||
storage.setItem(TOKEN_SESSION_KEY, normalized);
|
||||
return;
|
||||
}
|
||||
storage.removeItem(TOKEN_SESSION_KEY);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
const defaultUrl = (() => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
@@ -35,7 +73,7 @@ export function loadSettings(): UiSettings {
|
||||
|
||||
const defaults: UiSettings = {
|
||||
gatewayUrl: defaultUrl,
|
||||
token: "",
|
||||
token: loadSessionToken(),
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
@@ -106,6 +144,7 @@ export function saveSettings(next: UiSettings) {
|
||||
}
|
||||
|
||||
function persistSettings(next: UiSettings) {
|
||||
persistSessionToken(next.token);
|
||||
const persisted: PersistedUiSettings = {
|
||||
gatewayUrl: next.gatewayUrl,
|
||||
sessionKey: next.sessionKey,
|
||||
|
||||
@@ -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 = "";
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user