From bb73f2fe392696d1470bdd521e734ce975a830dd Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 6 Oct 2026 07:11:59 +0200 Subject: [PATCH] feat(exe-dev): copy a source VM with exe.dev cp (#14975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --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 '' '' --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) Bildschirmfoto 2026-10-02 um 23 04
32 --------- Co-authored-by: Claude Opus 5.5 --- .../sandbox-providers/exe-dev/README.md | 3 +- .../sandbox-providers/exe-dev/src/manifest.ts | 10 +++- .../exe-dev/src/plugin.test.ts | 58 +++++++++++++++++++ .../sandbox-providers/exe-dev/src/plugin.ts | 32 ++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/plugins/sandbox-providers/exe-dev/README.md b/packages/plugins/sandbox-providers/exe-dev/README.md index 52d420c25d..f1091e4896 100644 --- a/packages/plugins/sandbox-providers/exe-dev/README.md +++ b/packages/plugins/sandbox-providers/exe-dev/README.md @@ -23,7 +23,7 @@ Configure exe.dev from `Instance Settings -> Environments`, not from the plugin' To use the provider successfully, the environment/host needs all of the following: -- An exe.dev API token that allows the lifecycle commands the provider uses: `new`, `ls`, and `rm`. `whoami` and `help` are recommended for manual debugging. `restart` is only needed if you extend the provider to restart retained VMs. +- An exe.dev API token that allows the lifecycle commands the provider uses: `new`, `ls`, and `rm`, plus `cp` when `sourceVm` is set. exe.dev answers 403 for a command the token does not list. `whoami` and `help` are recommended for manual debugging. `restart` is only needed if you extend the provider to restart retained VMs. - SSH access from the Paperclip host to the resulting `*.exe.xyz` VMs. - An SSH private key that exe.dev already recognizes. You can either: - paste the private key into the environment config via `sshPrivateKey` @@ -37,6 +37,7 @@ Operational notes: - Reusable leases keep the VM alive between runs. exe.dev does not expose a documented "stop and later resume" command in the public CLI docs, so `reuseLease: true` means "retain the VM" rather than "suspend it." - The provisioning path uses `https://exe.dev/exec`, which exe.dev documents as a command-style HTTPS API with a 30-second request timeout. Typical `new` calls are expected to fit inside that limit; command execution itself does not use `/exec`. - Probes still create and delete a real exe.dev VM through `/exec`, and so do the `new`/`rm` calls inside the normal acquire/release lifecycle. Treat all of those as real provisioning cost, not just probes. +- Set `sourceVm` ("Source VM" in the form) to the name of an existing exe.dev VM to copy it for each run with `exe.dev cp`, disk and config included, instead of creating a fresh VM with `exe.dev new`. This lets you prepare one VM with your toolchain and caches and start every run from it. `cp` accepts only the VM name, `cpu`, `memory`, and `disk`, so config validation rejects `sourceVm` together with `image`, `command`, `comment`, `env`, `integrations`, `tags`, `setupScript`, or `prompt`. The default setup script does not run either, so the source VM must already have Node 24.11+ and accept the configured SSH key. Do not keep secrets on the source VM: every copy inherits its disk. `cp` also goes through `/exec`, so a large disk copy must finish inside the same 30-second limit. - exe.dev runs `--setup-script` as the unprivileged `exedev` user, not as root. That user has passwordless `sudo`, so any system-level steps in a custom `setupScript` must invoke `sudo` explicitly (for example `sudo apt-get install -y …`). When you omit `setupScript`, the plugin supplies a default that installs Node 24 via the official nodesource script — Paperclip's sandbox callback bridge is a Node program, so the VM needs `node` on `PATH` before the bridge can launch. ## Local development diff --git a/packages/plugins/sandbox-providers/exe-dev/src/manifest.ts b/packages/plugins/sandbox-providers/exe-dev/src/manifest.ts index bef86f3def..da176d0f96 100644 --- a/packages/plugins/sandbox-providers/exe-dev/src/manifest.ts +++ b/packages/plugins/sandbox-providers/exe-dev/src/manifest.ts @@ -31,7 +31,7 @@ const manifest: PaperclipPluginManifestV1 = { type: "string", format: "secret-ref", description: - "Paste your exe.dev API token, or pick a saved Paperclip secret. Create one at exe.dev → Settings → API tokens with `/exec` scope (`new`, `ls`, `rm`).", + "Paste your exe.dev API token, or pick a saved Paperclip secret. Create one at exe.dev → Settings → API tokens with `/exec` scope (`new`, `ls`, `rm`, plus `cp` if you use Source VM).", }, sshPrivateKey: { type: "string", @@ -99,6 +99,14 @@ const manifest: PaperclipPluginManifestV1 = { "x-paperclip-group": "VM resources", }, // ---- Advanced: VM creation ---- + sourceVm: { + type: "string", + title: "Source VM", + description: + "Name of an existing exe.dev VM to copy for each run with `exe.dev cp`, disk and config included. Leave blank to create a fresh VM with `exe.dev new`. Your API token must allow `cp`: tokens list their permitted commands, and one without `cp` fails with a 403. When set, leave image, command, env, integrations, tags, setup script, prompt, and comment empty: `cp` cannot apply them, and saving fails until they are cleared.", + "x-paperclip-advanced": true, + "x-paperclip-group": "VM creation", + }, command: { type: "string", description: "Optional container command passed to `exe.dev new --command`.", diff --git a/packages/plugins/sandbox-providers/exe-dev/src/plugin.test.ts b/packages/plugins/sandbox-providers/exe-dev/src/plugin.test.ts index 16618bf507..912105db85 100644 --- a/packages/plugins/sandbox-providers/exe-dev/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/exe-dev/src/plugin.test.ts @@ -106,6 +106,7 @@ describe("exe.dev sandbox provider plugin", () => { integrations: ["github"], tags: ["prod", "sandbox"], setupScript: null, + sourceVm: null, prompt: null, timeoutMs: 450000, reuseLease: true, @@ -319,6 +320,57 @@ describe("exe.dev sandbox provider plugin", () => { }); }); + it("copies the source VM with exe.dev cp instead of creating a new VM", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ + vm_name: "paperclip-env1-run1", + ssh_dest: "paperclip-env1-run1.exe.xyz", + status: "running", + }), { status: 200 }), + ); + queueSpawnResult({ stdout: "/home/exedev\nbash\n" }); + queueSpawnResult({}); + + const lease = await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "exe-dev", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + config: { + apiKey: "api-key", + sourceVm: " golden-vm ", + image: "ubuntu:22.04", + env: { FOO: "bar" }, + cpu: 4, + memory: "8GB", + disk: "40GB", + }, + }); + + expect(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")).toBe( + "cp 'golden-vm' 'paperclip-env1-run1' --json --cpu='4' --memory='8GB' --disk='40GB'", + ); + expect(lease).toMatchObject({ providerLeaseId: "paperclip-env1-run1" }); + }); + + it("rejects VM creation settings that exe.dev cp cannot apply when sourceVm is set", async () => { + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "exe-dev", + config: { + apiKey: "api-key", + sourceVm: "golden-vm", + image: "ubuntu:22.04", + env: { FOO: "bar" }, + setupScript: "echo hi", + }, + }); + + expect(result?.ok).toBe(false); + expect(result?.errors).toContain( + "sourceVm copies an existing VM with `exe.dev cp`, which cannot apply image, env, setupScript. Clear these settings or clear sourceVm.", + ); + }); + it("uses a pasted sshPrivateKey when connecting to the VM", async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ @@ -860,6 +912,12 @@ describe("exe-dev manifest form defaults", () => { expect(properties.disk?.default).toBe("20GB"); }); + it("explains the cp token permission on the Source VM field", () => { + const sourceVm = properties.sourceVm as { title?: string; description?: string } | undefined; + expect(sourceVm?.title).toBe("Source VM"); + expect(sourceVm?.description).toMatch(/token must allow `cp`.*403/); + }); + it("declares no default on secret-ref fields, which would be persisted as a company secret", () => { for (const prop of Object.values(properties)) { if (prop.format === "secret-ref") { diff --git a/packages/plugins/sandbox-providers/exe-dev/src/plugin.ts b/packages/plugins/sandbox-providers/exe-dev/src/plugin.ts index a586b665f4..d45f562fc0 100644 --- a/packages/plugins/sandbox-providers/exe-dev/src/plugin.ts +++ b/packages/plugins/sandbox-providers/exe-dev/src/plugin.ts @@ -24,6 +24,7 @@ interface ExeDevDriverConfig { apiKey: string | null; apiUrl: string; namePrefix: string; + sourceVm: string | null; image: string | null; command: string | null; cpu: number | null; @@ -246,6 +247,7 @@ function parseDriverConfig(raw: Record): ExeDevDriverConfig { 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), @@ -313,10 +315,23 @@ function resolveSetupScript(config: ExeDevDriverConfig): string | null { 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", @@ -774,6 +789,23 @@ const plugin = definePlugin({ 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."); }