From af570ff17d8d5ba71fa165784a951842618f3a82 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 13:19:09 +0000 Subject: [PATCH] feat(connectors): add Runs may act to the approval settings A trigger proposed from a chat can carry act.* permissions only when the connection lists them in approval.acts. The connector settings page had no control for it, so runs always read only. Add a switch "Runs may act" under the approval mode of Propose triggers. When on, four sensitive checkboxes (files, add-ons, send, spend) say what a run may do. A box is disabled with a reason when the agent lacks the matching grant, but a ticked one stays ticked and can be unticked. A line says how the approval modes treat acts, and the row of Propose triggers lists the acts that are set. The page sends acts together with the mode and limits, because the server replaces the whole approval object. An empty list means runs only read. Names outside the four are rejected before saving. Assisted-by: Claude:claude-sonnet-5-5 --- .../connector-ui/src/ApprovalFields.jsx | 67 ++++++++++++++++-- .../connector-ui/src/PermissionFields.jsx | 6 +- .../internal/connector-ui/src/connector.css | 7 ++ addons/internal/connector-ui/src/connector.js | 18 ++++- addons/telegram/ui/test/connector.test.mjs | 27 +++++++- core/http/react-ui/e2e/connector-fixtures.js | 1 + .../e2e/connector-permissions.spec.js | 69 ++++++++++++++++++- docs/content/features/chats-connectors.md | 2 + 8 files changed, 183 insertions(+), 14 deletions(-) diff --git a/addons/internal/connector-ui/src/ApprovalFields.jsx b/addons/internal/connector-ui/src/ApprovalFields.jsx index 456e291a4e..a131e630ef 100644 --- a/addons/internal/connector-ui/src/ApprovalFields.jsx +++ b/addons/internal/connector-ui/src/ApprovalFields.jsx @@ -3,9 +3,9 @@ // under the permission "Propose triggers" (see PermissionFields), as an option // of it. The same on every platform, so the words live here as defaults, like the other shared // fields. The server enforces the limits; the page only bounds what is typed. -import { useId } from 'react' -import { Field, RadioCards, TextInput } from './controls' -import { APPROVAL_LIMITS, APPROVAL_MODES, approvalProblem } from './connector' +import { useId, useState } from 'react' +import { Field, RadioCards, Switch, TextInput } from './controls' +import { APPROVAL_ACTS, APPROVAL_LIMITS, APPROVAL_MODES, agentHoldsAct, approvalProblem } from './connector' export const APPROVAL_TEXT = { 'approval.title': 'Timers and triggers from the chat', @@ -23,21 +23,50 @@ export const APPROVAL_TEXT = { 'approval.summary': 'Triggers: {{mode}}', 'approval.effect': 'Proposals: {{mode}}', 'approval.limits': 'Limits', + 'approval.acts.title': 'Runs may act', + 'approval.acts.help': 'Off: the runs of a trigger made from this chat only read and report. On: they may also do what you tick below, and only what the agent has.', + 'approval.acts.modes': 'Ask me: you always decide. At once: only when you are paired as sender and every act is ticked here.', + 'approval.acts.act.files': 'Write files in the agent\'s folders', + 'approval.acts.act.files.effect': 'A run can create and change files the agent keeps.', + 'approval.acts.act.addons': 'Use the actions of the agent\'s add-ons', + 'approval.acts.act.addons.effect': 'A run can use what the add-ons of the agent do, including commands.', + 'approval.acts.act.send': 'Send messages beyond where it reports', + 'approval.acts.act.send.effect': 'A run can write to other chats and channels on its own.', + 'approval.acts.act.spend': 'Use paid or external tools and the network', + 'approval.acts.act.spend.effect': 'A run can call services outside LocalAI, which may cost money.', + 'approval.acts.lacks': 'The agent does not have this.', + 'approval.acts.sensitive': 'Sensitive', + 'approval.effectActs': ', runs may: {{acts}}', + 'approval.actShort.act.files': 'write files', + 'approval.actShort.act.addons': 'use add-ons', + 'approval.actShort.act.send': 'send messages', + 'approval.actShort.act.spend': 'use paid or external tools', 'approval.short.ask': 'asks first', 'approval.short.auto': 'set up at once', 'approval.short.auto_readonly': 'at once if read-only', } +/** The line under "Propose triggers": the mode, and the acts a run may do. */ +export function approvalEffect(a, s) { + const base = s('approval.effect', { mode: s(`approval.short.${a.mode || 'ask'}`) }) + const acts = APPROVAL_ACTS.filter((n) => (a.acts || []).includes(n)) + return acts.length === 0 ? base : base + s('approval.effectActs', { acts: acts.map((n) => s(`approval.actShort.${n}`)).join(', ') }) +} + /** * The approval mode and its limits, as an option under "Propose triggers". * With off set (the permission is not ticked) the choice is greyed, with the * reason, and nothing can be changed. The limits sit in a closed disclosure. */ -export default function ApprovalOptions({ form, setForm, s, off = false }) { +export default function ApprovalOptions({ form, setForm, s, off = false, held = null }) { const a = form.approval const reasonId = useId() + const [actsOpen, setActsOpen] = useState(false) const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } }) const bad = approvalProblem(a) + const acts = a.acts || [] + const actsOn = acts.length > 0 || actsOpen + const toggle = (name, v) => set({ acts: APPROVAL_ACTS.filter((n) => (n === name ? v : acts.includes(n))) }) return (
{s('approval.title')} @@ -46,6 +75,36 @@ export default function ApprovalOptions({ form, setForm, s, off = false }) { {off &&

{s('perm.triggersNeed')}

} set({ mode })} options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} /> +
+ { setActsOpen(v); if (!v) set({ acts: [] }) }}> + {s('approval.acts.title')} + +

