fix(dashboard): respect HERMES_BASE_PATH in WebSocket URLs (#25547)

When the dashboard is reverse-proxied under a path prefix
(`X-Forwarded-Prefix: /dashboard`), the SPA already routes its
`/api/...` REST traffic through `HERMES_BASE_PATH` via
`web/src/lib/api.ts`. Three WebSocket URLs constructed elsewhere
were still hardcoded to root `/api/...` and so opened
`wss://host/api/...` instead of `wss://host/dashboard/api/...`,
forcing operators to forward selected root API/WS paths through the
reverse proxy as a workaround (see issue #25547).

Add `HERMES_BASE_PATH` between `host` and `/api/...` in the
three constructed WebSocket URLs:

- `web/src/pages/ChatPage.tsx` — PTY WebSocket
- `web/src/components/ChatSidebar.tsx` — events subscriber
- `web/src/lib/gatewayClient.ts` — JSON-RPC gateway WebSocket

When the dashboard is served at root, `HERMES_BASE_PATH === """
and the URLs are bit-for-bit identical to before. Under a prefix,
the WebSocket connections now go through the same proxy path the
REST calls already use.

Note: bundled dashboard plugins (kanban, hermes-achievements) embed
`"/api/plugins/..."` in their compiled `dist/index.js` and
remain out of scope here — those need source-side fixes per plugin.

Fixes #25547.
This commit is contained in:
wesleysimplicio 2026-05-17 11:36:29 -07:00 committed by Teknium
parent 714b3b2bd8
commit 74031e1e2a
3 changed files with 7 additions and 3 deletions

View file

@ -30,6 +30,7 @@ import { Card } from "@/components/ui/card";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { HERMES_BASE_PATH } from "@/lib/api";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
@ -160,7 +161,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
const ws = new WebSocket(
`${proto}//${window.location.host}/api/events?${qs.toString()}`,
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
);
// `unmounting` suppresses the banner during cleanup — `ws.close()`