From d37e3d582fd8193c5b645de5ead8d7a14a137e17 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Sun, 15 Mar 2026 13:08:37 -0400 Subject: [PATCH] Scope Control UI sessions per gateway (#47453) * Scope Control UI sessions per gateway Signed-off-by: sallyom * Add changelog for Control UI session scoping Signed-off-by: sallyom --------- Signed-off-by: sallyom --- CHANGELOG.md | 1 + ui/src/ui/app-settings.test.ts | 83 ++++++++++++++++++++++ ui/src/ui/app-settings.ts | 12 ++++ ui/src/ui/storage.node.test.ts | 122 +++++++++++++++++++++++++++++++-- ui/src/ui/storage.ts | 90 ++++++++++++++++++++---- 5 files changed, 291 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b29a7d668..5de1f1b05f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. +- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. - Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index aecc1f5bbcb..fd02f7673e9 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyResolvedTheme, applySettings, + applySettingsFromUrl, attachThemeListener, setTabFromRoute, syncThemeWithSettings, @@ -60,6 +61,8 @@ type SettingsHost = { themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; logsPollInterval: number | null; debugPollInterval: number | null; + pendingGatewayUrl?: string | null; + pendingGatewayToken?: string | null; }; function createStorageMock(): Storage { @@ -118,6 +121,8 @@ const createHost = (tab: Tab): SettingsHost => ({ themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, + pendingGatewayUrl: null, + pendingGatewayToken: null, }); describe("setTabFromRoute", () => { @@ -224,3 +229,81 @@ describe("setTabFromRoute", () => { expect(root.style.colorScheme).toBe("light"); }); }); + +describe("applySettingsFromUrl", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.history.replaceState({}, "", "/chat"); + }); + + it("resets stale persisted session selection to main when a token is supplied without a session", () => { + const host = createHost("chat"); + host.settings = { + ...host.settings, + gatewayUrl: "ws://localhost:18789", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }; + host.sessionKey = "agent:test_old:main"; + + window.history.replaceState({}, "", "/chat#token=test-token"); + + applySettingsFromUrl(host); + + expect(host.sessionKey).toBe("main"); + expect(host.settings.sessionKey).toBe("main"); + expect(host.settings.lastActiveSessionKey).toBe("main"); + }); + + it("preserves an explicit session from the URL when token and session are both supplied", () => { + const host = createHost("chat"); + host.settings = { + ...host.settings, + gatewayUrl: "ws://localhost:18789", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }; + host.sessionKey = "agent:test_old:main"; + + window.history.replaceState({}, "", "/chat?session=agent%3Atest_new%3Amain#token=test-token"); + + applySettingsFromUrl(host); + + expect(host.sessionKey).toBe("agent:test_new:main"); + expect(host.settings.sessionKey).toBe("agent:test_new:main"); + expect(host.settings.lastActiveSessionKey).toBe("agent:test_new:main"); + }); + + it("does not reset the current gateway session when a different gateway is pending confirmation", () => { + const host = createHost("chat"); + host.settings = { + ...host.settings, + gatewayUrl: "ws://gateway-a.example:18789", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }; + host.sessionKey = "agent:test_old:main"; + + window.history.replaceState( + {}, + "", + "/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=test-token", + ); + + applySettingsFromUrl(host); + + expect(host.sessionKey).toBe("agent:test_old:main"); + expect(host.settings.sessionKey).toBe("agent:test_old:main"); + expect(host.settings.lastActiveSessionKey).toBe("agent:test_old:main"); + expect(host.pendingGatewayUrl).toBe("ws://gateway-b.example:18789"); + expect(host.pendingGatewayToken).toBe("test-token"); + }); +}); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 50575826813..23f1de68caa 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -100,6 +100,9 @@ export function applySettingsFromUrl(host: SettingsHost) { const tokenRaw = hashParams.get("token"); const passwordRaw = params.get("password") ?? hashParams.get("password"); const sessionRaw = params.get("session") ?? hashParams.get("session"); + const shouldResetSessionForToken = Boolean( + tokenRaw?.trim() && !sessionRaw?.trim() && !gatewayUrlChanged, + ); let shouldCleanUrl = false; if (params.has("token")) { @@ -118,6 +121,15 @@ export function applySettingsFromUrl(host: SettingsHost) { shouldCleanUrl = true; } + if (shouldResetSessionForToken) { + host.sessionKey = "main"; + applySettings(host, { + ...host.settings, + sessionKey: "main", + lastActiveSessionKey: "main", + }); + } + if (passwordRaw != null) { // Never hydrate password from URL params; strip only. params.delete("password"); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 64ce3aec95c..2222e193e96 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -126,8 +126,6 @@ describe("loadSettings default gateway URL derivation", () => { }); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", - sessionKey: "agent", - lastActiveSessionKey: "agent", theme: "claw", themeMode: "system", chatFocusMode: false, @@ -137,6 +135,12 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, + sessionsByGateway: { + "wss://gateway.example:8443/openclaw": { + sessionKey: "agent", + lastActiveSessionKey: "agent", + }, + }, }); expect(sessionStorage.length).toBe(0); }); @@ -249,8 +253,6 @@ describe("loadSettings default gateway URL derivation", () => { expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", - sessionKey: "main", - lastActiveSessionKey: "main", theme: "claw", themeMode: "system", chatFocusMode: false, @@ -260,6 +262,12 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, + sessionsByGateway: { + "wss://gateway.example:8443/openclaw": { + sessionKey: "main", + lastActiveSessionKey: "main", + }, + }, }); expect(sessionStorage.length).toBe(1); }); @@ -337,4 +345,110 @@ describe("loadSettings default gateway URL derivation", () => { navWidth: 320, }); }); + + it("scopes persisted session selection per gateway", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + + saveSettings({ + gatewayUrl: "wss://gateway-a.example:8443/openclaw", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + + saveSettings({ + gatewayUrl: "wss://gateway-b.example:8443/openclaw", + token: "", + sessionKey: "agent:test_new:main", + lastActiveSessionKey: "agent:test_new:main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"), + gatewayUrl: "wss://gateway-a.example:8443/openclaw", + }), + ); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway-a.example:8443/openclaw", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }); + + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"), + gatewayUrl: "wss://gateway-b.example:8443/openclaw", + }), + ); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway-b.example:8443/openclaw", + sessionKey: "agent:test_new:main", + lastActiveSessionKey: "agent:test_new:main", + }); + }); + + it("caps persisted session scopes to the most recent gateways", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + + for (let i = 0; i < 12; i += 1) { + saveSettings({ + gatewayUrl: `wss://gateway-${i}.example:8443/openclaw`, + token: "", + sessionKey: `agent:test_${i}:main`, + lastActiveSessionKey: `agent:test_${i}:main`, + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + } + + const persisted = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"); + const scopes = Object.keys(persisted.sessionsByGateway ?? {}); + + expect(scopes).toHaveLength(10); + expect(scopes).not.toContain("wss://gateway-0.example:8443/openclaw"); + expect(scopes).not.toContain("wss://gateway-1.example:8443/openclaw"); + expect(scopes).toContain("wss://gateway-11.example:8443/openclaw"); + }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 02e826b3a1d..450c5124592 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,8 +1,19 @@ const KEY = "openclaw.control.settings.v1"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; +const MAX_SCOPED_SESSION_ENTRIES = 10; -type PersistedUiSettings = Omit & { token?: never }; +type ScopedSessionSelection = { + sessionKey: string; + lastActiveSessionKey: string; +}; + +type PersistedUiSettings = Omit & { + token?: never; + sessionKey?: string; + lastActiveSessionKey?: string; + sessionsByGateway?: Record; +}; import { isSupportedLocale } from "../i18n/index.ts"; import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; @@ -87,6 +98,41 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string { return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; } +function resolveScopedSessionSelection( + gatewayUrl: string, + parsed: PersistedUiSettings, + defaults: UiSettings, +): ScopedSessionSelection { + const scope = normalizeGatewayTokenScope(gatewayUrl); + const scoped = parsed.sessionsByGateway?.[scope]; + if ( + scoped && + typeof scoped.sessionKey === "string" && + scoped.sessionKey.trim() && + typeof scoped.lastActiveSessionKey === "string" && + scoped.lastActiveSessionKey.trim() + ) { + return { + sessionKey: scoped.sessionKey.trim(), + lastActiveSessionKey: scoped.lastActiveSessionKey.trim(), + }; + } + + const legacySessionKey = + typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() + ? parsed.sessionKey.trim() + : defaults.sessionKey; + const legacyLastActiveSessionKey = + typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim() + ? parsed.lastActiveSessionKey.trim() + : legacySessionKey || defaults.lastActiveSessionKey; + + return { + sessionKey: legacySessionKey, + lastActiveSessionKey: legacyLastActiveSessionKey, + }; +} + function loadSessionToken(gatewayUrl: string): string { try { const storage = getSessionStorage(); @@ -144,12 +190,13 @@ export function loadSettings(): UiSettings { if (!raw) { return defaults; } - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as PersistedUiSettings; const parsedGatewayUrl = typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim() : defaults.gatewayUrl; const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl; + const scopedSessionSelection = resolveScopedSessionSelection(gatewayUrl, parsed, defaults); const { theme, mode } = parseThemeSelection( (parsed as { theme?: unknown }).theme, (parsed as { themeMode?: unknown }).themeMode, @@ -158,15 +205,8 @@ export function loadSettings(): UiSettings { gatewayUrl, // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. token: loadSessionToken(gatewayUrl), - sessionKey: - typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() - ? parsed.sessionKey.trim() - : defaults.sessionKey, - lastActiveSessionKey: - typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim() - ? parsed.lastActiveSessionKey.trim() - : (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) || - defaults.lastActiveSessionKey, + sessionKey: scopedSessionSelection.sessionKey, + lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey, theme, themeMode: mode, chatFocusMode: @@ -212,10 +252,33 @@ export function saveSettings(next: UiSettings) { function persistSettings(next: UiSettings) { persistSessionToken(next.gatewayUrl, next.token); + const scope = normalizeGatewayTokenScope(next.gatewayUrl); + let existingSessionsByGateway: Record = {}; + try { + const raw = localStorage.getItem(KEY); + if (raw) { + const parsed = JSON.parse(raw) as PersistedUiSettings; + if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") { + existingSessionsByGateway = parsed.sessionsByGateway; + } + } + } catch { + // best-effort + } + const sessionsByGateway = Object.fromEntries( + [ + ...Object.entries(existingSessionsByGateway).filter(([key]) => key !== scope), + [ + scope, + { + sessionKey: next.sessionKey, + lastActiveSessionKey: next.lastActiveSessionKey, + }, + ], + ].slice(-MAX_SCOPED_SESSION_ENTRIES), + ); const persisted: PersistedUiSettings = { gatewayUrl: next.gatewayUrl, - sessionKey: next.sessionKey, - lastActiveSessionKey: next.lastActiveSessionKey, theme: next.theme, themeMode: next.themeMode, chatFocusMode: next.chatFocusMode, @@ -225,6 +288,7 @@ function persistSettings(next: UiSettings) { navCollapsed: next.navCollapsed, navWidth: next.navWidth, navGroupsCollapsed: next.navGroupsCollapsed, + sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; localStorage.setItem(KEY, JSON.stringify(persisted));