mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(ui): actionable diagnostics for missing widget sandbox origin behind proxies (#111909)
* fix(ui): actionable error and doctor warning for missing widget sandbox origin * fix(ui): phrase sandbox-origin diagnostics as conditional guidance * test(ui): satisfy deadcode and mock-factory gates for sandbox diagnostics
This commit is contained in:
@@ -6,6 +6,7 @@ import { OpenClawSchema } from "../config/zod-schema.js";
|
||||
import {
|
||||
formatConfigPath,
|
||||
noteImplicitFallbackClobberWarnings,
|
||||
noteSandboxOriginProxyWarning,
|
||||
resolveConfigPathTarget,
|
||||
stripUnknownConfigKeys,
|
||||
} from "./doctor-config-analysis.js";
|
||||
@@ -348,3 +349,33 @@ describe("collectImplicitFallbackClobberWarnings", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("noteSandboxOriginProxyWarning", () => {
|
||||
function warningsFor(cfg: OpenClawConfig): string[] {
|
||||
noteMock.mockClear();
|
||||
noteSandboxOriginProxyWarning(cfg);
|
||||
return noteMock.mock.calls.map((call) => String(call[0]));
|
||||
}
|
||||
|
||||
it("warns for trusted-proxy gateways without a sandbox origin", () => {
|
||||
const warnings = warningsFor({
|
||||
gateway: { auth: { mode: "trusted-proxy" } },
|
||||
} as OpenClawConfig);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("mcp.apps.sandboxOrigin is not set");
|
||||
expect(warnings[0]).toContain("sandbox listener");
|
||||
});
|
||||
|
||||
it("stays silent when a sandbox origin is configured", () => {
|
||||
const warnings = warningsFor({
|
||||
gateway: { auth: { mode: "trusted-proxy" } },
|
||||
mcp: { apps: { sandboxOrigin: "https://widgets.example.com" } },
|
||||
} as OpenClawConfig);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays silent for non-proxy auth modes", () => {
|
||||
expect(warningsFor({ gateway: { auth: { mode: "token" } } } as OpenClawConfig)).toHaveLength(0);
|
||||
expect(warningsFor({} as OpenClawConfig)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,3 +243,23 @@ export function noteIncludeConfinementWarning(snapshot: {
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
|
||||
/** Warns when a trusted-proxy gateway has no public sandbox origin for widget/MCP-app frames. */
|
||||
export function noteSandboxOriginProxyWarning(cfg: OpenClawConfig): void {
|
||||
// trusted-proxy auth means the Control UI is reached through a reverse proxy
|
||||
// or tunnel. Widget and MCP-app frames load from a separate sandbox listener
|
||||
// (gateway port + 1); without mcp.apps.sandboxOrigin the browser derives that
|
||||
// URL by port substitution, which such proxies do not route, and every
|
||||
// pinned widget fails to render.
|
||||
if (cfg.gateway?.auth?.mode !== "trusted-proxy" || cfg.mcp?.apps?.sandboxOrigin) {
|
||||
return;
|
||||
}
|
||||
note(
|
||||
[
|
||||
'- gateway.auth.mode is "trusted-proxy" but mcp.apps.sandboxOrigin is not set.',
|
||||
" Dashboard widgets and MCP apps render from a separate sandbox listener (gateway port + 1). If your proxy or tunnel does not also route that port, widget frames cannot load.",
|
||||
" Check: either route the sandbox port through your proxy, or set mcp.apps.sandboxOrigin to a dedicated public origin routed to the sandbox listener (see the MCP Apps section of docs/cli/mcp.md).",
|
||||
].join("\n"),
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1427,6 +1427,7 @@ vi.mock("./doctor-config-analysis.js", () => {
|
||||
noteImplicitFallbackClobberWarnings: noteImplicitFallbackClobberWarningsMock,
|
||||
noteIncludeConfinementWarning: vi.fn(),
|
||||
noteOpencodeProviderOverrides: vi.fn(),
|
||||
noteSandboxOriginProxyWarning: vi.fn(),
|
||||
resolveConfigPathTarget,
|
||||
stripUnknownConfigKeys: vi.fn((config: Record<string, unknown>) => {
|
||||
const next = structuredClone(config);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
noteImplicitFallbackClobberWarnings,
|
||||
noteOpencodeProviderOverrides,
|
||||
noteSandboxOriginProxyWarning,
|
||||
} from "./doctor-config-analysis.js";
|
||||
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
|
||||
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
||||
@@ -418,6 +419,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
|
||||
noteOpencodeProviderOverrides(cfg);
|
||||
noteImplicitFallbackClobberWarnings(cfg);
|
||||
noteSandboxOriginProxyWarning(cfg);
|
||||
|
||||
return {
|
||||
cfg,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BoardViewWidget } from "../../lib/board/view-types.ts";
|
||||
import { BoardWidgetFrameLifecycle } from "./board-widget-frame.ts";
|
||||
|
||||
type LifecycleInternals = {
|
||||
sandboxOrigin: string;
|
||||
frameFailureKey: string;
|
||||
frameRefreshAttempts: number;
|
||||
refreshFailedFrame: (widget: BoardViewWidget) => void;
|
||||
};
|
||||
|
||||
// Drives the private terminal-failure path directly: attempts are exhausted so
|
||||
// refreshFailedFrame surfaces the terminal message for the given sandbox origin.
|
||||
function terminalFailureError(params: {
|
||||
widget: Partial<BoardViewWidget>;
|
||||
resolvedSandboxOrigin: string;
|
||||
}): string {
|
||||
const widget = { name: "clock", revision: 1, ...params.widget } as BoardViewWidget;
|
||||
const lifecycle = new BoardWidgetFrameLifecycle({
|
||||
connected: () => true,
|
||||
context: () => undefined,
|
||||
refreshFrame: () => undefined,
|
||||
requestUpdate: () => {},
|
||||
resolveFrameUrl: () => () => "",
|
||||
root: () => document,
|
||||
widget: () => widget,
|
||||
});
|
||||
const internals = lifecycle as unknown as LifecycleInternals;
|
||||
internals.sandboxOrigin = params.resolvedSandboxOrigin;
|
||||
internals.frameFailureKey = `${widget.name}:${widget.revision}`;
|
||||
internals.frameRefreshAttempts = 3;
|
||||
internals.refreshFailedFrame(widget);
|
||||
return lifecycle.error;
|
||||
}
|
||||
|
||||
describe("board widget frame terminal failure message", () => {
|
||||
it("points at mcp.apps.sandboxOrigin when a derived remote sandbox origin fails", () => {
|
||||
const message = terminalFailureError({
|
||||
widget: {},
|
||||
resolvedSandboxOrigin: "https://team.example.com:18790",
|
||||
});
|
||||
expect(message).toContain("mcp.apps.sandboxOrigin");
|
||||
});
|
||||
|
||||
it("keeps the authorization message when a sandbox origin is explicitly configured", () => {
|
||||
const message = terminalFailureError({
|
||||
widget: { sandboxOrigin: "https://widgets.example.com" },
|
||||
resolvedSandboxOrigin: "https://widgets.example.com",
|
||||
});
|
||||
expect(message).toContain("authorization failed");
|
||||
expect(message).not.toContain("mcp.apps.sandboxOrigin");
|
||||
});
|
||||
|
||||
it("keeps the authorization message for loopback sandbox hosts", () => {
|
||||
for (const origin of [
|
||||
"http://localhost:18790",
|
||||
"http://127.0.0.1:18790",
|
||||
"http://[::1]:18790",
|
||||
]) {
|
||||
const message = terminalFailureError({ widget: {}, resolvedSandboxOrigin: origin });
|
||||
expect(message).toContain("authorization failed");
|
||||
expect(message).not.toContain("mcp.apps.sandboxOrigin");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the authorization message when no sandbox origin was resolved", () => {
|
||||
const message = terminalFailureError({ widget: {}, resolvedSandboxOrigin: "" });
|
||||
expect(message).toContain("authorization failed");
|
||||
expect(message).not.toContain("mcp.apps.sandboxOrigin");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,32 @@ const TICKET_REFRESH_MIN_DELAY_MS = 1_000;
|
||||
const TICKET_REFRESH_RETRY_MS = 1_000;
|
||||
const TICKET_REFRESH_MAX_RETRY_MS = 30_000;
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
}
|
||||
|
||||
// Without mcp.apps.sandboxOrigin the sandbox URL is the gateway origin with the
|
||||
// sandbox port substituted. On a non-loopback host that derived port often sits
|
||||
// behind a reverse proxy or tunnel that does not route it, and the browser
|
||||
// cannot distinguish that from a real authorization failure — so the terminal
|
||||
// message keeps the authorization fact but adds the deployment hint operators
|
||||
// otherwise never find.
|
||||
function resolveBoardFrameFailureMessage(
|
||||
widget: Pick<BoardViewWidget, "sandboxOrigin">,
|
||||
resolvedSandboxOrigin: string,
|
||||
): string {
|
||||
if (!widget.sandboxOrigin && resolvedSandboxOrigin) {
|
||||
try {
|
||||
if (!isLoopbackHostname(new URL(resolvedSandboxOrigin).hostname)) {
|
||||
return t("board.widget.sandboxOriginRequired");
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the generic message for unparseable origins.
|
||||
}
|
||||
}
|
||||
return t("board.widget.frameAuthorizationFailed");
|
||||
}
|
||||
|
||||
type FrameRefresh = (name: string) => Promise<void>;
|
||||
|
||||
type BoardWidgetFrameLifecycleHost = {
|
||||
@@ -211,7 +237,7 @@ export class BoardWidgetFrameLifecycle {
|
||||
this.frameFailureKey = failureKey;
|
||||
}
|
||||
if (this.frameRefreshAttempts >= MAX_FRAME_REFRESH_ATTEMPTS) {
|
||||
this.setError(t("board.widget.frameAuthorizationFailed"));
|
||||
this.setError(resolveBoardFrameFailureMessage(widget, this.sandboxOrigin));
|
||||
return;
|
||||
}
|
||||
const refreshFrame = this.host.refreshFrame();
|
||||
@@ -224,7 +250,7 @@ export class BoardWidgetFrameLifecycle {
|
||||
this.setError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
if (this.frameRefreshAttempts >= MAX_FRAME_REFRESH_ATTEMPTS) {
|
||||
this.setError(t("board.widget.frameAuthorizationFailed"));
|
||||
this.setError(resolveBoardFrameFailureMessage(widget, this.sandboxOrigin));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2631,6 +2631,8 @@ export const en: TranslationMap = {
|
||||
frameResolverMissing: "Widget content is unavailable.",
|
||||
sandboxUnavailable: "Widget sandbox host is unavailable.",
|
||||
frameAuthorizationFailed: "Widget authorization failed after repeated refresh attempts.",
|
||||
sandboxOriginRequired:
|
||||
"Widget authorization failed after repeated refresh attempts. If the gateway runs behind a reverse proxy or tunnel that does not route the widget sandbox port, set mcp.apps.sandboxOrigin to a dedicated public origin routed to the sandbox listener.",
|
||||
errorTitle: "This widget could not load",
|
||||
errorDetail: "The problem is contained to this card.",
|
||||
actionErrorTitle: "Widget change failed",
|
||||
|
||||
Reference in New Issue
Block a user