Fix gateway connect dead-end on insecure remote ws

This commit is contained in:
George Pickett
2026-03-14 17:37:41 -07:00
parent 607e934a05
commit 2843a43984
8 changed files with 156 additions and 10 deletions
+19 -6
View File
@@ -264,6 +264,7 @@ const AgentStudioPage = () => {
const { state, dispatch, hydrateAgents, setError, setLoading } = useAgentStore();
const [showConnectionPanel, setShowConnectionPanel] = useState(false);
const [showConnectSetup, setShowConnectSetup] = useState(false);
const [focusFilter, setFocusFilter] = useState<FocusFilter>("all");
const [focusedPreferencesLoaded, setFocusedPreferencesLoaded] = useState(false);
const [agentsLoadedOnce, setAgentsLoadedOnce] = useState(false);
@@ -1370,32 +1371,44 @@ const AgentStudioPage = () => {
}
}, [gatewayError]);
if (!agentsLoadedOnce && !coreConnected && (!didAttemptGatewayConnect || gatewayConnecting)) {
if (
!agentsLoadedOnce &&
!coreConnected &&
!showConnectSetup &&
(!didAttemptGatewayConnect || gatewayConnecting)
) {
return (
<div className="relative min-h-dvh w-screen overflow-hidden bg-background">
<div className="flex min-h-dvh items-center justify-center px-6">
<div className="glass-panel ui-panel w-full max-w-md px-6 py-6 text-center">
<div className="glass-panel ui-panel flex w-full max-w-md flex-col items-center px-6 py-6 text-center">
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
OpenClaw Studio
</div>
<div className="mt-3 text-sm text-muted-foreground">
{gatewayConnecting ? "Connecting to gateway…" : "Booting Studio…"}
</div>
<button
type="button"
className="ui-btn-secondary mt-4 px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={() => setShowConnectSetup(true)}
>
Edit connection settings
</button>
</div>
</div>
</div>
);
}
if (!coreConnected && !agentsLoadedOnce && didAttemptGatewayConnect) {
if (!coreConnected && !agentsLoadedOnce && (didAttemptGatewayConnect || showConnectSetup)) {
return (
<div className="relative min-h-dvh w-screen overflow-hidden bg-background">
<div className="relative z-10 flex h-dvh flex-col">
<div className="relative min-h-dvh w-screen overflow-y-auto bg-background">
<div className="relative z-10 flex min-h-dvh flex-col">
<HeaderBar
status={gatewayStatus}
onConnectionSettings={() => setShowConnectionPanel(true)}
/>
<div className="flex min-h-0 flex-1 flex-col gap-4 px-3 pb-3 pt-3 sm:px-4 sm:pb-4 sm:pt-4 md:px-6 md:pb-6 md:pt-4">
<div className="flex flex-1 flex-col gap-4 px-3 pb-6 pt-3 sm:px-4 sm:pb-6 sm:pt-4 md:px-6 md:pt-4">
{settingsRouteActive ? (
<div className="w-full">
<button
@@ -345,7 +345,7 @@ export const GatewayConnectScreen = ({
</div>
);
return (
<div className="mx-auto flex min-h-0 w-full max-w-[820px] flex-1 flex-col gap-5">
<div className="mx-auto flex w-full max-w-[820px] flex-1 flex-col gap-5 pb-4">
<div className="ui-card px-4 py-2">
<div className="flex items-start gap-3">
{status === "connecting" || status === "reconnecting" ? (
+13 -1
View File
@@ -105,6 +105,11 @@ const resolveConnectFailureMessage = (error: unknown, upstreamUrl: string): stri
return `Control-plane gateway connection failed: ${details}`;
};
const isConnectRejectionError = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
return error.message.startsWith("Control-plane connect rejected:");
};
const loadGatewaySettings = (): ControlPlaneGatewaySettings => {
const settings = loadStudioSettings();
const gateway = settings.gateway;
@@ -261,6 +266,7 @@ export class OpenClawGatewayAdapter {
await new Promise<void>((resolve, reject) => {
let settled = false;
let allowReconnectAfterClose = true;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
@@ -307,6 +313,7 @@ export class OpenClawGatewayAdapter {
const code = parsed.error?.code ?? "CONNECT_FAILED";
const message = parsed.error?.message ?? "Connect failed.";
settle(() => {
allowReconnectAfterClose = false;
ws.close(1011, "connect failed");
reject(new Error(`Control-plane connect rejected: ${code} ${message}`));
});
@@ -321,6 +328,9 @@ export class OpenClawGatewayAdapter {
}
this.rejectPending("Control-plane gateway connection closed.");
this.connectionEpoch = null;
if (!allowReconnectAfterClose) {
return;
}
this.updateStatus("reconnecting", "gateway_closed");
this.scheduleReconnect();
});
@@ -334,7 +344,9 @@ export class OpenClawGatewayAdapter {
}).catch((err) => {
this.connectionEpoch = null;
this.updateStatus("error", err instanceof Error ? err.message : "connect_error");
this.scheduleReconnect();
if (!isConnectRejectionError(err)) {
this.scheduleReconnect();
}
throw err;
});
}
+9
View File
@@ -181,6 +181,15 @@ export const resolveGatewayConnectionWarnings = (params: {
});
}
if (!localGateway && parsed.protocol === "ws:") {
warnings.push({
id: "remote-ws-control-ui-auth",
tone: "warn",
message:
"Remote ws:// gateway URLs are fragile with modern OpenClaw auth. Prefer wss:// via Tailscale Serve, or tunnel the gateway to ws://localhost from the Studio host.",
});
}
if (!localGateway && isPrivateHost(hostname)) {
warnings.push({
id: "private-ip-advanced",
+14 -2
View File
@@ -38,7 +38,17 @@ const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings |
};
const formatGatewayError = (error: unknown): string => {
if (error instanceof Error) return error.message;
if (error instanceof Error) {
const message = error.message.trim();
const normalized = message.toLowerCase();
if (
normalized.includes("control ui requires device identity") ||
normalized.includes("secure context")
) {
return "OpenClaw rejected this connection because its control-ui compatibility mode needs HTTPS or localhost device identity. Use wss:// via Tailscale Serve, or tunnel the gateway to ws://localhost from the Studio host.";
}
return message;
}
return "Unknown gateway error.";
};
@@ -354,7 +364,9 @@ export const useStudioGatewaySettings = (
}),
});
if (response.ok !== true) {
const message = readString(response.error) || "Connection test failed.";
const message = readString(response.error)
? formatGatewayError(new Error(readString(response.error)))
: "Connection test failed.";
setActionError(message);
setTestResult({ kind: "error", message });
return false;
+1
View File
@@ -38,6 +38,7 @@ describe("studio install context helpers", () => {
});
expect(warnings.map((warning) => warning.id)).toContain("tailscale-ws");
expect(warnings.map((warning) => warning.id)).toContain("remote-ws-control-ui-auth");
expect(warnings.map((warning) => warning.id)).toContain("tailscale-still-needs-token");
});
+65
View File
@@ -202,6 +202,71 @@ describe("OpenClawGatewayAdapter", () => {
await adapter.stop();
});
it("does not retry after the gateway rejects the connect request", async () => {
vi.useFakeTimers();
class RejectingConnectSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
close() {
if (this.readyState === WebSocket.CLOSED) return;
this.readyState = WebSocket.CLOSED;
this.emit("close");
}
terminate() {
this.close();
}
send(raw: string, callback?: (err?: Error) => void) {
const parsed = JSON.parse(raw) as { id?: string; method?: string };
callback?.();
if (parsed.method !== "connect" || !parsed.id) {
return;
}
queueMicrotask(() => {
this.emit(
"message",
JSON.stringify({
type: "res",
id: parsed.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "control ui requires device identity (use HTTPS or localhost secure context)",
},
})
);
});
}
}
const createWebSocket = vi.fn(() => {
const socket = new RejectingConnectSocket();
queueMicrotask(() => {
socket.emit(
"message",
JSON.stringify({ type: "event", event: "connect.challenge", payload: {} })
);
});
return socket as unknown as WebSocket;
});
const adapter = new OpenClawGatewayAdapter({
loadSettings: () => ({ url: "ws://10.0.0.8:18789", token: "tkn" }),
createWebSocket,
});
await expect(adapter.start()).rejects.toThrow(
"Control-plane connect rejected: INVALID_REQUEST control ui requires device identity"
);
await vi.advanceTimersByTimeAsync(20_000);
expect(createWebSocket).toHaveBeenCalledTimes(1);
await adapter.stop();
});
it("emits gateway events with unique connection epochs across reconnect cycles", async () => {
upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
@@ -171,4 +171,38 @@ describe("useStudioGatewaySettings", () => {
expect(ctx.getValue().disconnecting).toBe(false);
ctx.unmount();
});
it("shows actionable guidance for control-ui secure-context gateway errors", async () => {
mockedFetchJson.mockImplementation(async (input) => {
if (input === "/api/studio/test-connection") {
return {
ok: false,
error:
"Control-plane connect rejected: INVALID_REQUEST control ui requires device identity (use HTTPS or localhost secure context)",
};
}
throw new Error(`Unexpected fetchJson call: ${String(input)}`);
});
const ctx = renderHook();
await waitFor(() => {
expect(ctx.getValue().status).toBe("connected");
});
await act(async () => {
await ctx.getValue().testConnection();
});
expect(ctx.getValue().testResult).toEqual({
kind: "error",
message:
"OpenClaw rejected this connection because its control-ui compatibility mode needs HTTPS or localhost device identity. Use wss:// via Tailscale Serve, or tunnel the gateway to ws://localhost from the Studio host.",
});
expect(ctx.getValue().error).toBe(
"OpenClaw rejected this connection because its control-ui compatibility mode needs HTTPS or localhost device identity. Use wss:// via Tailscale Serve, or tunnel the gateway to ws://localhost from the Studio host."
);
ctx.unmount();
});
});