From ff2168052f76fd37318935a3b5c7ec6b3971fbf4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:27:04 -0700 Subject: [PATCH 1/5] fix(registry): tighten runtime route authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by Kai, a Commonly agent — https://commonly.me/v2/pods/6addd4c17f8fc798a6f66431 Co-authored-by: kai Agent-Model: codex/gpt-6.1-sol/medium --- .../registryRuntimeAuthorization.test.js | 110 +++++++++++ backend/routes/registry/runtime.ts | 177 +++++------------- 2 files changed, 160 insertions(+), 127 deletions(-) create mode 100644 backend/__tests__/service/registryRuntimeAuthorization.test.js diff --git a/backend/__tests__/service/registryRuntimeAuthorization.test.js b/backend/__tests__/service/registryRuntimeAuthorization.test.js new file mode 100644 index 0000000000..74bc581d6a --- /dev/null +++ b/backend/__tests__/service/registryRuntimeAuthorization.test.js @@ -0,0 +1,110 @@ +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'); + +// 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 owner; + let member; + let creator; + let admin; + let pod; + let ownPod; + let installation; + 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(app)[method](url).set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`); + 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); + }); + beforeEach(() => { jest.clearAllMocks(); }); + afterAll(async () => { await clearMongoDb(); await closeMongoDb(); }); + + 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('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('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/routes/registry/runtime.ts b/backend/routes/registry/runtime.ts index 9c33042536..5e32e8aba7 100644 --- a/backend/routes/registry/runtime.ts +++ b/backend/routes/registry/runtime.ts @@ -19,7 +19,7 @@ const { getUserId, normalizeInstanceId, normalizeConfigMap, - resolveInstallation, + isGlobalAdminUser, buildRuntimeLogFilters, resolveGatewayForRequest, resolveGatewayForInstallation, @@ -28,6 +28,37 @@ const { const runtimeRouter = express.Router({ mergeParams: true }); +// 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). @@ -42,28 +73,10 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, async (req: const instanceId = normalizeInstanceId(req.query.instanceId || 'default'); const gatewayId = req.query.gatewayId || null; - const pod = await Pod.findById(podId).lean(); - if (!pod) { - return res.status(404).json({ error: 'Pod not found' }); - } - - const isCreator = pod.createdBy?.toString() === userId.toString(); - const membership = pod.members?.find((m: any) => { - if (!m) return false; - const memberId = m.userId?.toString?.() || m.toString?.(); - return memberId && memberId === userId.toString(); - }); - - if (!membership && !isCreator) { - return res.status(403).json({ error: 'Access denied' }); - } - - const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({ - agentName: name, - podId, - instanceId, + const 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; @@ -104,28 +117,10 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, async (req: } const normalizedInstanceId = normalizeInstanceId(instanceId || 'default'); - const pod = await Pod.findById(podId).lean(); - if (!pod) { - return res.status(404).json({ error: 'Pod not found' }); - } - - const isCreator = pod.createdBy?.toString() === userId.toString(); - const membership = pod.members?.find((m: any) => { - if (!m) return false; - const memberId = m.userId?.toString?.() || m.toString?.(); - return memberId && memberId === userId.toString(); - }); - - if (!membership && !isCreator) { - return res.status(403).json({ error: 'Access denied' }); - } - - const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({ - agentName: name, - podId, - instanceId: normalizedInstanceId, + const 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; @@ -166,28 +161,10 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, async (req: a } 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(); + 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; @@ -228,28 +205,10 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, async (req } const normalizedInstanceId = normalizeInstanceId(instanceId || 'default'); - const pod = await Pod.findById(podId).lean(); - if (!pod) { - return res.status(404).json({ error: 'Pod not found' }); - } - - const isCreator = pod.createdBy?.toString() === userId.toString(); - const membership = pod.members?.find((m: any) => { - if (!m) return false; - const memberId = m.userId?.toString?.() || m.toString?.(); - return memberId && memberId === userId.toString(); - }); - - if (!membership && !isCreator) { - return res.status(403).json({ error: 'Access denied' }); - } - - const { installation, instanceId: resolvedInstanceId } = await resolveInstallation({ - agentName: name, - podId, - instanceId: normalizedInstanceId, + const 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; @@ -290,28 +249,10 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auth, asy } 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(); + 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; @@ -377,28 +318,10 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, async (req: an 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; From 59edbb3d4e40a5ad184092fd826ef040cb53d2fd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:38:41 -0700 Subject: [PATCH 2/5] fix(registry): apply consistent runtime request limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by Kai, a Commonly agent — https://commonly.me/v2/pods/6addd4c17f8fc798a6f66431 Co-authored-by: kai Agent-Model: codex/gpt-6.1-sol/medium --- .../registryRuntimeAuthorization.test.js | 27 +++++++++++++++++-- .../routes/routeRateLimitGuard.baseline.json | 6 ----- backend/routes/registry/runtime.ts | 22 ++++++++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/service/registryRuntimeAuthorization.test.js b/backend/__tests__/service/registryRuntimeAuthorization.test.js index 74bc581d6a..30c0e6b522 100644 --- a/backend/__tests__/service/registryRuntimeAuthorization.test.js +++ b/backend/__tests__/service/registryRuntimeAuthorization.test.js @@ -31,6 +31,7 @@ const SECRET = 'runtime-route-authorization-test'; describe('registry runtime authorization', () => { let app; + let server; let owner; let member; let creator; @@ -41,7 +42,7 @@ describe('registry runtime authorization', () => { 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(app)[method](url).set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`); + const req = request(server)[method](url).set('Authorization', `Bearer ${jwt.sign({ id: String(user._id) }, SECRET)}`); return method === 'get' ? req.query({ instanceId }) : req.send({ instanceId }); }; @@ -64,15 +65,37 @@ describe('registry runtime authorization', () => { 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(); }); - afterAll(async () => { await clearMongoDb(); await closeMongoDb(); }); + 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); + }); 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); 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/routes/registry/runtime.ts b/backend/routes/registry/runtime.ts index 5e32e8aba7..0a247979f7 100644 --- a/backend/routes/registry/runtime.ts +++ b/backend/routes/registry/runtime.ts @@ -1,6 +1,8 @@ // 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 Pod = require('../../models/Pod'); @@ -27,6 +29,13 @@ const { } = require('./helpers'); const runtimeRouter = express.Router({ mergeParams: true }); +const runtimeRouteLimit = rateLimit({ + windowMs: 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); // Runtime routes share one authorization boundary before dispatching any // adapter action. Use exact installation identity rather than legacy aliases. @@ -63,7 +72,7 @@ const resolveAuthorizedRuntimeInstallation = async ({ podId, name, instanceId, u * GET /api/registry/pods/:podId/agents/:name/runtime-status * Check local runtime status (docker). */ -runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, async (req: any, res: any) => { +runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const userId = getUserId(req); @@ -107,7 +116,7 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', auth, async (req: /** * POST /api/registry/pods/:podId/agents/:name/runtime-start */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId } = req.body || {}; @@ -151,7 +160,7 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auth, async (req: /** * POST /api/registry/pods/:podId/agents/:name/runtime-stop */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId } = req.body || {}; @@ -195,7 +204,7 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auth, async (req: a /** * POST /api/registry/pods/:podId/agents/:name/runtime-restart */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId } = req.body || {}; @@ -239,7 +248,8 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auth, async (req /** * POST /api/registry/pods/:podId/agents/:name/runtime-clear-sessions */ -runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auth, async (req: any, res: any) => { +runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', + runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const { instanceId, gatewayId, restart = true } = req.body || {}; @@ -307,7 +317,7 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auth, asy /** * GET /api/registry/pods/:podId/agents/:name/runtime-logs */ -runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', auth, async (req: any, res: any) => { +runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const lines = Number(req.query.lines || 200); From b8ad62e5a7bc9e4b967f90a8059d456a6eb2b067 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:45:47 -0700 Subject: [PATCH 3/5] Preserve stored runtime instance identifiers Co-authored-by: kai Agent-Model: codex/gpt-6.1-sol/medium --- .../registryRuntimeAuthorization.test.js | 39 +++++++++++++++++++ backend/routes/registry/runtime.ts | 32 ++++++++------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/backend/__tests__/service/registryRuntimeAuthorization.test.js b/backend/__tests__/service/registryRuntimeAuthorization.test.js index 4b4331f4bc..47fc7e56cf 100644 --- a/backend/__tests__/service/registryRuntimeAuthorization.test.js +++ b/backend/__tests__/service/registryRuntimeAuthorization.test.js @@ -89,6 +89,25 @@ describe('registry runtime authorization', () => { expect(effects()).toBe(0); }); + it('coerces a body instance value instead of accepting a Mongo query operator', async () => { + expect((await call('post', 'runtime-start', owner, { instanceId: { $ne: null } })).status).toBe(404); + 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' }); } + }); + 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); @@ -141,6 +160,26 @@ describe('registry runtime authorization', () => { }); }, ); + 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 an uninstalled record', async () => { await AgentInstallation.updateOne({ _id: installation._id }, { status: 'uninstalled' }); try { diff --git a/backend/routes/registry/runtime.ts b/backend/routes/registry/runtime.ts index 7018e01370..7904535b0f 100644 --- a/backend/routes/registry/runtime.ts +++ b/backend/routes/registry/runtime.ts @@ -19,7 +19,6 @@ const { } = require('../../services/agentProvisionerService'); const { getUserId, - normalizeInstanceId, normalizeConfigMap, isGlobalAdminUser, buildRuntimeLogFilters, @@ -37,6 +36,11 @@ const runtimeRouteLimit = rateLimit({ keyGenerator: cloudflareIpRateLimitKeyGenerator, }); +const getRequestedInstanceId = (req: any): string => { + const rawInstanceId = req.method === 'GET' ? req.query.instanceId : req.body?.instanceId; + return String(rawInstanceId || 'default'); +}; + // Emit once after dispatch, using only route/identity metadata. const auditRuntimeRoute = (req: any, res: any, next: any) => { res.once('finish', () => { @@ -45,7 +49,7 @@ const auditRuntimeRoute = (req: any, res: any, next: any) => { route: req.route.path, podId: req.params.podId, agentName: req.params.name, - instanceId: normalizeInstanceId(req.body?.instanceId || req.query.instanceId || 'default'), + instanceId: getRequestedInstanceId(req), callerUserId: getUserId(req) || null, outcome: res.statusCode < 400 ? 'allowed' : res.statusCode, })); @@ -96,7 +100,7 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } - const instanceId = normalizeInstanceId(req.query.instanceId || 'default'); + const instanceId = getRequestedInstanceId(req); const gatewayId = req.query.gatewayId || null; const installation = await resolveAuthorizedRuntimeInstallation({ @@ -137,12 +141,12 @@ 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 } = req.body || {}; const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } - const normalizedInstanceId = normalizeInstanceId(instanceId || 'default'); + const normalizedInstanceId = getRequestedInstanceId(req); const installation = await resolveAuthorizedRuntimeInstallation({ podId, name, instanceId: normalizedInstanceId, userId, @@ -182,12 +186,12 @@ 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 } = req.body || {}; const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } - const normalizedInstanceId = normalizeInstanceId(instanceId || 'default'); + const normalizedInstanceId = getRequestedInstanceId(req); const installation = await resolveAuthorizedRuntimeInstallation({ podId, name, instanceId: normalizedInstanceId, userId, @@ -227,12 +231,12 @@ 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 } = req.body || {}; const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } - const normalizedInstanceId = normalizeInstanceId(instanceId || 'default'); + const normalizedInstanceId = getRequestedInstanceId(req); const installation = await resolveAuthorizedRuntimeInstallation({ podId, name, instanceId: normalizedInstanceId, userId, @@ -272,12 +276,12 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; - const { instanceId, gatewayId, restart = true } = req.body || {}; + const { gatewayId, restart = true } = req.body || {}; const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } - const normalizedInstanceId = normalizeInstanceId(instanceId || 'default'); + const normalizedInstanceId = getRequestedInstanceId(req); const installation = await resolveAuthorizedRuntimeInstallation({ podId, name, instanceId: normalizedInstanceId, userId, @@ -341,8 +345,10 @@ 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 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 gatewayId = req.query.gatewayId || null; const userId = getUserId(req); if (!userId) { From 839b551baee99cf6fb6b6c502eaaad7134b1da2c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:15:46 -0700 Subject: [PATCH 4/5] Validate registry runtime gateway selectors Co-authored-by: kai Agent-Model: codex/gpt-6.1-sol/medium --- .../registryRuntimeAuthorization.test.js | 29 +++++++++++++++ backend/routes/registry/runtime.ts | 36 +++++++++++++++---- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/service/registryRuntimeAuthorization.test.js b/backend/__tests__/service/registryRuntimeAuthorization.test.js index 47fc7e56cf..c617376eb3 100644 --- a/backend/__tests__/service/registryRuntimeAuthorization.test.js +++ b/backend/__tests__/service/registryRuntimeAuthorization.test.js @@ -4,6 +4,7 @@ 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, @@ -108,6 +109,21 @@ describe('registry runtime authorization', () => { 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); @@ -180,6 +196,19 @@ describe('registry runtime authorization', () => { 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(); } + }); it('refuses an uninstalled record', async () => { await AgentInstallation.updateOne({ _id: installation._id }, { status: 'uninstalled' }); try { diff --git a/backend/routes/registry/runtime.ts b/backend/routes/registry/runtime.ts index 7904535b0f..fd14a8626d 100644 --- a/backend/routes/registry/runtime.ts +++ b/backend/routes/registry/runtime.ts @@ -101,7 +101,11 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-status', return res.status(401).json({ error: 'Unauthorized' }); } const instanceId = getRequestedInstanceId(req); - const gatewayId = req.query.gatewayId || null; + 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 = await resolveAuthorizedRuntimeInstallation({ podId, name, instanceId: instanceId, userId, @@ -141,7 +145,11 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-start', auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; - const { 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' }); @@ -186,7 +194,11 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-stop', auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; - const { 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' }); @@ -231,7 +243,11 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-restart', auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; - const { 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' }); @@ -276,7 +292,11 @@ runtimeRouter.post('/pods/:podId/agents/:name/runtime-clear-sessions', auditRuntimeRoute, runtimeRouteLimit, auth, async (req: any, res: any) => { try { const { podId, name } = req.params; - const { 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' }); @@ -349,7 +369,11 @@ runtimeRouter.get('/pods/:podId/agents/:name/runtime-logs', const lines = Number.isFinite(requestedLines) ? Math.min(1000, Math.max(1, Math.trunc(requestedLines))) : 200; const instanceId = getRequestedInstanceId(req); - const gatewayId = req.query.gatewayId || null; + 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' }); From 702e6f7a7853d18b296ddb627a1d0901f4df1c59 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:47:11 -0700 Subject: [PATCH 5/5] Handle registry runtime metadata consistently Co-authored-by: kai Agent-Model: codex/gpt-6.1-sol/medium --- .../registryRuntimeAuthorization.test.js | 18 +++++++++-- .../agentProvisionerServiceK8s.test.js | 24 ++++++++++++++- .../services/agentRuntimeLogsDocker.test.js | 30 +++++++++++++++++++ backend/routes/registry/helpers.ts | 3 +- backend/routes/registry/runtime.ts | 15 +++++++++- backend/services/agentProvisionerService.ts | 4 ++- .../services/agentProvisionerServiceK8s.ts | 15 ++-------- backend/utils/runtimeLogFilter.ts | 11 +++++++ 8 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 backend/__tests__/unit/services/agentRuntimeLogsDocker.test.js create mode 100644 backend/utils/runtimeLogFilter.ts diff --git a/backend/__tests__/service/registryRuntimeAuthorization.test.js b/backend/__tests__/service/registryRuntimeAuthorization.test.js index c617376eb3..c7380eed0d 100644 --- a/backend/__tests__/service/registryRuntimeAuthorization.test.js +++ b/backend/__tests__/service/registryRuntimeAuthorization.test.js @@ -44,7 +44,8 @@ describe('registry runtime authorization', () => { 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)}`); + 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 }); }; @@ -90,8 +91,8 @@ describe('registry runtime authorization', () => { expect(effects()).toBe(0); }); - it('coerces a body instance value instead of accepting a Mongo query operator', async () => { - expect((await call('post', 'runtime-start', owner, { instanceId: { $ne: null } })).status).toBe(404); + 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]])( @@ -209,6 +210,17 @@ describe('registry runtime authorization', () => { 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 { 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 fd14a8626d..119c29d03d 100644 --- a/backend/routes/registry/runtime.ts +++ b/backend/routes/registry/runtime.ts @@ -38,9 +38,22 @@ const runtimeRouteLimit = rateLimit({ 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', () => { @@ -49,7 +62,7 @@ const auditRuntimeRoute = (req: any, res: any, next: any) => { route: req.route.path, podId: req.params.podId, agentName: req.params.name, - instanceId: getRequestedInstanceId(req), + instanceId: getAuditInstanceId(req), callerUserId: getUserId(req) || null, outcome: res.statusCode < 400 ? 'allowed' : res.statusCode, })); 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'); +};