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
|
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 UI
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
|
|||||||
@@ -85,3 +85,4 @@ test-results
|
|||||||
|
|
||||||
# Local HTTPS development certificates (generated by dev:https).
|
# Local HTTPS development certificates (generated by dev:https).
|
||||||
.certs/
|
.certs/
|
||||||
|
.understand-anything/
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ Common environment variables:
|
|||||||
|
|
||||||
- `HOST` and `PORT` control the Studio server bind address and port.
|
- `HOST` and `PORT` control the Studio server bind address and port.
|
||||||
- `STUDIO_ACCESS_TOKEN` protects Studio when binding to a public host.
|
- `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_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.
|
- `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.
|
- `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 => {
|
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
|
||||||
if (!value || typeof value !== "object") return 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() : "";
|
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() : "";
|
const token = typeof raw.token === "string" ? raw.token.trim() : "";
|
||||||
if (!url || !token) return null;
|
|
||||||
return { url, token };
|
return { url, token };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -548,12 +552,22 @@ export const useGatewayConnection = (
|
|||||||
const settings = envelope.settings ?? null;
|
const settings = envelope.settings ?? null;
|
||||||
const gateway = settings?.gateway ?? null;
|
const gateway = settings?.gateway ?? null;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
|
const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults);
|
||||||
const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
|
setLocalGatewayDefaults(normalizedDefaults);
|
||||||
const nextToken =
|
// When the user has no saved gateway URL, prefer the runtime
|
||||||
gateway && "token" in gateway && typeof gateway.token === "string"
|
// localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL)
|
||||||
? gateway.token
|
// 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 = {
|
loadedGatewaySettings.current = {
|
||||||
gatewayUrl: nextGatewayUrl.trim(),
|
gatewayUrl: nextGatewayUrl.trim(),
|
||||||
token: nextToken,
|
token: nextToken,
|
||||||
|
|||||||
@@ -44,8 +44,15 @@ const readOpenclawGatewayDefaults = (): { url: string; token: string } | null =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadLocalGatewayDefaults = () => {
|
export const loadLocalGatewayDefaults = (): { url: string; token: string } | null => {
|
||||||
return readOpenclawGatewayDefaults();
|
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 => {
|
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");
|
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 () => {
|
it("applies_local_defaults_from_settings_envelope", async () => {
|
||||||
const { useGatewayConnection } = await setupAndImportHook(null);
|
const { useGatewayConnection } = await setupAndImportHook(null);
|
||||||
const coordinator = {
|
const coordinator = {
|
||||||
|
|||||||
Reference in New Issue
Block a user