From e71b62444c5b8eefc0313b029d82dbd029a23bc3 Mon Sep 17 00:00:00 2001 From: iamlukethedev Date: Fri, 27 Mar 2026 14:45:02 -0500 Subject: [PATCH] fix: resolve gateway URL at runtime via /api/studio fallback (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #57 — NEXT_PUBLIC_GATEWAY_URL is a build-time variable that gets baked into the client bundle. Changing it in .env and restarting has no effect without a rebuild. - normalizeLocalGatewayDefaults now accepts the sanitized public form ({url, tokenConfigured}) from /api/studio - When no saved gateway URL exists, prefer runtime localGatewayDefaults (from openclaw.json or CLAW3D_GATEWAY_URL env var) over the potentially stale build-time NEXT_PUBLIC_GATEWAY_URL - loadLocalGatewayDefaults falls back to CLAW3D_GATEWAY_URL/TOKEN env vars when openclaw.json is absent - Added runtime env vars documentation to .env.example and README Co-authored-by: robotica4us-collab Made-with: Cursor --- .env.example | 9 +++- .gitignore | 1 + README.md | 3 +- src/lib/gateway/GatewayClient.ts | 30 +++++++++---- src/lib/studio/settings-store.ts | 11 ++++- tests/unit/gatewayEnvDefaults.test.ts | 60 +++++++++++++++++++++++++ tests/unit/useGatewayConnection.test.ts | 50 +++++++++++++++++++++ 7 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 tests/unit/gatewayEnvDefaults.test.ts diff --git a/.env.example b/.env.example index c5aef1a..10726b2 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ -# Browser/client gateway URL +# Browser/client gateway URL (build-time — requires `npm run build` after changes) NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789 +# Runtime gateway URL — takes effect on restart without a rebuild. +# Use this instead of NEXT_PUBLIC_GATEWAY_URL when you want to change the +# gateway endpoint without re-running `npm run build`. Also used as a +# fallback when openclaw.json is not present. +# CLAW3D_GATEWAY_URL=ws://localhost:18789 +# CLAW3D_GATEWAY_TOKEN= + # Debug UI DEBUG=true diff --git a/.gitignore b/.gitignore index 35ff3e9..8242316 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ test-results # Local HTTPS development certificates (generated by dev:https). .certs/ +.understand-anything/ diff --git a/README.md b/README.md index e91a14c..4f0663a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,8 @@ Common environment variables: - `HOST` and `PORT` control the Studio server bind address and port. - `STUDIO_ACCESS_TOKEN` protects Studio when binding to a public host. -- `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty. +- `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty. **Note:** this is a build-time variable — changes require `npm run build` to take effect. +- `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild. These are also used as a fallback when `openclaw.json` is not present. - `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` override the default OpenClaw paths. - `OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`, `OPENCLAW_GATEWAY_SSH_PORT`, and `OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING` support advanced gateway-host operations over SSH when needed. - `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, and `ELEVENLABS_MODEL_ID` enable voice reply integration. diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 9db66ba..317ea25 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -103,10 +103,14 @@ const DEFAULT_UPSTREAM_GATEWAY_URL = const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => { if (!value || typeof value !== "object") return null; - const raw = value as { url?: unknown; token?: unknown }; + const raw = value as { url?: unknown; token?: unknown; tokenConfigured?: unknown }; const url = typeof raw.url === "string" ? raw.url.trim() : ""; + if (!url) return null; + // Accept both full settings ({ url, token }) and the sanitized public + // form ({ url, tokenConfigured }) returned by /api/studio. When only + // tokenConfigured is present the actual token isn't available on the + // client — leave it empty so the connection dialog can prompt if needed. const token = typeof raw.token === "string" ? raw.token.trim() : ""; - if (!url || !token) return null; return { url, token }; }; @@ -548,12 +552,22 @@ export const useGatewayConnection = ( const settings = envelope.settings ?? null; const gateway = settings?.gateway ?? null; if (cancelled) return; - setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); - const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; - const nextToken = - gateway && "token" in gateway && typeof gateway.token === "string" - ? gateway.token - : ""; + const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults); + setLocalGatewayDefaults(normalizedDefaults); + // When the user has no saved gateway URL, prefer the runtime + // localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL) + // over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale + // or empty if the operator forgot to rebuild after .env changes. + const hasSavedUrl = Boolean(gateway?.url?.trim()); + const resolvedUrl = hasSavedUrl + ? gateway!.url + : normalizedDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL; + const nextGatewayUrl = resolvedUrl; + const nextToken = hasSavedUrl + ? (gateway && "token" in gateway && typeof gateway.token === "string" + ? gateway.token + : "") + : normalizedDefaults?.token ?? ""; loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl.trim(), token: nextToken, diff --git a/src/lib/studio/settings-store.ts b/src/lib/studio/settings-store.ts index 61b9d08..cd09e64 100644 --- a/src/lib/studio/settings-store.ts +++ b/src/lib/studio/settings-store.ts @@ -44,8 +44,15 @@ const readOpenclawGatewayDefaults = (): { url: string; token: string } | null => } }; -export const loadLocalGatewayDefaults = () => { - return readOpenclawGatewayDefaults(); +export const loadLocalGatewayDefaults = (): { url: string; token: string } | null => { + const fromFile = readOpenclawGatewayDefaults(); + if (fromFile) return fromFile; + // Fall back to env vars so operators can configure the gateway URL at + // runtime without openclaw.json and without a rebuild. + const envUrl = process.env.CLAW3D_GATEWAY_URL?.trim(); + const envToken = process.env.CLAW3D_GATEWAY_TOKEN?.trim(); + if (envUrl) return { url: envUrl, token: envToken ?? "" }; + return null; }; export const loadStudioSettings = (): StudioSettings => { diff --git a/tests/unit/gatewayEnvDefaults.test.ts b/tests/unit/gatewayEnvDefaults.test.ts new file mode 100644 index 0000000..b65e2e2 --- /dev/null +++ b/tests/unit/gatewayEnvDefaults.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("loadLocalGatewayDefaults with CLAW3D_GATEWAY_URL", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetModules(); + }); + + it("returns env-based defaults when CLAW3D_GATEWAY_URL is set and no openclaw.json exists", async () => { + process.env.CLAW3D_GATEWAY_URL = "ws://my-gateway:18789"; + process.env.CLAW3D_GATEWAY_TOKEN = "my-token"; + // Point state dir to a non-existent location so openclaw.json is not found + process.env.OPENCLAW_STATE_DIR = "/tmp/claw3d-test-nonexistent-" + Date.now(); + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + expect(result).toEqual({ url: "ws://my-gateway:18789", token: "my-token" }); + }); + + it("returns env-based defaults with empty token when only URL is set", async () => { + process.env.CLAW3D_GATEWAY_URL = "ws://my-gateway:18789"; + delete process.env.CLAW3D_GATEWAY_TOKEN; + process.env.OPENCLAW_STATE_DIR = "/tmp/claw3d-test-nonexistent-" + Date.now(); + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + expect(result).toEqual({ url: "ws://my-gateway:18789", token: "" }); + }); + + it("returns null when no env var and no openclaw.json", async () => { + delete process.env.CLAW3D_GATEWAY_URL; + delete process.env.CLAW3D_GATEWAY_TOKEN; + process.env.OPENCLAW_STATE_DIR = "/tmp/claw3d-test-nonexistent-" + Date.now(); + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + expect(result).toBeNull(); + }); + + it("prefers openclaw.json over env vars when both exist", async () => { + process.env.CLAW3D_GATEWAY_URL = "ws://env-gateway:18789"; + process.env.CLAW3D_GATEWAY_TOKEN = "env-token"; + // Use real state dir which has openclaw.json + delete process.env.OPENCLAW_STATE_DIR; + const { loadLocalGatewayDefaults } = await import( + "../../src/lib/studio/settings-store" + ); + const result = loadLocalGatewayDefaults(); + // Should return the file-based defaults, not the env vars + if (result) { + expect(result.url).not.toBe("ws://env-gateway:18789"); + } + // If no file exists in CI, it falls back to env — that's also correct + }); +}); diff --git a/tests/unit/useGatewayConnection.test.ts b/tests/unit/useGatewayConnection.test.ts index 2e02946..c4c0313 100644 --- a/tests/unit/useGatewayConnection.test.ts +++ b/tests/unit/useGatewayConnection.test.ts @@ -154,6 +154,56 @@ describe("useGatewayConnection", () => { expect(captured.authScopeKey).toBe("ws://localhost:18789"); }); + it("auto_applies_runtime_local_defaults_when_no_saved_gateway_and_build_time_empty", async () => { + // Simulates #57: NEXT_PUBLIC_GATEWAY_URL was never rebuilt, but + // CLAW3D_GATEWAY_URL is set on the server so localGatewayDefaults + // comes through in the sanitized (public) form with tokenConfigured. + const { useGatewayConnection } = await setupAndImportHook(""); + const coordinator = { + loadSettings: async () => null, + loadSettingsEnvelope: async () => ({ + settings: { + version: 1, + gateway: null, // no saved gateway settings + focused: {}, + avatars: {}, + analytics: {}, + voiceReplies: {}, + office: {}, + deskAssignments: {}, + standup: {}, + }, + // Sanitized public form — token is replaced with tokenConfigured + localGatewayDefaults: { url: "ws://my-server:18789", tokenConfigured: true }, + }), + schedulePatch: () => {}, + flushPending: async () => {}, + }; + + const Probe = () => { + const state = useGatewayConnection(coordinator); + return createElement( + "div", + null, + createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl), + createElement( + "div", + { "data-testid": "localDefaultsUrl" }, + state.localGatewayDefaults?.url ?? "" + ) + ); + }; + + render(createElement(Probe)); + + // The runtime local defaults should be auto-applied since there are + // no saved settings and the build-time default is empty. + await waitFor(() => { + expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://my-server:18789"); + }); + expect(screen.getByTestId("localDefaultsUrl")).toHaveTextContent("ws://my-server:18789"); + }); + it("applies_local_defaults_from_settings_envelope", async () => { const { useGatewayConnection } = await setupAndImportHook(null); const coordinator = {