diff --git a/backend/__tests__/service/registryRuntimeAdmin.test.js b/backend/__tests__/service/registryRuntimeAdmin.test.js
deleted file mode 100644
index 8b98592e8f..0000000000
--- a/backend/__tests__/service/registryRuntimeAdmin.test.js
+++ /dev/null
@@ -1,64 +0,0 @@
-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);
- });
-});
diff --git a/backend/__tests__/service/registryRuntimeAuthorization.test.js b/backend/__tests__/service/registryRuntimeAuthorization.test.js
new file mode 100644
index 0000000000..c7380eed0d
--- /dev/null
+++ b/backend/__tests__/service/registryRuntimeAuthorization.test.js
@@ -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' }); }
+ });
+ });
+});
diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json
index 26f90b1080..1180a45720 100644
--- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json
+++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json
@@ -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]",
diff --git a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js
index 6e9fcd2e67..20ea517be6 100644
--- a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js
+++ b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js
@@ -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.
@@ -54,6 +55,7 @@ const mock = {
replaceNamespacedConfigMap: mock.replaceNamespacedConfigMap,
createNamespacedConfigMap: mock.createNamespacedConfigMap,
listNamespacedPod: mock.listNamespacedPod,
+ readNamespacedPodLog: mock.readNamespacedPodLog,
};
}
}
@@ -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');
@@ -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);
+ },
+ );
+});
diff --git a/backend/__tests__/unit/services/agentRuntimeLogsDocker.test.js b/backend/__tests__/unit/services/agentRuntimeLogsDocker.test.js
new file mode 100644
index 0000000000..60b239759d
--- /dev/null
+++ b/backend/__tests__/unit/services/agentRuntimeLogsDocker.test.js
@@ -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;
+ }
+});
diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts
index 9baeaf5355..fd24cf4a52 100644
--- a/backend/routes/registry/helpers.ts
+++ b/backend/routes/registry/helpers.ts
@@ -545,8 +545,7 @@ const buildRuntimeLogFilters = ({ runtimeType, agentName, instanceId }: { runtim
const accountId = normalizedAgent === 'openclaw'
? normalizedInstance
: `${normalizedAgent}-${normalizedInstance}`;
- const tokens = [normalizedInstance, accountId, normalizedAgent].filter(Boolean);
- return Array.from(new Set(tokens));
+ return [accountId];
};
const resolveGatewayForRequest = async ({ gatewayId, userId }: { gatewayId: any; userId: any }) => {
diff --git a/backend/routes/registry/runtime.ts b/backend/routes/registry/runtime.ts
index b46fa68304..119c29d03d 100644
--- a/backend/routes/registry/runtime.ts
+++ b/backend/routes/registry/runtime.ts
@@ -1,9 +1,10 @@
// Runtime management routes — extracted from registry.js (GH#112)
// Handles: runtime-status, runtime-start, runtime-stop, runtime-restart, runtime-clear-sessions, runtime-logs
export {};
+import rateLimit from 'express-rate-limit';
+import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit';
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');
@@ -18,9 +19,8 @@ const {
} = require('../../services/agentProvisionerService');
const {
getUserId,
- normalizeInstanceId,
normalizeConfigMap,
- resolveInstallation,
+ isGlobalAdminUser,
buildRuntimeLogFilters,
resolveGatewayForRequest,
resolveGatewayForInstallation,
@@ -28,43 +28,102 @@ const {
} = require('./helpers');
const runtimeRouter = express.Router({ mergeParams: true });
+const runtimeRouteLimit = rateLimit({
+ windowMs: 60_000,
+ max: 120,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: cloudflareIpRateLimitKeyGenerator,
+});
+
+const getRequestedInstanceId = (req: any): string => {
+ const rawInstanceId = req.method === 'GET' ? req.query.instanceId : req.body?.instanceId;
+ if (rawInstanceId && (typeof rawInstanceId === 'object' || typeof rawInstanceId === 'function')) {
+ const error: any = new Error('Invalid instance ID');
+ error.status = 400;
+ throw error;
+ }
+ return String(rawInstanceId || 'default');
+};
+
+const getAuditInstanceId = (req: any): string => {
+ try {
+ return getRequestedInstanceId(req);
+ } catch {
+ return 'invalid';
+ }
+};
+
+// Emit once after dispatch, using only route/identity metadata.
+const auditRuntimeRoute = (req: any, res: any, next: any) => {
+ res.once('finish', () => {
+ console.info(JSON.stringify({
+ event: 'registry.runtime',
+ route: req.route.path,
+ podId: req.params.podId,
+ agentName: req.params.name,
+ instanceId: getAuditInstanceId(req),
+ callerUserId: getUserId(req) || null,
+ outcome: res.statusCode < 400 ? 'allowed' : res.statusCode,
+ }));
+ });
+ next();
+};
+
+// Runtime routes share one authorization boundary before dispatching any
+// adapter action. Use exact installation identity rather than legacy aliases.
+const resolveAuthorizedRuntimeInstallation = async ({ podId, name, instanceId, userId }: any) => {
+ const pod = await Pod.findById(podId).lean();
+ if (!pod) {
+ const error: any = new Error('Pod not found');
+ error.status = 404;
+ throw error;
+ }
+ const isAdmin = await isGlobalAdminUser(userId);
+ if (!isAdmin && !userHasPodAccess(pod, userId)) {
+ const error: any = new Error('Access denied');
+ error.status = 403;
+ throw error;
+ }
+ const installation = await AgentInstallation.findOne({
+ agentName: String(name).toLowerCase(), podId, instanceId, status: { $ne: 'uninstalled' },
+ });
+ if (!installation) {
+ const error: any = new Error('Agent installation not found');
+ error.status = 404;
+ throw error;
+ }
+ if (!isAdmin && installation.installedBy?.toString() !== userId.toString()) {
+ const error: any = new Error('Access denied');
+ error.status = 403;
+ throw error;
+ }
+ return installation;
+};
/**
* GET /api/registry/pods/:podId/agents/:name/runtime-status
* Check local runtime status (docker).
*/
-runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, requireAdmin, async (req: any, res: any) => {
+runtimeRouter.get('/pods/:podId/agents/:name/runtime-status',
+ auditRuntimeRoute, runtimeRouteLimit, auth, 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 instanceId = getRequestedInstanceId(req);
+ const rawGatewayId = req.query.gatewayId;
+ if (rawGatewayId != null && typeof rawGatewayId !== 'string') {
+ return res.status(400).json({ error: 'Invalid gateway ID' });
}
+ const gatewayId = typeof rawGatewayId === 'string' ? rawGatewayId : null;
- const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
- agentName: name,
- podId,
- instanceId,
+ const installation = await resolveAuthorizedRuntimeInstallation({
+ podId, name, instanceId: instanceId, userId,
});
- const effectiveInstanceId = resolvedInstanceId || instanceId;
+ const effectiveInstanceId = installation.instanceId;
const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
@@ -95,38 +154,25 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, requireAdmin
/**
* POST /api/registry/pods/:podId/agents/:name/runtime-start
*/
-runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, requireAdmin, async (req: any, res: any) => {
+runtimeRouter.post('/pods/:podId/agents/:name/runtime-start',
+ auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
- const { instanceId, gatewayId } = req.body || {};
+ const { gatewayId: rawGatewayId } = req.body || {};
+ if (rawGatewayId != null && typeof rawGatewayId !== 'string') {
+ return res.status(400).json({ error: 'Invalid gateway ID' });
+ }
+ const gatewayId = typeof rawGatewayId === 'string' ? rawGatewayId : null;
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 normalizedInstanceId = getRequestedInstanceId(req);
- const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({
- agentName: name,
- podId,
- instanceId: normalizedInstanceId,
+ const installation = await resolveAuthorizedRuntimeInstallation({
+ podId, name, instanceId: normalizedInstanceId, userId,
});
- const effectiveInstanceId = resolvedInstanceId || normalizedInstanceId;
+ const effectiveInstanceId = installation.instanceId;
const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
@@ -157,38 +203,25 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, requireAdmin
/**
* POST /api/registry/pods/:podId/agents/:name/runtime-stop
*/
-runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, requireAdmin, async (req: any, res: any) => {
+runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop',
+ auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
- const { instanceId, gatewayId } = req.body || {};
+ const { gatewayId: rawGatewayId } = req.body || {};
+ if (rawGatewayId != null && typeof rawGatewayId !== 'string') {
+ return res.status(400).json({ error: 'Invalid gateway ID' });
+ }
+ const gatewayId = typeof rawGatewayId === 'string' ? rawGatewayId : null;
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
- const normalizedInstanceId = normalizeInstanceId(instanceId || 'default');
+ const normalizedInstanceId = getRequestedInstanceId(req);
- 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();
+ const installation = await resolveAuthorizedRuntimeInstallation({
+ podId, name, instanceId: normalizedInstanceId, userId,
});
-
- 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 effectiveInstanceId = installation.instanceId;
const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
@@ -219,38 +252,25 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, requireAdmin,
/**
* POST /api/registry/pods/:podId/agents/:name/runtime-restart
*/
-runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, requireAdmin, async (req: any, res: any) => {
+runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart',
+ auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
- const { instanceId, gatewayId } = req.body || {};
+ const { gatewayId: rawGatewayId } = req.body || {};
+ if (rawGatewayId != null && typeof rawGatewayId !== 'string') {
+ return res.status(400).json({ error: 'Invalid gateway ID' });
+ }
+ const gatewayId = typeof rawGatewayId === 'string' ? rawGatewayId : null;
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 normalizedInstanceId = getRequestedInstanceId(req);
- 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();
+ const installation = await resolveAuthorizedRuntimeInstallation({
+ podId, name, instanceId: normalizedInstanceId, userId,
});
-
- 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 effectiveInstanceId = installation.instanceId;
const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
@@ -282,38 +302,24 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, requireAdm
* POST /api/registry/pods/:podId/agents/:name/runtime-clear-sessions
*/
runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions',
- auth, requireAdmin, async (req: any, res: any) => {
+ auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => {
try {
const { podId, name } = req.params;
- const { instanceId, gatewayId, restart = true } = req.body || {};
+ const { gatewayId: rawGatewayId, restart = true } = req.body || {};
+ if (rawGatewayId != null && typeof rawGatewayId !== 'string') {
+ return res.status(400).json({ error: 'Invalid gateway ID' });
+ }
+ const gatewayId = typeof rawGatewayId === 'string' ? rawGatewayId : null;
const userId = getUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
- const normalizedInstanceId = normalizeInstanceId(instanceId || 'default');
+ const normalizedInstanceId = getRequestedInstanceId(req);
- 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();
+ const installation = await resolveAuthorizedRuntimeInstallation({
+ podId, name, instanceId: normalizedInstanceId, userId,
});
-
- 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 effectiveInstanceId = installation.instanceId;
const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
@@ -368,39 +374,28 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions',
/**
* GET /api/registry/pods/:podId/agents/:name/runtime-logs
*/
-runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, requireAdmin, async (req: any, res: any) => {
+runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs',
+ auditRuntimeRoute, runtimeRouteLimit, auth, 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 requestedLines = Number(req.query.lines ?? 200);
+ const lines = Number.isFinite(requestedLines)
+ ? Math.min(1000, Math.max(1, Math.trunc(requestedLines))) : 200;
+ const instanceId = getRequestedInstanceId(req);
+ const rawGatewayId = req.query.gatewayId;
+ if (rawGatewayId != null && typeof rawGatewayId !== 'string') {
+ return res.status(400).json({ error: 'Invalid gateway ID' });
+ }
+ const gatewayId = typeof rawGatewayId === 'string' ? rawGatewayId : 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 installation = await resolveAuthorizedRuntimeInstallation({
+ podId, name, instanceId: instanceId, userId,
});
- const effectiveInstanceId = resolvedInstanceId || instanceId;
+ const effectiveInstanceId = installation.instanceId;
const typeConfig = AgentIdentityService.getAgentTypeConfig(name);
const runtimeType = typeConfig?.runtime;
diff --git a/backend/services/agentProvisionerService.ts b/backend/services/agentProvisionerService.ts
index 5d7ab98db0..a9e8fd617c 100644
--- a/backend/services/agentProvisionerService.ts
+++ b/backend/services/agentProvisionerService.ts
@@ -1,4 +1,5 @@
export {};
+import { filterRuntimeAccountLogs } from '../utils/runtimeLogFilter';
const fs = require('fs');
const path = require('path');
@@ -1660,7 +1661,8 @@ const getAgentRuntimeLogs = async (runtimeType: any, instanceId: any, lines = 20
const k8sProvisioner = require('./agentProvisionerServiceK8s');
return k8sProvisioner.getAgentRuntimeLogs(runtimeType, instanceId, lines, options);
}
- return getDockerRuntimeLogs(runtimeType, lines);
+ const result = await getDockerRuntimeLogs(runtimeType, lines);
+ return { ...result, logs: filterRuntimeAccountLogs(result.logs, (options as any).filterTokens || []) };
};
const getAgentSessionSizes = async (options = {}) => {
diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts
index a02d96e030..54cc206114 100644
--- a/backend/services/agentProvisionerServiceK8s.ts
+++ b/backend/services/agentProvisionerServiceK8s.ts
@@ -1,3 +1,4 @@
+import { filterRuntimeAccountLogs } from '../utils/runtimeLogFilter';
/**
* Kubernetes-native Agent Provisioner Service
* Replaces Docker socket mounting with K8s API for agent runtime provisioning
@@ -3167,19 +3168,7 @@ const getDeploymentLogs = async ({ deploymentName, lines, filterTokens = [] }: a
undefined,
lines,
);
- let logs = logsResponse.body || '';
- const tokens = (filterTokens || []).map((t: any) => String(t || '').trim()).filter(Boolean);
- if (tokens.length) {
- logs = logs
- .split('\n')
- .filter((line: any) => {
- if (!line) return false;
- if (tokens.some((token: any) => line.includes(`[commonly] [${token}]`))) return true;
- if (tokens.some((token: any) => line.includes(token))) return true;
- return false;
- })
- .join('\n');
- }
+ const logs = filterRuntimeAccountLogs(logsResponse.body || '', filterTokens);
return { logs, pod: pod.metadata.name, deployment: deploymentName };
} catch (error: any) {
console.error(`[k8s-provisioner] Failed to get logs for ${deploymentName}:`, (error as Error).message);
diff --git a/backend/utils/runtimeLogFilter.ts b/backend/utils/runtimeLogFilter.ts
new file mode 100644
index 0000000000..eeacff5ed5
--- /dev/null
+++ b/backend/utils/runtimeLogFilter.ts
@@ -0,0 +1,11 @@
+// Shared gateway logs are visible only through the requested account tags.
+export const filterRuntimeAccountLogs = (logs: string, filterTokens: unknown[] = []): string => {
+ const tokens = filterTokens.map((token) => String(token || '').trim()).filter(Boolean);
+ if (!tokens.length) return logs;
+ return logs.split('\n')
+ .filter((line) => {
+ const account = line.match(/\[commonly\] \[([^\]]+)\]/);
+ return Boolean(account && tokens.includes(account[1]));
+ })
+ .join('\n');
+};
diff --git a/frontend/src/components/agents/AgentsHub.runtime.test.tsx b/frontend/src/components/agents/AgentsHub.runtime.test.tsx
new file mode 100644
index 0000000000..7b571eaea8
--- /dev/null
+++ b/frontend/src/components/agents/AgentsHub.runtime.test.tsx
@@ -0,0 +1,40 @@
+// @ts-nocheck
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import axios from 'axios';
+import AgentsHub from './AgentsHub';
+
+jest.mock('axios');
+jest.mock('../../context/AuthContext', () => ({
+ useAuth: () => ({ currentUser: { _id: 'owner', role: 'user' } }),
+}));
+jest.mock('./ClawdbotConfigPanel', () => () => null);
+jest.mock('../admin/AgentEventsDebugPage', () => () => null);
+jest.mock('./AgentCard', () => ({ agent, installed, onConfigure }) => (
+ installed ? : null
+));
+
+it('keeps a template installation’s stored instance when opening runtime controls', async () => {
+ const installation = {
+ name: 'template-agent', displayName: 'Renamed Template', templateId: 'template-1',
+ instanceId: 'original-stored-instance', installedBy: 'owner', status: 'active',
+ };
+ axios.get.mockImplementation(async (url) => {
+ if (url === '/api/pods') return { data: [{ _id: 'pod-1', name: 'Pod', createdBy: 'owner', members: ['owner'] }] };
+ if (url.startsWith('/api/registry/agents?')) return { data: { agents: [{ name: 'openclaw' }, { name: 'commonly-summarizer' }] } };
+ if (url === '/api/registry/pods/pod-1/agents') return { data: { agents: [installation] } };
+ if (url.includes('/agents/template-agent?')) return { data: { agent: installation } };
+ return { data: { templates: [], gateways: [], tokens: [], status: 'running' } };
+ });
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'Configure Renamed Template' }));
+ await waitFor(() => expect(axios.get).toHaveBeenCalledWith(
+ '/api/registry/pods/pod-1/agents/template-agent?instanceId=original-stored-instance', expect.anything(),
+ ));
+ fireEvent.click(await screen.findByRole('button', { name: 'Refresh Status', hidden: true }));
+ await waitFor(() => expect(axios.get).toHaveBeenCalledWith(
+ '/api/registry/pods/pod-1/agents/template-agent/runtime-status',
+ expect.objectContaining({ params: { instanceId: 'original-stored-instance' } }),
+ ));
+});