import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { AGENT_ADAPTER_TYPES, createEnvironmentSchema, getEnvironmentCapabilities, probeEnvironmentConfigSchema, updateEnvironmentSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, environmentService, executionWorkspaceService, issueService, logActivity, projectService, } from "../services/index.js"; import { normalizeEnvironmentConfigForPersistence, normalizeEnvironmentConfigForProbe, parseEnvironmentDriverConfig, readSshEnvironmentPrivateKeySecretId, type ParsedEnvironmentConfig, } from "../services/environment-config.js"; import { probeEnvironment } from "../services/environment-probe.js"; import { secretService } from "../services/secrets.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; export function environmentRoutes(db: Db) { const router = Router(); const agents = agentService(db); const access = accessService(db); const svc = environmentService(db); const executionWorkspaces = executionWorkspaceService(db); const issues = issueService(db); const projects = projectService(db); const secrets = secretService(db); function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function canCreateAgents(agent: { permissions: Record | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return false; return Boolean((agent.permissions as Record).canCreateAgents); } async function assertCanMutateEnvironments(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const allowed = await access.canUser(companyId, req.actor.userId, "environments:manage"); if (!allowed) { throw forbidden("Missing permission: environments:manage"); } return; } if (!req.actor.agentId) { throw forbidden("Agent authentication required"); } const actorAgent = await agents.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage"); if (allowedByGrant || canCreateAgents(actorAgent)) { return; } throw forbidden("Missing permission: environments:manage"); } async function actorCanReadEnvironmentConfigurations(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; return access.canUser(companyId, req.actor.userId, "environments:manage"); } if (!req.actor.agentId) return false; const actorAgent = await agents.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) return false; const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage"); return allowedByGrant || canCreateAgents(actorAgent); } function redactEnvironmentForRestrictedView; metadata: Record | null; }>(environment: T): T & { configRedacted: true; metadataRedacted: true } { return { ...environment, config: {}, metadata: null, configRedacted: true, metadataRedacted: true, }; } function summarizeEnvironmentUpdate( patch: Record, environment: { name: string; driver: string; status: string; }, ): Record { const details: Record = { changedFields: Object.keys(patch).sort(), }; if (patch.name !== undefined) details.name = environment.name; if (patch.driver !== undefined) details.driver = environment.driver; if (patch.status !== undefined) details.status = environment.status; if (patch.description !== undefined) details.descriptionChanged = true; if (patch.config !== undefined) { details.configChanged = true; details.configTopLevelKeyCount = patch.config && typeof patch.config === "object" && !Array.isArray(patch.config) ? Object.keys(patch.config as Record).length : 0; } if (patch.metadata !== undefined) { details.metadataChanged = true; details.metadataTopLevelKeyCount = patch.metadata && typeof patch.metadata === "object" && !Array.isArray(patch.metadata) ? Object.keys(patch.metadata as Record).length : 0; } return details; } router.get("/companies/:companyId/environments", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const rows = await svc.list(companyId, { status: req.query.status as string | undefined, driver: req.query.driver as string | undefined, }); const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, companyId); if (canReadConfigs) { res.json(rows); return; } res.json(rows.map((environment) => redactEnvironmentForRestrictedView(environment))); }); router.get("/companies/:companyId/environments/capabilities", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); res.json(getEnvironmentCapabilities(AGENT_ADAPTER_TYPES)); }); router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateEnvironments(req, companyId); const actor = getActorInfo(req); const input = { ...req.body, config: await normalizeEnvironmentConfigForPersistence({ db, companyId, environmentName: req.body.name, driver: req.body.driver, config: req.body.config, actor: { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, }), }; const environment = await svc.create(companyId, input); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "environment.created", entityType: "environment", entityId: environment.id, details: { name: environment.name, driver: environment.driver, status: environment.status, }, }); res.status(201).json(environment); }); router.get("/environments/:id", async (req, res) => { const environment = await svc.getById(req.params.id as string); if (!environment) { res.status(404).json({ error: "Environment not found" }); return; } assertCompanyAccess(req, environment.companyId); const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId); if (canReadConfigs) { res.json(environment); return; } res.json(redactEnvironmentForRestrictedView(environment)); }); router.get("/environments/:id/leases", async (req, res) => { const environment = await svc.getById(req.params.id as string); if (!environment) { res.status(404).json({ error: "Environment not found" }); return; } assertCompanyAccess(req, environment.companyId); const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId); if (!canReadConfigs) { throw forbidden("Missing permission: environments:manage"); } const leases = await svc.listLeases(environment.id, { status: req.query.status as string | undefined, }); res.json(leases); }); router.get("/environment-leases/:leaseId", async (req, res) => { const lease = await svc.getLeaseById(req.params.leaseId as string); if (!lease) { res.status(404).json({ error: "Environment lease not found" }); return; } assertCompanyAccess(req, lease.companyId); const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, lease.companyId); if (!canReadConfigs) { throw forbidden("Missing permission: environments:manage"); } res.json(lease); }); router.patch("/environments/:id", validate(updateEnvironmentSchema), async (req, res) => { const existing = await svc.getById(req.params.id as string); if (!existing) { res.status(404).json({ error: "Environment not found" }); return; } await assertCanMutateEnvironments(req, existing.companyId); const actor = getActorInfo(req); const nextDriver = req.body.driver ?? existing.driver; const nextName = req.body.name ?? existing.name; const configSource = req.body.config !== undefined ? req.body.driver !== undefined && req.body.driver !== existing.driver ? req.body.config : { ...parseObject(existing.config), ...parseObject(req.body.config), } : req.body.driver !== undefined && req.body.driver !== existing.driver ? {} : existing.config; const patch = { ...req.body, ...(req.body.config !== undefined || req.body.driver !== undefined ? { config: await normalizeEnvironmentConfigForPersistence({ db, companyId: existing.companyId, environmentName: nextName, driver: nextDriver, config: configSource, actor: { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }, }), } : {}), }; const environment = await svc.update(existing.id, patch); if (!environment) { res.status(404).json({ error: "Environment not found" }); return; } await logActivity(db, { companyId: environment.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "environment.updated", entityType: "environment", entityId: environment.id, details: summarizeEnvironmentUpdate(patch as Record, environment), }); res.json(environment); }); router.delete("/environments/:id", async (req, res) => { const existing = await svc.getById(req.params.id as string); if (!existing) { res.status(404).json({ error: "Environment not found" }); return; } await assertCanMutateEnvironments(req, existing.companyId); await Promise.all([ executionWorkspaces.clearEnvironmentSelection(existing.companyId, existing.id), issues.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id), projects.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id), ]); const removed = await svc.remove(existing.id); if (!removed) { res.status(404).json({ error: "Environment not found" }); return; } const secretId = readSshEnvironmentPrivateKeySecretId(existing); if (secretId) { await secrets.remove(secretId); } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "environment.deleted", entityType: "environment", entityId: removed.id, details: { name: removed.name, driver: removed.driver, status: removed.status, }, }); res.json(removed); }); router.post("/environments/:id/probe", async (req, res) => { const environment = await svc.getById(req.params.id as string); if (!environment) { res.status(404).json({ error: "Environment not found" }); return; } await assertCanMutateEnvironments(req, environment.companyId); const actor = getActorInfo(req); const probe = await probeEnvironment(db, environment); await logActivity(db, { companyId: environment.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "environment.probed", entityType: "environment", entityId: environment.id, details: { driver: environment.driver, ok: probe.ok, summary: probe.summary, }, }); res.json(probe); }); router.post( "/companies/:companyId/environments/probe-config", validate(probeEnvironmentConfigSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateEnvironments(req, companyId); const actor = getActorInfo(req); const normalizedConfig = normalizeEnvironmentConfigForProbe({ driver: req.body.driver, config: req.body.config, }); const environment = { id: "unsaved", companyId, name: req.body.name?.trim() || "Unsaved environment", description: req.body.description ?? null, driver: req.body.driver, status: "active" as const, config: normalizedConfig, metadata: req.body.metadata ?? null, createdAt: new Date(), updatedAt: new Date(), }; const probe = await probeEnvironment(db, environment, { resolvedConfig: { driver: req.body.driver, config: normalizedConfig, } as ParsedEnvironmentConfig, }); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "environment.probed_unsaved", entityType: "environment", entityId: "unsaved", details: { driver: environment.driver, ok: probe.ok, summary: probe.summary, configTopLevelKeyCount: Object.keys(environment.config).length, }, }); res.json(probe); }, ); return router; }