Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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://<host>/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
Expand Down
148 changes: 148 additions & 0 deletions packages/app/src/web-ws-base-fix.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[key] = value;
}

function getGlobal(key: string): unknown {
return (window as unknown as Record<string, unknown>)[key];
}

async function importFreshShim(): Promise<void> {
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();
});
});
186 changes: 186 additions & 0 deletions packages/app/src/web-ws-base-fix.ts
Original file line number Diff line number Diff line change
@@ -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:<apiPort>"`
* 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://<location.host>/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://<host>` so `getInjectedWsBase()` resolves the correct
* socket host.
*
* 2. REST base: set `__ELIZA_API_BASE__` to same-origin `https://<host>` 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
* `__<PREFIX>_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<string, unknown>;
w[key] = value;
} catch {
// best-effort — never block boot
}
}

/**
* Same-origin realtime socket base for the current page:
* `wss://<host>` on https, `ws://<host>` 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://<host>`. */
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://<host>/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://<host>.
const wsTarget = sameOriginWsBase();
setInjectedGlobal("__ELIZA_WS_BASE__", wsTarget);
setInjectedGlobal("__ELIZAOS_WS_BASE__", wsTarget);
try {
const wRecord = window as unknown as Record<string, unknown>;
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://<host>, 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();
17 changes: 17 additions & 0 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<div
aria-hidden="true"
data-testid="app-safe-area-floor"
className="pointer-events-none fixed inset-0 z-[-1] bg-bg"
/>
{/* The unified app background, mounted once here so it persists
seamlessly across shared-background routes. It keeps the
background event channel mounted for the whole session, but only
Expand Down
12 changes: 9 additions & 3 deletions packages/ui/src/state/ui-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,14 @@ export interface BackgroundConfig {
shader?: ShaderConfig;
}

/** The default shader color — preserves the prior warm-orange home look. */
export const DEFAULT_BACKGROUND_COLOR = "#ef5a1f";
/**
* The default shader base: a warm near-black field (NOT a saturated orange
* wall). The home reads as a banked ember in a dark room, a deep brown-black
* substrate the orange glow breathes against, so content stays legible and the
* accent stays an accent. The old default (#ef5a1f) flooded the whole viewport
* with bright orange and washed every surface out.
*/
export const DEFAULT_BACKGROUND_COLOR = "#160d07";

export const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
mode: "shader",
Expand All @@ -79,7 +85,7 @@ export interface BackgroundPreset {
* live, breathing shader field — not a flat fill.
*/
export const BACKGROUND_PRESETS: readonly BackgroundPreset[] = [
{ id: "orange", label: "Orange", color: DEFAULT_BACKGROUND_COLOR },
{ id: "orange", label: "Orange", color: "#ef5a1f" },
{ id: "amber", label: "Amber", color: "#f59e0b" },
{ id: "rose", label: "Rose", color: "#e11d48" },
{ id: "red", label: "Red", color: "#dc2626" },
Expand Down
Loading