diff --git a/backend/__tests__/service/agent-manage-settings.test.js b/backend/__tests__/service/agent-manage-settings.test.js index 104fc66a86..8fd8ae1996 100644 --- a/backend/__tests__/service/agent-manage-settings.test.js +++ b/backend/__tests__/service/agent-manage-settings.test.js @@ -53,12 +53,23 @@ describe('per-installation agent settings', () => { expect(response.body.activePodCount).toBe(2); expect(response.body.podCount).toBe(2); expect(response.body.turnCap).toBe(30); + expect(response.body.userRunCeiling).toBe(120); 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('shows the installation owner’s quota when an admin opens settings', async () => { + await User.updateOne({ _id: owner._id }, { $set: { 'entitlements.pro': true } }); + 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.turnCap).toBe(200); + expect(response.body.userRunCeiling).toBe(300); + await User.updateOne({ _id: owner._id }, { $set: { 'entitlements.pro': false } }); + }); + 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') diff --git a/backend/__tests__/service/agent-memory-editor.test.js b/backend/__tests__/service/agent-memory-editor.test.js new file mode 100644 index 0000000000..6b2ccbabc9 --- /dev/null +++ b/backend/__tests__/service/agent-memory-editor.test.js @@ -0,0 +1,141 @@ +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 AgentMemory = require('../../models/AgentMemory'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const memoryView = require('../../routes/agentMemoryView'); + +const SECRET = 'agent-memory-editor-test'; +const token = (user) => jwt.sign({ id: String(user._id) }, SECRET); +const base = '/api/agent-memory/editor-agent/machine-1/editable'; + +describe('owner memory editor', () => { + let app; let owner; let outsider; let admin; let pod; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + app = express(); + app.use(express.json()); + app.use('/api/agent-memory', memoryView); + owner = await User.create({ username: 'memory-edit-owner', email: 'memory-edit-owner@test.com', password: 'password123' }); + outsider = await User.create({ username: 'memory-edit-outsider', email: 'memory-edit-outsider@test.com', password: 'password123' }); + admin = await User.create({ username: 'memory-edit-admin', email: 'memory-edit-admin@test.com', password: 'password123', role: 'admin' }); + pod = await Pod.create({ name: 'Editor pod', type: 'chat', createdBy: owner._id, members: [owner._id, outsider._id] }); + }); + + afterAll(async () => closeMongoDb()); + + beforeEach(async () => { + await AgentInstallation.deleteMany({ agentName: 'editor-agent' }); + await AgentMemory.deleteMany({ agentName: 'editor-agent' }); + await AgentInstallation.create({ agentName: 'editor-agent', instanceId: 'machine-1', podId: pod._id, + version: '1', installedBy: owner._id }); + await AgentMemory.create({ agentName: 'editor-agent', instanceId: 'machine-1', + content: 'Old note', sections: { + long_term: { content: 'Old note', visibility: 'private', updatedAt: new Date('2026-01-01'), byteSize: 8 }, + dedup_state: { content: 'internal key', visibility: 'private', byteSize: 12 }, + } }); + }); + + it('shows editable documents only to the installer or admin', async () => { + const ownerRead = await request(app).get(base).set('Authorization', `Bearer ${token(owner)}`); + const adminRead = await request(app).get(base).set('Authorization', `Bearer ${token(admin)}`); + const outsiderRead = await request(app).get(base).set('Authorization', `Bearer ${token(outsider)}`); + expect(ownerRead.status).toBe(200); + expect(adminRead.status).toBe(200); + expect(outsiderRead.status).toBe(403); + expect(ownerRead.body.sections.long_term.content).toBe('Old note'); + expect(JSON.stringify(ownerRead.body)).not.toContain('internal key'); + }); + + it('preserves sibling memory and rejects a stale edit', async () => { + const read = await request(app).get(base).set('Authorization', `Bearer ${token(owner)}`); + const revision = read.body.sections.long_term.updatedAt; + const saved = await request(app).patch(`${base}/long_term`).set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'New note', expectedUpdatedAt: revision }); + expect(saved.status).toBe(200); + const stale = await request(app).patch(`${base}/long_term`).set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'Old edit', expectedUpdatedAt: revision }); + expect(stale.status).toBe(409); + const record = await AgentMemory.findOne({ agentName: 'editor-agent', instanceId: 'machine-1' }).lean(); + expect(record.content).toBe('New note'); + expect(record.sections.long_term.content).toBe('New note'); + expect(record.sections.dedup_state.content).toBe('internal key'); + expect(record.sections.long_term.history[0].content).toBe('Old note'); + }); + + it('creates the first memory document and rejects a stale second creation', async () => { + await AgentMemory.deleteMany({ agentName: 'editor-agent' }); + const first = await request(app).patch(`${base}/long_term`).set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'First note', expectedUpdatedAt: null }); + expect(first.status).toBe(200); + const stale = await request(app).patch(`${base}/long_term`).set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'Stale note', expectedUpdatedAt: null }); + expect(stale.status).toBe(409); + const records = await AgentMemory.find({ agentName: 'editor-agent', instanceId: 'machine-1' }).lean(); + expect(records).toHaveLength(1); + expect(records[0].content).toBe('First note'); + }); + + it('shows legacy long-term content and keeps it in history on the first edit', async () => { + await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, + { $set: { content: 'Legacy note' }, $unset: { 'sections.long_term': 1 } }); + const read = await request(app).get(base).set('Authorization', `Bearer ${token(owner)}`); + expect(read.body.sections.long_term).toMatchObject({ content: 'Legacy note', updatedAt: null }); + const saved = await request(app).patch(`${base}/long_term`).set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'New note', expectedUpdatedAt: null }); + expect(saved.status).toBe(200); + const record = await AgentMemory.findOne({ agentName: 'editor-agent', instanceId: 'machine-1' }).lean(); + expect(record.sections.long_term.history[0].content).toBe('Legacy note'); + }); + + it('blocks another member from editing and refuses unknown sections', async () => { + const denied = await request(app).patch(`${base}/long_term`).set('Authorization', `Bearer ${token(outsider)}`) + .send({ content: 'No', expectedUpdatedAt: null }); + const unknown = await request(app).patch(`${base}/dedup_state`).set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'No', expectedUpdatedAt: null }); + expect(denied.status).toBe(403); + expect(unknown.status).toBe(404); + }); + + it('keeps the editor available to the installer while the agent is paused', async () => { + await AgentInstallation.updateMany({ agentName: 'editor-agent' }, { $set: { status: 'paused' } }); + const response = await request(app).get(base).set('Authorization', `Bearer ${token(owner)}`); + const index = await request(app).get('/api/agent-memory/editor-agent/machine-1') + .set('Authorization', `Bearer ${token(owner)}`); + expect(response.status).toBe(200); + expect(response.body.sections.long_term.content).toBe('Old note'); + expect(index.status).toBe(200); + expect(index.body.pods).toHaveLength(1); + }); + + it('denies a paused installer when another installer owns the same identity', async () => { + const otherPod = await Pod.create({ name: 'Other owner pod', type: 'chat', + createdBy: outsider._id, members: [outsider._id] }); + await AgentInstallation.updateMany({ agentName: 'editor-agent' }, { $set: { status: 'paused' } }); + await AgentInstallation.create({ agentName: 'editor-agent', instanceId: 'machine-1', + podId: otherPod._id, version: '1', installedBy: outsider._id }); + + const read = await request(app).get(base).set('Authorization', `Bearer ${token(owner)}`); + const index = await request(app).get('/api/agent-memory/editor-agent/machine-1') + .set('Authorization', `Bearer ${token(owner)}`); + const write = await request(app).patch(`${base}/long_term`) + .set('Authorization', `Bearer ${token(owner)}`) + .send({ content: 'Stolen edit', expectedUpdatedAt: new Date('2026-01-01').toISOString() }); + const otherOwnerRead = await request(app).get(base) + .set('Authorization', `Bearer ${token(outsider)}`); + const adminRead = await request(app).get(base).set('Authorization', `Bearer ${token(admin)}`); + expect(read.status).toBe(403); + expect(index.status).toBe(403); + expect(write.status).toBe(403); + expect(otherOwnerRead.status).toBe(403); + expect(adminRead.status).toBe(200); + const memory = await AgentMemory.findOne({ agentName: 'editor-agent', instanceId: 'machine-1' }).lean(); + expect(memory.sections.long_term.content).toBe('Old note'); + expect(memory.content).toBe('Old note'); + }); +}); diff --git a/backend/__tests__/service/agent-policy-settings.test.js b/backend/__tests__/service/agent-policy-settings.test.js new file mode 100644 index 0000000000..71c817c42b --- /dev/null +++ b/backend/__tests__/service/agent-policy-settings.test.js @@ -0,0 +1,86 @@ +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 AgentProfile = require('../../models/AgentProfile'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const agentConfig = require('../../routes/registry/agent-config'); +const podAgents = require('../../routes/registry/pod-agents'); + +const SECRET = 'agent-policy-settings-test'; +const token = (user) => jwt.sign({ id: String(user._id) }, SECRET); + +describe('per-agent policy settings', () => { + let app; let owner; let outsider; let pod; let url; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await setupMongoDb(); + app = express(); + app.use(express.json()); + app.use('/api/registry', agentConfig); + app.use('/api/registry', podAgents); + owner = await User.create({ username: 'policy-owner', email: 'policy-owner@test.com', password: 'password123' }); + outsider = await User.create({ username: 'policy-outsider', email: 'policy-outsider@test.com', password: 'password123' }); + pod = await Pod.create({ name: 'Policy pod', type: 'chat', createdBy: owner._id, members: [owner._id, outsider._id] }); + url = `/api/registry/pods/${pod._id}/agents/policy-agent`; + }); + + afterAll(async () => closeMongoDb()); + + beforeEach(async () => { + await AgentInstallation.deleteMany({ agentName: 'policy-agent' }); + await AgentProfile.deleteMany({ agentName: 'policy-agent' }); + await AgentInstallation.create({ agentName: 'policy-agent', instanceId: 'machine-1', + podId: pod._id, version: '1', installedBy: owner._id, + config: new Map([['runtime', { runtimeType: 'wrapper', adapter: 'claude' }], + ['environment', { adapter: 'claude', workspace: { path: '/repo' }, + sandbox: { mode: 'read-only', network: { policy: 'restricted' } }, + mcp: [{ name: 'safe', transport: 'stdio', command: ['node', 'mcp.js'] }] }]]) }); + await AgentProfile.create({ agentId: 'policy-agent:machine-1', agentName: 'policy-agent', + instanceId: 'machine-1', podId: pod._id, name: 'Policy agent', purpose: 'Test', createdBy: owner._id }); + }); + + it('writes approvals and context for the installer, then projects them', async () => { + const response = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) + .send({ instanceId: 'machine-1', toolPolicy: { allowed: ['commonly'], blocked: ['Bash'], requireApproval: ['Write'] }, + contextPolicy: { maxTokens: 8000, compactionThreshold: 6000, summaryHours: 24, + includeMemory: true, includeSkills: false, includeSummaries: true } }); + expect(response.status).toBe(200); + const profile = await AgentProfile.findOne({ agentName: 'policy-agent' }).lean(); + expect(profile.toolPolicy.allowed).toEqual(['commonly']); + expect(profile.contextPolicy.includeSkills).toBe(false); + const listed = await request(app).get(`/api/registry/pods/${pod._id}/agents/policy-agent?instanceId=machine-1`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(listed.status).toBe(200); + expect(listed.body.agent.profile.toolPolicy.requireApproval).toEqual(['Write']); + }); + + it('updates trust mode without dropping workspace, network or MCP declarations', async () => { + const response = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) + .send({ instanceId: 'machine-1', config: { environment: { sandbox: { mode: 'workspace' } } } }); + expect(response.status).toBe(200); + const installation = await AgentInstallation.findOne({ agentName: 'policy-agent' }); + const environment = installation.config.get('environment'); + expect(environment.sandbox).toEqual({ mode: 'workspace', network: { policy: 'restricted' } }); + expect(environment.workspace).toEqual({ path: '/repo' }); + expect(environment.mcp).toHaveLength(1); + const listed = await request(app).get(`/api/registry/pods/${pod._id}/agents/policy-agent?instanceId=machine-1`) + .set('Authorization', `Bearer ${token(owner)}`); + expect(listed.body.agent.config.environment.sandbox).toEqual({ mode: 'workspace' }); + expect(JSON.stringify(listed.body)).not.toContain('mcp.js'); + }); + + it('refuses a pod member and invalid context before changing policy', async () => { + const denied = await request(app).patch(url).set('Authorization', `Bearer ${token(outsider)}`) + .send({ instanceId: 'machine-1', toolPolicy: { allowed: ['Bash'] } }); + const invalid = await request(app).patch(url).set('Authorization', `Bearer ${token(owner)}`) + .send({ instanceId: 'machine-1', contextPolicy: { maxTokens: 100, compactionThreshold: 200 } }); + expect(denied.status).toBe(403); + expect(invalid.status).toBe(400); + const profile = await AgentProfile.findOne({ agentName: 'policy-agent' }).lean(); + expect(profile.toolPolicy.allowed).not.toContain('Bash'); + }); +}); diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index 770e175e36..d19c384cd7 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -6,7 +6,7 @@ * * Access: * - admin (role === 'admin') → any agent - * - owner (installed this agent: AgentInstallation.installedBy === viewer) → their agent + * - owner (every retained installation belongs to the viewer) → their agent * - everyone else → 403 * * Returns snippets, never full raw content — "a fraction of memory". Internal @@ -34,7 +34,10 @@ const Pod = require('../models/Pod'); const PGMessage = require('../models/pg/Message'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { resolveAgentDisplayLabel } = require('../services/agentIdentityService'); -const { getLastAgentMemoryWrite, BOOKKEEPING_SECTIONS } = require('../services/agentMemoryService'); +const { + getLastAgentMemoryWrite, BOOKKEEPING_SECTIONS, + stampSectionsForWrite, decorateSectionsWithProvenance, +} = require('../services/agentMemoryService'); interface AuthReq { user?: { id?: string }; @@ -101,38 +104,112 @@ const memoryRateLimit = rateLimit({ const router: ReturnType = express.Router(); router.use(memoryRateLimit); -// GET /api/agent-memory/:agentName/:instanceId? — owner/admin memory index. -router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { +const EDITABLE_SECTIONS = ['soul', 'long_term', 'shared'] as const; +const editableSection = (value: string): value is typeof EDITABLE_SECTIONS[number] => ( + EDITABLE_SECTIONS.includes(value as typeof EDITABLE_SECTIONS[number]) +); + +const memoryOwnerAccess = async (req: AuthReq, res: Res) => { + const userId = getUserId(req); + if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return null; } + const agentName = String(req.params?.agentName || '').trim().toLowerCase(); + const instanceId = String(req.params?.instanceId || 'default').trim() || 'default'; + if (!agentName) { res.status(404).json({ error: 'Not found' }); return null; } + const isAdmin = (await User.findById(userId).select('role').lean())?.role === 'admin'; + if (!isAdmin) { + // Memory is one envelope per identity, not per pod installation. A paused + // install must not grant access to another installer's active agent memory. + const installs = await AgentInstallation.find({ + agentName, instanceId, status: { $ne: 'uninstalled' }, + }).select('installedBy').lean(); + const anotherOwner = installs.some((install: { installedBy?: unknown }) => String(install.installedBy) !== userId); + if (!installs.length || anotherOwner) { + res.status(403).json({ error: 'Forbidden' }); return null; + } + } + return { agentName, instanceId, role: isAdmin ? 'admin' : 'owner' } as const; +}; + +// Full text is limited to the owner's durable document sections. Runtime and +// dedup bookkeeping stay out of the human editor even for an admin. +router.get('/:agentName/:instanceId/editable', auth, async (req: AuthReq, res: Res) => { try { - const userId = getUserId(req); - if (!userId) { - res.status(401).json({ error: 'Unauthorized' }); - return; + const access = await memoryOwnerAccess(req, res); + if (!access) return; + const { agentName, instanceId } = access; + const record = await AgentMemory.findOne({ agentName, instanceId }) + .select('content sections.soul sections.long_term sections.shared').lean(); + const sections = Object.fromEntries(EDITABLE_SECTIONS.map((key) => { + const section = record?.sections?.[key]; + return [key, { content: section?.content || (key === 'long_term' ? record?.content || '' : ''), + updatedAt: section?.updatedAt || null, + visibility: section?.visibility || 'private' }]; + })); + res.json({ sections }); + } catch (error) { + console.error('[agent-memory] editor read failed:', (error as Error).message); + res.status(500).json({ error: 'Failed to load memory' }); + } +}); + +router.patch('/:agentName/:instanceId/editable/:section', auth, async (req: AuthReq & { body?: any }, res: Res) => { + try { + const access = await memoryOwnerAccess(req, res); + if (!access) return; + const { agentName, instanceId } = access; + const identity = { agentName, instanceId }; + const section = String((req.params as any).section || ''); + if (!editableSection(section)) { res.status(404).json({ error: 'Section not found' }); return; } + const { content, expectedUpdatedAt } = req.body || {}; + if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > 64 * 1024) { + res.status(400).json({ error: 'Memory content must be at most 64 KB' }); return; } - const agentName = String(req.params?.agentName || '').trim().toLowerCase(); - const instanceId = String(req.params?.instanceId || 'default').trim() || 'default'; - if (!agentName) { - res.status(404).json({ error: 'Not found' }); - return; + if (expectedUpdatedAt !== null && (typeof expectedUpdatedAt !== 'string' + || Number.isNaN(new Date(expectedUpdatedAt).getTime()))) { + res.status(400).json({ error: 'Expected memory revision required' }); return; } - - // Access gate: admin OR owner (installed this agent). - const viewer = await User.findById(userId).select('role').lean(); - const isAdmin = viewer?.role === 'admin'; - let role: 'admin' | 'owner' | null = isAdmin ? 'admin' : null; - if (!isAdmin) { - const owns = await AgentInstallation.findOne({ - agentName, - instanceId, - installedBy: userId, - status: 'active', - }).select('_id').lean(); - if (owns) role = 'owner'; + const prior = await AgentMemory.findOne(identity).select('content sections').lean(); + const before = prior?.sections?.[section] || (section === 'long_term' && prior?.content + ? { content: prior.content, visibility: 'private' } : null); + const priorRevision = before?.updatedAt ? new Date(before.updatedAt).toISOString() : null; + if (priorRevision !== expectedUpdatedAt) { + res.status(409).json({ error: 'Memory changed since you opened it' }); return; } - if (!role) { - res.status(403).json({ error: 'Forbidden' }); - return; + const incoming = stampSectionsForWrite({ [section]: { + content, visibility: before?.visibility || 'private', + } }); + const priorSections = before && !prior?.sections?.[section] + ? { ...(prior?.sections || {}), [section]: before } : prior?.sections; + const decorated = decorateSectionsWithProvenance(priorSections, incoming, { via: 'memory-owner' }); + const revisionPath = `sections.${section}.updatedAt`; + const query = { ...identity, [revisionPath]: expectedUpdatedAt === null + ? { $exists: false } : new Date(expectedUpdatedAt) }; + const update: Record = { + [`sections.${section}`]: decorated[section], schemaVersion: 2, + }; + if (section === 'long_term') update.content = content; + const saved = await AgentMemory.findOneAndUpdate(query, { + $set: update, $unset: { lastSyncKey: 1, lastSyncAt: 1 }, + }, { new: true, upsert: expectedUpdatedAt === null, runValidators: true }); + if (!saved) { res.status(409).json({ error: 'Memory changed since you opened it' }); return; } + const current = saved.sections?.[section]; + res.json({ section, content: current?.content || '', updatedAt: current?.updatedAt || null, + visibility: current?.visibility || 'private' }); + } catch (error) { + if ((error as { code?: number }).code === 11000) { + res.status(409).json({ error: 'Memory changed since you opened it' }); return; } + console.error('[agent-memory] editor write failed:', (error as Error).message); + res.status(500).json({ error: 'Failed to save memory' }); + } +}); + +// GET /api/agent-memory/:agentName/:instanceId? — owner/admin memory index. +router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { + try { + const access = await memoryOwnerAccess(req, res); + if (!access) return; + const { agentName, instanceId, role } = access; const agentUser = await User.findOne({ isBot: true, @@ -142,7 +219,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { // ── Pods this agent is in — ALL pods (owner/admin view), each with last // activity + the agent's own last message there, sorted most-active first. ── - const installs = await AgentInstallation.find({ agentName, instanceId, status: 'active' }) + const installs = await AgentInstallation.find({ agentName, instanceId, status: { $ne: 'uninstalled' } }) .select('podId').lean(); const podIds = Array.from(new Set((installs as Array<{ podId?: unknown }>) .map((i) => (i?.podId ? String(i.podId) : '')).filter(Boolean))); diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index b0af939475..0e55e4f3cc 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -132,6 +132,25 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: || !displayName.trim() || displayName.trim().length > 80)) { return res.status(400).json({ error: 'Display name must be 1–80 characters' }); } + if (normalizedContextPolicy) { + const numbers = ['maxTokens', 'compactionThreshold', 'summaryHours']; + if (numbers.some((key) => normalizedContextPolicy[key] !== undefined + && (!Number.isInteger(normalizedContextPolicy[key]) || normalizedContextPolicy[key] < 1)) + || (normalizedContextPolicy.maxTokens !== undefined + && normalizedContextPolicy.compactionThreshold !== undefined + && normalizedContextPolicy.compactionThreshold > normalizedContextPolicy.maxTokens) + || ['includeMemory', 'includeSkills', 'includeSummaries'].some((key) => ( + normalizedContextPolicy[key] !== undefined && typeof normalizedContextPolicy[key] !== 'boolean' + ))) { + return res.status(400).json({ error: 'Invalid context policy' }); + } + } + const requestedSandboxMode = config?.environment?.sandbox?.mode; + if (requestedSandboxMode !== undefined && !['none', 'workspace', 'read-only', 'bwrap', + 'firejail', 'container', 'managed'].includes(requestedSandboxMode)) { + return res.status(400).json({ error: 'Invalid sandbox mode' }); + } + // WRITE-TIME SHAPE CHECK (TASK-071, Vera's ruling). This route is one of // TWO backend writers of `config.environment` (the other is // POST /api/registry/install, which checks the same thing), and that field @@ -180,8 +199,14 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: const environmentControls = config.environment; if (environmentControls && typeof environmentControls === 'object' && !Array.isArray(environmentControls) - && Object.keys(environmentControls).every((key) => ['model', 'effort', 'adapter'].includes(key))) { - nextConfig.environment = { ...(existingConfig.environment || {}), ...environmentControls }; + && Object.keys(environmentControls).every((key) => ['model', 'effort', 'adapter', 'sandbox'].includes(key)) + && (!environmentControls.sandbox || (typeof environmentControls.sandbox === 'object' + && !Array.isArray(environmentControls.sandbox) + && Object.keys(environmentControls.sandbox).every((key) => key === 'mode')))) { + nextConfig.environment = { ...(existingConfig.environment || {}), ...environmentControls, + ...(environmentControls.sandbox ? { sandbox: { + ...(existingConfig.environment?.sandbox || {}), ...environmentControls.sandbox, + } } : {}) }; } if (config.runtime && typeof config.runtime === 'object') { nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin }); diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index 2d810332f8..9cfc79e8ba 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -410,6 +410,9 @@ const buildAgentInstallationPayload = (installation: any, { .filter((key) => typeof environmentSettings[key] === 'string') .map((key) => [key, environmentSettings[key]])) : {}; + if (typeof environmentSettings?.sandbox?.mode === 'string') { + editableEnvironment.sandbox = { mode: environmentSettings.sandbox.mode }; + } // Chat attributes this seat through the User principal. Use that same // resolver for the listing; scoped names are only a fallback for installs // that have no User row yet. diff --git a/backend/routes/registry/manage-settings.ts b/backend/routes/registry/manage-settings.ts index 98eb5f802a..c8bfad673b 100644 --- a/backend/routes/registry/manage-settings.ts +++ b/backend/routes/registry/manage-settings.ts @@ -1,7 +1,7 @@ // 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'; +import { hostedCapsForOwner, userRunCeilingForOwner } from '../../services/hostedRuntimeService'; const express = require('express'); const auth = require('../../middleware/auth'); const { AgentInstallation } = require('../../models/AgentRegistry'); @@ -58,8 +58,11 @@ router.get('/agents/:name/manage-settings', settingsRateLimit, auth, async (req: const names = new Map(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, + const quotaOwner = writable?.installedBy || userId; + const [{ turnsPerDay }, userRunCeiling] = await Promise.all([ + hostedCapsForOwner(quotaOwner), userRunCeilingForOwner(quotaOwner), + ]); + return res.json({ turnCap: turnsPerDay, userRunCeiling, podCount: installations.length, activePodCount: installations.filter((entry: any) => entry.status === 'active').length, writablePodId: writable ? String(writable.podId) : null, installations: visible diff --git a/backend/services/hostedRuntimeService.ts b/backend/services/hostedRuntimeService.ts index 702f93bd5d..472ed572a8 100644 --- a/backend/services/hostedRuntimeService.ts +++ b/backend/services/hostedRuntimeService.ts @@ -79,6 +79,14 @@ export const ownerIsPro = async (userId: unknown): Promise => { /** The hosted caps for whoever owns (installed) a hosted agent. */ export const hostedCapsForOwner = async (userId: unknown) => hostedCaps({ pro: await ownerIsPro(userId) }); +/** The native per-user daily run ceiling; null means the guard is disabled. */ +export const userRunCeilingForOwner = async (userId: unknown): Promise => { + const free = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120); + if (!Number.isFinite(free) || free <= 0) return null; + const pro = Number(process.env.AGENT_USER_DAILY_RUN_CEILING_PRO ?? 300); + return (await ownerIsPro(userId)) && Number.isFinite(pro) && pro > 0 ? pro : free; +}; + export const hostedRuntimeConfig = () => ({ url: String(process.env.HOSTED_RUNTIME_URL || '').trim().replace(/\/+$/, ''), adminToken: String(process.env.HOSTED_RUNTIME_ADMIN_TOKEN || '').trim(), @@ -282,6 +290,7 @@ module.exports = { hostedCaps, ownerIsPro, hostedCapsForOwner, + userRunCeilingForOwner, hostedRuntimeConfig, isConfigured, readRuntimeConfig, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index d906ed14f0..9057cc19d1 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -43,7 +43,8 @@ import { engineForNewHostedInstall, HOSTED_ENGINE_NATIVE, hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative, } from './hostedEngine'; import { - hostedCaps, hostedCapsForOwner, isConfigured as hostedWorkerConfigured, listHostedInstallationsForUser, ownerIsPro, + hostedCaps, hostedCapsForOwner, isConfigured as hostedWorkerConfigured, listHostedInstallationsForUser, + userRunCeilingForOwner, } from './hostedRuntimeService'; import { buildByoHandoffPayload, @@ -1523,8 +1524,8 @@ export async function runAgent( // a runaway guard and a spend ceiling have opposite safe directions. // Forward-only: rows without userId predate the stamp and cannot be // counted; the window inherits the cap's fixed-UTC-midnight semantics. - const freeCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING ?? 120); - if (Number.isFinite(freeCeiling) && freeCeiling > 0) { + const userCeiling = await userRunCeilingForOwner(installation.installedBy); + if (userCeiling !== null) { const installedBy = installation.installedBy ? String(installation.installedBy) : ''; if (!installedBy) { console.warn( @@ -1535,10 +1536,6 @@ export async function runAgent( // A pro owner's hosted line (200 turns a day, TASK-268) sits above the // free ceiling, so pro owners get the pro ceiling; a failed plan lookup // reads as free, the lower one. - const proCeiling = Number(process.env.AGENT_USER_DAILY_RUN_CEILING_PRO ?? 300); - const userCeiling = (await ownerIsPro(installedBy)) && Number.isFinite(proCeiling) && proCeiling > 0 - ? proCeiling - : freeCeiling; try { const AgentRunModel = require('../models/AgentRun'); const dayStart = new Date(); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d8c6ee166d..d18413b066 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2353,6 +2353,7 @@ "removeLastConfirm": "Remove this agent from its last pod, {{podName}}? Every key will be revoked and the agent will disconnect. Its identity and memory remain.", "removeDisconnectConfirm": "Remove this agent from {{podName}}? No active installation will remain, so every key will be revoked and the agent will disconnect. Its identity and memory remain.", "forbidden": "Only the installer or an admin can change this setting.", + "keyUnavailable": "Key management is unavailable for this agent. Ask an admin for access.", "noPods": "No pods to manage", "loadError": "Could not load settings.", "saveError": "Could not save wake settings.", @@ -2385,6 +2386,40 @@ "noPod": "This agent has no active pod.", "missingReplacement": "The replacement key was not returned." }, + "agentPolicy": { + "title": "Policy", + "approvals": "Tool approvals", + "commaHint": "Separate tool names with commas.", + "allowed": "Allowed tools", + "blocked": "Blocked tools", + "requireApproval": "Require approval", + "context": "Context", + "maxTokens": "Maximum tokens", + "compactionThreshold": "Compact after", + "summaryHours": "Summary window (hours)", + "includeMemory": "Include memory", + "includeSkills": "Include skills", + "includeSummaries": "Include summaries", + "savePolicy": "Save policy", + "trust": "Trust mode", + "repoAccess": "Repository access", + "chooseAccess": "Choose access", + "readOnly": "Read only", + "workspace": "Allow repository edits", + "saveTrust": "Save trust mode", + "memory": "Memory", + "section": { "long_term": "Long-term", "soul": "Identity", "shared": "Shared" }, + "saveMemory": "Save memory", + "loadingMemory": "Loading memory…", + "retry": "Try again", + "quotas": "Quotas", + "turnsPerDay": "Hosted turns per day", + "userRunCeiling": "Runs per day", + "contextInvalid": "Check the context limits.", + "loadError": "Could not load policy data.", + "saveError": "Could not save changes.", + "saved": "Saved" + }, "time": { "age": { "justNow": "just now", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 6bbf9b7a4b..4be56997d3 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2344,6 +2344,7 @@ "removeLastConfirm": "从最后一个 Pod「{{podName}}」移除此智能体?所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", "removeDisconnectConfirm": "从「{{podName}}」移除此智能体?移除后没有活跃安装,所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", "forbidden": "只有安装者或管理员可以更改此设置。", + "keyUnavailable": "无法管理此智能体的密钥。请联系管理员获取权限。", "noPods": "没有可管理的 Pod", "loadError": "无法加载设置。", "saveError": "无法保存唤醒设置。", @@ -2376,6 +2377,40 @@ "noPod": "此智能体没有活跃的 Pod。", "missingReplacement": "未返回新密钥。" }, + "agentPolicy": { + "title": "策略", + "approvals": "工具审批", + "commaHint": "用逗号分隔工具名称。", + "allowed": "允许的工具", + "blocked": "禁用的工具", + "requireApproval": "需要审批", + "context": "上下文", + "maxTokens": "最大 Token 数", + "compactionThreshold": "压缩阈值", + "summaryHours": "摘要范围(小时)", + "includeMemory": "包含记忆", + "includeSkills": "包含技能", + "includeSummaries": "包含摘要", + "savePolicy": "保存策略", + "trust": "信任模式", + "repoAccess": "仓库访问权限", + "chooseAccess": "选择权限", + "readOnly": "只读", + "workspace": "允许编辑仓库", + "saveTrust": "保存信任模式", + "memory": "记忆", + "section": { "long_term": "长期", "soul": "身份", "shared": "共享" }, + "saveMemory": "保存记忆", + "loadingMemory": "正在加载记忆…", + "retry": "重试", + "quotas": "配额", + "turnsPerDay": "托管运行次数/天", + "userRunCeiling": "运行次数/天", + "contextInvalid": "请检查上下文限制。", + "loadError": "无法加载策略数据。", + "saveError": "无法保存更改。", + "saved": "已保存" + }, "time": { "age": { "justNow": "刚刚", diff --git a/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx new file mode 100644 index 0000000000..69e4ef420a --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx @@ -0,0 +1,97 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import V2AgentPolicy from '../agents/V2AgentPolicy'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), patch: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }; + return { __esModule: true, default: mock, ...mock }; +}); +const axios = jest.requireMock('axios').default; +const update = jest.fn().mockResolvedValue({}); +const installation = { + name: 'maya-claude', instanceId: 'machine-1', displayName: 'Maya', + runtime: { runtimeType: 'wrapper', adapter: 'claude' }, + config: { environment: { adapter: 'claude', sandbox: { mode: 'read-only' } } }, + profile: { + toolPolicy: { allowed: ['commonly'], blocked: [], requireApproval: ['Bash'] }, + contextPolicy: { maxTokens: 8000, compactionThreshold: 6000, summaryHours: 24, + includeMemory: true, includeSkills: true, includeSummaries: true }, + }, +}; +const docs = () => ({ + soul: { content: 'Identity', updatedAt: null, visibility: 'private' }, + long_term: { content: 'Original', updatedAt: '2026-10-01T00:00:00.000Z', visibility: 'private' }, + shared: { content: '', updatedAt: null, visibility: 'private' }, +}); +const renderPolicy = (agent = installation) => render( + , +); + +beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockImplementation((url) => url.endsWith('/editable') + ? Promise.resolve({ data: { sections: docs() } }) + : Promise.resolve({ data: { turnCap: 30, userRunCeiling: 120 } })); + axios.patch.mockResolvedValue({ data: { section: 'long_term', content: 'Changed' } }); +}); + +it('writes approval and context policy through the shared settings hook', async () => { + renderPolicy(); + await screen.findByDisplayValue('Original'); + fireEvent.change(screen.getByLabelText('Allowed tools'), { target: { value: 'commonly, Read, Read' } }); + fireEvent.click(screen.getByLabelText('Include summaries')); + fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); + await waitFor(() => expect(update).toHaveBeenCalledWith({ + toolPolicy: { allowed: ['commonly', 'Read'], blocked: [], requireApproval: ['Bash'] }, + contextPolicy: { maxTokens: 8000, compactionThreshold: 6000, summaryHours: 24, + includeMemory: true, includeSkills: true, includeSummaries: false }, + })); +}); + +it('writes only the chosen trust mode and edits memory with its revision', async () => { + renderPolicy(); + await screen.findByDisplayValue('Original'); + fireEvent.change(screen.getByLabelText('Repository access'), { target: { value: 'workspace' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' })); + await waitFor(() => expect(update).toHaveBeenCalledWith({ + config: { environment: { sandbox: { mode: 'workspace' } } }, + })); + fireEvent.change(screen.getByLabelText('Long-term'), { target: { value: 'Changed' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save memory' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/agent-memory/maya-claude/machine-1/editable/long_term', + { content: 'Changed', expectedUpdatedAt: '2026-10-01T00:00:00.000Z' }, + )); +}); + +it('shows server quota for hosted agents without editable trust controls', async () => { + renderPolicy({ ...installation, runtime: { runtimeType: 'hosted' } }); + expect(await screen.findByText('Hosted turns per day')).toBeInTheDocument(); + expect(screen.getByText('Runs per day')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save trust mode' })).not.toBeInTheDocument(); +}); + +it('offers trust mode for the canonical BYO runtime shape', async () => { + renderPolicy({ ...installation, runtime: { runtimeType: 'codex', host: 'byo' }, + config: { environment: { sandbox: { mode: 'read-only' } } } }); + expect(await screen.findByLabelText('Repository access')).toHaveValue('read-only'); +}); + +it('shows a retry in Memory when loading fails, then opens the editor', async () => { + axios.get.mockImplementation((url) => url.endsWith('/editable') + ? Promise.reject(new Error('Memory offline')) + : Promise.resolve({ data: { turnCap: 30, userRunCeiling: 120 } })); + renderPolicy(); + expect(await screen.findByText('Memory offline')).toBeInTheDocument(); + expect(screen.queryByText('Loading memory…')).not.toBeInTheDocument(); + axios.get.mockImplementation((url) => url.endsWith('/editable') + ? Promise.resolve({ data: { sections: docs() } }) + : Promise.resolve({ data: { turnCap: 30, userRunCeiling: 120 } })); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByDisplayValue('Original')).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index e41e54a596..d29b9101a0 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -31,10 +31,9 @@ const renderPage = (path = '/v2/agents/maya-claude/default') => render( beforeEach(() => { jest.clearAllMocks(); axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); - if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, } } }); @@ -69,8 +68,8 @@ test('an ephemeral registry row is not presented as a marketplace listing', asyn test('wrapper key replacement cannot bypass the daemon reconnect flow', async () => { axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { @@ -90,7 +89,9 @@ test('revoked webhook key has one Reconnect action in Control', async () => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); - if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{ name: 'maya-claude', instanceId: 'default', runtime: { runtimeType: 'webhook' }, connection: { state: 'revoked', lastSeenAt: null }, @@ -111,7 +112,9 @@ test('connected nondefault wrapper regenerates from Control without a second Key if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); - if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{ name: 'maya-claude', instanceId: 'second', runtime: { runtimeType: 'wrapper' }, connection: { state: 'connected', lastSeenAt: new Date().toISOString() }, @@ -132,7 +135,9 @@ test('identity edits go through the shared installation PATCH with the exact ins if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); - if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', profile: { instructions: 'Old', persona: { tone: 'friendly', specialties: ['writing'] } }, @@ -195,8 +200,8 @@ test('a partial rename tells the installer to retry Save', async () => { test('nondefault wrapper instances never link to a default-instance reconnect', async () => { axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { @@ -232,9 +237,9 @@ test('an in-flight replacement never reveals its key on another agent page', asy let finishPost; axios.post.mockImplementation(() => new Promise((resolve) => { finishPost = resolve; })); axios.get.mockImplementation((url, options) => { - const other = url.includes('/other/') || options?.params?.agentName === 'other'; - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: other ? 'Other agent' : 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + const other = url.includes('/agents/other') || options?.params?.agentName === 'other'; + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { @@ -276,8 +281,8 @@ test('a denied owner probe renders no key controls or metadata', async () => { test('a partial installation owner cannot see identity-wide key controls', async () => { axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { @@ -286,9 +291,12 @@ test('a partial installation owner cannot see identity-wide key controls', async return Promise.reject({ response: { status: 403, data: { message: 'Access denied' } } }); }); renderPage(); - expect(await screen.findByRole('alert')).toHaveTextContent('Agent settings unavailable'); + expect(await screen.findByRole('heading', { name: 'Policy' })).toBeInTheDocument(); + expect(await screen.findByText('Key management is unavailable for this agent. Ask an admin for access.')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Pods' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByText('Laptop')).not.toBeInTheDocument(); + expect(axios.get.mock.calls.some(([url]) => url === '/api/agent-memory/maya-claude/default')).toBe(false); }); test('revocation uses the credential id only after confirmation', async () => { @@ -303,12 +311,11 @@ test('revocation uses the credential id only after confirmation', async () => { })); }); -test('selects the owner-writable pod even when another identity pod comes first', async () => { +test('selects an installation the owner can manage without exposing a foreign pod', async () => { axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'foreign', name: 'Other pod' }, { id: 'owned', name: 'My pod' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'owned', installations: [{ podId: 'owned', podName: 'My pod', status: 'active' }], } }); - if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'owned' } }); if (url.includes('/pods/foreign/')) return Promise.reject({ response: { status: 403 } }); if (url.includes('/pods/owned/')) return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, @@ -329,8 +336,8 @@ test('selects the owner-writable pod even when another identity pod comes first' test('runtime editor writes environment controls through the shared settings hook', async () => { axios.patch.mockResolvedValue({ data: { updated: true } }); axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { @@ -354,3 +361,19 @@ test('runtime editor writes environment controls through the shared settings hoo { config: { environment: { model: 'new-model' } }, instanceId: 'default' }, )); }); + +test('runtime editor keeps a wrapper model when no environment override exists', async () => { + axios.get.mockImplementation((url) => { + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', + runtime: { runtimeType: 'wrapper', adapter: 'claude', model: 'selected-model', effort: 'medium' }, + } } }); + return Promise.resolve({ data: [] }); + }); + renderPage(); + expect(await screen.findByLabelText('Model')).toHaveValue('selected-model'); + expect(screen.getByLabelText('Effort')).toHaveValue('medium'); +}); diff --git a/frontend/src/v2/__tests__/useAgentSettings.test.tsx b/frontend/src/v2/__tests__/useAgentSettings.test.tsx index 0c3473705b..6302e50eec 100644 --- a/frontend/src/v2/__tests__/useAgentSettings.test.tsx +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -30,10 +30,9 @@ const Probe = () => { test('reads one accessible installation and patches it with the instance id', async () => { axios.get.mockImplementation((url) => { - if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { - displayName: 'Maya', pods: [{ id: 'pod-other', name: 'Other owner' }, { id: 'pod-1', name: 'Workspace' }], + if (url.includes('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], } }); - if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } }); return Promise.resolve({ data: { agent: { name: 'maya-claude', instanceId: 'default', displayName: 'Maya', config: {}, } } }); @@ -53,6 +52,7 @@ test('reads one accessible installation and patches it with the instance id', as { displayName: 'Maya Updated', instanceId: 'default' }, )); expect(screen.getByTestId('pod')).toHaveTextContent('pod-1'); + expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/agent-memory/'))).toBe(false); fireEvent.click(screen.getByRole('button', { name: 'Save Wake' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( '/api/registry/agents/maya-claude/manage-settings/pods/pod-2', diff --git a/frontend/src/v2/agents/V2AgentPolicy.tsx b/frontend/src/v2/agents/V2AgentPolicy.tsx new file mode 100644 index 0000000000..78720c4529 --- /dev/null +++ b/frontend/src/v2/agents/V2AgentPolicy.tsx @@ -0,0 +1,213 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import type { AgentSettingsInstallation, AgentSettingsPatch } from './useAgentSettings'; +import './v2-agent-policy.css'; + +type MemorySection = 'soul' | 'long_term' | 'shared'; +type MemoryDocument = { content: string; updatedAt: string | null; visibility: string }; +type MemoryDocuments = Record; +const memorySections: MemorySection[] = ['long_term', 'soul', 'shared']; +const trustAdapters = new Set(['claude', 'codex', 'opencode', 'qwen']); +const commaList = (value: string) => Array.from(new Set(value.split(',').map((item) => item.trim()).filter(Boolean))); +const displayList = (value?: string[]) => (value || []).join(', '); +const message = (error: unknown, fallback: string) => ( + (error as { response?: { data?: { error?: string } } })?.response?.data?.error + || (error instanceof Error && error.message) || fallback +); + +interface Props { + agentName: string; + instanceId: string; + installation: AgentSettingsInstallation | null; + update: (patch: AgentSettingsPatch) => Promise; +} + +const V2AgentPolicy: React.FC = ({ agentName, instanceId, installation, update }) => { + const { t } = useTranslation(); + const policy = installation?.profile?.toolPolicy; + const context = installation?.profile?.contextPolicy; + const environment = (installation?.config?.environment || {}) as { adapter?: string; sandbox?: { mode?: string } }; + const runtime = installation?.runtime; + const adapter = String(environment.adapter || runtime?.adapter || runtime?.runtimeType || '') + .trim().toLowerCase().replace('claude-code', 'claude'); + const canSetTrust = (runtime?.host === 'byo' || runtime?.runtimeType === 'wrapper') + && trustAdapters.has(adapter); + const [allowed, setAllowed] = useState(displayList(policy?.allowed)); + const [blocked, setBlocked] = useState(displayList(policy?.blocked)); + const [approval, setApproval] = useState(displayList(policy?.requireApproval)); + const [maxTokens, setMaxTokens] = useState(context?.maxTokens || 8000); + const [compactionThreshold, setCompactionThreshold] = useState(context?.compactionThreshold || 6000); + const [summaryHours, setSummaryHours] = useState(context?.summaryHours || 24); + const [includeMemory, setIncludeMemory] = useState(context?.includeMemory !== false); + const [includeSkills, setIncludeSkills] = useState(context?.includeSkills !== false); + const [includeSummaries, setIncludeSummaries] = useState(context?.includeSummaries !== false); + const [trustMode, setTrustMode] = useState(environment.sandbox?.mode || ''); + const [quota, setQuota] = useState<{ turnCap: number; userRunCeiling: number | null } | null>(null); + const [documents, setDocuments] = useState(null); + const [section, setSection] = useState('long_term'); + const [content, setContent] = useState(''); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState<{ group: 'policy' | 'trust' | 'memory'; + kind: 'error' | 'saved'; text: string } | null>(null); + const [memoryLoadError, setMemoryLoadError] = useState(null); + const [quotaError, setQuotaError] = useState(null); + + useEffect(() => { + setAllowed(displayList(policy?.allowed)); + setBlocked(displayList(policy?.blocked)); + setApproval(displayList(policy?.requireApproval)); + setMaxTokens(context?.maxTokens || 8000); + setCompactionThreshold(context?.compactionThreshold || 6000); + setSummaryHours(context?.summaryHours || 24); + setIncludeMemory(context?.includeMemory !== false); + setIncludeSkills(context?.includeSkills !== false); + setIncludeSummaries(context?.includeSummaries !== false); + setTrustMode(environment.sandbox?.mode || ''); + }, [installation]); + + const memoryUrl = `/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}/editable`; + const loadMemory = useCallback(async (preserveDraft = false) => { + const response = await axios.get<{ sections: MemoryDocuments }>(memoryUrl); + setDocuments(response.data.sections); + setMemoryLoadError(null); + if (!preserveDraft) setContent(response.data.sections[section]?.content || ''); + }, [memoryUrl, section]); + + useEffect(() => { + let current = true; + setDocuments(null); + setMemoryLoadError(null); + setQuotaError(null); + void axios.get<{ sections: MemoryDocuments }>(memoryUrl).then((response) => { + if (current) { + setDocuments(response.data.sections); + setContent(response.data.sections.long_term?.content || ''); + } + }).catch((requestError) => { if (current) setMemoryLoadError(message(requestError, t('agentPolicy.loadError'))); }); + void axios.get<{ turnCap: number; userRunCeiling: number | null }>( + `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`, { params: { instanceId } }, + ).then((response) => { if (current) setQuota(response.data); }) + .catch((requestError) => { if (current) setQuotaError(message(requestError, t('agentPolicy.loadError'))); }); + return () => { current = false; }; + }, [agentName, instanceId, memoryUrl, t]); + + const savePolicy = async () => { + if (!Number.isInteger(maxTokens) || maxTokens < 1 || !Number.isInteger(compactionThreshold) + || compactionThreshold < 1 || compactionThreshold > maxTokens + || !Number.isInteger(summaryHours) || summaryHours < 1) { + setFeedback({ group: 'policy', kind: 'error', text: t('agentPolicy.contextInvalid') }); return; + } + setBusy(true); setFeedback(null); + try { + await update({ toolPolicy: { allowed: commaList(allowed), blocked: commaList(blocked), + requireApproval: commaList(approval) }, + contextPolicy: { maxTokens, compactionThreshold, summaryHours, + includeMemory, includeSkills, includeSummaries } }); + setFeedback({ group: 'policy', kind: 'saved', text: t('agentPolicy.saved') }); + } catch (requestError) { setFeedback({ group: 'policy', kind: 'error', text: message(requestError, t('agentPolicy.saveError')) }); } + finally { setBusy(false); } + }; + + const saveTrust = async () => { + if (!canSetTrust || !['read-only', 'workspace'].includes(trustMode)) return; + setBusy(true); setFeedback(null); + try { + await update({ config: { environment: { sandbox: { mode: trustMode } } } }); + setFeedback({ group: 'trust', kind: 'saved', text: t('agentPolicy.saved') }); + } catch (requestError) { setFeedback({ group: 'trust', kind: 'error', text: message(requestError, t('agentPolicy.saveError')) }); } + finally { setBusy(false); } + }; + + const saveMemory = async () => { + if (!documents) return; + setBusy(true); setFeedback(null); + try { + await axios.patch(`${memoryUrl}/${section}`, { + content, expectedUpdatedAt: documents[section].updatedAt, + }); + await loadMemory(); + setFeedback({ group: 'memory', kind: 'saved', text: t('agentPolicy.saved') }); + } catch (requestError) { + setFeedback({ group: 'memory', kind: 'error', text: message(requestError, t('agentPolicy.saveError')) }); + if ((requestError as { response?: { status?: number } })?.response?.status === 409) { + await loadMemory(true).catch(() => {}); + } + } finally { setBusy(false); } + }; + + const changeSection = (next: MemorySection) => { + setSection(next); + setContent(documents?.[next]?.content || ''); + setFeedback(null); + }; + + const groupFeedback = (group: 'policy' | 'trust' | 'memory') => feedback?.group === group && ( +

{feedback.text}

+ ); + + return
+

{t('agentPolicy.title')}

+
+

{t('agentPolicy.approvals')}

+

{t('agentPolicy.commaHint')}

+ + + +
+
+

{t('agentPolicy.context')}

+
+ + + +
+
+ + + +
+ + {groupFeedback('policy')} +
+ {canSetTrust &&
+

{t('agentPolicy.trust')}

+ + + {groupFeedback('trust')} +
} +
+

{t('agentPolicy.memory')}

+ {documents ? <> +
+ {memorySections.map((entry) => )} +
+