From c5194d8148170980656183725ec55d1f5950a10c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 6 Feb 2026 22:16:53 -0800 Subject: [PATCH] fix(dashboard): restore tokenized control ui links --- src/commands/dashboard.test.ts | 4 ++-- src/commands/dashboard.ts | 7 ++++++- src/commands/onboard-helpers.ts | 10 +++++++++- src/wizard/onboarding.finalize.ts | 24 +++++++++++++++++------- ui/src/ui/app-settings.ts | 25 ++++++++++++++++++------- ui/src/ui/navigation.browser.test.ts | 17 +++++++++++++---- 6 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index 32112c4d3..0f3201236 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -83,8 +83,8 @@ describe("dashboardCommand", () => { customBindHost: undefined, basePath: undefined, }); - expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/"); - expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/"); + expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123"); + expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123"); expect(runtime.log).toHaveBeenCalledWith( "Opened in your browser. Keep that tab to control OpenClaw.", ); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 01a08f1a3..deef34583 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -23,6 +23,7 @@ export async function dashboardCommand( const bind = cfg.gateway?.bind ?? "loopback"; const basePath = cfg.gateway?.controlUi?.basePath; const customBindHost = cfg.gateway?.customBindHost; + const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ""; const links = resolveControlUiLinks({ port, @@ -30,7 +31,10 @@ export async function dashboardCommand( customBindHost, basePath, }); - const dashboardUrl = links.httpUrl; + // Prefer URL fragment to avoid leaking auth tokens via query params. + const dashboardUrl = token + ? `${links.httpUrl}#token=${encodeURIComponent(token)}` + : links.httpUrl; runtime.log(`Dashboard URL: ${dashboardUrl}`); @@ -48,6 +52,7 @@ export async function dashboardCommand( hint = formatControlUiSshHint({ port, basePath, + token: token || undefined, }); } } else { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index f70c2dfb6..d708d5f97 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -179,16 +179,24 @@ export async function detectBrowserOpenSupport(): Promise { return { ok: true, command: resolved.command }; } -export function formatControlUiSshHint(params: { port: number; basePath?: string }): string { +export function formatControlUiSshHint(params: { + port: number; + basePath?: string; + token?: string; +}): string { const basePath = normalizeControlUiBasePath(params.basePath); const uiPath = basePath ? `${basePath}/` : "/"; const localUrl = `http://localhost:${params.port}${uiPath}`; + const authedUrl = params.token + ? `${localUrl}#token=${encodeURIComponent(params.token)}` + : undefined; const sshTarget = resolveSshTargetHint(); return [ "No GUI detected. Open from your computer:", `ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`, "Then open:", localUrl, + authedUrl, "Docs:", "https://docs.openclaw.ai/gateway/remote", "https://docs.openclaw.ai/web/control-ui", diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 568155169..fb5873f3d 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -255,7 +255,10 @@ export async function finalizeOnboardingWizard( customBindHost: settings.customBindHost, basePath: controlUiBasePath, }); - const dashboardUrl = links.httpUrl; + const authedUrl = + settings.authMode === "token" && settings.gatewayToken + ? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}` + : links.httpUrl; const gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined, @@ -275,7 +278,10 @@ export async function finalizeOnboardingWizard( await prompter.note( [ - `Web UI: ${dashboardUrl}`, + `Web UI: ${links.httpUrl}`, + settings.authMode === "token" && settings.gatewayToken + ? `Web UI (with token): ${authedUrl}` + : undefined, `Gateway WS: ${links.wsUrl}`, gatewayStatusLine, "Docs: https://docs.openclaw.ai/web/control-ui", @@ -312,7 +318,7 @@ export async function finalizeOnboardingWizard( `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).", `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, - "Paste the token into Control UI settings if prompted.", + "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", ].join("\n"), "Token", ); @@ -341,22 +347,24 @@ export async function finalizeOnboardingWizard( } else if (hatchChoice === "web") { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { - controlUiOpened = await openUrl(dashboardUrl); + controlUiOpened = await openUrl(authedUrl); if (!controlUiOpened) { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, }); } } else { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, }); } await prompter.note( [ - `Dashboard link: ${dashboardUrl}`, + `Dashboard link (with token): ${authedUrl}`, controlUiOpened ? "Opened in your browser. Keep that tab to control OpenClaw." : "Copy/paste this URL in a browser on this machine to control OpenClaw.", @@ -442,23 +450,25 @@ export async function finalizeOnboardingWizard( if (shouldOpenControlUi) { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { - controlUiOpened = await openUrl(dashboardUrl); + controlUiOpened = await openUrl(authedUrl); if (!controlUiOpened) { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, + token: settings.gatewayToken, }); } } else { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, basePath: controlUiBasePath, + token: settings.gatewayToken, }); } await prompter.note( [ - `Dashboard link: ${dashboardUrl}`, + `Dashboard link (with token): ${authedUrl}`, controlUiOpened ? "Opened in your browser. Keep that tab to control OpenClaw." : "Copy/paste this URL in a browser on this machine to control OpenClaw.", diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index bd74ad001..e0860e4e5 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -82,18 +82,26 @@ export function setLastActiveSessionKey(host: SettingsHost, next: string) { } export function applySettingsFromUrl(host: SettingsHost) { - if (!window.location.search) { + if (!window.location.search && !window.location.hash) { return; } - const params = new URLSearchParams(window.location.search); - const tokenRaw = params.get("token"); - const passwordRaw = params.get("password"); - const sessionRaw = params.get("session"); - const gatewayUrlRaw = params.get("gatewayUrl"); + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); + + const tokenRaw = params.get("token") ?? hashParams.get("token"); + const passwordRaw = params.get("password") ?? hashParams.get("password"); + const sessionRaw = params.get("session") ?? hashParams.get("session"); + const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); let shouldCleanUrl = false; if (tokenRaw != null) { + const token = tokenRaw.trim(); + if (token && token !== host.settings.token) { + applySettings(host, { ...host.settings, token }); + } params.delete("token"); + hashParams.delete("token"); shouldCleanUrl = true; } @@ -103,6 +111,7 @@ export function applySettingsFromUrl(host: SettingsHost) { (host as { password: string }).password = password; } params.delete("password"); + hashParams.delete("password"); shouldCleanUrl = true; } @@ -124,14 +133,16 @@ export function applySettingsFromUrl(host: SettingsHost) { host.pendingGatewayUrl = gatewayUrl; } params.delete("gatewayUrl"); + hashParams.delete("gatewayUrl"); shouldCleanUrl = true; } if (!shouldCleanUrl) { return; } - const url = new URL(window.location.href); url.search = params.toString(); + const nextHash = hashParams.toString(); + url.hash = nextHash ? `#${nextHash}` : ""; window.history.replaceState({}, "", url.toString()); } diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 02a3e247a..62e240fae 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -151,11 +151,11 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(maxScroll); }); - it("strips token URL params without importing them", async () => { + it("hydrates token from URL params and strips it", async () => { const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; - expect(app.settings.token).toBe(""); + expect(app.settings.token).toBe("abc123"); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -169,7 +169,7 @@ describe("control UI routing", () => { expect(window.location.search).toBe(""); }); - it("does not override stored settings from URL token params", async () => { + it("hydrates token from URL params even when settings already set", async () => { localStorage.setItem( "openclaw.control.settings.v1", JSON.stringify({ token: "existing-token" }), @@ -177,8 +177,17 @@ describe("control UI routing", () => { const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; - expect(app.settings.token).toBe("existing-token"); + expect(app.settings.token).toBe("abc123"); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); + + it("hydrates token from URL hash and strips it", async () => { + const app = mountApp("/ui/overview#token=abc123"); + await app.updateComplete; + + expect(app.settings.token).toBe("abc123"); + expect(window.location.pathname).toBe("/ui/overview"); + expect(window.location.hash).toBe(""); + }); });