mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
Integrate computer route execution-policy correction
Preserve the prerequisite and core ancestry while bringing the reviewed Kubernetes selection guard into the combined Boat feature branch. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
commit
869a304c62
2 files changed
+58
-4
No files matched your search
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user