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: