diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js index 8ee8e020c8..119aa7e34e 100644 --- a/core/http/react-ui/e2e/agents-edit.spec.js +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -35,10 +35,34 @@ test.describe('Agent form', () => { await expect(page).toHaveURL(/\/app\/agents$/) expect(s.calls.find(c => c.method === 'POST').body).toEqual({ name: 'Reviewer', description: 'Checks a change', instructions: 'Answer in French.', model: '', - mcp_servers: ['connector:m1'], addons: [], collections: ['handbook'], skills: ['citations'], builtins_off: [], builtins_on: [], network: 'off', + mcp_servers: ['connector:m1'], addons: [], collections: ['handbook'], skills: ['citations'], builtins_off: [], builtins_on: [], builtins: [], network: 'off', }) }) + test('warns when the first permission turns off the messaging defaults, and adds the missing ones in one click', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/agents/a2/edit') + await fold(page, 'Grants').locator('.ag-fold__button').click() + await expect(page.getByTestId('agent-grants-notice')).toHaveCount(0) + await page.getByTestId('agent-grant').filter({ hasText: 'Remember things' }).getByRole('checkbox').check() + const notice = page.getByTestId('agent-grants-notice') + await expect(notice).toContainText('triggers.propose, triggers.run, talkback.channels, files.upload') + await page.getByTestId('agent-grants-add-missing').click() + await expect(notice).toHaveCount(0) + await page.getByRole('button', { name: 'Save changes' }).first().click() + await expect(page).toHaveURL(/\/app\/agents$/) + const put = s.calls.find(c => c.method === 'PUT') + expect(put.body.builtins.sort()).toEqual(['files.upload', 'memory.write', 'talkback.channels', 'triggers.propose', 'triggers.run']) + }) + + test('shows no warning for an agent that already had permissions', async ({ page }) => { + await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write'] }] }) + await page.goto('/app/agents/a2/edit') + await fold(page, 'Grants').locator('.ag-fold__button').click() + await page.getByTestId('agent-grant').filter({ hasText: 'Propose triggers' }).getByRole('checkbox').check() + await expect(page.getByTestId('agent-grants-notice')).toHaveCount(0) + }) + test('sets the network of the commands of unattended runs', async ({ page }) => { const s = await installAgents(page) await page.goto('/app/agents/new') @@ -238,7 +262,7 @@ test.describe('Agent folder', () => { test('read only shows the lock chip, and the mode switch sends write', async ({ page }) => { const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) }) - await page.goto('/app/agents/a2') + await page.goto('/app/agents/a2/edit') const chip = page.getByTestId('agent-facts').getByTestId('agent-folder-chip') await expect(chip).toContainText('Acme, read only') await expect(chip.locator('[data-icon="lock"]')).toHaveCount(1) @@ -279,7 +303,7 @@ test.describe('Agent folder', () => { const chip = page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip') await expect(chip).toHaveText('Folder missing') - await page.goto('/app/agents/a2') + await page.goto('/app/agents/a2/edit') await expect(page.getByTestId('agent-folder-warning')).toContainText('This folder is gone. Choose another folder to use this agent.') await page.getByTestId('agent-facts').getByTestId('agent-folder-chip').click() await expect(page).toHaveURL(/\/app\/agents\/a2\/edit$/) diff --git a/core/http/react-ui/e2e/agents-fixtures.js b/core/http/react-ui/e2e/agents-fixtures.js index b0f1759f97..c021e6fd3c 100644 --- a/core/http/react-ui/e2e/agents-fixtures.js +++ b/core/http/react-ui/e2e/agents-fixtures.js @@ -17,6 +17,15 @@ export const AGENTS = [ }, ] +export const GRANTS = [ + { name: 'memory.write', title: 'Remember things', description: 'The agent can propose a note in your memory.', sensitive: false }, + { name: 'triggers.propose', title: 'Propose triggers', description: 'The agent can propose a task that runs by itself.', sensitive: false }, + { name: 'triggers.run', title: 'Run triggers', description: 'The triggers of this agent may start from a messaging app.', sensitive: false }, + { name: 'talkback.channels', title: 'Reply on known channels', description: 'The agent can send a message to a channel it already talks in.', sensitive: false }, + { name: 'files.upload', title: 'Receive files', description: 'Senders can attach files for the agent to read.', sensitive: false }, + { name: 'profile.write', title: 'Change its own rules', description: 'The agent can propose an edit to its profile.', sensitive: true }, +] + const iso = (minutesAgo) => new Date(Date.now() - minutesAgo * 60_000).toISOString() export const CHATS = [ @@ -168,6 +177,7 @@ export async function installAgents(page, { // Agent files and run history, as the server answers them: an export is the // definition without id or owner, an import always makes a new agent, and // the runs are the chats that ran with the agent. + await page.route('**/api/chats/agents/builtin-grants', route => route.fulfill({ json: { grants: GRANTS } })) await page.route(/\/api\/chats\/agents\/import$/, route => { const req = route.request() rec(req) diff --git a/core/http/react-ui/public/locales/de/agents.json b/core/http/react-ui/public/locales/de/agents.json index c142b6fc09..36548bbdfd 100644 --- a/core/http/react-ui/public/locales/de/agents.json +++ b/core/http/react-ui/public/locales/de/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "Dateien", - "Tools": "Werkzeuge" + "Tools": "Werkzeuge", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "Wähle, was ein Chat mit diesem Agenten nutzen kann. Das sind dieselben Schalter wie im Werkzeuge-Menü eines Chats. Ein ausgeschaltetes Werkzeug wird dem Modell nie angeboten, und ein Chat mit diesem Agenten kann es nicht einschalten.", "toolsHeading": "Werkzeuge dieses Agenten", "skillsLater": "Ein Chat mit diesem Agenten liest nur die eingeschalteten Skills.", - "skillsHeading": "Skills dieses Agenten" + "skillsHeading": "Skills dieses Agenten", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/en/agents.json b/core/http/react-ui/public/locales/en/agents.json index 1ae1d48d66..bf13f3cf8e 100644 --- a/core/http/react-ui/public/locales/en/agents.json +++ b/core/http/react-ui/public/locales/en/agents.json @@ -190,8 +190,16 @@ "MCP": "MCP servers", "export": "Export", "Files": "Files", - "Tools": "Tools" + "Tools": "Tools", + "Grants": "Permissions" }, + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions", "name": "Name", "description": "Description", "descriptionHelp": "One line that says what the agent is for. It shows in the list.", diff --git a/core/http/react-ui/public/locales/es/agents.json b/core/http/react-ui/public/locales/es/agents.json index cffb331c06..2cbe6bd5e7 100644 --- a/core/http/react-ui/public/locales/es/agents.json +++ b/core/http/react-ui/public/locales/es/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "Archivos", - "Tools": "Herramientas" + "Tools": "Herramientas", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "Elige qué puede usar un chat con este agente. Son los mismos interruptores que el menú Herramientas de un chat. Una herramienta apagada nunca se ofrece al modelo, y un chat con este agente no puede encenderla.", "toolsHeading": "Herramientas de este agente", "skillsLater": "Un chat con este agente solo lee las skills activas.", - "skillsHeading": "Skills de este agente" + "skillsHeading": "Skills de este agente", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/fr/agents.json b/core/http/react-ui/public/locales/fr/agents.json index cd194705f0..4587e03865 100644 --- a/core/http/react-ui/public/locales/fr/agents.json +++ b/core/http/react-ui/public/locales/fr/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "Fichiers", - "Tools": "Outils" + "Tools": "Outils", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "Choisissez ce qu'un chat avec cet agent peut utiliser. Ce sont les mêmes interrupteurs que le menu Outils d'un chat. Un outil désactivé n'est jamais proposé au modèle, et un chat avec cet agent ne peut pas l'activer.", "toolsHeading": "Outils de cet agent", "skillsLater": "Un chat avec cet agent ne lit que les skills activées.", - "skillsHeading": "Skills de cet agent" + "skillsHeading": "Skills de cet agent", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/id/agents.json b/core/http/react-ui/public/locales/id/agents.json index 5f16bb30b5..5bee76947e 100644 --- a/core/http/react-ui/public/locales/id/agents.json +++ b/core/http/react-ui/public/locales/id/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "File", - "Tools": "Alat" + "Tools": "Alat", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "Pilih apa yang dapat dipakai chat dengan agen ini. Sakelarnya sama dengan menu Alat pada chat. Alat yang mati tidak pernah ditawarkan kepada model, dan chat dengan agen ini tidak dapat menyalakannya.", "toolsHeading": "Alat agen ini", "skillsLater": "Chat dengan agen ini hanya membaca skill yang aktif.", - "skillsHeading": "Skill agen ini" + "skillsHeading": "Skill agen ini", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/it/agents.json b/core/http/react-ui/public/locales/it/agents.json index 8a402fbe75..7a2994b9b2 100644 --- a/core/http/react-ui/public/locales/it/agents.json +++ b/core/http/react-ui/public/locales/it/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "File", - "Tools": "Strumenti" + "Tools": "Strumenti", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "Scegli cosa può usare una chat con questo agente. Sono gli stessi interruttori del menu Strumenti di una chat. Uno strumento spento non viene mai offerto al modello, e una chat con questo agente non può accenderlo.", "toolsHeading": "Strumenti di questo agente", "skillsLater": "Una chat con questo agente legge solo le skill attive.", - "skillsHeading": "Skill di questo agente" + "skillsHeading": "Skill di questo agente", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/ko/agents.json b/core/http/react-ui/public/locales/ko/agents.json index 292257e8d8..329cc3a340 100644 --- a/core/http/react-ui/public/locales/ko/agents.json +++ b/core/http/react-ui/public/locales/ko/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "파일", - "Tools": "도구" + "Tools": "도구", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "이 에이전트와의 채팅이 사용할 수 있는 것을 고르세요. 채팅의 도구 메뉴와 같은 스위치입니다. 꺼진 도구는 모델에 제공되지 않으며, 이 에이전트와의 채팅에서 켤 수 없습니다.", "toolsHeading": "이 에이전트의 도구", "skillsLater": "이 에이전트와의 채팅은 켜진 스킬만 읽습니다.", - "skillsHeading": "이 에이전트의 스킬" + "skillsHeading": "이 에이전트의 스킬", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/pt-BR/agents.json b/core/http/react-ui/public/locales/pt-BR/agents.json index 0f19ba2a0e..ff37baee81 100644 --- a/core/http/react-ui/public/locales/pt-BR/agents.json +++ b/core/http/react-ui/public/locales/pt-BR/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "Arquivos", - "Tools": "Ferramentas" + "Tools": "Ferramentas", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "Escolha o que um chat com este agente pode usar. São os mesmos interruptores do menu Ferramentas de um chat. Uma ferramenta desligada nunca é oferecida ao modelo, e um chat com este agente não pode ligá-la.", "toolsHeading": "Ferramentas deste agente", "skillsLater": "Um chat com este agente lê apenas as skills ligadas.", - "skillsHeading": "Skills deste agente" + "skillsHeading": "Skills deste agente", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/zh-CN/agents.json b/core/http/react-ui/public/locales/zh-CN/agents.json index 24332fb2b1..e6072793a4 100644 --- a/core/http/react-ui/public/locales/zh-CN/agents.json +++ b/core/http/react-ui/public/locales/zh-CN/agents.json @@ -81,7 +81,8 @@ "MCP": "MCP servers", "export": "Export", "Files": "文件", - "Tools": "工具" + "Tools": "工具", + "Grants": "Permissions" }, "name": "Name", "description": "Description", @@ -139,7 +140,14 @@ "toolsHelp": "选择使用此智能体的聊天可以用什么。这些开关与聊天的工具菜单相同。关闭的工具不会提供给模型,使用此智能体的聊天也无法将其打开。", "toolsHeading": "此智能体的工具", "skillsLater": "使用此智能体的聊天只会读取已开启的技能。", - "skillsHeading": "此智能体的技能" + "skillsHeading": "此智能体的技能", + "grantsHelp": "What the agent may do beyond its tools. With no permission ticked, the agent keeps the defaults it had before permissions existed. Once you tick any, only the ticked ones work.", + "grantsDefault": "Defaults", + "countGrants": "{{count}} granted", + "grantSensitive": "needs care", + "grantsNoticeTitle": "Some messaging features will turn off.", + "grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.", + "grantsAddMissing": "Add the missing permissions" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx index 3eb6d0b643..7e89317fd1 100644 --- a/core/http/react-ui/src/pages/AgentCreate.jsx +++ b/core/http/react-ui/src/pages/AgentCreate.jsx @@ -7,7 +7,7 @@ import { agentCollectionsApi, chatAgentsApi, modelMCPServersApi } from '../utils import { useAgentTools } from '../hooks/useAgentTools' import { useToolGroups } from '../hooks/useToolGroups' import { AGENT_TEMPLATES, diffConfig, displayValue, maskSecrets, summarise } from '../utils/agentConfigTools' -import { agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS } from '../utils/chatAgents' +import { agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS, missingConnectorGrants, setGrant } from '../utils/chatAgents' import { downloadAgentFile } from '../utils/agentFiles' import { joinAgentMCP, splitAgentMCP } from '../utils/chatMCP' import './agents.css' @@ -40,6 +40,7 @@ const SECTIONS = [ { id: 'MemorySettings', icon: 'database' }, { id: 'Skills', icon: 'sparkles' }, { id: 'Tools', icon: 'plug' }, + { id: 'Grants', icon: 'shield' }, { id: 'MCP', icon: 'server' }, ] @@ -79,6 +80,7 @@ export default function AgentCreate() { const initialFormRef = useRef(null) const [collectionNames, setCollectionNames] = useState([]) const [modelServers, setModelServers] = useState([]) + const [grantList, setGrantList] = useState([]) // Suggestions are best effort: a failure leaves the field free text. useEffect(() => { @@ -87,6 +89,13 @@ export default function AgentCreate() { .catch(() => {}) }, []) + // The grants the editor can list: best effort, an empty list hides the checklist. + useEffect(() => { + chatAgentsApi.builtinGrants() + .then(r => setGrantList(Array.isArray(r?.grants) ? r.grants : [])) + .catch(() => {}) + }, []) + // The servers of the fixed model are suggestions; with no fixed model the field is free text. const fixedModel = form.model || '' useEffect(() => { @@ -142,6 +151,10 @@ export default function AgentCreate() { // A folder row was added or changed and has no folder chosen. const folderUnchosen = foldersUnchosen(initialFolders, form.folders) + // Adding the first grant to an agent that had none turns off what it did + // by default on its messaging connections. Say so, and offer the fix. + const missingGrants = isEdit ? missingConnectorGrants(savedConfig?.builtins, form.builtins) : [] + const handleSubmit = async (e) => { e?.preventDefault?.() if (!payload.name) { @@ -210,6 +223,12 @@ export default function AgentCreate() { summary = total ? t('create.toolsSummary', { on, total }) : t('create.noneYet') state = on ? 'ready' : 'empty' } + else if (s.id === 'Grants') { + keys = ['builtins'] + const n = (form.builtins || []).length + summary = n ? t('create.countGrants', { count: n }) : t('create.grantsDefault') + state = missingGrants.length ? 'needs' : 'ready' + } else if (s.id === 'Files') { keys = ['folders']; ({ summary, state } = folderFold()) } else if (s.id === 'MCP') { keys = ['mcp_servers']; summary = countOf(form.mcp_servers.length, 'create.countServers'); state = form.mcp_servers.length ? 'ready' : 'empty' } else { @@ -343,6 +362,32 @@ export default function AgentCreate() { > ) + case 'Grants': + return ( + <> +
{t('create.grantsHelp')}
+ {missingGrants.length > 0 && ( +