Files
PaperClipAI/packages/plugins/sandbox-providers/exe-dev/src/plugin.ts
T
Valentin PalkovicandClaude Opus 5.5 bb73f2fe39 feat(exe-dev): copy a source VM with exe.dev cp (#14975)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The exe.dev sandbox provider plugin gives each run its own exe.dev
VM
> - Today the plugin always creates that VM with `exe.dev new`, so every
run starts from a bare image
> - Large repositories need a long setup on each VM: toolchain, agent
CLIs, package caches, and browsers for tests
> - exe.dev has a `cp` command that copies an existing VM, disk and
config included
> - This pull request adds an optional `sourceVm` setting. When it is
set, the plugin copies that VM with `exe.dev cp` instead of creating a
new one
> - The benefit is that operators prepare one VM once, and each run
starts from it

## Linked Issues or Issue Description

Refs #13575. That open PR rewrites this plugin for durable exe.dev
environments. It does not add `cp`. The two changes touch the same files
and can conflict.

I found no issue for this. Feature description:

**Subsystem affected**
packages/plugins: the exe.dev sandbox provider plugin
(`packages/plugins/sandbox-providers/exe-dev`).

**Problem or motivation**
Each run gets a fresh exe.dev VM from `exe.dev new`. We use a large
monorepo (Storybook). Before the agent can work, each run must install
Node, the agent CLIs, and Playwright browsers. Each run must also fill
the package manager cache. This setup takes a long time on each run.

**Proposed solution**
Add a `sourceVm` setting ("Source VM" in the environment form). When it
is set, lease acquisition and probes run `exe.dev cp <sourceVm>
<generated-name> --json`. The command also sends the configured `cpu`,
`memory`, and `disk`. The VM name, the SSH setup, the workspace, and the
release and destroy steps do not change. When the setting is blank, the
plugin uses `exe.dev new` as before.

**Alternatives considered**
- A custom image with `--image`: the operator must build and push a
large image for each change. A private registry needs `--registry-auth`,
and the plugin does not support it.
- `--setup-script`: it runs on every new VM, so each run still pays the
setup cost. It also has a 10 KiB limit.
- `reuseLease`: it keeps one VM for one lease. It does not give each run
a fresh copy of a prepared VM.

**Roadmap alignment**
The change stays inside an existing sandbox provider plugin.
`ROADMAP.md` lists "Cloud / Sandbox agent support" as done and does not
plan VM copies. CONTRIBUTING.md asks to discuss features in Discord
`#dev` first. I open this pull request as a draft and start that
discussion in `#dev`.

**Additional context**
exe.dev documents `cp` here: https://exe.dev/docs/cli-cp. exe.dev token
permissions are documented here: https://exe.dev/docs/https-api.

## What Changed

- `plugin.ts`: add `sourceVm` to the driver config.
- `plugin.ts`: `buildCreateCommand` sends `cp` when `sourceVm` is set.
- `plugin.ts`: config validation rejects `sourceVm` together with
settings that `cp` cannot apply (`image`, `command`, `comment`, `env`,
`integrations`, `tags`, `setupScript`, `prompt`). The error names the
settings to clear. The server shows validation errors in the form, but
it does not show warnings after a successful save.
- `manifest.ts`: add the "Source VM" field to the "VM creation" group.
Its description says that the API token must allow `cp`, because exe.dev
returns 403 for a command that the token does not list. The API key
description now also mentions `cp`.
- `README.md`: document `sourceVm`, its limits, and the token
permission.
- `plugin.test.ts`: add tests for the `cp` command, the validation
error, and the form field. Add `sourceVm: null` to the expected
normalized config.

## Verification

- `vitest run --config vitest.config.ts` in
`packages/plugins/sandbox-providers/exe-dev`: 38 tests pass. The three
new tests fail without the change.
- `tsc --noEmit -p packages/plugins/sandbox-providers/exe-dev`: no
errors.
- Manual test on a self-hosted Paperclip instance (2026.1001.0). I
applied the same change to the installed plugin. I prepared a source VM
and set "Source VM" on an exe.dev environment. Then I ran agent tasks.
Each run copied the source VM and ran in the copy. Paperclip deleted the
copy at release.
- With an API token that does not list `cp`, the run fails with `exe.dev
API command failed (403) for: cp '<source>' '<name>' --json ...`. The
new field description tells operators about this.

## Risks

- Low risk. The new code runs only when `sourceVm` is set. The `new`
path is unchanged.
- `cp` uses the same `/exec` endpoint and its 30-second request limit. A
copy of a very large disk can take longer than the limit.
- Every copy inherits the source VM disk. The README tells operators not
to keep secrets on the source VM.
- Can conflict with #13575.

## Model Used

- Provider and model: Anthropic Claude Opus 5.5.
- Model ID: `claude-opus-5-5`.
- Tool: Claude Code, with tool use (shell commands and file edits) and
extended thinking.
- Context window: the tool does not report it.
- The model wrote the change and the tests. A human tested the feature
on a real exe.dev setup.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<img width="1166" height="852" alt="Bildschirmfoto 2026-10-02 um 23 04
32"
src="https://github.com/user-attachments/assets/c057bee4-9f27-4a69-945e-0f71d5bcc1ba"
/>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:11:59 -07:00

999 lines
33 KiB
TypeScript

import path from "node:path";
import { randomUUID } from "node:crypto";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { definePlugin } from "@paperclipai/plugin-sdk";
import type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentLease,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
interface ExeDevDriverConfig {
apiKey: string | null;
apiUrl: string;
namePrefix: string;
sourceVm: string | null;
image: string | null;
command: string | null;
cpu: number | null;
memory: string | null;
disk: string | null;
comment: string | null;
env: Record<string, string>;
integrations: string[];
tags: string[];
setupScript: string | null;
prompt: string | null;
timeoutMs: number;
reuseLease: boolean;
sshUser: string | null;
sshPrivateKey: string | null;
sshIdentityFile: string | null;
sshPort: number;
strictHostKeyChecking: string;
}
interface ExeDevVmRecord {
name: string;
sshDest: string;
httpsUrl: string | null;
status: string | null;
region: string | null;
regionDisplay: string | null;
}
interface SshExecutionResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
}
const DEFAULT_API_URL = "https://exe.dev/exec";
const DEFAULT_TIMEOUT_MS = 300_000;
const EXE_DEV_API_MAX_TIMEOUT_MS = 29_000;
const SSH_SIGKILL_GRACE_MS = 250;
const MAX_VM_RECORD_DEPTH = 4;
const EXE_DEV_SSH_ONBOARDING_MARKER = "Please complete registration by running: ssh exe.dev";
const EXE_DEV_SSH_EMAIL_PROMPT = "Please enter your email address:";
const EXE_DEV_SSH_INVALID_KEY_FORMAT = /Load key [^\n]*invalid format/i;
const UUID_SECRET_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// exe.dev's `--setup-script` runs at VM init as the unprivileged `exedev` user, which
// has passwordless sudo. The Paperclip sandbox callback bridge is a Node script, so
// every Paperclip workload on this provider needs node on PATH before the bridge can
// start. When the operator hasn't supplied their own setup script, install Node 24 via
// nodesource so the VM comes up ready for Paperclip out of the box.
const DEFAULT_SETUP_SCRIPT =
"(command -v node >/dev/null 2>&1 && " +
"node -e 'const v=process.versions.node.split(\".\").map(Number);" +
"process.exit(v[0]>24||(v[0]===24&&v[1]>=11)?0:1)' >/dev/null 2>&1) || " +
"(curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && " +
"sudo apt-get install -y nodejs)";
class ExeDevApiError extends Error {
readonly status: number;
readonly body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = "ExeDevApiError";
this.status = status;
this.body = body;
}
}
function parseOptionalString(value: unknown): string | null {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseOptionalInteger(value: unknown): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}
function parseStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => parseOptionalString(entry))
.filter((entry): entry is string => entry != null);
}
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
return [];
}
function parseEnvMap(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const env: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
const normalizedKey = key.trim();
const normalizedValue = parseOptionalString(raw);
if (normalizedKey.length > 0 && normalizedValue != null) {
env[normalizedKey] = normalizedValue;
}
}
return env;
}
function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
function isSecretRef(value: string): boolean {
return UUID_SECRET_REF_RE.test(value);
}
// Catch the SSH-key paste failure modes we've seen in the wild (wrong file,
// PPK export, truncated paste) before the user pays the cost of provisioning a
// VM and getting a cryptic SSH error. Inline parse — no `ssh-keygen` dependency
// — so this also works on hosts where openssh-client isn't installed.
export function validateSshPrivateKey(rawKey: string): string | null {
const trimmed = rawKey.trim();
if (!trimmed) return null;
if (/^PuTTY-User-Key-File-\d/m.test(trimmed)) {
return "sshPrivateKey looks like a PuTTY .ppk file. Convert it to OpenSSH format (PuTTYgen → Conversions → Export OpenSSH key) and paste the resulting PEM.";
}
if (
/^(?:ssh-(?:rsa|dss|ed25519)|ecdsa-sha2-[a-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-[a-z0-9-]+)@openssh\.com)\s+\S/.test(
trimmed,
)
) {
return "sshPrivateKey looks like a PUBLIC key. Paste the matching private key (the file without the .pub extension).";
}
const headerMatch = trimmed.match(/^-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/m);
if (!headerMatch) {
return "sshPrivateKey must be a PEM-encoded private key starting with a line like '-----BEGIN OPENSSH PRIVATE KEY-----'.";
}
const footerMatch = trimmed.match(/^-----END ([A-Z0-9 ]*)PRIVATE KEY-----\s*$/m);
if (!footerMatch) {
return "sshPrivateKey is missing its '-----END … PRIVATE KEY-----' footer. Make sure you copied the whole file, including the final line.";
}
const headerLabel = headerMatch[1].trim();
const footerLabel = footerMatch[1].trim();
if (headerLabel !== footerLabel) {
return `sshPrivateKey header/footer mismatch (BEGIN ${headerLabel || "(none)"} vs END ${footerLabel || "(none)"}). The file is likely truncated or two keys are concatenated.`;
}
const headerLineEnd = trimmed.indexOf("\n", headerMatch.index ?? 0);
const footerStart = trimmed.lastIndexOf(footerMatch[0]);
if (headerLineEnd < 0 || footerStart <= headerLineEnd) {
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
}
const bodyLines = trimmed
.slice(headerLineEnd + 1, footerStart)
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (bodyLines.length === 0) {
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
}
// PEM bodies are base64 lines, optionally preceded by `Header: value` lines
// on encrypted PKCS#1 keys (`Proc-Type:`, `DEK-Info:`).
const base64Line = /^[A-Za-z0-9+/=]+$/;
const pemHeaderLine = /^[A-Za-z][A-Za-z0-9-]*:\s.+$/;
for (const line of bodyLines) {
if (!base64Line.test(line) && !pemHeaderLine.test(line)) {
return "sshPrivateKey body contains non-base64 characters. The key may have been corrupted by line-wrapping or copy-paste.";
}
}
return null;
}
function normalizeApiUrl(value: string | null): string {
if (!value) return DEFAULT_API_URL;
const trimmed = value.trim();
if (!trimmed) return DEFAULT_API_URL;
try {
const parsed = new URL(trimmed);
const normalizedPath = parsed.pathname.replace(/\/+$/, "") || "/";
if (normalizedPath === "/exec") {
parsed.pathname = "/exec";
return parsed.toString();
}
parsed.pathname = `${normalizedPath === "/" ? "" : normalizedPath}/exec`.replace(/\/{2,}/g, "/");
return parsed.toString();
} catch {
return trimmed;
}
}
function normalizeNamePrefix(value: string | null): string {
const normalized = (value ?? "paperclip")
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
return normalized.length > 0 ? normalized.slice(0, 24) : "paperclip";
}
function parseDriverConfig(raw: Record<string, unknown>): ExeDevDriverConfig {
const timeoutMs = Number(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const sshPort = Number(raw.sshPort ?? 22);
return {
apiKey: parseOptionalString(raw.apiKey),
apiUrl: normalizeApiUrl(parseOptionalString(raw.apiUrl)),
namePrefix: normalizeNamePrefix(parseOptionalString(raw.namePrefix)),
sourceVm: parseOptionalString(raw.sourceVm),
image: parseOptionalString(raw.image),
command: parseOptionalString(raw.command),
cpu: parseOptionalInteger(raw.cpu),
memory: parseOptionalString(raw.memory),
disk: parseOptionalString(raw.disk),
comment: parseOptionalString(raw.comment),
env: parseEnvMap(raw.env),
integrations: parseStringArray(raw.integrations),
tags: parseStringArray(raw.tags),
setupScript: parseOptionalString(raw.setupScript),
prompt: parseOptionalString(raw.prompt),
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : DEFAULT_TIMEOUT_MS,
reuseLease: raw.reuseLease === true,
sshUser: parseOptionalString(raw.sshUser),
sshPrivateKey: parseOptionalString(raw.sshPrivateKey),
sshIdentityFile: parseOptionalString(raw.sshIdentityFile),
sshPort: Number.isFinite(sshPort) ? Math.trunc(sshPort) : 22,
strictHostKeyChecking: parseOptionalString(raw.strictHostKeyChecking) ?? "accept-new",
};
}
function resolveApiKey(config: ExeDevDriverConfig): string {
if (config.apiKey) return config.apiKey;
const envApiKey = process.env.EXE_API_KEY?.trim() ?? "";
if (!envApiKey) {
throw new Error("exe.dev environments require an API key in config or EXE_API_KEY.");
}
return envApiKey;
}
function isValidShellEnvKey(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildVmName(config: ExeDevDriverConfig, params: PluginEnvironmentAcquireLeaseParams): string {
const envPart = params.environmentId.replace(/[^a-z0-9]+/gi, "").slice(0, 8).toLowerCase() || "env";
const runPart = params.runId.replace(/[^a-z0-9]+/gi, "").slice(0, 8).toLowerCase() || randomUUID().slice(0, 8);
return `${config.namePrefix}-${envPart}-${runPart}`.slice(0, 63);
}
function buildFlag(name: string, value: string | number | null | undefined): string[] {
if (value == null) return [];
return [`--${name}=${shellQuote(String(value))}`];
}
function buildRepeatedFlag(name: string, values: string[]): string[] {
return values.flatMap((value) => buildFlag(name, value));
}
function buildEnvFlags(env: Record<string, string>): string[] {
return Object.entries(env).flatMap(([key, value]) => buildFlag("env", `${key}=${value}`));
}
function resolveSetupScript(config: ExeDevDriverConfig): string | null {
if (config.setupScript === null) return DEFAULT_SETUP_SCRIPT;
const trimmed = config.setupScript.trim();
return trimmed.length > 0 ? config.setupScript : null;
}
function buildCopyCommand(sourceVm: string, config: ExeDevDriverConfig, vmName: string): string {
return [
"cp",
shellQuote(sourceVm),
shellQuote(vmName),
"--json",
...buildFlag("cpu", config.cpu),
...buildFlag("memory", config.memory),
...buildFlag("disk", config.disk),
].join(" ");
}
function buildCreateCommand(
config: ExeDevDriverConfig,
vmName: string,
): string {
if (config.sourceVm) return buildCopyCommand(config.sourceVm, config, vmName);
return [
"new",
"--json",
"--no-email",
...buildFlag("name", vmName),
...buildFlag("image", config.image),
...buildFlag("command", config.command),
...buildFlag("cpu", config.cpu),
...buildFlag("memory", config.memory),
...buildFlag("disk", config.disk),
...buildFlag("comment", config.comment),
...buildEnvFlags(config.env),
...buildRepeatedFlag("integration", config.integrations),
...buildRepeatedFlag("tag", config.tags),
...buildFlag("setup-script", resolveSetupScript(config)),
...buildFlag("prompt", config.prompt),
].join(" ");
}
function replaceLiteralAll(input: string, search: string, replacement: string): string {
return search.length === 0 ? input : input.split(search).join(replacement);
}
function redactCreateCommand(command: string, config: ExeDevDriverConfig): string {
let redacted = command;
for (const [key, value] of Object.entries(config.env)) {
redacted = replaceLiteralAll(
redacted,
`--env=${shellQuote(`${key}=${value}`)}`,
`--env=${shellQuote(`${key}=[REDACTED]`)}`,
);
}
if (config.prompt) {
redacted = replaceLiteralAll(
redacted,
`--prompt=${shellQuote(config.prompt)}`,
`--prompt=${shellQuote("[REDACTED]")}`,
);
}
const resolvedSetupScript = resolveSetupScript(config);
if (resolvedSetupScript && resolvedSetupScript !== DEFAULT_SETUP_SCRIPT) {
redacted = replaceLiteralAll(
redacted,
`--setup-script=${shellQuote(resolvedSetupScript)}`,
`--setup-script=${shellQuote("[REDACTED]")}`,
);
}
return redacted;
}
async function runLifecycleCommand(
config: ExeDevDriverConfig,
command: string,
logCommand = command,
): Promise<unknown> {
const response = await fetch(config.apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${resolveApiKey(config)}`,
"Content-Type": "text/plain; charset=utf-8",
},
body: command,
signal: AbortSignal.timeout(Math.min(config.timeoutMs, EXE_DEV_API_MAX_TIMEOUT_MS)),
});
const body = await response.text();
if (!response.ok) {
throw new ExeDevApiError(
`exe.dev API command failed (${response.status}) for: ${logCommand}`,
response.status,
body,
);
}
const trimmed = body.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return body;
}
}
function parseVmRecord(value: unknown, depth = 0): ExeDevVmRecord | null {
if (depth > MAX_VM_RECORD_DEPTH) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const nested = parseVmRecord(record.vm, depth + 1) ?? parseVmRecord(record.data, depth + 1);
if (nested) return nested;
const name = parseOptionalString(record.vm_name ?? record.name ?? record.vmName);
const sshDest = parseOptionalString(record.ssh_dest ?? record.sshDest)
?? (name ? `${name}.exe.xyz` : null);
if (!name || !sshDest) return null;
return {
name,
sshDest,
httpsUrl: parseOptionalString(record.https_url ?? record.httpsUrl),
status: parseOptionalString(record.status),
region: parseOptionalString(record.region),
regionDisplay: parseOptionalString(record.region_display ?? record.regionDisplay),
};
}
async function lookupVm(config: ExeDevDriverConfig, vmName: string): Promise<ExeDevVmRecord | null> {
const response = await runLifecycleCommand(config, `ls --json ${shellQuote(vmName)}`);
const list = Array.isArray((response as { vms?: unknown[] } | null)?.vms)
? (response as { vms: unknown[] }).vms
: Array.isArray(response)
? response
: response
? [response]
: [];
for (const candidate of list) {
const parsed = parseVmRecord(candidate);
if (parsed?.name === vmName || parsed?.sshDest === vmName) {
return parsed;
}
}
return null;
}
async function createVm(
config: ExeDevDriverConfig,
params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams,
): Promise<ExeDevVmRecord> {
const vmName = "runId" in params
? buildVmName(config, params)
: `${config.namePrefix}-probe-${randomUUID().slice(0, 8)}`.slice(0, 63);
const command = buildCreateCommand(config, vmName);
const response = await runLifecycleCommand(config, command, redactCreateCommand(command, config));
const created = parseVmRecord(response) ?? await lookupVm(config, vmName);
if (!created) {
throw new Error(`exe.dev did not return VM metadata for ${vmName}.`);
}
return created;
}
async function deleteVm(config: ExeDevDriverConfig, vmName: string): Promise<void> {
await runLifecycleCommand(config, `rm --json ${shellQuote(vmName)}`);
}
function buildSshDestination(config: ExeDevDriverConfig, vm: ExeDevVmRecord): string {
return config.sshUser ? `${config.sshUser}@${vm.sshDest}` : vm.sshDest;
}
function buildSshArgs(
config: ExeDevDriverConfig,
vm: ExeDevVmRecord,
remoteCommand: string,
sshIdentityFile: string | null,
): string[] {
const args = [
"-T",
"-o",
"BatchMode=yes",
"-o",
`StrictHostKeyChecking=${config.strictHostKeyChecking}`,
"-o",
"ConnectTimeout=15",
"-p",
String(config.sshPort),
];
if (sshIdentityFile) {
args.push("-i", sshIdentityFile, "-o", "IdentitiesOnly=yes");
}
args.push(buildSshDestination(config, vm), remoteCommand);
return args;
}
async function prepareSshIdentity(config: ExeDevDriverConfig): Promise<{
sshIdentityFile: string | null;
cleanup: () => Promise<void>;
}> {
if (!config.sshPrivateKey) {
return {
sshIdentityFile: config.sshIdentityFile,
cleanup: async () => {},
};
}
const tempDir = await mkdtemp(path.join(tmpdir(), "paperclip-exe-dev-ssh-"));
const sshIdentityFile = path.join(tempDir, "id_ed25519");
const privateKey = config.sshPrivateKey.endsWith("\n")
? config.sshPrivateKey
: `${config.sshPrivateKey}\n`;
await writeFile(sshIdentityFile, privateKey, { mode: 0o600 });
await chmod(sshIdentityFile, 0o600);
return {
sshIdentityFile,
cleanup: async () => {
await rm(tempDir, { recursive: true, force: true });
},
};
}
function buildLoginShellScript(input: {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
}): string {
const env = input.env ?? {};
for (const key of Object.keys(env)) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid exe.dev environment variable key: ${key}`);
}
}
const envArgs = Object.entries(env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
const finalLine = envArgs.length > 0
? `exec env ${envArgs.join(" ")} ${commandParts}`
: `exec ${commandParts}`;
// Source the common login profiles before exec so the command runs with the
// interactive-shell PATH. The wrapper sources no `nvm.sh`; the sandbox image
// supplies node on the PATH.
const lines = [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);
}
lines.push(finalLine);
return lines.join(" && ");
}
function formatSshFailure(
action: string,
vmName: string,
result: Pick<SshExecutionResult, "stdout" | "stderr">,
): string {
const combinedOutput = `${result.stderr}\n${result.stdout}`;
if (
combinedOutput.includes(EXE_DEV_SSH_ONBOARDING_MARKER)
|| combinedOutput.includes(EXE_DEV_SSH_EMAIL_PROMPT)
) {
return [
`Failed to ${action} exe.dev VM ${vmName}: the Paperclip host SSH key is not registered with exe.dev.`,
"Complete exe.dev's one-time SSH onboarding on this host by running `ssh exe.dev` and following the email verification prompt, then retry.",
].join(" ");
}
if (EXE_DEV_SSH_INVALID_KEY_FORMAT.test(combinedOutput)) {
return [
`Failed to ${action} exe.dev VM ${vmName}: the configured SSH private key isn't an OpenSSH-format private key.`,
"Confirm the secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub` file or a PuTTY `.ppk` export.",
].join(" ");
}
return `Failed to ${action} exe.dev VM ${vmName}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`;
}
async function runSshCommand(
config: ExeDevDriverConfig,
vm: ExeDevVmRecord,
remoteCommand: string,
options: { stdin?: string; timeoutMs?: number } = {},
): Promise<SshExecutionResult> {
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
const identity = await prepareSshIdentity(config);
try {
return await new Promise((resolve, reject) => {
const child = spawn("ssh", buildSshArgs(config, vm, remoteCommand, identity.sshIdentityFile), {
stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
let killTimer: NodeJS.Timeout | null = null;
const timer = timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
killTimer = setTimeout(() => {
child.kill("SIGKILL");
}, SSH_SIGKILL_GRACE_MS);
}, timeoutMs)
: null;
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
if (timer) clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
reject(error);
});
child.on("close", (code, signal) => {
if (timer) clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolve({
exitCode: timedOut ? null : code,
signal,
timedOut,
stdout,
stderr,
});
});
if (options.stdin != null && child.stdin) {
child.stdin.write(options.stdin);
child.stdin.end();
}
});
} finally {
await identity.cleanup();
}
}
async function detectRemoteContext(
config: ExeDevDriverConfig,
vm: ExeDevVmRecord,
): Promise<{ homeDir: string; shellCommand: "bash" | "sh" }> {
const result = await runSshCommand(
config,
vm,
`sh -lc ${shellQuote(
'home="${HOME:-}"; if [ -z "$home" ]; then home="$(pwd)"; fi; if command -v bash >/dev/null 2>&1; then shell=bash; else shell=sh; fi; printf "%s\\n%s\\n" "$home" "$shell"',
)}`,
);
if (result.timedOut || result.exitCode !== 0) {
throw new Error(formatSshFailure("inspect", vm.name, result));
}
const [homeDirRaw, shellRaw] = result.stdout.split(/\r?\n/);
const homeDir = homeDirRaw?.trim() || "/tmp";
return {
homeDir,
shellCommand: shellRaw?.trim() === "bash" ? "bash" : "sh",
};
}
async function ensureRemoteWorkspace(
config: ExeDevDriverConfig,
vm: ExeDevVmRecord,
remoteCwd: string,
): Promise<void> {
const result = await runSshCommand(
config,
vm,
`sh -lc ${shellQuote(`mkdir -p ${shellQuote(remoteCwd)}`)}`,
);
if (result.timedOut || result.exitCode !== 0) {
throw new Error(formatSshFailure("create workspace for", vm.name, result));
}
}
async function buildLease(
config: ExeDevDriverConfig,
vm: ExeDevVmRecord,
requestedCwd: string | undefined,
resumedLease: boolean,
): Promise<PluginEnvironmentLease> {
const remote = await detectRemoteContext(config, vm);
const remoteCwd = requestedCwd?.trim() || path.posix.join(remote.homeDir, "paperclip-workspace");
await ensureRemoteWorkspace(config, vm, remoteCwd);
return {
providerLeaseId: vm.name,
metadata: {
provider: "exe-dev",
vmName: vm.name,
sshDest: vm.sshDest,
httpsUrl: vm.httpsUrl,
region: vm.region,
regionDisplay: vm.regionDisplay,
shellCommand: remote.shellCommand,
remoteCwd,
timeoutMs: config.timeoutMs,
reuseLease: config.reuseLease,
resumedLease,
},
};
}
function metadataVmRecord(params: {
providerLeaseId: string | null;
leaseMetadata?: Record<string, unknown> | null;
}): ExeDevVmRecord | null {
if (!params.providerLeaseId) return null;
const sshDest = parseOptionalString(params.leaseMetadata?.sshDest) ?? `${params.providerLeaseId}.exe.xyz`;
return {
name: params.providerLeaseId,
sshDest,
httpsUrl: parseOptionalString(params.leaseMetadata?.httpsUrl),
status: parseOptionalString(params.leaseMetadata?.status),
region: parseOptionalString(params.leaseMetadata?.region),
regionDisplay: parseOptionalString(params.leaseMetadata?.regionDisplay),
};
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info("exe.dev sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "exe.dev sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const config = parseDriverConfig(params.config);
const errors: string[] = [];
const warnings: string[] = [];
if (config.apiUrl && !isValidUrl(config.apiUrl)) {
errors.push("apiUrl must be a valid URL.");
}
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
errors.push("timeoutMs must be between 1 and 86400000.");
}
if (config.cpu != null && config.cpu <= 0) {
errors.push("cpu must be greater than 0 when provided.");
}
if (config.sshPort < 1 || config.sshPort > 65_535) {
errors.push("sshPort must be between 1 and 65535.");
}
if (!config.apiKey && !(process.env.EXE_API_KEY?.trim())) {
errors.push("exe.dev environments require an API key in config or EXE_API_KEY.");
}
for (const key of Object.keys(config.env)) {
if (!isValidShellEnvKey(key)) {
errors.push(`env contains an invalid key: ${key}`);
}
}
if (
typeof params.config.strictHostKeyChecking === "string" &&
params.config.strictHostKeyChecking.trim().length === 0
) {
errors.push("strictHostKeyChecking cannot be empty.");
}
if (config.sshPrivateKey && !isSecretRef(config.sshPrivateKey)) {
const sshKeyError = validateSshPrivateKey(config.sshPrivateKey);
if (sshKeyError) errors.push(sshKeyError);
}
warnings.push(
"The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning.",
);
if (config.sourceVm) {
const ignored = Object.entries({
image: config.image,
command: config.command,
comment: config.comment,
env: Object.keys(config.env).length > 0,
integrations: config.integrations.length > 0,
tags: config.tags.length > 0,
setupScript: config.setupScript,
prompt: config.prompt,
}).filter(([, value]) => value).map(([key]) => key);
if (ignored.length > 0) {
errors.push(
`sourceVm copies an existing VM with \`exe.dev cp\`, which cannot apply ${ignored.join(", ")}. Clear these settings or clear sourceVm.`,
);
}
}
if (config.reuseLease) {
warnings.push("reuseLease keeps the VM alive between runs; this provider does not suspend retained VMs.");
}
if (errors.length > 0) {
return { ok: false, errors, warnings };
}
return {
ok: true,
warnings,
normalizedConfig: { ...config },
};
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const config = parseDriverConfig(params.config);
let vm: ExeDevVmRecord | null = null;
try {
vm = await createVm(config, params);
const lease = await buildLease(config, vm, undefined, false);
return {
ok: true,
summary: `Connected to exe.dev VM ${vm.name}.`,
metadata: {
provider: "exe-dev",
vmName: vm.name,
sshDest: vm.sshDest,
timeoutMs: config.timeoutMs,
reuseLease: config.reuseLease,
remoteCwd: lease.metadata?.remoteCwd,
shellCommand: lease.metadata?.shellCommand,
},
};
} catch (error) {
return {
ok: false,
summary: "exe.dev environment probe failed.",
metadata: {
provider: "exe-dev",
timeoutMs: config.timeoutMs,
reuseLease: config.reuseLease,
error: formatErrorMessage(error),
},
};
} finally {
if (vm) {
await deleteVm(config, vm.name).catch(() => undefined);
}
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const vm = await createVm(config, params);
try {
return await buildLease(config, vm, params.requestedCwd, false);
} catch (error) {
await deleteVm(config, vm.name).catch(() => undefined);
throw error;
}
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const vm = await lookupVm(config, params.providerLeaseId);
if (!vm) {
return { providerLeaseId: null, metadata: { expired: true } };
}
const requestedCwd = parseOptionalString(params.leaseMetadata?.remoteCwd);
return await buildLease(config, vm, requestedCwd ?? undefined, true);
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
if (config.reuseLease) return;
await deleteVm(config, params.providerLeaseId);
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
await deleteVm(config, params.providerLeaseId);
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const config = parseDriverConfig(params.config);
const remoteCwd =
parseOptionalString(params.lease.metadata?.remoteCwd)
?? params.workspace.remotePath
?? params.workspace.localPath
?? "/tmp/paperclip-workspace";
const vm = metadataVmRecord({
providerLeaseId: params.lease.providerLeaseId,
leaseMetadata: params.lease.metadata,
});
if (vm) {
await ensureRemoteWorkspace(config, vm, remoteCwd);
}
return {
cwd: remoteCwd,
metadata: {
provider: "exe-dev",
remoteCwd,
},
};
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
if (!params.lease.providerLeaseId) {
return {
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
};
}
const config = parseDriverConfig(params.config);
const vm = metadataVmRecord({
providerLeaseId: params.lease.providerLeaseId,
leaseMetadata: params.lease.metadata,
});
if (!vm) {
return {
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "No exe.dev VM metadata available for execution.",
};
}
const command = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
cwd: params.cwd ?? parseOptionalString(params.lease.metadata?.remoteCwd) ?? undefined,
env: params.env,
});
// `buildLoginShellScript` already explicitly sources `/etc/profile`,
// `~/.profile`, `~/.bash_profile`/`~/.bashrc`, and `~/.zprofile`. Wrapping
// the result in `sh -lc` (login shell) would source the same files a
// second time, which can cause `PATH` duplication or unexpected behavior
// on VMs whose profile init isn't idempotent. Use `sh -c` here so the
// explicit sourcing inside the script is the single source of truth.
const result = await runSshCommand(
config,
vm,
`sh -c ${shellQuote(command)}`,
{ stdin: params.stdin, timeoutMs: params.timeoutMs ?? config.timeoutMs },
);
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
stdout: result.stdout,
stderr:
!result.timedOut && result.exitCode !== 0
? formatSshFailure("execute commands on", vm.name, result)
: result.stderr,
metadata: {
provider: "exe-dev",
vmName: vm.name,
sshDest: vm.sshDest,
},
};
},
});
export default plugin;