gateway: add webchat role with strict method allowlist

This commit is contained in:
nzhu 2026-01-29 10:48:54 +08:00
parent 614aabfa22
commit 5fa7d31d87
4 changed files with 68 additions and 1 deletions

View File

@ -34,6 +34,15 @@ const PAIRING_SCOPE = "operator.pairing";
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); 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([ const PAIRING_METHODS = new Set([
"node.pair.request", "node.pair.request",
"node.pair.list", "node.pair.list",
@ -98,9 +107,16 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
if (role === "node") return null; if (role === "node") return null;
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
} }
if (role === "node") { if (role === "node") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); 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") { if (role !== "operator") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
} }

View File

@ -10,6 +10,7 @@ import {
onceMessage, onceMessage,
startGatewayServer, startGatewayServer,
startServerWithClient, startServerWithClient,
rpcReq,
testState, testState,
} from "./test-helpers.js"; } from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.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", () => { describe("token auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>; let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number; let port: number;

View File

@ -333,7 +333,10 @@ export function attachGatewayWsMessageHandler(params: {
} }
const roleRaw = connectParams.role ?? "operator"; 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) { if (!role) {
setHandshakeState("failed"); setHandshakeState("failed");
setCloseCause("invalid-role", { setCloseCause("invalid-role", {

View File

@ -120,12 +120,17 @@ export function connectGateway(host: GatewayHost) {
const clientName = const clientName =
host.tab === "public-chat" ? GATEWAY_CLIENT_NAMES.WEBCHAT_UI : "moltbot-control-ui"; host.tab === "public-chat" ? GATEWAY_CLIENT_NAMES.WEBCHAT_UI : "moltbot-control-ui";
const role = host.tab === "public-chat" ? "webchat" : undefined;
host.client = new GatewayBrowserClient({ host.client = new GatewayBrowserClient({
url: host.settings.gatewayUrl, url: host.settings.gatewayUrl,
token: host.settings.token.trim() ? host.settings.token : undefined, token: host.settings.token.trim() ? host.settings.token : undefined,
password: host.password.trim() ? host.password : undefined, password: host.password.trim() ? host.password : undefined,
clientName, clientName,
mode: "webchat", mode: "webchat",
role,
// Leave scopes undefined for operator (gateway will default). For webchat we want no scopes.
scopes: role === "webchat" ? [] : undefined,
onHello: (hello) => { onHello: (hello) => {
host.connected = true; host.connected = true;
host.lastError = null; host.lastError = null;