From ec7a92f7a88c38726de0e4b40f70f9e79c2fee88 Mon Sep 17 00:00:00 2001 From: kevin <5299031+kevin9327@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:09:12 +0000 Subject: [PATCH] Keep the computer fleet off a signed-in user's listing The admin computers page asks for every Bot's machine. That list was behind a session only, so anyone signed in could read private coworker ids and whether those computers were running. --- server/src/computer/routes.ts | 7 +- server/tests/computer-routes.test.ts | 95 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 server/tests/computer-routes.test.ts diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 3ce1c28..4596fbe 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -194,10 +194,13 @@ 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", requireUser, async (context) => { + const denied = requireAdmin(context); + if (denied) return denied; + try { return context.json(await gateway.computers()); } catch (error) { diff --git a/server/tests/computer-routes.test.ts b/server/tests/computer-routes.test.ts new file mode 100644 index 0000000..a59825e --- /dev/null +++ b/server/tests/computer-routes.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import type { AppVariables, AuthenticatedActor } from "../src/auth/guards"; +import type { ComputerClient } from "../src/computer/client"; +import type { ComputerGateway } from "../src/computer/gateway"; +import type { PolicyStore } from "../src/computer/policy-store"; +import { createComputerRoutes } from "../src/computer/routes"; + +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) { + const gateway = { + async computers() { + return computers(); + }, + } as ComputerGateway; + let listed = 0; + const countingGateway = { + async computers() { + listed += 1; + return gateway.computers(); + }, + } as ComputerGateway; + + const app = createComputerRoutes( + {} as ComputerClient, + countingGateway, + {} as PolicyStore, + asActor(actor), + ); + + return { + app, + 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.", + }); + 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); + }); +});