From fce155081401fb88b4ac2f8be4758084b378e05d Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 18 May 2026 00:53:36 +0800 Subject: [PATCH] fix(webui): refresh bootstrap token before expiry --- webui/src/App.tsx | 67 +++++++++++++++++++++++++++-- webui/src/tests/app-layout.test.tsx | 51 ++++++++++++++++++++-- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 591cf4a96..7ff9bae20 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -32,14 +32,30 @@ type BootState = status: "ready"; client: NanobotClient; token: string; + tokenExpiresAt: number; modelName: string | null; }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const SIDEBAR_WIDTH = 272; +const TOKEN_REFRESH_MARGIN_MS = 30_000; +const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; type ShellView = "chat" | "settings"; +function bootstrapTokenExpiresAt(expiresInSeconds: number): number { + return Date.now() + Math.max(0, expiresInSeconds) * 1000; +} + +function tokenRefreshDelayMs(expiresAt: number): number { + const remaining = Math.max(0, expiresAt - Date.now()); + const margin = Math.min( + TOKEN_REFRESH_MARGIN_MS, + Math.max(1_000, remaining / 2), + ); + return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin); +} + function AuthForm({ failed, onSecret, @@ -108,6 +124,7 @@ function readSidebarOpen(): boolean { export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); + const bootstrapSecretRef = useRef(""); const bootstrapWithSecret = useCallback( (secret: string) => { @@ -119,22 +136,37 @@ export default function App() { if (cancelled) return; if (secret) saveSecret(secret); const url = deriveWsUrl(boot.ws_path, boot.token); - const client = new NanobotClient({ + let client: NanobotClient; + client = new NanobotClient({ url, onReauth: async () => { try { - const refreshed = await fetchBootstrap("", secret); - return deriveWsUrl(refreshed.ws_path, refreshed.token); + const refreshed = await fetchBootstrap("", bootstrapSecretRef.current); + const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token); + const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in); + setState((current) => + current.status === "ready" && current.client === client + ? { + ...current, + token: refreshed.token, + tokenExpiresAt, + modelName: refreshed.model_name ?? current.modelName, + } + : current, + ); + return refreshedUrl; } catch { return null; } }, }); + bootstrapSecretRef.current = secret; client.connect(); setState({ status: "ready", client, token: boot.token, + tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), modelName: boot.model_name ?? null, }); } catch (e) { @@ -154,6 +186,35 @@ export default function App() { [], ); + useEffect(() => { + if (state.status !== "ready") return; + const client = state.client; + const timer = window.setTimeout(async () => { + try { + const boot = await fetchBootstrap("", bootstrapSecretRef.current); + const url = deriveWsUrl(boot.ws_path, boot.token); + const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in); + client.updateUrl(url); + setState((current) => + current.status === "ready" && current.client === client + ? { + ...current, + token: boot.token, + tokenExpiresAt, + modelName: boot.model_name ?? current.modelName, + } + : current, + ); + } catch (e) { + const msg = (e as Error).message; + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + setState({ status: "auth", failed: true }); + } + } + }, tokenRefreshDelayMs(state.tokenExpiresAt)); + return () => window.clearTimeout(timer); + }, [state]); + useEffect(() => { const saved = loadSavedSecret(); return bootstrapWithSecret(saved); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f6e3f8aec..e766bceec 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatSummary } from "@/lib/types"; @@ -8,6 +8,7 @@ const refreshSpy = vi.fn(); const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const deleteChatSpy = vi.fn(); const toggleThemeSpy = vi.fn(); +const updateUrlSpy = vi.fn(); let mockSessions: ChatSummary[] = []; vi.mock("@/hooks/useSessions", async (importOriginal) => { @@ -70,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => { newChat = vi.fn(); attach = vi.fn(); close = vi.fn(); - updateUrl = vi.fn(); + updateUrl = updateUrlSpy; } return { NanobotClient: MockClient }; }); +import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; import App from "@/App"; describe("App layout", () => { beforeEach(() => { mockSessions = []; connectSpy.mockClear(); + updateUrlSpy.mockClear(); refreshSpy.mockReset(); createChatSpy.mockClear(); deleteChatSpy.mockReset(); toggleThemeSpy.mockReset(); + vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ + token: "tok", + ws_path: "/", + expires_in: 300, + }); + vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -95,6 +104,10 @@ describe("App layout", () => { ); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("keeps sidebar layout out of the main thread width contract", async () => { const { container } = render(); @@ -479,4 +492,36 @@ describe("App layout", () => { expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument(); }); + + it("refreshes the bootstrap token before REST settings auth expires", async () => { + vi.useFakeTimers(); + vi.mocked(fetchBootstrap) + .mockResolvedValueOnce({ + token: "tok-1", + ws_path: "/", + expires_in: 30, + }) + .mockResolvedValueOnce({ + token: "tok-2", + ws_path: "/", + expires_in: 300, + }); + vi.mocked(deriveWsUrl).mockImplementation( + (_wsPath: string, token: string) => `ws://test?token=${token}`, + ); + + const { unmount } = render(); + await act(async () => {}); + + expect(connectSpy).toHaveBeenCalled(); + expect(fetchBootstrap).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + expect(fetchBootstrap).toHaveBeenCalledTimes(2); + expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2"); + unmount(); + }); });