Files
PaperClipAI/server/src/routes/environments.ts
T
Jannes StubbemannandClaude Opus 4.7 7f2ed0ad90 security(server): close cross-tenant existence oracle (404 instead of 403) (#3967)
## 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>
2026-07-14 15:53:09 -07:00

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;
}