mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
Scope Control UI sessions per gateway (#47453)
* Scope Control UI sessions per gateway Signed-off-by: sallyom <somalley@redhat.com> * Add changelog for Control UI session scoping Signed-off-by: sallyom <somalley@redhat.com> --------- Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UiSettings, "token"> & { token?: never };
|
||||
type ScopedSessionSelection = {
|
||||
sessionKey: string;
|
||||
lastActiveSessionKey: string;
|
||||
};
|
||||
|
||||
type PersistedUiSettings = Omit<UiSettings, "token" | "sessionKey" | "lastActiveSessionKey"> & {
|
||||
token?: never;
|
||||
sessionKey?: string;
|
||||
lastActiveSessionKey?: string;
|
||||
sessionsByGateway?: Record<string, ScopedSessionSelection>;
|
||||
};
|
||||
|
||||
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<UiSettings>;
|
||||
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<string, ScopedSessionSelection> = {};
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user