mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +00:00
fix(webui): refresh bootstrap token before expiry
This commit is contained in:
parent
af26ed0041
commit
fce1550814
@ -32,14 +32,30 @@ type BootState =
|
|||||||
status: "ready";
|
status: "ready";
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
token: string;
|
token: string;
|
||||||
|
tokenExpiresAt: number;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||||
const SIDEBAR_WIDTH = 272;
|
const SIDEBAR_WIDTH = 272;
|
||||||
|
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||||
|
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||||
type ShellView = "chat" | "settings";
|
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({
|
function AuthForm({
|
||||||
failed,
|
failed,
|
||||||
onSecret,
|
onSecret,
|
||||||
@ -108,6 +124,7 @@ function readSidebarOpen(): boolean {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||||
|
const bootstrapSecretRef = useRef("");
|
||||||
|
|
||||||
const bootstrapWithSecret = useCallback(
|
const bootstrapWithSecret = useCallback(
|
||||||
(secret: string) => {
|
(secret: string) => {
|
||||||
@ -119,22 +136,37 @@ export default function App() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (secret) saveSecret(secret);
|
if (secret) saveSecret(secret);
|
||||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||||
const client = new NanobotClient({
|
let client: NanobotClient;
|
||||||
|
client = new NanobotClient({
|
||||||
url,
|
url,
|
||||||
onReauth: async () => {
|
onReauth: async () => {
|
||||||
try {
|
try {
|
||||||
const refreshed = await fetchBootstrap("", secret);
|
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
bootstrapSecretRef.current = secret;
|
||||||
client.connect();
|
client.connect();
|
||||||
setState({
|
setState({
|
||||||
status: "ready",
|
status: "ready",
|
||||||
client,
|
client,
|
||||||
token: boot.token,
|
token: boot.token,
|
||||||
|
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||||
modelName: boot.model_name ?? null,
|
modelName: boot.model_name ?? null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
const saved = loadSavedSecret();
|
const saved = loadSavedSecret();
|
||||||
return bootstrapWithSecret(saved);
|
return bootstrapWithSecret(saved);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ const refreshSpy = vi.fn();
|
|||||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||||
const deleteChatSpy = vi.fn();
|
const deleteChatSpy = vi.fn();
|
||||||
const toggleThemeSpy = vi.fn();
|
const toggleThemeSpy = vi.fn();
|
||||||
|
const updateUrlSpy = vi.fn();
|
||||||
let mockSessions: ChatSummary[] = [];
|
let mockSessions: ChatSummary[] = [];
|
||||||
|
|
||||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||||
@ -70,22 +71,30 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
newChat = vi.fn();
|
newChat = vi.fn();
|
||||||
attach = vi.fn();
|
attach = vi.fn();
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = vi.fn();
|
updateUrl = updateUrlSpy;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { NanobotClient: MockClient };
|
return { NanobotClient: MockClient };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
|
|
||||||
describe("App layout", () => {
|
describe("App layout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSessions = [];
|
mockSessions = [];
|
||||||
connectSpy.mockClear();
|
connectSpy.mockClear();
|
||||||
|
updateUrlSpy.mockClear();
|
||||||
refreshSpy.mockReset();
|
refreshSpy.mockReset();
|
||||||
createChatSpy.mockClear();
|
createChatSpy.mockClear();
|
||||||
deleteChatSpy.mockReset();
|
deleteChatSpy.mockReset();
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||||
|
token: "tok",
|
||||||
|
ws_path: "/",
|
||||||
|
expires_in: 300,
|
||||||
|
});
|
||||||
|
vi.mocked(deriveWsUrl).mockReset().mockReturnValue("ws://test");
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValue({
|
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 () => {
|
it("keeps sidebar layout out of the main thread width contract", async () => {
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
|
|
||||||
@ -479,4 +492,36 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
|
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(<App />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user