fix(ui): restore control-ui query token compatibility (#43979)

* fix(ui): restore control-ui query token imports

* chore(changelog): add entry for openclaw#43979 thanks @stim64045-spec

---------

Co-authored-by: 大禹 <dayu@dayudeMac-mini.local>
Co-authored-by: Val Alexander <bunsthedev@gmail.com>
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
stim64045-spec
2026-03-17 17:03:35 +08:00
committed by GitHub
parent 6bec21bf00
commit 6101c023bb
5 changed files with 107 additions and 15 deletions

View File

@@ -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

View File

@@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; it is not stored in localStorage.
- `token` is preferably imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; legacy `?token=` query params are also imported once for compatibility and then removed.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.

View File

@@ -89,6 +89,49 @@ function createStorageMock(): Storage {
};
}
function setTestWindowUrl(urlString: string) {
const current = new URL(urlString);
const history = {
replaceState: vi.fn((_state: unknown, _title: string, nextUrl: string | URL) => {
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");

View File

@@ -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(

View File

@@ -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<HTMLButtonElement>("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;