fix(gateway): use secure dashboard links when TLS is enabled (#71499)

Fixes #71494.

- Render Control UI links with https:// when gateway TLS is enabled.
- Render websocket links with wss:// through the shared link resolver.
- Add daemon status handoff coverage and TLS scheme docs.

Co-authored-by: deepkilord <wang_hgang@msn.com>
This commit is contained in:
deepkilo 2026-04-25 12:45:15 +02:00 committed by GitHub
parent 8cbb62d93c
commit df6c58cf30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 110 additions and 4 deletions

View file

@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/dashboard: render Control UI and WebSocket links with `https://`/`wss://` when `gateway.tls.enabled=true`, including `openclaw gateway status`. Fixes #71494. (#71499) Thanks @deepkilo.
- Agents/OpenAI-compatible: default proxy/local completions tool requests to `tool_choice: "auto"` when tools are present, so providers enter native tool-calling mode instead of replying with plain-text tool directives. (#71472) Thanks @Speed-maker.
- OpenAI image generation: use `gpt-5.5` for the Codex OAuth responses transport instead of the retired `gpt-5.4` model, fixing 500s from ChatGPT Codex image generation. Fixes #71513. Thanks @baolongl.
- Google video generation: download direct MLDev Veo `video.uri` results instead of passing them through the Files API path, fixing 404s after successful generation/polling. Fixes #71200. Thanks @panhaishan.

View file

@ -18,6 +18,8 @@ openclaw dashboard --no-open
Notes:
- `dashboard` resolves configured `gateway.auth.token` SecretRefs when possible.
- `dashboard` follows `gateway.tls.enabled`: TLS-enabled gateways print/open
`https://` Control UI URLs and connect over `wss://`.
- For SecretRef-managed tokens (resolved or unresolved), `dashboard` prints/copies/opens a non-tokenized URL to avoid exposing external secrets in terminal output, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is SecretRef-managed but unresolved in this command path, the command prints a non-tokenized URL and explicit remediation guidance instead of embedding an invalid token placeholder.

View file

@ -11,6 +11,8 @@ The Gateway dashboard is the browser Control UI served at `/` by default
Quick open (local Gateway):
- [http://127.0.0.1:18789/](http://127.0.0.1:18789/) (or [http://localhost:18789/](http://localhost:18789/))
- With `gateway.tls.enabled: true`, use `https://127.0.0.1:18789/` and
`wss://127.0.0.1:18789` for the WebSocket endpoint.
Key references:
@ -43,6 +45,8 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Auth basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`.
- **Gateway TLS**: when `gateway.tls.enabled: true`, dashboard/status links use
`https://` and Control UI WebSocket links use `wss://`.
- **Shared-secret token source**: `gateway.auth.token` (or
`OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment
for one-time bootstrap, and the Control UI keeps it in sessionStorage for the

View file

@ -9,6 +9,7 @@ title: "Web"
The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket:
- default: `http://<host>:18789/`
- with `gateway.tls.enabled: true`: `https://<host>:18789/`
- optional prefix: set `gateway.controlUi.basePath` (e.g. `/openclaw`)
Capabilities live in [Control UI](/web/control-ui).
@ -100,6 +101,8 @@ Open:
gateway token (even on loopback).
- In shared-secret mode, the UI sends `connect.params.auth.token` or
`connect.params.auth.password`.
- When `gateway.tls.enabled: true`, local dashboard and status helpers render
`https://` dashboard URLs and `wss://` WebSocket URLs.
- In identity-bearing modes such as Tailscale Serve or `trusted-proxy`, the
WebSocket auth check is satisfied from request headers instead.
- For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins`

View file

@ -205,6 +205,7 @@ describe("gatherDaemonStatus", () => {
}),
);
expect(status.gateway?.probeUrl).toBe("wss://127.0.0.1:19001");
expect(status.gateway?.tlsEnabled).toBe(true);
expect(status.rpc?.url).toBe("wss://127.0.0.1:19001");
expect(status.rpc?.ok).toBe(true);
expect(inspectGatewayRestart).not.toHaveBeenCalled();

View file

@ -43,6 +43,7 @@ type GatewayStatusSummary = {
bindMode: GatewayBindMode;
bindHost: string;
customBindHost?: string;
tlsEnabled?: boolean;
port: number;
portSource: "service args" | "env/config";
probeUrl: string;
@ -284,7 +285,8 @@ async function resolveGatewayStatusSummary(params: {
});
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
const probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null;
const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws";
const tlsEnabled = params.daemonCfg.gateway?.tls?.enabled === true;
const scheme = tlsEnabled ? "wss" : "ws";
const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`;
let probeNote =
!probeUrlOverride && bindMode === "lan"
@ -300,6 +302,7 @@ async function resolveGatewayStatusSummary(params: {
bindMode,
bindHost,
customBindHost,
...(tlsEnabled ? { tlsEnabled } : {}),
port: daemonPort,
portSource,
probeUrl,

View file

@ -6,6 +6,9 @@ const runtime = vi.hoisted(() => ({
log: vi.fn<(line: string) => void>(),
error: vi.fn<(line: string) => void>(),
}));
const resolveControlUiLinksMock = vi.hoisted(() =>
vi.fn((_opts?: unknown) => ({ httpUrl: "http://127.0.0.1:18789" })),
);
vi.mock("../../runtime.js", () => ({
defaultRuntime: runtime,
@ -21,7 +24,7 @@ vi.mock("../../terminal/theme.js", async () => {
});
vi.mock("../../gateway/control-ui-links.js", () => ({
resolveControlUiLinks: () => ({ httpUrl: "http://127.0.0.1:18789" }),
resolveControlUiLinks: resolveControlUiLinksMock,
}));
vi.mock("../../daemon/inspect.js", () => ({
@ -73,6 +76,7 @@ describe("printDaemonStatus", () => {
beforeEach(() => {
runtime.log.mockReset();
runtime.error.mockReset();
resolveControlUiLinksMock.mockClear();
});
it("prints stale gateway pid guidance when runtime does not own the listener", () => {
@ -152,4 +156,56 @@ describe("printDaemonStatus", () => {
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Connectivity probe: ok"));
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Capability: write-capable"));
});
it("passes daemon TLS state to dashboard link rendering", () => {
printDaemonStatus(
{
service: {
label: "LaunchAgent",
loaded: true,
loadedText: "loaded",
notLoadedText: "not loaded",
runtime: { status: "running", pid: 8000 },
},
config: {
cli: {
path: "/tmp/openclaw-cli/openclaw.json",
exists: true,
valid: true,
},
daemon: {
path: "/tmp/openclaw-daemon/openclaw.json",
exists: true,
valid: true,
controlUi: { basePath: "/ui" },
},
mismatch: true,
},
gateway: {
bindMode: "lan",
bindHost: "0.0.0.0",
port: 19001,
portSource: "service args",
probeUrl: "wss://127.0.0.1:19001",
tlsEnabled: true,
},
rpc: {
ok: true,
kind: "connect",
capability: "write_capable",
url: "wss://127.0.0.1:19001",
},
extraServices: [],
},
{ json: false },
);
expect(resolveControlUiLinksMock).toHaveBeenCalledWith({
port: 19001,
bind: "lan",
customBindHost: undefined,
basePath: "/ui",
tlsEnabled: true,
});
});
});

View file

@ -165,6 +165,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
bind: status.gateway.bindMode,
customBindHost: status.gateway.customBindHost,
basePath: status.config?.daemon?.controlUi?.basePath,
tlsEnabled: status.gateway.tlsEnabled === true,
});
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`);
}

View file

@ -108,6 +108,7 @@ async function runGatewayHealthCheck(params: {
port: params.port,
customBindHost: params.cfg.gateway?.customBindHost,
basePath: undefined,
tlsEnabled: params.cfg.gateway?.tls?.enabled === true,
});
const remoteUrl = params.cfg.gateway?.remote?.url?.trim();
const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl;
@ -754,6 +755,7 @@ export async function runConfigureWizard(
port: gatewayPort,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: nextConfig.gateway?.controlUi?.basePath,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
const newPassword =
process.env.OPENCLAW_GATEWAY_PASSWORD ??

View file

@ -88,6 +88,7 @@ describe("dashboardCommand", () => {
bind: "loopback",
customBindHost: undefined,
basePath: undefined,
tlsEnabled: false,
});
// clipboard and browser still get the full authenticated URL
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");

View file

@ -85,6 +85,7 @@ describe("dashboardCommand bind selection", () => {
bind: "loopback",
customBindHost: undefined,
basePath: undefined,
tlsEnabled: false,
});
});
@ -98,6 +99,7 @@ describe("dashboardCommand bind selection", () => {
bind: "custom",
customBindHost: "10.0.0.5",
basePath: undefined,
tlsEnabled: false,
});
});
@ -111,6 +113,7 @@ describe("dashboardCommand bind selection", () => {
bind: "tailnet",
customBindHost: undefined,
basePath: undefined,
tlsEnabled: false,
});
});
});

View file

@ -38,6 +38,7 @@ export async function dashboardCommand(
bind: bind === "lan" ? "loopback" : bind,
customBindHost,
basePath,
tlsEnabled: cfg.gateway?.tls?.enabled === true,
});
// Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args.
const includeTokenInUrl = token.length > 0 && !resolvedToken.secretRefConfigured;

View file

@ -204,6 +204,17 @@ describe("resolveControlUiLinks", () => {
expect(links.wsUrl).toBe("ws://192.168.1.100:18789");
});
it("uses secure schemes when gateway TLS is enabled", () => {
const links = resolveControlUiLinks({
port: 18789,
bind: "custom",
customBindHost: "192.168.1.100",
tlsEnabled: true,
});
expect(links.httpUrl).toBe("https://192.168.1.100:18789/");
expect(links.wsUrl).toBe("wss://192.168.1.100:18789");
});
it("falls back to loopback for invalid customBindHost", () => {
const links = resolveControlUiLinks({
port: 18789,

View file

@ -262,6 +262,7 @@ export async function runNonInteractiveLocalSetup(params: {
port: gatewayResult.port,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: undefined,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
const installDaemonGatewayHealthTiming = resolveInstallDaemonGatewayHealthTiming();
const probeAuth = await resolveGatewayHealthProbeToken(nextConfig);

View file

@ -101,6 +101,16 @@ describe("status-all format", () => {
},
}),
).toBe("http://127.0.0.1:18789/ui/");
expect(
resolveStatusDashboardUrl({
cfg: {
gateway: {
bind: "loopback",
tls: { enabled: true },
},
},
}),
).toBe("https://127.0.0.1:18789/");
expect(
resolveStatusDashboardUrl({
cfg: {

View file

@ -169,6 +169,7 @@ export function resolveStatusDashboardUrl(params: {
bind: params.cfg.gateway?.bind,
customBindHost: params.cfg.gateway?.customBindHost,
basePath: params.cfg.gateway?.controlUi?.basePath,
tlsEnabled: params.cfg.gateway?.tls?.enabled === true,
}).httpUrl;
}

View file

@ -10,6 +10,7 @@ export function resolveControlUiLinks(params: {
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
customBindHost?: string;
basePath?: string;
tlsEnabled?: boolean;
}): { httpUrl: string; wsUrl: string } {
const port = params.port;
const bind = params.bind ?? "loopback";
@ -30,8 +31,10 @@ export function resolveControlUiLinks(params: {
const basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/";
const wsPath = basePath ? basePath : "";
const httpScheme = params.tlsEnabled === true ? "https" : "http";
const wsScheme = params.tlsEnabled === true ? "wss" : "ws";
return {
httpUrl: `http://${host}:${port}${uiPath}`,
wsUrl: `ws://${host}:${port}${wsPath}`,
httpUrl: `${httpScheme}://${host}:${port}${uiPath}`,
wsUrl: `${wsScheme}://${host}:${port}${wsPath}`,
};
}

View file

@ -242,6 +242,7 @@ export async function finalizeSetupWizard(
port: settings.port,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: undefined,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
// Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail.
gatewayProbe = await waitForGatewayReachable({
@ -319,6 +320,7 @@ export async function finalizeSetupWizard(
port: settings.port,
customBindHost: settings.customBindHost,
basePath: controlUiBasePath,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
const authedUrl =
settings.authMode === "token" && settings.gatewayToken