mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +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>
978 lines
35 KiB
TypeScript
978 lines
35 KiB
TypeScript
import { Router, type Request } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
AGENT_ADAPTER_TYPES,
|
|
cancelEnvironmentCustomImageSetupSessionSchema,
|
|
createEnvironmentCustomImageTerminalSessionTokenSchema,
|
|
createEnvironmentSchema,
|
|
finishEnvironmentCustomImageSetupSessionSchema,
|
|
getEnvironmentCapabilities,
|
|
probeEnvironmentConfigSchema,
|
|
redactEnvironmentCustomImageSetupSession,
|
|
redactEnvironmentCustomImageTemplate,
|
|
startEnvironmentCustomImageSetupSessionSchema,
|
|
type EnvironmentDeleteBlastRadius,
|
|
updateEnvironmentSchema,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, forbidden, unprocessable } from "../errors.js";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import {
|
|
environmentCustomImageService,
|
|
issueService,
|
|
instanceSettingsService,
|
|
logActivity,
|
|
projectService,
|
|
} from "../services/index.js";
|
|
import {
|
|
environmentCustomImageTerminalConnectionRegistry,
|
|
environmentCustomImageTerminalSessionStore,
|
|
validateCustomImageSetupSshPayload,
|
|
type EnvironmentCustomImageTerminalPayloadValidationResult,
|
|
} from "../services/environment-custom-image-terminal-sessions.js";
|
|
import {
|
|
readCustomImageSetupSessionCompanyId,
|
|
requireFutureCustomImageSetupExpiry,
|
|
} from "../services/environment-custom-image-setup-session-utils.js";
|
|
import {
|
|
collectEnvironmentSecretRefs,
|
|
normalizeEnvironmentConfigForPersistence,
|
|
normalizeEnvironmentConfigForProbe,
|
|
readSshEnvironmentPrivateKeySecretId,
|
|
type ParsedEnvironmentConfig,
|
|
} from "../services/environment-config.js";
|
|
import { probeEnvironment } from "../services/environment-probe.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
|
|
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
|
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
|
|
|
export function environmentRoutes(
|
|
db: Db,
|
|
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
|
) {
|
|
const router = Router();
|
|
const svc = environmentService(db);
|
|
const customImages = environmentCustomImageService(db, {
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
});
|
|
const executionWorkspaces = executionWorkspaceService(db);
|
|
const issues = issueService(db);
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const projects = projectService(db);
|
|
const secrets = secretService(db);
|
|
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
|
|
|
function parseObject(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function assertCanAccessInstanceEnvironments(req: Request) {
|
|
if (req.actor.type !== "board") {
|
|
throw forbidden("Instance environment management is restricted to board operators");
|
|
}
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
|
throw forbidden("Instance admin access required");
|
|
}
|
|
|
|
function assertCanReadInstanceEnvironments(req: Request) {
|
|
assertBoardOrgAccess(req);
|
|
}
|
|
|
|
function assertCustomImageCompanyAccess(req: Request, companyId: string) {
|
|
if (req.actor.type !== "board") {
|
|
throw forbidden("Board access required");
|
|
}
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
|
const allowedCompanies = req.actor.companyIds ?? [];
|
|
if (!allowedCompanies.includes(companyId)) {
|
|
throw forbidden("User does not have access to this company");
|
|
}
|
|
}
|
|
|
|
function canReadFullInstanceEnvironment(req: Request) {
|
|
return req.actor.type === "board"
|
|
&& (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin);
|
|
}
|
|
|
|
function redactEnvironmentForRestrictedView<T extends {
|
|
config: Record<string, unknown> | null;
|
|
envVars?: Record<string, unknown> | null;
|
|
metadata: Record<string, unknown> | null;
|
|
}>(environment: T): T {
|
|
return {
|
|
...environment,
|
|
config: {},
|
|
...(Object.prototype.hasOwnProperty.call(environment, "envVars") ? { envVars: {} } : {}),
|
|
metadata: null,
|
|
};
|
|
}
|
|
|
|
function presentEnvironmentForRead<T extends {
|
|
config: Record<string, unknown> | null;
|
|
envVars?: Record<string, unknown> | null;
|
|
metadata: Record<string, unknown> | null;
|
|
}>(req: Request, environment: T): T {
|
|
return canReadFullInstanceEnvironment(req)
|
|
? environment
|
|
: redactEnvironmentForRestrictedView(environment);
|
|
}
|
|
|
|
async function assertCanReadSecretsForDraftProbe(req: Request, companyId: string) {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
return companyId;
|
|
}
|
|
|
|
async function logInstanceEnvironmentActivity(input: {
|
|
actor: ReturnType<typeof getActorInfo>;
|
|
action: string;
|
|
entityId: string;
|
|
details: Record<string, unknown>;
|
|
}) {
|
|
const companyIds = await instanceSettings.listCompanyIds();
|
|
await Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(db, {
|
|
companyId,
|
|
actorType: input.actor.actorType,
|
|
actorId: input.actor.actorId,
|
|
agentId: input.actor.agentId,
|
|
runId: input.actor.runId,
|
|
action: input.action,
|
|
entityType: "environment",
|
|
entityId: input.entityId,
|
|
details: input.details,
|
|
})
|
|
),
|
|
);
|
|
}
|
|
|
|
async function logEnvironmentCustomImageActivity(input: {
|
|
actor: ReturnType<typeof getActorInfo>;
|
|
companyId: string;
|
|
action: string;
|
|
entityId: string;
|
|
details: Record<string, unknown>;
|
|
}) {
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType: input.actor.actorType,
|
|
actorId: input.actor.actorId,
|
|
agentId: input.actor.agentId,
|
|
runId: input.actor.runId,
|
|
action: input.action,
|
|
entityType: "environment",
|
|
entityId: input.entityId,
|
|
details: input.details,
|
|
});
|
|
}
|
|
|
|
async function resolveCustomImageCompanyId(req: Request): Promise<string> {
|
|
const queryCompanyId =
|
|
typeof req.query.companyId === "string" && req.query.companyId.trim().length > 0
|
|
? req.query.companyId.trim()
|
|
: null;
|
|
if (queryCompanyId) {
|
|
assertCustomImageCompanyAccess(req, queryCompanyId);
|
|
return queryCompanyId;
|
|
}
|
|
if (req.actor.type === "board" && req.actor.companyIds?.length === 1) {
|
|
return req.actor.companyIds[0]!;
|
|
}
|
|
const companyIds = await instanceSettings.listCompanyIds();
|
|
if (companyIds.length === 1 && companyIds[0]) {
|
|
const companyId = companyIds[0];
|
|
assertCustomImageCompanyAccess(req, companyId);
|
|
return companyId;
|
|
}
|
|
throw unprocessable("companyId query parameter is required for environment customImage setup.");
|
|
}
|
|
|
|
async function resolveCustomImageSessionCompanyId(
|
|
req: Request,
|
|
session: { metadata?: Record<string, unknown> | null },
|
|
): Promise<string> {
|
|
const metadataCompanyId = readCustomImageSetupSessionCompanyId(session);
|
|
if (metadataCompanyId) {
|
|
assertCustomImageCompanyAccess(req, metadataCompanyId);
|
|
return metadataCompanyId;
|
|
}
|
|
return await resolveCustomImageCompanyId(req);
|
|
}
|
|
|
|
async function resolveEnvironmentSecretContextCompanyId(
|
|
req: Request,
|
|
environmentId: string,
|
|
options: { required: boolean },
|
|
): Promise<string | null> {
|
|
const routeCompanyId =
|
|
typeof req.params.companyId === "string" && req.params.companyId.trim().length > 0
|
|
? req.params.companyId.trim()
|
|
: typeof req.query.companyId === "string" && req.query.companyId.trim().length > 0
|
|
? req.query.companyId.trim()
|
|
: null;
|
|
const bindingCompanyIds = await secrets.listBindingCompanyIdsForTarget({
|
|
targetType: "environment",
|
|
targetId: environmentId,
|
|
});
|
|
if (routeCompanyId && bindingCompanyIds.length > 0 && !bindingCompanyIds.includes(routeCompanyId)) {
|
|
throw conflict("Environment secret bindings already use a different company context.");
|
|
}
|
|
if (routeCompanyId) return routeCompanyId;
|
|
if (bindingCompanyIds.length === 1) return bindingCompanyIds[0] ?? null;
|
|
if (bindingCompanyIds.length > 1) {
|
|
throw conflict("Environment secret bindings span multiple companies and require explicit companyId context.");
|
|
}
|
|
if (req.actor.type === "agent" && req.actor.companyId) return req.actor.companyId;
|
|
if (req.actor.type === "board" && Array.isArray(req.actor.companyIds) && req.actor.companyIds.length === 1) {
|
|
return req.actor.companyIds[0] ?? null;
|
|
}
|
|
if (!options.required) return null;
|
|
throw unprocessable(
|
|
"Environment secret management requires a companyId context during the instance-scoped transition.",
|
|
);
|
|
}
|
|
|
|
function summarizeEnvironmentUpdate(
|
|
patch: Record<string, unknown>,
|
|
environment: {
|
|
name: string;
|
|
driver: string;
|
|
status: string;
|
|
},
|
|
): Record<string, unknown> {
|
|
const details: Record<string, unknown> = {
|
|
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<string, unknown>).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<string, unknown>).length
|
|
: 0;
|
|
}
|
|
|
|
return details;
|
|
}
|
|
|
|
function environmentDeleteBlockMessage(impact: EnvironmentDeleteBlastRadius): string | null {
|
|
if (impact.staticReferences.isManagedLocal) {
|
|
return "Cannot delete the managed local environment.";
|
|
}
|
|
if (impact.staticReferences.isInstanceDefault) {
|
|
return "Cannot delete the current instance default environment. Set a new default environment before deleting this one.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rejectEnvironmentDelete(input: {
|
|
actor: ReturnType<typeof getActorInfo>;
|
|
environment: { id: string; driver: string };
|
|
impact: EnvironmentDeleteBlastRadius;
|
|
}): never {
|
|
const message =
|
|
environmentDeleteBlockMessage(input.impact)
|
|
?? "Environment delete is currently blocked. Refresh the environment and retry.";
|
|
logger.warn(
|
|
{
|
|
environmentId: input.environment.id,
|
|
environmentDriver: input.environment.driver,
|
|
deleteBlockedReasons: input.impact.deleteBlockedReasons,
|
|
actorType: input.actor.actorType,
|
|
actorId: input.actor.actorId,
|
|
agentId: input.actor.agentId,
|
|
runId: input.actor.runId,
|
|
},
|
|
"environment delete rejected by guard",
|
|
);
|
|
throw conflict(message, { deleteBlockedReasons: input.impact.deleteBlockedReasons });
|
|
}
|
|
|
|
function setupSessionActivityDetails(session: {
|
|
id: string;
|
|
environmentId: string;
|
|
provider: string;
|
|
status: string;
|
|
providerLeaseId: string | null;
|
|
baseTemplateRef: string | null;
|
|
connectionSummary?: Record<string, unknown> | null;
|
|
connectionSecretRef: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
}) {
|
|
return redactEnvironmentCustomImageSetupSession({
|
|
sessionId: session.id,
|
|
environmentId: session.environmentId,
|
|
provider: session.provider,
|
|
status: session.status,
|
|
providerLeaseId: session.providerLeaseId,
|
|
baseTemplateRef: session.baseTemplateRef,
|
|
connectionSummary: session.connectionSummary,
|
|
connectionSecretRef: session.connectionSecretRef,
|
|
metadata: session.metadata,
|
|
});
|
|
}
|
|
|
|
function templateActivityDetails(template: {
|
|
id: string;
|
|
environmentId: string;
|
|
provider: string;
|
|
status: string;
|
|
templateKind: string;
|
|
templateRef: string | null;
|
|
sourceTemplateRef: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
}) {
|
|
return redactEnvironmentCustomImageTemplate({
|
|
templateId: template.id,
|
|
environmentId: template.environmentId,
|
|
provider: template.provider,
|
|
status: template.status,
|
|
templateKind: template.templateKind,
|
|
templateRef: template.templateRef,
|
|
sourceTemplateRef: template.sourceTemplateRef,
|
|
metadata: template.metadata,
|
|
});
|
|
}
|
|
|
|
function throwTerminalPayloadValidationFailure(
|
|
failure: Extract<EnvironmentCustomImageTerminalPayloadValidationResult, { ok: false }>,
|
|
): never {
|
|
if (failure.status === 409) {
|
|
throw conflict(failure.message);
|
|
}
|
|
throw unprocessable(failure.message);
|
|
}
|
|
|
|
router.get("/companies/:companyId/environments", async (req, res) => {
|
|
assertCanReadInstanceEnvironments(req);
|
|
const rows = await svc.list({
|
|
status: req.query.status as string | undefined,
|
|
driver: req.query.driver as string | undefined,
|
|
});
|
|
res.json(rows.map((row) => presentEnvironmentForRead(req, row)));
|
|
});
|
|
|
|
router.get("/environments/:id/delete-blast-radius", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const impact = await svc.getDeleteBlastRadius(req.params.id as string);
|
|
if (!impact) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
res.json(impact);
|
|
});
|
|
|
|
router.get("/companies/:companyId/environments/capabilities", async (req, res) => {
|
|
assertCanReadInstanceEnvironments(req);
|
|
const pluginDrivers = await listReadyPluginEnvironmentDrivers({
|
|
db,
|
|
workerManager: options.pluginWorkerManager,
|
|
});
|
|
res.json(getEnvironmentCapabilities(
|
|
AGENT_ADAPTER_TYPES,
|
|
{
|
|
sandboxProviders: Object.fromEntries(pluginDrivers.map((driver) => [
|
|
driver.driverKey,
|
|
{
|
|
status: "supported" as const,
|
|
supportsSavedProbe: true,
|
|
supportsUnsavedProbe: true,
|
|
supportsRunExecution: true,
|
|
supportsReusableLeases: driver.supportsReusableLeases ?? true,
|
|
supportsInteractiveSetup: driver.supportsInteractiveSetup,
|
|
interactiveSetupConnectionTypes: driver.interactiveSetupConnectionTypes,
|
|
supportsTemplateCapture: driver.supportsTemplateCapture,
|
|
templateRefKind: driver.templateRefKind,
|
|
templateConfigBinding: driver.templateConfigBinding,
|
|
supportsTemplateDelete: driver.supportsTemplateDelete,
|
|
displayName: driver.displayName,
|
|
description: driver.description,
|
|
source: "plugin" as const,
|
|
pluginKey: driver.pluginKey,
|
|
pluginId: driver.pluginId,
|
|
configSchema: driver.configSchema,
|
|
},
|
|
])),
|
|
},
|
|
));
|
|
});
|
|
|
|
router.get("/environments/:environmentId/custom-image-template", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
await resolveCustomImageCompanyId(req);
|
|
const overview = await customImages.getOverview({
|
|
environmentId: req.params.environmentId as string,
|
|
});
|
|
res.json(overview);
|
|
});
|
|
|
|
router.post(
|
|
"/environments/:environmentId/custom-image-setup-sessions",
|
|
validate(startEnvironmentCustomImageSetupSessionSchema),
|
|
async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const companyId = await resolveCustomImageCompanyId(req);
|
|
const actor = getActorInfo(req);
|
|
const result = await customImages.startSetupSession({
|
|
environmentId: req.params.environmentId as string,
|
|
templateId: req.body.templateId ?? null,
|
|
ttlSeconds: req.body.ttlSeconds ?? null,
|
|
actor: {
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
agentId: actor.agentId,
|
|
},
|
|
secretContextCompanyId: companyId,
|
|
});
|
|
await logEnvironmentCustomImageActivity({
|
|
actor,
|
|
companyId,
|
|
action: "environment.custom_image_setup.started",
|
|
entityId: result.session.environmentId,
|
|
details: setupSessionActivityDetails(result.session),
|
|
});
|
|
res.status(201).json(result);
|
|
},
|
|
);
|
|
|
|
router.get("/environment-custom-image-setup-sessions/:sessionId", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const session = await customImages.getSessionById(req.params.sessionId as string);
|
|
if (!session) {
|
|
res.status(404).json({ error: "Environment customImage setup session not found" });
|
|
return;
|
|
}
|
|
await resolveCustomImageSessionCompanyId(req, session);
|
|
const result = await customImages.refreshSetupSession({
|
|
sessionId: session.id,
|
|
includeConnectionPayload: true,
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
router.post(
|
|
"/environment-custom-image-setup-sessions/:sessionId/terminal-session-token",
|
|
validate(createEnvironmentCustomImageTerminalSessionTokenSchema),
|
|
async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const session = await customImages.getSessionById(req.params.sessionId as string);
|
|
if (!session) {
|
|
res.status(404).json({ error: "Environment customImage setup session not found" });
|
|
return;
|
|
}
|
|
const companyId = await resolveCustomImageSessionCompanyId(req, session);
|
|
|
|
const refreshed = await customImages.refreshSetupSession({
|
|
sessionId: session.id,
|
|
includeConnectionPayload: true,
|
|
});
|
|
const now = new Date();
|
|
if (refreshed.session.status !== "waiting_for_user") {
|
|
throw conflict(`Cannot create terminal session token from setup status "${refreshed.session.status}".`);
|
|
}
|
|
const setupExpiresAt = requireFutureCustomImageSetupExpiry(refreshed.session, now);
|
|
const payloadValidation = validateCustomImageSetupSshPayload(refreshed.connectionPayload, now);
|
|
if (!payloadValidation.ok) {
|
|
throwTerminalPayloadValidationFailure(payloadValidation);
|
|
}
|
|
|
|
const minted = environmentCustomImageTerminalSessionStore.create({
|
|
setupSessionId: refreshed.session.id,
|
|
companyId,
|
|
environmentId: refreshed.session.environmentId,
|
|
provider: refreshed.session.provider,
|
|
ssh: payloadValidation.ssh,
|
|
setupExpiresAt,
|
|
connectionExpiresAt: payloadValidation.connectionExpiresAt,
|
|
now,
|
|
});
|
|
const actor = getActorInfo(req);
|
|
await logEnvironmentCustomImageActivity({
|
|
actor,
|
|
companyId,
|
|
action: "environment.custom_image_terminal_session_token.created",
|
|
entityId: refreshed.session.environmentId,
|
|
details: {
|
|
session: setupSessionActivityDetails(refreshed.session),
|
|
terminalSession: {
|
|
connectionType: "ssh",
|
|
connectExpiresAt: minted.session.connectExpiresAt.toISOString(),
|
|
sessionExpiresAt: minted.session.sessionExpiresAt.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
res.status(201).json({
|
|
id: minted.session.id,
|
|
token: minted.token,
|
|
expiresAt: minted.session.connectExpiresAt.toISOString(),
|
|
setupSessionId: minted.session.setupSessionId,
|
|
environmentId: minted.session.environmentId,
|
|
connectionType: "ssh",
|
|
websocketPath:
|
|
`/api/environment-custom-image-setup-sessions/${encodeURIComponent(minted.session.setupSessionId)}/terminal/ws`
|
|
+ `?terminalSessionId=${encodeURIComponent(minted.session.id)}`,
|
|
});
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/environment-custom-image-setup-sessions/:sessionId/finish",
|
|
validate(finishEnvironmentCustomImageSetupSessionSchema),
|
|
async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const session = await customImages.getSessionById(req.params.sessionId as string);
|
|
if (!session) {
|
|
res.status(404).json({ error: "Environment customImage setup session not found" });
|
|
return;
|
|
}
|
|
const companyId = await resolveCustomImageSessionCompanyId(req, session);
|
|
const actor = getActorInfo(req);
|
|
const result = await customImages.finishSetupSession({
|
|
sessionId: session.id,
|
|
metadata: req.body.metadata,
|
|
});
|
|
environmentCustomImageTerminalSessionStore.deleteBySetupSessionId(session.id);
|
|
environmentCustomImageTerminalConnectionRegistry.closeBySetupSessionId(session.id, "setup_finished");
|
|
await logEnvironmentCustomImageActivity({
|
|
actor,
|
|
companyId,
|
|
action: "environment.custom_image_setup.finished",
|
|
entityId: result.session.environmentId,
|
|
details: {
|
|
session: setupSessionActivityDetails(result.session),
|
|
template: templateActivityDetails(result.template),
|
|
},
|
|
});
|
|
res.json(result);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/environment-custom-image-setup-sessions/:sessionId/cancel",
|
|
validate(cancelEnvironmentCustomImageSetupSessionSchema),
|
|
async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const session = await customImages.getSessionById(req.params.sessionId as string);
|
|
if (!session) {
|
|
res.status(404).json({ error: "Environment customImage setup session not found" });
|
|
return;
|
|
}
|
|
const companyId = await resolveCustomImageSessionCompanyId(req, session);
|
|
const actor = getActorInfo(req);
|
|
const cancelled = await customImages.cancelSetupSession({
|
|
sessionId: session.id,
|
|
reason: req.body.reason ?? null,
|
|
});
|
|
environmentCustomImageTerminalSessionStore.deleteBySetupSessionId(session.id);
|
|
environmentCustomImageTerminalConnectionRegistry.closeBySetupSessionId(session.id, "setup_cancelled");
|
|
await logEnvironmentCustomImageActivity({
|
|
actor,
|
|
companyId,
|
|
action: "environment.custom_image_setup.cancelled",
|
|
entityId: cancelled.environmentId,
|
|
details: setupSessionActivityDetails(cancelled),
|
|
});
|
|
res.json(cancelled);
|
|
},
|
|
);
|
|
|
|
router.post("/environments/:environmentId/custom-image-template/rollback", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const companyId = await resolveCustomImageCompanyId(req);
|
|
const actor = getActorInfo(req);
|
|
const result = await customImages.rollbackTemplate({
|
|
environmentId: req.params.environmentId as string,
|
|
});
|
|
await logEnvironmentCustomImageActivity({
|
|
actor,
|
|
companyId,
|
|
action: "environment.custom_image_template.rolled_back",
|
|
entityId: req.params.environmentId as string,
|
|
details: {
|
|
activeTemplate: templateActivityDetails(result.activeTemplate),
|
|
supersededTemplate: templateActivityDetails(result.supersededTemplate),
|
|
},
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
router.delete("/environments/:environmentId/custom-image-template", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const companyId = await resolveCustomImageCompanyId(req);
|
|
const actor = getActorInfo(req);
|
|
const template = await customImages.disableTemplate({
|
|
environmentId: req.params.environmentId as string,
|
|
deleteProviderTemplate: req.query.deleteProviderTemplate === "true",
|
|
});
|
|
await logEnvironmentCustomImageActivity({
|
|
actor,
|
|
companyId,
|
|
action: "environment.custom_image_template.disabled",
|
|
entityId: req.params.environmentId as string,
|
|
details: templateActivityDetails(template),
|
|
});
|
|
res.json(template);
|
|
});
|
|
|
|
router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCanAccessInstanceEnvironments(req);
|
|
if (req.body.driver === "local") {
|
|
const existingLocal = await svc.list({ driver: "local" });
|
|
if (existingLocal.length > 0) {
|
|
throw conflict("A local environment already exists for this instance.");
|
|
}
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const input = {
|
|
...req.body,
|
|
envVars: await secrets.normalizeEnvBindingsForPersistence(
|
|
companyId,
|
|
req.body.envVars,
|
|
{ strictMode: strictSecretsMode, fieldPath: "envVars" },
|
|
),
|
|
config: await normalizeEnvironmentConfigForPersistence({
|
|
db,
|
|
companyId,
|
|
environmentName: req.body.name,
|
|
driver: req.body.driver,
|
|
secretProvider: getConfiguredSecretProvider(),
|
|
config: req.body.config,
|
|
actor: {
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
}),
|
|
};
|
|
const environment = await svc.create(input);
|
|
await secrets.syncSecretRefsForTarget(
|
|
companyId,
|
|
{ targetType: "environment", targetId: environment.id },
|
|
await collectEnvironmentSecretRefs({ db, environment }),
|
|
);
|
|
await secrets.syncEnvBindingsForTarget(
|
|
companyId,
|
|
{ targetType: "environment", targetId: environment.id },
|
|
environment.envVars,
|
|
);
|
|
await logInstanceEnvironmentActivity({
|
|
actor,
|
|
action: "environment.created",
|
|
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) => {
|
|
assertCanReadInstanceEnvironments(req);
|
|
const environment = await svc.getById(req.params.id as string);
|
|
if (!environment) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
res.json(presentEnvironmentForRead(req, environment));
|
|
});
|
|
|
|
router.get("/environments/:id/leases", async (req, res) => {
|
|
assertCanReadInstanceEnvironments(req);
|
|
const environment = await svc.getById(req.params.id as string);
|
|
if (!environment) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
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) => {
|
|
assertCanReadInstanceEnvironments(req);
|
|
const lease = await svc.getLeaseById(req.params.leaseId as string);
|
|
if (!lease) {
|
|
res.status(404).json({ error: "Environment lease not found" });
|
|
return;
|
|
}
|
|
res.json(lease);
|
|
});
|
|
|
|
router.patch("/environments/:id", validate(updateEnvironmentSchema), async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const existing = await svc.getById(req.params.id as string);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const nextDriver = req.body.driver ?? existing.driver;
|
|
const nextName = req.body.name ?? existing.name;
|
|
const companyIdForSecrets =
|
|
req.body.config !== undefined || req.body.driver !== undefined || req.body.envVars !== undefined
|
|
? await resolveEnvironmentSecretContextCompanyId(req, existing.id, { required: true })
|
|
: null;
|
|
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.envVars !== undefined
|
|
? {
|
|
envVars: await secrets.normalizeEnvBindingsForPersistence(
|
|
companyIdForSecrets!,
|
|
req.body.envVars,
|
|
{ strictMode: strictSecretsMode, fieldPath: "envVars" },
|
|
),
|
|
}
|
|
: {}),
|
|
...(req.body.config !== undefined || req.body.driver !== undefined
|
|
? {
|
|
config: await normalizeEnvironmentConfigForPersistence({
|
|
db,
|
|
companyId: companyIdForSecrets!,
|
|
environmentName: nextName,
|
|
driver: nextDriver,
|
|
secretProvider: getConfiguredSecretProvider(),
|
|
config: configSource,
|
|
actor: {
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
}),
|
|
}
|
|
: {}),
|
|
};
|
|
const environment = await svc.update(existing.id, patch);
|
|
if (!environment) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
let customImageReconciliation: Awaited<
|
|
ReturnType<typeof customImages.reconcileActiveTemplateForConfigChange>
|
|
> = { action: "none" };
|
|
if (patch.config !== undefined || patch.driver !== undefined) {
|
|
await secrets.syncSecretRefsForTarget(
|
|
companyIdForSecrets!,
|
|
{ targetType: "environment", targetId: environment.id },
|
|
await collectEnvironmentSecretRefs({ db, environment }),
|
|
);
|
|
try {
|
|
customImageReconciliation = await customImages.reconcileActiveTemplateForConfigChange({
|
|
environmentId: environment.id,
|
|
previous: existing,
|
|
next: environment,
|
|
});
|
|
} catch {
|
|
// Reconciliation is best-effort; a failure must not fail the save.
|
|
}
|
|
}
|
|
if (patch.envVars !== undefined) {
|
|
await secrets.syncEnvBindingsForTarget(
|
|
companyIdForSecrets!,
|
|
{ targetType: "environment", targetId: environment.id },
|
|
environment.envVars,
|
|
);
|
|
}
|
|
await logInstanceEnvironmentActivity({
|
|
actor,
|
|
action: "environment.updated",
|
|
entityId: environment.id,
|
|
details: summarizeEnvironmentUpdate(patch as Record<string, unknown>, environment),
|
|
});
|
|
res.json(customImageReconciliation.action === "none"
|
|
? environment
|
|
: { ...environment, customImageReconciliation });
|
|
});
|
|
|
|
router.delete("/environments/:id", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const existing = await svc.getById(req.params.id as string);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const impact = await svc.getDeleteBlastRadius(existing.id);
|
|
if (!impact) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
if (!impact.canDelete) {
|
|
rejectEnvironmentDelete({ actor, environment: existing, impact });
|
|
}
|
|
|
|
const removed = await svc.removeIfDeletable(existing.id);
|
|
if (!removed) {
|
|
const latestImpact = await svc.getDeleteBlastRadius(existing.id);
|
|
if (!latestImpact) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
rejectEnvironmentDelete({ actor, environment: existing, impact: latestImpact });
|
|
}
|
|
const companyIds = await instanceSettings.listCompanyIds();
|
|
await Promise.all(
|
|
companyIds.flatMap((companyId) => [
|
|
executionWorkspaces.clearEnvironmentSelection(companyId, existing.id),
|
|
issues.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
|
|
projects.clearExecutionWorkspaceEnvironmentSelection(companyId, existing.id),
|
|
secrets.syncEnvBindingsForTarget(
|
|
companyId,
|
|
{ targetType: "environment", targetId: existing.id },
|
|
{},
|
|
),
|
|
secrets.syncSecretRefsForTarget(
|
|
companyId,
|
|
{ targetType: "environment", targetId: existing.id },
|
|
[],
|
|
{ replaceAll: true },
|
|
),
|
|
]),
|
|
);
|
|
const secretId = readSshEnvironmentPrivateKeySecretId(existing);
|
|
if (secretId) {
|
|
await secrets.remove(secretId);
|
|
}
|
|
await logInstanceEnvironmentActivity({
|
|
actor,
|
|
action: "environment.deleted",
|
|
entityId: removed.id,
|
|
details: {
|
|
name: removed.name,
|
|
driver: removed.driver,
|
|
status: removed.status,
|
|
},
|
|
});
|
|
res.json(removed);
|
|
});
|
|
|
|
router.post("/environments/:id/probe", async (req, res) => {
|
|
assertCanAccessInstanceEnvironments(req);
|
|
const environment = await svc.getById(req.params.id as string);
|
|
if (!environment) {
|
|
res.status(404).json({ error: "Environment not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false });
|
|
const companyIdForProbe = companyIdForSecrets
|
|
?? (environment.driver === "sandbox" ? await resolveCustomImageCompanyId(req) : null);
|
|
if (!companyIdForSecrets) {
|
|
const secretRefs = await collectEnvironmentSecretRefs({ db, environment });
|
|
if (secretRefs.length > 0) {
|
|
throw unprocessable(
|
|
"Environment probe requires an explicit companyId to resolve secret-backed config for this environment.",
|
|
);
|
|
}
|
|
}
|
|
const probe = await probeEnvironment(db, environment, {
|
|
companyId: companyIdForProbe,
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
applyCustomImageTemplate: environment.driver === "sandbox",
|
|
acquireSandboxRuntimeLease: environment.driver === "sandbox",
|
|
});
|
|
await logInstanceEnvironmentActivity({
|
|
actor,
|
|
action: "environment.probed",
|
|
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;
|
|
assertCanAccessInstanceEnvironments(req);
|
|
if (req.body.driver === "sandbox") {
|
|
await assertCanReadSecretsForDraftProbe(req, companyId);
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const normalizedConfig = await normalizeEnvironmentConfigForProbe({
|
|
db,
|
|
companyId,
|
|
driver: req.body.driver,
|
|
config: req.body.config,
|
|
accessContext: {
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
actorSource: actor.actorSource,
|
|
heartbeatRunId: actor.runId,
|
|
},
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
});
|
|
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,
|
|
envVars: {},
|
|
metadata: req.body.metadata ?? null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
const probe = await probeEnvironment(db, environment, {
|
|
companyId,
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
resolvedConfig: {
|
|
driver: req.body.driver,
|
|
config: normalizedConfig,
|
|
} as ParsedEnvironmentConfig,
|
|
});
|
|
await logInstanceEnvironmentActivity({
|
|
actor,
|
|
action: "environment.probed_unsaved",
|
|
entityId: "unsaved",
|
|
details: {
|
|
driver: environment.driver,
|
|
ok: probe.ok,
|
|
summary: probe.summary,
|
|
configTopLevelKeyCount: Object.keys(environment.config).length,
|
|
},
|
|
});
|
|
res.json(probe);
|
|
},
|
|
);
|
|
|
|
return router;
|
|
}
|