From bf6961d33efc1bc0883fd4fce898bb680ed7ba87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 10:35:47 -0700 Subject: [PATCH] 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 --- src/commands/doctor-config-analysis.test.ts | 31 ++++++++ src/commands/doctor-config-analysis.ts | 20 +++++ src/commands/doctor-config-flow.test.ts | 1 + src/commands/doctor-config-flow.ts | 2 + .../board/board-widget-frame.test.ts | 73 +++++++++++++++++++ ui/src/components/board/board-widget-frame.ts | 30 +++++++- ui/src/i18n/locales/en.ts | 2 + 7 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/board/board-widget-frame.test.ts diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts index 4d967269f43a..5276e9d3cc69 100644 --- a/src/commands/doctor-config-analysis.test.ts +++ b/src/commands/doctor-config-analysis.test.ts @@ -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); + }); +}); diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts index 3d137b7d6b76..fd40ec0b3716 100644 --- a/src/commands/doctor-config-analysis.ts +++ b/src/commands/doctor-config-analysis.ts @@ -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", + ); +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index c12fa531c71d..42aa331f358e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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) => { const next = structuredClone(config); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 333a567b03ef..75c8235cc6cb 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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, diff --git a/ui/src/components/board/board-widget-frame.test.ts b/ui/src/components/board/board-widget-frame.test.ts new file mode 100644 index 000000000000..aefbff41ea7b --- /dev/null +++ b/ui/src/components/board/board-widget-frame.test.ts @@ -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; + 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"); + }); +}); diff --git a/ui/src/components/board/board-widget-frame.ts b/ui/src/components/board/board-widget-frame.ts index 4935be0cd38b..bdb001426b22 100644 --- a/ui/src/components/board/board-widget-frame.ts +++ b/ui/src/components/board/board-widget-frame.ts @@ -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, + 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; 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)); } } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 226d941e9822..9a699a280186 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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",