fix(control-ui): allow configured chat message width

This commit is contained in:
Val Alexander
2026-05-02 09:39:41 -05:00
parent 9d5a0d4094
commit cea25a4ca9
23 changed files with 206 additions and 4 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI: allow deployments to configure grouped chat message max-width with a validated `gateway.controlUi.chatMessageMaxWidth` setting instead of patching bundled CSS after upgrades. Fixes #67935. Thanks @xiew4589-lang.
- Control UI/sessions: bound the default Sessions tab query to recent activity and fewer rows, avoiding expensive full-history loads while keeping filters editable. Fixes #76050. (#76051) Thanks @Neomail2.
- Gateway/channels: cap startup fanout at four channel/account handoffs and recover from Bonjour ciao self-probe races, reducing Windows startup stalls with many Telegram accounts. Fixes #75687.
- CLI/update: treat inherited Gateway service markers as origin hints and only block package replacement when the managed Gateway is still live, so self-updates can stop the service and continue safely. (#75729) Thanks @hxy91819.

View File

@@ -1,4 +1,4 @@
f3a0cf57605c6c25ce162080d2631c0256018c2ec128383d521153f65e69a699 config-baseline.json
711b933e8748fe220d4be1bcc7df74503ab9c5973e967839302b8c5c773ecebf config-baseline.core.json
cf956c5e58ec0e36cf47708b0cd42fa34b1f39d0da951de343be0ba6e5b28168 config-baseline.json
057e444dfc78472bac172d9d8a7bd9c9a40f9ca4755268307cfcbd7e87a4d932 config-baseline.core.json
a2a949a99f5cc5960d4d7ae0159b6b48c4d6b1f813be67cda196457ab2f88034 config-baseline.channel.json
fffe0e74eab92a88c3c57952a70bc932438ce3a7f5f9982688437f2cdaee0bcb config-baseline.plugin.json

View File

@@ -366,6 +366,7 @@ See [Plugins](/tools/plugin).
// root: "dist/control-ui",
// embedSandbox: "scripts", // strict | scripts | trusted
// allowExternalEmbedUrls: false, // dangerous: allow absolute external http(s) embed URLs
// chatMessageMaxWidth: "min(1280px, 82%)", // optional grouped chat message max-width
// allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI
// dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode
// allowInsecureAuth: false,
@@ -427,6 +428,7 @@ See [Plugins](/tools/plugin).
lock out a different origin.
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
- `controlUi.chatMessageMaxWidth`: optional max-width for grouped Control UI chat messages. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side process-environment

View File

@@ -247,6 +247,22 @@ Use `trusted` only when the embedded document genuinely needs same-origin behavi
Absolute external `http(s)` embed URLs stay blocked by default. If you intentionally want `[embed url="https://..."]` to load third-party pages, set `gateway.controlUi.allowExternalEmbedUrls: true`.
## Chat message width
Grouped chat messages use a readable default max-width. Wide-monitor deployments can override it without patching bundled CSS by setting `gateway.controlUi.chatMessageMaxWidth`:
```json5
{
gateway: {
controlUi: {
chatMessageMaxWidth: "min(1280px, 82%)",
},
},
}
```
The value is validated before it reaches the browser. Supported values include plain lengths and percentages such as `960px` or `82%`, plus constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and `fit-content(...)` width expressions.
## Tailnet access (recommended)
<Tabs>

View File

@@ -296,6 +296,52 @@ describe("gateway.controlUi.allowExternalEmbedUrls", () => {
});
});
describe("gateway.controlUi.chatMessageMaxWidth", () => {
it("accepts constrained CSS width values", () => {
for (const value of ["960px", "82%", "min(1280px, 82%)", "calc(100% - 2rem)"]) {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
chatMessageMaxWidth: value,
},
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.gateway?.controlUi?.chatMessageMaxWidth).toBe(value);
}
}
});
it("normalizes whitespace around the width value", () => {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
chatMessageMaxWidth: " min(1280px, 82%) ",
},
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.gateway?.controlUi?.chatMessageMaxWidth).toBe("min(1280px, 82%)");
}
});
it("rejects arbitrary CSS injection", () => {
for (const value of ["url(https://example.com/x)", "960px; color: red", "var(--x)"]) {
const result = OpenClawSchema.safeParse({
gateway: {
controlUi: {
chatMessageMaxWidth: value,
},
},
});
expect(result.success).toBe(false);
}
});
});
describe("plugins.entries.*.hooks", () => {
it.each([true, false])("accepts allowConversationAccess=%s", (allowConversationAccess) => {
const result = OpenClawSchema.safeParse({

View File

@@ -0,0 +1,60 @@
const CSS_WIDTH_KEYWORDS = new Set(["none", "min-content", "max-content"]);
const CSS_WIDTH_FUNCTIONS = new Set(["calc", "clamp", "fit-content", "max", "min"]);
const CSS_WIDTH_UNITS = new Set(["ch", "em", "rem", "vh", "vmax", "vmin", "vw", "px"]);
const CSS_WIDTH_ALLOWED_CHARS = /^[0-9A-Za-z.%+\-*/(),\s]+$/;
const CSS_WIDTH_IDENTIFIER_RE = /[A-Za-z][A-Za-z0-9-]*/g;
const CSS_WIDTH_SIMPLE_RE = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|vw|vh|vmin|vmax|%)$/i;
const CSS_WIDTH_MAX_LENGTH = 96;
function hasBalancedParentheses(value: string): boolean {
let depth = 0;
for (const char of value) {
if (char === "(") {
depth++;
} else if (char === ")") {
depth--;
if (depth < 0) {
return false;
}
}
}
return depth === 0;
}
function hasAllowedIdentifiers(value: string): boolean {
for (const match of value.matchAll(CSS_WIDTH_IDENTIFIER_RE)) {
const identifier = match[0].toLowerCase();
if (
!CSS_WIDTH_FUNCTIONS.has(identifier) &&
!CSS_WIDTH_KEYWORDS.has(identifier) &&
!CSS_WIDTH_UNITS.has(identifier)
) {
return false;
}
}
return true;
}
export function normalizeControlUiChatMessageMaxWidth(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export function isValidControlUiChatMessageMaxWidth(value: string): boolean {
const normalized = normalizeControlUiChatMessageMaxWidth(value);
if (normalized.length === 0 || normalized.length > CSS_WIDTH_MAX_LENGTH) {
return false;
}
if (CSS_WIDTH_KEYWORDS.has(normalized.toLowerCase())) {
return true;
}
if (CSS_WIDTH_SIMPLE_RE.test(normalized)) {
return true;
}
if (!CSS_WIDTH_ALLOWED_CHARS.test(normalized)) {
return false;
}
if (!hasBalancedParentheses(normalized) || !hasAllowedIdentifiers(normalized)) {
return false;
}
return /^(?:calc|clamp|fit-content|max|min)\(.+\)$/i.test(normalized);
}

View File

@@ -22293,6 +22293,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
},
chatMessageMaxWidth: {
title: "Control UI Chat Message Max Width",
description:
'Optional CSS max-width for grouped Control UI chat messages, for example "960px", "82%", or "min(1280px, 82%)". Values are validated against a constrained width grammar before reaching the browser.',
},
allowedOrigins: {
type: "array",
items: {
@@ -25988,6 +25993,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
tags: ["security", "access", "network", "advanced"],
},
"gateway.controlUi.chatMessageMaxWidth": {
label: "Control UI Chat Message Max Width",
help: 'Optional CSS max-width for grouped Control UI chat messages, for example "960px", "82%", or "min(1280px, 82%)". Values are validated against a constrained width grammar before reaching the browser.',
tags: ["advanced"],
},
"gateway.controlUi.allowedOrigins": {
label: "Control UI Allowed Origins",
help: 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.',

View File

@@ -458,6 +458,8 @@ export const FIELD_HELP: Record<string, string> = {
'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.',
"gateway.controlUi.allowExternalEmbedUrls":
"DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
"gateway.controlUi.chatMessageMaxWidth":
'Optional CSS max-width for grouped Control UI chat messages, for example "960px", "82%", or "min(1280px, 82%)". Values are validated against a constrained width grammar before reaching the browser.',
"gateway.controlUi.allowedOrigins":
'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.',
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":

View File

@@ -308,6 +308,7 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode",
"gateway.controlUi.allowExternalEmbedUrls": "Allow External Control UI Embed URLs",
"gateway.controlUi.chatMessageMaxWidth": "Control UI Chat Message Max Width",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
"Dangerously Allow Host-Header Origin Fallback",

View File

@@ -45,6 +45,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.push.apns.relay.baseUrl": ["network", "advanced"],
"gateway.controlUi.embedSandbox": ["security", "access", "advanced"],
"gateway.controlUi.allowExternalEmbedUrls": ["security", "access", "network", "advanced"],
"gateway.controlUi.chatMessageMaxWidth": ["advanced"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
"security",
"access",

View File

@@ -99,6 +99,8 @@ export type GatewayControlUiConfig = {
* Default off; prefer hosted /__openclaw__/canvas or /__openclaw__/a2ui content.
*/
allowExternalEmbedUrls?: boolean;
/** Optional max-width for grouped Control UI chat messages (default: min(900px, 68%)). */
chatMessageMaxWidth?: string;
/** Allowed browser origins for Control UI/WebChat websocket connections. */
allowedOrigins?: string[];
/**

View File

@@ -5,6 +5,10 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import {
isValidControlUiChatMessageMaxWidth,
normalizeControlUiChatMessageMaxWidth,
} from "./control-ui-css.js";
import {
SilentReplyPolicyConfigSchema,
SilentReplyRewriteConfigSchema,
@@ -784,6 +788,14 @@ export const OpenClawSchema = z
.union([z.literal("strict"), z.literal("scripts"), z.literal("trusted")])
.optional(),
allowExternalEmbedUrls: z.boolean().optional(),
chatMessageMaxWidth: z
.string()
.transform((value) => normalizeControlUiChatMessageMaxWidth(value))
.refine((value) => isValidControlUiChatMessageMaxWidth(value), {
message:
"Expected a CSS width value such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem)",
})
.optional(),
allowedOrigins: z.array(z.string()).optional(),
dangerouslyAllowHostHeaderOriginFallback: z.boolean().optional(),
allowInsecureAuth: z.boolean().optional(),

View File

@@ -14,4 +14,5 @@ export type ControlUiBootstrapConfig = {
localMediaPreviewRoots?: string[];
embedSandbox?: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
chatMessageMaxWidth?: string;
};

View File

@@ -37,6 +37,7 @@ describe("handleControlUiHttpRequest", () => {
assistantAvatar: string;
assistantAgentId: string;
localMediaPreviewRoots?: string[];
chatMessageMaxWidth?: string;
};
}
@@ -594,6 +595,7 @@ describe("handleControlUiHttpRequest", () => {
root: { kind: "resolved", path: tmp },
config: {
agents: { defaults: { workspace: tmp } },
gateway: { controlUi: { chatMessageMaxWidth: "min(1280px, 82%)" } },
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" } },
},
},
@@ -604,6 +606,7 @@ describe("handleControlUiHttpRequest", () => {
expect(parsed.assistantName).toBe("</script><script>alert(1)//");
expect(parsed.assistantAvatar).toBe("/avatar/main");
expect(parsed.assistantAgentId).toBe("main");
expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)");
expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true);
},
});

View File

@@ -805,6 +805,7 @@ export async function handleControlUiHttpRequest(
? "strict"
: "scripts",
allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true,
chatMessageMaxWidth: config?.gateway?.controlUi?.chatMessageMaxWidth,
} satisfies ControlUiBootstrapConfig);
return true;
}

View File

@@ -24,7 +24,7 @@
gap: 2px;
flex: 1 1 auto;
width: 100%;
max-width: min(900px, 68%);
max-width: var(--chat-message-max-width, min(900px, 68%));
align-items: flex-start;
min-width: 0;
}

View File

@@ -20,6 +20,15 @@ function readLayoutCss(): string {
return readFileSync(cssPath!, "utf8");
}
function readGroupedChatCss(): string {
const cssPath = [
resolve(process.cwd(), "ui/src/styles/chat/grouped.css"),
resolve(process.cwd(), "..", "ui/src/styles/chat/grouped.css"),
].find((candidate) => existsSync(candidate));
expect(cssPath).toBeTruthy();
return readFileSync(cssPath!, "utf8");
}
describe("chat header responsive mobile styles", () => {
it("keeps the chat header and session controls from clipping on narrow widths", () => {
const css = readMobileCss();
@@ -46,3 +55,11 @@ describe("sidebar menu trigger styles", () => {
expect(css).toContain("display: none;");
});
});
describe("grouped chat width styles", () => {
it("uses the config-fed CSS variable with the current fallback", () => {
const css = readGroupedChatCss();
expect(css).toContain("max-width: var(--chat-message-max-width, min(900px, 68%));");
});
});

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { html } from "lit";
import { html, render } from "lit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppViewState } from "./app-view-state.ts";
import type { QuickSettingsProps } from "./views/config-quick.ts";
@@ -89,6 +89,7 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
localMediaPreviewRoots: [],
embedSandboxMode: "scripts",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
sessionKey: "main",
chatLoading: false,
chatSending: false,
@@ -227,4 +228,16 @@ describe("renderApp assistant avatar routing", () => {
expect(quickSettingsProps.current?.assistantAvatarReason).toBeNull();
expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(dataUrl);
});
it("applies the configured chat message width as a shell CSS variable", () => {
const container = document.createElement("div");
render(
renderApp(createState({ tab: "chat", chatMessageMaxWidth: "min(1280px, 82%)" })),
container,
);
const shell = container.querySelector<HTMLElement>(".shell");
expect(shell?.style.getPropertyValue("--chat-message-max-width")).toBe("min(1280px, 82%)");
});
});

View File

@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { styleMap } from "lit/directives/style-map.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChat } from "./app-chat.ts";
@@ -1329,6 +1330,9 @@ export function renderApp(state: AppViewState) {
: ""} ${navCollapsed ? "shell--nav-collapsed" : ""} ${navDrawerOpen
? "shell--nav-drawer-open"
: ""} ${state.onboarding ? "shell--onboarding" : ""}"
style=${styleMap(
state.chatMessageMaxWidth ? { "--chat-message-max-width": state.chatMessageMaxWidth } : {},
)}
>
<button
type="button"

View File

@@ -84,6 +84,7 @@ export type AppViewState = {
localMediaPreviewRoots: string[];
embedSandboxMode: EmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth?: string | null;
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;

View File

@@ -189,6 +189,7 @@ export class OpenClawApp extends LitElement {
@state() localMediaPreviewRoots: string[] = [];
@state() embedSandboxMode: "strict" | "scripts" | "trusted" = "scripts";
@state() allowExternalEmbedUrls = false;
@state() chatMessageMaxWidth: string | null = null;
@state() serverVersion: string | null = null;
@state() sessionKey = this.settings.sessionKey;

View File

@@ -20,6 +20,7 @@ describe("loadControlUiBootstrapConfig", () => {
localMediaPreviewRoots: ["/tmp/openclaw"],
embedSandbox: "scripts",
allowExternalEmbedUrls: true,
chatMessageMaxWidth: "min(1280px, 82%)",
}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
@@ -35,6 +36,7 @@ describe("loadControlUiBootstrapConfig", () => {
localMediaPreviewRoots: [],
embedSandboxMode: "scripts" as const,
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
serverVersion: null,
};
@@ -54,6 +56,7 @@ describe("loadControlUiBootstrapConfig", () => {
expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]);
expect(state.embedSandboxMode).toBe("scripts");
expect(state.allowExternalEmbedUrls).toBe(true);
expect(state.chatMessageMaxWidth).toBe("min(1280px, 82%)");
vi.unstubAllGlobals();
});

View File

@@ -22,6 +22,7 @@ export type ControlUiBootstrapState = {
localMediaPreviewRoots: string[];
embedSandboxMode: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth?: string | null;
sessionKey?: string | null;
hello?: { auth?: { deviceToken?: string | null } | null } | null;
settings?: { token?: string | null } | null;
@@ -130,6 +131,10 @@ export async function loadControlUiBootstrapConfig(
? "strict"
: "scripts";
state.allowExternalEmbedUrls = parsed.allowExternalEmbedUrls === true;
state.chatMessageMaxWidth =
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
? parsed.chatMessageMaxWidth
: null;
} catch {
// Ignore bootstrap failures; UI will update identity after connecting.
}