Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions server/src/agents/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
145 changes: 145 additions & 0 deletions server/tests/agent-public-remote.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<AgentProfile> = {}) {
const written: string[] = [];
const store = {
async get(_actor: unknown, id: string) {
return profile({ id, ...stored });
},
async create(_actor: unknown, input: Partial<AgentProfile>) {
written.push("create");
return profile({ ...input });
},
async update(_actor: unknown, id: string, input: Partial<AgentProfile>) {
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<string, unknown>) => ({
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);
});
});