Add SSH environment support (#4358)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The environments subsystem already models execution environments,
but before this branch there was no end-to-end SSH-backed runtime path
for agents to actually run work against a remote box
> - That meant agents could be configured around environment concepts
without a reliable way to execute adapter sessions remotely, sync
workspace state, and preserve run context across supported adapters
> - We also need environment selection to participate in normal
Paperclip control-plane behavior: agent defaults, project/issue
selection, route validation, and environment probing
> - Because this capability is still experimental, the UI surface should
be easy to hide and easy to remove later without undoing the underlying
implementation
> - This pull request adds SSH environment execution support across the
runtime, adapters, routes, schema, and tests, then puts the visible
environment-management UI behind an experimental flag
> - The benefit is that we can validate real SSH-backed agent execution
now while keeping the user-facing controls safely gated until the
feature is ready to come out of experimentation

## What Changed

- Added SSH-backed execution target support in the shared adapter
runtime, including remote workspace preparation, skill/runtime asset
sync, remote session handling, and workspace restore behavior after
runs.
- Added SSH execution coverage for supported local adapters, plus remote
execution tests across Claude, Codex, Cursor, Gemini, OpenCode, and Pi.
- Added environment selection and environment-management backend support
needed for SSH execution, including route/service work, validation,
probing, and agent default environment persistence.
- Added CLI support for SSH environment lab verification and updated
related docs/tests.
- Added the `enableEnvironments` experimental flag and gated the
environment UI behind it on company settings, agent configuration, and
project configuration surfaces.

## Verification

- `pnpm exec vitest run
packages/adapters/claude-local/src/server/execute.remote.test.ts
packages/adapters/cursor-local/src/server/execute.remote.test.ts
packages/adapters/gemini-local/src/server/execute.remote.test.ts
packages/adapters/opencode-local/src/server/execute.remote.test.ts
packages/adapters/pi-local/src/server/execute.remote.test.ts`
- `pnpm exec vitest run server/src/__tests__/environment-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/instance-settings-routes.test.ts`
- `pnpm exec vitest run ui/src/lib/new-agent-hire-payload.test.ts
ui/src/lib/new-agent-runtime-config.test.ts`
- `pnpm -r typecheck`
- `pnpm build`
- Manual verification on a branch-local dev server:
  - enabled the experimental flag
  - created an SSH environment
  - created a Linux Claude agent using that environment
- confirmed a run executed on the Linux box and synced workspace changes
back

## Risks

- Medium: this touches runtime execution flow across multiple adapters,
so regressions would likely show up in remote session setup, workspace
sync, or environment selection precedence.
- The UI flag reduces exposure, but the underlying runtime and route
changes are still substantial and rely on migration correctness.
- The change set is broad across adapters, control-plane services,
migrations, and UI gating, so review should pay close attention to
environment-selection precedence and remote workspace lifecycle
behavior.

## Model Used

- OpenAI Codex via Paperclip's local Codex adapter, GPT-5-class coding
model with tool use and code execution in the local repo workspace. The
local adapter does not surface a more specific public model version
string in this branch workflow.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-04-23 19:15:22 -07:00 committed by GitHub
parent f98c348e2b
commit e4995bbb1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 10162 additions and 315 deletions

View file

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