From ae1196d7dff350538a94fb29c66f090c23fda934 Mon Sep 17 00:00:00 2001 From: Dmitriy Date: Mon, 7 Sep 2026 12:08:39 +0200 Subject: [PATCH 1/2] Allow package subpath plugin entries in serverless.yml Plugin entries such as '@scope/package/lib/plugin' are valid require specifiers (package exports subpaths) and loaded fine before the 4.0 validation, which now rejects them with INVALID_PLUGIN_NAME. Validate the package name and the subpath separately for configured entries. Subpath segments must be non-empty and must not be '.' or '..'. 'plugin install' and 'plugin uninstall' stay package-name only. --- docs/guides/upgrading-to-v4.md | 2 +- lib/classes/plugin-manager.js | 30 +++++++++++++++++++- test/unit/lib/classes/plugin-manager.test.js | 20 +++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/guides/upgrading-to-v4.md b/docs/guides/upgrading-to-v4.md index 2e681ba5e4..a55bf5bfe6 100644 --- a/docs/guides/upgrading-to-v4.md +++ b/docs/guides/upgrading-to-v4.md @@ -135,7 +135,7 @@ Java and Ruby local invocation now fails the command when the local runtime exit ### `plugins` configuration entries are validated -Plugin entries in `serverless.yml` are now validated when osls loads the service. Entries must be lowercase npm package names, scoped npm package names, or explicit local paths beginning with `./` that stay inside the service directory. +Plugin entries in `serverless.yml` are now validated when osls loads the service. Entries must be lowercase npm package names, scoped npm package names, either optionally followed by a package subpath (such as `@scope/package/lib/plugin`), or explicit local paths beginning with `./` that stay inside the service directory. Versioned plugin configuration entries such as `example-osls-plugin@1.2.3` now fail with `INVALID_PLUGIN_REFERENCE`; pin plugin versions in `package.json` instead. Non-string entries also fail with `INVALID_PLUGIN_REFERENCE`. Local plugin paths that escape the service directory, such as `./../plugin`, fail with `INVALID_LOCAL_PLUGIN_PATH`. diff --git a/lib/classes/plugin-manager.js b/lib/classes/plugin-manager.js index 48c1aade10..6c1ebbcedc 100644 --- a/lib/classes/plugin-manager.js +++ b/lib/classes/plugin-manager.js @@ -53,6 +53,32 @@ const mergeCommands = (target, source) => { return target; }; +const pluginSubpathSegmentPattern = /^[A-Za-z0-9._~-]+$/; + +// '@scope/name/lib/plugin' -> { packageName: '@scope/name', subpath: 'lib/plugin' } +const splitPluginReference = (reference) => { + const segments = reference.split('/'); + const packageSegmentCount = reference.startsWith('@') ? 2 : 1; + return { + packageName: segments.slice(0, packageSegmentCount).join('/'), + subpath: + segments.length > packageSegmentCount ? segments.slice(packageSegmentCount).join('/') : null, + }; +}; + +const validatePluginSubpath = (subpath, entry) => { + const segments = subpath.split('/'); + const isValid = segments.every( + (segment) => segment !== '.' && segment !== '..' && pluginSubpathSegmentPattern.test(segment) + ); + if (!isValid) { + throw new ServerlessError( + `Invalid plugin reference "${entry}". A package subpath must not be empty or traverse directories.`, + 'INVALID_PLUGIN_REFERENCE' + ); + } +}; + const validateConfiguredPluginReference = (entry, serviceDir) => { if (typeof entry !== 'string' || entry.trim() !== entry || entry === '') { throw new ServerlessError('Plugin entries must be strings.', 'INVALID_PLUGIN_REFERENCE'); @@ -74,7 +100,9 @@ const validateConfiguredPluginReference = (entry, serviceDir) => { } const { name, version } = splitPackageSpec(entry); - validatePluginName(name); + const { packageName, subpath } = splitPluginReference(name); + validatePluginName(packageName); + if (subpath != null) validatePluginSubpath(subpath, entry); if (version != null) { throw new ServerlessError( diff --git a/test/unit/lib/classes/plugin-manager.test.js b/test/unit/lib/classes/plugin-manager.test.js index 76656c7f64..4386ad3165 100644 --- a/test/unit/lib/classes/plugin-manager.test.js +++ b/test/unit/lib/classes/plugin-manager.test.js @@ -823,6 +823,26 @@ describe('PluginManager', () => { ]); }); + it('preserves package subpath plugin entries', () => { + expect( + pluginManager.parsePluginsObject(['@scope/package/lib/plugin', 'package/plugin']).modules + ).to.deep.equal(['@scope/package/lib/plugin', 'package/plugin']); + }); + + for (const input of ['package/../plugin', '@scope/package/./plugin', 'package//plugin']) { + it(`rejects package subpath plugin entry ${JSON.stringify(input)}`, () => { + expect(() => pluginManager.parsePluginsObject([input])) + .to.throw() + .with.property('code', 'INVALID_PLUGIN_REFERENCE'); + }); + } + + it('rejects versioned package subpath plugin entries', () => { + expect(() => pluginManager.parsePluginsObject(['@scope/package/plugin@1.2.3'])) + .to.throw() + .with.property('code', 'INVALID_PLUGIN_REFERENCE'); + }); + it('rejects non-string plugin entries', () => { expect(() => pluginManager.parsePluginsObject([{}])) .to.throw() From ecde9abee7fcb046dba52ffa7d4aeefae9079d85 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 7 Sep 2026 17:54:43 +0100 Subject: [PATCH 2/2] Suggest the package name when a subpath plugin entry is missing --- docs/guides/upgrading-to-v4.md | 2 +- lib/classes/plugin-manager.js | 6 ++++-- test/unit/lib/classes/plugin-manager.test.js | 6 ++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/guides/upgrading-to-v4.md b/docs/guides/upgrading-to-v4.md index a55bf5bfe6..990de02593 100644 --- a/docs/guides/upgrading-to-v4.md +++ b/docs/guides/upgrading-to-v4.md @@ -135,7 +135,7 @@ Java and Ruby local invocation now fails the command when the local runtime exit ### `plugins` configuration entries are validated -Plugin entries in `serverless.yml` are now validated when osls loads the service. Entries must be lowercase npm package names, scoped npm package names, either optionally followed by a package subpath (such as `@scope/package/lib/plugin`), or explicit local paths beginning with `./` that stay inside the service directory. +Plugin entries in `serverless.yml` are now validated when osls loads the service. Entries must be lowercase npm package names or scoped npm package names, optionally followed by a package subpath such as `@scope/package/lib/plugin`, or explicit local paths beginning with `./` that stay inside the service directory. Versioned plugin configuration entries such as `example-osls-plugin@1.2.3` now fail with `INVALID_PLUGIN_REFERENCE`; pin plugin versions in `package.json` instead. Non-string entries also fail with `INVALID_PLUGIN_REFERENCE`. Local plugin paths that escape the service directory, such as `./../plugin`, fail with `INVALID_LOCAL_PLUGIN_PATH`. diff --git a/lib/classes/plugin-manager.js b/lib/classes/plugin-manager.js index 6c1ebbcedc..23282fca5f 100644 --- a/lib/classes/plugin-manager.js +++ b/lib/classes/plugin-manager.js @@ -55,7 +55,6 @@ const mergeCommands = (target, source) => { const pluginSubpathSegmentPattern = /^[A-Za-z0-9._~-]+$/; -// '@scope/name/lib/plugin' -> { packageName: '@scope/name', subpath: 'lib/plugin' } const splitPluginReference = (reference) => { const segments = reference.split('/'); const packageSegmentCount = reference.startsWith('@') ? 2 : 1; @@ -281,13 +280,16 @@ class PluginManager { } const isLocalPlugin = name.startsWith('./'); + const installHint = isLocalPlugin + ? '' + : ` Run "serverless plugin install -n ${splitPluginReference(name).packageName}" to install it.`; throw new ServerlessError( [ `osls plugin "${name}" not found.`, ' Make sure it\'s installed and listed in the "plugins" section', ' of your serverless config file.', - isLocalPlugin ? '' : ` Run "serverless plugin install -n ${name}" to install it.`, + installHint, ].join(''), 'PLUGIN_NOT_FOUND' ); diff --git a/test/unit/lib/classes/plugin-manager.test.js b/test/unit/lib/classes/plugin-manager.test.js index 4386ad3165..1e241cc8d9 100644 --- a/test/unit/lib/classes/plugin-manager.test.js +++ b/test/unit/lib/classes/plugin-manager.test.js @@ -654,6 +654,12 @@ describe('PluginManager', () => { ); }); + it('should suggest installing the package for unknown package subpath plugins', () => { + return expect(pluginManager.loadAllPlugins(['@scope/package/lib/plugin'])) + .to.be.eventually.rejected.and.have.property('message') + .that.includes('serverless plugin install -n @scope/package'); + }); + it('should not throw error when trying to load unknown plugin with help flag', async () => { const servicePlugins = [servicePluginMock3Name, servicePluginMock1Name];