From 3223e57b747844834d57d3915d8e7c60b119c723 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 08:20:46 -0500 Subject: [PATCH] Respect forced Kubernetes policy in computer routes Use the same execution-policy guard as heartbeat before exposing Boat connection, presence or preview actions. Keep exact-owner disconnect available after the policy changes. Cover agent and instance defaults with present or absent managed Kubernetes configuration, including a historical Boat lease and cleanup access. Co-authored-by: Codex --- server/src/__tests__/computer-routes.test.ts | 51 ++++++++++++++++++-- server/src/routes/computers.ts | 11 ++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/computer-routes.test.ts b/server/src/__tests__/computer-routes.test.ts index 771cf2dbea..fa829ee941 100644 --- a/server/src/__tests__/computer-routes.test.ts +++ b/server/src/__tests__/computer-routes.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { agents, companies, createDb, environmentLeases, environments, issues, type Db, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { computerRoutes } from "../routes/computers.js"; import type { computerService } from "../modules/computers/index.js"; @@ -18,13 +18,18 @@ if (!support.supported) console.warn(`Skipping computer route database tests: ${ describe.skipIf(!support.supported)("task computer environment selection", () => { let database: Awaited>; let db: Db; + let managedEnvironmentId: string | null = null; beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-computer-routes"); db = createDb(database.connectionString); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + afterEach(async () => { + if (managedEnvironmentId) await db.delete(environments).where(eq(environments.id, managedEnvironmentId)); + managedEnvironmentId = null; + }); beforeEach(() => { - settings.get.mockResolvedValue({ defaultEnvironmentId: null }); + settings.get.mockResolvedValue({ defaultEnvironmentId: null, general: { executionMode: "any" } }); settings.getExperimental.mockResolvedValue({ enableBoatEnvironments: true }); }); @@ -55,6 +60,46 @@ describe.skipIf(!support.supported)("task computer environment selection", () => expect(f.computers.connect).not.toHaveBeenCalled(); }); + it.each([ + { selection: "agent", managedEnvironment: true }, + { selection: "instance", managedEnvironment: true }, + { selection: "agent", managedEnvironment: false }, + { selection: "instance", managedEnvironment: false }, + ])("refuses Boat access under forced Kubernetes ($selection default, managed row $managedEnvironment)", async ({ selection, managedEnvironment }) => { + const f = await fixture(); + if (selection === "instance") { + await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, f.agent.id)); + } + settings.get.mockResolvedValue({ + defaultEnvironmentId: selection === "instance" ? f.selected.id : null, + general: { executionMode: "kubernetes" }, + }); + if (managedEnvironment) { + const [managed] = await db.insert(environments).values({ + name: randomUUID(), driver: "sandbox", config: { provider: "kubernetes" }, + metadata: { managedKubernetesSandbox: true }, + }).returning(); + managedEnvironmentId = managed.id; + } + // Even a usable historical Boat lease must not override the execution policy. + await db.insert(environmentLeases).values({ + companyId: f.company.id, issueId: f.issue.id, environmentId: f.selected.id, + provider: "boat", providerLeaseId: f.owner.ownerId, metadata: { computerOwner: f.owner }, + }); + expect((await request(f.app).get(f.base)).body).toBeNull(); + expect((await request(f.app).post(`${f.base}/connect`).send({ environmentId: f.selected.id })).status).toBe(404); + expect((await request(f.app).post(`${f.base}/preview`).send({ environmentId: f.selected.id, port: 5173 })).status).toBe(404); + expect((await request(f.app).post(`${f.base}/presence`).send({ environmentId: f.selected.id, owner: f.owner })).status).toBe(404); + expect(f.computers.connect).not.toHaveBeenCalled(); + expect(f.computers.preview).not.toHaveBeenCalled(); + expect(f.computers.renewViewer).not.toHaveBeenCalled(); + // A viewer opened before the policy changed can still release its exact owner. + expect((await request(f.app).post(`${f.base}/disconnect`).send({ environmentId: f.selected.id, owner: f.owner })).status).toBe(204); + expect(f.computers.disconnectViewer).toHaveBeenCalledWith({ + companyId: f.company.id, environmentId: f.selected.id, owner: f.owner, userId: "board", + }); + }); + it("selects Boat B before its first run and cannot preview Boat A's lease", async () => { const f = await fixture(); expect((await request(f.app).get(f.base)).body).toEqual({ environmentId: f.selected.id, name: f.selected.name }); @@ -68,7 +113,7 @@ describe.skipIf(!support.supported)("task computer environment selection", () => it("uses the current instance default when the agent has no selection", async () => { const f = await fixture(); await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, f.agent.id)); - settings.get.mockResolvedValue({ defaultEnvironmentId: f.selected.id }); + settings.get.mockResolvedValue({ defaultEnvironmentId: f.selected.id, general: { executionMode: "any" } }); expect((await request(f.app).get(f.base)).body).toEqual({ environmentId: f.selected.id, name: f.selected.name }); }); diff --git a/server/src/routes/computers.ts b/server/src/routes/computers.ts index 702c01469c..631fe8e4a2 100644 --- a/server/src/routes/computers.ts +++ b/server/src/routes/computers.ts @@ -6,6 +6,7 @@ import { resolvePaperclipRunnerIdleTimeoutMs } from "@paperclipai/adapter-utils" import { computerService, ComputerError } from "../modules/computers/index.js"; import { computerOwnerFromLease } from "../services/computer-environment-driver.js"; import { environmentService } from "../services/environments.js"; +import { isExecutionForcedToKubernetes } from "../services/execution-allowlist.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { logActivity } from "../services/activity-log.js"; import { conflict, notFound, unprocessable } from "../errors.js"; @@ -39,9 +40,17 @@ export function computerRoutes(db: Db, computers = computerService(db)) { if (req.method === "GET") { res.json(null); return; } throw unprocessable("Boat environments are disabled."); } + const instanceSettings = await settings.get(); + // Heartbeat forces Kubernetes before using agent or instance defaults. + // This read path must neither expose Boat nor provision an environment; + // missing managed Kubernetes configuration also cannot fall back to Boat. + if (isExecutionForcedToKubernetes(instanceSettings.general)) { + if (req.method === "GET") { res.json(null); return; } + throw notFound("Computer not available for this task"); + } const [agent] = issue.assigneeAgentId ? await db.select().from(agents) .where(and(eq(agents.companyId, issue.companyId), eq(agents.id, issue.assigneeAgentId))).limit(1) : []; - const environmentId = agent?.defaultEnvironmentId ?? (await settings.get()).defaultEnvironmentId; + const environmentId = agent?.defaultEnvironmentId ?? instanceSettings.defaultEnvironmentId; const [environment] = environmentId ? await db.select().from(environments).where(eq(environments.id, environmentId)).limit(1) : []; if (!environment || environment.driver !== "computer" || environment.metadata?.computerCompanyId !== issue.companyId || environment.status !== "active") { if (req.method === "GET") { res.json(null); return; }