mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 12:30:38 +09:00
## Thinking Path
> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.
## What Changed
- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.
## Verification
CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.
Run locally:
```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.
# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```
Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):
1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).
## Risks
- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination
## Checklist
- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
963 lines
31 KiB
TypeScript
963 lines
31 KiB
TypeScript
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { spawn } from "node:child_process";
|
|
import { definePlugin } from "@paperclipai/plugin-sdk";
|
|
import type {
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentDestroyLeaseParams,
|
|
PluginEnvironmentExecuteParams,
|
|
PluginEnvironmentExecuteResult,
|
|
PluginEnvironmentLease,
|
|
PluginEnvironmentProbeParams,
|
|
PluginEnvironmentProbeResult,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentRealizeWorkspaceResult,
|
|
PluginEnvironmentReleaseLeaseParams,
|
|
PluginEnvironmentResumeLeaseParams,
|
|
PluginEnvironmentValidateConfigParams,
|
|
PluginEnvironmentValidationResult,
|
|
} from "@paperclipai/plugin-sdk";
|
|
|
|
interface ExeDevDriverConfig {
|
|
apiKey: string | null;
|
|
apiUrl: string;
|
|
namePrefix: string;
|
|
image: string | null;
|
|
command: string | null;
|
|
cpu: number | null;
|
|
memory: string | null;
|
|
disk: string | null;
|
|
comment: string | null;
|
|
env: Record<string, string>;
|
|
integrations: string[];
|
|
tags: string[];
|
|
setupScript: string | null;
|
|
prompt: string | null;
|
|
timeoutMs: number;
|
|
reuseLease: boolean;
|
|
sshUser: string | null;
|
|
sshPrivateKey: string | null;
|
|
sshIdentityFile: string | null;
|
|
sshPort: number;
|
|
strictHostKeyChecking: string;
|
|
}
|
|
|
|
interface ExeDevVmRecord {
|
|
name: string;
|
|
sshDest: string;
|
|
httpsUrl: string | null;
|
|
status: string | null;
|
|
region: string | null;
|
|
regionDisplay: string | null;
|
|
}
|
|
|
|
interface SshExecutionResult {
|
|
exitCode: number | null;
|
|
signal: string | null;
|
|
timedOut: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
const DEFAULT_API_URL = "https://exe.dev/exec";
|
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
const EXE_DEV_API_MAX_TIMEOUT_MS = 29_000;
|
|
const SSH_SIGKILL_GRACE_MS = 250;
|
|
const MAX_VM_RECORD_DEPTH = 4;
|
|
const EXE_DEV_SSH_ONBOARDING_MARKER = "Please complete registration by running: ssh exe.dev";
|
|
const EXE_DEV_SSH_EMAIL_PROMPT = "Please enter your email address:";
|
|
const EXE_DEV_SSH_INVALID_KEY_FORMAT = /Load key [^\n]*invalid format/i;
|
|
const UUID_SECRET_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
// exe.dev's `--setup-script` runs at VM init as the unprivileged `exedev` user, which
|
|
// has passwordless sudo. The Paperclip sandbox callback bridge is a Node script, so
|
|
// every Paperclip workload on this provider needs node on PATH before the bridge can
|
|
// start. When the operator hasn't supplied their own setup script, install Node 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 isSecretRef(value: string): boolean {
|
|
return UUID_SECRET_REF_RE.test(value);
|
|
}
|
|
|
|
// Catch the SSH-key paste failure modes we've seen in the wild (wrong file,
|
|
// PPK export, truncated paste) before the user pays the cost of provisioning a
|
|
// VM and getting a cryptic SSH error. Inline parse — no `ssh-keygen` dependency
|
|
// — so this also works on hosts where openssh-client isn't installed.
|
|
export function validateSshPrivateKey(rawKey: string): string | null {
|
|
const trimmed = rawKey.trim();
|
|
if (!trimmed) return null;
|
|
|
|
if (/^PuTTY-User-Key-File-\d/m.test(trimmed)) {
|
|
return "sshPrivateKey looks like a PuTTY .ppk file. Convert it to OpenSSH format (PuTTYgen → Conversions → Export OpenSSH key) and paste the resulting PEM.";
|
|
}
|
|
|
|
if (
|
|
/^(?:ssh-(?:rsa|dss|ed25519)|ecdsa-sha2-[a-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-[a-z0-9-]+)@openssh\.com)\s+\S/.test(
|
|
trimmed,
|
|
)
|
|
) {
|
|
return "sshPrivateKey looks like a PUBLIC key. Paste the matching private key (the file without the .pub extension).";
|
|
}
|
|
|
|
const headerMatch = trimmed.match(/^-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/m);
|
|
if (!headerMatch) {
|
|
return "sshPrivateKey must be a PEM-encoded private key starting with a line like '-----BEGIN OPENSSH PRIVATE KEY-----'.";
|
|
}
|
|
|
|
const footerMatch = trimmed.match(/^-----END ([A-Z0-9 ]*)PRIVATE KEY-----\s*$/m);
|
|
if (!footerMatch) {
|
|
return "sshPrivateKey is missing its '-----END … PRIVATE KEY-----' footer. Make sure you copied the whole file, including the final line.";
|
|
}
|
|
|
|
const headerLabel = headerMatch[1].trim();
|
|
const footerLabel = footerMatch[1].trim();
|
|
if (headerLabel !== footerLabel) {
|
|
return `sshPrivateKey header/footer mismatch (BEGIN ${headerLabel || "(none)"} vs END ${footerLabel || "(none)"}). The file is likely truncated or two keys are concatenated.`;
|
|
}
|
|
|
|
const headerLineEnd = trimmed.indexOf("\n", headerMatch.index ?? 0);
|
|
const footerStart = trimmed.lastIndexOf(footerMatch[0]);
|
|
if (headerLineEnd < 0 || footerStart <= headerLineEnd) {
|
|
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
|
|
}
|
|
|
|
const bodyLines = trimmed
|
|
.slice(headerLineEnd + 1, footerStart)
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
if (bodyLines.length === 0) {
|
|
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
|
|
}
|
|
|
|
// PEM bodies are base64 lines, optionally preceded by `Header: value` lines
|
|
// on encrypted PKCS#1 keys (`Proc-Type:`, `DEK-Info:`).
|
|
const base64Line = /^[A-Za-z0-9+/=]+$/;
|
|
const pemHeaderLine = /^[A-Za-z][A-Za-z0-9-]*:\s.+$/;
|
|
for (const line of bodyLines) {
|
|
if (!base64Line.test(line) && !pemHeaderLine.test(line)) {
|
|
return "sshPrivateKey body contains non-base64 characters. The key may have been corrupted by line-wrapping or copy-paste.";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeApiUrl(value: string | null): string {
|
|
if (!value) return DEFAULT_API_URL;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return DEFAULT_API_URL;
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
const normalizedPath = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
if (normalizedPath === "/exec") {
|
|
parsed.pathname = "/exec";
|
|
return parsed.toString();
|
|
}
|
|
parsed.pathname = `${normalizedPath === "/" ? "" : normalizedPath}/exec`.replace(/\/{2,}/g, "/");
|
|
return parsed.toString();
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
function normalizeNamePrefix(value: string | null): string {
|
|
const normalized = (value ?? "paperclip")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.replace(/-{2,}/g, "-");
|
|
return normalized.length > 0 ? normalized.slice(0, 24) : "paperclip";
|
|
}
|
|
|
|
function parseDriverConfig(raw: Record<string, unknown>): ExeDevDriverConfig {
|
|
const timeoutMs = Number(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
const sshPort = Number(raw.sshPort ?? 22);
|
|
|
|
return {
|
|
apiKey: parseOptionalString(raw.apiKey),
|
|
apiUrl: normalizeApiUrl(parseOptionalString(raw.apiUrl)),
|
|
namePrefix: normalizeNamePrefix(parseOptionalString(raw.namePrefix)),
|
|
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(" ");
|
|
}
|
|
|
|
if (EXE_DEV_SSH_INVALID_KEY_FORMAT.test(combinedOutput)) {
|
|
return [
|
|
`Failed to ${action} exe.dev VM ${vmName}: the configured SSH private key isn't an OpenSSH-format private key.`,
|
|
"Confirm the secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub` file or a PuTTY `.ppk` export.",
|
|
].join(" ");
|
|
}
|
|
|
|
return `Failed to ${action} exe.dev VM ${vmName}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`;
|
|
}
|
|
|
|
async function runSshCommand(
|
|
config: ExeDevDriverConfig,
|
|
vm: ExeDevVmRecord,
|
|
remoteCommand: string,
|
|
options: { stdin?: string; timeoutMs?: number } = {},
|
|
): Promise<SshExecutionResult> {
|
|
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
|
|
const identity = await prepareSshIdentity(config);
|
|
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
const child = spawn("ssh", buildSshArgs(config, vm, remoteCommand, identity.sshIdentityFile), {
|
|
stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let timedOut = false;
|
|
let killTimer: NodeJS.Timeout | null = null;
|
|
const timer = timeoutMs > 0
|
|
? setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
killTimer = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
}, SSH_SIGKILL_GRACE_MS);
|
|
}, timeoutMs)
|
|
: null;
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
child.on("error", (error) => {
|
|
if (timer) clearTimeout(timer);
|
|
if (killTimer) clearTimeout(killTimer);
|
|
reject(error);
|
|
});
|
|
child.on("close", (code, signal) => {
|
|
if (timer) clearTimeout(timer);
|
|
if (killTimer) clearTimeout(killTimer);
|
|
resolve({
|
|
exitCode: timedOut ? null : code,
|
|
signal,
|
|
timedOut,
|
|
stdout,
|
|
stderr,
|
|
});
|
|
});
|
|
|
|
if (options.stdin != null && child.stdin) {
|
|
child.stdin.write(options.stdin);
|
|
child.stdin.end();
|
|
}
|
|
});
|
|
} finally {
|
|
await identity.cleanup();
|
|
}
|
|
}
|
|
|
|
async function detectRemoteContext(
|
|
config: ExeDevDriverConfig,
|
|
vm: ExeDevVmRecord,
|
|
): Promise<{ homeDir: string; shellCommand: "bash" | "sh" }> {
|
|
const result = await runSshCommand(
|
|
config,
|
|
vm,
|
|
`sh -lc ${shellQuote(
|
|
'home="${HOME:-}"; if [ -z "$home" ]; then home="$(pwd)"; fi; if command -v bash >/dev/null 2>&1; then shell=bash; else shell=sh; fi; printf "%s\\n%s\\n" "$home" "$shell"',
|
|
)}`,
|
|
);
|
|
if (result.timedOut || result.exitCode !== 0) {
|
|
throw new Error(formatSshFailure("inspect", vm.name, result));
|
|
}
|
|
|
|
const [homeDirRaw, shellRaw] = result.stdout.split(/\r?\n/);
|
|
const homeDir = homeDirRaw?.trim() || "/tmp";
|
|
return {
|
|
homeDir,
|
|
shellCommand: shellRaw?.trim() === "bash" ? "bash" : "sh",
|
|
};
|
|
}
|
|
|
|
async function ensureRemoteWorkspace(
|
|
config: ExeDevDriverConfig,
|
|
vm: ExeDevVmRecord,
|
|
remoteCwd: string,
|
|
): Promise<void> {
|
|
const result = await runSshCommand(
|
|
config,
|
|
vm,
|
|
`sh -lc ${shellQuote(`mkdir -p ${shellQuote(remoteCwd)}`)}`,
|
|
);
|
|
if (result.timedOut || result.exitCode !== 0) {
|
|
throw new Error(formatSshFailure("create workspace for", vm.name, result));
|
|
}
|
|
}
|
|
|
|
async function buildLease(
|
|
config: ExeDevDriverConfig,
|
|
vm: ExeDevVmRecord,
|
|
requestedCwd: string | undefined,
|
|
resumedLease: boolean,
|
|
): Promise<PluginEnvironmentLease> {
|
|
const remote = await detectRemoteContext(config, vm);
|
|
const remoteCwd = requestedCwd?.trim() || path.posix.join(remote.homeDir, "paperclip-workspace");
|
|
await ensureRemoteWorkspace(config, vm, remoteCwd);
|
|
|
|
return {
|
|
providerLeaseId: vm.name,
|
|
metadata: {
|
|
provider: "exe-dev",
|
|
vmName: vm.name,
|
|
sshDest: vm.sshDest,
|
|
httpsUrl: vm.httpsUrl,
|
|
region: vm.region,
|
|
regionDisplay: vm.regionDisplay,
|
|
shellCommand: remote.shellCommand,
|
|
remoteCwd,
|
|
timeoutMs: config.timeoutMs,
|
|
reuseLease: config.reuseLease,
|
|
resumedLease,
|
|
},
|
|
};
|
|
}
|
|
|
|
function metadataVmRecord(params: {
|
|
providerLeaseId: string | null;
|
|
leaseMetadata?: Record<string, unknown> | null;
|
|
}): ExeDevVmRecord | null {
|
|
if (!params.providerLeaseId) return null;
|
|
const sshDest = parseOptionalString(params.leaseMetadata?.sshDest) ?? `${params.providerLeaseId}.exe.xyz`;
|
|
return {
|
|
name: params.providerLeaseId,
|
|
sshDest,
|
|
httpsUrl: parseOptionalString(params.leaseMetadata?.httpsUrl),
|
|
status: parseOptionalString(params.leaseMetadata?.status),
|
|
region: parseOptionalString(params.leaseMetadata?.region),
|
|
regionDisplay: parseOptionalString(params.leaseMetadata?.regionDisplay),
|
|
};
|
|
}
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
ctx.logger.info("exe.dev sandbox provider plugin ready");
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "exe.dev sandbox provider plugin healthy" };
|
|
},
|
|
|
|
async onEnvironmentValidateConfig(
|
|
params: PluginEnvironmentValidateConfigParams,
|
|
): Promise<PluginEnvironmentValidationResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
if (config.apiUrl && !isValidUrl(config.apiUrl)) {
|
|
errors.push("apiUrl must be a valid URL.");
|
|
}
|
|
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
|
|
errors.push("timeoutMs must be between 1 and 86400000.");
|
|
}
|
|
if (config.cpu != null && config.cpu <= 0) {
|
|
errors.push("cpu must be greater than 0 when provided.");
|
|
}
|
|
if (config.sshPort < 1 || config.sshPort > 65_535) {
|
|
errors.push("sshPort must be between 1 and 65535.");
|
|
}
|
|
if (!config.apiKey && !(process.env.EXE_API_KEY?.trim())) {
|
|
errors.push("exe.dev environments require an API key in config or EXE_API_KEY.");
|
|
}
|
|
for (const key of Object.keys(config.env)) {
|
|
if (!isValidShellEnvKey(key)) {
|
|
errors.push(`env contains an invalid key: ${key}`);
|
|
}
|
|
}
|
|
if (
|
|
typeof params.config.strictHostKeyChecking === "string" &&
|
|
params.config.strictHostKeyChecking.trim().length === 0
|
|
) {
|
|
errors.push("strictHostKeyChecking cannot be empty.");
|
|
}
|
|
if (config.sshPrivateKey && !isSecretRef(config.sshPrivateKey)) {
|
|
const sshKeyError = validateSshPrivateKey(config.sshPrivateKey);
|
|
if (sshKeyError) errors.push(sshKeyError);
|
|
}
|
|
|
|
warnings.push(
|
|
"The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning.",
|
|
);
|
|
if (config.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;
|