{s('approval.acts.help')}

+ {actsOn && ( +
    + {APPROVAL_ACTS.map((n) => { + const on = acts.includes(n) + const lacks = !agentHoldsAct(held, n) + const blocked = lacks && !on + return ( +
  • + +

    {s(`approval.acts.${n}.effect`)}

    + {lacks &&

    {s('approval.acts.lacks')}

    } +
  • + ) + })} +
+ )} +

{s('approval.acts.modes')}

+
{s('approval.limits')}

{s('approval.limitsHelp')}

diff --git a/addons/internal/connector-ui/src/PermissionFields.jsx b/addons/internal/connector-ui/src/PermissionFields.jsx index c2ff3092bc..ae7e8c9d59 100644 --- a/addons/internal/connector-ui/src/PermissionFields.jsx +++ b/addons/internal/connector-ui/src/PermissionFields.jsx @@ -10,7 +10,7 @@ import { Field, Hint, Notice, Switch, TextInput } from './controls' import Icon from '../../host-compat/src/components/Icon' import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unpairedSensitive, usualFor, withAddedCaps, withCopiedCaps } from './connector' import PairingFields from './PairingFields' -import ApprovalOptions from './ApprovalFields' +import ApprovalOptions, { approvalEffect } from './ApprovalFields' export const PERMISSION_TEXT = { 'perm.title': 'What senders may do', @@ -307,8 +307,8 @@ export default function PermissionFields({ form, setForm, kind, capabilities, s,
    {caps.map((c) => setForm(setCap(form, c.name, v))}> - {c.name === 'triggers.propose' && form.approval && } + effect={c.name === 'triggers.propose' && form.approval ? approvalEffect(form.approval, s) : undefined} lacks={Boolean(form.agent_id) && held !== null && !agentHas(held, c.name)} onChange={(v) => setForm(setCap(form, c.name, v))}> + {c.name === 'triggers.propose' && form.approval && } )}
diff --git a/addons/internal/connector-ui/src/connector.css b/addons/internal/connector-ui/src/connector.css index 30bd3b2a53..fe1d7d7236 100644 --- a/addons/internal/connector-ui/src/connector.css +++ b/addons/internal/connector-ui/src/connector.css @@ -207,3 +207,10 @@ fieldset.cn-group { .cn-option__title { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } .cn-option__limits summary { cursor: pointer; font-size: var(--fs-small); color: var(--color-text-secondary); } .cn-option__limits[open] summary { margin-bottom: var(--dk-space-2); } +.cn-acts { display: flex; flex-direction: column; gap: var(--dk-space-2); } +.cn-acts__list { list-style: none; margin: 0; padding: 0 0 0 var(--dk-space-3); display: flex; flex-direction: column; gap: var(--dk-space-2); } +.cn-acts__item { display: flex; flex-direction: column; gap: 2px; } +.cn-acts__label { display: flex; align-items: flex-start; gap: var(--dk-space-2); } +.cn-acts__label .dk-check { flex: none; margin-top: 2px; } +.cn-acts__text { display: flex; align-items: center; flex-wrap: wrap; gap: 2px var(--dk-space-2); min-width: 0; } +.cn-acts__item[data-lacks="true"] .cn-acts__label { opacity: 0.7; } diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index 366571f58d..66db66edb0 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -17,14 +17,23 @@ export const APPROVAL_LIMITS = [ { key: 'max_lifetime_days', max: 365, fallback: 30 }, ] +// The acts a run of a trigger proposed from a chat may be allowed to do. The +// names are also the grants of the agent, which must hold the same one. Every +// one is sensitive. +export const APPROVAL_ACTS = ['act.files', 'act.addons', 'act.send', 'act.spend'] + export function emptyApproval() { - return { mode: 'ask', max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' } + return { mode: 'ask', acts: [], max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' } } +// Whether the agent holds the grant behind an act. held is agentHeld(agent); +// null (permissions unreadable) gates nothing. +export const agentHoldsAct = (held, act) => held === null || held === undefined || held.includes(act) + // The approval of a saved binding as a form: a limit of zero is the default // and shows empty. export function approvalForm(a) { - const f = { ...emptyApproval(), mode: a?.mode || 'ask' } + const f = { ...emptyApproval(), mode: a?.mode || 'ask', acts: Array.isArray(a?.acts) ? [...a.acts] : [] } for (const { key } of APPROVAL_LIMITS) f[key] = a?.[key] > 0 ? String(a[key]) : '' return f } @@ -34,6 +43,7 @@ export function approvalForm(a) { export function approvalProblem(a) { if (!a) return '' if (!APPROVAL_MODES.includes(a.mode)) return 'mode' + if ((a.acts || []).some((n) => !APPROVAL_ACTS.includes(n))) return 'acts' for (const { key, max } of APPROVAL_LIMITS) { const v = String(a[key] ?? '').trim() if (v === '') continue @@ -44,7 +54,9 @@ export function approvalProblem(a) { } export function approvalPayload(a) { - const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask' } + // The whole approval is sent each time (the server replaces it), acts too; + // an empty list means runs only read. + const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask', acts: APPROVAL_ACTS.filter((n) => (a?.acts || []).includes(n)) } for (const { key } of APPROVAL_LIMITS) body[key] = Number(String(a?.[key] ?? '').trim()) || 0 return body } diff --git a/addons/telegram/ui/test/connector.test.mjs b/addons/telegram/ui/test/connector.test.mjs index a92c97cce0..6c5ee70678 100644 --- a/addons/telegram/ui/test/connector.test.mjs +++ b/addons/telegram/ui/test/connector.test.mjs @@ -142,14 +142,14 @@ test('chat connectors: a saved limit reads back to the same number of bytes', () test('chat connectors: the approval of chat triggers asks by default and sends its limits as numbers', () => { const base = { ...emptyConnector(), name: 'B', agent_id: 'a' } - assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) + assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', acts: [], max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) const auto = connectorPayload({ ...base, approval: { mode: 'auto', max_pending: '3', min_gap_minutes: '', max_runs_per_day: ' 8 ', max_lifetime_days: '14' } }, 'telegram') - assert.deepEqual(auto.approval, { mode: 'auto', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 }) + assert.deepEqual(auto.approval, { mode: 'auto', acts: [], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 }) assert.equal(connectorPayload({ ...base, approval: { mode: 'sometimes' } }, 'telegram').approval.mode, 'ask', 'an unknown mode falls back to ask') }) test('chat connectors: a saved approval reads back, with a zero limit shown as empty', () => { - assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }) + assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', acts: [], max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }) assert.equal(connectorForm({ id: 'b' }).approval.mode, 'ask') }) @@ -163,3 +163,24 @@ test('chat connectors: an approval limit is a whole number within its bound, or assert.equal(canSave({ ...ok, approval: { mode: 'auto', max_pending: '500' } }, false), false) assert.equal(canSave(ok, false), true) }) + +test('chat connectors: approval acts round trip, are sent with the mode and limits, and an empty list is sent as []', () => { + const saved = { mode: 'auto', acts: ['act.spend', 'act.files'], max_pending: 3 } + const form = approvalForm(saved) + assert.deepEqual(form.acts, ['act.spend', 'act.files']) + const out = approvalPayload(form) + assert.deepEqual(out, { mode: 'auto', acts: ['act.files', 'act.spend'], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) + assert.deepEqual(approvalPayload({ ...form, acts: [] }).acts, []) + assert.deepEqual(approvalPayload({ mode: 'ask' }).acts, []) + assert.deepEqual(approvalForm({ mode: 'ask' }).acts, []) +}) + +test('chat connectors: approval acts replace the whole object, so a mode change keeps them, and only the four names are valid', () => { + const base = { ...connectorForm({ id: 'b', approval: { mode: 'ask', acts: ['act.addons'] } }) } + const changed = connectorPayload({ ...base, approval: { ...base.approval, mode: 'auto' } }, 'telegram') + assert.equal(changed.approval.mode, 'auto') + assert.deepEqual(changed.approval.acts, ['act.addons']) + assert.deepEqual(approvalPayload({ mode: 'ask', acts: ['act.files', 'act.bogus'] }).acts, ['act.files']) + assert.equal(approvalProblem({ mode: 'ask', acts: ['act.send'] }), '') + assert.equal(approvalProblem({ mode: 'ask', acts: ['act.bogus'] }), 'acts') +}) diff --git a/core/http/react-ui/e2e/connector-fixtures.js b/core/http/react-ui/e2e/connector-fixtures.js index 6e4529dd98..6261d91ad3 100644 --- a/core/http/react-ui/e2e/connector-fixtures.js +++ b/core/http/react-ui/e2e/connector-fixtures.js @@ -5,6 +5,7 @@ export const AGENTS = [ { id: 'a1', name: 'Support', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel', 'profile.write'] }, { id: 'a2', name: 'No model', model: '', mcp_servers: [], addons: [], collections: [], skills: [], builtins: [] }, + { id: 'a4', name: 'Builder', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'triggers.propose', 'act.files', 'act.addons'] }, { id: 'a3', name: 'Careful', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'files.upload'] }, ] diff --git a/core/http/react-ui/e2e/connector-permissions.spec.js b/core/http/react-ui/e2e/connector-permissions.spec.js index af1ca95248..568402c9f3 100644 --- a/core/http/react-ui/e2e/connector-permissions.spec.js +++ b/core/http/react-ui/e2e/connector-permissions.spec.js @@ -353,7 +353,74 @@ test.describe('Connector permissions', () => { await limits.getByText('Limits', { exact: true }).click() await limits.getByTestId('connector-approval-max_pending').fill('3') await page.getByRole('button', { name: 'Save' }).click() - await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto_readonly', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) + await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto_readonly', acts: [], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) + }) + + test('Runs may act sits under the approval mode, is off by default, lists the four acts, disables what the agent lacks and is sent with the whole approval', async ({ page }) => { + const a = api(page, [{ ...LEGACY, agent_id: 'a4', caps: ['triggers.propose'], approval: { mode: 'auto', acts: [], max_pending: 3 } }]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const row = page.getByTestId('connector-cap-triggers.propose') + const acts = row.getByTestId('connector-acts') + await expect(acts.getByTestId('connector-acts-switch')).toHaveAttribute('aria-checked', 'false') + await expect(acts.getByRole('checkbox')).toHaveCount(0) + await expect(acts.getByTestId('connector-acts-modes')).toHaveText('Ask me: you always decide. At once: only when you are paired as sender and every act is ticked here.') + await shot(page, 'acts-1-off') + await acts.getByTestId('connector-acts-switch').click() + await expect(acts.getByRole('checkbox')).toHaveCount(4) + await expect(acts.getByTestId('connector-act-act.files')).toContainText("Write files in the agent's folders") + await expect(acts.getByTestId('connector-act-act.addons')).toContainText("Use the actions of the agent's add-ons") + await expect(acts.getByTestId('connector-act-act.send')).toContainText('Send messages beyond where it reports') + await expect(acts.getByTestId('connector-act-act.spend')).toContainText('Use paid or external tools and the network') + await expect(acts.getByText('Sensitive')).toHaveCount(4) + // The agent holds files and add-ons only. + await expect(acts.getByTestId('connector-act-check-act.files')).toBeEnabled() + await expect(acts.getByTestId('connector-act-check-act.send')).toBeDisabled() + await expect(acts.getByTestId('connector-act-lacks-act.send')).toHaveText('The agent does not have this.') + await expect(acts.getByTestId('connector-act-check-act.send')).toHaveAccessibleDescription('The agent does not have this.') + await expect(acts.getByTestId('connector-act-lacks-act.files')).toHaveCount(0) + await shot(page, 'acts-3-lacks') + await acts.getByTestId('connector-act-check-act.addons').check() + await acts.getByTestId('connector-act-check-act.files').check() + await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: set up at once, runs may: write files, use add-ons') + await shot(page, 'acts-2-on') + await page.getByRole('button', { name: 'Save' }).click() + await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto', acts: ['act.files', 'act.addons'], max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) + }) + + test('a ticked act the agent no longer holds stays ticked and can be unticked; turning Runs may act off sends an empty list', async ({ page }) => { + const a = api(page, [{ ...LEGACY, agent_id: 'a1', caps: ['triggers.propose'], approval: { mode: 'ask', acts: ['act.send', 'act.spend'] } }]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const acts = page.getByTestId('connector-acts') + await expect(acts.getByTestId('connector-acts-switch')).toHaveAttribute('aria-checked', 'true') + await expect(acts.getByTestId('connector-act-check-act.send')).toBeChecked() + await expect(acts.getByTestId('connector-act-check-act.send')).toBeEnabled() + await expect(acts.getByTestId('connector-act-lacks-act.send')).toBeVisible() + await expect(acts.getByTestId('connector-act-check-act.files')).toBeDisabled() + await acts.getByTestId('connector-act-check-act.send').uncheck() + await expect(acts.getByTestId('connector-act-check-act.send')).toBeDisabled() + await acts.getByTestId('connector-acts-switch').click() + await expect(acts.getByRole('checkbox')).toHaveCount(0) + await page.getByRole('button', { name: 'Save' }).click() + await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'ask', acts: [], max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 }) + }) + + test('Runs may act is greyed with the reason while Propose triggers is off', async ({ page }) => { + await api(page, [{ ...LEGACY, agent_id: 'a4', caps: ['memory.write'], approval: { mode: 'ask', acts: ['act.files'] } }]).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const row = page.getByTestId('connector-cap-triggers.propose') + await expect(row.getByTestId('connector-approval-off')).toHaveText('Turn on Propose triggers to use this.') + await expect(row.getByTestId('connector-acts-switch')).toBeDisabled() + await expect(row.getByTestId('connector-act-check-act.files')).toBeDisabled() + await shot(page, 'acts-4-greyed') + await row.getByTestId('connector-cap-switch-triggers.propose').click() + await expect(row.getByTestId('connector-act-check-act.files')).toBeEnabled() + await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: asks first, runs may: write files') + await shot(page, 'acts-5-summary') }) test('an older host that sends no capability list shows no permission section', async ({ page }) => { diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index bd572a3133..97ca43831b 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -704,6 +704,8 @@ the network). The default is an empty list. Any other name is refused with `400` present `approval` object replaces the whole object, so send `acts` again when you change the mode or a limit. +On the settings page of the connector add-on, **Runs may act** is a switch under the approval mode of the permission **Propose triggers** (greyed while that permission is off). It is off by default. When it is on, four checkboxes name the acts in plain words: write files in the agent's folders, use the actions of the agent's add-ons, send messages beyond where it reports, and use paid or external tools and the network. All four are marked sensitive. A box is disabled with "The agent does not have this." when the agent lacks the matching grant; a box that is already ticked stays ticked and can be unticked. The line of the permission shows the choice, for example "Proposals: set up at once, runs may: write files, use add-ons". The page always sends the mode, the limits and `acts` together. + The list is a ceiling, not a grant. The acts of a proposal are what the model asks for in `acts`, limited to this list, limited to what the agent holds: