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:
Peter Steinberger
2026-07-20 10:35:47 -07:00
committed by GitHub
parent 271e6b7391
commit bf6961d33e
7 changed files with 157 additions and 2 deletions
@@ -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);
});
});
+20
View File
@@ -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",
);
}
+1
View File
@@ -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);
+2
View File
@@ -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");
});
});
+28 -2
View File
@@ -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));
}
}
+2
View File
@@ -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",