diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd43a1de..a7e8e324f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Alerts with no icon no longer squeeze their text to one word per line - Password fields with the show button are the same height as the other fields next to them - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on +- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot ## 26.11.0 diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 455b6c902..5b3220c84 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -409,6 +409,15 @@ export class PluginLoader { } loaded.push(plugin); } catch (error) { + // A newer image ships a newer copy, so the old update can go. + if (isArtifact && error instanceof SupersededUpdateError) { + await fs.promises.rm(dir, { force: true }).catch(() => {}); + await fs.promises.rm(`${dir}.sig`, { force: true }).catch(() => {}); + pluginLogger.info(`Removed ${entry.name}: ${error.message}`, { + operation: "plugin_load", + }); + continue; + } this.skippedPlugins.push( describeSkipped(dir, entry.name, isArtifact, error), ); @@ -948,6 +957,16 @@ function withTimeout( const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +/** An installed update that the bundled copy has caught up with. */ +export class SupersededUpdateError extends Error { + constructor(id: string, version: string, bundledVersion: string) { + super( + `update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`, + ); + this.name = "SupersededUpdateError"; + } +} + /** * Throws unless an artifact may replace the bundled plugin with the same id: * a trusted key signed it and its version is strictly higher, so an old @@ -971,9 +990,7 @@ export function assertBundledUpdate( // An admin pinning an exact older release is the one allowed downgrade. if (pinnedVersion && pinnedVersion === version) return; if (bundledVersion && !semver.gt(version, bundledVersion)) { - throw new Error( - `update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`, - ); + throw new SupersededUpdateError(id, version, bundledVersion); } } diff --git a/src/backend/tests/plugins/signed-plugins-gate.test.ts b/src/backend/tests/plugins/signed-plugins-gate.test.ts index 8916366ce..1bd20e49f 100644 --- a/src/backend/tests/plugins/signed-plugins-gate.test.ts +++ b/src/backend/tests/plugins/signed-plugins-gate.test.ts @@ -231,6 +231,16 @@ describe("updating a bundled plugin", () => { expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0"); }); + it("removes an update the bundled copy caught up with", async () => { + const { userDir } = setup(); + const file = await writeArtifact(userDir, "bundled-one", "valid", "0.9.0"); + const loader = new PluginLoader(); + await loader.loadAll(); + expect(fs.existsSync(file)).toBe(false); + expect(fs.existsSync(`${file}.sig`)).toBe(false); + expect(loader.skipped()).toEqual([]); + }); + it("refuses a downgrade after the bundled copy was unloaded", async () => { const { userDir } = setup(); const loader = new PluginLoader();