diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f88b4c8ce..0a05568fc48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 790e4babaf6..ce2ec2080e7 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 231775c65e4..a1d2bf420f3 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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 diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 46fe5863ca5..a4d9c27d4a7 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index cbaf13d53c9..a06ef3beeaa 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -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({ diff --git a/src/config/control-ui-css.ts b/src/config/control-ui-css.ts new file mode 100644 index 00000000000..111eef44c4a --- /dev/null +++ b/src/config/control-ui-css.ts @@ -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); +} diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 4cf4c815acd..7346c59ffff 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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.', diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 636d2026eab..205677b0209 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -458,6 +458,8 @@ export const FIELD_HELP: Record = { '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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 8b5587dade0..e08763b2e27 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -308,6 +308,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 314bc8ee7c3..3372d6db092 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -45,6 +45,7 @@ const TAG_OVERRIDES: Record = { "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", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 72c5242f60f..b0420387032 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -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[]; /** diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d22aafd9ef7..7706b05bcf0 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 7e0ba402725..d7fd3504428 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -14,4 +14,5 @@ export type ControlUiBootstrapConfig = { localMediaPreviewRoots?: string[]; embedSandbox?: ControlUiEmbedSandboxMode; allowExternalEmbedUrls?: boolean; + chatMessageMaxWidth?: string; }; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index c864845006e..26ec371e694 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -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: ".png" } }, }, }, @@ -604,6 +606,7 @@ describe("handleControlUiHttpRequest", () => { expect(parsed.assistantName).toBe("