From 993da21ddef2d2e2b9e3d16a7d4a55b66cf02f95 Mon Sep 17 00:00:00 2001 From: Daniel Nylander Date: Sat, 21 Mar 2026 16:16:18 +0100 Subject: [PATCH 1/2] feat: add support for self-signed certificates Add toggle in UI to allow self-signed TLS certificates for gateway connections. This is useful for Tailscale Serve with custom certs, local development, and private networks with self-signed certificates. Changes: - Added rejectUnauthorized option to GatewaySocketOptions - Extended StudioGatewaySettings with allowSelfSignedCerts field - Updated buildGatewayConnectProfile to pass rejectUnauthorized setting - Modified OpenClawAdapter to handle rejectUnauthorized in WebSocket options - Added UI checkbox in GatewayConnectScreen and ConnectionPanel - Updated useStudioGatewaySettings to manage the new setting - Added warning message when self-signed certs are enabled - Updated unit tests to include new props Fixes connection issues with self-signed certificates. --- src/app/page.tsx | 6 +++++ .../agents/components/ConnectionPanel.tsx | 25 +++++++++++++++++++ .../components/GatewayConnectScreen.tsx | 24 ++++++++++++++++++ src/lib/controlplane/contracts.ts | 1 + .../controlplane/gateway-connect-profile.ts | 16 ++++++++++-- src/lib/controlplane/openclaw-adapter.ts | 15 +++++++++-- src/lib/studio/settings.ts | 10 ++++++-- src/lib/studio/useStudioGatewaySettings.ts | 24 ++++++++++++++++-- tests/unit/connectionPanel-close.test.ts | 2 ++ 9 files changed, 115 insertions(+), 8 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index b45cb75d2..94399b5a3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -220,6 +220,7 @@ const AgentStudioPage = () => { gatewayUrl, draftGatewayUrl, token, + allowSelfSignedCerts, localGatewayDefaults, localGatewayDefaultsHasToken, hasStoredToken, @@ -237,6 +238,7 @@ const AgentStudioPage = () => { useLocalGatewayDefaults, setGatewayUrl, setToken, + setAllowSelfSignedCerts, applyRuntimeStatusEvent, } = useStudioGatewaySettings(settingsCoordinator); const gatewayStatus: GatewayStatus = status; @@ -1424,6 +1426,7 @@ const AgentStudioPage = () => { savedGatewayUrl={gatewayUrl} draftGatewayUrl={draftGatewayUrl} token={token} + allowSelfSignedCerts={allowSelfSignedCerts} localGatewayDefaults={localGatewayDefaults} localGatewayDefaultsHasToken={localGatewayDefaultsHasToken} hasStoredToken={hasStoredToken} @@ -1438,6 +1441,7 @@ const AgentStudioPage = () => { disconnecting={gatewayDisconnecting} onGatewayUrlChange={setGatewayUrl} onTokenChange={setToken} + onAllowSelfSignedCertsChange={setAllowSelfSignedCerts} onUseLocalDefaults={useLocalGatewayDefaults} onSaveSettings={() => void saveSettings()} onTestConnection={() => void testConnection()} @@ -1491,6 +1495,7 @@ const AgentStudioPage = () => { savedGatewayUrl={gatewayUrl} draftGatewayUrl={draftGatewayUrl} token={token} + allowSelfSignedCerts={allowSelfSignedCerts} hasStoredToken={hasStoredToken} localGatewayDefaultsHasToken={localGatewayDefaultsHasToken} hasUnsavedChanges={hasUnsavedChanges} @@ -1503,6 +1508,7 @@ const AgentStudioPage = () => { disconnecting={gatewayDisconnecting} onGatewayUrlChange={setGatewayUrl} onTokenChange={setToken} + onAllowSelfSignedCertsChange={setAllowSelfSignedCerts} onSaveSettings={() => void saveSettings()} onTestConnection={() => void testConnection()} onDisconnect={() => void disconnect()} diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index b7d030bf2..21507e632 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -6,6 +6,7 @@ type ConnectionPanelProps = { savedGatewayUrl: string; draftGatewayUrl: string; token: string; + allowSelfSignedCerts: boolean; hasStoredToken: boolean; localGatewayDefaultsHasToken: boolean; hasUnsavedChanges: boolean; @@ -23,6 +24,7 @@ type ConnectionPanelProps = { disconnecting: boolean; onGatewayUrlChange: (value: string) => void; onTokenChange: (value: string) => void; + onAllowSelfSignedCertsChange: (value: boolean) => void; onSaveSettings: () => void; onTestConnection: () => void; onDisconnect: () => void; @@ -33,6 +35,7 @@ export const ConnectionPanel = ({ savedGatewayUrl, draftGatewayUrl, token, + allowSelfSignedCerts, hasStoredToken, localGatewayDefaultsHasToken, hasUnsavedChanges, @@ -45,6 +48,7 @@ export const ConnectionPanel = ({ disconnecting, onGatewayUrlChange, onTokenChange, + onAllowSelfSignedCertsChange, onSaveSettings, onTestConnection, onDisconnect, @@ -131,6 +135,27 @@ export const ConnectionPanel = ({ /> + + {/* Allow self-signed certificates checkbox */} +
+ +
+ + {allowSelfSignedCerts && ( +
+ ⚠️ + Self-signed certificates are enabled. Do not use in production. +
+ )} +

{tokenHelper}

{hasUnsavedChanges ? (

diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index 971ed086a..f26a6f363 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -16,6 +16,7 @@ type GatewayConnectScreenProps = { savedGatewayUrl: string; draftGatewayUrl: string; token: string; + allowSelfSignedCerts: boolean; localGatewayDefaults: StudioGatewaySettings | null; localGatewayDefaultsHasToken: boolean; hasStoredToken: boolean; @@ -35,6 +36,7 @@ type GatewayConnectScreenProps = { disconnecting: boolean; onGatewayUrlChange: (value: string) => void; onTokenChange: (value: string) => void; + onAllowSelfSignedCertsChange: (value: boolean) => void; onUseLocalDefaults: () => void; onSaveSettings: () => void; onTestConnection: () => void; @@ -54,6 +56,7 @@ export const GatewayConnectScreen = ({ savedGatewayUrl, draftGatewayUrl, token, + allowSelfSignedCerts, localGatewayDefaults, localGatewayDefaultsHasToken, hasStoredToken, @@ -68,6 +71,7 @@ export const GatewayConnectScreen = ({ disconnecting, onGatewayUrlChange, onTokenChange, + onAllowSelfSignedCertsChange, onUseLocalDefaults, onSaveSettings, onTestConnection, @@ -308,6 +312,26 @@ export const GatewayConnectScreen = ({

{tokenHelper}

+ {/* Allow self-signed certificates checkbox */} +
+ +
+ + {allowSelfSignedCerts && ( +
+ ⚠️ + Self-signed certificates are enabled. Do not use in production. +
+ )} + {hasUnsavedChanges ? (

Unsaved changes diff --git a/src/lib/controlplane/contracts.ts b/src/lib/controlplane/contracts.ts index 1136e461b..bf7c8c37c 100644 --- a/src/lib/controlplane/contracts.ts +++ b/src/lib/controlplane/contracts.ts @@ -45,6 +45,7 @@ export type GatewayResponseFrame = { export type ControlPlaneGatewaySettings = { url: string; token: string; + allowSelfSignedCerts?: boolean; }; export type ControlPlaneRuntimeSnapshot = { diff --git a/src/lib/controlplane/gateway-connect-profile.ts b/src/lib/controlplane/gateway-connect-profile.ts index 033d39678..f6fe0f041 100644 --- a/src/lib/controlplane/gateway-connect-profile.ts +++ b/src/lib/controlplane/gateway-connect-profile.ts @@ -2,6 +2,7 @@ export type GatewayConnectProfileId = "backend-local" | "legacy-control-ui"; export type GatewaySocketOptions = { origin?: string; + rejectUnauthorized?: boolean; }; export type GatewayConnectProfile = { @@ -74,6 +75,7 @@ export function buildGatewayConnectProfile(args: { token: string; protocol: number; capabilities: string[]; + rejectUnauthorized?: boolean; }): GatewayConnectProfile { const baseParams = { minProtocol: args.protocol, @@ -84,10 +86,20 @@ export function buildGatewayConnectProfile(args: { auth: { token: args.token }, }; + const socketOptions: GatewaySocketOptions = {}; + + if (args.profileId === "legacy-control-ui") { + socketOptions.origin = resolveOriginForUpstream(args.upstreamUrl); + } + + if (args.rejectUnauthorized === false) { + socketOptions.rejectUnauthorized = false; + } + if (args.profileId === "legacy-control-ui") { return { id: args.profileId, - socketOptions: { origin: resolveOriginForUpstream(args.upstreamUrl) }, + socketOptions, connectParams: { ...baseParams, client: { @@ -102,7 +114,7 @@ export function buildGatewayConnectProfile(args: { return { id: args.profileId, - socketOptions: {}, + socketOptions, connectParams: { ...baseParams, client: { diff --git a/src/lib/controlplane/openclaw-adapter.ts b/src/lib/controlplane/openclaw-adapter.ts index b28904b35..48ce15a83 100644 --- a/src/lib/controlplane/openclaw-adapter.ts +++ b/src/lib/controlplane/openclaw-adapter.ts @@ -172,13 +172,14 @@ const loadGatewaySettings = (): ControlPlaneGatewaySettings => { const gateway = settings.gateway; const url = typeof gateway?.url === "string" ? gateway.url.trim() : ""; const token = typeof gateway?.token === "string" ? gateway.token.trim() : ""; + const allowSelfSignedCerts = gateway?.allowSelfSignedCerts === true; if (!url) { throw new Error("Control-plane start failed: Studio gateway URL is not configured."); } if (!token) { throw new Error("Control-plane start failed: Studio gateway token is not configured."); } - return { url, token }; + return { url, token, allowSelfSignedCerts }; }; export type OpenClawAdapterOptions = { @@ -211,7 +212,16 @@ export class OpenClawGatewayAdapter { constructor(options?: OpenClawAdapterOptions) { this.loadSettings = options?.loadSettings ?? loadGatewaySettings; - this.createWebSocket = options?.createWebSocket ?? ((url, opts) => new WebSocket(url, opts)); + this.createWebSocket = options?.createWebSocket ?? ((url, opts) => { + const wsOptions: any = {}; + if (opts.origin) { + wsOptions.origin = opts.origin; + } + if (opts.rejectUnauthorized === false) { + wsOptions.rejectUnauthorized = false; + } + return new WebSocket(url, wsOptions); + }); this.methodAllowlist = options?.methodAllowlist ?? DEFAULT_METHOD_ALLOWLIST; this.onDomainEvent = options?.onDomainEvent; } @@ -325,6 +335,7 @@ export class OpenClawGatewayAdapter { token: settings.token, protocol: CONNECT_PROTOCOL, capabilities: CONNECT_CAPABILITIES, + rejectUnauthorized: settings.allowSelfSignedCerts ? false : undefined, }); this.connectionEpoch = randomUUID(); const ws = this.createWebSocket(settings.url, profile.socketOptions); diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts index 0a2dc9009..bd3074adb 100644 --- a/src/lib/studio/settings.ts +++ b/src/lib/studio/settings.ts @@ -1,11 +1,13 @@ export type StudioGatewaySettings = { url: string; token: string; + allowSelfSignedCerts?: boolean; }; type StudioGatewaySettingsPatch = { url?: string | null; token?: string | null; + allowSelfSignedCerts?: boolean | null; }; type FocusFilter = "all" | "running" | "approvals"; @@ -128,7 +130,8 @@ const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null const url = normalizeGatewayUrl(value.url); if (!url) return null; const token = coerceString(value.token); - return { url, token }; + const allowSelfSignedCerts = typeof value.allowSelfSignedCerts === "boolean" ? value.allowSelfSignedCerts : false; + return { url, token, allowSelfSignedCerts }; }; const hasOwn = (value: Record, key: string) => @@ -144,8 +147,11 @@ const mergeGatewaySettings = ( const nextUrl = hasOwn(patch, "url") ? normalizeGatewayUrl(patch.url) : current?.url ?? ""; const nextToken = hasOwn(patch, "token") ? coerceString(patch.token) : current?.token ?? ""; + const nextAllowSelfSignedCerts = hasOwn(patch, "allowSelfSignedCerts") ? + (typeof patch.allowSelfSignedCerts === "boolean" ? patch.allowSelfSignedCerts : false) : + current?.allowSelfSignedCerts ?? false; if (!nextUrl) return null; - return { url: nextUrl, token: nextToken }; + return { url: nextUrl, token: nextToken, allowSelfSignedCerts: nextAllowSelfSignedCerts }; }; const normalizeFocused = (value: unknown): Record => { diff --git a/src/lib/studio/useStudioGatewaySettings.ts b/src/lib/studio/useStudioGatewaySettings.ts index dc53bdd2c..c3602e427 100644 --- a/src/lib/studio/useStudioGatewaySettings.ts +++ b/src/lib/studio/useStudioGatewaySettings.ts @@ -125,6 +125,7 @@ type StudioGatewaySettingsState = { gatewayUrl: string; draftGatewayUrl: string; token: string; + allowSelfSignedCerts: boolean; localGatewayDefaults: StudioGatewaySettings | null; localGatewayDefaultsHasToken: boolean; hasStoredToken: boolean; @@ -147,6 +148,7 @@ type StudioGatewaySettingsState = { useLocalGatewayDefaults: () => void; setGatewayUrl: (value: string) => void; setToken: (value: string) => void; + setAllowSelfSignedCerts: (value: boolean) => void; applyRuntimeStatusEvent: (event: { status?: unknown; reason?: unknown } | null) => void; clearError: () => void; }; @@ -178,6 +180,7 @@ export const useStudioGatewaySettings = ( const [gatewayUrl, setGatewayUrlState] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); const [draftGatewayUrl, setDraftGatewayUrlState] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); const [token, setTokenState] = useState(""); + const [allowSelfSignedCerts, setAllowSelfSignedCertsState] = useState(false); const [localGatewayDefaults, setLocalGatewayDefaults] = useState( null ); @@ -273,7 +276,9 @@ export const useStudioGatewaySettings = ( const settings = envelope.settings ?? null; const gateway = settings?.gateway ?? null; const nextUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; + const nextAllowSelfSignedCerts = gateway?.allowSelfSignedCerts === true; setGatewayUrlState(nextUrl); + setAllowSelfSignedCertsState(nextAllowSelfSignedCerts); setHasStoredToken(Boolean(envelope.gatewayMeta?.hasStoredToken)); setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); setLocalGatewayDefaultsHasToken(Boolean(envelope.localGatewayDefaultsMeta?.hasToken)); @@ -344,8 +349,8 @@ export const useStudioGatewaySettings = ( await settingsCoordinator.flushPending(); const patch: StudioSettingsPatch = { gateway: trimmedToken - ? { url: trimmedGatewayUrl, token: trimmedToken } - : { url: trimmedGatewayUrl }, + ? { url: trimmedGatewayUrl, token: trimmedToken, allowSelfSignedCerts } + : { url: trimmedGatewayUrl, allowSelfSignedCerts }, }; const envelope = await fetchJson("/api/studio", { method: "PUT", @@ -399,6 +404,7 @@ export const useStudioGatewaySettings = ( gateway: { url: trimmedGatewayUrl, token: token.trim(), + allowSelfSignedCerts: allowSelfSignedCerts, }, useStoredToken: token.trim().length === 0, }), @@ -485,10 +491,20 @@ export const useStudioGatewaySettings = ( [] ); + const setAllowSelfSignedCerts = useCallback( + (value: boolean) => { + setAllowSelfSignedCertsState(value); + setActionError(null); + setTestResult(null); + }, + [] + ); + const useLocalGatewayDefaults = useCallback(() => { if (!localGatewayDefaults) return; setDraftGatewayUrlState(localGatewayDefaults.url); setTokenState(""); + setAllowSelfSignedCertsState(localGatewayDefaults.allowSelfSignedCerts === true); setActionError(null); setTestResult(null); }, [localGatewayDefaults]); @@ -505,6 +521,7 @@ export const useStudioGatewaySettings = ( gatewayUrl, draftGatewayUrl, token, + allowSelfSignedCerts, localGatewayDefaults, localGatewayDefaultsHasToken, hasStoredToken, @@ -522,10 +539,12 @@ export const useStudioGatewaySettings = ( useLocalGatewayDefaults, setGatewayUrl, setToken, + setAllowSelfSignedCerts, applyRuntimeStatusEvent, clearError, }), [ + allowSelfSignedCerts, applyRuntimeStatusEvent, clearError, disconnect, @@ -541,6 +560,7 @@ export const useStudioGatewaySettings = ( saveSettings, saving, disconnecting, + setAllowSelfSignedCerts, setGatewayUrl, setToken, status, diff --git a/tests/unit/connectionPanel-close.test.ts b/tests/unit/connectionPanel-close.test.ts index 81a053d4a..7c57db5cc 100644 --- a/tests/unit/connectionPanel-close.test.ts +++ b/tests/unit/connectionPanel-close.test.ts @@ -7,6 +7,7 @@ const buildProps = () => ({ savedGatewayUrl: "ws://127.0.0.1:18789", draftGatewayUrl: "ws://127.0.0.1:18789", token: "token", + allowSelfSignedCerts: false, hasStoredToken: true, localGatewayDefaultsHasToken: false, hasUnsavedChanges: false, @@ -19,6 +20,7 @@ const buildProps = () => ({ disconnecting: false, onGatewayUrlChange: vi.fn(), onTokenChange: vi.fn(), + onAllowSelfSignedCertsChange: vi.fn(), onSaveSettings: vi.fn(), onTestConnection: vi.fn(), onDisconnect: vi.fn(), From 45ba4819d960071eeabe89ade4f58cf0fbb07caa Mon Sep 17 00:00:00 2001 From: Daniel Nylander Date: Sat, 21 Mar 2026 16:19:17 +0100 Subject: [PATCH 2/2] fix: update tests and styling for allowSelfSignedCerts feature - Fix unit tests to include allowSelfSignedCerts field in assertions - Use semantic ui-alert-danger class for warning messages - Update test expectations in studioSettings and studioSettingsRoute tests --- src/features/agents/components/ConnectionPanel.tsx | 8 +++++--- src/features/agents/components/GatewayConnectScreen.tsx | 8 +++++--- tests/unit/studioSettings.test.ts | 1 + tests/unit/studioSettingsRoute.test.ts | 7 ++++--- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index 21507e632..41c66647e 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -150,9 +150,11 @@ export const ConnectionPanel = ({ {allowSelfSignedCerts && ( -

- ⚠️ - Self-signed certificates are enabled. Do not use in production. +
+
+ ⚠️ + Self-signed certificates are enabled. Do not use in production. +
)} diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index f26a6f363..0cd18986e 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -326,9 +326,11 @@ export const GatewayConnectScreen = ({
{allowSelfSignedCerts && ( -
- ⚠️ - Self-signed certificates are enabled. Do not use in production. +
+
+ ⚠️ + Self-signed certificates are enabled. Do not use in production. +
)} diff --git a/tests/unit/studioSettings.test.ts b/tests/unit/studioSettings.test.ts index add6feed8..b38470148 100644 --- a/tests/unit/studioSettings.test.ts +++ b/tests/unit/studioSettings.test.ts @@ -172,6 +172,7 @@ describe("studio settings normalization", () => { expect(merged.gateway).toEqual({ url: "ws://gateway.new:18789", token: "secret-token", + allowSelfSignedCerts: false, }); }); diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts index 5b68c83cf..65acefc88 100644 --- a/tests/unit/studioSettingsRoute.test.ts +++ b/tests/unit/studioSettingsRoute.test.ts @@ -125,7 +125,7 @@ describe("studio settings route", () => { }; expect(getResponse.status).toBe(200); - expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "" }); + expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "", allowSelfSignedCerts: false }); expect(body.gatewayMeta?.hasStoredToken).toBe(true); const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json"); @@ -135,7 +135,7 @@ describe("studio settings route", () => { gateway?: { url?: string; token?: string } | null; gatewayAutoStart?: boolean; }; - expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" }); + expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t", allowSelfSignedCerts: false }); expect(parsed.gatewayAutoStart).toBe(true); }); @@ -169,7 +169,7 @@ describe("studio settings route", () => { gatewayMeta?: { hasStoredToken?: unknown }; }; expect(getResponse.status).toBe(200); - expect(body.settings?.gateway).toEqual({ url: "ws://new.example:18789", token: "" }); + expect(body.settings?.gateway).toEqual({ url: "ws://new.example:18789", token: "", allowSelfSignedCerts: false }); expect(body.gatewayMeta?.hasStoredToken).toBe(true); const persisted = JSON.parse( @@ -178,6 +178,7 @@ describe("studio settings route", () => { expect(persisted.gateway).toEqual({ url: "ws://new.example:18789", token: "secret-token", + allowSelfSignedCerts: false, }); expect(persisted.gatewayAutoStart).toBe(true); });