fix(dashboard): restore tokenized control ui links

main
Peter Steinberger 2026-02-06 22:16:53 -08:00
parent e78ae48e69
commit c5194d8148
6 changed files with 65 additions and 22 deletions

View File

@ -83,8 +83,8 @@ describe("dashboardCommand", () => {
customBindHost: undefined, customBindHost: undefined,
basePath: undefined, basePath: undefined,
}); });
expect(mocks.copyToClipboard).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/"); expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
"Opened in your browser. Keep that tab to control OpenClaw.", "Opened in your browser. Keep that tab to control OpenClaw.",
); );

View File

@ -23,6 +23,7 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback"; const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath; const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost; const customBindHost = cfg.gateway?.customBindHost;
const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const links = resolveControlUiLinks({ const links = resolveControlUiLinks({
port, port,
@ -30,7 +31,10 @@ export async function dashboardCommand(
customBindHost, customBindHost,
basePath, 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}`); runtime.log(`Dashboard URL: ${dashboardUrl}`);
@ -48,6 +52,7 @@ export async function dashboardCommand(
hint = formatControlUiSshHint({ hint = formatControlUiSshHint({
port, port,
basePath, basePath,
token: token || undefined,
}); });
} }
} else { } else {

View File

@ -179,16 +179,24 @@ export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
return { ok: true, command: resolved.command }; 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 basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/"; const uiPath = basePath ? `${basePath}/` : "/";
const localUrl = `http://localhost:${params.port}${uiPath}`; const localUrl = `http://localhost:${params.port}${uiPath}`;
const authedUrl = params.token
? `${localUrl}#token=${encodeURIComponent(params.token)}`
: undefined;
const sshTarget = resolveSshTargetHint(); const sshTarget = resolveSshTargetHint();
return [ return [
"No GUI detected. Open from your computer:", "No GUI detected. Open from your computer:",
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`, `ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
"Then open:", "Then open:",
localUrl, localUrl,
authedUrl,
"Docs:", "Docs:",
"https://docs.openclaw.ai/gateway/remote", "https://docs.openclaw.ai/gateway/remote",
"https://docs.openclaw.ai/web/control-ui", "https://docs.openclaw.ai/web/control-ui",

View File

@ -255,7 +255,10 @@ export async function finalizeOnboardingWizard(
customBindHost: settings.customBindHost, customBindHost: settings.customBindHost,
basePath: controlUiBasePath, 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({ const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl, url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined, token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@ -275,7 +278,10 @@ export async function finalizeOnboardingWizard(
await prompter.note( 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}`, `Gateway WS: ${links.wsUrl}`,
gatewayStatusLine, gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui", "Docs: https://docs.openclaw.ai/web/control-ui",
@ -312,7 +318,7 @@ export async function finalizeOnboardingWizard(
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
"Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).", "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
`Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, `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"), ].join("\n"),
"Token", "Token",
); );
@ -341,22 +347,24 @@ export async function finalizeOnboardingWizard(
} else if (hatchChoice === "web") { } else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
controlUiOpened = await openUrl(dashboardUrl); controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) { if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
}); });
} }
} else { } else {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
}); });
} }
await prompter.note( await prompter.note(
[ [
`Dashboard link: ${dashboardUrl}`, `Dashboard link (with token): ${authedUrl}`,
controlUiOpened controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw." ? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine 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) { if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
controlUiOpened = await openUrl(dashboardUrl); controlUiOpened = await openUrl(authedUrl);
if (!controlUiOpened) { if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.gatewayToken,
}); });
} }
} else { } else {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.gatewayToken,
}); });
} }
await prompter.note( await prompter.note(
[ [
`Dashboard link: ${dashboardUrl}`, `Dashboard link (with token): ${authedUrl}`,
controlUiOpened controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw." ? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.", : "Copy/paste this URL in a browser on this machine to control OpenClaw.",

View File

@ -82,18 +82,26 @@ export function setLastActiveSessionKey(host: SettingsHost, next: string) {
} }
export function applySettingsFromUrl(host: SettingsHost) { export function applySettingsFromUrl(host: SettingsHost) {
if (!window.location.search) { if (!window.location.search && !window.location.hash) {
return; return;
} }
const params = new URLSearchParams(window.location.search); const url = new URL(window.location.href);
const tokenRaw = params.get("token"); const params = new URLSearchParams(url.search);
const passwordRaw = params.get("password"); const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl"); 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; let shouldCleanUrl = false;
if (tokenRaw != null) { if (tokenRaw != null) {
const token = tokenRaw.trim();
if (token && token !== host.settings.token) {
applySettings(host, { ...host.settings, token });
}
params.delete("token"); params.delete("token");
hashParams.delete("token");
shouldCleanUrl = true; shouldCleanUrl = true;
} }
@ -103,6 +111,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
(host as { password: string }).password = password; (host as { password: string }).password = password;
} }
params.delete("password"); params.delete("password");
hashParams.delete("password");
shouldCleanUrl = true; shouldCleanUrl = true;
} }
@ -124,14 +133,16 @@ export function applySettingsFromUrl(host: SettingsHost) {
host.pendingGatewayUrl = gatewayUrl; host.pendingGatewayUrl = gatewayUrl;
} }
params.delete("gatewayUrl"); params.delete("gatewayUrl");
hashParams.delete("gatewayUrl");
shouldCleanUrl = true; shouldCleanUrl = true;
} }
if (!shouldCleanUrl) { if (!shouldCleanUrl) {
return; return;
} }
const url = new URL(window.location.href);
url.search = params.toString(); url.search = params.toString();
const nextHash = hashParams.toString();
url.hash = nextHash ? `#${nextHash}` : "";
window.history.replaceState({}, "", url.toString()); window.history.replaceState({}, "", url.toString());
} }

View File

@ -151,11 +151,11 @@ describe("control UI routing", () => {
expect(container.scrollTop).toBe(maxScroll); 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"); const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete; await app.updateComplete;
expect(app.settings.token).toBe(""); expect(app.settings.token).toBe("abc123");
expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
}); });
@ -169,7 +169,7 @@ describe("control UI routing", () => {
expect(window.location.search).toBe(""); 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( localStorage.setItem(
"openclaw.control.settings.v1", "openclaw.control.settings.v1",
JSON.stringify({ token: "existing-token" }), JSON.stringify({ token: "existing-token" }),
@ -177,8 +177,17 @@ describe("control UI routing", () => {
const app = mountApp("/ui/overview?token=abc123"); const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete; 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.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); 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("");
});
}); });