From 8a3d04c19cb5f728a4ecab5456a0dca9243c0da2 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Sun, 22 Feb 2026 03:39:56 -0700 Subject: [PATCH] Gateway UX: harden remote ws guidance and onboarding defaults --- src/commands/doctor-security.e2e.test.ts | 2 + src/commands/doctor-security.ts | 7 ++ src/commands/onboard-remote.test.ts | 122 +++++++++++++++++++++++ src/commands/onboard-remote.ts | 27 ++++- src/gateway/call.test.ts | 2 + src/gateway/call.ts | 7 +- src/gateway/client.test.ts | 5 + src/gateway/client.ts | 4 +- 8 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 src/commands/onboard-remote.test.ts diff --git a/src/commands/doctor-security.e2e.test.ts b/src/commands/doctor-security.e2e.test.ts index c2f0e6f1e2a..faee8f19251 100644 --- a/src/commands/doctor-security.e2e.test.ts +++ b/src/commands/doctor-security.e2e.test.ts @@ -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 () => { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index f58107e6838..cbd93e97021 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -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, ); } } diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts new file mode 100644 index 00000000000..4292a7b09b3 --- /dev/null +++ b/src/commands/onboard-remote.test.ts @@ -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>()); +const resolveWideAreaDiscoveryDomain = vi.hoisted(() => vi.fn(() => undefined)); +const detectBinary = vi.hoisted(() => vi.fn<(name: string) => Promise>()); + +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 { + 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(); + }); +}); diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index 01c1c99417c..3126a0d9f7c 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -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)); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 2bc4d4ddc77..5d41c7f4f60 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -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", () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 5713864a443..ea8ed6cdbca 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -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"), ); } diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index fac8166450c..20010db4897 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -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(); }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 4e957c6e087..5cfe52eb87d 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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;