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: 0 additions & 64 deletions backend/__tests__/service/registryRuntimeAdmin.test.js

This file was deleted.

232 changes: 232 additions & 0 deletions backend/__tests__/service/registryRuntimeAuthorization.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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 Gateway = require('../../models/Gateway');
const { AgentInstallation } = require('../../models/AgentRegistry');

// External runtime adapters are the side-effect boundary. Authentication,
// installation lookup and ownership checks use the real models/database.
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: 'runtime output' })),
clearAgentRuntimeSessions: jest.fn(async () => ({})),
resolveOpenClawAccountId: jest.fn(({ instanceId }) => instanceId),
}));
const adapters = require('../../services/agentProvisionerService');
const runtimeRouter = require('../../routes/registry/runtime');
const operations = [
['get', 'runtime-status', 'getAgentRuntimeStatus'],
['post', 'runtime-start', 'startAgentRuntime'],
['post', 'runtime-stop', 'stopAgentRuntime'],
['post', 'runtime-restart', 'restartAgentRuntime'],
['post', 'runtime-clear-sessions', 'clearAgentRuntimeSessions'],
['get', 'runtime-logs', 'getAgentRuntimeLogs'],
];
const SECRET = 'runtime-route-authorization-test';

describe('registry runtime authorization', () => {
let app;
let server;
let owner;
let member;
let creator;
let admin;
let pod;
let ownPod;
let installation;
let audit;
const effects = () => Object.values(adapters).reduce((count, method) => count + method.mock.calls.length, 0);
const call = (method, operation, user, { podId = pod._id, instanceId = 'curator' } = {}) => {
const url = `/api/registry/pods/${podId}/agents/openclaw/${operation}`;
const req = request(server)[method](url).set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`)
.set('cf-connecting-ip', `192.0.2.${10 + operations.findIndex((entry) => entry[1] === operation)}`);
return method === 'get' ? req.query({ instanceId }) : req.send({ instanceId });
};

beforeAll(async () => {
process.env.JWT_SECRET = SECRET;
await setupMongoDb();
[owner, member, creator, admin] = await Promise.all(['owner', 'member', 'creator', 'admin'].map((name) => User.create({
username: `runtime-${name}`, email: `runtime-${name}@example.test`, password: 'test-password',
role: name === 'admin' ? 'admin' : 'user',
})));
pod = await Pod.create({
name: 'Runtime controls', createdBy: creator._id,
members: [owner._id, member._id, creator._id],
});
ownPod = await Pod.create({ name: 'Personal controls', createdBy: member._id, members: [member._id] });
installation = await AgentInstallation.create({
agentName: 'openclaw', instanceId: 'curator', podId: pod._id,
version: '1', installedBy: owner._id,
});
app = express();
app.use(express.json());
app.use('/api/registry', runtimeRouter);
server = await new Promise((resolve) => { const listening = app.listen(0, () => resolve(listening)); });
});
beforeEach(() => {
jest.clearAllMocks();
audit = jest.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => { audit.mockRestore(); });
afterAll(async () => {
if (server) await new Promise((resolve) => server.close(resolve));
await clearMongoDb(); await closeMongoDb();
});

it('limits unauthenticated requests before authentication and dispatch', async () => {
const url = `/api/registry/pods/${pod._id}/agents/openclaw/runtime-status`;
for (let count = 0; count < 120; count += 1) {
const response = await request(server).get(url).set('cf-connecting-ip', '192.0.2.50');
expect(response.status).toBe(401);
}
const response = await request(server).get(url).set('cf-connecting-ip', '192.0.2.50');
expect(response.status).toBe(429);
expect(effects()).toBe(0);
});

it('rejects a body instance object before it reaches a Mongo query', async () => {
expect((await call('post', 'runtime-start', owner, { instanceId: { $ne: null } })).status).toBe(400);
expect(effects()).toBe(0);
});
it.each([[undefined, 200], ['NaN', 200], ['Infinity', 200], ['1000000', 1000], ['-1', 1], ['0', 1], ['2.9', 2]])(
'bounds runtime log lines %s to %s', async (requested, expected) => {
const query = { instanceId: 'curator', ...(requested === undefined ? {} : { lines: requested }) };
const response = await request(server).get(`/api/registry/pods/${pod._id}/agents/openclaw/runtime-logs`)
.set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`).query(query);
expect(response.status).toBe(200);
expect(adapters.getAgentRuntimeLogs.mock.calls[0][2]).toBe(expected);
},
);
it('keeps paused installations inspectable by their owner', async () => {
await AgentInstallation.updateOne({ _id: installation._id }, { status: 'paused' });
try { expect((await call('get', 'runtime-status', owner)).status).toBe(200); }
finally { await AgentInstallation.updateOne({ _id: installation._id }, { status: 'active' }); }
});

