mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
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:
parent
74cb560c41
commit
5a64cf52a1
12 changed files with 1994 additions and 25 deletions
697
packages/plugins/sandbox-providers/exe-dev/src/plugin.test.ts
Normal file
697
packages/plugins/sandbox-providers/exe-dev/src/plugin.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue