This commit is contained in:
Chen 2026-01-29 05:45:16 +00:00 committed by GitHub
commit fc0446143b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 207 additions and 10 deletions

View File

@ -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,19 @@ 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}`);
}

View File

@ -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<ReturnType<typeof startGatewayServer>>;
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<ReturnType<typeof startGatewayServer>>;
let port: number;

View File

@ -333,7 +333,8 @@ 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", {

View File

@ -550,10 +550,16 @@ 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 > (or many other chars) in filenames.
// Use a platform-appropriate name.
const platform = process.platform;
const rawName = platform === "win32" ? "file&test.txt" : "file<test>.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<test>.txt");
const filePath = path.join(dir, rawName);
await fs.writeFile(filePath, "safe content");
const ctx: MsgContext = {
@ -574,11 +580,17 @@ 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("&lt;");
expect(ctx.Body).toContain("&gt;");
// 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") {
// '&' should be escaped as &amp; inside the name attribute.
expect(ctx.Body).toContain("&amp;");
expect(ctx.Body).not.toMatch(/name="[^"]*&[^"]*"/);
} else {
expect(ctx.Body).toContain("&lt;");
expect(ctx.Body).toContain("&gt;");
// 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 () => {

View File

@ -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;
}

View File

@ -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,12 +117,20 @@ 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";
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: "moltbot-control-ui",
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;

View File

@ -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`
<div class="public-chat">
${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,
})}
</div>
`;
}
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;

View File

@ -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<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;

View File

@ -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");
});
});

View File

@ -19,6 +19,7 @@ export type Tab =
| "skills"
| "nodes"
| "chat"
| "public-chat"
| "config"
| "debug"
| "logs";
@ -32,6 +33,7 @@ const TAB_PATHS: Record<Tab, string> = {
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":