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

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,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", {

View File

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