From dda74c2d208ca1bff33f9cb4a11288ed31aa4977 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 16 Apr 2026 15:45:42 -0700 Subject: [PATCH] feat(cowork-service): POST /api/plugins/reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hot-reload the orchestrator's on-disk plugin registry without a restart. Routes to the reload_plugins Rust IPC method, gated by the same authz the orchestrator enforces (admin role OR platform-signed JWT) so a forbidden caller gets a canonical ForbiddenError envelope instead of a raw IPC error passthrough. The response body is a ReloadStats { loaded, added, removed, updated, errors } summary, validated against ReloadResponseSchema before being returned to the caller. Tests cover: admin success (200 + envelope), user-without-platform (403 before IPC), bridge unavailable (400), orchestrator -32003 → ForbiddenError, other IPC errors → BadRequestError, malformed orchestrator payloads → BadRequestError. Phase: 3.1 Verified: pnpm -r typecheck, pnpm --filter @lysnrai/cowork-service {lint,build,test} (140 passed, 6 new reload tests) --- .../src/modules/plugins/routes.test.ts | 104 ++++++++++++++++++ .../src/modules/plugins/routes.ts | 36 +++++- .../src/modules/plugins/types.ts | 17 +++ 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/services/cowork-service/src/modules/plugins/routes.test.ts b/services/cowork-service/src/modules/plugins/routes.test.ts index 9034f218..92855568 100644 --- a/services/cowork-service/src/modules/plugins/routes.test.ts +++ b/services/cowork-service/src/modules/plugins/routes.test.ts @@ -114,4 +114,108 @@ describe('plugin routes', () => { expect(res.statusCode).toBe(200); expect(JSON.parse(res.payload).deleted).toBe(true); }); + + describe('POST /api/plugins/reload', () => { + it('returns 200 + ReloadStats when admin role is set on jwtPayload', async () => { + const stats = { + loaded: 3, + added: ['a'], + removed: [], + updated: [], + errors: [], + }; + call.mockResolvedValue({ result: stats }); + + // Spin up a fresh app with a preHandler that injects an admin jwtPayload. + const adminApp = Fastify({ logger: false }); + (adminApp as any).decorateRequest('jwtPayload', null); + adminApp.addHook('preHandler', async req => { + (req as unknown as { jwtPayload: unknown }).jwtPayload = { + sub: 'demo-user', + role: 'admin', + }; + }); + await adminApp.register(pluginRoutes); + + const res = await adminApp.inject({ method: 'POST', url: '/api/plugins/reload' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toEqual(stats); + expect(call).toHaveBeenCalledWith( + 'reload_plugins', + expect.objectContaining({ + auth: expect.objectContaining({ role: 'admin', isPlatformAuth: true }), + }) + ); + await adminApp.close(); + }); + + it('returns 403 when role=user and no platform auth', async () => { + const res = await app.inject({ method: 'POST', url: '/api/plugins/reload' }); + expect(res.statusCode).toBe(403); + // Bridge must not be called because authz rejects before IPC. + expect(call).not.toHaveBeenCalled(); + }); + + it('returns 400 when IPC bridge is unavailable', async () => { + setIpcBridge({ isRunning: false, call } as any); + + const downApp = Fastify({ logger: false }); + (downApp as any).decorateRequest('jwtPayload', null); + downApp.addHook('preHandler', async req => { + (req as unknown as { jwtPayload: unknown }).jwtPayload = { role: 'admin' }; + }); + await downApp.register(pluginRoutes); + + const res = await downApp.inject({ method: 'POST', url: '/api/plugins/reload' }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.payload).message).toMatch(/IPC bridge unavailable/i); + await downApp.close(); + }); + + it('propagates orchestrator -32003 as ForbiddenError', async () => { + call.mockResolvedValue({ error: { code: -32003, message: 'admin required' } }); + + const adminApp = Fastify({ logger: false }); + (adminApp as any).decorateRequest('jwtPayload', null); + adminApp.addHook('preHandler', async req => { + (req as unknown as { jwtPayload: unknown }).jwtPayload = { role: 'admin' }; + }); + await adminApp.register(pluginRoutes); + + const res = await adminApp.inject({ method: 'POST', url: '/api/plugins/reload' }); + expect(res.statusCode).toBe(403); + await adminApp.close(); + }); + + it('maps other IPC errors to BadRequestError', async () => { + call.mockResolvedValue({ error: { code: -32603, message: 'internal boom' } }); + + const adminApp = Fastify({ logger: false }); + (adminApp as any).decorateRequest('jwtPayload', null); + adminApp.addHook('preHandler', async req => { + (req as unknown as { jwtPayload: unknown }).jwtPayload = { role: 'admin' }; + }); + await adminApp.register(pluginRoutes); + + const res = await adminApp.inject({ method: 'POST', url: '/api/plugins/reload' }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.payload).message).toContain('internal boom'); + await adminApp.close(); + }); + + it('rejects malformed orchestrator payloads', async () => { + call.mockResolvedValue({ result: { loaded: 'not-a-number' } }); + + const adminApp = Fastify({ logger: false }); + (adminApp as any).decorateRequest('jwtPayload', null); + adminApp.addHook('preHandler', async req => { + (req as unknown as { jwtPayload: unknown }).jwtPayload = { role: 'admin' }; + }); + await adminApp.register(pluginRoutes); + + const res = await adminApp.inject({ method: 'POST', url: '/api/plugins/reload' }); + expect(res.statusCode).toBe(400); + await adminApp.close(); + }); + }); }); diff --git a/services/cowork-service/src/modules/plugins/routes.ts b/services/cowork-service/src/modules/plugins/routes.ts index a45b1a0b..3dbce29f 100644 --- a/services/cowork-service/src/modules/plugins/routes.ts +++ b/services/cowork-service/src/modules/plugins/routes.ts @@ -1,9 +1,9 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; -import { BadRequestError, NotFoundError } from '@bytelyst/errors'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@bytelyst/errors'; import { PRODUCT_ID } from '../../lib/product-config.js'; import { getUserId } from '../../lib/request-context.js'; import { getIpcBridge } from '../../lib/ipc-bridge.js'; -import { InstallPluginSchema, PluginIdParamsSchema } from './types.js'; +import { InstallPluginSchema, PluginIdParamsSchema, ReloadResponseSchema } from './types.js'; function buildAuth(req: FastifyRequest): Record { const userId = getUserId(req); @@ -71,4 +71,36 @@ export async function pluginRoutes(app: FastifyInstance) { } return resp.result; }); + + // POST /api/plugins/reload — hot-reload the orchestrator's plugin registry + // without a restart. Admin role OR a platform-signed JWT is required; the + // orchestrator enforces the same check, but we reject here too so a + // forbidden caller gets the canonical `ForbiddenError` envelope instead of + // a raw IPC error passthrough. In-flight sessions that have already + // resolved a plugin handle are unaffected (see `PluginLoader::reload`). + app.post('/api/plugins/reload', async req => { + if (!bridge.isRunning) throw new BadRequestError('IPC bridge unavailable'); + + const auth = buildAuth(req); + if (auth.role !== 'admin' && !auth.isPlatformAuth) { + throw new ForbiddenError('plugin reload requires admin role or platform auth'); + } + + const resp = await bridge.call('reload_plugins', { auth }); + if (resp.error) { + if (resp.error.code === -32003) { + throw new ForbiddenError(resp.error.message); + } + throw new BadRequestError(resp.error.message); + } + + const parsed = ReloadResponseSchema.safeParse(resp.result); + if (!parsed.success) { + req.log.error({ issues: parsed.error.issues }, 'reload_plugins returned malformed payload'); + throw new BadRequestError('orchestrator returned malformed reload stats'); + } + + req.log.info({ stats: parsed.data }, 'plugins reloaded'); + return parsed.data; + }); } diff --git a/services/cowork-service/src/modules/plugins/types.ts b/services/cowork-service/src/modules/plugins/types.ts index ed07e495..54eb3dd7 100644 --- a/services/cowork-service/src/modules/plugins/types.ts +++ b/services/cowork-service/src/modules/plugins/types.ts @@ -7,3 +7,20 @@ export const PluginIdParamsSchema = z.object({ export const InstallPluginSchema = z.object({ pluginId: z.string().min(1).max(256), }); + +/** + * Response body of `POST /api/plugins/reload`. + * + * Mirrors `cowork_orchestrator::ReloadStats` — an atomic-swap summary of the + * on-disk plugin registry. `errors` entries are `"{path}: {reason}"` strings + * for plugin directories whose manifest failed to parse. + */ +export const ReloadResponseSchema = z.object({ + loaded: z.number().int().nonnegative(), + added: z.array(z.string()), + removed: z.array(z.string()), + updated: z.array(z.string()), + errors: z.array(z.string()), +}); + +export type ReloadResponse = z.infer;