From 84ae0333913d5e511221101c5955d98eb01a3e42 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:54:58 -0700 Subject: [PATCH 01/13] Add per-agent policy controls and memory editor Co-authored-by: tern Agent-Model: codex/gpt-6-sol/medium --- .../service/agent-manage-settings.test.js | 11 + .../service/agent-memory-editor.test.js | 92 +++++++++ .../service/agent-policy-settings.test.js | 86 ++++++++ backend/routes/agentMemoryView.ts | 90 +++++++- backend/routes/registry/agent-config.ts | 29 ++- backend/routes/registry/helpers.ts | 3 + backend/routes/registry/manage-settings.ts | 9 +- backend/services/hostedRuntimeService.ts | 9 + backend/services/nativeRuntimeService.ts | 11 +- frontend/src/i18n/locales/en.json | 32 +++ frontend/src/i18n/locales/zh-CN.json | 32 +++ .../src/v2/__tests__/V2AgentPolicy.test.tsx | 83 ++++++++ frontend/src/v2/agents/V2AgentPolicy.tsx | 195 ++++++++++++++++++ .../src/v2/agents/V2AgentSettingsPage.tsx | 3 + frontend/src/v2/agents/useAgentSettings.ts | 7 +- frontend/src/v2/agents/v2-agent-policy.css | 28 +++ 16 files changed, 706 insertions(+), 14 deletions(-) create mode 100644 backend/__tests__/service/agent-memory-editor.test.js create mode 100644 backend/__tests__/service/agent-policy-settings.test.js create mode 100644 frontend/src/v2/__tests__/V2AgentPolicy.test.tsx create mode 100644 frontend/src/v2/agents/V2AgentPolicy.tsx create mode 100644 frontend/src/v2/agents/v2-agent-policy.css 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..722f1affb9 --- /dev/null +++ b/backend/__tests__/service/agent-memory-editor.test.js @@ -0,0 +1,92 @@ +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('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); + }); +}); 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..761edc64fd 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -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,6 +104,91 @@ const memoryRateLimit = rateLimit({ const router: ReturnType = express.Router(); router.use(memoryRateLimit); +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 memoryEditorAccess = 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'; + const owns = isAdmin || await AgentInstallation.exists({ + agentName, instanceId, installedBy: userId, status: 'active', + }); + if (!owns) { res.status(403).json({ error: 'Forbidden' }); return null; } + return { agentName, instanceId }; +}; + +// 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 identity = await memoryEditorAccess(req, res); + if (!identity) return; + const record = await AgentMemory.findOne(identity).select('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 || '', 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 identity = await memoryEditorAccess(req, res); + if (!identity) return; + 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; + } + if (expectedUpdatedAt !== null && (typeof expectedUpdatedAt !== 'string' + || Number.isNaN(new Date(expectedUpdatedAt).getTime()))) { + res.status(400).json({ error: 'Expected memory revision required' }); return; + } + const prior = await AgentMemory.findOne(identity).select('sections').lean(); + const before = prior?.sections?.[section]; + 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; + } + const incoming = stampSectionsForWrite({ [section]: { + content, visibility: before?.visibility || 'private', + } }); + const decorated = decorateSectionsWithProvenance(prior?.sections, 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 { 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..61e4f6ccb1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2385,6 +2385,38 @@ "noPod": "This agent has no active pod.", "missingReplacement": "The replacement key was not returned." }, + "agentPolicy": { + "title": "Policy", + "approvals": "Tool approvals", + "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": "Work in my repo", + "saveTrust": "Save trust mode", + "memory": "Memory", + "section": { "long_term": "Long-term", "soul": "Identity", "shared": "Shared" }, + "saveMemory": "Save memory", + "loadingMemory": "Loading memory…", + "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..3b62a1ff00 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2376,6 +2376,38 @@ "noPod": "此智能体没有活跃的 Pod。", "missingReplacement": "未返回新密钥。" }, + "agentPolicy": { + "title": "策略", + "approvals": "工具审批", + "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": "正在加载记忆…", + "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..3fa1a46f8a --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx @@ -0,0 +1,83 @@ +// @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'); +}); diff --git a/frontend/src/v2/agents/V2AgentPolicy.tsx b/frontend/src/v2/agents/V2AgentPolicy.tsx new file mode 100644 index 0000000000..ce73c05dda --- /dev/null +++ b/frontend/src/v2/agents/V2AgentPolicy.tsx @@ -0,0 +1,195 @@ +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 [error, setError] = useState(null); + const [saved, setSaved] = 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); + if (!preserveDraft) setContent(response.data.sections[section]?.content || ''); + }, [memoryUrl, section]); + + useEffect(() => { + let current = true; + setDocuments(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) setError(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) setError(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) { + setError(t('agentPolicy.contextInvalid')); return; + } + setBusy(true); setError(null); setSaved(null); + try { + await update({ toolPolicy: { allowed: commaList(allowed), blocked: commaList(blocked), + requireApproval: commaList(approval) }, + contextPolicy: { maxTokens, compactionThreshold, summaryHours, + includeMemory, includeSkills, includeSummaries } }); + setSaved(t('agentPolicy.saved')); + } catch (requestError) { setError(message(requestError, t('agentPolicy.saveError'))); } + finally { setBusy(false); } + }; + + const saveTrust = async () => { + if (!canSetTrust || !['read-only', 'workspace'].includes(trustMode)) return; + setBusy(true); setError(null); setSaved(null); + try { + await update({ config: { environment: { sandbox: { mode: trustMode } } } }); + setSaved(t('agentPolicy.saved')); + } catch (requestError) { setError(message(requestError, t('agentPolicy.saveError'))); } + finally { setBusy(false); } + }; + + const saveMemory = async () => { + if (!documents) return; + setBusy(true); setError(null); setSaved(null); + try { + await axios.patch(`${memoryUrl}/${section}`, { + content, expectedUpdatedAt: documents[section].updatedAt, + }); + await loadMemory(); + setSaved(t('agentPolicy.saved')); + } catch (requestError) { + setError(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 || ''); + setError(null); setSaved(null); + }; + + return
+

{t('agentPolicy.title')}

+ {error &&

{error}

} + {saved &&

{saved}

} +
+

{t('agentPolicy.approvals')}

+ + + +
+
+

{t('agentPolicy.context')}

+
+ + + +
+
+ + + +
+ +
+ {canSetTrust &&
+

{t('agentPolicy.trust')}

+ + +
} +
+

{t('agentPolicy.memory')}

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