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:
parent
756f5ef56b
commit
dda74c2d20
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, unknown> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<typeof ReloadResponseSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user