Skip to content
Merged
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
10 changes: 8 additions & 2 deletions server/src/computer/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,16 @@ export function createComputerRoutes(
* The computers, for the admin surface.
*
* Not per-Bot in the path the way the acting routes are: this asks the computer what it holds, and
* it holds a list. `:botId` is still there because every route under this router has it and the
* gateway wants somebody to attribute the call to.
* it holds a list. `:botId` is still there because every route under this router has it. The
* list itself is every computer, so a signed-in user is not enough; an administrator has to ask.
*/
routes.get("/:botId/computers", async (context) => {
// The session guard and the question of whether this person may act as the Bot in the path are
// both applied by the middleware above. Neither is the question here: the answer is the whole
// fleet whatever `:botId` says, so it takes administering the deployment.
const denied = requireAdmin(context);
if (denied) return denied;

try {
return context.json(await gateway.computers());
} catch (error) {
Expand Down
94 changes: 93 additions & 1 deletion server/tests/computer-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { MiddlewareHandler } from "hono";
import type { AppVariables } from "../src/auth/guards";
import type { AppVariables, AuthenticatedActor } from "../src/auth/guards";
import type { ComputerGateway } from "../src/computer/gateway";
import type { PolicyStore } from "../src/computer/policy-store";
import { createComputerRoutes } from "../src/computer/routes";
Expand Down Expand Up @@ -40,3 +40,95 @@ describe("computer routes", () => {
expect(requestedBotIds).toEqual(["bot-17"]);
});
});

/**
* The fleet listing is the one route here that is not about the Bot in its path.
*
* `:botId` is ignored and the handler returns every computer, so a signed-in person asking about a
* Bot they own learned every Bot id in the deployment and whether its computer was running,
* private coworkers included. Being signed in is not the question; administering the deployment is.
*/
const member: AuthenticatedActor = {
id: "user-1",
email: "member@openbot.test",
role: "user",
};

const administrator: AuthenticatedActor = {
id: "admin-1",
email: "admin@openbot.test",
role: "admin",
};

function asActor(
actor: AuthenticatedActor,
): MiddlewareHandler<{ Variables: AppVariables }> {
return async (context, next) => {
context.set("actor", actor);
await next();
};
}

function appFor(actor: AuthenticatedActor, computers: () => Promise<unknown>) {
let listed = 0;
const countingGateway = {
async computers() {
listed += 1;
return computers();
},
} as ComputerGateway;

return {
app: createComputerRoutes(
countingGateway,
{} as PolicyStore,
asActor(actor),
// Permissive. Whether this person may act as the Bot in the path is a different question with
// its own suite, and `:botId` is not what this route answers about anyway.
async () => true,
),
listed: () => listed,
};
}

describe("computer fleet listing", () => {
test("refuses a signed-in user the fleet, and does not ask the gateway", async () => {
const { app, listed } = appFor(member, async () => ({
isolation: "per-bot",
computers: [
{ botId: "private-coworker", running: true, startedAt: null },
],
}));

const response = await app.request("http://openbot.test/any-bot/computers");

expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({
error: "Administrator access required.",
});
// Refused before the gateway is asked: a check that runs after the fleet has been read is not a
// check, it is a filter on the response.
expect(listed()).toBe(0);
});

test("lets an administrator see the fleet", async () => {
const fleet = {
isolation: "per-bot" as const,
computers: [
{
botId: "private-coworker",
running: true,
startedAt: "2026-08-20T00:00:00.000Z",
egress: null,
},
],
};
const { app, listed } = appFor(administrator, async () => fleet);

const response = await app.request("http://openbot.test/any-bot/computers");

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(fleet);
expect(listed()).toBe(1);
});
});