mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - In a multi-tenant deployment, route handlers that take a resource id (`issue`, `goal`, `project`, `approval`, etc.) look the resource up by id and then call `assertCompanyAccess` on its `companyId` — 404 if it doesn't exist, 403 if it exists in another tenant > - The split status codes are a classic *existence oracle*: any authenticated user can enumerate ids across tenants by probing for the 403/404 boundary, mapping out which issues, labels, approvals, etc. exist in other customers' tenants even when they cannot read the contents > - The right fix is a single uniform 404 for both "not found" and "found but cross-tenant", which collapses the oracle but still preserves write-path checks (active membership, viewer-readonly) for *authorized* tenants > - This pull request adds a non-throwing `hasCompanyAccess(req, companyId)` helper plus a `getAccessibleResource` wrapper that ~130 handlers across 14 route files now use, folding the access check into the existence check while still running `assertCompanyAccess` for authorized tenants so viewer-readonly / inactive-membership rejections fire unchanged on write paths > - The benefit is closing a multi-tenant information leak without breaking write-path security or single-tenant local-first behavior ## Linked Issues or Issue Description Refs #709 — asks for company-scope regression coverage across approval/activity/access routes, because a subtle route refactor could leak cross-tenant data; this PR hardens exactly those surfaces (uniform 404 across 14 route files including `approvals`, `activity`, `secrets`) and updates cross-tenant expectations in test files. It does not add the full coverage matrix #709 asks for — hence Refs, not Closes. No existing issue covers the oracle itself — described in-PR: - Route handlers returned 404 for "not found" but 403 for "exists in another tenant", a classic *existence oracle*: any authenticated user could enumerate ids across tenants by probing the 403/404 boundary. - That maps out which issues, labels, approvals, etc. exist in other customers' tenants even when their contents are unreadable. - Fix: a uniform 404 for both cases, while keeping write-path checks (active membership, viewer-readonly) for authorized tenants. ## What Changed - **`server/src/routes/authz.ts`** — new `hasCompanyAccess(req, companyId): boolean` helper alongside the existing `assertCompanyAccess`. Docstring spells out the two-step pattern (404 gate, then `assertCompanyAccess` for write-path checks). The helper mirrors `assertCompanyAccess`'s company-scope semantics exactly — in particular, signed-in instance admins do **not** get blanket access to companies they are not a member of (the repo's `authz-company-access` tests pin that behavior for `assertCompanyAccess`; an earlier draft of the helper accidentally widened it for reads). - **`getAccessibleResource(req, res, lookup, notFoundMessage)`** — the safe thing is now the easy thing. One helper wraps the whole pattern (uniform 404 for missing/cross-tenant, then `assertCompanyAccess` for write-path membership checks) and ~130 handlers across 14 route files use it: ```ts const goal = await getAccessibleResource(req, res, svc.getById(id), "Goal not found"); if (!goal) return; ``` Files: `activity`, `agents`, `approvals`, `assets`, `costs`, `environments`, `execution-workspaces`, `file-resources`, `goals`, `issue-tree-control`, `issues`, `projects`, `routines`, `secrets`. Handlers with bespoke not-found behavior (the legacy `200 []` contract, audit-logged denials in `file-resources`, null-returning authz helpers) compose `hasCompanyAccess` directly using the documented two-step pattern: ```ts // step 1: close the oracle (uniform 404 for both not-found and cross-tenant) if (!existing || !hasCompanyAccess(req, existing.companyId)) { res.status(404).json({ error: "Goal not found" }); return; } // step 2: enforce write-path membership checks for authorised tenants (no-op on GET) assertCompanyAccess(req, existing.companyId); ``` Routes where `companyId` comes from *request input* (`req.params.companyId`, `req.body.companyId`, e.g. in `companies.ts` and `plugins.ts`) deliberately retain plain `assertCompanyAccess` — there's no existence oracle to close because the companyId is an input, not a discovered value. - **Full-sweep coverage** — a scripted audit of every `assertCompanyAccess(req, <resource>.companyId)` call site in `server/src/routes/` found ~55 lookup-then-assert pairs the first pass missed; all are now gated. Notable ones: the `/secret-provider-configs/:id` CRUD routes, the agents instructions-bundle/config-revision/skills-sync routes (which check access via the `assertCanUpdateAgent` / `assertCanReadAgent` / `assertCanManageInstructionsPath` helpers), `POST /heartbeat-runs/:runId/watchdog-decisions`, `GET /issues/:id/cost-summary`, the environment + environment-lease GET routes, all six issue-tree-control routes, ~24 issue sub-resource routes (document annotations, interactions, approvals links, recovery actions, plan decompositions, lock/unlock), and the three workspace file-resource routes (these throw `notFound` instead of `forbidden` inside their audit-logging wrappers, so denied attempts are still activity-logged server-side while the client sees a uniform 404). - **Helpers made self-defending** — `assertCanUpdateAgent` / `assertCanReadAgent` / `assertCanManageInstructionsPath` (agents) and `assertCanManage{Project,Execution}WorkspaceRuntimeServices` throw `notFound` for cross-tenant resources before their `assertCompanyAccess` step, so a future caller that forgets the route-level gate still can't reopen the oracle. - **Pattern enforcement** — new `authz-existence-oracle-guard.test.ts` statically scans `server/src/routes/*.ts` and fails CI on any `assertCompanyAccess(req, <resource>.companyId)` call that is not preceded by a `hasCompanyAccess` gate, with an explicit allowlist (plus staleness check) for the request-input cases. New routes that regress to the 403/404 split fail the suite with a message pointing at the documented pattern. - **Tests** — cross-tenant expectations updated from 403→404 where routes are now gated; new `hasCompanyAccess` unit tests in `authz-company-access.test.ts` pin the instance-admin/local-implicit/agent/none semantics in lockstep with `assertCompanyAccess`; `write-path-membership.test.ts` (added in an earlier round) confirms viewer/inactive users are still rejected on writes. - **One legacy-contract preserve** — `GET /heartbeat-runs/:runId/issues` still returns `200 []` for both "doesn't exist" and "cross-tenant" so the legacy contract is preserved while the oracle stays closed. ## Verification - `pnpm run typecheck` — PASS. - `pnpm -F @paperclipai/server exec vitest run` — full server suite green locally apart from 4 pre-existing local-environment failures (`paperclip-skill-utils` ×2 and `workspace-runtime` ×1 are cwd/git-environment dependent — verified identical on a clean checkout of the base; `heartbeat-process-recovery` is the known macOS flake). - The new `authz-existence-oracle-guard` test sweeps `server/src/routes/*.ts` and confirms no remaining `assertCompanyAccess(resource.companyId)` site without a `hasCompanyAccess` gate; the only allowlisted holdouts take `companyId` from request input. ## Risks - **API contract narrowing.** Any client that specifically checked for `403` on cross-tenant access now sees `404`. This is a strict narrowing (one status instead of two for the same negative outcome) and matches what a client should expect for any id it can't access. - **Write-path checks preserved.** `assertCompanyAccess` still runs after the 404 gate on write routes, so viewer-readonly / inactive-membership rejections fire unchanged for legitimate users. - **Instance-admin scope unchanged.** `hasCompanyAccess` denies signed-in instance admins without an explicit membership, exactly like `assertCompanyAccess` (pinned by unit tests) — so the gate introduces no new read access for admins. - **Single-tenant local-first deploys** behave identically — the helper short-circuits to `true` for `local_implicit` sessions. - No new env vars, no deployment-mode switch. ## Model Used Claude Opus 4.7 (1M context), extended thinking mode; completeness sweep + instance-admin parity fix by Claude Fable 5 (1M context). ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Checked ROADMAP.md — part of the multi-tenant hardening initiative - [x] Tests run locally and pass - [x] Added/updated cross-tenant 404 expectations across test files - [x] No UI changes - [x] Documented risks above - [x] Will address all Greptile and reviewer comments before merge Part of the multi-tenant hardening initiative — see also #5864 (per-company JWT keys) and #5865 (plugin tables `company_id`). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
689 lines
26 KiB
TypeScript
689 lines
26 KiB
TypeScript
import { Router, type Request, type Response } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
createProjectSchema,
|
|
createProjectWorkspaceSchema,
|
|
findWorkspaceCommandDefinition,
|
|
isUuidLike,
|
|
matchWorkspaceRuntimeServiceToCommand,
|
|
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 } from "../errors.js";
|
|
import { externalObjectService } from "../services/external-objects.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.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();
|
|
const svc = projectService(db);
|
|
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);
|
|
|
|
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 resolved = await svc.resolveByReference(companyId, rawId);
|
|
if (resolved.ambiguous) {
|
|
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
|
|
}
|
|
return resolved.project?.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(403).json({ error: "Project is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function filterProjectsForActor<T extends { id: string; companyId: string }>(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/projects", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.list(companyId);
|
|
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;
|
|
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<typeof svc.create>[1] & {
|
|
workspace?: Parameters<typeof svc.createWorkspace>[1];
|
|
};
|
|
|
|
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
|
await assertProjectEnvironmentSelection(
|
|
companyId,
|
|
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
|
|
);
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
[
|
|
...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy),
|
|
...collectProjectWorkspaceCommandPaths(workspace, "workspace"),
|
|
],
|
|
);
|
|
if (projectData.env !== undefined) {
|
|
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
|
|
companyId,
|
|
projectData.env,
|
|
{ strictMode: strictSecretsMode, fieldPath: "env" },
|
|
);
|
|
}
|
|
const project = await svc.create(companyId, projectData);
|
|
if (project.env) {
|
|
await secretsSvc.syncEnvBindingsForTarget?.(
|
|
companyId,
|
|
{ targetType: "project", targetId: project.id },
|
|
project.env,
|
|
);
|
|
}
|
|
let createdWorkspaceId: string | null = null;
|
|
if (workspace) {
|
|
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
|
|
if (!createdWorkspace) {
|
|
await svc.remove(project.id);
|
|
res.status(422).json({ error: "Invalid project workspace payload" });
|
|
return;
|
|
}
|
|
createdWorkspaceId = createdWorkspace.id;
|
|
}
|
|
const hydratedProject = workspace ? await svc.getById(project.id) : project;
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.created",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: {
|
|
name: project.name,
|
|
workspaceId: createdWorkspaceId,
|
|
envKeys: project.env ? Object.keys(project.env).sort() : [],
|
|
},
|
|
});
|
|
const telemetryClient = getTelemetryClient();
|
|
if (telemetryClient) {
|
|
trackProjectCreated(telemetryClient);
|
|
}
|
|
res.status(201).json(hydratedProject ?? 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;
|
|
const body = { ...req.body };
|
|
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<string, unknown>).sort()
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
res.json(project);
|
|
});
|
|
|
|
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;
|
|
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;
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectWorkspaceCommandPaths(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;
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectWorkspaceCommandPaths(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;
|
|
|
|
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
|
if (!workspace) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
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,
|
|
},
|
|
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,
|
|
},
|
|
config: { workspaceRuntime: runtimeConfig },
|
|
adapterEnv: {},
|
|
onLog,
|
|
serviceIndex: selectedServiceIndex,
|
|
});
|
|
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 === "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;
|
|
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;
|
|
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;
|
|
}
|