diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b33d3f4947..61272b7a77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -296,6 +296,8 @@ Docs: https://docs.openclaw.ai - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. - Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit. +- Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec. + ## 2026.3.11 ### Security diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index d35b245d814..3ad5feb80b4 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://:18789#token= { + const next = new URL(String(nextUrl), current.toString()); + current.href = next.toString(); + current.protocol = next.protocol; + current.host = next.host; + current.pathname = next.pathname; + current.search = next.search; + current.hash = next.hash; + }), + }; + const locationLike = { + get href() { + return current.toString(); + }, + get protocol() { + return current.protocol; + }, + get host() { + return current.host; + }, + get pathname() { + return current.pathname; + }, + get search() { + return current.search; + }, + get hash() { + return current.hash; + }, + }; + vi.stubGlobal("window", { + location: locationLike, + history, + setInterval, + clearInterval, + } as unknown as Window & typeof globalThis); + vi.stubGlobal("location", locationLike as Location); + return { history, location: locationLike }; +} + const createHost = (tab: Tab): SettingsHost => ({ settings: { gatewayUrl: "", @@ -233,15 +276,44 @@ describe("setTabFromRoute", () => { describe("applySettingsFromUrl", () => { beforeEach(() => { vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + setTestWindowUrl("https://control.example/ui/overview"); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); - window.history.replaceState({}, "", "/chat"); + }); + + it("hydrates query token params and strips them from the URL", () => { + setTestWindowUrl("https://control.example/ui/overview?token=abc123"); + const host = createHost("overview"); + host.settings.gatewayUrl = "wss://control.example/openclaw"; + + applySettingsFromUrl(host); + + expect(host.settings.token).toBe("abc123"); + expect(window.location.search).toBe(""); + }); + + it("keeps query token params pending when a gatewayUrl confirmation is required", () => { + setTestWindowUrl( + "https://control.example/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123", + ); + const host = createHost("overview"); + host.settings.gatewayUrl = "wss://control.example/openclaw"; + + applySettingsFromUrl(host); + + expect(host.settings.token).toBe(""); + expect(host.pendingGatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(host.pendingGatewayToken).toBe("abc123"); + expect(window.location.search).toBe(""); }); it("resets stale persisted session selection to main when a token is supplied without a session", () => { + setTestWindowUrl("https://control.example/chat#token=test-token"); const host = createHost("chat"); host.settings = { ...host.settings, @@ -252,8 +324,6 @@ describe("applySettingsFromUrl", () => { }; host.sessionKey = "agent:test_old:main"; - window.history.replaceState({}, "", "/chat#token=test-token"); - applySettingsFromUrl(host); expect(host.sessionKey).toBe("main"); @@ -262,6 +332,9 @@ describe("applySettingsFromUrl", () => { }); it("preserves an explicit session from the URL when token and session are both supplied", () => { + setTestWindowUrl( + "https://control.example/chat?session=agent%3Atest_new%3Amain#token=test-token", + ); const host = createHost("chat"); host.settings = { ...host.settings, @@ -272,8 +345,6 @@ describe("applySettingsFromUrl", () => { }; 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"); @@ -282,6 +353,9 @@ describe("applySettingsFromUrl", () => { }); it("does not reset the current gateway session when a different gateway is pending confirmation", () => { + setTestWindowUrl( + "https://control.example/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=test-token", + ); const host = createHost("chat"); host.settings = { ...host.settings, @@ -292,12 +366,6 @@ describe("applySettingsFromUrl", () => { }; 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"); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 2a9c2685589..bd924915b76 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -97,7 +97,7 @@ export function applySettingsFromUrl(host: SettingsHost) { 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 tokenRaw = hashParams.get("token") ?? params.get("token"); const passwordRaw = params.get("password") ?? hashParams.get("password"); const sessionRaw = params.get("session") ?? hashParams.get("session"); const shouldResetSessionForToken = Boolean( diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 5251eda790c..3407288c03d 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -315,11 +315,11 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(maxScroll); }); - it("strips query token params without importing them", async () => { + it("hydrates token from query params and strips them", async () => { const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; - expect(app.settings.token).toBe(""); + expect(app.settings.token).toBe("abc123"); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( undefined, ); @@ -405,6 +405,28 @@ describe("control UI routing", () => { expect(window.location.hash).toBe(""); }); + it("keeps a query 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("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;