Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions backend/__tests__/service/registryRuntimeAdmin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
const { setupMongoDb, clearMongoDb, closeMongoDb } = require('../utils/testUtils');
const User = require('../../models/User');
const Pod = require('../../models/Pod');
const { AgentInstallation } = require('../../models/AgentRegistry');

jest.mock('../../services/agentProvisionerService', () => ({
startAgentRuntime: jest.fn(async () => ({})),
stopAgentRuntime: jest.fn(async () => ({})),
restartAgentRuntime: jest.fn(async () => ({})),
getAgentRuntimeStatus: jest.fn(async () => ({})),
getAgentRuntimeLogs: jest.fn(async () => ({ logs: '' })),
clearAgentRuntimeSessions: jest.fn(async () => ({})),
resolveOpenClawAccountId: jest.fn(({ instanceId }) => instanceId),
}));
const adapters = require('../../services/agentProvisionerService');
const router = require('../../routes/registry/runtime');
const operations = [
['get', 'runtime-status'], ['post', 'runtime-start'], ['post', 'runtime-stop'],
['post', 'runtime-restart'], ['post', 'runtime-clear-sessions'], ['get', 'runtime-logs'],
];
const SECRET = 'registry-runtime-admin-test';

describe('registry runtime admin access', () => {
let app;
let pod;
let member;
let admin;
const call = (method, route, user) => request(app)[method](
`/api/registry/pods/${pod._id}/agents/openclaw/${route}`,
).set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`);

beforeAll(async () => {
process.env.JWT_SECRET = SECRET;
await setupMongoDb();
[member, admin] = await Promise.all(['user', 'admin'].map((role) => User.create({
username: `runtime-admin-${role}`, email: `runtime-admin-${role}@example.test`,
password: 'test-password', role,
})));
pod = await Pod.create({ name: 'Runtime administration', createdBy: member._id, members: [member._id, admin._id] });
await AgentInstallation.create({
agentName: 'openclaw', instanceId: 'default', podId: pod._id, version: '1', installedBy: member._id,
});
app = express();
app.use(express.json());
app.use('/api/registry', router);
});
beforeEach(() => { jest.clearAllMocks(); });
afterAll(async () => { await clearMongoDb(); await closeMongoDb(); });

it.each(operations)('refuses a non-admin member for %s %s', async (method, route) => {
const response = await call(method, route, member);
expect(response.status).toBe(403);
expect(response.body.message).toBe('Admin access required');
for (const adapter of Object.values(adapters)) expect(adapter).not.toHaveBeenCalled();
});

it.each(operations)('permits an admin member for %s %s', async (method, route) => {
expect((await call(method, route, admin)).status).toBe(200);
expect(Object.values(adapters).some((adapter) => adapter.mock.calls.length)).toBe(true);
});
});
14 changes: 8 additions & 6 deletions backend/routes/registry/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
export {};
const express = require('express');
const auth = require('../../middleware/auth');
const requireAdmin = require('../../middleware/adminAuth');
const Pod = require('../../models/Pod');
const { AgentInstallation } = require('../../models/AgentRegistry');
const AgentIdentityService = require('../../services/agentIdentityService');
Expand Down Expand Up @@ -32,404 +33,405 @@
* GET /api/registry/pods/:podId/agents/:name/runtime-status
* Check local runtime status (docker).
*/
runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, async (req: any, res: any) => {
runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, requireAdmin, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const instanceId = normalizeInstanceId(req.query.instanceId || 'default');
const gatewayId = req.query.gatewayId || null;

const pod = await Pod.findById(podId).lean();
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

const isCreator = pod.createdBy?.toString() === userId.toString();
const membership = pod.members?.find((m: any) => {
if (!m) return false;
const memberId = m.userId?.toString?.() || m.toString?.();
return memberId && memberId === userId.toString();
});

if (!membership && !isCreator) {
return res.status(403).json({ error: 'Access denied' });
}

const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
agentName: name,
podId,
instanceId,
});
const effectiveInstanceId = resolvedInstanceId || instanceId;

const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
if (!runtimeType) {
return res.status(400).json({ error: 'Unknown agent runtime type' });
}

const configPayload = normalizeConfigMap(installation?.config) || {};
const configuredGatewayId = configPayload?.runtime?.gatewayId || null;
let gateway = null;
if (gatewayId) {
gateway = await resolveGatewayForRequest({ gatewayId, userId });
} else if (configuredGatewayId) {
gateway = await resolveGatewayForInstallation({ gatewayId: configuredGatewayId });
}

const status = await getAgentRuntimeStatus(runtimeType, effectiveInstanceId, { gateway });
return res.json({ runtimeType, status, gatewayId: gateway?._id || null, gatewaySlug: gateway?.slug || null });
} catch (error: unknown) {
console.error('Error fetching runtime status:', error);
if ((error as any).status) {
return res.status((error as any).status).json({ error: (error as Error).message });
}
return res.status(500).json({ error: 'Failed to fetch runtime status' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

/**
* POST /api/registry/pods/:podId/agents/:name/runtime-start
*/
runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, async (req: any, res: any) => {
runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, requireAdmin, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
const { instanceId, gatewayId } = req.body || {};
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const normalizedInstanceId = normalizeInstanceId(instanceId || 'default');

const pod = await Pod.findById(podId).lean();
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

const isCreator = pod.createdBy?.toString() === userId.toString();
const membership = pod.members?.find((m: any) => {
if (!m) return false;
const memberId = m.userId?.toString?.() || m.toString?.();
return memberId && memberId === userId.toString();
});

if (!membership && !isCreator) {
return res.status(403).json({ error: 'Access denied' });
}

const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
agentName: name,
podId,
instanceId: normalizedInstanceId,
});
const effectiveInstanceId = resolvedInstanceId || normalizedInstanceId;

const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
if (!runtimeType) {
return res.status(400).json({ error: 'Unknown agent runtime type' });
}

const configPayload = normalizeConfigMap(installation?.config) || {};
const configuredGatewayId = configPayload?.runtime?.gatewayId || null;
let gateway = null;
if (gatewayId) {
gateway = await resolveGatewayForRequest({ gatewayId, userId });
} else if (configuredGatewayId) {
gateway = await resolveGatewayForInstallation({ gatewayId: configuredGatewayId });
}

const started = await startAgentRuntime(runtimeType, effectiveInstanceId, { gateway });
return res.json({ runtimeType, started, gatewayId: gateway?._id || null, gatewaySlug: gateway?.slug || null });
} catch (error: unknown) {
console.error('Error starting runtime:', error);
if ((error as any).status) {
return res.status((error as any).status).json({ error: (error as Error).message });
}
return res.status(500).json({ error: 'Failed to start runtime' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

/**
* POST /api/registry/pods/:podId/agents/:name/runtime-stop
*/
runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, async (req: any, res: any) => {
runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, requireAdmin, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
const { instanceId, gatewayId } = req.body || {};
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const normalizedInstanceId = normalizeInstanceId(instanceId || 'default');

const pod = await Pod.findById(podId).lean();
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

const isCreator = pod.createdBy?.toString() === userId.toString();
const membership = pod.members?.find((m: any) => {
if (!m) return false;
const memberId = m.userId?.toString?.() || m.toString?.();
return memberId && memberId === userId.toString();
});

if (!membership && !isCreator) {
return res.status(403).json({ error: 'Access denied' });
}

const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
agentName: name,
podId,
instanceId: normalizedInstanceId,
});
const effectiveInstanceId = resolvedInstanceId || normalizedInstanceId;

const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
if (!runtimeType) {
return res.status(400).json({ error: 'Unknown agent runtime type' });
}

const configPayload = normalizeConfigMap(installation?.config) || {};
const configuredGatewayId = configPayload?.runtime?.gatewayId || null;
let gateway = null;
if (gatewayId) {
gateway = await resolveGatewayForRequest({ gatewayId, userId });
} else if (configuredGatewayId) {
gateway = await resolveGatewayForInstallation({ gatewayId: configuredGatewayId });
}

const stopped = await stopAgentRuntime(runtimeType, effectiveInstanceId, { gateway });
return res.json({ runtimeType, stopped, gatewayId: gateway?._id || null, gatewaySlug: gateway?.slug || null });
} catch (error: unknown) {
console.error('Error stopping runtime:', error);
if ((error as any).status) {
return res.status((error as any).status).json({ error: (error as Error).message });
}
return res.status(500).json({ error: 'Failed to stop runtime' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

/**
* POST /api/registry/pods/:podId/agents/:name/runtime-restart
*/
runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, async (req: any, res: any) => {
runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, requireAdmin, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
const { instanceId, gatewayId } = req.body || {};
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const normalizedInstanceId = normalizeInstanceId(instanceId || 'default');

const pod = await Pod.findById(podId).lean();
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

const isCreator = pod.createdBy?.toString() === userId.toString();
const membership = pod.members?.find((m: any) => {
if (!m) return false;
const memberId = m.userId?.toString?.() || m.toString?.();
return memberId && memberId === userId.toString();
});

if (!membership && !isCreator) {
return res.status(403).json({ error: 'Access denied' });
}

const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
agentName: name,
podId,
instanceId: normalizedInstanceId,
});
const effectiveInstanceId = resolvedInstanceId || normalizedInstanceId;

const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
if (!runtimeType) {
return res.status(400).json({ error: 'Unknown agent runtime type' });
}

const configPayload = normalizeConfigMap(installation?.config) || {};
const configuredGatewayId = configPayload?.runtime?.gatewayId || null;
let gateway = null;
if (gatewayId) {
gateway = await resolveGatewayForRequest({ gatewayId, userId });
} else if (configuredGatewayId) {
gateway = await resolveGatewayForInstallation({ gatewayId: configuredGatewayId });
}

const restarted = await restartAgentRuntime(runtimeType, effectiveInstanceId, { gateway });
return res.json({ runtimeType, restarted, gatewayId: gateway?._id || null, gatewaySlug: gateway?.slug || null });
} catch (error: unknown) {
console.error('Error restarting runtime:', error);
if ((error as any).status) {
return res.status((error as any).status).json({ error: (error as Error).message });
}
return res.status(500).json({ error: 'Failed to restart runtime' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

/**
* POST /api/registry/pods/:podId/agents/:name/runtime-clear-sessions
*/
runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auth, async (req: any, res: any) => {
runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions',
auth, requireAdmin, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
const { instanceId, gatewayId, restart = true } = req.body || {};
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const normalizedInstanceId = normalizeInstanceId(instanceId || 'default');

const pod = await Pod.findById(podId).lean();
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

const isCreator = pod.createdBy?.toString() === userId.toString();
const membership = pod.members?.find((m: any) => {
if (!m) return false;
const memberId = m.userId?.toString?.() || m.toString?.();
return memberId && memberId === userId.toString();
});

if (!membership && !isCreator) {
return res.status(403).json({ error: 'Access denied' });
}

const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
agentName: name,
podId,
instanceId: normalizedInstanceId,
});
const effectiveInstanceId = resolvedInstanceId || normalizedInstanceId;

const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
if (!runtimeType) {
return res.status(400).json({ error: 'Unknown agent runtime type' });
}
if (runtimeType !== 'moltbot') {
return res.status(400).json({ error: 'Session clearing is only supported for OpenClaw runtimes' });
}

const configPayload = normalizeConfigMap(installation?.config) || {};
const configuredGatewayId = configPayload?.runtime?.gatewayId || null;
let gateway = null;
if (gatewayId) {
gateway = await resolveGatewayForRequest({ gatewayId, userId });
} else if (configuredGatewayId) {
gateway = await resolveGatewayForInstallation({ gatewayId: configuredGatewayId });
}

const accountId = resolveOpenClawAccountId({
agentName: name,
instanceId: effectiveInstanceId,
});

const cleared = await clearAgentRuntimeSessions(runtimeType, effectiveInstanceId, {
gateway,
accountId,
});

let restarted = null;
if (restart) {
restarted = await restartAgentRuntime(runtimeType, effectiveInstanceId, { gateway });
}

return res.json({
runtimeType,
accountId,
cleared,
restarted,
gatewayId: gateway?._id || null,
gatewaySlug: gateway?.slug || null,
});
} catch (error: unknown) {
console.error('Error clearing runtime sessions:', error);
if ((error as any).status) {
return res.status((error as any).status).json({ error: (error as Error).message });
}
return res.status(500).json({ error: 'Failed to clear runtime sessions' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

/**
* GET /api/registry/pods/:podId/agents/:name/runtime-logs
*/
runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, async (req: any, res: any) => {
runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, requireAdmin, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
const lines = Number(req.query.lines || 200);
const instanceId = normalizeInstanceId(req.query.instanceId || 'default');
const gatewayId = req.query.gatewayId || null;
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}

const pod = await Pod.findById(podId).lean();
if (!pod) {
return res.status(404).json({ error: 'Pod not found' });
}

const isCreator = pod.createdBy?.toString() === userId.toString();
const membership = pod.members?.find((m: any) => {
if (!m) return false;
const memberId = m.userId?.toString?.() || m.toString?.();
return memberId && memberId === userId.toString();
});

if (!membership && !isCreator) {
return res.status(403).json({ error: 'Access denied' });
}

const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
agentName: name,
podId,
instanceId,
});
const effectiveInstanceId = resolvedInstanceId || instanceId;

const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
if (!runtimeType) {
return res.status(400).json({ error: 'Unknown agent runtime type' });
}

const configPayload = normalizeConfigMap(installation?.config) || {};
const configuredGatewayId = configPayload?.runtime?.gatewayId || null;
let gateway = null;
if (gatewayId) {
gateway = await resolveGatewayForRequest({ gatewayId, userId });
} else if (configuredGatewayId) {
gateway = await resolveGatewayForInstallation({ gatewayId: configuredGatewayId });
}

const filterTokens = buildRuntimeLogFilters({ runtimeType, agentName: name, instanceId: effectiveInstanceId });
const logs = await getAgentRuntimeLogs(runtimeType, effectiveInstanceId, lines, { gateway, filterTokens });
return res.json({
runtimeType,
...logs,
gatewayId: gateway?._id || null,
gatewaySlug: gateway?.slug || null,
});
} catch (error: unknown) {
console.error('Error fetching runtime logs:', error);
if ((error as any).status) {
return res.status((error as any).status).json({ error: (error as Error).message });
}
return res.status(500).json({ error: 'Failed to fetch runtime logs' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

module.exports = runtimeRouter;
Loading