Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/shared/src/json-rpc-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface GatewayClientOptions {
connectErrorMessage?: string
connectTimeoutMs?: number
createRequestId?: (nextId: number) => GatewayRequestId
/** Return true to intercept the default closed-state transition. */
onSocketClose?: (event: CloseEvent) => boolean | void
requestIdPrefix?: string
requestTimeoutMs?: number
socketFactory?: (url: string) => WebSocketLike
Expand Down Expand Up @@ -83,6 +85,7 @@ export class JsonRpcGatewayClient {
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected',
onSocketClose: options.onSocketClose ?? (() => false),
requestIdPrefix: options.requestIdPrefix ?? 'r',
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
socketFactory: options.socketFactory
Expand Down Expand Up @@ -111,11 +114,15 @@ export class JsonRpcGatewayClient {
this.handleMessage(message.data)
})

socket.addEventListener('close', () => {
socket.addEventListener('close', event => {
if (this.socket !== socket) {
return
}

if (this.options.onSocketClose(event)) {
return
}

this.socket = null
this.setState('closed')
this.rejectAllPending(new Error(this.options.closedErrorMessage))
Expand Down
133 changes: 133 additions & 0 deletions web/src/components/ChatSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const apiMocks = vi.hoisted(() => ({
buildWsUrl: vi.fn(async () => "ws://localhost/api/events?channel=chat-1"),
getModelInfo: vi.fn(async () => ({
capabilities: { supports_reasoning: false },
model: "test/model",
})),
}));

const gatewayMocks = vi.hoisted(() => ({
close: vi.fn(),
connect: vi.fn(async () => undefined),
on: vi.fn(() => () => undefined),
onState: vi.fn((handler: (state: string) => void) => {
handler("open");
return () => undefined;
}),
request: vi.fn(async () => ({ session_id: "sidecar-1" })),
}));

const reloadMocks = vi.hoisted(() => ({
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true),
}));

vi.mock("@/lib/api", () => ({
api: { getModelInfo: apiMocks.getModelInfo },
buildWsUrl: apiMocks.buildWsUrl,
}));
vi.mock("@/lib/dashboard-auth-reload", () => ({
maybeReloadForLoopbackWsAuthFailure:
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
}));
vi.mock("@/lib/gatewayClient", () => ({
GatewayClient: class {
close = gatewayMocks.close;
connect = gatewayMocks.connect;
on = gatewayMocks.on;
onState = gatewayMocks.onState;
request = gatewayMocks.request;
},
}));
vi.mock("@/components/ModelPickerDialog", () => ({
ModelPickerDialog: () => null,
}));
vi.mock("@/components/ModelReloadConfirm", () => ({
ModelReloadConfirm: () => null,
}));
vi.mock("@/components/ReasoningPicker", () => ({
ReasoningPicker: () => null,
}));
vi.mock("@nous-research/ui/ui/components/button", () => ({
Button: ({ children }: { children?: ReactNode }) => <button>{children}</button>,
}));
vi.mock("@nous-research/ui/ui/components/badge", () => ({
Badge: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}));
vi.mock("@nous-research/ui/ui/components/card", () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));

type EventLike = { code?: number; data?: string };

class FakeWebSocket {
static instances: FakeWebSocket[] = [];

private listeners = new Map<string, Array<(event: EventLike) => void>>();
readonly url: string;

constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}

addEventListener(type: string, listener: (event: EventLike) => void) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}

close() {}

emit(type: string, event: EventLike) {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}

let container: HTMLDivElement;
let root: Root;

async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(ui));
}

beforeEach(() => {
FakeWebSocket.instances = [];
vi.clearAllMocks();
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
vi.stubGlobal("WebSocket", FakeWebSocket);
});

afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
vi.unstubAllGlobals();
});

describe("ChatSidebar event socket", () => {
it("routes loopback 4401 closes through stale-token recovery", async () => {
const { ChatSidebar } = await import("./ChatSidebar");

await render(<ChatSidebar channel="chat-1" />);

await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
expect(apiMocks.buildWsUrl).toHaveBeenCalledWith("/api/events", {
channel: "chat-1",
});

FakeWebSocket.instances[0].emit("close", { code: 4401 });

expect(
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
).toHaveBeenCalledWith(4401);
});
});
4 changes: 4 additions & 0 deletions web/src/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import { ReasoningPicker } from "@/components/ReasoningPicker";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { api, buildWsUrl } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import { titleFromSessionInfoPayload } from "@/lib/chat-title";

