diff --git a/server/src/__tests__/app-hmr-port.test.ts b/server/src/__tests__/app-hmr-port.test.ts index 2f25d3ab8f3..f6e6ac1cf6d 100644 --- a/server/src/__tests__/app-hmr-port.test.ts +++ b/server/src/__tests__/app-hmr-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveViteHmrPort } from "../app.ts"; +import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts"; describe("resolveViteHmrPort", () => { it("uses serverPort + 10000 when the result stays in range", () => { @@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => { expect(resolveViteHmrPort(9_000)).toBe(19_000); }); }); + +describe("resolveViteHmrHost", () => { + it("omits wildcard bind hosts so Vite uses the browser hostname", () => { + expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined(); + expect(resolveViteHmrHost("::")).toBeUndefined(); + }); + + it("keeps concrete bind hosts", () => { + expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1"); + expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev"); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 421a72a4390..a7c9a4ed630 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -96,6 +96,12 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } +export function resolveViteHmrHost(bindHost: string): string | undefined { + const normalized = bindHost.trim().toLowerCase(); + if (normalized === "0.0.0.0" || normalized === "::") return undefined; + return bindHost; +} + export function shouldServeViteDevHtml(req: ExpressRequest): boolean { const pathname = req.path; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; @@ -373,6 +379,7 @@ export async function createApp( const uiRoot = path.resolve(__dirname, "../../ui"); const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); + const hmrHost = resolveViteHmrHost(opts.bindHost); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: uiRoot, @@ -380,7 +387,7 @@ export async function createApp( server: { middlewareMode: true, hmr: { - host: opts.bindHost, + ...(hmrHost ? { host: hmrHost } : {}), port: hmrPort, clientPort: hmrPort, }, diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 74f94256d8e..6c001e2fae4 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings"; import { heartbeatsApi } from "../../api/heartbeats"; import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters"; import { queryKeys } from "../../lib/queryKeys"; +import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url"; const LOG_POLL_INTERVAL_MS = 2000; const LOG_READ_LIMIT_BYTES = 256_000; @@ -279,8 +280,9 @@ export function useLiveRunTranscripts({ const connect = () => { if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; + const url = buildSameOriginWebSocketUrl( + `/api/companies/${encodeURIComponent(companyId)}/events/ws`, + ); socket = new WebSocket(url); socket.onmessage = (message) => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 783537e0708..376a92ad5db 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss import { queryKeys } from "../lib/queryKeys"; import { toCompanyRelativePath } from "../lib/company-routes"; import { useLocation } from "../lib/router"; +import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; const TOAST_COOLDOWN_WINDOW_MS = 10_000; const TOAST_COOLDOWN_MAX = 3; @@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const connect = () => { if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; + const url = buildSameOriginWebSocketUrl( + `/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`, + ); const nextSocket = new WebSocket(url); socket = nextSocket; diff --git a/ui/src/lib/websocket-url.test.ts b/ui/src/lib/websocket-url.test.ts new file mode 100644 index 00000000000..5971bd8cc28 --- /dev/null +++ b/ui/src/lib/websocket-url.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { browserReachableHost, buildSameOriginWebSocketUrl } from "./websocket-url"; + +describe("browserReachableHost", () => { + it("keeps concrete browser hosts unchanged", () => { + expect(browserReachableHost({ + protocol: "http:", + hostname: "paperclip-dev", + host: "paperclip-dev:46259", + port: "46259", + })).toBe("paperclip-dev:46259"); + }); + + it("rewrites wildcard IPv4 bind hosts to localhost", () => { + expect(browserReachableHost({ + protocol: "http:", + hostname: "0.0.0.0", + host: "0.0.0.0:46259", + port: "46259", + })).toBe("localhost:46259"); + }); + + it("rewrites wildcard IPv6 bind hosts to localhost", () => { + expect(browserReachableHost({ + protocol: "http:", + hostname: "::", + host: "[::]:46259", + port: "46259", + })).toBe("localhost:46259"); + }); +}); + +describe("buildSameOriginWebSocketUrl", () => { + it("uses wss for https pages", () => { + expect(buildSameOriginWebSocketUrl("/api/events/ws", { + protocol: "https:", + hostname: "example.com", + host: "example.com", + port: "", + })).toBe("wss://example.com/api/events/ws"); + }); + + it("does not emit 0.0.0.0 websocket URLs", () => { + expect(buildSameOriginWebSocketUrl("api/events/ws", { + protocol: "http:", + hostname: "0.0.0.0", + host: "0.0.0.0:46259", + port: "46259", + })).toBe("ws://localhost:46259/api/events/ws"); + }); +}); diff --git a/ui/src/lib/websocket-url.ts b/ui/src/lib/websocket-url.ts new file mode 100644 index 00000000000..a82ffe6db77 --- /dev/null +++ b/ui/src/lib/websocket-url.ts @@ -0,0 +1,20 @@ +type BrowserLocationLike = Pick; + +function isWildcardHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]"; +} + +export function browserReachableHost(location: BrowserLocationLike = window.location): string { + if (!isWildcardHost(location.hostname)) return location.host; + return location.port ? `localhost:${location.port}` : "localhost"; +} + +export function buildSameOriginWebSocketUrl( + path: string, + location: BrowserLocationLike = window.location, +): string { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${protocol}://${browserReachableHost(location)}${normalizedPath}`; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 699e5631062..81ab1cf864e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom"; import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout"; import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge"; import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold"; +import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; import { describeRunRetryState } from "../lib/runRetryState"; @@ -3865,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const connect = () => { if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`; + const url = buildSameOriginWebSocketUrl( + `/api/companies/${encodeURIComponent(run.companyId)}/events/ws`, + ); socket = new WebSocket(url); socket.onopen = () => {