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/); + }); +});