Repository navigation
TASK-391: Add Wake and Pods agent settings #2519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b05b2d3
5398051
7cbaa4b
05fc7be
caac724
66228f1
b3c5503
cee481d
1da297f
4feb1a7
762b2e5
e941818
59de3c2
002bc59
eb70de9
dca9d03
42a754a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| const express = require('express'); | ||
| const request = require('supertest'); | ||
| const jwt = require('jsonwebtoken'); | ||
| const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); | ||
| const User = require('../../models/User'); | ||
| const Pod = require('../../models/Pod'); | ||
| const { AgentInstallation } = require('../../models/AgentRegistry'); | ||
| const AgentMemory = require('../../models/AgentMemory'); | ||
| const manageSettings = require('../../routes/registry/manage-settings'); | ||
| const podAgents = require('../../routes/registry/pod-agents'); | ||
|
|
||
| const SECRET = 'agent-manage-settings-test'; | ||
| const token = (user) => jwt.sign({ id: String(user._id) }, SECRET); | ||
| const config = (installation) => Object.fromEntries(installation.config || []); | ||
|
|
||
| describe('per-installation agent settings', () => { | ||
| let app; let owner; let other; let admin; let podA; let podB; | ||
|
|
||
| beforeAll(async () => { | ||
| process.env.JWT_SECRET = SECRET; | ||
| await setupMongoDb(); | ||
| app = express(); | ||
| app.use(express.json()); | ||
| app.use('/api/registry', manageSettings); | ||
| app.use('/api/registry', podAgents); | ||
| owner = await User.create({ username: 'settings-owner', email: 'settings-owner@test.com', password: 'password123' }); | ||
| other = await User.create({ username: 'settings-other', email: 'settings-other@test.com', password: 'password123' }); | ||
| admin = await User.create({ username: 'settings-admin', email: 'settings-admin@test.com', password: 'password123', role: 'admin' }); | ||
| podA = await Pod.create({ name: 'Settings A', type: 'chat', createdBy: owner._id, members: [owner._id, other._id] }); | ||
| podB = await Pod.create({ name: 'Settings B', type: 'chat', createdBy: other._id, members: [owner._id, other._id] }); | ||
| }); | ||
|
|
||
| afterAll(async () => closeMongoDb()); | ||
|
|
||
| beforeEach(async () => { | ||
| await AgentInstallation.deleteMany({ agentName: 'settings-agent' }); | ||
| await AgentInstallation.create([ | ||
| { agentName: 'settings-agent', instanceId: 'same', podId: podA._id, version: '1', installedBy: owner._id, | ||
| config: new Map([['wakeOnMessage', { enabled: true, humanOnly: true }], ['boardWake', { enabled: false }], ['runtime', { runtimeType: 'wrapper' }]]) }, | ||
| { agentName: 'settings-agent', instanceId: 'same', podId: podB._id, version: '1', installedBy: other._id, | ||
| config: new Map([['wakeOnMessage', { enabled: false }], ['runtime', { runtimeType: 'wrapper' }]]) }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('lists only installations the caller may manage', async () => { | ||
| const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') | ||
| .set('Authorization', `Bearer ${token(owner)}`); | ||
| expect(response.status).toBe(200); | ||
| expect(response.body.installations).toEqual([expect.objectContaining({ | ||
| podId: String(podA._id), status: 'active', wakeOnMessage: true, humanOnly: true, boardWake: false, | ||
| heartbeatEnabled: false, heartbeatMinutes: 60, | ||
| })]); | ||
| expect(response.body.activePodCount).toBe(2); | ||
| expect(response.body.podCount).toBe(2); | ||
| expect(response.body.turnCap).toBe(30); | ||
| expect(response.body.writablePodId).toBe(String(podA._id)); | ||
| const otherResponse = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') | ||
| .set('Authorization', `Bearer ${token(other)}`); | ||
| expect(otherResponse.body.writablePodId).toBe(String(podB._id)); | ||
| }); | ||
|
|
||
| it('counts active connections separately from installed pod rows', async () => { | ||
| await AgentInstallation.updateOne({ podId: podB._id, agentName: 'settings-agent' }, { status: 'paused' }); | ||
| const response = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') | ||
| .set('Authorization', `Bearer ${token(admin)}`); | ||
| expect(response.status).toBe(200); | ||
| expect(response.body.podCount).toBe(2); | ||
| expect(response.body.activePodCount).toBe(1); | ||
| expect(response.body.installations.find((entry) => entry.podId === String(podB._id)).status).toBe('paused'); | ||
| }); | ||
|
|
||
| it('writes one installation and preserves its human-only default and runtime', async () => { | ||
| const response = await request(app) | ||
| .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`) | ||
| .set('Authorization', `Bearer ${token(owner)}`) | ||
| .send({ wakeOnMessage: false, humanOnly: false, boardWake: true, | ||
| heartbeatEnabled: true, heartbeatMinutes: 90 }); | ||
| expect(response.status).toBe(200); | ||
| const [a, b] = await Promise.all([ | ||
| AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }), | ||
| AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' }), | ||
| ]); | ||
| expect(config(a).wakeOnMessage).toEqual({ enabled: false, humanOnly: false }); | ||
| expect(config(a).boardWake).toEqual({ enabled: true }); | ||
| expect(config(a).heartbeat).toEqual({ enabled: true, everyMinutes: 90 }); | ||
| expect(config(a).runtime).toEqual({ runtimeType: 'wrapper' }); | ||
| expect(config(b).wakeOnMessage).toEqual({ enabled: false }); | ||
| expect(config(b).heartbeat).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('refuses another pod member and invalid intervals before writing', async () => { | ||
| const url = `/api/registry/agents/settings-agent/manage-settings/pods/${podA._id}?instanceId=same`; | ||
| const body = { wakeOnMessage: false, humanOnly: true, boardWake: true, | ||
| heartbeatEnabled: true, heartbeatMinutes: 30 }; | ||
| const denied = await request(app).patch(url).set('Authorization', `Bearer ${token(other)}`).send(body); | ||
| const malformed = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) | ||
| .send({ ...body, heartbeatMinutes: 0 }); | ||
| expect(denied.status).toBe(403); | ||
| expect(malformed.status).toBe(400); | ||
| const unchanged = await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' }); | ||
| expect(config(unchanged).wakeOnMessage).toEqual({ enabled: true, humanOnly: true }); | ||
| }); | ||
|
|
||
| it('lets an instance admin manage an installation they did not create', async () => { | ||
| const listed = await request(app).get('/api/registry/agents/settings-agent/manage-settings?instanceId=same') | ||
| .set('Authorization', `Bearer ${token(admin)}`); | ||
| expect(listed.status).toBe(200); | ||
| expect(listed.body.installations).toHaveLength(2); | ||
| const saved = await request(app) | ||
| .patch(`/api/registry/agents/settings-agent/manage-settings/pods/${podB._id}?instanceId=same`) | ||
| .set('Authorization', `Bearer ${token(admin)}`) | ||
| .send({ wakeOnMessage: true, humanOnly: false, boardWake: false, | ||
| heartbeatEnabled: true, heartbeatMinutes: 120 }); | ||
| expect(saved.status).toBe(200); | ||
| expect(config(await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).heartbeat) | ||
| .toEqual({ enabled: true, everyMinutes: 120 }); | ||
| }); | ||
|
|
||
| it('removes one pod installation while keeping the agent identity and memory', async () => { | ||
| const bot = await User.create({ | ||
| username: 'settings-agent-same', email: 'settings-agent@test.com', password: 'password123', | ||
| isBot: true, botMetadata: { agentName: 'settings-agent', instanceId: 'same' }, | ||
| }); | ||
| const memory = await AgentMemory.create({ | ||
| agentName: 'settings-agent', instanceId: 'same', | ||
| sections: { long_term: { content: 'Remember this', visibility: 'private', byteSize: 13 } }, | ||
| }); | ||
| const response = await request(app) | ||
| .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) | ||
| .set('Authorization', `Bearer ${token(owner)}`); | ||
| expect(response.status).toBe(200); | ||
| expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); | ||
| expect((await AgentInstallation.findOne({ podId: podB._id, agentName: 'settings-agent' })).status).toBe('active'); | ||
| expect(await User.exists({ _id: bot._id })).toBeTruthy(); | ||
| expect(await AgentMemory.exists({ _id: memory._id })).toBeTruthy(); | ||
| }); | ||
|
|
||
| it('keeps the pod creator’s authority to remove an installation', async () => { | ||
| await AgentInstallation.updateOne({ podId: podA._id, agentName: 'settings-agent' }, { installedBy: other._id }); | ||
| const response = await request(app) | ||
| .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=same`) | ||
| .set('Authorization', `Bearer ${token(owner)}`); | ||
| expect(response.status).toBe(200); | ||
| expect((await AgentInstallation.findOne({ podId: podA._id, agentName: 'settings-agent' })).status).toBe('uninstalled'); | ||
| }); | ||
|
|
||
| it('never removes another instance when the requested instance is missing', async () => { | ||
| const response = await request(app) | ||
| .delete(`/api/registry/agents/settings-agent/pods/${podA._id}?instanceId=missing`) | ||
| .set('Authorization', `Bearer ${token(owner)}`); | ||
| expect(response.status).toBe(404); | ||
| expect((await AgentInstallation.findOne({ | ||
| podId: podA._id, agentName: 'settings-agent', instanceId: 'same', | ||
| })).status).toBe('active'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| const { normalizeInstanceId } = require('../../../routes/registry/helpers'); | ||
|
|
||
| it('collapses separators and trims the edges of an instance ID', () => { | ||
| expect(normalizeInstanceId(' ---Machine -- 2--- ')).toBe('machine-2'); | ||
| expect(normalizeInstanceId('---')).toBe('default'); | ||
| expect(normalizeInstanceId(null)).toBe('default'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| const { buildAgentInstallationPayload } = require('../../../routes/registry/helpers'); | ||
|
|
||
| it('projects wake and heartbeat settings for the shared Settings reader', () => { | ||
| const payload = buildAgentInstallationPayload({ | ||
| agentName: 'test-agent', instanceId: 'default', podId: 'pod-1', installedBy: 'owner', | ||
| config: new Map([ | ||
| ['wakeOnMessage', { enabled: true, humanOnly: true }], | ||
| ['boardWake', { enabled: false }], | ||
| ['heartbeat', { everyMinutes: 90 }], | ||
| ['runtime', { runtimeType: 'wrapper', webhookSecret: 'never-return' }], | ||
| ]), | ||
| }); | ||
| expect(payload.config).toEqual(expect.objectContaining({ | ||
| wakeOnMessage: { enabled: true, humanOnly: true }, | ||
| boardWake: { enabled: false }, | ||
| heartbeat: { everyMinutes: 90 }, | ||
| })); | ||
| expect(JSON.stringify(payload)).not.toContain('never-return'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // Owner/admin controls for one agent installation at a time. | ||
| import rateLimit from 'express-rate-limit'; | ||
| import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; | ||
| import { hostedCapsForOwner } from '../../services/hostedRuntimeService'; | ||
| const express = require('express'); | ||
| const auth = require('../../middleware/auth'); | ||
| const { AgentInstallation } = require('../../models/AgentRegistry'); | ||
| const Pod = require('../../models/Pod'); | ||
| const User = require('../../models/User'); | ||
| const { getUserId, normalizeConfigMap, normalizeInstanceId } = require('./helpers'); | ||
|
|
||
| const router = express.Router(); | ||
| const settingsRateLimit = rateLimit({ | ||
| windowMs: 60_000, | ||
| max: 120, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| skip: () => process.env.NODE_ENV === 'test', | ||
| keyGenerator: (req: unknown) => cloudflareIpRateLimitKeyGenerator(req as never), | ||
| handler: (_req: unknown, res: any) => res.status(429).json({ code: 'rate_limited' }), | ||
| }); | ||
|
|
||
| const canManage = (installation: any, userId: string, isAdmin: boolean) => ( | ||
| isAdmin || String(installation.installedBy) === String(userId) | ||
| ); | ||
|
|
||
| const settings = (installation: any, podName: string) => { | ||
| const config = normalizeConfigMap(installation.config) || {}; | ||
| const wakeOnMessage = config.wakeOnMessage?.enabled === true; | ||
| return { | ||
| podId: String(installation.podId), | ||
| podName, | ||
| status: installation.status, | ||
| wakeOnMessage, | ||
| humanOnly: config.wakeOnMessage?.humanOnly === true, | ||
| boardWake: typeof config.boardWake?.enabled === 'boolean' | ||
| ? config.boardWake.enabled : wakeOnMessage, | ||
| heartbeatEnabled: config.heartbeat?.enabled === true, | ||
| heartbeatMinutes: Number.isFinite(Number(config.heartbeat?.everyMinutes)) | ||
| && Number(config.heartbeat?.everyMinutes) > 0 | ||
| ? Math.max(1, Math.min(1440, Math.trunc(Number(config.heartbeat.everyMinutes)))) : 60, | ||
| }; | ||
| }; | ||
|
|
||
| router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: any, res: any) => { | ||
| try { | ||
| const userId = getUserId(req); | ||
| if (!userId) return res.status(401).json({ error: 'Unauthorized' }); | ||
| const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; | ||
| const installations = await AgentInstallation.find({ | ||
| agentName: String(req.params.name).toLowerCase(), | ||
| instanceId: normalizeInstanceId(req.query.instanceId), | ||
| status: { $ne: 'uninstalled' }, | ||
| }).lean(); | ||
| const manageable = installations.filter((entry: any) => canManage(entry, userId, isAdmin)); | ||
| const pods = await Pod.find({ _id: { $in: manageable.map((entry: any) => entry.podId) } }) | ||
| .select('name').lean(); | ||
| const names = new Map<string, string>(pods.map((pod: any) => [String(pod._id), String(pod.name)])); | ||
| const visible = manageable.filter((entry: any) => names.has(String(entry.podId))); | ||
| const writable = visible.find((entry: any) => entry.status === 'active') || visible[0]; | ||
| const { turnsPerDay } = await hostedCapsForOwner(userId); | ||
| return res.json({ turnCap: turnsPerDay, podCount: installations.length, | ||
| activePodCount: installations.filter((entry: any) => entry.status === 'active').length, | ||
| writablePodId: writable ? String(writable.podId) : null, | ||
| installations: visible | ||
| .map((entry: any) => settings(entry, names.get(String(entry.podId)) || 'Pod')) }); | ||
| } catch (error) { | ||
| console.error('Error loading agent settings:', error); | ||
| return res.status(500).json({ error: 'Failed to load agent settings' }); | ||
| } | ||
| }); | ||
|
|
||
| router.patch('/agents/:name/manage-settings/pods/:podId', settingsRateLimit, auth, async (req: any, res: any) => { | ||
| try { | ||
| const userId = getUserId(req); | ||
| if (!userId) return res.status(401).json({ error: 'Unauthorized' }); | ||
| const { wakeOnMessage, humanOnly, boardWake, heartbeatEnabled, heartbeatMinutes } = req.body || {}; | ||
| if (typeof wakeOnMessage !== 'boolean' || typeof boardWake !== 'boolean' | ||
| || typeof humanOnly !== 'boolean' || typeof heartbeatEnabled !== 'boolean' | ||
| || !Number.isInteger(heartbeatMinutes) || heartbeatMinutes < 1 || heartbeatMinutes > 1440) { | ||
| return res.status(400).json({ error: 'Invalid wake settings' }); | ||
| } | ||
| const installation = await AgentInstallation.findOne({ | ||
| agentName: String(req.params.name).toLowerCase(), | ||
| instanceId: normalizeInstanceId(req.query.instanceId), | ||
| podId: req.params.podId, | ||
| status: { $ne: 'uninstalled' }, | ||
| }); | ||
| if (!installation) return res.status(404).json({ error: 'Agent not installed in this pod' }); | ||
| const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; | ||
| if (!canManage(installation, userId, isAdmin)) return res.status(403).json({ error: 'Installer or admin required' }); | ||
| const config = normalizeConfigMap(installation.config) || {}; | ||
| installation.config = new Map(Object.entries({ | ||
| ...config, | ||
| wakeOnMessage: { ...config.wakeOnMessage, enabled: wakeOnMessage, humanOnly }, | ||
| boardWake: { ...config.boardWake, enabled: boardWake }, | ||
| heartbeat: { ...config.heartbeat, enabled: heartbeatEnabled, everyMinutes: heartbeatMinutes }, | ||
| })); | ||
| await installation.save(); | ||
| return res.json({ settings: settings(installation, '') }); | ||
| } catch (error) { | ||
| console.error('Error saving agent settings:', error); | ||
| return res.status(500).json({ error: 'Failed to save agent settings' }); | ||
| } | ||
| }); | ||
|
|
||
| module.exports = router; | ||
| export {}; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ const { revokeRuntimeTokensAfterLastUninstall } = require('./tokens'); | |
| const { | ||
| getUserId, | ||
| isGlobalAdminUser, | ||
| normalizeInstanceId, | ||
| normalizeDisplayName, | ||
| resolveInstallation, | ||
| buildAgentProfileId, | ||
|
|
@@ -29,10 +30,12 @@ const podAgentsRouter = express.Router(); | |
| podAgentsRouter.delete('/agents/:name/pods/:podId', auth, async (req: any, res: any) => { | ||
| try { | ||
| const { name, podId } = req.params; | ||
| const { installation, instanceId } = await resolveInstallation({ | ||
| agentName: name, | ||
| podId, | ||
| instanceId: req.query.instanceId, | ||
| // Destructive writes must never use resolveInstallation's single-install | ||
| // fallback: a stale instanceId could otherwise remove a different agent. | ||
| const instanceId = normalizeInstanceId(req.query.instanceId); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Reject malformed explicit instance IDs before uninstall. If a pod creator sends 🤖 Prompt for AI Agents
|
||
| const installation = await AgentInstallation.findOne({ | ||
| agentName: String(name).toLowerCase(), podId, instanceId, | ||
| status: { $ne: 'uninstalled' }, | ||
| }); | ||
| const userId = getUserId(req); | ||
| if (!userId) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: Team-Commonly/commonly
Length of output: 12787
🏁 Script executed:
Repository: Team-Commonly/commonly
Length of output: 28141
Update wake fields without replacing the configuration map.
installation.configis read, rebuilt, and saved as a complete map. A concurrent configuration update can therefore be overwritten. Use targeted$setpaths for the edited fields. The schema does not enableoptimisticConcurrency, so do not rely onsave()to reject this conflict.🤖 Prompt for AI Agents
Source: Coding guidelines