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 && ( + updateField('builtins', missingGrants.reduce((l, g) => setGrant(l, g, true), form.builtins || []))} data-testid="agent-grants-add-missing">{t('create.grantsAddMissing')}} + > + {t('create.grantsNotice', { grants: missingGrants.join(', ') })} + + )} +
+ {grantList.map(g => ( + + ))} +
+ + ) case 'Files': { const rows = form.folders || [] const effective = effectiveModes(rows) diff --git a/core/http/react-ui/src/pages/agents.css b/core/http/react-ui/src/pages/agents.css index 8614340a1c..e403fed8b5 100644 --- a/core/http/react-ui/src/pages/agents.css +++ b/core/http/react-ui/src/pages/agents.css @@ -354,3 +354,11 @@ a.ag-run-row:hover { background: color-mix(in srgb, var(--dk-hover) 45%, transpa margin-bottom: var(--sp-4); } .ag-switches .swl-list { overflow-y: visible; } + +/* The grants checklist of the agent editor. */ +.ag-grants { display: flex; flex-direction: column; gap: var(--sp-2); margin-top: var(--sp-3); } +.ag-grants__row { display: flex; align-items: flex-start; gap: var(--sp-2); cursor: pointer; } +.ag-grants__row input { margin-top: 3px; } +.ag-grants__text { display: flex; flex-direction: column; min-width: 0; } +.ag-grants__title { font-weight: 500; } +.ag-grants__tag { margin-left: var(--sp-2); font-size: var(--fs-small); color: var(--dk-muted); } diff --git a/core/http/react-ui/src/utils/chatAgents.js b/core/http/react-ui/src/utils/chatAgents.js index db013ca486..bb344b962f 100644 --- a/core/http/react-ui/src/utils/chatAgents.js +++ b/core/http/react-ui/src/utils/chatAgents.js @@ -2,10 +2,33 @@ // builtins_off and builtins_on are the switches of the built-in tool groups // (see utils/agentTools.js); they are lists like the grants. -export const LIST_KEYS = ['mcp_servers', 'addons', 'collections', 'skills', 'builtins_off', 'builtins_on'] +export const LIST_KEYS = ['mcp_servers', 'addons', 'collections', 'skills', 'builtins_off', 'builtins_on', 'builtins'] + +// The grants that an agent without a grant list holds for its messaging +// connections (not sensitive, so implied). As soon as the list names any +// grant, only the named ones work: these four are then off until added. The +// server rule is core/services/studio/connectorgrants.go (grantGated). +export const CONNECTOR_GRANTS = ['triggers.propose', 'triggers.run', 'talkback.channels', 'files.upload'] + +// The notice for the grants list: set when the saved agent had no list, the +// form now has one, and some connector grants are missing from it. Returns +// the missing names, or an empty array when nothing would turn off. +export function missingConnectorGrants(saved, current) { + const had = (saved || []).length > 0 + const now = current || [] + if (had || now.length === 0) return [] + return CONNECTOR_GRANTS.filter(g => !now.includes(g)) +} + +// The grants list with a name switched on or off. +export function setGrant(list, name, on) { + const rest = (list || []).filter(x => x !== name) + return on ? [...rest, name] : rest +} + export function emptyAgent() { - return { id: '', name: '', description: '', instructions: '', model: '', mcp_servers: [], addons: [], collections: [], skills: [], builtins_off: [], builtins_on: [], network: 'off', folders: [] } + return { id: '', name: '', description: '', instructions: '', model: '', mcp_servers: [], addons: [], collections: [], skills: [], builtins_off: [], builtins_on: [], builtins: [], network: 'off', folders: [] } } // Adds a name to a list: trimmed, not empty, once. diff --git a/core/http/react-ui/tests/unit/chatagents.test.mjs b/core/http/react-ui/tests/unit/chatagents.test.mjs index 6b8bf20ada..fa535f00dc 100644 --- a/core/http/react-ui/tests/unit/chatagents.test.mjs +++ b/core/http/react-ui/tests/unit/chatagents.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { addEntry, agentFolderMarks, agentPayload, emptyAgent, effectiveModes, folderName, folderPickable, folderRows, folderState, foldersPayload, foldersUnchosen, MAX_FOLDERS, grantSummary, isFolderMissingError, removeEntry } from '../../src/utils/chatAgents.js' +import { addEntry, agentFolderMarks, agentPayload, emptyAgent, effectiveModes, folderName, folderPickable, folderRows, folderState, foldersPayload, foldersUnchosen, MAX_FOLDERS, grantSummary, isFolderMissingError, removeEntry, missingConnectorGrants, setGrant, CONNECTOR_GRANTS } from '../../src/utils/chatAgents.js' test('chat agents: an entry is trimmed and added once', () => { assert.deepEqual(addEntry([], ' office '), ['office']) @@ -113,3 +113,22 @@ test('chat agents: a 409 agent_folder_missing is told apart from other errors', assert.equal(isFolderMissingError(new Error('x')), false) assert.equal(isFolderMissingError(null), false) }) + +test('chat agents: the first grant on an agent without a list names the connector grants that turn off', () => { + assert.deepEqual(missingConnectorGrants([], ['memory.write']), CONNECTOR_GRANTS) + assert.deepEqual(missingConnectorGrants(undefined, ['memory.write', 'triggers.run']), ['triggers.propose', 'talkback.channels', 'files.upload']) + assert.deepEqual(missingConnectorGrants([], CONNECTOR_GRANTS), []) +}) + +test('chat agents: no notice when the agent had a list, or the form still has none', () => { + assert.deepEqual(missingConnectorGrants(['memory.write'], ['memory.write', 'skills.write']), []) + assert.deepEqual(missingConnectorGrants([], []), []) + assert.deepEqual(missingConnectorGrants([], undefined), []) +}) + +test('chat agents: a grant is switched on once and off again', () => { + assert.deepEqual(setGrant([], 'memory.write', true), ['memory.write']) + assert.deepEqual(setGrant(['memory.write'], 'memory.write', true), ['memory.write']) + assert.deepEqual(setGrant(['memory.write', 'files.upload'], 'memory.write', false), ['files.upload']) + assert.deepEqual(agentPayload({ ...emptyAgent(), name: 'a', builtins: ['memory.write', ' memory.write'] }).builtins, ['memory.write']) +}) diff --git a/core/services/studio/builtins/builtins_test.go b/core/services/studio/builtins/builtins_test.go index b12e0072a9..1685a03636 100644 --- a/core/services/studio/builtins/builtins_test.go +++ b/core/services/studio/builtins/builtins_test.go @@ -180,3 +180,17 @@ var _ = Describe("a tool gated by a capability", func() { Expect(audited).To(Equal([]string{"memory.write"})) }) }) + +var _ = Describe("the files.upload grant", func() { + // The connector gate enforces files.upload on the files a sender attaches to + // a message (the sender's files going to the agent). The text of the editor + // must say that, not the opposite direction. + It("says that senders' files reach the agent, and not that the agent sends files", func() { + g, ok := builtins.LookupGrant(builtins.GrantFilesUpload) + Expect(ok).To(BeTrue()) + Expect(g.Title).To(Equal("Receive files")) + Expect(g.Description).To(ContainSubstring("attach files for the agent to read")) + Expect(g.Title + g.Description).NotTo(ContainSubstring("from your folders")) + Expect(g.Sensitive).To(BeFalse()) + }) +}) diff --git a/core/services/studio/builtins/grants.go b/core/services/studio/builtins/grants.go index 8b43b8601b..0b9f90637a 100644 --- a/core/services/studio/builtins/grants.go +++ b/core/services/studio/builtins/grants.go @@ -60,7 +60,7 @@ var grants = []Grant{ {GrantTriggersRun, "Run triggers", "The triggers of this agent may start from a messaging app.", false}, {GrantTalkbackChannels, "Reply on known channels", "The agent can send a message to a channel it already talks in.", false}, {GrantTalkbackNewChan, "Start new channels", "The agent can start a conversation on a channel it has not used before.", true}, - {GrantFilesUpload, "Send files", "The agent can send a file from your folders to a channel.", false}, + {GrantFilesUpload, "Receive files", "In a chat that a messaging app starts, a sender can attach files for the agent to read. It does not let the agent send files out.", false}, {GrantActFiles, "Write files on its own", "A run that nobody watches may write files inside the agent's folders.", true}, {GrantActAddons, "Use add-on actions on its own", "A run that nobody watches may call the actions of the add-ons it has.", true}, {GrantActSend, "Send messages on its own", "A run that nobody watches may send messages beyond its delivery target.", true}, diff --git a/docs/content/features/chats-agents.md b/docs/content/features/chats-agents.md index 9b17ec537b..fe14cc918a 100644 --- a/docs/content/features/chats-agents.md +++ b/docs/content/features/chats-agents.md @@ -269,7 +269,7 @@ In a chat that a messaging connection started, the grants also decide which tool the model gets. A tool exists when the sender holds the capability on the connection, the agent holds the grant, and the chat did not switch the tool off. Four grants work this way: `triggers.propose`, `triggers.run`, `talkback.channels` -and `files.upload` (the sender's files going to the agent), plus the sensitive +and `files.upload` (a sender's files going to the agent; it does not let the agent send files out), plus the sensitive `talkback.new_channel`. An agent that has no `builtins` list at all keeps the non-sensitive ones, as before. A sensitive grant is never implied: it needs to be on the list. The moment you grant any built-in, the list is the whole answer, so