Files
openclaw/src/plugins/install-paths.ts
Peter Steinberger ea682182d0 fix: isolate npm plugin installs per package (#87647)
* fix: isolate npm plugin installs per package

* test: assert isolated npm plugin projects in upgrade survivor

* test: assert plugin lifecycle npm project roots

* test: resolve npm project deps in live assertions

* fix: resolve codex bins from isolated npm projects

* docs: document isolated npm plugin projects

* ci: configure testbox workflow for crabbox

* fix: stabilize npm project fingerprint

* fix: keep fetch runtime import side-effect free

* test: keep dynamic live model unit hermetic

* ci: handle empty node toolcache roots

* test: make nounset toolcache probe deterministic
2026-05-28 21:16:07 +01:00

150 lines
4.4 KiB
TypeScript

import path from "node:path";
import {
resolveSafeInstallDir,
safeDirName,
safePathSegmentHashed,
unscopedPackageName,
} from "../infra/install-safe-path.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
export function safePluginInstallFileName(input: string): string {
return safeDirName(input);
}
export function encodePluginInstallDirName(pluginId: string): string {
const trimmed = pluginId.trim();
if (!trimmed.includes("/")) {
return safeDirName(trimmed);
}
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
// with valid unscoped ids that happen to match the hashed slug.
return `@${safePathSegmentHashed(trimmed)}`;
}
export function validatePluginId(pluginId: string): string | null {
const trimmed = pluginId.trim();
if (!trimmed) {
return "invalid plugin name: missing";
}
if (trimmed.includes("\\")) {
return "invalid plugin name: path separators not allowed";
}
const segments = trimmed.split("/");
if (segments.some((segment) => !segment)) {
return "invalid plugin name: malformed scope";
}
if (segments.some((segment) => segment === "." || segment === "..")) {
return "invalid plugin name: reserved path segment";
}
if (segments.length === 1) {
if (trimmed.startsWith("@")) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
if (segments.length !== 2) {
return "invalid plugin name: path separators not allowed";
}
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
export function matchesExpectedPluginId(params: {
expectedPluginId?: string;
pluginId: string;
manifestPluginId?: string;
npmPluginId: string;
}): boolean {
if (!params.expectedPluginId) {
return true;
}
if (params.expectedPluginId === params.pluginId) {
return true;
}
// Backward compatibility: older install records keyed scoped npm packages by
// their unscoped package name. Preserve update-in-place for those records
// unless the package declares an explicit manifest id override.
return (
!params.manifestPluginId &&
params.pluginId === params.npmPluginId &&
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
);
}
export function resolveDefaultPluginExtensionsDir(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
return path.join(resolveConfigDir(env, homedir), "extensions");
}
export function resolveDefaultPluginNpmDir(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
return path.join(resolveConfigDir(env, homedir), "npm");
}
export function encodePluginNpmProjectDirName(packageName: string): string {
const trimmed = packageName.trim();
if (!trimmed) {
throw new Error("invalid npm package name: missing");
}
return safePathSegmentHashed(trimmed);
}
export function resolvePluginNpmProjectsDir(npmDir?: string): string {
const npmBase = npmDir ? resolveUserPath(npmDir) : resolveDefaultPluginNpmDir();
return path.join(npmBase, "projects");
}
export function resolvePluginNpmProjectDir(params: {
packageName: string;
npmDir?: string;
}): string {
return path.join(
resolvePluginNpmProjectsDir(params.npmDir),
encodePluginNpmProjectDirName(params.packageName),
);
}
export function resolvePluginNpmPackageDir(params: {
packageName: string;
npmDir?: string;
}): string {
return path.join(
resolvePluginNpmProjectDir(params),
"node_modules",
...params.packageName.split("/"),
);
}
export function resolveDefaultPluginGitDir(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
return path.join(resolveConfigDir(env, homedir), "git");
}
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
const extensionsBase = extensionsDir
? resolveUserPath(extensionsDir)
: resolveDefaultPluginExtensionsDir();
const pluginIdError = validatePluginId(pluginId);
if (pluginIdError) {
throw new Error(pluginIdError);
}
const targetDirResult = resolveSafeInstallDir({
baseDir: extensionsBase,
id: pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
throw new Error(targetDirResult.error);
}
return targetDirResult.path;
}