diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index b99b87c32..9ddea8055 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 000000000..719487c45 --- /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); + }); +});