Gateway UX: harden remote ws guidance and onboarding defaults

This commit is contained in:
Brian Mendonca
2026-02-22 03:39:56 -07:00
committed by Peter Steinberger
parent 6fda04e938
commit 8a3d04c19c
8 changed files with 169 additions and 7 deletions

View File

@@ -48,6 +48,8 @@ describe("noteSecurityWarnings gateway exposure", () => {
const message = lastMessage();
expect(message).toContain("CRITICAL");
expect(message).toContain("without authentication");
expect(message).toContain("Safer remote access");
expect(message).toContain("ssh -N -L 18789:127.0.0.1:18789");
});
it("uses env token to avoid critical warning", async () => {

View File

@@ -42,6 +42,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
(resolvedAuth.mode === "token" && hasToken) ||
(resolvedAuth.mode === "password" && hasPassword);
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
const saferRemoteAccessLines = [
" Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.",
" Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host",
" Docs: https://docs.openclaw.ai/gateway/remote",
];
if (isExposed) {
if (!hasSharedSecret) {
@@ -61,6 +66,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`,
...saferRemoteAccessLines,
...authFixLines,
);
} else {
@@ -68,6 +74,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
warnings.push(
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
` Ensure your auth credentials are strong and not exposed.`,
...saferRemoteAccessLines,
);
}
}

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { createWizardPrompter } from "./test-wizard-helpers.js";
const discoverGatewayBeacons = vi.hoisted(() => vi.fn<() => Promise<GatewayBonjourBeacon[]>>());
const resolveWideAreaDiscoveryDomain = vi.hoisted(() => vi.fn(() => undefined));
const detectBinary = vi.hoisted(() => vi.fn<(name: string) => Promise<boolean>>());
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons,
}));
vi.mock("../infra/widearea-dns.js", () => ({
resolveWideAreaDiscoveryDomain,
}));
vi.mock("./onboard-helpers.js", () => ({
detectBinary,
}));
const { promptRemoteGatewayConfig } = await import("./onboard-remote.js");
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(overrides, { defaultSelect: "" });
}
describe("promptRemoteGatewayConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
detectBinary.mockResolvedValue(false);
discoverGatewayBeacons.mockResolvedValue([]);
resolveWideAreaDiscoveryDomain.mockReturnValue(undefined);
});
it("defaults discovered direct remote URLs to wss://", async () => {
detectBinary.mockResolvedValue(true);
discoverGatewayBeacons.mockResolvedValue([
{
instanceName: "gateway",
displayName: "Gateway",
host: "gateway.tailnet.ts.net",
port: 18789,
},
]);
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "Select gateway") {
return "0" as never;
}
if (params.message === "Connection method") {
return "direct" as never;
}
if (params.message === "Gateway auth") {
return "token" as never;
}
return (params.options[0]?.value ?? "") as never;
});
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789");
expect(params.validate?.(String(params.initialValue))).toBeUndefined();
return String(params.initialValue);
}
if (params.message === "Gateway token") {
return "token-123";
}
return "";
}) as WizardPrompter["text"];
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => true),
select,
text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789");
expect(next.gateway?.remote?.token).toBe("token-123");
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Direct remote access defaults to TLS."),
"Direct remote",
);
});
it("validates insecure ws:// remote URLs and allows loopback ws://", async () => {
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
expect(params.validate?.("ws://10.0.0.8:18789")).toContain("Use wss://");
expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined();
expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined();
return "wss://remote.example.com:18789";
}
return "";
}) as WizardPrompter["text"];
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "Gateway auth") {
return "off" as never;
}
return (params.options[0]?.value ?? "") as never;
});
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
expect(next.gateway?.remote?.token).toBeUndefined();
});
});

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { isSecureWebSocketUrl } from "../gateway/net.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
@@ -29,6 +30,17 @@ function ensureWsUrl(value: string): string {
return trimmed;
}
function validateGatewayWebSocketUrl(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
return "URL must start with ws:// or wss://";
}
if (!isSecureWebSocketUrl(trimmed)) {
return "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel.";
}
return undefined;
}
export async function promptRemoteGatewayConfig(
cfg: OpenClawConfig,
prompter: WizardPrompter,
@@ -95,7 +107,15 @@ export async function promptRemoteGatewayConfig(
],
});
if (mode === "direct") {
suggestedUrl = `ws://${host}:${port}`;
suggestedUrl = `wss://${host}:${port}`;
await prompter.note(
[
"Direct remote access defaults to TLS.",
`Using: ${suggestedUrl}`,
"If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.",
].join("\n"),
"Direct remote",
);
} else {
suggestedUrl = DEFAULT_GATEWAY_URL;
await prompter.note(
@@ -115,10 +135,7 @@ export async function promptRemoteGatewayConfig(
const urlInput = await prompter.text({
message: "Gateway WebSocket URL",
initialValue: suggestedUrl,
validate: (value) =>
String(value).trim().startsWith("ws://") || String(value).trim().startsWith("wss://")
? undefined
: "URL must start with ws:// or wss://",
validate: (value) => validateGatewayWebSocketUrl(String(value)),
});
const url = ensureWsUrl(String(urlInput));

View File

@@ -334,6 +334,8 @@ describe("buildGatewayConnectionDetails", () => {
expect((thrown as Error).message).toContain("SECURITY ERROR");
expect((thrown as Error).message).toContain("plaintext ws://");
expect((thrown as Error).message).toContain("wss://");
expect((thrown as Error).message).toContain("Tailscale Serve/Funnel");
expect((thrown as Error).message).toContain("openclaw doctor --fix");
});
it("allows ws:// for loopback addresses in local mode", () => {

View File

@@ -149,7 +149,12 @@ export function buildGatewayConnectionDetails(
"Both credentials and chat data would be exposed to network interception.",
`Source: ${urlSource}`,
`Config: ${configPath}`,
"Fix: Use wss:// for the gateway URL, or connect via SSH tunnel to localhost.",
"Fix: Use wss:// for remote gateway URLs.",
"Safe remote access defaults:",
"- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)",
"- or use Tailscale Serve/Funnel for HTTPS remote access",
"Doctor: openclaw doctor --fix",
"Docs: https://docs.openclaw.ai/gateway/remote",
].join("\n"),
);
}

View File

@@ -130,6 +130,9 @@ describe("GatewayClient security checks", () => {
message: expect.stringContaining("SECURITY ERROR"),
}),
);
const error = onConnectError.mock.calls[0]?.[0] as Error;
expect(error.message).toContain("openclaw doctor --fix");
expect(error.message).toContain("Tailscale Serve/Funnel");
expect(wsInstances.length).toBe(0); // No WebSocket created
client.stop();
});
@@ -149,6 +152,8 @@ describe("GatewayClient security checks", () => {
message: expect.stringContaining("SECURITY ERROR"),
}),
);
const error = onConnectError.mock.calls[0]?.[0] as Error;
expect(error.message).toContain("openclaw doctor --fix");
expect(wsInstances.length).toBe(0); // No WebSocket created
client.stop();
});

View File

@@ -126,7 +126,9 @@ export class GatewayClient {
const error = new Error(
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
"Both credentials and chat data would be exposed to network interception. " +
"Use wss:// for the gateway URL, or connect via SSH tunnel to localhost.",
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
"Run `openclaw doctor --fix` for guidance.",
);
this.opts.onConnectError?.(error);
return;