From 9a98a39748dd2bfafec876265d8ed81a642d563a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 09:39:09 +0000 Subject: [PATCH] feat(connectors): copy or fill permissions from the agent The permissions form of a connection gets two buttons that fill it from the agent of the binding. "Allow the usual set" ticks the usual capabilities the agent holds and says which ones it left out because the agent does not hold them. "Same as the agent" copies, once, every capability the agent holds that has a binding capability, after a confirmation that lists the sensitive ones. Both only fill the form, and are off with a reason when the binding has no agent or its permissions cannot be read. A capability the agent does not hold cannot be switched on. The form also says when ticked capabilities cannot be used yet: the host lets a sender use one only with a pairing or a role that holds it, so with nobody paired and no role grant the ticked ones do nothing. The notice links to pairing. A link with ?binding= opens that binding at its permissions. Assisted-by: Claude:claude-sonnet-5-5 --- .../connector-ui/src/ConnectorPage.jsx | 18 ++- .../connector-ui/src/PairingFields.jsx | 4 +- .../connector-ui/src/PermissionFields.jsx | 130 ++++++++++++++++-- .../internal/connector-ui/src/connector.css | 8 ++ addons/internal/connector-ui/src/connector.js | 76 ++++++++++ addons/internal/connector-ui/src/controls.jsx | 4 +- addons/telegram/ui/test/permissions.test.mjs | 61 ++++++++ core/http/react-ui/e2e/connector-fixtures.js | 5 +- .../e2e/connector-permissions.spec.js | 111 +++++++++++++++ docs/content/features/chats-connectors.md | 12 +- 10 files changed, 408 insertions(+), 21 deletions(-) diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index eebd4c31d9..4cb7038b7b 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -4,7 +4,7 @@ // log live in core; this page edits them through the core API. An add-on // gives its own words (an i18n namespace), the fields of its token, and its // allowlists; the rest is the same for every platform. -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog' import Icon from '../../host-compat/src/components/Icon' @@ -126,6 +126,20 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo }, [kind]) useEffect(() => { agentsApi.list().then((r) => setAgents(r.agents || [])).catch(() => {}) }, []) + // A link with ?binding= (from the Tools list of an agent) opens that + // binding and brings its permissions into view, once. + const linked = useRef(false) + useEffect(() => { + if (linked.current || items.length === 0) return + const id = new URLSearchParams(window.location.search).get('binding') + const c = id && items.find((x) => x.id === id) + if (!c) return + linked.current = true + open({ ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) }) + setTimeout(() => document.querySelector('[data-testid="connector-permissions"]')?.scrollIntoView?.({ block: 'start' }), 50) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]) + const agentName = (id) => agents.find((a) => a.id === id)?.name || id const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote('') } const extra = (form) => ({ ...(messaging ? {} : { enabled: false, accept_any: false }), ...(extraBody ? extraBody(form) : {}) }) @@ -244,7 +258,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo - + a.id === form.agent_id)} /> 0 && !(form.caps || []).includes('triggers.propose')} /> {capabilities.length > 0 && } )} diff --git a/addons/internal/connector-ui/src/PairingFields.jsx b/addons/internal/connector-ui/src/PairingFields.jsx index 94eb42e5c4..b3cc1aef6e 100644 --- a/addons/internal/connector-ui/src/PairingFields.jsx +++ b/addons/internal/connector-ui/src/PairingFields.jsx @@ -60,7 +60,7 @@ export default function PairingFields({ binding, s, onPaired }) { setPaired(list) setCode(null) setDone(fresh.sender_id) - onPaired?.() + onPaired?.(list) stop() } } catch (_e) { /* the next tick tries again */ } @@ -88,7 +88,7 @@ export default function PairingFields({ binding, s, onPaired }) { const list = b?.paired || paired.filter((p) => p.sender_id !== sender) known.current = new Set(list.map((p) => p.sender_id)) setPaired(list) - onPaired?.() + onPaired?.(list) } catch (e) { setError(e?.message || '') } } const full = paired.length >= MAX_PAIRED diff --git a/addons/internal/connector-ui/src/PermissionFields.jsx b/addons/internal/connector-ui/src/PermissionFields.jsx index 574edb567b..139f96e117 100644 --- a/addons/internal/connector-ui/src/PermissionFields.jsx +++ b/addons/internal/connector-ui/src/PermissionFields.jsx @@ -5,9 +5,10 @@ // approved channels for talk-back, and the paired senders. A binding that was // saved before the host enforced permissions has none switched on, so it denies // everything gated; the notice and the preset below say so and fix it in a click. +import { useCallback, useState } from 'react' 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, channelsProblem, grantableFor, hasNoCaps, roleHas, setCap, setGrant, usualCaps } from './connector' +import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unusableCaps, usualFor, withAddedCaps, withCopiedCaps } from './connector' import PairingFields from './PairingFields' export const PERMISSION_TEXT = { @@ -19,6 +20,25 @@ export const PERMISSION_TEXT = { 'perm.usualHelp': 'Switches on the everyday permissions that stay inside LocalAI: {{names}}. Sensitive ones are never included.', 'perm.usualDone': 'The usual permissions are on. Save to keep them.', 'perm.usualSaved': 'The usual permissions are saved for {{name}}.', + 'perm.usualSet': 'Allow the usual set', + 'perm.usualSetHelp': 'Ticks the everyday permissions that stay inside LocalAI, for what the agent has. Sensitive ones are never included.', + 'perm.sameAs': 'Same as the agent', + 'perm.sameAsHelp': 'Copies the permissions the agent has now, sensitive ones too. It is a copy of today and does not follow later changes to the agent.', + 'perm.block.noAgent': 'Choose an agent first.', + 'perm.block.unreadable': 'The permissions of this agent could not be read.', + 'perm.block.nothing': 'This agent has no permission that a connection can use.', + 'perm.usualTicked': 'Ticked {{count}}: {{names}}. Save to keep them.', + 'perm.usualNone': 'Nothing new to tick: the usual permissions the agent has are already on.', + 'perm.usualSkipped': 'Not ticked, because the agent does not have them: {{names}}.', + 'perm.sameTicked': 'Copied {{count}} permissions of {{agent}}: {{names}}. This is a copy of today and does not follow later changes to the agent. Save to keep them.', + 'perm.agentLacks': 'The agent does not have this.', + 'perm.confirmTitle': 'Copy the sensitive permissions too?', + 'perm.confirmText': 'The agent has these sensitive permissions. Once they are on, a sender you paired could use them through this bot:', + 'perm.confirmYes': 'Copy them too', + 'perm.confirmNo': 'Cancel', + 'perm.stuckTitle': 'You ticked these, but no sender can use them yet', + 'perm.stuckText': 'A sender can use a permission only when you paired them with this bot, or when a role that you gave the permission to applies. Nobody is paired yet and no role has these: {{names}}.', + 'perm.stuckPair': 'Pair your account', 'perm.sensitive': 'Paired senders only', 'perm.sensitiveHelp': 'Sensitive. Never given to a role. Only a sender you paired can use it, and every use is recorded.', 'perm.capsLegend': 'Permissions of this bot', @@ -52,14 +72,18 @@ export function permissionT(t) { return (key, opts = {}) => t(key, { defaultValue: PERMISSION_TEXT[key], ...opts }) } -function Cap({ cap, on, onChange, s }) { +function Cap({ cap, on, onChange, s, lacks }) { + // A permission the agent does not have cannot be switched on; one that is + // already on can still be switched off. + const blocked = lacks && !on return ( -
  • - +
  • + {cap.title} {cap.sensitive && {s('perm.sensitive')}} {cap.description}{cap.sensitive ? ` ${s('perm.sensitiveHelp')}` : ''} + {lacks &&

    {s('perm.agentLacks')}

    }
  • ) } @@ -152,46 +176,128 @@ function Channels({ form, setForm, s }) { * It stands alone so the list of bindings can show it too. */ export function NoCapsNotice({ capabilities, onUsual, busy, s, name }) { + // Inside the form the presets below do the same job, limited to the agent. const usual = (capabilities || []).filter((c) => c.usual && !c.sensitive) return (

    {s('perm.noCapsTitle')}

    {s('perm.noCapsText')}

    {usual.length > 0 &&

    {s('perm.usualHelp', { names: usual.map((c) => c.title).join(', ') })}

    } - + {onUsual && ( + + )}
    ) } + +/** + * Two buttons that fill the permissions of the form from the agent of the + * binding: the usual set (what the agent has), and a copy of everything the + * agent has. They only change the form; the owner saves as for any change. + */ +function Presets({ form, setForm, capabilities, agent, s }) { + const [done, setDone] = useState(null) + const [pending, setPending] = useState(null) + const held = agentHeld(agent) + const block = presetBlock(form.agent_id, held, capabilities) + const title = (name) => capabilities.find((c) => c.name === name)?.title || name + const names = (list) => list.map(title).join(', ') + const reasonId = 'cn-preset-reason' + + const usual = () => { + const { caps, skipped } = usualFor(capabilities, held) + const fresh = caps.filter((n) => !(form.caps || []).includes(n)) + setPending(null) + setForm(withAddedCaps(form, caps)) + setDone({ kind: 'usual', text: [fresh.length ? s('perm.usualTicked', { count: fresh.length, names: names(fresh) }) : s('perm.usualNone'), skipped.length ? s('perm.usualSkipped', { names: names(skipped) }) : ''].filter(Boolean).join(' ') }) + } + const copy = (caps) => { + setPending(null) + setForm(withCopiedCaps(form, caps)) + setDone({ kind: 'same', text: s('perm.sameTicked', { count: caps.length, agent: agent?.name || '', names: names(caps) }) }) + } + const same = () => { + const { caps, sensitive } = copyFromAgent(capabilities, held) + if (sensitive.length > 0) { setDone(null); setPending({ caps, sensitive }); return } + copy(caps) + } + + return ( +
    +
    + + +
    + {block ?

    {s(`perm.block.${block}`)}

    + :

    {s('perm.usualSetHelp')} {s('perm.sameAsHelp')}

    } + {pending && ( + +

    {s('perm.confirmTitle')}

    +

    {s('perm.confirmText')}

    +
      + {pending.sensitive.map((n) =>
    • {title(n)}: {capabilities.find((c) => c.name === n)?.description}
    • )} +
    +
    + + +
    +
    + )} + {done &&

    {done.text}

    } +
    + ) +} + /** * @param {object} props * @param {object} props.form the form of the binding + * @param {object} props.agent the saved agent of the binding, when it is known * @param {Function} props.setForm * @param {object} props.kind the kind from the kinds call (roles, permissions) * @param {Array} props.capabilities the closed list from the kinds call * @param {Function} props.s the translation function with the defaults * @param {Function} props.onPaired called when pairing changed the binding */ -export default function PermissionFields({ form, setForm, kind, capabilities, s, onPaired }) { +export default function PermissionFields({ form, setForm, kind, capabilities, s, onPaired, agent }) { const caps = capabilities || [] if (caps.length === 0) return null const on = (name) => (form.caps || []).includes(name) - const usual = usualCaps(caps) + const held = agentHeld(agent) + // What the pairing list is now: the saved one, and what pairing here changes. + const [paired, setPaired] = useState(form.paired || []) + const pairedHere = useCallback((list) => { if (Array.isArray(list)) setPaired(list); onPaired?.() }, [onPaired]) + const stuck = unusableCaps(form, paired) + const title = (name) => caps.find((c) => c.name === name)?.title || name + const toPairing = () => { + const el = document.querySelector('[data-testid="connector-pair-start"]') || document.querySelector('[data-testid="connector-pairing"]') + el?.scrollIntoView?.({ block: 'center' }) + el?.focus?.() + } return (
    {s('perm.title')}

    {s('perm.help')}

    {hasNoCaps(form) && ( - setForm({ ...form, caps: [...new Set([...(form.caps || []), ...usual])] })} /> + )} + {stuck.length > 0 && ( + +

    {s('perm.stuckTitle')}

    +

    {s('perm.stuckText', { names: stuck.map(title).join(', ') })}

    + +
    + )} +
      - {caps.map((c) => setForm(setCap(form, c.name, v))} />)} + {caps.map((c) => setForm(setCap(form, c.name, v))} />)}
    - +
    ) } diff --git a/addons/internal/connector-ui/src/connector.css b/addons/internal/connector-ui/src/connector.css index 2a91cac242..850b6b13ea 100644 --- a/addons/internal/connector-ui/src/connector.css +++ b/addons/internal/connector-ui/src/connector.css @@ -191,3 +191,11 @@ fieldset.cn-group { .cn-access__no summary { cursor: pointer; color: var(--color-text-muted); } .cn-access__no ul { margin: var(--dk-space-1) 0 0; padding-left: var(--dk-space-5); } .cn-row:has(.cn-nocaps) { align-items: flex-start; } + +/* The presets of the permissions: fill the form from the agent of the binding. */ +.cn-presets { display: flex; flex-direction: column; gap: var(--dk-space-2); margin-block: var(--dk-space-2); } +.cn-presets__row { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); } +.cn-presets__done { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-primary); } +.cn-confirm__list { margin: 0; padding-left: var(--dk-space-5); font-size: var(--fs-small); line-height: var(--lh-small); } +.cn-cap__lacks { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-muted); } +.cn-cap[data-lacks="true"] .cn-cap__title { color: var(--color-text-muted); } diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index e13322f9ff..50a03c3934 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -70,6 +70,82 @@ export function usualCaps(capabilities) { return (capabilities || []).filter((c) => c.usual && !c.sensitive).map((c) => c.name) } +// The binding capabilities that are also grants of a saved agent. A sender can +// only use one of them when the agent holds the grant too, so the presets only +// tick what the agent holds. proposals.confirm and tools.write are not grants: +// they belong to the binding alone. +export const AGENT_GRANT_CAPS = [ + 'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write', + 'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel', +] + +// What an agent without a grant list holds for its messaging connections (the +// server rule is core/services/studio/connectorgrants.go). As soon as the list +// names any grant, only the named ones count. +export const DEFAULT_CONNECTOR_GRANTS = ['triggers.propose', 'triggers.run', 'talkback.channels', 'files.upload'] + +// The grants the agent holds for a connection, or null when its permissions +// cannot be read (no agent, an agent that is not in the list, a host that does +// not send them). +export function agentHeld(agent) { + if (!agent || !Array.isArray(agent.builtins)) return null + return agent.builtins.length > 0 ? agent.builtins : DEFAULT_CONNECTOR_GRANTS +} + +// Whether the agent lets a capability be switched on: a capability that is not +// a grant of the agent is not gated. held is agentHeld(agent); null gates nothing. +export const agentHas = (held, name) => held === null || !AGENT_GRANT_CAPS.includes(name) || held.includes(name) + +// Why a preset cannot be used: 'noAgent', 'unreadable', 'nothing' or ''. +export function presetBlock(agentId, held, capabilities) { + if (!agentId) return 'noAgent' + if (held === null) return 'unreadable' + if (!(capabilities || []).some((c) => AGENT_GRANT_CAPS.includes(c.name) && held.includes(c.name))) return 'nothing' + return '' +} + +// "Allow the usual set": the caps the host marks usual (never a sensitive one) +// that the agent holds. skipped are the usual ones the agent does not hold. +export function usualFor(capabilities, held) { + const usual = usualCaps(capabilities) + return { caps: usual.filter((n) => agentHas(held, n)), skipped: usual.filter((n) => !agentHas(held, n)) } +} + +// "Same as the agent": every cap the agent holds that is a capability of the +// host's list, sensitive ones too. sensitive names the ones that are. +export function copyFromAgent(capabilities, held) { + const list = (capabilities || []).filter((c) => AGENT_GRANT_CAPS.includes(c.name) && (held || []).includes(c.name)) + return { caps: list.map((c) => c.name), sensitive: list.filter((c) => c.sensitive).map((c) => c.name) } +} + +// The form after the caps of the agent were copied in. The caps that are not +// grants of an agent stay as they were; the others become exactly the copy, and +// a role never keeps a cap the ceiling lost. +export function withCopiedCaps(form, caps) { + const keep = (form.caps || []).filter((n) => !AGENT_GRANT_CAPS.includes(n)) + const next = [...new Set([...keep, ...caps])] + const grants = (form.grants || []).map((g) => ({ ...g, caps: g.caps.filter((x) => next.includes(x)) })).filter((g) => g.caps.length > 0) + return { ...form, caps: next, grants } +} + +// The form with these caps added to the ones it has. +export function withAddedCaps(form, caps) { + return { ...form, caps: [...new Set([...(form.caps || []), ...caps])] } +} + +// The ticked caps that no sender can use yet. The host lets a sender use a cap +// only when the sender is paired, or has a role the owner granted the cap to +// (a sensitive cap needs a pairing always; see Authorize in +// core/services/studio/connectoraccess.go). So with nobody paired, a ticked +// cap that no role holds is dead, however it is ticked. +export function unusableCaps(form, paired, now = Date.now()) { + const caps = form?.caps || [] + const active = (paired || []).some((p) => !p.expires_at || new Date(p.expires_at).getTime() > now) + if (caps.length === 0 || active) return [] + const granted = new Set((form.grants || []).flatMap((g) => g.caps || [])) + return caps.filter((c) => !granted.has(c)) +} + // The form after cap name was turned on or off. Turning one off also takes it // from every role grant, so a role never holds more than the ceiling. export function setCap(form, name, on) { diff --git a/addons/internal/connector-ui/src/controls.jsx b/addons/internal/connector-ui/src/controls.jsx index 03c798e298..3112cace04 100644 --- a/addons/internal/connector-ui/src/controls.jsx +++ b/addons/internal/connector-ui/src/controls.jsx @@ -72,11 +72,11 @@ export function Select({ children, className = '', ...rest }) { // A switch for an on/off setting. `label` names it for assistive technology; // when `children` is set, it is drawn as the visible label next to the switch. -export function Switch({ checked, onChange, label, children, testId, disabled }) { +export function Switch({ checked, onChange, label, children, testId, disabled, describedBy }) { const id = useId() const button = (