Merge c10e3d88b3 into 0761652701
This commit is contained in:
commit
fc0446143b
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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("<");
|
||||
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") {
|
||||
// '&' should be escaped as & inside the name attribute.
|
||||
expect(ctx.Body).toContain("&");
|
||||
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 () => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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":
|
||||
|
||||
Loading…
Reference in New Issue
Block a user