Add exe.dev sandbox provider plugin (#5688)
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## 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 — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# `@paperclipai/plugin-exe-dev`
|
||||
|
||||
Published exe.dev sandbox provider plugin for Paperclip.
|
||||
|
||||
This package lives in the Paperclip monorepo, but it is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That lets operators install it from the Plugins page by package name without introducing root lockfile churn.
|
||||
|
||||
## Install
|
||||
|
||||
From a Paperclip instance, install:
|
||||
|
||||
```text
|
||||
@paperclipai/plugin-exe-dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure exe.dev from `Company Settings -> Environments`, not from the plugin's instance settings page.
|
||||
|
||||
- Put the exe.dev API token on the sandbox environment itself.
|
||||
- When you save an environment, Paperclip stores pasted API keys and pasted SSH private keys as company secrets.
|
||||
- `EXE_API_KEY` remains an optional host-level fallback when an environment omits the API token.
|
||||
- The current implementation provisions VMs through exe.dev's HTTPS API and runs commands through direct SSH to the created VM.
|
||||
|
||||
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.
|
||||
- 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`
|
||||
- point `sshIdentityFile` at an absolute host path
|
||||
- or leave both blank and rely on the host's default SSH agent/keychain
|
||||
- The matching public key must already be registered with exe.dev before the provider can execute commands inside the VM.
|
||||
|
||||
Operational notes:
|
||||
|
||||
- If exe.dev replies `Please complete registration by running: ssh exe.dev`, the host key has not finished exe.dev onboarding yet.
|
||||
- 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.
|
||||
- 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 20 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
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/exe-dev
|
||||
pnpm install --ignore-workspace --no-lockfile
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development.
|
||||
|
||||
## Package layout
|
||||
|
||||
- `src/manifest.ts` declares the sandbox-provider driver metadata
|
||||
- `src/plugin.ts` implements the environment lifecycle hooks
|
||||
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-exe-dev",
|
||||
"version": "0.1.0",
|
||||
"description": "exe.dev sandbox provider plugin for Paperclip environments",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/sandbox-providers/exe-dev"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"sandbox",
|
||||
"exe.dev"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.exe-dev-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "exe.dev Sandbox Provider",
|
||||
description:
|
||||
"Sandbox provider plugin that provisions exe.dev VMs as Paperclip execution environments.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "exe-dev",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "exe.dev VM",
|
||||
description:
|
||||
"Provisions exe.dev VMs through the HTTPS API, then runs commands over direct SSH for long-lived Paperclip workloads.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
description:
|
||||
"Environment-specific exe.dev API token. Needs `/exec` permission for at least `new`, `ls`, and `rm`. Paste a token or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Falls back to EXE_API_KEY if omitted.",
|
||||
},
|
||||
apiUrl: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional exe.dev HTTPS API base URL or /exec endpoint. Defaults to https://exe.dev/exec.",
|
||||
},
|
||||
namePrefix: {
|
||||
type: "string",
|
||||
description: "Optional prefix used when generating VM names.",
|
||||
default: "paperclip",
|
||||
},
|
||||
image: {
|
||||
type: "string",
|
||||
description: "Optional container image to use when creating the VM.",
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Optional container command passed to `exe.dev new --command`.",
|
||||
},
|
||||
cpu: {
|
||||
type: "number",
|
||||
description: "Optional CPU count passed to `exe.dev new --cpu`.",
|
||||
},
|
||||
memory: {
|
||||
type: "string",
|
||||
description: "Optional memory size such as `4GB`.",
|
||||
},
|
||||
disk: {
|
||||
type: "string",
|
||||
description: "Optional disk size such as `20GB`.",
|
||||
},
|
||||
comment: {
|
||||
type: "string",
|
||||
description: "Optional short note attached to created VMs.",
|
||||
},
|
||||
env: {
|
||||
type: "object",
|
||||
description: "Optional environment variables applied at VM creation time.",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
integrations: {
|
||||
type: "array",
|
||||
description: "Optional exe.dev integrations to attach during VM creation.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
description: "Optional tags to apply during VM creation.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
setupScript: {
|
||||
type: "string",
|
||||
description: "Optional first-boot setup script passed to `exe.dev new --setup-script`.",
|
||||
},
|
||||
prompt: {
|
||||
type: "string",
|
||||
description: "Optional Shelley prompt passed to `exe.dev new --prompt`.",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Timeout for VM lifecycle and SSH operations in milliseconds.",
|
||||
default: 300000,
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Whether to keep the VM alive between runs instead of deleting it on release.",
|
||||
default: false,
|
||||
},
|
||||
sshUser: {
|
||||
type: "string",
|
||||
description: "Optional SSH username for direct VM access.",
|
||||
},
|
||||
sshPrivateKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
maxLength: 4096,
|
||||
description:
|
||||
"Optional exe.dev-registered SSH private key. Paste the private key or an existing Paperclip secret reference; saved environments store pasted values as company secrets. If omitted, Paperclip falls back to sshIdentityFile, then the host's default SSH agent/keychain.",
|
||||
},
|
||||
sshIdentityFile: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional absolute path to the SSH private key the Paperclip host should use for VM access when sshPrivateKey is omitted. Leave both blank to rely on the host's default SSH agent/keychain.",
|
||||
},
|
||||
sshPort: {
|
||||
type: "number",
|
||||
description: "SSH port for direct VM access.",
|
||||
default: 22,
|
||||
},
|
||||
strictHostKeyChecking: {
|
||||
type: "string",
|
||||
description:
|
||||
"Host key policy passed to ssh via StrictHostKeyChecking. Typical values are `accept-new`, `yes`, or `no`.",
|
||||
default: "accept-new",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,697 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawn: spawnMock,
|
||||
};
|
||||
});
|
||||
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
class MockChildProcess extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
stdin = {
|
||||
written: "" as string,
|
||||
ended: false,
|
||||
write: (chunk: string) => {
|
||||
this.stdin.written += chunk;
|
||||
return true;
|
||||
},
|
||||
end: () => {
|
||||
this.stdin.ended = true;
|
||||
},
|
||||
};
|
||||
kill = vi.fn();
|
||||
|
||||
constructor(input: { code?: number; signal?: string | null; stdout?: string; stderr?: string }) {
|
||||
super();
|
||||
queueMicrotask(() => {
|
||||
if (input.stdout) this.stdout.emit("data", input.stdout);
|
||||
if (input.stderr) this.stderr.emit("data", input.stderr);
|
||||
this.emit("close", input.code ?? 0, input.signal ?? null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function queueSpawnResult(input: { code?: number; signal?: string | null; stdout?: string; stderr?: string }) {
|
||||
spawnMock.mockImplementationOnce(() => new MockChildProcess(input));
|
||||
}
|
||||
|
||||
describe("exe.dev sandbox provider plugin", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
spawnMock.mockReset();
|
||||
delete process.env.EXE_API_KEY;
|
||||
});
|
||||
|
||||
it("declares environment lifecycle handlers", async () => {
|
||||
expect(await plugin.definition.onHealth?.()).toEqual({
|
||||
status: "ok",
|
||||
message: "exe.dev sandbox provider plugin healthy",
|
||||
});
|
||||
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("normalizes config and emits SSH guidance warnings", async () => {
|
||||
process.env.EXE_API_KEY = "host-key";
|
||||
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "exe-dev",
|
||||
config: {
|
||||
apiUrl: "https://exe.dev",
|
||||
namePrefix: " Paperclip Sandbox ",
|
||||
image: " ubuntu:22.04 ",
|
||||
cpu: "4.8",
|
||||
memory: " 8GB ",
|
||||
disk: " 50GB ",
|
||||
env: {
|
||||
FOO: " bar ",
|
||||
},
|
||||
integrations: [" github "],
|
||||
tags: "prod, sandbox",
|
||||
timeoutMs: "450000.9",
|
||||
reuseLease: true,
|
||||
sshPort: "2222",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
warnings: [
|
||||
"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.",
|
||||
"reuseLease keeps the VM alive between runs; this provider does not suspend retained VMs.",
|
||||
],
|
||||
normalizedConfig: {
|
||||
apiKey: null,
|
||||
apiUrl: "https://exe.dev/exec",
|
||||
namePrefix: "paperclip-sandbox",
|
||||
image: "ubuntu:22.04",
|
||||
command: null,
|
||||
cpu: 4,
|
||||
memory: "8GB",
|
||||
disk: "50GB",
|
||||
comment: null,
|
||||
env: { FOO: "bar" },
|
||||
integrations: ["github"],
|
||||
tags: ["prod", "sandbox"],
|
||||
setupScript: null,
|
||||
prompt: null,
|
||||
timeoutMs: 450000,
|
||||
reuseLease: true,
|
||||
sshUser: null,
|
||||
sshPrivateKey: null,
|
||||
sshIdentityFile: null,
|
||||
sshPort: 2222,
|
||||
strictHostKeyChecking: "accept-new",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes trailing /exec apiUrl inputs without duplication", async () => {
|
||||
process.env.EXE_API_KEY = "host-key";
|
||||
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "exe-dev",
|
||||
config: {
|
||||
apiUrl: "https://exe.dev/exec/",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
normalizedConfig: {
|
||||
apiUrl: "https://exe.dev/exec",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid config", async () => {
|
||||
await expect(plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "exe-dev",
|
||||
config: {
|
||||
apiUrl: "not-a-url",
|
||||
cpu: 0,
|
||||
env: {
|
||||
"BAD-KEY": "value",
|
||||
},
|
||||
sshPort: 70000,
|
||||
strictHostKeyChecking: "",
|
||||
timeoutMs: 0,
|
||||
},
|
||||
})).resolves.toEqual({
|
||||
ok: false,
|
||||
warnings: [
|
||||
"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.",
|
||||
],
|
||||
errors: [
|
||||
"apiUrl must be a valid URL.",
|
||||
"timeoutMs must be between 1 and 86400000.",
|
||||
"cpu must be greater than 0 when provided.",
|
||||
"sshPort must be between 1 and 65535.",
|
||||
"exe.dev environments require an API key in config or EXE_API_KEY.",
|
||||
"env contains an invalid key: BAD-KEY",
|
||||
"strictHostKeyChecking cannot be empty.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("acquires a lease by creating a VM and preparing the SSH workspace", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-env-run",
|
||||
ssh_dest: "paperclip-env-run.exe.xyz",
|
||||
https_url: "https://paperclip-env-run.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
);
|
||||
queueSpawnResult({ stdout: "/home/exe\nbash\n" });
|
||||
queueSpawnResult({});
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
requestedCwd: "/workspace/custom",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
namePrefix: "paperclip",
|
||||
image: "ubuntu:22.04",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")).toContain("new --json --no-email");
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(lease).toMatchObject({
|
||||
providerLeaseId: "paperclip-env-run",
|
||||
metadata: {
|
||||
provider: "exe-dev",
|
||||
vmName: "paperclip-env-run",
|
||||
sshDest: "paperclip-env-run.exe.xyz",
|
||||
remoteCwd: "/workspace/custom",
|
||||
shellCommand: "bash",
|
||||
reuseLease: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a pasted sshPrivateKey when connecting to the VM", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-env-run",
|
||||
ssh_dest: "paperclip-env-run.exe.xyz",
|
||||
https_url: "https://paperclip-env-run.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
);
|
||||
queueSpawnResult({ stdout: "/home/exe\nbash\n" });
|
||||
queueSpawnResult({});
|
||||
|
||||
await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
sshPrivateKey: "-----BEGIN PRIVATE KEY-----\npretend\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
});
|
||||
|
||||
const firstSpawnArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined;
|
||||
expect(firstSpawnArgs).toContain("-i");
|
||||
expect(firstSpawnArgs).toContain("-o");
|
||||
expect(firstSpawnArgs).toContain("IdentitiesOnly=yes");
|
||||
});
|
||||
|
||||
it("supplies a default Node-install setup script when none is provided", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-env-run",
|
||||
ssh_dest: "paperclip-env-run.exe.xyz",
|
||||
https_url: "https://paperclip-env-run.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
);
|
||||
queueSpawnResult({ stdout: "/home/exedev\nbash\n" });
|
||||
queueSpawnResult({});
|
||||
|
||||
await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
});
|
||||
|
||||
const body = String(fetchMock.mock.calls[0]?.[1]?.body ?? "");
|
||||
expect(body).toContain("--setup-script=");
|
||||
expect(body).toContain("nodesource.com/setup_20.x");
|
||||
expect(body).toContain("sudo apt-get install -y nodejs");
|
||||
});
|
||||
|
||||
it("preserves an operator-supplied setup script and does not append the default", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-env-run",
|
||||
ssh_dest: "paperclip-env-run.exe.xyz",
|
||||
https_url: "https://paperclip-env-run.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
);
|
||||
queueSpawnResult({ stdout: "/home/exedev\nbash\n" });
|
||||
queueSpawnResult({});
|
||||
|
||||
await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
setupScript: "echo custom",
|
||||
},
|
||||
});
|
||||
|
||||
const body = String(fetchMock.mock.calls[0]?.[1]?.body ?? "");
|
||||
expect(body).toContain("--setup-script='echo custom'");
|
||||
expect(body).not.toContain("nodesource.com");
|
||||
});
|
||||
|
||||
it("does not redact the built-in default setup script in API errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("upstream boom", { status: 500 }));
|
||||
|
||||
const acquirePromise = plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(acquirePromise).rejects.toMatchObject({
|
||||
name: "ExeDevApiError",
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await acquirePromise?.catch((error: Error) => {
|
||||
// Operator did not supply a setupScript, so the visible default install
|
||||
// is not a secret and stays in the error for debuggability.
|
||||
expect(error.message).toContain("nodesource.com/setup_20.x");
|
||||
expect(error.message).not.toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces exe.dev SSH onboarding guidance during lease acquisition", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-env-run",
|
||||
ssh_dest: "paperclip-env-run.exe.xyz",
|
||||
https_url: "https://paperclip-env-run.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
queueSpawnResult({ code: 1, stdout: "Please complete registration by running: ssh exe.dev\n" });
|
||||
|
||||
await expect(plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
})).rejects.toThrow(
|
||||
"the Paperclip host SSH key is not registered with exe.dev",
|
||||
);
|
||||
|
||||
expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-env-run'");
|
||||
});
|
||||
|
||||
it("redacts sensitive lifecycle flags in API errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("upstream boom", { status: 500 }));
|
||||
|
||||
const acquirePromise = plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
env: {
|
||||
SECRET: "super-secret",
|
||||
},
|
||||
prompt: "build me a secret app",
|
||||
setupScript: "export TOKEN=super-secret",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(acquirePromise).rejects.toMatchObject({
|
||||
name: "ExeDevApiError",
|
||||
status: 500,
|
||||
body: "upstream boom",
|
||||
});
|
||||
|
||||
await acquirePromise?.catch((error: Error) => {
|
||||
expect(error.message).toContain("--env='SECRET=[REDACTED]'");
|
||||
expect(error.message).toContain("--prompt='[REDACTED]'");
|
||||
expect(error.message).toContain("--setup-script='[REDACTED]'");
|
||||
expect(error.message).not.toContain("super-secret");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an expired lease when the retained VM no longer exists", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ vms: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "missing-vm",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
leaseMetadata: {
|
||||
sshDest: "missing-vm.exe.xyz",
|
||||
},
|
||||
});
|
||||
|
||||
expect(lease).toEqual({
|
||||
providerLeaseId: null,
|
||||
metadata: {
|
||||
expired: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("executes commands over SSH with cwd, env, and stdin", async () => {
|
||||
queueSpawnResult({ code: 0, stdout: "hello\n", stderr: "" });
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
lease: {
|
||||
providerLeaseId: "vm-1",
|
||||
metadata: {
|
||||
sshDest: "vm-1.exe.xyz",
|
||||
},
|
||||
},
|
||||
command: "node",
|
||||
args: ["-e", "process.stdout.write('hello\\n')"],
|
||||
cwd: "/workspace",
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
stdin: "input-body",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0]?.[0]).toBe("ssh");
|
||||
expect(String(spawnMock.mock.calls[0]?.[1]?.at(-1) ?? "")).toContain("/workspace");
|
||||
expect(String(spawnMock.mock.calls[0]?.[1]?.at(-1) ?? "")).toContain("FOO='");
|
||||
const child = spawnMock.mock.results[0]?.value as MockChildProcess;
|
||||
expect(child.stdin.written).toBe("input-body");
|
||||
expect(child.stdin.ended).toBe(true);
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "hello\n",
|
||||
stderr: "",
|
||||
metadata: {
|
||||
provider: "exe-dev",
|
||||
vmName: "vm-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns exe.dev SSH onboarding guidance for command execution failures", async () => {
|
||||
queueSpawnResult({ code: 1, stdout: "Please complete registration by running: ssh exe.dev\n" });
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
lease: {
|
||||
providerLeaseId: "vm-1",
|
||||
metadata: {
|
||||
sshDest: "vm-1.exe.xyz",
|
||||
},
|
||||
},
|
||||
command: "node",
|
||||
args: ["-v"],
|
||||
});
|
||||
|
||||
expect(result?.exitCode).toBe(1);
|
||||
expect(String(result?.stderr ?? "")).toContain("the Paperclip host SSH key is not registered with exe.dev");
|
||||
expect(String(result?.stderr ?? "")).toContain("ssh exe.dev");
|
||||
});
|
||||
|
||||
it("probes by creating and then deleting a VM after SSH verification", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-probe",
|
||||
ssh_dest: "paperclip-probe.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
queueSpawnResult({ stdout: "/home/exe\nbash\n" });
|
||||
queueSpawnResult({});
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
summary: "Connected to exe.dev VM paperclip-probe.",
|
||||
metadata: {
|
||||
provider: "exe-dev",
|
||||
vmName: "paperclip-probe",
|
||||
sshDest: "paperclip-probe.exe.xyz",
|
||||
shellCommand: "bash",
|
||||
},
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-probe'");
|
||||
});
|
||||
|
||||
it("cleans up the probe VM when SSH verification fails", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-probe",
|
||||
ssh_dest: "paperclip-probe.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
queueSpawnResult({ code: 1, stderr: "permission denied" });
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
summary: "exe.dev environment probe failed.",
|
||||
metadata: {
|
||||
provider: "exe-dev",
|
||||
},
|
||||
});
|
||||
expect(String(result?.metadata?.error ?? "")).toContain("permission denied");
|
||||
expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-probe'");
|
||||
});
|
||||
|
||||
it("returns onboarding guidance when probe hits exe.dev SSH registration", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
vm_name: "paperclip-probe",
|
||||
ssh_dest: "paperclip-probe.exe.xyz",
|
||||
status: "running",
|
||||
}), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
queueSpawnResult({ code: 1, stdout: "Please complete registration by running: ssh exe.dev\n" });
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
summary: "exe.dev environment probe failed.",
|
||||
});
|
||||
expect(String(result?.metadata?.error ?? "")).toContain("the Paperclip host SSH key is not registered with exe.dev");
|
||||
expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-probe'");
|
||||
});
|
||||
|
||||
it("deletes non-reusable leases on release", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "vm-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
reuseLease: false,
|
||||
},
|
||||
leaseMetadata: {},
|
||||
});
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")).toBe("rm --json 'vm-1'");
|
||||
});
|
||||
|
||||
it("destroys leases on demand", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "vm-2",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
},
|
||||
leaseMetadata: {},
|
||||
});
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")).toBe("rm --json 'vm-2'");
|
||||
});
|
||||
|
||||
it("realizes a workspace by mkdir-ing the remote cwd over SSH when VM metadata is present", async () => {
|
||||
queueSpawnResult({ code: 0, stdout: "", stderr: "" });
|
||||
|
||||
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
lease: {
|
||||
providerLeaseId: "vm-1",
|
||||
metadata: {
|
||||
sshDest: "vm-1.exe.xyz",
|
||||
remoteCwd: "/srv/paperclip/run-1",
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
localPath: "/local/paperclip",
|
||||
remotePath: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0]?.[0]).toBe("ssh");
|
||||
const sshCommand = String(spawnMock.mock.calls[0]?.[1]?.at(-1) ?? "");
|
||||
expect(sshCommand).toContain("mkdir -p");
|
||||
expect(sshCommand).toContain("/srv/paperclip/run-1");
|
||||
expect(result).toMatchObject({
|
||||
cwd: "/srv/paperclip/run-1",
|
||||
metadata: {
|
||||
provider: "exe-dev",
|
||||
remoteCwd: "/srv/paperclip/run-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back through workspace.remotePath then workspace.localPath when lease.metadata.remoteCwd is missing", async () => {
|
||||
queueSpawnResult({ code: 0, stdout: "", stderr: "" });
|
||||
|
||||
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
lease: {
|
||||
providerLeaseId: "vm-1",
|
||||
metadata: {
|
||||
sshDest: "vm-1.exe.xyz",
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
localPath: "/local/paperclip",
|
||||
remotePath: "/srv/paperclip/remote-fallback",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.cwd).toBe("/srv/paperclip/remote-fallback");
|
||||
});
|
||||
|
||||
it("skips ensureRemoteWorkspace and returns the resolved cwd when no VM metadata is available", async () => {
|
||||
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
|
||||
driverKey: "exe-dev",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
apiKey: "api-key",
|
||||
timeoutMs: 300000,
|
||||
},
|
||||
lease: {
|
||||
providerLeaseId: null,
|
||||
metadata: {
|
||||
remoteCwd: "/srv/paperclip/no-vm",
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
localPath: "/local/paperclip",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(result?.cwd).toBe("/srv/paperclip/no-vm");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,882 @@
|
||||
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;
|
||||
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:";
|
||||
|
||||
// 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 20 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 || " +
|
||||
"(curl -fsSL https://deb.nodesource.com/setup_20.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 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)),
|
||||
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 buildCreateCommand(
|
||||
config: ExeDevDriverConfig,
|
||||
vmName: string,
|
||||
): string {
|
||||
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}`;
|
||||
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',
|
||||
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
||||
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
||||
];
|
||||
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(" ");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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.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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -94,6 +94,11 @@
|
||||
"name": "@paperclipai/plugin-daytona",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/exe-dev",
|
||||
"name": "@paperclipai/plugin-exe-dev",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/e2b",
|
||||
"name": "@paperclipai/plugin-e2b",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { JsonSchemaForm } from "./JsonSchemaForm";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("JsonSchemaForm secret-ref rendering", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders multiline secret-ref fields as textareas", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
sshPrivateKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
maxLength: 4096,
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ sshPrivateKey: "secret" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector("textarea")).not.toBeNull();
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -478,6 +478,7 @@ const SecretField = React.memo(({
|
||||
description,
|
||||
error,
|
||||
defaultValue,
|
||||
maxLength,
|
||||
}: {
|
||||
value: unknown;
|
||||
onChange: (val: unknown) => void;
|
||||
@@ -487,8 +488,10 @@ const SecretField = React.memo(({
|
||||
description?: string;
|
||||
error?: string;
|
||||
defaultValue?: unknown;
|
||||
maxLength?: number;
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
|
||||
return (
|
||||
<FieldWrapper
|
||||
label={label}
|
||||
@@ -500,34 +503,83 @@ const SecretField = React.memo(({
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isTextArea ? (
|
||||
<div className="relative">
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
// Render a placeholder summary instead of the secret content while
|
||||
// hidden. This avoids exposing multi-line secrets (e.g. SSH
|
||||
// private keys) on screen-shares; clicking the eye toggle reveals
|
||||
// the editable textarea above.
|
||||
value={
|
||||
String(value ?? "").length === 0
|
||||
? ""
|
||||
: `Sensitive — ${String(value ?? "").length} characters hidden. Click the eye to reveal.`
|
||||
}
|
||||
readOnly
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FieldWrapper>
|
||||
);
|
||||
});
|
||||
@@ -885,6 +937,7 @@ const FormField = React.memo(({
|
||||
description={propSchema.description}
|
||||
error={error}
|
||||
defaultValue={propSchema.default}
|
||||
maxLength={typeof propSchema.maxLength === "number" ? propSchema.maxLength : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user