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>
90 lines
3.3 KiB
JavaScript
90 lines
3.3 KiB
JavaScript
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));
|
|
});
|