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:
Devin Foley
2026-06-18 07:45:53 -07:00
committed by GitHub
parent d47b4da655
commit 93291df5c8
12 changed files with 180 additions and 34 deletions
+2 -1
View File
@@ -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 {
+82 -25
View File
@@ -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 });
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);
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;
}
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
return { linked, skipped };
}
const relativeSdkDir = relative(scopeDir, sdkDir);
symlinkSync(relativeSdkDir, linkTarget, "dir");
// 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})`);
}
console.log(` ✓ Linked local @paperclipai/plugin-sdk for ${packageDir}`);
// 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;
}
+89
View File
@@ -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));
});