mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add E2B sandbox provider plugin (#4452)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Sandbox environments are part of that execution layer, and the recent core refactor moved provider-specific behavior to a generic plugin seam > - This pull request adds a dedicated `@paperclipai/plugin-e2b` package so E2B can live entirely outside core host code > - Because the feature is still unreleased, the plugin should model third-party packaging directly instead of carrying extra backward-compatibility complexity in core or the workspace lockfile > - This branch therefore makes the E2B provider a standalone publishable package, documents the package-local dev flow, and keeps the publish manifest/runtime dependency story correct > - The benefit is that E2B becomes a true plugin reference implementation that can be installed by package name without reopening core Paperclip code ## What Changed - Added `packages/plugins/paperclip-plugin-e2b` as the E2B sandbox provider plugin package - Implemented config validation, lease acquire/resume/release/destroy handlers, workspace realization, and command execution for E2B sandboxes - Excluded the E2B plugin package from the root workspace so the repo no longer needs `pnpm-lock.yaml` churn for its third-party dependency graph - Added package-local development/install support plus a prepack manifest generator so the published tarball still declares `@paperclipai/plugin-sdk` and `e2b` runtime dependencies - Addressed review feedback by fixing sandbox cleanup on acquire failures, rejecting blank templates, normalizing fractional `timeoutMs`, and always passing the configured template name to the E2B SDK - Updated focused Vitest coverage for config normalization, validation, acquire cleanup, command execution, and lease release behavior - Updated the Dockerfile deps stage to copy the E2B package manifest so the policy check stays in sync ## Verification - `cd packages/plugins/paperclip-plugin-e2b && pnpm install --ignore-workspace --no-lockfile` - `cd packages/plugins/paperclip-plugin-e2b && pnpm build` - `cd packages/plugins/paperclip-plugin-e2b && pnpm --ignore-workspace test` - `cd packages/plugins/paperclip-plugin-e2b && pnpm --ignore-workspace typecheck` - `cd packages/plugins/paperclip-plugin-e2b && npm pack --dry-run` ## Risks - The package now relies on a prepack manifest rewrite so the publish-time dependency list stays correct while the repo-local dev manifest stays workspace-light - The current repo snapshot is still unreleased, so the generated publish manifest points at the repo SDK version until the normal release flow rewrites versions before publish - Real-world E2B environments may still expose edge cases around lifecycle timing or sandbox metadata beyond the mocked unit coverage > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex via `codex_local` - Model ID: `gpt-5.4` - Reasoning effort: `high` - Context window observed in runtime session metadata: `258400` tokens - Capabilities used: terminal tool execution, git, GitHub CLI, and local build/test inspection ## 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 - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
5bd0f578fd
commit
4ef969f084
16 changed files with 1279 additions and 38 deletions
176
scripts/check-docker-deps-stage.mjs
Normal file
176
scripts/check-docker-deps-stage.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const dockerfilePath = path.join(repoRoot, "Dockerfile");
|
||||
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
|
||||
|
||||
function extractDepsStage(dockerfileText) {
|
||||
const lines = dockerfileText.split("\n");
|
||||
const captured = [];
|
||||
let inDeps = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!inDeps) {
|
||||
if (/^FROM .* AS deps$/i.test(line.trim())) inDeps = true;
|
||||
continue;
|
||||
}
|
||||
if (/^FROM /i.test(line.trim())) break;
|
||||
captured.push(line);
|
||||
}
|
||||
|
||||
return captured.join("\n");
|
||||
}
|
||||
|
||||
function parseWorkspaceRoots(workspaceText) {
|
||||
return workspaceText
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
|
||||
.map((entry) => {
|
||||
if (!entry) return entry;
|
||||
return entry.replace(/^(['"])(.*)\1$/, "$2");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((entry) => !entry.startsWith("!"))
|
||||
.map((entry) => entry.replace(/\*+$/, ""))
|
||||
.filter((entry) => entry.length > 0)
|
||||
.filter((entry) => !entry.includes("examples"))
|
||||
.filter((entry) => !entry.includes("create-paperclip-plugin"));
|
||||
}
|
||||
|
||||
function walkPackageJsonFiles(rootRelative, maxDepth) {
|
||||
const results = [];
|
||||
const rootAbsolute = path.join(repoRoot, rootRelative);
|
||||
|
||||
if (!existsSync(rootAbsolute)) return results;
|
||||
|
||||
function visit(currentAbsolute, depthFromRoot) {
|
||||
const entries = readdirSync(currentAbsolute, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
|
||||
const absolute = path.join(currentAbsolute, entry.name);
|
||||
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (depthFromRoot < maxDepth) visit(absolute, depthFromRoot + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.name === "package.json" &&
|
||||
!relative.includes("/examples/") &&
|
||||
!relative.includes("/create-paperclip-plugin/")
|
||||
) {
|
||||
results.push(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(rootAbsolute, 0);
|
||||
return results;
|
||||
}
|
||||
|
||||
function globToRegExp(pattern) {
|
||||
const normalized = pattern.replace("/./", "/");
|
||||
let regex = "";
|
||||
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const char = normalized[index];
|
||||
const next = normalized[index + 1];
|
||||
|
||||
if (char === "*" && next === "*") {
|
||||
regex += ".*";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "*") {
|
||||
regex += "[^/]*";
|
||||
continue;
|
||||
}
|
||||
if (char === "?") {
|
||||
regex += "[^/]";
|
||||
continue;
|
||||
}
|
||||
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
|
||||
}
|
||||
|
||||
return new RegExp(`^${regex}$`);
|
||||
}
|
||||
|
||||
function parseCopySources(depsStage) {
|
||||
const sources = [];
|
||||
|
||||
for (const rawLine of depsStage.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("COPY ")) continue;
|
||||
|
||||
const tokens = line.split(/\s+/);
|
||||
let index = 1;
|
||||
while (tokens[index]?.startsWith("--")) index += 1;
|
||||
|
||||
const args = tokens.slice(index);
|
||||
if (args.length < 2) continue;
|
||||
|
||||
const lineSources = args.slice(0, -1);
|
||||
for (const source of lineSources) {
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const depsStage = extractDepsStage(readFileSync(dockerfilePath, "utf8"));
|
||||
if (!depsStage.trim()) {
|
||||
console.error("Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps').");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspaceRoots = parseWorkspaceRoots(readFileSync(workspacePath, "utf8"));
|
||||
if (workspaceRoots.length === 0) {
|
||||
console.error("Could not derive workspace roots from pnpm-workspace.yaml.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const requiredPackageJsons = [...new Set(
|
||||
workspaceRoots.flatMap((root) => walkPackageJsonFiles(root, 2)),
|
||||
)].sort();
|
||||
|
||||
const copySources = parseCopySources(depsStage);
|
||||
const copyMatchers = copySources.map((source) => ({
|
||||
source,
|
||||
regex: globToRegExp(source),
|
||||
}));
|
||||
|
||||
let missing = 0;
|
||||
for (const pkg of requiredPackageJsons) {
|
||||
const covered = copyMatchers.some(({ regex }) => regex.test(pkg));
|
||||
if (!covered) {
|
||||
console.error(`Dockerfile deps stage missing package manifest coverage for: ${pkg}`);
|
||||
missing = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(path.join(repoRoot, "patches"))) {
|
||||
const patchesCovered = copySources.includes("patches/");
|
||||
if (!patchesCovered) {
|
||||
console.error("Dockerfile deps stage missing: COPY patches/ patches/");
|
||||
missing = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
console.error("Dockerfile deps stage is out of sync. Update it to cover the missing files.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("PASS");
|
||||
}
|
||||
|
||||
main();
|
||||
46
scripts/generate-plugin-package-json.mjs
Normal file
46
scripts/generate-plugin-package-json.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const packageDir = process.cwd();
|
||||
const packageJsonPath = join(packageDir, "package.json");
|
||||
const sdkPackageJsonPath = join(repoRoot, "packages", "plugins", "sdk", "package.json");
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`No package.json found in plugin directory: ${packageDir}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const sdkPackageJson = JSON.parse(readFileSync(sdkPackageJsonPath, "utf8"));
|
||||
const publishConfig = packageJson.publishConfig ?? {};
|
||||
const dependencies = {
|
||||
...(packageJson.dependencies ?? {}),
|
||||
"@paperclipai/plugin-sdk": sdkPackageJson.version,
|
||||
};
|
||||
|
||||
const publishPackageJson = {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
description: packageJson.description,
|
||||
license: packageJson.license,
|
||||
homepage: packageJson.homepage,
|
||||
bugs: packageJson.bugs,
|
||||
repository: packageJson.repository,
|
||||
type: packageJson.type,
|
||||
exports: publishConfig.exports ?? packageJson.exports,
|
||||
main: publishConfig.main,
|
||||
types: publishConfig.types,
|
||||
publishConfig,
|
||||
files: packageJson.files,
|
||||
paperclipPlugin: packageJson.paperclipPlugin,
|
||||
keywords: packageJson.keywords,
|
||||
dependencies,
|
||||
};
|
||||
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
|
||||
|
||||
console.log(` ✓ Generated publishable plugin package.json for ${packageJson.name}`);
|
||||
35
scripts/link-plugin-dev-sdk.mjs
Normal file
35
scripts/link-plugin-dev-sdk.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, lstatSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const packageDir = process.cwd();
|
||||
const sdkDir = join(repoRoot, "packages", "plugins", "sdk");
|
||||
const scopeDir = join(packageDir, "node_modules", "@paperclipai");
|
||||
const linkTarget = join(scopeDir, "plugin-sdk");
|
||||
|
||||
if (!existsSync(join(packageDir, "package.json"))) {
|
||||
throw new Error(`No package.json found in plugin directory: ${packageDir}`);
|
||||
}
|
||||
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const stat = lstatSync(linkTarget);
|
||||
if (stat.isSymbolicLink()) {
|
||||
rmSync(linkTarget, { force: true });
|
||||
} else {
|
||||
console.log(" i Keeping existing installed @paperclipai/plugin-sdk directory in place");
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// target does not exist yet
|
||||
}
|
||||
|
||||
const relativeSdkDir = relative(scopeDir, sdkDir);
|
||||
symlinkSync(relativeSdkDir, linkTarget, "dir");
|
||||
|
||||
console.log(` ✓ Linked local @paperclipai/plugin-sdk for ${packageDir}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue