93291df5c8
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox-provider plugins (cloudflare, daytona, e2b, exe-dev, kubernetes, modal, novita) and `plugin-workspace-diff` are published as standalone npm packages, but during local dev they need the in-repo `@paperclipai/plugin-sdk` symlinked in > - Each of these plugins shipped a `postinstall` lifecycle script that traversed *out* of its own package directory (`node ../../../../scripts/link-plugin-dev-sdk.mjs`) to do that linking > - The publishable manifest is built by a `prepack` whitelist that drops the `scripts` field, so npm consumers don't see the postinstall today — but that safety property depends entirely on `prepack` running on every publish. A publish that skips lifecycle scripts would ship a tarball whose postinstall escapes its package directory at consumer install time > - This pull request removes the escape-the-package-dir lifecycle script from every plugin source manifest and moves the dev linking to a single root-level postinstall that iterates the excluded plugin directories itself > - The benefit is that plugin tarballs can no longer carry an install-time script that reaches outside their own directory, regardless of whether `prepack` runs ## Linked Issues or Issue Description This is a follow-up hardening change flagged during review of the Novita sandbox provider PR (#7595). **Problem (security):** Excluded plugin packages each carried `"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs"`. The relative path traverses outside the package root. Today the published manifest is sanitized by a `prepack` whitelist that drops `scripts`, so consumers are unaffected in the normal publish path. The risk is that this is a defense-in-depth gap: if a publish ever skips lifecycle scripts (e.g. `npm publish --ignore-scripts` is *not* used, or a tool publishes the raw manifest), the tarball would ship a postinstall that runs out-of-tree code at the consumer's install time. ## What Changed - Added a single root `package.json` `postinstall`: `node scripts/link-plugin-dev-sdk.mjs`. - Rewrote `scripts/link-plugin-dev-sdk.mjs` to iterate the excluded plugin directories itself (`packages/plugins/sandbox-providers/*` + the orchestration smoke example) instead of relying on each plugin to invoke it from its own cwd. Preserves both prior behaviors: leave a real installed SDK dir alone, and skip when already correctly symlinked (idempotent). - Removed `scripts.postinstall` from all 7 sandbox-provider plugins (cloudflare, daytona, e2b, exe-dev, kubernetes, modal, novita). - Removed `scripts.postinstall` from `plugin-workspace-diff` (a pnpm workspace member — pnpm already links the SDK, so the script was a no-op there). ## Verification - `node scripts/link-plugin-dev-sdk.mjs` from repo root: links the SDK into the excluded plugins and reports skipped (already-linked) dirs; re-running is idempotent. - `grep -r "link-plugin-dev-sdk" packages/plugins/*/package.json packages/plugins/sandbox-providers/*/package.json` returns no matches — no plugin source manifest references the linker any longer. - All affected `package.json` files re-validated as parseable JSON. ## Risks Low risk. Dev-only tooling: the linker only runs at the repo root during local install and only touches `node_modules/@paperclipai/plugin-sdk` symlinks inside excluded plugin dirs. No change to published plugin behavior or runtime code. Worst case if the root postinstall failed to run, local dev of an excluded plugin would not find the SDK symlink — easily re-run manually. ## Model Used Claude Opus (claude-opus-4-8), extended reasoning, with tool use / code execution in an agentic coding harness. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable (added `scripts/link-plugin-dev-sdk.test.js`, wired into `test:release-registry`) - [ ] If this change affects the UI, I have included before/after screenshots (N/A — no UI change) - [ ] I have updated relevant documentation to reflect my changes (N/A) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
99 lines
3.8 KiB
JavaScript
99 lines
3.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Dev-only: link the in-repo @paperclipai/plugin-sdk into plugin packages that
|
|
// are excluded from the pnpm workspace (see pnpm-workspace.yaml). Workspace
|
|
// members get their SDK link from pnpm automatically; excluded packages do not.
|
|
//
|
|
// Invoked from the root postinstall. Intentionally NOT referenced from any
|
|
// plugin's own package.json so the published tarballs cannot carry a lifecycle
|
|
// script that escapes their package directory at install time.
|
|
|
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, 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 sdkDir = join(repoRoot, "packages", "plugins", "sdk");
|
|
|
|
// Plugin packages excluded from the workspace that still need @paperclipai/plugin-sdk
|
|
// linked in for local dev. Keep in sync with pnpm-workspace.yaml exclusions.
|
|
function excludedPluginDirs() {
|
|
return [
|
|
...readPluginsUnder(join(repoRoot, "packages", "plugins", "sandbox-providers")),
|
|
join(repoRoot, "packages", "plugins", "examples", "plugin-orchestration-smoke-example"),
|
|
];
|
|
}
|
|
|
|
export function linkExcludedPlugins() {
|
|
let linked = 0;
|
|
let skipped = 0;
|
|
for (const packageDir of excludedPluginDirs()) {
|
|
if (!existsSync(join(packageDir, "package.json"))) continue;
|
|
if (linkSdkInto(packageDir)) {
|
|
linked += 1;
|
|
} else {
|
|
skipped += 1;
|
|
}
|
|
}
|
|
return { linked, skipped };
|
|
}
|
|
|
|
// Run as a CLI (root postinstall) when invoked directly, but stay importable so
|
|
// repo-internal scripts (e.g. the standalone package builder) can relink a
|
|
// single package after a fresh install.
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const { linked, skipped } = linkExcludedPlugins();
|
|
console.log(` ✓ Linked @paperclipai/plugin-sdk into ${linked} excluded plugin(s) (skipped ${skipped})`);
|
|
}
|
|
|
|
// Recursively collect package directories (those containing a package.json)
|
|
// under parentDir. The matching pnpm-workspace.yaml exclusion uses a recursive
|
|
// glob (e.g. "!packages/plugins/sandbox-providers/**"), so a provider nested
|
|
// deeper than one level must still be discovered here.
|
|
export function readPluginsUnder(parentDir) {
|
|
if (!existsSync(parentDir)) return [];
|
|
const found = [];
|
|
for (const entry of readdirSync(parentDir, { withFileTypes: true })) {
|
|
// Skip symlinked directories so discovery can't be steered outside the
|
|
// intended plugin subtree, and skip node_modules.
|
|
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "node_modules") continue;
|
|
const childDir = join(parentDir, entry.name);
|
|
if (existsSync(join(childDir, "package.json"))) {
|
|
found.push(childDir);
|
|
} else {
|
|
found.push(...readPluginsUnder(childDir));
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function linkSdkInto(packageDir) {
|
|
const scopeDir = join(packageDir, "node_modules", "@paperclipai");
|
|
const linkTarget = join(scopeDir, "plugin-sdk");
|
|
const relativeSdkDir = relative(scopeDir, sdkDir);
|
|
|
|
mkdirSync(scopeDir, { recursive: true });
|
|
|
|
try {
|
|
const stat = lstatSync(linkTarget);
|
|
if (stat.isSymbolicLink()) {
|
|
if (readlinkSync(linkTarget) === relativeSdkDir) {
|
|
// Already linked to the in-repo SDK; nothing to do.
|
|
return false;
|
|
}
|
|
rmSync(linkTarget, { force: true });
|
|
} else {
|
|
// A real install has already populated @paperclipai/plugin-sdk (e.g. the
|
|
// plugin host did `npm install` of the published tarball). Leave it.
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
// A missing target is expected (nothing linked yet); surface anything else.
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
|
|
symlinkSync(relativeSdkDir, linkTarget, "dir");
|
|
return true;
|
|
}
|