From 85d3639b9b085e0c12050af0cab964453f211409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=BA=C5=A1=20Nosko?= Date: Fri, 30 Oct 2026 00:30:05 +0100 Subject: [PATCH] Leave opening a remote Bot to everybody to an administrator Any signed-in person could register a Bot at an address of their own and mark it public. A public Bot is offered to every person in the deployment, and a remote one is sent each of their conversations along with the run assertion it calls tools with, so the person running that server saw what everybody said to it and could act as each of them within the Bot's grants. Creating a public Bot with its own endpoint, or making a remote Bot public, is now refused for anyone but an admin, with a message that points at keeping it private and sharing it with named people. Bots with no endpoint, or on the deployment's own managed Bot, are unaffected, and admins can still publish remote Bots. --- server/src/agents/routes.ts | 37 ++++++ server/tests/agent-public-remote.test.ts | 145 +++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 server/tests/agent-public-remote.test.ts diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index b99b87c32d..9ddea80552 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -606,6 +606,23 @@ export function createAgentRoutes( const NO_ISOLATION = "This OpenBot can't give a Bot a computer of its own, so it runs on yours."; + /** + * Whether this person may open a Bot that runs on a server of its own to everybody. + * + * Public admits every signed-in person, and a remote Bot is sent each of their conversations along + * with the run assertion that lets it call tools as them. So whoever runs that server sees what + * everybody says to it and can act as each of them within the Bot's grants. That is a decision for + * an administrator rather than for whoever registered the address. A Bot with no address of its own, + * or on this deployment's own Bot, is unaffected, and a private remote Bot can still be shared with + * named people and teams. + */ + const PUBLIC_REMOTE_REFUSED = + "Only an admin can make a Bot that runs on its own server public. Keep it private and share it with the people who need it, or ask an admin."; + const runsElsewhere = (endpoint: string | null | undefined) => + typeof endpoint === "string" && + endpoint !== "" && + endpoint !== managedEndpoint; + routes.post("/", requireUser, async (context) => { // Malformed JSON is a recoverable client-input error and is validated by the same parser. const parsed = parseAgentInput( @@ -614,6 +631,13 @@ export function createAgentRoutes( allowedHosts, ); if (!parsed.ok) return context.json({ error: parsed.error }, 400); + if ( + context.var.actor.role !== "admin" && + parsed.value.visibility === "public" && + runsElsewhere(parsed.value.endpoint) + ) { + return context.json({ error: PUBLIC_REMOTE_REFUSED }, 403); + } if (parsed.value.isolated === true && !isolationAvailable) { return refuseIsolation( context, @@ -684,6 +708,19 @@ export function createAgentRoutes( allowedHosts, ); if (!parsed.ok) return context.json({ error: parsed.error }, 400); + if ( + context.var.actor.role !== "admin" && + parsed.value.visibility === "public" + ) { + // An absent endpoint leaves the stored one in place, so the stored one is what will run. + const endpoint = + parsed.value.endpoint ?? + (await store.get(context.var.actor, context.req.param("agentId"))) + ?.endpoint; + if (runsElsewhere(endpoint)) { + return context.json({ error: PUBLIC_REMOTE_REFUSED }, 403); + } + } if (parsed.value.isolated === true && !isolationAvailable) { return refuseIsolation( context, diff --git a/server/tests/agent-public-remote.test.ts b/server/tests/agent-public-remote.test.ts new file mode 100644 index 0000000000..719487c454 --- /dev/null +++ b/server/tests/agent-public-remote.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AgentProfileStore } from "../src/agents/profile-store"; +import type { AgentProfile } from "../src/agents/profile-types"; +import { createAgentRoutes } from "../src/agents/routes"; +import type { AppVariables } from "../src/auth/guards"; + +/** + * A Bot that runs on a server of its own is sent every conversation it is in, and the run assertion + * that lets it call tools as the person. Opening one to everybody is an administrator's decision. + */ + +const MANAGED = "http://agent-bot:4200/ag-ui"; + +function profile(overrides: Partial = {}): AgentProfile { + return { + id: "agent-1", + name: "Sales", + title: "Sales assistant", + roleDescription: "Helps with sales questions.", + avatarSeed: "sales", + visibility: "private", + ownerUserId: "user-1", + systemOwned: false, + hidden: false, + pinned: false, + deletedAt: null, + endpoint: null, + hasAuth: false, + hasCallbackToken: false, + isolated: false, + ...overrides, + }; +} + +function signedIn( + role: "admin" | "user", +): MiddlewareHandler<{ Variables: AppVariables }> { + return async (context, next) => { + context.set("actor", { id: "user-1", email: "p@openbot.test", role }); + await next(); + }; +} + +function appFor(role: "admin" | "user", stored: Partial = {}) { + const written: string[] = []; + const store = { + async get(_actor: unknown, id: string) { + return profile({ id, ...stored }); + }, + async create(_actor: unknown, input: Partial) { + written.push("create"); + return profile({ ...input }); + }, + async update(_actor: unknown, id: string, input: Partial) { + written.push("update"); + return profile({ id, ...stored, ...input }); + }, + } as unknown as AgentProfileStore; + const app = new Hono<{ Variables: AppVariables }>().route( + "/", + createAgentRoutes( + store, + signedIn(role), + false, + undefined, + new Set(), + undefined, + false, + MANAGED, + ), + ); + return { app, written }; +} + +const body = (input: Record) => ({ + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Sales", + title: "Sales assistant", + roleDescription: "Helps with sales questions.", + ...input, + }), +}); + +describe("a public Bot on a server of its own", () => { + test("a member cannot create one", async () => { + const { app, written } = appFor("user"); + const response = await app.request("http://t/", { + method: "POST", + ...body({ + visibility: "public", + endpoint: "https://agents.example.com/ag-ui", + }), + }); + expect(response.status).toBe(403); + expect(written).toEqual([]); + }); + + test("a member cannot make their remote Bot public", async () => { + const { app, written } = appFor("user", { + endpoint: "https://agents.example.com/ag-ui", + }); + const response = await app.request("http://t/agent-1", { + method: "PATCH", + ...body({ visibility: "public" }), + }); + expect(response.status).toBe(403); + expect(written).toEqual([]); + }); + + test("a member may still create a private one", async () => { + const { app, written } = appFor("user"); + const response = await app.request("http://t/", { + method: "POST", + ...body({ + visibility: "private", + endpoint: "https://agents.example.com/ag-ui", + }), + }); + expect(response.status).toBe(201); + expect(written).toEqual(["create"]); + }); + + test("an admin may create one", async () => { + const { app } = appFor("admin"); + const response = await app.request("http://t/", { + method: "POST", + ...body({ + visibility: "public", + endpoint: "https://agents.example.com/ag-ui", + }), + }); + expect(response.status).toBe(201); + }); + + test("a member may make public a Bot that runs on this deployment", async () => { + const { app } = appFor("user", { endpoint: MANAGED }); + const response = await app.request("http://t/agent-1", { + method: "PATCH", + ...body({ visibility: "public" }), + }); + expect(response.status).toBe(200); + }); +});