import { skillAccessRequestContext } from "../services/skill-access.js"; import { createHash } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { activityLog } from "@paperclipai/db"; import { projectToolContext } from "../services/project-tool-context.js"; import { persistActivity, publishActivity } from "../services/activity-log.js"; import { z } from "zod"; import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from "../services/project-repositories.js"; import { toolAccessService } from "../services/tool-access.js"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { agents, authUsers, companyMemberships, instanceUserRoles, projectAccessMembers } from "@paperclipai/db"; import { inArray } from "drizzle-orm"; import { addProjectAccessMemberSchema, createProjectSchema, projectDiscoverySchema, type ProjectDiscoveryPage, createProjectWorkspaceSchema, deriveProjectUrlKey, findWorkspaceCommandDefinition, isUuidLike, matchWorkspaceRuntimeServiceToCommand, normalizeProjectUrlKey, updateProjectSchema, updateProjectWorkspaceSchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict, forbidden, unprocessable } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; import { ensureProjectAccessMember } from "../services/projects.js"; import { buildWorkspaceRuntimeDesiredStatePatch, listConfiguredRuntimeServiceEntries, runWorkspaceJobForControl, startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace, } from "../services/workspace-runtime.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectProjectExecutionWorkspaceCommandPaths, collectProjectWorkspaceCommandPaths, } from "./workspace-command-authz.js"; import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; import { appendWithCap } from "../adapters/utils.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { environmentService } from "../services/environments.js"; import { secretService } from "../services/secrets.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]); export function projectRoutes(db: Db) { const router = Router(); router.use(skillAccessRequestContext); const svc = projectService(db); async function repositoryViewer(req: Request) { if (req.actor.type === "board") return { userId: req.actor.userId ?? null, localTrusted: req.actor.source === "local_implicit" }; const context = await projectToolContext(db, req.actor); if (!context.userId) throw forbidden("Repository access requires a responsible user"); return context; } async function selectedRepositories(req: Request, companyId: string, ids: string[], existing: import("@paperclipai/shared").ProjectWorkspace[] = []) { const viewer = await repositoryViewer(req); if (!ids.length) return []; const available = await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted); return resolveProjectRepositorySelection(ids, available.repositories, existing); } const access = accessService(db); const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); const instanceSettings = instanceSettingsService(db); const externalObjectsSvc = externalObjectService(db, { enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true, }); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); /** * Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project * workspace `cwd` is an absolute path on the execution host. When the policy * is on every agent runs in the platform-managed environment, so there is no * host for a user to point at and a write that carries a path is refused * rather than stored and silently ignored. This is the floor behind the * hidden UI field, and it applies to every actor, mirroring how * `assertNoAgentHostWorkspaceCommandMutation` floors host-executed commands * on these same routes. * * `cwd: null` still passes: clearing a stale path is exactly what an instance * that just turned the policy on needs to do. The settings read only happens * when the payload actually carries a path. */ async function assertNoManagedSandboxWorkspacePath(workspacePatch: unknown) { if (typeof workspacePatch !== "object" || workspacePatch === null || Array.isArray(workspacePatch)) return; const patch = workspacePatch as Record; if (!Object.prototype.hasOwnProperty.call(patch, "cwd")) return; if (patch.cwd === null || patch.cwd === undefined) return; if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly !== true) return; throw unprocessable( "This instance runs agents only in the platform-managed environment; local folders are not configurable.", ); } async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) { if (environmentId === undefined || environmentId === null) return; await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { allowedDrivers: ["local", "ssh", "sandbox"], }); } function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined { if (!policy || typeof policy !== "object" || !("environmentId" in policy)) { return undefined; } const environmentId = (policy as { environmentId?: unknown }).environmentId; return typeof environmentId === "string" || environmentId === null ? environmentId : undefined; } async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; const requestedCompanyId = typeof companyIdQuery === "string" && companyIdQuery.trim().length > 0 ? companyIdQuery.trim() : null; if (requestedCompanyId) { assertCompanyAccess(req, requestedCompanyId); return requestedCompanyId; } if (req.actor.type === "agent" && req.actor.companyId) { return req.actor.companyId; } return null; } async function normalizeProjectReference(req: Request, rawId: string) { if (isUuidLike(rawId)) return rawId; const companyId = await resolveCompanyIdForProjectReference(req); if (!companyId) return rawId; const urlKey = normalizeProjectUrlKey(rawId); if (!urlKey) return rawId; const visibleProjects = await filterProjectsForActor( req, await svc.list(companyId, { includeArchived: true }), ); const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey); if (matches.length > 1) { throw conflict("Project shortname is ambiguous in this company. Use the project ID."); } return matches[0]?.id ?? rawId; } async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) { const decision = await access.decide({ actor: req.actor, action: "project:read", resource: { type: "project", companyId: project.companyId, projectId: project.id }, }); if (decision.allowed) return true; res.status(404).json({ error: "Project not found" }); return false; } async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id: string; companyId: string }) { const assigneeAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null; const decision = await access.decide({ actor: req.actor, action: "project:write_workspace", resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId }, scope: { projectId: project.id, assigneeAgentId } }); if (decision.allowed) return true; res.status(403).json({ error: decision.explanation }); return false; } async function assertCanManageProjectPrivacy(req: Request, res: Response, project: { companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null; }) { const actor = req.actor; if (actor.type === "board" && actor.userId) { if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true; if (actor.userId === project.privacyOwnerUserId || actor.userId === project.personalOwnerUserId) return true; const instanceAdmin = actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, actor.userId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0); if (instanceAdmin) return true; const membership = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships) .where(and(eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"))).limit(1); if (["owner", "admin"].includes(membership[0]?.role ?? "")) return true; } res.status(403).json({ error: "Only the project privacy owner or an administrator can change project access" }); return false; } async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null }, dbOrTx: Db = db) { const ownerUserId = project.privacyOwnerUserId ?? project.personalOwnerUserId; if (ownerUserId) await ensureProjectAccessMember(dbOrTx, { companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: ownerUserId, }); if (req.actor.type === "board" && req.actor.userId) { await ensureProjectAccessMember(dbOrTx, { companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: req.actor.userId, }); return; } if (req.actor.type === "agent" && req.actor.agentId) { if (req.actor.onBehalfOfUserId) { await ensureProjectAccessMember(dbOrTx, { companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: req.actor.onBehalfOfUserId, }); } await ensureProjectAccessMember(dbOrTx, { companyId: project.companyId, projectId: project.id, subjectType: "agent", subjectId: req.actor.agentId, }); } } async function enrichProjectAccessMembers(rows: Array) { const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId); const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId); const [agentRows, userRows] = await Promise.all([ agentIds.length === 0 ? Promise.resolve([]) : db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)), userIds.length === 0 ? Promise.resolve([]) : db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image }) .from(authUsers) .where(inArray(authUsers.id, userIds)), ]); const agentsById = new Map(agentRows.map((row) => [row.id, row])); const usersById = new Map(userRows.map((row) => [row.id, row])); return rows.map((row) => { const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null; const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null; return { ...row, subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null, subjectAvatarUrl: user?.image ?? null, }; }); } async function assertRuntimeManageAllowed(req: Request, res: Response, companyId: string) { const decision = await access.decide({ actor: req.actor, action: "runtime:manage", resource: { type: "company", companyId }, }); if (decision.allowed) return true; res.status(403).json({ error: "Runtime service control is outside this actor's authorization boundary" }); return false; } async function filterProjectsForActor(req: Request, rows: T[]) { const decisions = await Promise.all(rows.map((project) => access.decide({ actor: req.actor, action: "project:read", resource: { type: "project", companyId: project.companyId, projectId: project.id }, }) )); return rows.filter((_, index) => decisions[index]?.allowed); } router.param("id", async (req, _res, next, rawId) => { try { req.params.id = await normalizeProjectReference(req, rawId); next(); } catch (err) { next(err); } }); router.get("/companies/:companyId/project-repositories", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const viewer = await repositoryViewer(req); res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted)); }); router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => { assertBoard(req); const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found"); if (!project) return; if (!(await assertProjectReadAllowed(req, res, project))) return; if (!(await assertProjectWorkspaceWriteAllowed(req, res, project))) return; const repositories = await selectedRepositories(req, project.companyId, req.body.repositoryIds, project.workspaces); const updated = await svc.replaceRepositories(project.id, repositories); const actor = getActorInfo(req); await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, action: "project.repositories_updated", entityType: "project", entityId: project.id, details: { repositoryIds: repositories.map((repo) => repo.id) }, }); res.json(updated); }); router.get("/companies/:companyId/projects", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const includeArchived = req.query.includeArchived === "true"; if (req.query.view === "summary") { const page = projectDiscoverySchema.extend({ limit: z.coerce.number().int().min(1).max(50).default(50) }).parse({ limit: req.query.limit, cursor: req.query.cursor, }); const candidateIds = await access.projectDiscoveryCandidateIds(req.actor, companyId); const visible: ProjectDiscoveryPage["projects"] = []; let cursor = page.cursor; // Scan bounded projections; authorization runs before selecting the public // page/cursor so denied projects neither fill pages nor leak their IDs. while (visible.length <= page.limit) { const batch = await svc.listSummaries(companyId, { limit: 51, cursor, includeArchived, candidateIds }); const allowed = await filterProjectsForActor(req, batch.map(project => ({ ...project, companyId }))); visible.push(...allowed.map(({ companyId: _companyId, ...project }) => project)); if (batch.length < 51) break; cursor = batch.at(-1)!.id; } const selected = visible.slice(0, page.limit); res.json({ projects: selected, nextCursor: visible.length > page.limit ? selected.at(-1)!.id : null } satisfies ProjectDiscoveryPage); return; } const result = await svc.list(companyId, { includeArchived }); res.json(await filterProjectsForActor(req, result)); }); router.get("/projects/:id", async (req, res) => { const id = req.params.id as string; const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project) return; if (!(await assertProjectReadAllowed(req, res, project))) return; res.json(project); }); router.get("/projects/:id/external-object-summary", async (req, res) => { const id = req.params.id as string; const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project) return; if (!(await assertProjectReadAllowed(req, res, project))) return; const summary = await externalObjectsSvc.getProjectSummary(project.id); res.json(summary); }); router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); type CreateProjectPayload = Parameters[1] & { workspace?: Parameters[1]; repositoryIds?: string[]; }; const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] }; projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null; const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId ? await projectToolContext(db, req.actor, true) : null; await assertProjectEnvironmentSelection( companyId, readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy), ); assertNoAgentHostWorkspaceCommandMutation( req, [ ...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy), ...collectProjectWorkspaceCommandPaths(workspace, "workspace"), ], ); await assertNoManagedSandboxWorkspacePath(workspace); if (projectData.env !== undefined) { projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence( companyId, projectData.env, { strictMode: strictSecretsMode, fieldPath: "env" }, ); } if (workspace && (repositoryIds || repositoryUrls)) throw unprocessable("Use either workspace or repositoryIds/repositoryUrls when creating a project"); const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl); const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null; const actor = getActorInfo(req); const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex"); const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null; const result = await db.transaction(async (tx) => { if (receiptKey) { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${receiptKey}, 0))`); const [prior] = await tx.select().from(activityLog).where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "project.created"), sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`, )); if (prior) { if (prior.details?.fingerprint !== fingerprint) throw conflict("Project idempotency key was used with different inputs"); const project = await projectService(tx as unknown as Db).getById(prior.entityId); if (!project) throw conflict("Previously created project is no longer available"); return { project, publication: null, duplicate: true }; } } if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true); const service = projectService(tx as unknown as Db); const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData); if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db); const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase())); const registeredUrls: typeof urlRepositories = []; for (const repo of urlRepositories) { if (attachedUrls.has(repo.url.toLowerCase())) continue; attachedUrls.add(repo.url.toLowerCase()); await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url }); registeredUrls.push(repo); } const createdWorkspace = workspace ? await service.createWorkspace(project.id, workspace) : null; if (workspace && !createdWorkspace) throw unprocessable("Invalid project workspace payload"); const hydrated = await service.getById(project.id); const activity = await persistActivity(tx as unknown as Db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, issueId: runContext?.issue.id, action: "project.created", entityType: "project", entityId: project.id, details: { name: project.name, description: project.description, icon: project.icon, sourceIssueId: runContext?.issue.id ?? null, repositories: [...(repositories ?? []).map(repo => ({ id: repo.id, name: repo.fullName, url: repo.url })), ...registeredUrls.map(repo => ({ id: repo.url, name: repo.fullName, url: repo.url })), ...(createdWorkspace?.repoUrl ? [{ id: createdWorkspace.id, name: createdWorkspace.name, url: createdWorkspace.repoUrl }] : []), ], workspaceId: createdWorkspace?.id ?? null, envKeys: project.env ? Object.keys(project.env).sort() : [], ...(receiptKey ? { idempotencyKey: receiptKey, fingerprint } : {}), }, }); return { project: hydrated ?? project, publication: activity.publication, duplicate: false }; }); if (result.publication) publishActivity(result.publication); if (result.project.env) await secretsSvc.syncEnvBindingsForTarget?.(companyId, { targetType: "project", targetId: result.project.id }, result.project.env); if (result.duplicate) { res.status(200).json(result.project); return; } const telemetryClient = getTelemetryClient(); if (telemetryClient) { trackProjectCreated(telemetryClient); } res.status(result.duplicate ? 200 : 201).json(result.project); }); router.patch("/projects/:id", validate(updateProjectSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; const body = { ...req.body }; if (body.visibility !== undefined) { if (!(await assertCanManageProjectPrivacy(req, res, existing))) return; } if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing); assertNoAgentHostWorkspaceCommandMutation( req, collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), ); await assertProjectEnvironmentSelection( existing.companyId, readProjectPolicyEnvironmentId(body.executionWorkspacePolicy), ); if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } if (body.env !== undefined) { body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, { strictMode: strictSecretsMode, fieldPath: "env", }); } const project = await svc.update(id, body); if (!project) { res.status(404).json({ error: "Project not found" }); return; } if (body.env !== undefined) { await secretsSvc.syncEnvBindingsForTarget?.( project.companyId, { targetType: "project", targetId: project.id }, project.env, ); } const actor = getActorInfo(req); await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.updated", entityType: "project", entityId: project.id, details: { changedKeys: Object.keys(req.body).sort(), envKeys: body.env && typeof body.env === "object" && !Array.isArray(body.env) ? Object.keys(body.env as Record).sort() : undefined, }, }); res.json(project); }); router.get("/projects/:id/access-members", async (req, res) => { const id = req.params.id as string; const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project || !(await assertProjectReadAllowed(req, res, project))) return; const rows = await db .select() .from(projectAccessMembers) .where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id))); res.json(await enrichProjectAccessMembers(rows)); }); router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project || !(await assertProjectReadAllowed(req, res, project))) return; if (!(await assertCanManageProjectPrivacy(req, res, project))) return; const subjectType = req.body.subjectType as "user" | "agent"; const subjectId = req.body.subjectId as string; const subjectExists = subjectType === "agent" ? await db.select({ id: agents.id }).from(agents) .where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId))) .then((rows) => rows.length > 0) : await db.select({ id: companyMemberships.id }).from(companyMemberships) .where(and( eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, subjectId), eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.status, "active"), )) .then((rows) => rows.length > 0); if (!subjectExists) { res.status(422).json({ error: "Project access member must belong to this company" }); return; } await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId }); const rows = await db.select().from(projectAccessMembers).where(and( eq(projectAccessMembers.projectId, project.id), eq(projectAccessMembers.subjectType, subjectType), eq(projectAccessMembers.subjectId, subjectId), )); const [member] = await enrichProjectAccessMembers(rows); const actor = getActorInfo(req); await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.access_member_added", entityType: "project", entityId: project.id, details: { subjectType, subjectId }, }); res.status(201).json(member); }); router.delete("/projects/:id/access-members/:memberId", async (req, res) => { assertBoard(req); const id = req.params.id as string; const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project || !(await assertProjectReadAllowed(req, res, project))) return; if (!(await assertCanManageProjectPrivacy(req, res, project))) return; const member = await db.select().from(projectAccessMembers).where(and( eq(projectAccessMembers.id, req.params.memberId as string), eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id), )).then((rows) => rows[0] ?? null); if (!member) { res.status(404).json({ error: "Project access member not found" }); return; } if (member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId)) { res.status(422).json({ error: "The project privacy owner cannot be removed" }); return; } await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id)); const actor = getActorInfo(req); await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.access_member_removed", entityType: "project", entityId: project.id, details: { subjectType: member.subjectType, subjectId: member.subjectId }, }); res.json(member); }); router.get("/projects/:id/workspaces", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; const workspaces = await svc.listWorkspaces(id); res.json(workspaces); }); router.post("/projects/:id/workspaces", validate(createProjectWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; if (!(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return; assertNoAgentHostWorkspaceCommandMutation( req, collectProjectWorkspaceCommandPaths(req.body), ); await assertNoManagedSandboxWorkspacePath(req.body); const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.workspace_created", entityType: "project", entityId: id, details: { workspaceId: workspace.id, name: workspace.name, cwd: workspace.cwd, isPrimary: workspace.isPrimary, }, }); res.status(201).json(workspace); }); router.patch( "/projects/:id/workspaces/:workspaceId", validate(updateProjectWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; if (!(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return; assertNoAgentHostWorkspaceCommandMutation( req, collectProjectWorkspaceCommandPaths(req.body), ); await assertNoManagedSandboxWorkspacePath(req.body); const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId); if (!workspaceExists) { res.status(404).json({ error: "Project workspace not found" }); return; } const workspace = await svc.updateWorkspace(id, workspaceId, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.workspace_updated", entityType: "project", entityId: id, details: { workspaceId: workspace.id, changedKeys: Object.keys(req.body).sort(), }, }); res.json(workspace); }, ); async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; const action = String(req.params.action ?? "").trim().toLowerCase(); if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { res.status(404).json({ error: "Workspace command action not found" }); return; } const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!project) return; if (!(await assertProjectReadAllowed(req, res, project))) return; const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null; if (!workspace) { res.status(404).json({ error: "Project workspace not found" }); return; } if (!(await assertRuntimeManageAllowed(req, res, project.companyId))) return; const isSharedWorkspace = Boolean(workspace.sharedWorkspaceKey); if ( req.actor.type === "agent" && isSharedWorkspace && SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS.has(action) ) { throw forbidden("Missing permission to manage workspace runtime services"); } await assertCanManageProjectWorkspaceRuntimeServices(db, req, { companyId: project.companyId, projectWorkspaceId: workspace.id, }); const workspaceCwd = workspace.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" }); return; } const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null; const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; const configuredServices = runtimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtimeConfig }) : []; const workspaceCommand = runtimeConfig ? findWorkspaceCommandDefinition(runtimeConfig, target.workspaceCommandId ?? null) : null; if (target.workspaceCommandId && !workspaceCommand) { res.status(404).json({ error: "Workspace command not found for this project workspace" }); return; } if (target.runtimeServiceId && !(workspace.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) { res.status(404).json({ error: "Runtime service not found for this project workspace" }); return; } const matchedRuntimeService = workspaceCommand?.kind === "service" && !target.runtimeServiceId ? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, workspace.runtimeServices ?? []) : null; const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null; const selectedServiceIndex = workspaceCommand?.kind === "service" ? workspaceCommand.serviceIndex : target.serviceIndex ?? null; if ( selectedServiceIndex !== undefined && selectedServiceIndex !== null && (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length) ) { res.status(422).json({ error: "Selected runtime service is not defined in this project workspace runtime config" }); return; } if (workspaceCommand?.kind === "job" && action !== "run") { res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` }); return; } if (workspaceCommand?.kind === "service" && action === "run") { res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` }); return; } if (action === "run" && !workspaceCommand) { res.status(422).json({ error: "Select a workspace job to run" }); return; } if ((action === "start" || action === "restart") && !runtimeConfig) { res.status(422).json({ error: "Project workspace has no workspace command configuration" }); return; } const actor = getActorInfo(req); const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; let stdout = ""; let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", command: workspaceCommand?.command ?? `workspace command ${action}`, cwd: workspace.cwd, metadata: { action, projectId: project.id, projectWorkspaceId: workspace.id, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, workspaceCommandKind: workspaceCommand?.kind ?? null, workspaceCommandName: workspaceCommand?.name ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, run: async () => { if (action === "run") { if (!workspaceCommand || workspaceCommand.kind !== "job") { throw new Error("Workspace job selection is required"); } return await runWorkspaceJobForControl({ actor: { id: actor.agentId ?? null, name: actor.actorType === "user" ? "Board" : "Agent", companyId: project.companyId, }, issue: null, workspace: { baseCwd: workspaceCwd, source: "project_primary", projectId: project.id, workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, strategy: "project_primary", cwd: workspaceCwd, branchName: workspace.defaultRef ?? workspace.repoRef ?? null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false, }, command: workspaceCommand.rawConfig, adapterEnv: {}, recorder, metadata: { action, projectId: project.id, projectWorkspaceId: workspace.id, workspaceCommandId: workspaceCommand.id, }, }).then((nestedOperation) => ({ status: "succeeded" as const, exitCode: 0, metadata: { nestedOperationId: nestedOperation?.id ?? null, runtimeServiceCount, }, })); } const onLog = async (stream: "stdout" | "stderr", chunk: string) => { if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { await stopRuntimeServicesForProjectWorkspace({ db, projectWorkspaceId: workspace.id, runtimeServiceId: selectedRuntimeServiceId, }); } if (action === "start" || action === "restart") { const startedServices = await startRuntimeServicesForWorkspaceControl({ db, actor: { id: actor.agentId ?? null, name: actor.actorType === "user" ? "Board" : "Agent", companyId: project.companyId, }, issue: null, workspace: { baseCwd: workspaceCwd, source: "project_primary", projectId: project.id, workspaceId: workspace.id, repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, strategy: "project_primary", cwd: workspaceCwd, branchName: workspace.defaultRef ?? workspace.repoRef ?? null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false, }, config: { workspaceRuntime: runtimeConfig }, adapterEnv: {}, onLog, serviceIndex: selectedServiceIndex, runtimeServiceId: selectedRuntimeServiceId, }); runtimeServiceCount = startedServices.length; } else { runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0; } const currentDesiredState: WorkspaceRuntimeDesiredState = workspace.runtimeConfig?.desiredState ?? ((workspace.runtimeServices ?? []).some((service) => service.status === "provisioning" || service.status === "starting" || service.status === "running" ) ? "running" : "stopped"); const nextRuntimeState: { desiredState: WorkspaceRuntimeDesiredState; serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined; } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) ? { desiredState: currentDesiredState, serviceStates: workspace.runtimeConfig?.serviceStates ?? null, } : buildWorkspaceRuntimeDesiredStatePatch({ config: { workspaceRuntime: runtimeConfig }, currentDesiredState, currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null, action, serviceIndex: selectedServiceIndex, }); await svc.updateWorkspace(project.id, workspace.id, { runtimeConfig: { desiredState: nextRuntimeState.desiredState, serviceStates: nextRuntimeState.serviceStates, }, }); return { status: "succeeded", stdout, stderr, system: action === "stop" ? "Stopped project workspace runtime services.\nThis does not pause issue work or held wake scheduling." : action === "restart" ? "Restarted project workspace runtime services.\nThis does not pause issue work or held wake scheduling." : "Started project workspace runtime services.\n", metadata: { runtimeServiceCount, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, }; }, }); const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace; await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: `project.workspace_runtime_${action}`, entityType: "project", entityId: project.id, details: { projectWorkspaceId: workspace.id, runtimeServiceCount, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, workspaceCommandKind: workspaceCommand?.kind ?? null, workspaceCommandName: workspaceCommand?.name ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, }); res.json({ workspace: updatedWorkspace, operation, }); } router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand); router.post("/projects/:id/workspaces/:workspaceId/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand); router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; if (!(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return; const workspace = await svc.removeWorkspace(id, workspaceId); if (!workspace) { res.status(404).json({ error: "Project workspace not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.workspace_deleted", entityType: "project", entityId: id, details: { workspaceId: workspace.id, name: workspace.name, }, }); res.json(workspace); }); router.delete("/projects/:id", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found"); if (!existing) return; if (!(await assertProjectReadAllowed(req, res, existing))) return; const project = await svc.remove(id); if (!project) { res.status(404).json({ error: "Project not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "project.deleted", entityType: "project", entityId: project.id, }); res.json(project); }); return router; }