From 247bb833e37dc837d81d20455721124142140cf5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 4 Jul 2026 11:08:38 +0200 Subject: [PATCH] feat(plugins): re-consent gate when an update wants new permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updating a plugin used to just reinstall and reactivate, which silently granted whatever the new version declared — so a plugin could quietly widen its own rights on the next release. Route updates through a new server-side update() that diffs the new version's declared permissions against what the admin already granted: - nothing new -> the plugin is restarted transparently on the new code. - new permissions or a new outbound host -> the new code is installed but the plugin is left OFF, and the delta is handed back so the admin has to approve it before it turns on. Install runs first, so a failed download/signature check leaves the running plugin untouched. The client shows the delta in a consent dialog and only then activates. An update can never widen a plugin behind your back. --- client/src/api/client.ts | 1 + .../nest/plugins/plugin-runtime.service.ts | 46 ++++++++++++++++ server/src/nest/plugins/plugins.controller.ts | 11 ++++ .../plugins/plugin-runtime.test.ts | 54 +++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 8782ba4c9..5d95f542b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -466,6 +466,7 @@ export const adminApi = { pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data), pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), + pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data), pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data), pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data), diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 164719139..ce5c1d8d9 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -9,6 +9,9 @@ import { removePluginData } from './host/plugin-data.service'; import { isKnownPermission } from './protocol/envelope'; import { discoverPlugins } from './install/discovery'; import { pluginCodeDir } from './paths'; +import { PluginRegistryService } from './registry/registry.service'; + +const HTTP_OUTBOUND = 'http:outbound:'; /** * Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2). @@ -43,6 +46,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { }, }); + // Optional at the type level so tests can `new PluginRuntimeService()` without a + // registry; Nest always injects the real one (the provider is in the module). + constructor(private readonly registry?: PluginRegistryService) {} + onModuleInit(): void { if (!pluginsEnabled()) return; // Discover plugins placed on the volume (registers new ones inactive), then @@ -95,6 +102,45 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id); } + /** + * Update a plugin to the registry's latest version with a re-consent gate: the + * new version's declared permissions are diffed against what the admin already + * granted. Nothing new → the plugin is transparently restarted on the new code. + * Anything new (a permission or an outbound host) → the plugin is left INACTIVE + * and the delta is returned, so the caller can show it and only re-activate on + * an explicit admin click. An update never silently widens what a plugin may do. + * + * Install runs first so a failed download/signature/integrity check leaves the + * currently-running child untouched (it keeps serving the old code from memory). + */ + async update(id: string): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> { + const before = db.prepare('SELECT enabled, granted_permissions FROM plugins WHERE id = ?').get(id) as + | { enabled: number; granted_permissions: string } + | undefined; + if (!before) throw new Error(`plugin ${id} not found`); + if (!this.registry) throw new Error('registry service unavailable'); + const wasEnabled = before.enabled === 1; + const granted = new Set(parseArray(before.granted_permissions)); + + const res = await this.registry.install(id); // swaps code + refreshes declared permissions; keeps granted + + const declared = parseArray( + (db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions, + ).filter(isKnownPermission); + const newGrants = declared.filter((p) => !granted.has(p)); + const newEgress = newGrants.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)).filter(Boolean); + const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND)); + + if (wasEnabled) await this.deactivate(id); // stop the old child now that new code is in place + if (newGrants.length === 0 && wasEnabled) { + await this.activate(id); // no wider rights → transparent restart on the new code + return { version: res.version, activated: true, newPermissions, newEgress }; + } + // New rights requested (or it was already disabled): leave it inactive until + // an admin explicitly consents by activating it. + return { version: res.version, activated: false, newPermissions, newEgress }; + } + /** Stop the plugin, remove its code, and optionally delete all its data. */ async uninstall(id: string, deleteData: boolean): Promise { await this.supervisor.disable(id); diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index d12daf9d5..080be77d1 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -83,6 +83,17 @@ export class PluginsController { return { status: 'inactive' }; } + @Post(':id/update') + @HttpCode(200) + async update(@Param('id') id: string) { + if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); + try { + return await this.runtime.update(id); + } catch (e) { + throw new HttpException({ error: e instanceof Error ? e.message : 'update failed' }, 400); + } + } + @Post(':id/uninstall') @HttpCode(200) async uninstall(@Param('id') id: string, @Body() body: { deleteData?: boolean }) { diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 179fa1874..aa472fed8 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -200,3 +200,57 @@ describe('PluginRuntimeService (M2 end-to-end)', () => { await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined(); }); }); + +describe('PluginRuntimeService.update (re-consent gate)', () => { + const fakeRegistry = (impl: () => void) => + ({ install: vi.fn(async () => { impl(); return { id: 'x', version: '2.0.0' }; }) }) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService; + + const seed = (id: string, enabled: number, permissions: string[], granted: string[]) => { + fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true }); + fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); + testDb.prepare('INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config) VALUES (?,?,?,?,?,?)') + .run(id, enabled ? 'active' : 'inactive', enabled, JSON.stringify(permissions), JSON.stringify(granted), '{}'); + }; + + it('restarts transparently when the new version requests no new permissions', async () => { + seed('upd-same', 1, ['db:own'], ['db:own']); + const rt = new PluginRuntimeService(fakeRegistry(() => {})); // declared perms unchanged + await rt.activate('upd-same'); + const res = await rt.update('upd-same'); + expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] }); + expect(rt.isActive('upd-same')).toBe(true); + await rt.deactivate('upd-same'); + }); + + it('leaves the plugin inactive and reports the delta when new rights are requested', async () => { + seed('upd-wider', 1, ['db:own'], ['db:own']); + const rt = new PluginRuntimeService( + fakeRegistry(() => { + testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'") + .run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com'])); + }), + ); + await rt.activate('upd-wider'); + const res = await rt.update('upd-wider'); + expect(res.activated).toBe(false); + expect(res.newPermissions).toEqual(['db:read:trips']); + expect(res.newEgress).toEqual(['api.new.com']); + expect(rt.isActive('upd-wider')).toBe(false); + expect(testDb.prepare("SELECT enabled FROM plugins WHERE id='upd-wider'").get()).toMatchObject({ enabled: 0 }); + }); + + it('a disabled plugin stays disabled even with no new permissions', async () => { + seed('upd-off', 0, ['db:own'], ['db:own']); + const res = await new PluginRuntimeService(fakeRegistry(() => {})).update('upd-off'); + expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] }); + }); + + it('throws for an unknown plugin id', async () => { + await expect(new PluginRuntimeService(fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/); + }); + + it('throws if no registry service is wired', async () => { + seed('upd-noreg', 0, ['db:own'], ['db:own']); + await expect(new PluginRuntimeService().update('upd-noreg')).rejects.toThrow(/registry service unavailable/); + }); +});