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>
152 lines
4.1 KiB
JavaScript
152 lines
4.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
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");
|
|
const releasePackageMapPath = path.join(repoRoot, "scripts", "release-package-map.mjs");
|
|
|
|
function parseWorkspaceEntries(workspaceText) {
|
|
// Keep this aligned with the repo's block-sequence `packages:` format in
|
|
// pnpm-workspace.yaml. If that file moves to a more complex YAML shape,
|
|
// switch this parser to a real YAML parser instead of line matching.
|
|
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)
|
|
.map((entry) => ({
|
|
pattern: entry.startsWith("!") ? entry.slice(1) : entry,
|
|
negated: entry.startsWith("!"),
|
|
}));
|
|
}
|
|
|
|
function globToRegExp(pattern) {
|
|
let regex = "";
|
|
|
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
const char = pattern[index];
|
|
const next = pattern[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 isWorkspacePackage(pkgDir, workspaceEntries) {
|
|
let included = false;
|
|
|
|
for (const entry of workspaceEntries) {
|
|
if (globToRegExp(entry.pattern).test(pkgDir)) {
|
|
included = !entry.negated;
|
|
}
|
|
}
|
|
|
|
return included;
|
|
}
|
|
|
|
function listPublicPackages() {
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
[releasePackageMapPath, "list"],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
|
|
return output
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [dir, name] = line.split("\t");
|
|
return { dir, name };
|
|
});
|
|
}
|
|
|
|
function readPackageJson(pkgDir) {
|
|
return JSON.parse(
|
|
readFileSync(path.join(repoRoot, pkgDir, "package.json"), "utf8"),
|
|
);
|
|
}
|
|
|
|
function run(command, args, cwd) {
|
|
execFileSync(command, args, {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
CI: "true",
|
|
},
|
|
stdio: "inherit",
|
|
});
|
|
}
|
|
|
|
function main() {
|
|
const workspaceEntries = parseWorkspaceEntries(readFileSync(workspacePath, "utf8"));
|
|
const standalonePackages = listPublicPackages()
|
|
.filter(({ dir }) => !isWorkspacePackage(dir, workspaceEntries));
|
|
|
|
if (standalonePackages.length === 0) {
|
|
console.log(" i No standalone public packages detected outside the pnpm workspace");
|
|
return;
|
|
}
|
|
|
|
for (const pkg of standalonePackages) {
|
|
const pkgDir = path.join(repoRoot, pkg.dir);
|
|
const pkgJson = readPackageJson(pkg.dir);
|
|
const nodeModulesDir = path.join(pkgDir, "node_modules");
|
|
const packageLockfilePath = path.join(pkgDir, "pnpm-lock.yaml");
|
|
|
|
console.log(` Preparing standalone package ${pkg.name} (${pkg.dir})`);
|
|
if (existsSync(nodeModulesDir)) {
|
|
rmSync(nodeModulesDir, { force: true, recursive: true });
|
|
}
|
|
|
|
const installArgs = existsSync(packageLockfilePath)
|
|
? ["install", "--ignore-workspace", "--frozen-lockfile"]
|
|
: [
|
|
"install",
|
|
"--ignore-workspace",
|
|
"--no-lockfile",
|
|
// Standalone packages intentionally avoid committed lockfile churn in the repo.
|
|
];
|
|
|
|
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 {
|
|
console.log(" i No build script; skipped build");
|
|
}
|
|
}
|
|
}
|
|
|
|
main();
|