fix: resolve gateway URL at runtime via /api/studio fallback (#66)

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 <neo@openclaw.ai>
Made-with: Cursor
This commit is contained in:
iamlukethedev
2026-03-27 14:45:02 -05:00
committed by iamlukethedev
parent 456cfae771
commit e71b62444c
7 changed files with 152 additions and 12 deletions
+8 -1
View File
@@ -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
+1
View File
@@ -85,3 +85,4 @@ test-results
# Local HTTPS development certificates (generated by dev:https).
.certs/
.understand-anything/
+2 -1
View File
@@ -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.
+22 -8
View File
@@ -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,
+9 -2
View File
@@ -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 => {
+60
View File
@@ -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
});
});
+50
View File
@@ -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 = {