import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -86,7 +87,7 @@
* can be tested without reading component source text. See
* ``chat-sidebar-session-params.test.ts``.
*/
export function sidecarSessionCreateParams(profile?: string): Record<string, unknown> {

Check warning on line 90 in web/src/components/ChatSidebar.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / Typecheck & Test (web)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
return {
close_on_disconnect: true,
source: "tool",
Expand Down Expand Up @@ -258,6 +259,9 @@
ws.addEventListener("error", () => surface(DISCONNECTED));

ws.addEventListener("close", (ev) => {
if (maybeReloadForLoopbackWsAuthFailure(ev.code)) {
return;
}
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
Expand Down
73 changes: 71 additions & 2 deletions web/src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { api } from "./api";
import { api, fetchJSON } from "./api";

const reloadMocks = vi.hoisted(() => ({
attemptDashboardTokenReloadOnce: vi.fn(() => false),
clearDashboardTokenReloadAttempt: vi.fn(),
}));

vi.mock("./dashboard-auth-reload", () => ({
attemptDashboardTokenReloadOnce: reloadMocks.attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt: reloadMocks.clearDashboardTokenReloadAttempt,
}));

const SESSION_HEADER = "X-Hermes-Session-Token";

beforeEach(() => {
reloadMocks.attemptDashboardTokenReloadOnce.mockReset();
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(false);
reloadMocks.clearDashboardTokenReloadAttempt.mockReset();

Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
configurable: true,
value: "stale-token",
writable: true,
});
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
configurable: true,
value: false,
writable: true,
});
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
Expand All @@ -19,6 +47,47 @@ function jsonFetchMock(body: unknown = { ok: true }) {
);
}

describe("fetchJSON", () => {
it("tries the one-shot reload path for loopback 401s", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
clone: () => ({
json: async () => ({}),
}),
ok: false,
status: 401,
statusText: "Unauthorized",
text: async () => "Unauthorized",
})),
);
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(true);

const pending = fetchJSON("/api/status");
await expect(Promise.race([pending, Promise.resolve("pending")])).resolves.toBe(
"pending",
);

expect(reloadMocks.attemptDashboardTokenReloadOnce).toHaveBeenCalledTimes(1);
expect(reloadMocks.clearDashboardTokenReloadAttempt).not.toHaveBeenCalled();
});

it("clears the reload latch after a successful response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
json: async () => ({ ok: true }),
ok: true,
status: 200,
})),
);

await expect(fetchJSON("/api/status")).resolves.toEqual({ ok: true });

expect(reloadMocks.clearDashboardTokenReloadAttempt).toHaveBeenCalledTimes(1);
});
});

describe("api.getModelOptions", () => {
it("requests a live model refresh when asked", async () => {
vi.stubGlobal("window", {});
Expand Down
25 changes: 6 additions & 19 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const HERMES_BASE_PATH = readBasePath();
const BASE = HERMES_BASE_PATH;

import type { DashboardTheme } from "@/themes/types";
import {
attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt,
} from "@/lib/dashboard-auth-reload";

// Ephemeral session token for protected endpoints.
// Injected into index.html by the server — never fetched via API.
Expand Down Expand Up @@ -156,20 +160,7 @@ export async function fetchJSON<T>(
// handled above, so reaching here in gated mode means a real
// middleware failure that should not reload-loop.
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
let alreadyReloaded = false;
try {
alreadyReloaded =
sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
} catch {
/* SSR / privacy mode — fall through to throw */
}
if (!alreadyReloaded) {
try {
sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
} catch {
/* SSR / privacy mode — best effort */
}
window.location.reload();
if (attemptDashboardTokenReloadOnce()) {
return new Promise<T>(() => {});
}
}
Expand All @@ -178,11 +169,7 @@ export async function fetchJSON<T>(
// Clear the stale-token reload guard: a successful 2xx proves the
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
// 401 — if any — should be allowed to trigger its own reload cycle.
try {
sessionStorage.removeItem("hermes.tokenReloadAttempted");
} catch {
/* SSR / privacy mode — ignore */
}
clearDashboardTokenReloadAttempt();
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
Expand Down
70 changes: 70 additions & 0 deletions web/src/lib/dashboard-auth-reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";

import {
attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt,
maybeReloadForLoopbackWsAuthFailure,
} from "./dashboard-auth-reload";

function makeStorage() {
const values = new Map<string, string>();
return {
getItem(key: string) {
return values.get(key) ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
};
}

describe("attemptDashboardTokenReloadOnce", () => {
it("reloads once and latches the attempt", () => {
const storage = makeStorage();
const reload = vi.fn();

expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);

expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});

it("clears the latch when asked", () => {
const storage = makeStorage();
const reload = vi.fn();

expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
clearDashboardTokenReloadAttempt(storage);
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
expect(reload).toHaveBeenCalledTimes(2);
});
});

describe("maybeReloadForLoopbackWsAuthFailure", () => {
it("reloads once for loopback 4401 closes", () => {
const storage = makeStorage();
const reload = vi.fn();

expect(
maybeReloadForLoopbackWsAuthFailure(4401, false, storage, reload),
).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
});

it("does not reload in gated mode or for other close codes", () => {
const storage = makeStorage();
const reload = vi.fn();

expect(
maybeReloadForLoopbackWsAuthFailure(4401, true, storage, reload),
).toBe(false);
expect(
maybeReloadForLoopbackWsAuthFailure(4403, false, storage, reload),
).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
Loading
Loading