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

@ -1,6 +1,7 @@
import { spawn, type ChildProcess } from "node:child_process";
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
import type {
AdapterSkillEntry,
AdapterSkillSnapshot,
@ -30,8 +31,12 @@ interface RunningProcess {
interface SpawnTarget {
command: string;
args: string[];
cwd?: string;
cleanup?: () => Promise<void>;
}
type RemoteExecutionSpec = SshRemoteExecutionSpec;
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
@ -806,11 +811,26 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
);
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
const apiUrl =
process.env.PAPERCLIP_RUNTIME_API_URL ??
process.env.PAPERCLIP_API_URL ??
`http://${runtimeHost}:${runtimePort}`;
vars.PAPERCLIP_API_URL = apiUrl;
return vars;
}
export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
for (const key of Object.keys(env)) {
if (!key.startsWith("PAPERCLIP_")) continue;
if (key === "PAPERCLIP_RUNTIME_API_URL") continue;
if (key === "PAPERCLIP_LISTEN_HOST") continue;
if (key === "PAPERCLIP_LISTEN_PORT") continue;
delete env[key];
}
return env;
}
export function defaultPathForPlatform() {
if (process.platform === "win32") {
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
@ -859,7 +879,18 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc
return null;
}
export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
export async function resolveCommandForLogs(
command: string,
cwd: string,
env: NodeJS.ProcessEnv,
options: {
remoteExecution?: RemoteExecutionSpec | null;
} = {},
): Promise<string> {
const remote = options.remoteExecution ?? null;
if (remote) {
return `ssh://${remote.username}@${remote.host}:${remote.port}/${remote.remoteCwd} :: ${command}`;
}
return (await resolveCommandPath(command, cwd, env)) ?? command;
}
@ -879,7 +910,33 @@ async function resolveSpawnTarget(
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
options: {
remoteExecution?: RemoteExecutionSpec | null;
remoteEnv?: Record<string, string> | null;
} = {},
): Promise<SpawnTarget> {
const remote = options.remoteExecution ?? null;
if (remote) {
const sshResolved = await resolveCommandPath("ssh", process.cwd(), env);
if (!sshResolved) {
throw new Error('Command not found in PATH: "ssh"');
}
const spawnTarget = await buildSshSpawnTarget({
spec: remote,
command,
args,
env: Object.fromEntries(
Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
),
});
return {
command: sshResolved,
args: spawnTarget.args,
cwd: process.cwd(),
cleanup: spawnTarget.cleanup,
};
}
const resolved = await resolveCommandPath(command, cwd, env);
const executable = resolved ?? command;
@ -1306,7 +1363,19 @@ export async function removeMaintainerOnlySkillSymlinks(
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
export async function ensureCommandResolvable(
command: string,
cwd: string,
env: NodeJS.ProcessEnv,
options: {
remoteExecution?: RemoteExecutionSpec | null;
} = {},
) {
if (options.remoteExecution) {
const resolvedSsh = await resolveCommandPath("ssh", process.cwd(), env);
if (resolvedSsh) return;
throw new Error('Command not found in PATH: "ssh"');
}
const resolved = await resolveCommandPath(command, cwd, env);
if (resolved) return;
if (command.includes("/") || command.includes("\\")) {
@ -1330,12 +1399,15 @@ export async function runChildProcess(
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
terminalResultCleanup?: TerminalResultCleanupOptions;
stdin?: string;
remoteExecution?: RemoteExecutionSpec | null;
},
): Promise<RunProcessResult> {
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
return new Promise<RunProcessResult>((resolve, reject) => {
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
const rawMerged: NodeJS.ProcessEnv = {
...sanitizeInheritedPaperclipEnv(process.env),
...opts.env,
};
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
// don't refuse to start with "cannot be launched inside another session".
@ -1353,10 +1425,13 @@ export async function runChildProcess(
}
const mergedEnv = ensurePathInEnv(rawMerged);
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv, {
remoteExecution: opts.remoteExecution ?? null,
remoteEnv: opts.remoteExecution ? opts.env : null,
})
.then((target) => {
const child = spawn(target.command, target.args, {
cwd: opts.cwd,
cwd: target.cwd ?? opts.cwd,
env: mergedEnv,
detached: process.platform !== "win32",
shell: false,
@ -1484,6 +1559,7 @@ export async function runChildProcess(
if (timeout) clearTimeout(timeout);
clearTerminalCleanupTimers();
runningProcesses.delete(runId);
void target.cleanup?.();
const errno = (err as NodeJS.ErrnoException).code;
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
const msg =
@ -1502,15 +1578,19 @@ export async function runChildProcess(
clearTerminalCleanupTimers();
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
pid: child.pid ?? null,
startedAt,
});
void Promise.resolve()
.then(() => target.cleanup?.())
.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
pid: child.pid ?? null,
startedAt,
});
});
});
});
})