From f337c9019c34491fbb55abcfc38fb2a80860f1ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:11:41 +0100 Subject: [PATCH] refactor: share plugin package entry resolution --- src/plugins/discovery.test.ts | 29 ++ src/plugins/discovery.ts | 251 +-------------- src/plugins/install-paths.ts | 94 ++++++ src/plugins/install.test.ts | 101 ++++++ src/plugins/install.ts | 253 ++------------- src/plugins/package-entry-resolution.ts | 412 ++++++++++++++++++++++++ 6 files changed, 663 insertions(+), 477 deletions(-) create mode 100644 src/plugins/install-paths.ts create mode 100644 src/plugins/package-entry-resolution.ts diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 630e8a7c669..638123d0acd 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -499,6 +499,35 @@ describe("discoverOpenClawPlugins", () => { ); }); + it("rejects package runtimeExtensions that do not match extension entries", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "runtime-mismatch-pack"); + mkdirSafe(path.join(pluginDir, "src")); + mkdirSafe(path.join(pluginDir, "dist")); + + writePluginPackageManifest({ + packageDir: pluginDir, + packageName: "@openclaw/runtime-mismatch-pack", + extensions: ["./src/one.ts", "./src/two.ts"], + runtimeExtensions: ["./dist/one.js"], + }); + writePluginEntry(path.join(pluginDir, "src", "one.ts")); + writePluginEntry(path.join(pluginDir, "src", "two.ts")); + writePluginEntry(path.join(pluginDir, "dist", "one.js")); + + const result = await discoverWithStateDir(stateDir, {}); + + expectCandidatePresence(result, { absent: ["runtime-mismatch-pack"] }); + expect( + result.diagnostics.some( + (entry) => + entry.level === "error" && + entry.message.includes("runtimeExtensions length (1)") && + entry.message.includes("extensions length (2)"), + ), + ).toBe(true); + }); + it("infers built dist entries for installed TypeScript package plugins", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "extensions", "built-peer-pack"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 970460c49e1..04d426e12f9 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { resolveBoundaryPathSync } from "../infra/boundary-path.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -19,7 +18,10 @@ import { type OpenClawPackageManifest, type PackageManifest, } from "./manifest.js"; -import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import { + resolvePackageRuntimeExtensionSources, + resolvePackageSetupSource, +} from "./package-entry-resolution.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; @@ -555,245 +557,6 @@ function discoverBundleInRoot(params: { return "added"; } -function resolvePackageEntrySource(params: { - packageDir: string; - entryPath: string; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const source = path.resolve(params.packageDir, params.entryPath); - const rejectHardlinks = params.rejectHardlinks ?? true; - const candidates = [source]; - const openCandidate = (absolutePath: string): string | null => { - const opened = openBoundaryFileSync({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - rejectHardlinks, - }); - if (!opened.ok) { - return matchBoundaryFileOpenFailure(opened, { - path: () => null, - io: () => { - params.diagnostics.push({ - level: "warn", - message: `extension entry unreadable (I/O error): ${params.entryPath}`, - source: params.sourceLabel, - }); - return null; - }, - fallback: () => { - params.diagnostics.push({ - level: "error", - message: `extension entry escapes package directory: ${params.entryPath}`, - source: params.sourceLabel, - }); - return null; - }, - }); - } - const safeSource = opened.path; - fs.closeSync(opened.fd); - return safeSource; - }; - if (!rejectHardlinks) { - const builtCandidate = source.replace(/\.[^.]+$/u, ".js"); - if (builtCandidate !== source) { - candidates.push(builtCandidate); - } - } - - for (const candidate of new Set(candidates)) { - if (!fs.existsSync(candidate)) { - continue; - } - return openCandidate(candidate); - } - - return openCandidate(source); -} - -function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { - return origin === "config" || origin === "global"; -} - -function resolveSafePackageEntry(params: { - packageDir: string; - entryPath: string; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): { relativePath: string; existingSource?: string } | null { - const absolutePath = path.resolve(params.packageDir, params.entryPath); - if (fs.existsSync(absolutePath)) { - const existingSource = resolvePackageEntrySource({ - packageDir: params.packageDir, - entryPath: params.entryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (!existingSource) { - return null; - } - return { - relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"), - existingSource, - }; - } - - try { - resolveBoundaryPathSync({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - }); - } catch { - params.diagnostics.push({ - level: "error", - message: `extension entry escapes package directory: ${params.entryPath}`, - source: params.sourceLabel, - }); - return null; - } - return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") }; -} - -function resolveExistingPackageEntrySource(params: { - packageDir: string; - entryPath: string; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const source = path.resolve(params.packageDir, params.entryPath); - if (!fs.existsSync(source)) { - return null; - } - return resolvePackageEntrySource(params); -} - -function normalizePackageManifestStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); -} - -function resolvePackageRuntimeEntrySource(params: { - packageDir: string; - entryPath: string; - runtimeEntryPath?: string; - origin: PluginOrigin; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const safeEntry = resolveSafePackageEntry({ - packageDir: params.packageDir, - entryPath: params.entryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (!safeEntry) { - return null; - } - - if (params.runtimeEntryPath) { - const runtimeSource = resolvePackageEntrySource({ - packageDir: params.packageDir, - entryPath: params.runtimeEntryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (runtimeSource) { - return runtimeSource; - } - } - - if (shouldInferBuiltRuntimeEntry(params.origin)) { - for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) { - const runtimeSource = resolveExistingPackageEntrySource({ - packageDir: params.packageDir, - entryPath: candidate, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - if (runtimeSource) { - return runtimeSource; - } - } - } - - if (safeEntry.existingSource) { - return safeEntry.existingSource; - } - - return resolvePackageEntrySource({ - packageDir: params.packageDir, - entryPath: params.entryPath, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); -} - -function resolvePackageSetupSource(params: { - packageDir: string; - manifest: PackageManifest | null; - origin: PluginOrigin; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string | null { - const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); - const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry); - if (!setupEntryPath) { - return null; - } - return resolvePackageRuntimeEntrySource({ - packageDir: params.packageDir, - entryPath: setupEntryPath, - runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry), - origin: params.origin, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); -} - -function resolvePackageRuntimeExtensionEntries(params: { - packageDir: string; - manifest: PackageManifest | null; - extensions: readonly string[]; - origin: PluginOrigin; - sourceLabel: string; - diagnostics: PluginDiagnostic[]; - rejectHardlinks?: boolean; -}): string[] { - const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); - const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions); - return params.extensions.flatMap((entryPath, index) => { - const source = resolvePackageRuntimeEntrySource({ - packageDir: params.packageDir, - entryPath, - runtimeEntryPath: - runtimeExtensions.length === params.extensions.length - ? runtimeExtensions[index] - : undefined, - origin: params.origin, - sourceLabel: params.sourceLabel, - diagnostics: params.diagnostics, - rejectHardlinks: params.rejectHardlinks, - }); - return source ? [source] : []; - }); -} - function discoverInDirectory(params: { dir: string; origin: PluginOrigin; @@ -871,7 +634,7 @@ function discoverInDirectory(params: { }); if (extensions.length > 0) { - const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({ + const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({ packageDir: fullPath, manifest, extensions, @@ -1007,7 +770,7 @@ function discoverFromPath(params: { }); if (extensions.length > 0) { - const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({ + const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({ packageDir: resolved, manifest, extensions, diff --git a/src/plugins/install-paths.ts b/src/plugins/install-paths.ts new file mode 100644 index 00000000000..56d5db79c87 --- /dev/null +++ b/src/plugins/install-paths.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import { + resolveSafeInstallDir, + safeDirName, + safePathSegmentHashed, + unscopedPackageName, +} from "../infra/install-safe-path.js"; +import { CONFIG_DIR, 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 resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { + const extensionsBase = extensionsDir + ? resolveUserPath(extensionsDir) + : path.join(CONFIG_DIR, "extensions"); + 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; +} diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index f2c0abea3d8..17fee1da4dd 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -865,6 +865,107 @@ describe("installPluginFromArchive", () => { } }); + it("rejects package installs when runtimeExtensions length does not match extensions", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "runtime-mismatch-plugin", + version: "1.0.0", + openclaw: { + extensions: ["./src/one.ts", "./src/two.ts"], + runtimeExtensions: ["./dist/one.js"], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "one.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("runtimeExtensions length (1)"); + expect(result.error).toContain("extensions length (2)"); + } + }); + + it("rejects package installs when an extension entry is a symlink escape", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const outsideDir = path.join(path.dirname(pluginDir), "outside-symlink"); + const outsideEntry = path.join(outsideDir, "escape.js"); + const linkedDir = path.join(pluginDir, "linked"); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(outsideEntry, "export {};\n"); + try { + fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); + } catch { + return; + } + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "symlink-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["./linked/escape.js"] }, + }), + ); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("extension entry"); + } + }); + + it("rejects package installs when an extension entry is a hardlinked alias", async () => { + if (process.platform === "win32") { + return; + } + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const outsideDir = path.join(path.dirname(pluginDir), "outside-hardlink"); + const outsideEntry = path.join(outsideDir, "escape.js"); + const linkedEntry = path.join(pluginDir, "escape.js"); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(outsideEntry, "export {};\n"); + try { + fs.linkSync(outsideEntry, linkedEntry); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "hardlink-entry-plugin", + version: "1.0.0", + openclaw: { extensions: ["./escape.js"] }, + }), + ); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("boundary checks"); + } + }); + it("blocks package installs when plugin contains dangerous code patterns", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 591aeca3461..cb226e7c755 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,27 +1,25 @@ -import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { matchBoundaryFileOpenFailure, openBoundaryFile } from "../infra/boundary-file-read.js"; -import { resolveBoundaryPath } from "../infra/boundary-path.js"; -import { - packageNameMatchesId, - resolveSafeInstallDir, - safeDirName, - safePathSegmentHashed, - unscopedPackageName, -} from "../infra/install-safe-path.js"; +import { packageNameMatchesId } from "../infra/install-safe-path.js"; import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { + encodePluginInstallDirName, + matchesExpectedPluginId, + safePluginInstallFileName, + validatePluginId, +} from "./install-paths.js"; import type { InstallSecurityScanResult } from "./install-security-scan.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { - getPackageManifestMetadata, resolvePackageExtensionEntries, type PackageManifest as PluginPackageManifest, } from "./manifest.js"; -import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js"; + +export { resolvePluginInstallDir } from "./install-paths.js"; let pluginInstallRuntimePromise: Promise | undefined; @@ -95,71 +93,6 @@ type PluginInstallPolicyRequest = { }; const defaultLogger: PluginInstallLogger = {}; -function safeFileName(input: string): string { - return safeDirName(input); -} - -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)}`; -} - -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; -} - -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) - ); -} function ensureOpenClawExtensions(params: { manifest: PackageManifest }): | { @@ -192,139 +125,6 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }): }; } -type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; - -async function validatePackageExtensionEntry(params: { - packageDir: string; - entry: string; - label: string; - requireExisting: boolean; -}): Promise { - const absolutePath = path.resolve(params.packageDir, params.entry); - try { - const resolved = await resolveBoundaryPath({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - }); - if (!resolved.exists) { - return params.requireExisting - ? { ok: false, error: `${params.label} not found: ${params.entry}` } - : { ok: true, exists: false }; - } - } catch { - return { - ok: false, - error: `${params.label} escapes plugin directory: ${params.entry}`, - }; - } - - const opened = await openBoundaryFile({ - absolutePath, - rootPath: params.packageDir, - boundaryLabel: "plugin package directory", - }); - if (!opened.ok) { - return matchBoundaryFileOpenFailure(opened, { - path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }), - io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }), - validation: () => ({ - ok: false, - error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, - }), - fallback: () => ({ - ok: false, - error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, - }), - }); - } - fsSync.closeSync(opened.fd); - return { ok: true, exists: true }; -} - -async function validatePackageExtensionEntries(params: { - packageDir: string; - extensions: string[]; - manifest: PackageManifest; -}): Promise<{ ok: true } | { ok: false; error: string; code: PluginInstallErrorCode }> { - const packageMetadata = getPackageManifestMetadata(params.manifest); - const runtimeExtensions = Array.isArray(packageMetadata?.runtimeExtensions) - ? packageMetadata.runtimeExtensions - .map((entry) => normalizeOptionalString(entry) ?? "") - .filter(Boolean) - : []; - const useRuntimeExtensions = runtimeExtensions.length === params.extensions.length; - - for (const [index, entry] of params.extensions.entries()) { - const sourceEntry = await validatePackageExtensionEntry({ - packageDir: params.packageDir, - entry, - label: "extension entry", - requireExisting: false, - }); - if (!sourceEntry.ok) { - return { - ok: false, - error: sourceEntry.error, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - - const runtimeEntry = useRuntimeExtensions ? runtimeExtensions[index] : undefined; - if (runtimeEntry) { - const runtimeResult = await validatePackageExtensionEntry({ - packageDir: params.packageDir, - entry: runtimeEntry, - label: "runtime extension entry", - requireExisting: true, - }); - if (!runtimeResult.ok) { - return { - ok: false, - error: runtimeResult.error, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - continue; - } - - if (sourceEntry.exists) { - continue; - } - - let foundBuiltEntry = false; - for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { - const builtResult = await validatePackageExtensionEntry({ - packageDir: params.packageDir, - entry: builtEntry, - label: "inferred runtime extension entry", - requireExisting: false, - }); - if (!builtResult.ok) { - return { - ok: false, - error: builtResult.error, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - if (builtResult.exists) { - foundBuiltEntry = true; - break; - } - } - - if (!foundBuiltEntry) { - return { - ok: false, - error: `extension entry not found: ${entry}`, - code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, - }; - } - } - - return { ok: true }; -} - function isNpmPackageNotFoundMessage(error: string): boolean { const normalized = error.trim(); if (normalized.startsWith("Package not found on npm:")) { @@ -581,26 +381,6 @@ async function installPluginDirectoryIntoExtensions(params: { }); } -export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { - const extensionsBase = extensionsDir - ? resolveUserPath(extensionsDir) - : path.join(CONFIG_DIR, "extensions"); - 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; -} - async function resolvePluginInstallTarget(params: { runtime: Awaited>; pluginId: string; @@ -905,13 +685,17 @@ async function installPluginFromPackageDir( }; } - const extensionValidation = await validatePackageExtensionEntries({ + const extensionValidation = await validatePackageExtensionEntriesForInstall({ packageDir: params.packageDir, extensions, manifest, }); if (!extensionValidation.ok) { - return extensionValidation; + return { + ok: false, + error: extensionValidation.error, + code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS, + }; } const targetResult = await resolvePreparedDirectoryInstallTarget({ @@ -1099,7 +883,10 @@ export async function installPluginFromFile(params: { if (pluginIdError) { return { ok: false, error: pluginIdError }; } - const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`); + const targetFile = path.join( + extensionsDir, + `${safePluginInstallFileName(pluginId)}${path.extname(filePath)}`, + ); const preparedTarget: PreparedInstallTarget = { targetPath: targetFile, effectiveMode: await resolveEffectiveInstallMode({ diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts new file mode 100644 index 00000000000..e532dd3bf0a --- /dev/null +++ b/src/plugins/package-entry-resolution.ts @@ -0,0 +1,412 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + matchBoundaryFileOpenFailure, + openBoundaryFile, + openBoundaryFileSync, +} from "../infra/boundary-file-read.js"; +import { resolveBoundaryPath, resolveBoundaryPathSync } from "../infra/boundary-path.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; +import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import type { PluginOrigin } from "./plugin-origin.types.js"; + +type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; + +type RuntimeExtensionsResolution = + | { ok: true; runtimeExtensions: string[] } + | { ok: false; error: string }; + +function runtimeExtensionsLengthMismatchMessage(params: { + runtimeExtensionsLength: number; + extensionsLength: number; +}): string { + return ( + `package.json openclaw.runtimeExtensions length (${params.runtimeExtensionsLength}) ` + + `must match openclaw.extensions length (${params.extensionsLength})` + ); +} + +export function normalizePackageManifestStringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); +} + +export function resolvePackageRuntimeExtensionEntries(params: { + manifest: PackageManifest | null | undefined; + extensions: readonly string[]; +}): RuntimeExtensionsResolution { + const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); + const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions); + if (runtimeExtensions.length === 0) { + return { ok: true, runtimeExtensions: [] }; + } + if (runtimeExtensions.length !== params.extensions.length) { + return { + ok: false, + error: runtimeExtensionsLengthMismatchMessage({ + runtimeExtensionsLength: runtimeExtensions.length, + extensionsLength: params.extensions.length, + }), + }; + } + return { ok: true, runtimeExtensions }; +} + +async function validatePackageExtensionEntry(params: { + packageDir: string; + entry: string; + label: string; + requireExisting: boolean; +}): Promise { + const absolutePath = path.resolve(params.packageDir, params.entry); + try { + const resolved = await resolveBoundaryPath({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!resolved.exists) { + return params.requireExisting + ? { ok: false, error: `${params.label} not found: ${params.entry}` } + : { ok: true, exists: false }; + } + } catch { + return { + ok: false, + error: `${params.label} escapes plugin directory: ${params.entry}`, + }; + } + + const opened = await openBoundaryFile({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + if (!opened.ok) { + return matchBoundaryFileOpenFailure(opened, { + path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }), + io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }), + validation: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + fallback: () => ({ + ok: false, + error: `${params.label} failed plugin directory boundary checks: ${params.entry}`, + }), + }); + } + fs.closeSync(opened.fd); + return { ok: true, exists: true }; +} + +export async function validatePackageExtensionEntriesForInstall(params: { + packageDir: string; + extensions: string[]; + manifest: PackageManifest; +}): Promise<{ ok: true } | { ok: false; error: string }> { + const runtimeResolution = resolvePackageRuntimeExtensionEntries({ + manifest: params.manifest, + extensions: params.extensions, + }); + if (!runtimeResolution.ok) { + return runtimeResolution; + } + + for (const [index, entry] of params.extensions.entries()) { + const sourceEntry = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry, + label: "extension entry", + requireExisting: false, + }); + if (!sourceEntry.ok) { + return sourceEntry; + } + + const runtimeEntry = runtimeResolution.runtimeExtensions[index]; + if (runtimeEntry) { + const runtimeResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: runtimeEntry, + label: "runtime extension entry", + requireExisting: true, + }); + if (!runtimeResult.ok) { + return runtimeResult; + } + continue; + } + + if (sourceEntry.exists) { + continue; + } + + let foundBuiltEntry = false; + for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { + const builtResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: builtEntry, + label: "inferred runtime extension entry", + requireExisting: false, + }); + if (!builtResult.ok) { + return builtResult; + } + if (builtResult.exists) { + foundBuiltEntry = true; + break; + } + } + + if (!foundBuiltEntry) { + return { ok: false, error: `extension entry not found: ${entry}` }; + } + } + + return { ok: true }; +} + +function resolvePackageEntrySource(params: { + packageDir: string; + entryPath: string; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const source = path.resolve(params.packageDir, params.entryPath); + const rejectHardlinks = params.rejectHardlinks ?? true; + const candidates = [source]; + const openCandidate = (absolutePath: string): string | null => { + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + rejectHardlinks, + }); + if (!opened.ok) { + return matchBoundaryFileOpenFailure(opened, { + path: () => null, + io: () => { + params.diagnostics.push({ + level: "warn", + message: `extension entry unreadable (I/O error): ${params.entryPath}`, + source: params.sourceLabel, + }); + return null; + }, + fallback: () => { + params.diagnostics.push({ + level: "error", + message: `extension entry escapes package directory: ${params.entryPath}`, + source: params.sourceLabel, + }); + return null; + }, + }); + } + const safeSource = opened.path; + fs.closeSync(opened.fd); + return safeSource; + }; + if (!rejectHardlinks) { + const builtCandidate = source.replace(/\.[^.]+$/u, ".js"); + if (builtCandidate !== source) { + candidates.push(builtCandidate); + } + } + + for (const candidate of new Set(candidates)) { + if (!fs.existsSync(candidate)) { + continue; + } + return openCandidate(candidate); + } + + return openCandidate(source); +} + +function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { + return origin === "config" || origin === "global"; +} + +function resolveSafePackageEntry(params: { + packageDir: string; + entryPath: string; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): { relativePath: string; existingSource?: string } | null { + const absolutePath = path.resolve(params.packageDir, params.entryPath); + if (fs.existsSync(absolutePath)) { + const existingSource = resolvePackageEntrySource({ + packageDir: params.packageDir, + entryPath: params.entryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (!existingSource) { + return null; + } + return { + relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"), + existingSource, + }; + } + + try { + resolveBoundaryPathSync({ + absolutePath, + rootPath: params.packageDir, + boundaryLabel: "plugin package directory", + }); + } catch { + params.diagnostics.push({ + level: "error", + message: `extension entry escapes package directory: ${params.entryPath}`, + source: params.sourceLabel, + }); + return null; + } + return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") }; +} + +function resolveExistingPackageEntrySource(params: { + packageDir: string; + entryPath: string; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const source = path.resolve(params.packageDir, params.entryPath); + if (!fs.existsSync(source)) { + return null; + } + return resolvePackageEntrySource(params); +} + +function resolvePackageRuntimeEntrySource(params: { + packageDir: string; + entryPath: string; + runtimeEntryPath?: string; + origin: PluginOrigin; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const safeEntry = resolveSafePackageEntry({ + packageDir: params.packageDir, + entryPath: params.entryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (!safeEntry) { + return null; + } + + if (params.runtimeEntryPath) { + const runtimeSource = resolvePackageEntrySource({ + packageDir: params.packageDir, + entryPath: params.runtimeEntryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (runtimeSource) { + return runtimeSource; + } + } + + if (shouldInferBuiltRuntimeEntry(params.origin)) { + for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) { + const runtimeSource = resolveExistingPackageEntrySource({ + packageDir: params.packageDir, + entryPath: candidate, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + if (runtimeSource) { + return runtimeSource; + } + } + } + + if (safeEntry.existingSource) { + return safeEntry.existingSource; + } + + return resolvePackageEntrySource({ + packageDir: params.packageDir, + entryPath: params.entryPath, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); +} + +export function resolvePackageSetupSource(params: { + packageDir: string; + manifest: PackageManifest | null; + origin: PluginOrigin; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string | null { + const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); + const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry); + if (!setupEntryPath) { + return null; + } + return resolvePackageRuntimeEntrySource({ + packageDir: params.packageDir, + entryPath: setupEntryPath, + runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry), + origin: params.origin, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); +} + +export function resolvePackageRuntimeExtensionSources(params: { + packageDir: string; + manifest: PackageManifest | null; + extensions: readonly string[]; + origin: PluginOrigin; + sourceLabel: string; + diagnostics: PluginDiagnostic[]; + rejectHardlinks?: boolean; +}): string[] { + const runtimeResolution = resolvePackageRuntimeExtensionEntries({ + manifest: params.manifest, + extensions: params.extensions, + }); + if (!runtimeResolution.ok) { + params.diagnostics.push({ + level: "error", + message: runtimeResolution.error, + source: params.sourceLabel, + }); + return []; + } + + return params.extensions.flatMap((entryPath, index) => { + const source = resolvePackageRuntimeEntrySource({ + packageDir: params.packageDir, + entryPath, + runtimeEntryPath: runtimeResolution.runtimeExtensions[index], + origin: params.origin, + sourceLabel: params.sourceLabel, + diagnostics: params.diagnostics, + rejectHardlinks: params.rejectHardlinks, + }); + return source ? [source] : []; + }); +}