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 && (
+
+ )}
+
{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: