diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 66115e62a0339..d4000f63d2cdb 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,3 +1,9 @@ +// FIRST side-effect: repair the same-origin WebSocket base for the plain-web +// served bundle before the `client` singleton can dial its socket. The dev +// server injects a desktop-loopback `__ELIZA_WS_BASE__` (ws://127.0.0.1:31337) +// that client-base reads first; on a reverse-proxied web page the socket must +// be same-origin (wss:///ws). No-op on desktop / native. See module. +import "./web-ws-base-fix"; /** * Renderer boot entry and composition root for the cross-platform Eliza app * shell (web browser, Electrobun desktop, and Capacitor iOS/Android). Runs diff --git a/packages/app/src/web-ws-base-fix.test.ts b/packages/app/src/web-ws-base-fix.test.ts new file mode 100644 index 0000000000000..2c8c234e68ea6 --- /dev/null +++ b/packages/app/src/web-ws-base-fix.test.ts @@ -0,0 +1,148 @@ +/** + * Plain-web boot shim coverage for same-origin API and WebSocket repair. + * + * The module runs as the first side-effect import in the renderer entrypoint, + * so these tests import it fresh per case with mocked platform detectors and a + * controlled browser location. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const platformState = vi.hoisted(() => ({ + isElectrobun: false, + isNative: false, + setElizaApiBase: vi.fn(), +})); + +vi.mock("@capacitor/core", () => ({ + Capacitor: { + isNativePlatform: () => platformState.isNative, + }, +})); + +vi.mock("@elizaos/ui/bridge", () => ({ + isElectrobunRuntime: () => platformState.isElectrobun, +})); + +vi.mock("@elizaos/shared", () => ({ + setElizaApiBase: platformState.setElizaApiBase, +})); + +function setLocation(url: string): void { + const parsed = new URL(url); + vi.stubGlobal("location", { + protocol: parsed.protocol, + host: parsed.host, + hostname: parsed.hostname, + }); +} + +function setGlobal(key: string, value: unknown): void { + (window as unknown as Record)[key] = value; +} + +function getGlobal(key: string): unknown { + return (window as unknown as Record)[key]; +} + +async function importFreshShim(): Promise { + vi.resetModules(); + await import("./web-ws-base-fix"); +} + +beforeEach(() => { + platformState.isElectrobun = false; + platformState.isNative = false; + platformState.setElizaApiBase.mockReset(); + for (const key of [ + "__ELIZA_WS_BASE__", + "__ELIZAOS_WS_BASE__", + "__ACME_WS_BASE__", + "__ELIZA_APP_API_BASE__", + ]) { + Reflect.deleteProperty(window, key); + } +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("web same-origin WS base repair", () => { + it("rewrites desktop-loopback WS globals and API base on remote https plain web", async () => { + setLocation("https://app.example.test/dashboard"); + setGlobal("__ELIZA_WS_BASE__", "ws://127.0.0.1:31337"); + setGlobal("__ELIZAOS_WS_BASE__", "http://127.0.0.1:31337"); + setGlobal("__ACME_WS_BASE__", "ws://127.0.0.1:31337"); + setGlobal("__ELIZA_APP_API_BASE__", "https://brand.example.test"); + + await importFreshShim(); + + expect(getGlobal("__ELIZA_WS_BASE__")).toBe("wss://app.example.test"); + expect(getGlobal("__ELIZAOS_WS_BASE__")).toBe("wss://app.example.test"); + expect(getGlobal("__ACME_WS_BASE__")).toBe("wss://app.example.test"); + expect(getGlobal("__ELIZA_APP_API_BASE__")).toBe( + "https://brand.example.test", + ); + expect(platformState.setElizaApiBase).toHaveBeenCalledWith( + "https://app.example.test", + ); + }); + + it("uses ws/http same-origin bases for remote plain http", async () => { + setLocation("http://preview.example.test/chat"); + setGlobal("__ELIZA_WS_BASE__", "ws://127.0.0.1:31337"); + + await importFreshShim(); + + expect(getGlobal("__ELIZA_WS_BASE__")).toBe("ws://preview.example.test"); + expect(getGlobal("__ELIZAOS_WS_BASE__")).toBe("ws://preview.example.test"); + expect(platformState.setElizaApiBase).toHaveBeenCalledWith( + "http://preview.example.test", + ); + }); + + it("does not rewrite loopback browser sessions", async () => { + setLocation("http://localhost:2138"); + setGlobal("__ELIZA_WS_BASE__", "ws://127.0.0.1:31337"); + + await importFreshShim(); + + expect(getGlobal("__ELIZA_WS_BASE__")).toBe("ws://127.0.0.1:31337"); + expect(getGlobal("__ELIZAOS_WS_BASE__")).toBeUndefined(); + expect(platformState.setElizaApiBase).not.toHaveBeenCalled(); + }); + + it("does not rewrite Electrobun desktop sessions", async () => { + platformState.isElectrobun = true; + setLocation("https://desktop-shell.example.test"); + setGlobal("__ELIZA_WS_BASE__", "ws://127.0.0.1:31337"); + + await importFreshShim(); + + expect(getGlobal("__ELIZA_WS_BASE__")).toBe("ws://127.0.0.1:31337"); + expect(platformState.setElizaApiBase).not.toHaveBeenCalled(); + }); + + it("does not rewrite Capacitor native sessions", async () => { + platformState.isNative = true; + setLocation("https://native-shell.example.test"); + setGlobal("__ELIZA_WS_BASE__", "ws://127.0.0.1:31337"); + + await importFreshShim(); + + expect(getGlobal("__ELIZA_WS_BASE__")).toBe("ws://127.0.0.1:31337"); + expect(platformState.setElizaApiBase).not.toHaveBeenCalled(); + }); + + it("does not second-guess an already secure injected WS base", async () => { + setLocation("https://app.example.test"); + setGlobal("__ELIZA_WS_BASE__", "wss://api.example.test"); + + await importFreshShim(); + + expect(getGlobal("__ELIZA_WS_BASE__")).toBe("wss://api.example.test"); + expect(getGlobal("__ELIZAOS_WS_BASE__")).toBeUndefined(); + expect(platformState.setElizaApiBase).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/web-ws-base-fix.ts b/packages/app/src/web-ws-base-fix.ts new file mode 100644 index 0000000000000..6248ea08ddba4 --- /dev/null +++ b/packages/app/src/web-ws-base-fix.ts @@ -0,0 +1,186 @@ +/** + * Same-origin API + WebSocket base repair for the PLAIN-WEB served bundle. + * + * Context: when the app is served by the Vite dev server as a plain browser page + * (NOT the electrobun desktop shell, NOT a Capacitor native webview), the dev + * `appDevWsBasePlugin` injects `window.__ELIZA_WS_BASE__ = "ws://127.0.0.1:"` + * into the served HTML (apiPort defaults to 31337, the desktop loopback API). + * + * `client-base.ts` `getInjectedWsBase()` reads that global FIRST — before it + * would otherwise derive the socket host from `window.location`. So even with an + * empty (same-origin) REST base, the realtime socket dials the dead desktop + * loopback `ws://127.0.0.1:31337/ws`, which is refused, and live chat never + * connects. + * + * When the page is actually served over http/https from a real remote host that + * a reverse proxy (nginx) fronts — proxying `/ws` and `/api` to the backend with + * auth injected — the correct socket target is same-origin + * `wss:///ws` and REST is same-origin `/api`. + * + * Two things must be corrected for the plain-web path, both UPSTREAM of the + * DO-NOT-EDIT client-base.ts: + * + * 1. WS base: rewrite the injected desktop-loopback `__ELIZA_WS_BASE__` to + * same-origin `wss://` so `getInjectedWsBase()` resolves the correct + * socket host. + * + * 2. REST base: set `__ELIZA_API_BASE__` to same-origin `https://` so + * `this.baseUrl` is NON-EMPTY. This is required because client-base's + * `connectWs()` has a guard that BAILS when `baseUrl` is empty AND the page + * host has no port and isn't loopback (a Capacitor synthetic-host + * protection). A plain remote https host like `sol-overhaul.shad0w.xyz` + * (portless, non-loopback) trips that guard, so an empty REST base leaves + * the socket un-opened even with a correct WS base. A same-origin absolute + * REST base is equivalent to relative `/api` (nginx proxies it with the + * injected auth header) and makes the guard pass so the socket opens. + * + * Scope: the WS base is set via the `__ELIZA_WS_BASE__` / `__ELIZAOS_WS_BASE__` + * window globals that client-base's `getInjectedWsBase()` still reads directly. + * The REST base is set via `setElizaApiBase()` (@elizaos/shared) — the boot + * config is the single source of truth `getElizaApiBase()` reads (a bespoke + * `__ELIZA_API_BASE__` window global is NO LONGER read for the REST base), and + * the setter also mirrors `__ELIZAOS_API_BASE__` for any legacy reader. It + * deliberately does NOT touch `__ELIZA_APP_API_BASE__` / the branded + * `___API_BASE__` that `getInjectedAppApiBase()` reads for cloud-only + * branding — so app branding is unaffected. + * + * Desktop (electrobun) and native (Capacitor) contexts are left untouched — they + * legitimately need the injected / native base. + * + * This module MUST be imported as the first side-effect in `main.tsx`, before + * the `client` singleton's `connectWs()` can run. + */ +import { Capacitor } from "@capacitor/core"; +import { setElizaApiBase } from "@elizaos/shared"; +import { isElectrobunRuntime } from "@elizaos/ui/bridge"; + +const LOOPBACK_HOSTNAMES = new Set([ + "localhost", + "127.0.0.1", + "::1", + "[::1]", + "0.0.0.0", +]); + +function isLoopbackHostname(hostname: string): boolean { + return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); +} + +function setInjectedGlobal(key: string, value: string): void { + try { + const w = window as unknown as Record; + w[key] = value; + } catch { + // best-effort — never block boot + } +} + +/** + * Same-origin realtime socket base for the current page: + * `wss://` on https, `ws://` on http. client-base appends `/ws` + * and the clientId/token query itself, so only the origin (protocol + host) + * needs to be correct here. + */ +function sameOriginWsBase(): string { + const loc = window.location; + const proto = loc.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${loc.host}`; +} + +/** Same-origin REST API base for the current page: `https://`. */ +function sameOriginRestBase(): string { + const loc = window.location; + return `${loc.protocol}//${loc.host}`; +} + +/** + * Returns true only for the plain-web served context that should use a + * same-origin API/socket (not desktop, not native, page on a real http/https + * non-loopback host). + */ +function isPlainWebSameOriginContext(): boolean { + if (typeof window === "undefined") return false; + // Desktop shell needs the injected loopback API base. + if (isElectrobunRuntime()) return false; + // Capacitor iOS/Android use their own native/injected bases. + try { + if (Capacitor.isNativePlatform()) return false; + } catch { + // If Capacitor isn't resolvable treat as web; fall through. + } + const loc = window.location; + if (loc.protocol !== "http:" && loc.protocol !== "https:") return false; + // Loopback page host = an actual local dev-in-browser session pointed at the + // real loopback API; leave the injection alone there. + if (isLoopbackHostname(loc.hostname)) return false; + return true; +} + +function injectedWsBaseIsForeignLoopback(value: unknown): boolean { + if (typeof value !== "string" || !value.trim()) return false; + try { + const parsed = new URL(value); + if (parsed.protocol !== "ws:" && parsed.protocol !== "http:") { + // A wss:/https: injection already implies a real proxied host; don't + // second-guess it. + return false; + } + // ws:/http: injection is the desktop-loopback default; on a plain-web + // remote page it is always wrong. + return true; + } catch { + return false; + } +} + +/** + * Repoint the dev-injected desktop-loopback API + WS bases at the current + * (reverse-proxied) origin on the plain-web path so REST hits same-origin + * `/api` and the realtime socket dials `wss:///ws`. No-op on desktop / + * native / loopback-dev contexts. + */ +export function repairWebSameOriginWsBase(): void { + if (!isPlainWebSameOriginContext()) return; + const w = window as unknown as { + __ELIZA_WS_BASE__?: unknown; + __ELIZAOS_WS_BASE__?: unknown; + }; + const anyForeign = + injectedWsBaseIsForeignLoopback(w.__ELIZA_WS_BASE__) || + injectedWsBaseIsForeignLoopback(w.__ELIZAOS_WS_BASE__); + if (!anyForeign) return; + + // 1) WS base → same-origin wss://. + const wsTarget = sameOriginWsBase(); + setInjectedGlobal("__ELIZA_WS_BASE__", wsTarget); + setInjectedGlobal("__ELIZAOS_WS_BASE__", wsTarget); + try { + const wRecord = window as unknown as Record; + for (const key of Object.keys(wRecord)) { + if ( + /^__[A-Z0-9]+_WS_BASE__$/.test(key) && + injectedWsBaseIsForeignLoopback(wRecord[key]) + ) { + setInjectedGlobal(key, wsTarget); + } + } + } catch { + // best-effort + } + + // 2) REST base → same-origin https://, so the client's baseUrl is + // non-empty and connectWs()'s empty-baseUrl guard does not bail. The boot + // config is the single source of truth getElizaApiBase() reads, so this + // goes through setElizaApiBase() (which sets boot-config AND mirrors the + // __ELIZAOS_API_BASE__ global) rather than a raw window global that + // getElizaApiBase() no longer reads. This does NOT touch the app-branding + // globals (getInjectedAppApiBase()). + const restTarget = sameOriginRestBase(); + try { + setElizaApiBase(restTarget); + } catch { + // best-effort — never block boot + } +} + +repairWebSameOriginWsBase(); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 6bda8e788b9b1..14cf2fac2cc76 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -2426,6 +2426,23 @@ export function App() { "max(calc(var(--safe-area-top, 0px) - 1.25rem), 1.25rem)", }} > + {/* BOTTOM-BAR / SAFE-AREA FLOOR (do not remove): a viewport-filling + dark background-token floor mounted on EVERY route, behind the + shader (z-0) and every other layer. html/body/#root paint the + orange launch guard (--launch-bg #ef5a1f) as a FOUC color, and on + shared-background routes (home/chat) the AppBackground shader was + the ONLY thing hiding it. On iOS the composer overlay is anchored + by the visualViewport-derived `bottom`, so in the home-indicator + safe-area the shader coverage can fall short and the orange host + color bled through as a band under the composer. This floor makes + the bottom inset (and every unpainted zone) the dark BACKGROUND + token — never accent — regardless of route or shader state. The + shader/wallpaper renders on top of it unchanged on shared routes. */} +