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' } }), + )); +});