feat(cowork-service): POST /api/plugins/reload

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)
This commit is contained in:
saravanakumardb1 2026-04-16 15:45:42 -07:00
parent 756f5ef56b
commit dda74c2d20
3 changed files with 155 additions and 2 deletions

View File

@ -114,4 +114,108 @@ describe('plugin routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).deleted).toBe(true); 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();
});
});
}); });

View File

@ -1,9 +1,9 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'; 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 { PRODUCT_ID } from '../../lib/product-config.js';
import { getUserId } from '../../lib/request-context.js'; import { getUserId } from '../../lib/request-context.js';
import { getIpcBridge } from '../../lib/ipc-bridge.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<string, unknown> { function buildAuth(req: FastifyRequest): Record<string, unknown> {
const userId = getUserId(req); const userId = getUserId(req);
@ -71,4 +71,36 @@ export async function pluginRoutes(app: FastifyInstance) {
} }
return resp.result; 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;
});
} }

View File

@ -7,3 +7,20 @@ export const PluginIdParamsSchema = z.object({
export const InstallPluginSchema = z.object({ export const InstallPluginSchema = z.object({
pluginId: z.string().min(1).max(256), 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<typeof ReloadResponseSchema>;