From 7ad2ee3af5b65c70f649391355b729b3bbc86386 Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 10:39:46 +0800 Subject: [PATCH 1/7] ui: add /public/chat route (public webchat shell) --- ui/src/styles/layout.css | 13 ++++++ ui/src/ui/app-render.ts | 78 ++++++++++++++++++++++++++++++++++++ ui/src/ui/navigation.test.ts | 2 + ui/src/ui/navigation.ts | 10 +++++ 4 files changed, 103 insertions(+) diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index c2a5c6fe3..878e89e30 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -607,3 +607,16 @@ grid-template-columns: 1fr; } } + +/* Public chat mode: minimal chrome for shareable/mobile use. */ +.public-chat { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); +} + +.public-chat .chat { + flex: 1; + min-height: 0; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 422af6863..6cf9dcfd0 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -86,6 +86,80 @@ import { loadLogs } from "./controllers/logs"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; +function renderPublicChat(state: AppViewState) { + const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; + const showThinking = state.onboarding ? false : state.settings.chatShowThinking; + const assistantAvatarUrl = resolveAssistantAvatarUrl(state); + const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; + + return html` +
+ ${renderChat({ + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.chatAttachments = []; + state.chatStream = null; + state.chatStreamStartedAt = null; + state.chatRunId = null; + state.chatQueue = []; + state.resetToolStream(); + state.resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + void loadChatHistory(state); + void refreshChatAvatar(state); + }, + thinkingLevel: state.chatThinkingLevel, + showThinking, + loading: state.chatLoading, + sending: state.chatSending, + compactionStatus: state.compactionStatus, + assistantAvatarUrl: chatAvatarUrl, + messages: state.chatMessages, + toolMessages: state.chatToolMessages, + stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, + draft: state.chatMessage, + queue: state.chatQueue, + connected: state.connected, + canSend: state.connected, + disabledReason: chatDisabledReason, + error: state.lastError, + // Public chat should not expose session lists. + sessions: null, + // Always "focus" (no sidebar/nav). + focusMode: true, + onRefresh: () => { + state.resetToolStream(); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); + }, + onToggleFocusMode: () => { + // no-op + }, + onChatScroll: (event) => state.handleChatScroll(event), + onDraftChange: (next) => (state.chatMessage = next), + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), + onSend: () => state.handleSendChat(), + canAbort: Boolean(state.chatRunId), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + // Hide tool sidebar in public chat. + sidebarOpen: false, + assistantName: state.assistantName, + assistantAvatar: state.assistantAvatar, + })} +
+ `; +} + function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -102,6 +176,10 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { + if (state.tab === "public-chat") { + return renderPublicChat(state); + } + const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts index 7b15deb4a..eac66589d 100644 --- a/ui/src/ui/navigation.test.ts +++ b/ui/src/ui/navigation.test.ts @@ -129,6 +129,7 @@ describe("pathForTab", () => { describe("tabFromPath", () => { it("returns tab for valid path", () => { expect(tabFromPath("/chat")).toBe("chat"); + expect(tabFromPath("/public/chat")).toBe("public-chat"); expect(tabFromPath("/overview")).toBe("overview"); expect(tabFromPath("/sessions")).toBe("sessions"); }); @@ -148,6 +149,7 @@ describe("tabFromPath", () => { it("is case-insensitive", () => { expect(tabFromPath("/CHAT")).toBe("chat"); + expect(tabFromPath("/Public/Chat")).toBe("public-chat"); expect(tabFromPath("/Overview")).toBe("overview"); }); }); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 966abec96..1b48a9f43 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -19,6 +19,7 @@ export type Tab = | "skills" | "nodes" | "chat" + | "public-chat" | "config" | "debug" | "logs"; @@ -32,6 +33,7 @@ const TAB_PATHS: Record = { skills: "/skills", nodes: "/nodes", chat: "/chat", + "public-chat": "/public/chat", config: "/config", debug: "/debug", logs: "/logs", @@ -103,6 +105,7 @@ export function inferBasePathFromPathname(pathname: string): string { export function iconForTab(tab: Tab): IconName { switch (tab) { case "chat": + case "public-chat": return "messageSquare"; case "overview": return "barChart"; @@ -133,6 +136,8 @@ export function titleForTab(tab: Tab) { switch (tab) { case "overview": return "Overview"; + case "public-chat": + return "Chat"; case "channels": return "Channels"; case "instances": @@ -146,6 +151,7 @@ export function titleForTab(tab: Tab) { case "nodes": return "Nodes"; case "chat": + case "public-chat": return "Chat"; case "config": return "Config"; @@ -162,6 +168,8 @@ export function subtitleForTab(tab: Tab) { switch (tab) { case "overview": return "Gateway status, entry points, and a fast health read."; + case "public-chat": + return "Public web chat (mobile-friendly)."; case "channels": return "Manage channels and settings."; case "instances": @@ -176,6 +184,8 @@ export function subtitleForTab(tab: Tab) { return "Paired devices, capabilities, and command exposure."; case "chat": return "Direct gateway chat session for quick interventions."; + case "public-chat": + return "A minimal chat UI intended for sharing."; case "config": return "Edit ~/.clawdbot/moltbot.json safely."; case "debug": From 614aabfa224cba41d317d415833c706c69102f02 Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 10:42:06 +0800 Subject: [PATCH 2/7] ui: make web gateway client role/scopes configurable --- ui/src/ui/app-gateway.ts | 6 +++++- ui/src/ui/gateway.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..438aa5d62 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -4,6 +4,7 @@ import { loadNodes } from "./controllers/nodes"; import { loadAgents } from "./controllers/agents"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; import { GatewayBrowserClient } from "./gateway"; +import { GATEWAY_CLIENT_NAMES } from "../../../src/gateway/protocol/client-info.js"; import type { EventLogEntry } from "./app-events"; import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types"; import type { Tab } from "./navigation"; @@ -116,11 +117,14 @@ export function connectGateway(host: GatewayHost) { host.execApprovalError = null; host.client?.stop(); + const clientName = + host.tab === "public-chat" ? GATEWAY_CLIENT_NAMES.WEBCHAT_UI : "moltbot-control-ui"; + host.client = new GatewayBrowserClient({ url: host.settings.gatewayUrl, token: host.settings.token.trim() ? host.settings.token : undefined, password: host.password.trim() ? host.password : undefined, - clientName: "moltbot-control-ui", + clientName, mode: "webchat", onHello: (hello) => { host.connected = true; diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index fc8dde08a..ab6a5ae66 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -53,6 +53,15 @@ export type GatewayBrowserClientOptions = { platform?: string; mode?: GatewayClientMode; instanceId?: string; + + /** + * Gateway role/scopes for this client. + * Control UI defaults to operator + admin scopes. + * Public WebChat will override this in a follow-up PR once the gateway enforces role allowlists. + */ + role?: string; + scopes?: string[]; + onHello?: (hello: GatewayHelloOk) => void; onEvent?: (evt: GatewayEventFrame) => void; onClose?: (info: { code: number; reason: string }) => void; @@ -132,8 +141,9 @@ export class GatewayBrowserClient { // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; - const role = "operator"; + const scopes = + this.opts.scopes ?? ["operator.admin", "operator.approvals", "operator.pairing"]; + const role = this.opts.role ?? "operator"; let deviceIdentity: Awaited> | null = null; let canFallbackToShared = false; let authToken = this.opts.token; From 5fa7d31d87b01db634b11787128cf3571a81dca1 Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 10:48:54 +0800 Subject: [PATCH 3/7] gateway: add webchat role with strict method allowlist --- src/gateway/server-methods.ts | 16 +++++++ src/gateway/server.auth.e2e.test.ts | 43 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 5 ++- ui/src/ui/app-gateway.ts | 5 +++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 66492b976..ea92d4e72 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -34,6 +34,15 @@ const PAIRING_SCOPE = "operator.pairing"; const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); + +// Minimal method surface for public webchat clients. +// This is intentionally tiny; expand only when a concrete UI need is proven. +const WEBCHAT_ROLE_METHODS = new Set([ + "agent.identity.get", + "chat.history", + "chat.send", + "chat.abort", +]); const PAIRING_METHODS = new Set([ "node.pair.request", "node.pair.list", @@ -98,9 +107,16 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (role === "node") return null; return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } + if (role === "node") { return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } + + if (role === "webchat") { + if (WEBCHAT_ROLE_METHODS.has(method)) return null; + return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized method for role webchat: ${method}`); + } + if (role !== "operator") { return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index ee700ead6..0bbf860c8 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -10,6 +10,7 @@ import { onceMessage, startGatewayServer, startServerWithClient, + rpcReq, testState, } from "./test-helpers.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -214,6 +215,48 @@ describe("gateway server auth/connect", () => { }); }); + describe("webchat role authz", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + + test("webchat role can connect but is restricted to chat methods", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { + token: "secret", + role: "webchat", + client: { + id: GATEWAY_CLIENT_NAMES.WEBCHAT_UI, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + + const deny = await rpcReq(ws, "sessions.list", { limit: 1 }); + expect(deny.ok).toBe(false); + + ws.close(); + }); + }); + describe("token auth", () => { let server: Awaited>; let port: number; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d1f6ae511..949629fee 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -333,7 +333,10 @@ export function attachGatewayWsMessageHandler(params: { } const roleRaw = connectParams.role ?? "operator"; - const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null; + const role = + roleRaw === "operator" || roleRaw === "node" || roleRaw === "webchat" + ? roleRaw + : null; if (!role) { setHandshakeState("failed"); setCloseCause("invalid-role", { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 438aa5d62..25b8f4b8f 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -120,12 +120,17 @@ export function connectGateway(host: GatewayHost) { const clientName = host.tab === "public-chat" ? GATEWAY_CLIENT_NAMES.WEBCHAT_UI : "moltbot-control-ui"; + const role = host.tab === "public-chat" ? "webchat" : undefined; + host.client = new GatewayBrowserClient({ url: host.settings.gatewayUrl, token: host.settings.token.trim() ? host.settings.token : undefined, password: host.password.trim() ? host.password : undefined, clientName, mode: "webchat", + role, + // Leave scopes undefined for operator (gateway will default). For webchat we want no scopes. + scopes: role === "webchat" ? [] : undefined, onHello: (hello) => { host.connected = true; host.lastError = null; From dfcd7b65426fb3bc5002c6328cd9b7d3c1327317 Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 10:56:41 +0800 Subject: [PATCH 4/7] gateway: format webchat authz error --- src/gateway/server-methods.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index ea92d4e72..fc763bac2 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -114,7 +114,10 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (role === "webchat") { if (WEBCHAT_ROLE_METHODS.has(method)) return null; - return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized method for role webchat: ${method}`); + return errorShape( + ErrorCodes.INVALID_REQUEST, + `unauthorized method for role webchat: ${method}`, + ); } if (role !== "operator") { From 52d30a43e467003f7118d176765696138c36e8dc Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 11:08:29 +0800 Subject: [PATCH 5/7] format: oxfmt ws connect role check --- src/gateway/server/ws-connection/message-handler.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 949629fee..8ec5161fe 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -334,9 +334,7 @@ export function attachGatewayWsMessageHandler(params: { const roleRaw = connectParams.role ?? "operator"; const role = - roleRaw === "operator" || roleRaw === "node" || roleRaw === "webchat" - ? roleRaw - : null; + roleRaw === "operator" || roleRaw === "node" || roleRaw === "webchat" ? roleRaw : null; if (!role) { setHandshakeState("failed"); setCloseCause("invalid-role", { From 72b5d7b8ced0da75d25d0e991a4e17eb24cf808d Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 12:00:33 +0800 Subject: [PATCH 6/7] test: make XML filename escaping test windows-safe --- src/media-understanding/apply.test.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index e06adbc24..16b86b2fc 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -550,10 +550,15 @@ describe("applyMediaUnderstanding", () => { it("escapes XML special characters in filenames to prevent injection", async () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + + // Windows does not allow < or > in filenames. + const platform = process.platform; + const rawName = platform === "win32" ? "file&test>.txt" : "file.txt"; + // Create file with XML special characters in the name (what filesystem allows) // Note: The sanitizeFilename in store.ts would strip most dangerous chars, // but we test that even if some slip through, they get escaped in output - const filePath = path.join(dir, "file.txt"); + const filePath = path.join(dir, rawName); await fs.writeFile(filePath, "safe content"); const ctx: MsgContext = { @@ -574,11 +579,18 @@ describe("applyMediaUnderstanding", () => { const result = await applyMediaUnderstanding({ ctx, cfg }); expect(result.appliedFile).toBe(true); - // Verify XML special chars are escaped in the output - expect(ctx.Body).toContain("<"); - expect(ctx.Body).toContain(">"); - // The raw < and > should not appear unescaped in the name attribute - expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/); + // Verify XML special chars are escaped in the output. + if (platform === "win32") { + expect(ctx.Body).toContain(">"); + expect(ctx.Body).toContain("&"); + // The raw > should not appear unescaped in the name attribute + expect(ctx.Body).not.toMatch(/name="[^"]*>[^"]*"/); + } else { + expect(ctx.Body).toContain("<"); + expect(ctx.Body).toContain(">"); + // The raw < and > should not appear unescaped in the name attribute + expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/); + } }); it("normalizes MIME types to prevent attribute injection", async () => { From c10e3d88b3a7d87df36c692a4efa06b8df640e9d Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 13:23:50 +0800 Subject: [PATCH 7/7] test: avoid invalid filename chars on windows --- src/media-understanding/apply.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 16b86b2fc..366cc3386 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -551,9 +551,10 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); - // Windows does not allow < or > in filenames. + // Windows does not allow < or > (or many other chars) in filenames. + // Use a platform-appropriate name. const platform = process.platform; - const rawName = platform === "win32" ? "file&test>.txt" : "file.txt"; + const rawName = platform === "win32" ? "file&test.txt" : "file.txt"; // Create file with XML special characters in the name (what filesystem allows) // Note: The sanitizeFilename in store.ts would strip most dangerous chars, @@ -581,10 +582,9 @@ describe("applyMediaUnderstanding", () => { expect(result.appliedFile).toBe(true); // Verify XML special chars are escaped in the output. if (platform === "win32") { - expect(ctx.Body).toContain(">"); + // '&' should be escaped as & inside the name attribute. expect(ctx.Body).toContain("&"); - // The raw > should not appear unescaped in the name attribute - expect(ctx.Body).not.toMatch(/name="[^"]*>[^"]*"/); + expect(ctx.Body).not.toMatch(/name="[^"]*&[^"]*"/); } else { expect(ctx.Body).toContain("<"); expect(ctx.Body).toContain(">");