Files
PaperClipAI/server/src/routes/computers.ts
T
DottaandCodex 3223e57b74 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 <noreply@openai.com>
2026-10-11 08:20:46 -05:00

125 lines
7.6 KiB
TypeScript

import { Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { agents, environmentLeases, environments, issues, type Db } from "@paperclipai/db";
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";
import { assertBoard, getActorInfo, getAccessibleResource } from "./authz.js";
const ownerSchema = z.object({ computerId: z.string().uuid(), ownerId: z.string().uuid(), generation: z.number().int().positive() }).strict();
const scopeSchema = z.object({ environmentId: z.string().uuid() });
const presenceSchema = scopeSchema.extend({ owner: ownerSchema }).strict();
export function computerRoutes(db: Db, computers = computerService(db)) {
const router = Router();
const settings = instanceSettingsService(db);
const envs = environmentService(db);
router.use("/issues/:issueId/computer", async (req, res, next) => {
assertBoard(req);
if (!z.string().uuid().safeParse(req.params.issueId).success) throw notFound("Task not found");
const [row] = await db.select().from(issues).where(eq(issues.id, req.params.issueId)).limit(1);
const issue = await getAccessibleResource(req, res, row, "Task not found");
if (!issue) return;
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
if (req.method === "POST" && req.path === "/disconnect") {
// A task may have moved since this viewer connected. Cleanup is scoped
// by the module to the posted environment, exact owner, and current user.
res.locals.computer = { issue };
next();
return;
}
if (!(await settings.getExperimental()).enableBoatEnvironments && !req.path.endsWith("/disconnect")) {
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 ?? 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; }
throw notFound("Computer not available for this task");
}
const [lease] = await db.select().from(environmentLeases)
.where(and(eq(environmentLeases.companyId, issue.companyId), eq(environmentLeases.issueId, issue.id),
eq(environmentLeases.environmentId, environment.id), eq(environmentLeases.provider, "boat")))
// A historical lease cannot select the task's computer. Within the current
// selection, later finalization must not promote an older admission.
.orderBy(desc(environmentLeases.createdAt), desc(environmentLeases.id)).limit(1);
res.locals.computer = { environment, issue, lease, agent };
next();
});
router.get("/issues/:issueId/computer", async (_req, res) => {
const { environment } = res.locals.computer;
res.json({ environmentId: environment.id, name: environment.name });
});
router.post("/issues/:issueId/computer/connect", async (req, res) => {
const input = scopeSchema.strict().parse(req.body);
const { environment, issue, agent } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const actor = getActorInfo(req);
const viewer = await computers.connect({ companyId: issue.companyId, environmentId: environment.id,
userId: actor.actorId, idleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs(environment.config.runnerIdleTimeoutMs ?? agent?.adapterConfig?.idleTimeoutMs) });
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.connected", entityType: "environment", entityId: environment.id, details: { issueId: issue.id } });
res.json(viewer);
});
router.post("/issues/:issueId/computer/presence", async (req, res) => {
const input = presenceSchema.parse(req.body);
const { environment, issue } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
res.json(await computers.renewViewer({ companyId: issue.companyId, environmentId: environment.id,
owner: input.owner, userId: getActorInfo(req).actorId }));
});
router.post("/issues/:issueId/computer/disconnect", async (req, res) => {
const input = presenceSchema.parse(req.body);
const { issue } = res.locals.computer;
const actor = getActorInfo(req);
await computers.disconnectViewer({ companyId: issue.companyId, environmentId: input.environmentId,
owner: input.owner, userId: actor.actorId });
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.disconnected", entityType: "environment", entityId: input.environmentId, details: { issueId: issue.id } });
res.status(204).end();
});
router.post("/issues/:issueId/computer/preview", async (req, res) => {
const input = scopeSchema.extend({ port: z.number().int().min(1024).max(65535) }).strict().parse(req.body);
const { environment, issue, lease } = res.locals.computer;
if (input.environmentId !== environment.id) throw notFound("Computer not available for this task");
const current = lease ? await envs.getLeaseById(lease.id) : null;
if (!current) throw conflict("Start a dev server in this task before opening its preview.");
const result = await computers.preview({ companyId: issue.companyId, environmentId: environment.id,
owner: computerOwnerFromLease(current), port: input.port });
const actor = getActorInfo(req);
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "computer.preview_opened", entityType: "environment", entityId: environment.id, details: { issueId: issue.id, port: input.port } });
res.json(result);
});
router.use((error: unknown, _req: import("express").Request, res: import("express").Response, next: import("express").NextFunction) => {
if (!(error instanceof ComputerError)) return next(error);
res.status(error.code === "conflict" ? 409 : error.code === "invalid" ? 422 : error.code === "not_found" ? 404 : error.code === "forbidden" ? 403 : 502).json({ error: error.message, code: error.code });
});
return router;
}