Files
paperclip/scripts/check-docker-deps-stage.mjs
Devin Foley 4ef969f084 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
2026-04-25 11:01:11 -07:00

177 lines
4.6 KiB
JavaScript

#!/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();