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:
committed by
iamlukethedev
parent
456cfae771
commit
e71b62444c
+8
-1
@@ -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
|
||||
|
||||
|
||||
@@ -85,3 +85,4 @@ test-results
|
||||
|
||||
# Local HTTPS development certificates (generated by dev:https).
|
||||
.certs/
|
||||
.understand-anything/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user