fix(plugins): move dev SDK linking out of plugin postinstall scripts (#8255)
## 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>
This commit is contained in:
+2
-1
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/link-plugin-dev-sdk.mjs",
|
||||
"preflight:workspace-links": "node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts",
|
||||
"dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
|
||||
"dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch",
|
||||
@@ -42,7 +43,7 @@
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/link-plugin-dev-sdk.test.js",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
||||
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"diff"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"cloudflare"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"daytona"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"e2b"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"exe.dev"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"kubernetes"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"modal"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"novita-ai"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
@@ -6,6 +6,8 @@ import path, { dirname } from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { linkSdkInto } from "./link-plugin-dev-sdk.mjs";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
|
||||
@@ -133,6 +135,11 @@ function main() {
|
||||
|
||||
run("pnpm", installArgs, pkgDir);
|
||||
|
||||
// The fresh install above wipes node_modules and no longer fires a
|
||||
// per-plugin postinstall (removed for supply-chain safety), so link the
|
||||
// in-repo @paperclipai/plugin-sdk that the build's tsc resolves against.
|
||||
linkSdkInto(pkgDir);
|
||||
|
||||
if (pkgJson.scripts?.build) {
|
||||
run("pnpm", ["run", "build"], pkgDir);
|
||||
} else {
|
||||
|
||||
@@ -1,41 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
||||
// 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 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}`);
|
||||
// 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"),
|
||||
];
|
||||
}
|
||||
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
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 };
|
||||
}
|
||||
|
||||
try {
|
||||
// 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()) {
|
||||
const existingTarget = readlinkSync(linkTarget);
|
||||
const relativeSdkDir = relative(scopeDir, sdkDir);
|
||||
if (existingTarget === relativeSdkDir) {
|
||||
console.log(` ✓ Local @paperclipai/plugin-sdk already linked for ${packageDir}`);
|
||||
process.exit(0);
|
||||
if (readlinkSync(linkTarget) === relativeSdkDir) {
|
||||
// Already linked to the in-repo SDK; nothing to do.
|
||||
return false;
|
||||
}
|
||||
rmSync(linkTarget, { force: true });
|
||||
} else {
|
||||
console.log(" i Keeping existing installed @paperclipai/plugin-sdk directory in place");
|
||||
process.exit(0);
|
||||
// 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 {
|
||||
// target does not exist yet
|
||||
} 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;
|
||||
}
|
||||
|
||||
const relativeSdkDir = relative(scopeDir, sdkDir);
|
||||
symlinkSync(relativeSdkDir, linkTarget, "dir");
|
||||
|
||||
console.log(` ✓ Linked local @paperclipai/plugin-sdk for ${packageDir}`);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { linkSdkInto, readPluginsUnder } from "./link-plugin-dev-sdk.mjs";
|
||||
|
||||
let workDir;
|
||||
|
||||
before(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "link-plugin-dev-sdk-"));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
rmSync(workDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function makePackage(dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "package.json"), "{}\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("readPluginsUnder returns [] for a missing directory", () => {
|
||||
assert.deepEqual(readPluginsUnder(join(workDir, "does-not-exist")), []);
|
||||
});
|
||||
|
||||
test("readPluginsUnder finds first-level package directories", () => {
|
||||
const parent = join(workDir, "first-level");
|
||||
const a = makePackage(join(parent, "a"));
|
||||
const b = makePackage(join(parent, "b"));
|
||||
|
||||
assert.deepEqual(readPluginsUnder(parent).sort(), [a, b].sort());
|
||||
});
|
||||
|
||||
test("readPluginsUnder recurses into directories that are not themselves packages", () => {
|
||||
// Mirrors the recursive pnpm-workspace exclusion glob: a provider nested
|
||||
// deeper than one level must still be discovered.
|
||||
const parent = join(workDir, "nested");
|
||||
const nested = makePackage(join(parent, "vendor", "my-plugin"));
|
||||
|
||||
assert.deepEqual(readPluginsUnder(parent), [nested]);
|
||||
});
|
||||
|
||||
test("readPluginsUnder stops descending once a package.json is found and skips node_modules", () => {
|
||||
const parent = join(workDir, "boundaries");
|
||||
const pkg = makePackage(join(parent, "plugin"));
|
||||
// A nested package inside an already-matched package must not be returned.
|
||||
makePackage(join(pkg, "sub-package"));
|
||||
// node_modules must be ignored entirely.
|
||||
makePackage(join(parent, "node_modules", "some-dep"));
|
||||
|
||||
assert.deepEqual(readPluginsUnder(parent), [pkg]);
|
||||
});
|
||||
|
||||
test("linkSdkInto creates the plugin-sdk symlink and is idempotent", () => {
|
||||
const pkg = makePackage(join(workDir, "link-target"));
|
||||
|
||||
assert.equal(linkSdkInto(pkg), true);
|
||||
|
||||
const link = join(pkg, "node_modules", "@paperclipai", "plugin-sdk");
|
||||
assert.ok(lstatSync(link).isSymbolicLink());
|
||||
|
||||
// Second call is a no-op because the link already points at the in-repo SDK.
|
||||
assert.equal(linkSdkInto(pkg), false);
|
||||
});
|
||||
|
||||
test("linkSdkInto leaves a real (non-symlink) install in place", () => {
|
||||
const pkg = makePackage(join(workDir, "real-install"));
|
||||
const scopeDir = join(pkg, "node_modules", "@paperclipai");
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
// Simulate a published-tarball install: a real directory, not a symlink.
|
||||
makePackage(join(scopeDir, "plugin-sdk"));
|
||||
|
||||
assert.equal(linkSdkInto(pkg), false);
|
||||
assert.ok(!lstatSync(join(scopeDir, "plugin-sdk")).isSymbolicLink());
|
||||
});
|
||||
|
||||
test("linkSdkInto replaces a symlink that points somewhere else", () => {
|
||||
const pkg = makePackage(join(workDir, "stale-link"));
|
||||
const scopeDir = join(pkg, "node_modules", "@paperclipai");
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
symlinkSync("../somewhere-else", join(scopeDir, "plugin-sdk"), "dir");
|
||||
|
||||
assert.equal(linkSdkInto(pkg), true);
|
||||
assert.notEqual(readlinkSync(join(scopeDir, "plugin-sdk")), "../somewhere-else");
|
||||
assert.ok(existsSync(scopeDir));
|
||||
});
|
||||
Reference in New Issue
Block a user