mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
Fix gateway connect dead-end on insecure remote ws
This commit is contained in:
+19
-6
@@ -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" ? (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user