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

@ -218,7 +218,7 @@ export const PROJECT_STATUSES = [
] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const ENVIRONMENT_DRIVERS = ["local"] as const;
export const ENVIRONMENT_DRIVERS = ["local", "ssh"] as const;
export type EnvironmentDriver = (typeof ENVIRONMENT_DRIVERS)[number];
export const ENVIRONMENT_STATUSES = ["active", "archived"] as const;
@ -486,6 +486,7 @@ export const PERMISSION_KEYS = [
"tasks:assign_scope",
"tasks:manage_active_checkouts",
"joins:approve",
"environments:manage",
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];

View file

@ -0,0 +1,64 @@
import type { AgentAdapterType, EnvironmentDriver } from "./constants.js";
export type EnvironmentSupportStatus = "supported" | "unsupported";
export interface AdapterEnvironmentSupport {
adapterType: AgentAdapterType;
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
}
export interface EnvironmentCapabilities {
adapters: AdapterEnvironmentSupport[];
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
}
const REMOTE_MANAGED_ADAPTERS = new Set<AgentAdapterType>([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
export function adapterSupportsRemoteManagedEnvironments(adapterType: string): boolean {
return REMOTE_MANAGED_ADAPTERS.has(adapterType as AgentAdapterType);
}
export function supportedEnvironmentDriversForAdapter(adapterType: string): EnvironmentDriver[] {
return adapterSupportsRemoteManagedEnvironments(adapterType)
? ["local", "ssh"]
: ["local"];
}
export function isEnvironmentDriverSupportedForAdapter(
adapterType: string,
driver: string,
): boolean {
return supportedEnvironmentDriversForAdapter(adapterType).includes(driver as EnvironmentDriver);
}
export function getAdapterEnvironmentSupport(
adapterType: AgentAdapterType,
): AdapterEnvironmentSupport {
const supportedDrivers = new Set(supportedEnvironmentDriversForAdapter(adapterType));
return {
adapterType,
drivers: {
local: supportedDrivers.has("local") ? "supported" : "unsupported",
ssh: supportedDrivers.has("ssh") ? "supported" : "unsupported",
},
};
}
export function getEnvironmentCapabilities(
adapterTypes: readonly AgentAdapterType[],
): EnvironmentCapabilities {
return {
adapters: adapterTypes.map((adapterType) => getAdapterEnvironmentSupport(adapterType)),
drivers: {
local: "supported",
ssh: "supported",
},
};
}

View file

@ -218,7 +218,9 @@ export type {
Company,
Environment,
EnvironmentLease,
EnvironmentProbeResult,
LocalEnvironmentConfig,
SshEnvironmentConfig,
FeedbackVote,
FeedbackDataSharingPreference,
FeedbackTargetType,
@ -540,6 +542,17 @@ export {
isClosedIsolatedExecutionWorkspace,
} from "./execution-workspace-guards.js";
export {
adapterSupportsRemoteManagedEnvironments,
getAdapterEnvironmentSupport,
getEnvironmentCapabilities,
isEnvironmentDriverSupportedForAdapter,
supportedEnvironmentDriversForAdapter,
type AdapterEnvironmentSupport,
type EnvironmentCapabilities,
type EnvironmentSupportStatus,
} from "./environment-support.js";
export {
instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema,
@ -567,8 +580,10 @@ export {
environmentLeaseCleanupStatusSchema,
createEnvironmentSchema,
updateEnvironmentSchema,
probeEnvironmentConfigSchema,
type CreateEnvironment,
type UpdateEnvironment,
type ProbeEnvironmentConfig,
agentSkillStateSchema,
agentSkillSyncModeSchema,
agentSkillEntrySchema,

View file

@ -73,6 +73,7 @@ export interface Agent {
adapterType: AgentAdapterType;
adapterConfig: Record<string, unknown>;
runtimeConfig: Record<string, unknown>;
defaultEnvironmentId?: string | null;
budgetMonthlyCents: number;
spentMonthlyCents: number;
pauseReason: PauseReason | null;

View file

@ -5,11 +5,30 @@ import type {
EnvironmentLeaseStatus,
EnvironmentStatus,
} from "../constants.js";
import type { EnvSecretRefBinding } from "./secrets.js";
export interface LocalEnvironmentConfig {
[key: string]: unknown;
}
export interface SshEnvironmentConfig {
host: string;
port: number;
username: string;
remoteWorkspacePath: string;
privateKey: string | null;
privateKeySecretRef: EnvSecretRefBinding | null;
knownHosts: string | null;
strictHostKeyChecking: boolean;
}
export interface EnvironmentProbeResult {
ok: boolean;
driver: EnvironmentDriver;
summary: string;
details: Record<string, unknown> | null;
}
export interface Environment {
id: string;
companyId: string;
@ -17,7 +36,7 @@ export interface Environment {
description: string | null;
driver: EnvironmentDriver;
status: EnvironmentStatus;
config: LocalEnvironmentConfig;
config: Record<string, unknown>;
metadata: Record<string, unknown> | null;
createdAt: Date;
updatedAt: Date;

View file

@ -1,5 +1,11 @@
export type { Company } from "./company.js";
export type { Environment, EnvironmentLease, LocalEnvironmentConfig } from "./environment.js";
export type {
Environment,
EnvironmentLease,
EnvironmentProbeResult,
LocalEnvironmentConfig,
SshEnvironmentConfig,
} from "./environment.js";
export type {
FeedbackVote,
FeedbackDataSharingPreference,

View file

@ -24,6 +24,7 @@ export interface InstanceGeneralSettings {
}
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
autoRestartDevServerWhenIdle: boolean;
}

View file

@ -78,6 +78,7 @@ export interface ExecutionWorkspaceStrategy {
}
export interface ExecutionWorkspaceConfig {
environmentId?: string | null;
provisionCommand: string | null;
teardownCommand: string | null;
cleanupCommand: string | null;
@ -147,6 +148,7 @@ export interface ProjectExecutionWorkspacePolicy {
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
allowIssueOverride?: boolean;
defaultProjectWorkspaceId?: string | null;
environmentId?: string | null;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
branchPolicy?: Record<string, unknown> | null;
@ -157,6 +159,7 @@ export interface ProjectExecutionWorkspacePolicy {
export interface IssueExecutionWorkspaceSettings {
mode?: ExecutionWorkspaceMode;
environmentId?: string | null;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
}
@ -227,3 +230,82 @@ export interface WorkspaceRuntimeService {
createdAt: Date;
updatedAt: Date;
}
export type WorkspaceRealizationTransport = "local" | "ssh";
export type WorkspaceRealizationSyncStrategy =
| "none"
| "ssh_git_import_export";
export interface WorkspaceRealizationRequest {
version: 1;
adapterType: string;
companyId: string;
environmentId: string;
executionWorkspaceId: string | null;
issueId: string | null;
heartbeatRunId: string;
requestedMode: string | null;
source: {
kind: "project_primary" | "task_session" | "agent_home";
localPath: string;
projectId: string | null;
projectWorkspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
strategy: "project_primary" | "git_worktree";
branchName: string | null;
worktreePath: string | null;
};
runtimeOverlay: {
provisionCommand: string | null;
teardownCommand: string | null;
cleanupCommand: string | null;
workspaceRuntime: Record<string, unknown> | null;
};
}
export interface WorkspaceRealizationRecord {
version: 1;
transport: WorkspaceRealizationTransport;
provider: string | null;
environmentId: string;
leaseId: string;
providerLeaseId: string | null;
local: {
path: string;
source: WorkspaceRealizationRequest["source"]["kind"];
strategy: WorkspaceRealizationRequest["source"]["strategy"];
projectId: string | null;
projectWorkspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
branchName: string | null;
worktreePath: string | null;
};
remote: {
path: string | null;
host?: string | null;
port?: number | null;
username?: string | null;
};
sync: {
strategy: WorkspaceRealizationSyncStrategy;
prepare: string;
syncBack: string | null;
};
bootstrap: {
command: string | null;
};
rebuild: {
executionWorkspaceId: string | null;
mode: string | null;
repoUrl: string | null;
repoRef: string | null;
localPath: string;
remotePath: string | null;
providerLeaseId: string | null;
metadata: Record<string, unknown>;
};
summary: string;
}

View file

@ -55,6 +55,7 @@ export const createAgentSchema = z.object({
adapterType: agentAdapterTypeSchema,
adapterConfig: adapterConfigSchema.optional().default({}),
runtimeConfig: z.record(z.unknown()).optional().default({}),
defaultEnvironmentId: z.string().uuid().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
permissions: agentPermissionsSchema.optional(),
metadata: z.record(z.unknown()).optional().nullable(),

View file

@ -32,3 +32,12 @@ export const updateEnvironmentSchema = z.object({
metadata: z.record(z.unknown()).optional().nullable(),
}).strict();
export type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
export const probeEnvironmentConfigSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
driver: environmentDriverSchema,
config: z.record(z.unknown()).optional().default({}),
metadata: z.record(z.unknown()).optional().nullable(),
}).strict();
export type ProbeEnvironmentConfig = z.infer<typeof probeEnvironmentConfigSchema>;

View file

@ -9,6 +9,7 @@ export const executionWorkspaceStatusSchema = z.enum([
]);
export const executionWorkspaceConfigSchema = z.object({
environmentId: z.string().uuid().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
cleanupCommand: z.string().optional().nullable(),

View file

@ -31,8 +31,10 @@ export {
environmentLeaseCleanupStatusSchema,
createEnvironmentSchema,
updateEnvironmentSchema,
probeEnvironmentConfigSchema,
type CreateEnvironment,
type UpdateEnvironment,
type ProbeEnvironmentConfig,
} from "./environment.js";
export {
feedbackDataSharingPreferenceSchema,

View file

@ -33,6 +33,7 @@ export const instanceGeneralSettingsSchema = z.object({
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
}).strict();

View file

@ -34,6 +34,7 @@ const executionWorkspaceStrategySchema = z
export const issueExecutionWorkspaceSettingsSchema = z
.object({
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
environmentId: z.string().uuid().optional().nullable(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
})

View file

@ -19,6 +19,7 @@ export const projectExecutionWorkspacePolicySchema = z
defaultMode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch", "adapter_default"]).optional(),
allowIssueOverride: z.boolean().optional(),
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
environmentId: z.string().uuid().optional().nullable(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
branchPolicy: z.record(z.unknown()).optional().nullable(),