it.each([['admin', 404], ['owner', 403]])('keeps string gateway selection policy for %s', async (actor, status) => {
const lookup = jest.spyOn(Gateway, 'findById');
const gatewayId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
try {
const user = actor === 'admin' ? admin : owner;
const response = await request(server).get(`/api/registry/pods/${pod._id}/agents/openclaw/runtime-status`)
.set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`)
.query({ instanceId: 'curator', gatewayId });
expect(response.status).toBe(status);
if (actor === 'admin') expect(lookup).toHaveBeenCalledWith(gatewayId);
else expect(lookup).not.toHaveBeenCalled();
expect(effects()).toBe(0);
} finally { lookup.mockRestore(); }
});

describe.each(operations)('%s %s', (method, operation, adapter) => {
it('refuses a non-installer member before adapter dispatch', async () => {
expect((await call(method, operation, member)).status).toBe(403);
expect(effects()).toBe(0);
});
it('requires pod access from a non-admin installer', async () => {
await Pod.updateOne({ _id: pod._id }, { $pull: { members: owner._id } });
try {
expect((await call(method, operation, owner)).status).toBe(403);
expect(effects()).toBe(0);
} finally { await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: owner._id } }); }
});
it('refuses a pod creator who is not the installer', async () => {
expect((await call(method, operation, creator)).status).toBe(403);
expect(effects()).toBe(0);
});
it('requires an installation in the requested pod', async () => {
expect((await call(method, operation, member, { podId: ownPod._id })).status).toBe(404);
expect(effects()).toBe(0);
});
it('requires the exact installed instance', async () => {
expect((await call(method, operation, owner, { instanceId: 'default' })).status).toBe(404);
expect(effects()).toBe(0);
});
it('allows the installer and dispatches the installed identity', async () => {
expect((await call(method, operation, owner)).status).toBe(200);
expect(adapters[adapter]).toHaveBeenCalledWith('moltbot', 'curator', ...(
adapter === 'getAgentRuntimeLogs' ? [200, expect.any(Object)] : [expect.any(Object)]
));
});
it('allows a global admin without pod membership', async () => {
expect((await call(method, operation, admin)).status).toBe(200);
expect(adapters[adapter]).toHaveBeenCalled();
});
it('requires an installation even for an admin', async () => {
expect((await call(method, operation, admin, { podId: ownPod._id })).status).toBe(404);
expect(effects()).toBe(0);
});
it.each([['owner', 'allowed', 200], ['member', 403, 403], ['missing', 404, 404]])(
'audits one %s outcome without content or credentials', async (actor, outcome, status) => {
const user = actor === 'member' ? member : owner;
const options = actor === 'missing' ? { instanceId: 'missing' } : {};
expect((await call(method, operation, user, options)).status).toBe(status);
expect(audit).toHaveBeenCalledTimes(1);
expect(JSON.parse(audit.mock.calls[0][0])).toEqual({
event: 'registry.runtime', route: `/pods/:podId/agents/:name/${operation}`,
podId: String(pod._id), agentName: 'openclaw',
instanceId: actor === 'missing' ? 'missing' : 'curator',
callerUserId: String(user._id), outcome,
});
},
);
it('looks up a stored session ID without changing its spelling', async () => {
await AgentInstallation.updateOne({ _id: installation._id }, { instanceId: 'My Session' });
try {
expect((await call(method, operation, owner, { instanceId: 'My Session' })).status).toBe(200);
expect(adapters[adapter].mock.calls[0][1]).toBe('My Session');
expect(JSON.parse(audit.mock.calls[0][0]).instanceId).toBe('My Session');
} finally { await AgentInstallation.updateOne({ _id: installation._id }, { instanceId: 'curator' }); }
});
it('audits the same method-specific instance used by authorization', async () => {
const req = request(server)[method](`/api/registry/pods/${pod._id}/agents/openclaw/${operation}`)
.set('Authorization', `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`);
if (method === 'post') await AgentInstallation.updateOne({ _id: installation._id }, { instanceId: 'default' });
try {
const response = method === 'get'
? await req.query({ instanceId: 'curator' }).send({ instanceId: 'wrong-body' })
: await req.query({ instanceId: 'wrong-query' }).send({});
expect(response.status).toBe(200);
expect(JSON.parse(audit.mock.calls[0][0]).instanceId).toBe(method === 'get' ? 'curator' : 'default');
} finally { await AgentInstallation.updateOne({ _id: installation._id }, { instanceId: 'curator' }); }
});
it('refuses a non-string gateway selection before database lookup or dispatch', async () => {
const lookup = jest.spyOn(Gateway, 'findById');
try {
const req = request(server)[method](`/api/registry/pods/${pod._id}/agents/openclaw/${operation}`)
.set('Authorization', `Bearer ${jwt.sign({ id: String(admin._id) }, SECRET)}`);
const response = method === 'get'
? await req.query({ instanceId: 'curator', 'gatewayId[$ne]': 'null' })
: await req.send({ instanceId: 'curator', gatewayId: { $ne: null } });
expect(response.status).toBe(400);
expect(lookup).not.toHaveBeenCalled();
expect(effects()).toBe(0);
} finally { lookup.mockRestore(); }
});
if (method === 'post') {
it('returns 400 and safely audits a malformed instance object', async () => {
const response = await call(method, operation, owner, { instanceId: { toString: null } });
expect(response.status).toBe(400);
expect(effects()).toBe(0);
expect(audit).toHaveBeenCalledTimes(1);
expect(JSON.parse(audit.mock.calls[0][0])).toEqual(expect.objectContaining({
instanceId: 'invalid', outcome: 400, callerUserId: String(owner._id),
}));
});
}
it('refuses an uninstalled record', async () => {
await AgentInstallation.updateOne({ _id: installation._id }, { status: 'uninstalled' });
try {
expect((await call(method, operation, owner)).status).toBe(404);
expect(effects()).toBe(0);
} finally { await AgentInstallation.updateOne({ _id: installation._id }, { status: 'active' }); }
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,6 @@
"routes/registry/publish.ts POST /generate-avatar [unlimited]",
"routes/registry/publish.ts POST /publish [unlimited]",
"routes/registry/publish.ts POST /seed [unlimited]",
"routes/registry/runtime.ts GET /pods/:podId/agents/:name/runtime-logs [unlimited]",
"routes/registry/runtime.ts GET /pods/:podId/agents/:name/runtime-status [unlimited]",
"routes/registry/runtime.ts POST /pods/:podId/agents/:name/runtime-clear-sessions [unlimited]",
"routes/registry/runtime.ts POST /pods/:podId/agents/:name/runtime-restart [unlimited]",
"routes/registry/runtime.ts POST /pods/:podId/agents/:name/runtime-start [unlimited]",
"routes/registry/runtime.ts POST /pods/:podId/agents/:name/runtime-stop [unlimited]",
"routes/registry/templates.ts DELETE /templates/:id [unlimited]",
"routes/registry/templates.ts GET /templates [unlimited]",
"routes/registry/templates.ts PATCH /templates/:id [unlimited]",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const mock = {
// HEARTBEAT.md the fleet actually receives is a base64 blob inside one of
// these, so this is the only place a test can assert on the delivered file
// rather than on the source that composes it.
readNamespacedPodLog: jest.fn(),
execCommands: [],
execArgv: [],
// What the next exec writes to its stdout sink; empty = no output.
Expand All @@ -54,6 +55,7 @@ const mock = {
replaceNamespacedConfigMap: mock.replaceNamespacedConfigMap,
createNamespacedConfigMap: mock.createNamespacedConfigMap,
listNamespacedPod: mock.listNamespacedPod,
readNamespacedPodLog: mock.readNamespacedPodLog,
};
}
}
Expand Down Expand Up @@ -97,7 +99,7 @@ const mock = {
const k8s = require('@kubernetes/client-node');
const {
restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey,
readOpenClawIdentityFile,
readOpenClawIdentityFile, getAgentRuntimeLogs,
} = require('../../../services/agentProvisionerServiceK8s');
const { CYCLES_DIRECTIVE_MARKER } = require('../../../routes/registry/presets');

Expand Down Expand Up @@ -560,3 +562,23 @@ describe('issueLiteLLMVirtualKey never sends the master key to LiteLLM', () => {
});
});



describe('runtime account log filtering', () => {
const { buildRuntimeLogFilters } = require('../../../routes/registry/helpers');
it.each([['bob', 'default', 'bob-default'], ['openclaw', 'default', 'default']])(
'returns only the exact account tag for %s', async (agentName, instanceId, accountId) => {
const own = `[commonly] [${accountId}] own line`;
const peerId = accountId === 'default' ? 'bob-default' : 'default';
k8s.__mock.readNamespacedPodLog.mockResolvedValue({ body: [
own, `[commonly] [${peerId}] peer line`, `[commonly] [${accountId}-other] suffix peer`,
`[commonly] [${peerId}] content repeats [commonly] [${accountId}] marker`,
`diagnostic mentions ${accountId}`, `[commonly] [elsewhere] text mentions ${accountId}`,
].join('\n') });
const filterTokens = buildRuntimeLogFilters({ runtimeType: 'moltbot', agentName, instanceId });
expect(filterTokens).toEqual([accountId]);
const result = await getAgentRuntimeLogs('moltbot', instanceId, 200, { filterTokens });
expect(result.logs).toBe(own);
},
);
});
30 changes: 30 additions & 0 deletions backend/__tests__/unit/services/agentRuntimeLogsDocker.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
jest.mock('child_process', () => ({ execFile: jest.fn() }));
const { execFile } = require('child_process');
const { getAgentRuntimeLogs } = require('../../../services/agentProvisionerService');
const { buildRuntimeLogFilters } = require('../../../routes/registry/helpers');

it.each([['bob', 'bob-default'], ['openclaw', 'default']])('filters shared Docker logs for %s', async (agentName, accountId) => {
const previousK8s = process.env.AGENT_PROVISIONER_K8S;
const previousDocker = process.env.AGENT_PROVISIONER_DOCKER;
process.env.AGENT_PROVISIONER_K8S = '0';
process.env.AGENT_PROVISIONER_DOCKER = '1';
const own = `[commonly] [${accountId}] own line`;
const peerId = accountId === 'default' ? 'bob-default' : 'default';
execFile.mockImplementation((_bin, args, _options, callback) => {
callback(null, { stdout: args[0] === 'ps' ? 'test-container' : [
own, `[commonly] [${peerId}] peer line`, `diagnostic mentions ${accountId}`,
`[commonly] [${accountId}-other] suffix peer`,
`[commonly] [${peerId}] content repeats [commonly] [${accountId}] marker`,
].join('\n'), stderr: '' });
});
try {
const filterTokens = buildRuntimeLogFilters({ runtimeType: 'moltbot', agentName, instanceId: 'default' });
const result = await getAgentRuntimeLogs('moltbot', 'default', 200, { filterTokens });
expect(result.logs).toBe(own);
} finally {
if (previousK8s === undefined) delete process.env.AGENT_PROVISIONER_K8S;
else process.env.AGENT_PROVISIONER_K8S = previousK8s;
if (previousDocker === undefined) delete process.env.AGENT_PROVISIONER_DOCKER;
else process.env.AGENT_PROVISIONER_DOCKER = previousDocker;
}
});
Loading
Loading