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:
parent
6bec21bf00
commit
6101c023bb
@ -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.
|
- 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.
|
- 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
|
## 2026.3.11
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
- `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.
|
- `password` is kept in memory only.
|
||||||
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
|
- 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.
|
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
|
||||||
|
|||||||
@ -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 => ({
|
const createHost = (tab: Tab): SettingsHost => ({
|
||||||
settings: {
|
settings: {
|
||||||
gatewayUrl: "",
|
gatewayUrl: "",
|
||||||
@ -233,15 +276,44 @@ describe("setTabFromRoute", () => {
|
|||||||
describe("applySettingsFromUrl", () => {
|
describe("applySettingsFromUrl", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal("localStorage", createStorageMock());
|
vi.stubGlobal("localStorage", createStorageMock());
|
||||||
|
vi.stubGlobal("sessionStorage", createStorageMock());
|
||||||
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
||||||
|
setTestWindowUrl("https://control.example/ui/overview");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
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", () => {
|
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");
|
const host = createHost("chat");
|
||||||
host.settings = {
|
host.settings = {
|
||||||
...host.settings,
|
...host.settings,
|
||||||
@ -252,8 +324,6 @@ describe("applySettingsFromUrl", () => {
|
|||||||
};
|
};
|
||||||
host.sessionKey = "agent:test_old:main";
|
host.sessionKey = "agent:test_old:main";
|
||||||
|
|
||||||
window.history.replaceState({}, "", "/chat#token=test-token");
|
|
||||||
|
|
||||||
applySettingsFromUrl(host);
|
applySettingsFromUrl(host);
|
||||||
|
|
||||||
expect(host.sessionKey).toBe("main");
|
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", () => {
|
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");
|
const host = createHost("chat");
|
||||||
host.settings = {
|
host.settings = {
|
||||||
...host.settings,
|
...host.settings,
|
||||||
@ -272,8 +345,6 @@ describe("applySettingsFromUrl", () => {
|
|||||||
};
|
};
|
||||||
host.sessionKey = "agent:test_old:main";
|
host.sessionKey = "agent:test_old:main";
|
||||||
|
|
||||||
window.history.replaceState({}, "", "/chat?session=agent%3Atest_new%3Amain#token=test-token");
|
|
||||||
|
|
||||||
applySettingsFromUrl(host);
|
applySettingsFromUrl(host);
|
||||||
|
|
||||||
expect(host.sessionKey).toBe("agent:test_new:main");
|
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", () => {
|
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");
|
const host = createHost("chat");
|
||||||
host.settings = {
|
host.settings = {
|
||||||
...host.settings,
|
...host.settings,
|
||||||
@ -292,12 +366,6 @@ describe("applySettingsFromUrl", () => {
|
|||||||
};
|
};
|
||||||
host.sessionKey = "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);
|
applySettingsFromUrl(host);
|
||||||
|
|
||||||
expect(host.sessionKey).toBe("agent:test_old:main");
|
expect(host.sessionKey).toBe("agent:test_old:main");
|
||||||
|
|||||||
@ -97,7 +97,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
|||||||
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
|
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
|
||||||
const nextGatewayUrl = gatewayUrlRaw?.trim() ?? "";
|
const nextGatewayUrl = gatewayUrlRaw?.trim() ?? "";
|
||||||
const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl);
|
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 passwordRaw = params.get("password") ?? hashParams.get("password");
|
||||||
const sessionRaw = params.get("session") ?? hashParams.get("session");
|
const sessionRaw = params.get("session") ?? hashParams.get("session");
|
||||||
const shouldResetSessionForToken = Boolean(
|
const shouldResetSessionForToken = Boolean(
|
||||||
|
|||||||
@ -315,11 +315,11 @@ describe("control UI routing", () => {
|
|||||||
expect(container.scrollTop).toBe(maxScroll);
|
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");
|
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(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@ -405,6 +405,28 @@ describe("control UI routing", () => {
|
|||||||
expect(window.location.hash).toBe("");
|
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 () => {
|
it("restores the token after a same-tab refresh", async () => {
|
||||||
const first = mountApp("/ui/overview#token=abc123");
|
const first = mountApp("/ui/overview#token=abc123");
|
||||||
await first.updateComplete;
|
await first.updateComplete;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user