diff --git a/addons/internal/connector-ui/src/AccessView.jsx b/addons/internal/connector-ui/src/AccessView.jsx new file mode 100644 index 0000000000..d620492ec7 --- /dev/null +++ b/addons/internal/connector-ui/src/AccessView.jsx @@ -0,0 +1,92 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +// A read-only answer to "what can this sender do". The host runs the same +// check it uses for each turn, for a sender on the allowlist, a paired sender +// and a sender of each role, and lists its latest decisions. It shows the +// saved binding: unsaved changes on this page do not count yet. +import { useCallback, useEffect, useState } from 'react' +import { Notice } from './controls' +import { connectorsApi } from './api' + +export const ACCESS_TEXT = { + 'access.title': 'What can a sender do', + 'access.help': 'The check the host runs for each message, shown for the saved settings. It changes nothing.', + 'access.needSave': 'Save this bot first to see what its senders may do.', + 'access.refresh': 'Refresh', + 'access.loading': 'Loading', + 'access.can': 'May: {{list}}', + 'access.cannot': 'May not: {{list}}', + 'access.nothing': 'nothing', + 'access.recentTitle': 'Latest decisions', + 'access.recentHelp': 'The latest checks of this server, newest first. They hold IDs and names of permissions, never message text. They reset when the server restarts.', + 'access.recentNone': 'No decision yet.', + 'access.allowed': 'allowed', + 'access.denied': 'denied', + 'access.who': 'sender {{senders}}', + 'access.roles': 'roles {{roles}}', + 'access.via': 'via {{via}}', +} + +function Subject({ sub, title, s }) { + const ok = sub.decisions.filter((d) => d.allowed) + const no = sub.decisions.filter((d) => !d.allowed) + return ( +
  • + {sub.title} + {s('access.can', { list: ok.length ? ok.map((d) => title(d.capability)).join(', ') : s('access.nothing') })} + {no.length > 0 && ( +
    + {s('access.cannot', { list: no.map((d) => title(d.capability)).join(', ') })} + +
    + )} +
  • + ) +} + +export default function AccessView({ binding, capabilities, s, version }) { + const [view, setView] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const id = binding.id + const load = useCallback(async () => { + if (!id) return + setLoading(true); setError('') + try { setView(await connectorsApi.access(id)) } catch (e) { setError(e?.message || '') } finally { setLoading(false) } + }, [id]) + useEffect(() => { load() }, [load, version]) + const title = (name) => (capabilities || []).find((c) => c.name === name)?.title || name + return ( +
    + {s('access.title')} +

    {s('access.help')}

    + {!id ?

    {s('access.needSave')}

    : (<> + {error && {error}} +
    + +
    + {view && (<> + +

    {s('access.recentTitle')}

    +

    {s('access.recentHelp')}

    + {(view.recent || []).length === 0 ?

    {s('access.recentNone')}

    : ( + + )} + )} + )} +
    + ) +} diff --git a/addons/internal/connector-ui/src/AnswerFields.jsx b/addons/internal/connector-ui/src/AnswerFields.jsx index 468690a28b..7c55b6640f 100644 --- a/addons/internal/connector-ui/src/AnswerFields.jsx +++ b/addons/internal/connector-ui/src/AnswerFields.jsx @@ -6,6 +6,9 @@ import { Field, Hint, Select, Switch, TextInput } from './controls' import { APPROVAL_TEXT } from './ApprovalFields' import { FILES_TEXT } from './FilesFields' +import { PERMISSION_TEXT } from './PermissionFields' +import { PAIRING_TEXT } from './PairingFields' +import { ACCESS_TEXT } from './AccessView' import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_MODES } from './connector' export const ANSWER_TEXT = { @@ -45,7 +48,7 @@ export const ANSWER_TEXT = { // text reads a string of the add-on's namespace, with the default above. export function answerT(t) { - return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key] ?? APPROVAL_TEXT[key], ...opts }) + return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key] ?? APPROVAL_TEXT[key] ?? PERMISSION_TEXT[key] ?? PAIRING_TEXT[key] ?? ACCESS_TEXT[key], ...opts }) } // answerSummary says in a few words how a binding answers, for its row. diff --git a/addons/internal/connector-ui/src/ApprovalFields.jsx b/addons/internal/connector-ui/src/ApprovalFields.jsx index ee7cf72bd9..45c350fe88 100644 --- a/addons/internal/connector-ui/src/ApprovalFields.jsx +++ b/addons/internal/connector-ui/src/ApprovalFields.jsx @@ -24,7 +24,7 @@ export const APPROVAL_TEXT = { 'approval.short.auto_readonly': 'at once if read-only', } -export default function ApprovalFields({ form, setForm, s }) { +export default function ApprovalFields({ form, setForm, s, triggersOff = false }) { const a = form.approval const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } }) const auto = a.mode !== 'ask' @@ -33,6 +33,7 @@ export default function ApprovalFields({ form, setForm, s }) {
    {s('approval.title')}

    {s('approval.help')}

    + {triggersOff &&

    {s('perm.triggersNeed')}

    } set({ mode })} options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} /> {auto && (<> diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index dca9e7da83..4ab83474cf 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -9,12 +9,14 @@ import { useTranslation } from 'react-i18next' import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog' import Icon from '../../host-compat/src/components/Icon' import AnswerFields, { answerSummary, answerT } from './AnswerFields' +import AccessView from './AccessView' import ApprovalFields from './ApprovalFields' import FilesFields from './FilesFields' import { Check, Field, Notice, RadioCards, Select, Switch, TextInput } from './controls' import NameList from './NameList' +import PermissionFields, { NoCapsNotice } from './PermissionFields' import { agentsApi, connectorsApi } from './api' -import { canSave, connectorForm, connectorPayload, emptyConnector, listEntries, replaceList, senderLine } from './connector' +import { canSave, connectorForm, hasNoCaps, connectorPayload, emptyConnector, listEntries, replaceList, senderLine, usualCaps } from './connector' import './connector.css' function Senders({ id, onAllowed, t }) { @@ -93,7 +95,11 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo const [toDelete, setToDelete] = useState(null) const [openSenders, setOpenSenders] = useState('') const [kindInfo, setKindInfo] = useState(null) + const [capabilities, setCapabilities] = useState([]) + const [accessVersion, setAccessVersion] = useState(0) const kindFiles = kindInfo?.files || null + const s = answerT(t) + const pairedChanged = useCallback(() => setAccessVersion((v) => v + 1), []) const fail = useCallback((e) => { if (e?.status === 409 && e.body?.error?.type === 'connectors_off') { setOff(e.body.error.message); return } @@ -110,7 +116,12 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo useEffect(() => { load() }, [load]) // What the add-on of this kind offers: the choices of its options. - useEffect(() => { connectorsApi.kinds().then((r) => setKindInfo((r.kinds || []).find((k) => k.kind === kind) || null)).catch(() => {}) }, [kind]) + useEffect(() => { + connectorsApi.kinds().then((r) => { + setKindInfo((r.kinds || []).find((k) => k.kind === kind) || null) + setCapabilities(r.capabilities || []) + }).catch(() => {}) + }, [kind]) useEffect(() => { agentsApi.list().then((r) => setAgents(r.agents || [])).catch(() => {}) }, []) const agentName = (id) => agents.find((a) => a.id === id)?.name || id @@ -144,6 +155,21 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo } catch (err) { fail(err) } finally { setBusy(false) } } + // The preset for a saved binding that has no permission on: the usual set, + // saved at once, with every other setting as it was. + const allowUsual = async (c) => { + setBusy(true) + setError('') + setNote('') + try { + const f = { ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) } + f.caps = usualCaps(capabilities) + await connectorsApi.update(c.id, connectorPayload(f, kind, '', extra(f))) + setNote(s('perm.usualSaved', { name: c.name })) + await load() + } catch (e) { fail(e) } finally { setBusy(false) } + } + const act = async (fn) => { setError('') try { await fn(); await load() } catch (e) { fail(e) } @@ -216,7 +242,9 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo - + + 0 && !(form.caps || []).includes('triggers.propose')} /> + {capabilities.length > 0 && } )}
    @@ -250,6 +278,9 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {answerSummary(c, t)} ) : {t('agentOf', { name: agentName(c.agent_id) })}} {RowInfo && } + {messaging && capabilities.length > 0 && hasNoCaps(c) && ( + allowUsual(c)} /> + )} {c.active === false && {t('inactive', { reason: c.reason })}} {openSenders === c.id && }
    diff --git a/addons/internal/connector-ui/src/PairingFields.jsx b/addons/internal/connector-ui/src/PairingFields.jsx new file mode 100644 index 0000000000..94eb42e5c4 --- /dev/null +++ b/addons/internal/connector-ui/src/PairingFields.jsx @@ -0,0 +1,130 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +// Pairing proves that the owner controls a platform account. The owner starts +// it here, gets a code, and sends the code as a direct message to the bot from +// an allowed account. The code is shown once and never saved by this page. A +// paired sender may use the sensitive permissions the owner switched on. +import { useEffect, useRef, useState } from 'react' +import { Notice } from './controls' +import { connectorsApi } from './api' + +export const PAIRING_TEXT = { + 'pair.title': 'Paired senders', + 'pair.help': 'A paired sender proved that they control an allowed account. Only a paired sender can use the sensitive permissions. At most {{max}}; a pairing ends after {{days}} days.', + 'pair.start': 'Pair a sender', + 'pair.startHelp': 'Makes a code. Send it to the bot as a direct message from the account to pair, within ten minutes. The code works once.', + 'pair.needSave': 'Save this bot first, then pair a sender.', + 'pair.codeTitle': 'Your pairing code', + 'pair.codeHelp': 'Send this code to the bot as a direct message from the account to pair. It is shown only now, works once and expires at {{time}}.', + 'pair.codeGone': 'The code expired. Make a new one if the sender is not listed yet.', + 'pair.done': 'Paired: {{sender}}.', + 'pair.dismiss': 'Hide the code', + 'pair.none': 'Nobody is paired.', + 'pair.expires': 'ends {{time}}', + 'pair.remove': 'Remove', + 'pair.removeSender': 'Remove the pairing of {{sender}}', + 'pair.full': 'Three senders are paired. Remove one to pair another.', +} + +const MAX_PAIRED = 3 +const DAYS = 90 +const POLL_MS = 3000 + +function time(iso) { + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? '' : d.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) +} + +export default function PairingFields({ binding, s, onPaired }) { + const [paired, setPaired] = useState(binding.paired || []) + const [code, setCode] = useState(null) // { code, expires } + const [expired, setExpired] = useState(false) + const [done, setDone] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const known = useRef(new Set((binding.paired || []).map((p) => p.sender_id))) + const id = binding.id + + // While a code is live, look for the sender that used it. + useEffect(() => { + if (!code || !id) return undefined + const stop = () => { clearInterval(timer) } + const timer = setInterval(async () => { + if (Date.now() > new Date(code.expires).getTime()) { setExpired(true); setCode(null); stop(); return } + try { + const r = await connectorsApi.list() + const mine = (r.connectors || []).find((c) => c.id === id) + const list = mine?.paired || [] + const fresh = list.find((p) => !known.current.has(p.sender_id)) + if (fresh) { + known.current = new Set(list.map((p) => p.sender_id)) + setPaired(list) + setCode(null) + setDone(fresh.sender_id) + onPaired?.() + stop() + } + } catch (_e) { /* the next tick tries again */ } + }, POLL_MS) + return stop + }, [code, id, onPaired]) + + if (!id) { + return ( +
    +

    {s('pair.title')}

    +

    {s('pair.needSave')}

    +
    + ) + } + + const start = async () => { + setBusy(true); setError(''); setDone(''); setExpired(false) + try { setCode(await connectorsApi.pair(id)) } catch (e) { setError(e?.message || '') } finally { setBusy(false) } + } + const remove = async (sender) => { + setError('') + try { + const b = await connectorsApi.unpair(id, sender) + const list = b?.paired || paired.filter((p) => p.sender_id !== sender) + known.current = new Set(list.map((p) => p.sender_id)) + setPaired(list) + onPaired?.() + } catch (e) { setError(e?.message || '') } + } + const full = paired.length >= MAX_PAIRED + + return ( +
    +

    {s('pair.title')}

    +

    {s('pair.help', { max: MAX_PAIRED, days: DAYS })}

    + {error && {error}} + {paired.length === 0 ?

    {s('pair.none')}

    : ( +
      + {paired.map((p) => ( +
    • + {p.sender_id} + {s('pair.expires', { time: time(p.expires_at) })} + +
    • + ))} +
    + )} + {done && {s('pair.done', { sender: done })}} + {expired && {s('pair.codeGone')}} + {code && ( + +

    {s('pair.codeTitle')}

    +

    {code.code}

    +

    {s('pair.codeHelp', { time: time(code.expires) })}

    + +
    + )} +
    +
    + +
    +

    {full ? s('pair.full') : s('pair.startHelp')}

    +
    +
    + ) +} diff --git a/addons/internal/connector-ui/src/PermissionFields.jsx b/addons/internal/connector-ui/src/PermissionFields.jsx new file mode 100644 index 0000000000..574edb567b --- /dev/null +++ b/addons/internal/connector-ui/src/PermissionFields.jsx @@ -0,0 +1,197 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +// What the senders of a binding may do. The host owns the list of capabilities +// and enforces it on every turn; this section edits the owner's side of it: +// the capabilities the binding allows, what each role of the kind gets, the +// 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 { 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 PairingFields from './PairingFields' + +export const PERMISSION_TEXT = { + 'perm.title': 'What senders may do', + 'perm.help': 'Choose what the people who write to this bot may ask the agent to do. Anything switched off stays off for everyone, whatever a role or a pairing says.', + 'perm.noCapsTitle': 'No permission is on, so these features are off', + 'perm.noCapsText': 'This bot was saved before permissions existed, or no permission was chosen yet. Until you turn some on, senders cannot send files to the agent, set up triggers, save to memory or have the agent post to a channel. Messages are still answered.', + 'perm.usual': 'Allow the usual', + '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.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', + 'perm.capsHelp': 'The ceiling for everyone. A role can be given less, never more.', + 'perm.rolesTitle': 'Roles', + 'perm.rolesHelp': 'The platform tells the host which roles a sender has. Choose what each role may do. Sensitive permissions are never given to a role, and a role can only get a permission that is switched on above.', + 'perm.rolesNone': 'Switch on a permission above that this add-on can give to a role, and it shows here.', + 'perm.rolesGrid': 'What each role may do', + 'perm.roleCap': '{{role}}: {{cap}}', + 'perm.channelsTitle': 'Approved channels', + 'perm.channelsHelp': 'Chats where the agent may post when a sender asks, besides the chat it answers in. A channel that is not approved needs a paired sender for each message. At most {{max}}.', + 'perm.channelsOff': 'Switch on "Post to approved channels" above to choose channels.', + 'perm.channelId': 'Channel ID', + 'perm.channelIdHelp': 'The ID the platform gives the chat. No spaces.', + 'perm.channelLabel': 'Name', + 'perm.channelLabelHelp': 'A name for you and the agent. Optional.', + 'perm.channelApproved': 'Approved', + 'perm.channelApprovedHelp': 'On: the agent may post here on any allowed sender\'s request.', + 'perm.channelAdd': 'Add a channel', + 'perm.channelRemove': 'Remove channel {{id}}', + 'perm.remove': 'Remove', + 'perm.channelFull': 'The list is full.', + 'perm.channelProblem.id': 'Each channel needs an ID of 1 to {{max}} bytes, with no space.', + 'perm.channelProblem.label': 'A name has at most {{max}} characters.', + 'perm.channelProblem.repeat': 'A channel is listed twice.', + 'perm.channelProblem.count': 'At most {{max}} channels.', + 'perm.triggersNeed': 'These settings apply only while "Propose triggers" is on above.', +} + +export function permissionT(t) { + return (key, opts = {}) => t(key, { defaultValue: PERMISSION_TEXT[key], ...opts }) +} + +function Cap({ cap, on, onChange, s }) { + return ( +
  • + + {cap.title} + {cap.sensitive && {s('perm.sensitive')}} + + {cap.description}{cap.sensitive ? ` ${s('perm.sensitiveHelp')}` : ''} +
  • + ) +} + +function Roles({ form, setForm, kind, capabilities, s }) { + const roles = kind?.roles || [] + if (roles.length === 0) return null + const cols = grantableFor(kind, form.caps) + const title = (name) => capabilities.find((c) => c.name === name)?.title || name + return ( +
    +

    {s('perm.rolesTitle')}

    +

    {s('perm.rolesHelp')}

    + {cols.length === 0 ?

    {s('perm.rolesNone')}

    : ( +
    + + + + + {cols.map((c) => )} + + + + {roles.map((r) => ( + + + {cols.map((c) => ( + + ))} + + ))} + +
    {s('perm.rolesTitle')}{title(c)}
    + {r.title} + {r.description && {r.description}} + + setForm(setGrant(form, r.id, c, e.target.checked, capabilities))} /> +
    +
    + )} +
    + ) +} + +function Channels({ form, setForm, s }) { + const list = form.channels || [] + const problem = channelsProblem(list) + const set = (i, patch) => setForm({ ...form, channels: list.map((c, j) => (j === i ? { ...c, ...patch } : c)) }) + return ( +
    +

    {s('perm.channelsTitle')}

    +

    {s('perm.channelsHelp', { max: MAX_CHANNELS })}

    + {!(form.caps || []).includes('talkback.channels') ?

    {s('perm.channelsOff')}

    : (<> +
      + {list.map((c, i) => ( +
    • + + {(p) => set(i, { id: e.target.value })} />} + + + {(p) => set(i, { label: e.target.value })} />} + +
      + set(i, { approved: on })}>{s('perm.channelApproved')} + {s('perm.channelApprovedHelp')} +
      + +
    • + ))} +
    + {problem &&

    {s(`perm.channelProblem.${problem}`, { max: problem === 'id' ? MAX_CHANNEL_ID : problem === 'label' ? MAX_CHANNEL_LABEL : MAX_CHANNELS })}

    } +
    + +
    + )} +
    + ) +} + +/** + * The notice of a binding that has no permission on, with the one-click preset. + * It stands alone so the list of bindings can show it too. + */ +export function NoCapsNotice({ capabilities, onUsual, busy, s, name }) { + 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(', ') })}

    } + +
    + ) +} + +/** + * @param {object} props + * @param {object} props.form the form of the binding + * @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 }) { + const caps = capabilities || [] + if (caps.length === 0) return null + const on = (name) => (form.caps || []).includes(name) + const usual = usualCaps(caps) + return ( +
    + {s('perm.title')} +

    {s('perm.help')}

    + {hasNoCaps(form) && ( + setForm({ ...form, caps: [...new Set([...(form.caps || []), ...usual])] })} /> + )} +
      + {caps.map((c) => setForm(setCap(form, c.name, v))} />)} +
    + + + +
    + ) +} diff --git a/addons/internal/connector-ui/src/api.js b/addons/internal/connector-ui/src/api.js index 4213e0b459..d4b91df7d7 100644 --- a/addons/internal/connector-ui/src/api.js +++ b/addons/internal/connector-ui/src/api.js @@ -41,5 +41,7 @@ export const connectorsApi = { // Pairing proves that the owner controls a platform account: the code is sent // to the bot as a direct message from that account. pair: (id) => send('POST', `/api/chats/connectors/${enc(id)}/pairing`, {}), + // What each kind of sender may do on a binding now, and the latest decisions. + access: (id) => call(`/api/chats/connectors/${enc(id)}/access`), unpair: (id, sender) => send('DELETE', `/api/chats/connectors/${enc(id)}/paired/${enc(sender)}`), } diff --git a/addons/internal/connector-ui/src/connector.css b/addons/internal/connector-ui/src/connector.css index 26e16bf527..2a91cac242 100644 --- a/addons/internal/connector-ui/src/connector.css +++ b/addons/internal/connector-ui/src/connector.css @@ -149,3 +149,45 @@ fieldset.cn-group { } .cn-answer__window .dk-input { max-width: 10rem; } + +/* Permissions: one row per capability, a role matrix, channels, pairing and + the read-only view of the access check. */ +.cn-caps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.cn-cap { display: flex; flex-direction: column; gap: var(--dk-space-1); padding: var(--dk-space-2) 0; border-bottom: 1px solid var(--color-border-subtle); } +.cn-cap:last-child { border-bottom: 0; } +.cn-cap__title { font-weight: var(--font-weight-medium, 500); } +.cn-cap .cn-switchrow > span:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-2); } + +.cn-badge { display: inline-block; padding: 0 var(--dk-space-2); border-radius: var(--r-sm); font-size: var(--fs-meta); background: var(--color-bg-secondary); color: var(--color-text-secondary); } +.cn-badge--warn { color: var(--color-warning, var(--color-text-secondary)); border: 1px solid currentColor; } + +.cn-nocaps { align-items: flex-start; } +.cn-nocaps .dk-btn { margin-top: var(--dk-space-1); } +.ui-callout__text .cn-notice__title { margin: 0 0 var(--dk-space-1); font-weight: var(--font-weight-semibold); } + +.cn-sub { display: flex; flex-direction: column; gap: var(--dk-space-2); padding-top: var(--dk-space-3); border-top: 1px solid var(--color-border-subtle); } +.cn-sub__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-matrix__wrap { overflow-x: auto; } +.cn-matrix { border-collapse: collapse; font-size: var(--fs-small); width: 100%; } +.cn-matrix th, .cn-matrix td { padding: var(--dk-space-2); text-align: center; border-bottom: 1px solid var(--color-border-subtle); vertical-align: middle; } +.cn-matrix thead th { color: var(--color-text-muted); font-weight: var(--font-weight-medium, 500); } +.cn-matrix tbody th { text-align: left; font-weight: var(--font-weight-semibold); } +.cn-matrix__desc { display: block; font-weight: normal; } +.cn-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + +.cn-channels { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-3); } +.cn-channel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--dk-space-3); align-items: start; padding-bottom: var(--dk-space-3); border-bottom: 1px solid var(--color-border-subtle); } +.cn-channel .cn-switchfield { grid-column: 1 / -1; } +.cn-channel > .dk-btn { justify-self: start; } +@media (max-width: 700px) { .cn-channel { grid-template-columns: minmax(0, 1fr); } } + +.cn-mono { font-family: var(--font-mono, monospace); font-size: var(--fs-meta); } +.cn-pairing__code { margin: var(--dk-space-1) 0; font-family: var(--font-mono, monospace); font-size: var(--fs-title); letter-spacing: 0.08em; user-select: all; } + +.cn-access { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-3); font-size: var(--fs-small); } +.cn-access__subject { display: flex; flex-direction: column; gap: var(--dk-space-1); } +.cn-access__who { font-weight: var(--font-weight-semibold); } +.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; } diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index df3a8530f7..623c11898b 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -49,6 +49,74 @@ export function approvalPayload(a) { return body } +// What a binding lets its senders do. The host owns the closed list of +// capabilities (the kinds call returns it with a title and a description of +// each); a sensitive one is never granted to a role, and the host keeps the +// same limits. +export const MAX_CHANNELS = 10 +export const MAX_CHANNEL_ID = 64 +export const MAX_CHANNEL_LABEL = 60 + +// A binding with no caps denies every gated feature: files from senders, +// triggers, memory writes and talk-back. A binding saved before the host +// enforced permissions has none. +export function hasNoCaps(c) { + return !(c?.caps || []).length +} + +// The caps that the one-click preset turns on: the ones the host marks usual, +// never a sensitive one, whatever the list says. +export function usualCaps(capabilities) { + return (capabilities || []).filter((c) => c.usual && !c.sensitive).map((c) => c.name) +} + +// 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) { + const caps = (form.caps || []).filter((x) => x !== name) + if (on) caps.push(name) + const grants = (form.grants || []) + .map((g) => ({ ...g, caps: g.caps.filter((x) => caps.includes(x)) })) + .filter((g) => g.caps.length > 0) + return { ...form, caps, grants } +} + +export function roleHas(form, role, cap) { + return (form.grants || []).some((g) => g.role === role && g.caps.includes(cap)) +} + +// The caps that a kind can give to a role: listed by the kind, not sensitive, +// and inside the ceiling the owner enabled. +export function grantableFor(kind, caps) { + return (kind?.permissions || []).filter((p) => !p.sensitive && (caps || []).includes(p.name)).map((p) => p.name) +} + +// The form after role was given or refused cap. A sensitive cap, or one that +// is not in the ceiling, is refused: the form is returned as it was. The host's +// list says which caps are sensitive. +export function setGrant(form, role, cap, on, capabilities = []) { + const sensitive = capabilities.some((c) => c.name === cap && c.sensitive) + if (on && (sensitive || !(form.caps || []).includes(cap))) return form + const rest = (form.grants || []).filter((g) => g.role !== role) + const mine = (form.grants || []).find((g) => g.role === role)?.caps || [] + const caps = on ? [...mine.filter((x) => x !== cap), cap] : mine.filter((x) => x !== cap) + return { ...form, grants: caps.length ? [...rest, { role, caps }] : rest } +} + +// Why the list of channels cannot be saved: 'count', 'id', 'label' or +// 'repeat'; else ''. The server has the same limits. +export function channelsProblem(list) { + const channels = list || [] + if (channels.length > MAX_CHANNELS) return 'count' + for (let i = 0; i < channels.length; i++) { + const { id = '', label = '' } = channels[i] + if (!id || new TextEncoder().encode(id).length > MAX_CHANNEL_ID || /\s/u.test(id)) return 'id' + if ([...label].length > MAX_CHANNEL_LABEL) return 'label' + if (channels.slice(0, i).some((o) => o.id === id)) return 'repeat' + } + return '' +} + export function emptyConnector() { return { id: '', name: '', token: '', agent_id: '', enabled: true, accept_any: false, allow: [], notify_chat: '', @@ -56,6 +124,7 @@ export function emptyConnector() { options: {}, file_receive_mib: '', file_send_mib: '', approval: emptyApproval(), + caps: [], grants: [], channels: [], } } @@ -78,6 +147,8 @@ export function connectorForm(c) { ...emptyConnector(), ...c, token: '', file_receive_mib: bytesToMib(c.file_receive_max_bytes), file_send_mib: bytesToMib(c.file_send_max_bytes), approval: approvalForm(c.approval), + caps: [...(c.caps || [])], grants: (c.grants || []).map((g) => ({ role: g.role, caps: [...g.caps] })), + channels: (c.channels || []).map((x) => ({ id: x.id, label: x.label || '', approved: Boolean(x.approved) })), } } @@ -132,6 +203,9 @@ export function connectorPayload(form, kind, token = form.token, extra = {}) { file_receive_max_bytes: mibToBytes(form.file_receive_mib), file_send_max_bytes: mibToBytes(form.file_send_mib), approval: approvalPayload(form.approval), + caps: [...(form.caps || [])], + grants: (form.grants || []).map((g) => ({ role: g.role, caps: [...g.caps] })), + channels: (form.channels || []).map((x) => ({ id: x.id.trim(), label: (x.label || '').trim(), approved: Boolean(x.approved) })), } if (body.conversation === 'window') body.window_minutes = Number(form.window_minutes) || 0 const secret = (token || '').trim() @@ -160,6 +234,7 @@ export function canSave(form, confirmed, hasToken = Boolean((form.token || '').t if (!form.id && !hasToken) return false if (form.accept_any && !confirmed) return false if (approvalProblem(form.approval)) return false + if (channelsProblem(form.channels)) return false if (fileLimitProblem(form.file_receive_mib, files?.receive?.allowed_bytes) || fileLimitProblem(form.file_send_mib, files?.send?.allowed_bytes)) return false if (form.conversation === 'window') { const m = Number(form.window_minutes) diff --git a/addons/telegram/ui/test/permissions.test.mjs b/addons/telegram/ui/test/permissions.test.mjs new file mode 100644 index 0000000000..56510e2f63 --- /dev/null +++ b/addons/telegram/ui/test/permissions.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + MAX_CHANNELS, canSave, channelsProblem, connectorForm, connectorPayload, emptyConnector, grantableFor, hasNoCaps, roleHas, setCap, setGrant, usualCaps, +} from '../../../internal/connector-ui/src/connector.js' + +const CAPS = [ + { name: 'memory.write', title: 'Save to memory', description: 'x', sensitive: false, usual: true }, + { name: 'talkback.channels', title: 'Post', description: 'x', sensitive: false, usual: true }, + { name: 'tools.write', title: 'Tools', description: 'x', sensitive: false, usual: false }, + { name: 'profile.write', title: 'Profile', description: 'x', sensitive: true, usual: false }, +] +const KIND = { roles: [{ id: 'admin', title: 'Admin' }], permissions: [{ name: 'memory.write', sensitive: false }, { name: 'profile.write', sensitive: true }, { name: 'talkback.channels', sensitive: false }] } + +test('permissions: a new binding has no caps, grants or channels', () => { + const c = emptyConnector() + assert.deepEqual([c.caps, c.grants, c.channels], [[], [], []]) + assert.equal(hasNoCaps(c), true) + assert.equal(hasNoCaps({ caps: ['memory.write'] }), false) + assert.equal(hasNoCaps({}), true, 'a binding from an older host has no caps field') +}) + +test('permissions: the usual preset is the non-sensitive caps the host marks, and never a sensitive one', () => { + assert.deepEqual(usualCaps(CAPS), ['memory.write', 'talkback.channels']) + assert.deepEqual(usualCaps([{ name: 'profile.write', sensitive: true, usual: true }]), [], 'a sensitive cap is never usual, whatever the list says') + assert.deepEqual(usualCaps(undefined), []) +}) + +test('permissions: turning a cap off removes it from every role grant and drops empty grants', () => { + let f = { caps: ['memory.write', 'talkback.channels'], grants: [{ role: 'admin', caps: ['memory.write', 'talkback.channels'] }, { role: 'x', caps: ['memory.write'] }] } + f = setCap(f, 'memory.write', false) + assert.deepEqual(f.caps, ['talkback.channels']) + assert.deepEqual(f.grants, [{ role: 'admin', caps: ['talkback.channels'] }]) + f = setCap(f, 'memory.write', true) + assert.deepEqual(f.caps, ['talkback.channels', 'memory.write']) + assert.equal(setCap(f, 'memory.write', true).caps.length, 2, 'no repeats') +}) + +test('permissions: a role is granted only a non-sensitive cap that is in the ceiling', () => { + const f = { caps: ['memory.write', 'profile.write'], grants: [] } + const g = setGrant(f, 'admin', 'memory.write', true, CAPS) + assert.equal(roleHas(g, 'admin', 'memory.write'), true) + assert.equal(setGrant(f, 'admin', 'profile.write', true, CAPS), f, 'sensitive: refused') + assert.equal(setGrant(f, 'admin', 'talkback.channels', true, CAPS), f, 'not in the ceiling: refused') + const off = setGrant(g, 'admin', 'memory.write', false, CAPS) + assert.deepEqual(off.grants, [], 'the last cap of a role removes its grant') +}) + +test('permissions: the matrix lists what the kind can grant, inside the ceiling, never a sensitive cap', () => { + assert.deepEqual(grantableFor(KIND, ['memory.write', 'profile.write']), ['memory.write']) + assert.deepEqual(grantableFor(KIND, []), []) + assert.deepEqual(grantableFor({}, ['memory.write']), []) +}) + +test('permissions: channels need a unique id with no space, at most ten', () => { + assert.equal(channelsProblem([{ id: 'C1', label: 'ops', approved: true }]), '') + assert.equal(channelsProblem([{ id: '', label: '' }]), 'id') + assert.equal(channelsProblem([{ id: 'a b' }]), 'id') + assert.equal(channelsProblem([{ id: 'C1' }, { id: 'C1' }]), 'repeat') + assert.equal(channelsProblem([{ id: 'C'.repeat(65) }]), 'id') + assert.equal(channelsProblem([{ id: 'C1', label: 'x'.repeat(61) }]), 'label') + assert.equal(channelsProblem(Array.from({ length: MAX_CHANNELS + 1 }, (_, i) => ({ id: `C${i}` }))), 'count') +}) + +test('permissions: the request carries caps, grants and channels, and a legacy binding reads back unchanged', () => { + const saved = { id: 'b1', name: 'B', agent_id: 'a', caps: ['memory.write'], grants: [{ role: 'admin', caps: ['memory.write'] }], channels: [{ id: 'C1', label: 'ops', approved: true }] } + const body = connectorPayload(connectorForm(saved), 'slack') + assert.deepEqual(body.caps, ['memory.write']) + assert.deepEqual(body.grants, [{ role: 'admin', caps: ['memory.write'] }]) + assert.deepEqual(body.channels, [{ id: 'C1', label: 'ops', approved: true }]) + const legacy = connectorPayload(connectorForm({ id: 'b2', name: 'B', agent_id: 'a' }), 'slack') + assert.deepEqual([legacy.caps, legacy.grants, legacy.channels], [[], [], []]) +}) + +test('permissions: a bad channel list blocks saving', () => { + const ok = { ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' } + assert.equal(canSave(ok, false), true) + assert.equal(canSave({ ...ok, channels: [{ id: '' }] }, false), false) +}) diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 20d978fc06..b7148d2592 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -193,6 +193,9 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error { out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options), Files: filesOut(host.KindFileLimits(c.Request().Context(), k)), Roles: rolesOut(k.ConnectorKind), Permissions: permissionsOut(k.ConnectorKind)}) } + for _, ci := range contract.ConnectorCapabilities() { + out.Capabilities = append(out.Capabilities, schema.ChatsConnectorCapability(ci)) + } return c.JSON(http.StatusOK, out) } diff --git a/core/http/react-ui/e2e/connector-fixtures.js b/core/http/react-ui/e2e/connector-fixtures.js index b3e9b53210..0c10119078 100644 --- a/core/http/react-ui/e2e/connector-fixtures.js +++ b/core/http/react-ui/e2e/connector-fixtures.js @@ -25,7 +25,25 @@ export const DECLARED = { inline_bytes: MIB, } -export function connectorApi(page, initial = [], kind = 'telegram', senders = [NEWCOMER], files = INLINE_ONLY) { +// The closed list of capabilities that the kinds call returns, as the host writes it. +export const CAPABILITIES = [ + { name: 'files.upload', title: 'Receive files', description: 'The agent may read files that senders attach to a message.', sensitive: false, usual: true }, + { name: 'memory.write', title: 'Save to memory', description: 'The agent may remember what a sender asks it to remember.', sensitive: false, usual: true }, + { name: 'proposals.confirm', title: 'Confirm changes from the chat', description: 'A sender can confirm a change that the agent proposed, by answering in the same chat.', sensitive: false, usual: true }, + { name: 'talkback.channels', title: 'Post to approved channels', description: 'The agent may write to the approved channels when a sender asks.', sensitive: false, usual: true }, + { name: 'talkback.new_channel', title: 'Post to a new channel', description: 'The agent may write to a channel that is not approved yet.', sensitive: true, usual: false }, + { name: 'tools.write', title: 'Change things outside LocalAI', description: 'An add-on tool may change something with the credential of the binding.', sensitive: false, usual: false }, + { name: 'triggers.propose', title: 'Propose triggers', description: 'The agent may propose a timer or an event trigger when a sender asks for one.', sensitive: false, usual: true }, + { name: 'triggers.run', title: 'Fire event triggers', description: 'A message from a sender may start a run of an event trigger.', sensitive: false, usual: true }, +] + +// A kind that declares two roles and what it can give to them. +export const ROLES_KIND = { + roles: [{ id: 'workspace_admin', title: 'Workspace admin', description: 'Owners and admins of the workspace.' }, { id: 'member', title: 'Channel member' }], + permissions: [{ name: 'memory.write', sensitive: false }, { name: 'files.upload', sensitive: false }, { name: 'talkback.channels', sensitive: false }, { name: 'talkback.new_channel', sensitive: true }], +} + +export function connectorApi(page, initial = [], kind = 'telegram', senders = [NEWCOMER], files = INLINE_ONLY, { capabilities = CAPABILITIES, kindExtra = {}, access = null } = {}) { const s = { items: structuredClone(initial), calls: [], senders: structuredClone(senders) } const rec = (method, url, body) => s.calls.push({ method, url: new URL(url).pathname, body }) return { @@ -37,6 +55,17 @@ export function connectorApi(page, initial = [], kind = 'telegram', senders = [N s.senders = s.senders.map(x => ({ ...x, allowed: true })) return r.fulfill({ json: s.items[0] }) }) + await page.route(/\/api\/chats\/connectors\/[^/?]+\/access$/, r => r.fulfill({ json: access || { binding: 'b1', subjects: [], recent: [] } })) + await page.route(/\/api\/chats\/connectors\/[^/?]+\/pairing$/, r => { + rec('POST', r.request().url(), null) + return r.fulfill({ json: { code: 'LA-K7M2QX9P', expires: new Date(Date.now() + 10 * 60 * 1000).toISOString() } }) + }) + await page.route(/\/api\/chats\/connectors\/[^/?]+\/paired\/[^/?]+$/, r => { + rec('DELETE', r.request().url(), null) + const sender = decodeURIComponent(new URL(r.request().url()).pathname.split('/').pop()) + s.items = s.items.map(x => ({ ...x, paired: (x.paired || []).filter(p => p.sender_id !== sender) })) + return r.fulfill({ json: s.items[0] }) + }) await page.route(/\/api\/chats\/connectors\/[^/?]+\/senders$/, r => r.fulfill({ json: { senders: s.senders } })) await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ json: { bot_id: 1, bot_username: 'support_bot' } })) await page.route(/\/api\/chats\/connectors\/[^/?]+\/(enable|disable)$/, r => { @@ -65,7 +94,7 @@ export function connectorApi(page, initial = [], kind = 'telegram', senders = [N await page.route(/\/api\/chats(\?.*)?$/, r => r.fulfill({ json: { conversations: [], persistent: true, files: true } })) await page.route('**/api/chats/notifications*', r => r.fulfill({ json: { notifications: [], unread: 0 } })) // Registered last, so it answers before the route of one binding. - await page.route(/\/api\/chats\/connectors\/kinds$/, r => r.fulfill({ json: { kinds: [{ addon: kind, kind, title: kind, files }] } })) + await page.route(/\/api\/chats\/connectors\/kinds$/, r => r.fulfill({ json: { kinds: [{ addon: kind, kind, title: kind, files, ...kindExtra }], capabilities } })) }, } } diff --git a/core/http/react-ui/e2e/connector-permissions.spec.js b/core/http/react-ui/e2e/connector-permissions.spec.js new file mode 100644 index 0000000000..5687f2119b --- /dev/null +++ b/core/http/react-ui/e2e/connector-permissions.spec.js @@ -0,0 +1,203 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' +import { installStudioAddons } from './studio-addon.js' +import { connectorApi, CAPABILITIES, ROLES_KIND } from './connector-fixtures.js' + +// The permissions of a binding on the shared connector page (drawn here by the +// Slack add-on, whose kind declares roles): the notice of a binding that has no +// permission on, the one-click preset, the switches, the role matrix, the +// approved channels, pairing and the read-only access view. + +const PAGE = '/app/addons/slack/apps' +const SHOTS = process.env.UI_SHOTS_DIR +const LEGACY = { id: 'b1', name: 'Team app', kind: 'slack', agent_id: 'a1', enabled: true, accept_any: false, allow: ['U001'], has_token: true, caps: [], grants: [], channels: [], paired: [] } +const SET = { ...LEGACY, caps: ['memory.write', 'talkback.channels'], grants: [{ role: 'workspace_admin', caps: ['memory.write'] }], channels: [{ id: 'C0123', label: 'ops', approved: true }], paired: [{ sender_id: 'U001', paired_at: '2026-11-01T10:00:00Z', expires_at: '2027-01-30T10:00:00Z' }] } +const ACCESS = { + binding: 'b1', + subjects: [ + { id: 'anyone', title: 'A sender on the allowlist', decisions: [{ capability: 'memory.write', allowed: true }, { capability: 'files.upload', allowed: false, reason: 'not enabled for this bot' }] }, + { id: 'paired', title: 'A paired sender', decisions: [{ capability: 'memory.write', allowed: true, via: 'paired' }, { capability: 'talkback.new_channel', allowed: false, reason: 'not enabled for this bot' }] }, + { id: 'role:workspace_admin', title: 'Workspace admin', decisions: [{ capability: 'memory.write', allowed: true, via: 'role:workspace_admin' }] }, + ], + recent: [ + { at: '2026-11-03T09:30:00Z', senders: ['U001'], capability: 'memory.write', allowed: true, via: 'paired' }, + { at: '2026-11-03T09:10:00Z', senders: ['U002'], roles: ['member'], capability: 'files.upload', allowed: false, reason: 'not enabled for this bot' }, + ], +} +const api = (page, initial) => connectorApi(page, initial, 'slack', [], undefined, { kindExtra: ROLES_KIND, access: ACCESS }) + +async function shot(page, name) { + if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) +} + +test.describe('Connector permissions', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + await installStudioAddons(page, { ids: ['slack'] }) + }) + + test('a binding with no permission says why features are off, and one click turns the usual set on', async ({ page }) => { + const a = api(page, [LEGACY]) + await a.install() + await page.goto(PAGE) + const row = page.getByTestId('connector-row') + await expect(row.getByTestId('connector-nocaps')).toContainText('these features are off') + await expect(row.getByTestId('connector-nocaps')).toContainText('Receive files') + await expect(row.getByTestId('connector-nocaps')).not.toContainText('Post to a new channel') + await shot(page, 'perm-1-legacy-banner') + await row.getByTestId('connector-usual').click() + await expect(page.getByText('The usual permissions are saved for Team app.')).toBeVisible() + const put = a.s.calls.find(c => c.method === 'PUT') + expect(put.body.caps.sort()).toEqual(['files.upload', 'memory.write', 'proposals.confirm', 'talkback.channels', 'triggers.propose', 'triggers.run']) + expect(put.body.caps).not.toContain('talkback.new_channel') + expect(put.body.caps).not.toContain('tools.write') + expect('token' in put.body).toBe(false) + expect(put.body.allow).toEqual(['U001']) + }) + + test('the form lists every permission with a switch and a description, marks the sensitive one and previews the preset', async ({ page }) => { + await api(page, [LEGACY]).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const box = page.getByTestId('connector-permissions') + for (const c of CAPABILITIES) { + await expect(box.getByTestId(`connector-cap-${c.name}`)).toContainText(c.title) + await expect(box.getByTestId(`connector-cap-switch-${c.name}`)).toHaveAttribute('aria-checked', 'false') + } + await expect(box.getByTestId('connector-cap-sensitive-talkback.new_channel')).toHaveText('Paired senders only') + await expect(box.getByTestId('connector-cap-sensitive-memory.write')).toHaveCount(0) + await box.getByTestId('connector-usual').click() + await expect(box.getByTestId('connector-cap-switch-memory.write')).toHaveAttribute('aria-checked', 'true') + await expect(box.getByTestId('connector-cap-switch-talkback.new_channel')).toHaveAttribute('aria-checked', 'false') + await expect(box.getByTestId('connector-nocaps')).toHaveCount(0) + await shot(page, 'perm-2-form-switches') + }) + + test('roles get only non-sensitive permissions that are switched on, and the request carries the grants', async ({ page }) => { + const a = api(page, [{ ...LEGACY, caps: ['memory.write', 'talkback.channels', 'talkback.new_channel'] }]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const roles = page.getByTestId('connector-roles') + await expect(roles.getByRole('columnheader')).toHaveText(['Roles', 'Save to memory', 'Post to approved channels']) + await expect(roles.getByRole('checkbox', { name: 'Workspace admin: Post to a new channel' })).toHaveCount(0) + await roles.getByRole('checkbox', { name: 'Workspace admin: Save to memory' }).check() + await roles.getByRole('checkbox', { name: 'Channel member: Post to approved channels' }).check() + // Turning a permission off takes it from the roles too. + await page.getByTestId('connector-cap-switch-talkback.channels').click() + await expect(roles.getByRole('checkbox', { name: 'Channel member: Post to approved channels' })).toHaveCount(0) + await page.getByRole('button', { name: 'Save' }).click() + await expect.poll(() => a.s.calls.some(c => c.method === 'PUT')).toBe(true) + const body = a.s.calls.find(c => c.method === 'PUT').body + expect(body.caps.sort()).toEqual(['memory.write', 'talkback.new_channel']) + expect(body.grants).toEqual([{ role: 'workspace_admin', caps: ['memory.write'] }]) + }) + + test('edits the approved channels, at most ten, and refuses a bad id', async ({ page }) => { + const a = api(page, [SET]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const box = page.getByTestId('connector-channels') + await expect(box.getByTestId('connector-channel-id')).toHaveValue('C0123') + await expect(box.getByTestId('connector-channel-label')).toHaveValue('ops') + await expect(box.getByTestId('connector-channel-approved')).toHaveAttribute('aria-checked', 'true') + await box.getByTestId('connector-channel-add').click() + await expect(page.getByTestId('connector-channel-problem')).toContainText('needs an ID') + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await box.getByTestId('connector-channel-id').nth(1).fill('bad id') + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await box.getByTestId('connector-channel-id').nth(1).fill('C0999') + await box.getByTestId('connector-channel-label').nth(1).fill('random') + for (let i = 2; i < 10; i++) { + await box.getByTestId('connector-channel-add').click() + await box.getByTestId('connector-channel-id').nth(i).fill(`C${i}`) + } + await expect(box.getByTestId('connector-channel')).toHaveCount(10) + await expect(box.getByTestId('connector-channel-add')).toBeDisabled() + await shot(page, 'perm-3-channels') + await box.getByRole('button', { name: 'Remove channel C9' }).click() + await page.getByRole('button', { name: 'Save' }).click() + await expect.poll(() => a.s.calls.some(c => c.method === 'PUT')).toBe(true) + const sent = a.s.calls.find(c => c.method === 'PUT').body.channels + expect(sent).toHaveLength(9) + expect(sent[0]).toEqual({ id: 'C0123', label: 'ops', approved: true }) + expect(sent[1]).toEqual({ id: 'C0999', label: 'random', approved: false }) + }) + + test('channels are off until talk-back is switched on', async ({ page }) => { + await api(page, [LEGACY]).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.getByTestId('connector-channels')).toContainText('Switch on "Post to approved channels"') + await expect(page.getByTestId('connector-channel-add')).toHaveCount(0) + }) + + test('pairing shows the code once, lists paired senders with their expiry and removes one', async ({ page }) => { + const a = api(page, [SET]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const box = page.getByTestId('connector-pairing') + await expect(box.getByTestId('connector-paired')).toContainText('U001') + await expect(box.getByTestId('connector-paired')).toContainText('ends') + await box.getByTestId('connector-pair-start').click() + await expect(box.getByTestId('connector-pair-code')).toHaveText('LA-K7M2QX9P') + await expect(box.getByTestId('connector-pair-code-box')).toContainText('shown only now') + await shot(page, 'perm-4-pairing') + await box.getByRole('button', { name: 'Hide the code' }).click() + await expect(page.getByText('LA-K7M2QX9P')).toHaveCount(0) + await box.getByRole('button', { name: 'Remove the pairing of U001' }).click() + await expect(box).toContainText('Nobody is paired.') + expect(a.s.calls.some(c => c.method === 'DELETE' && c.url === '/api/chats/connectors/b1/paired/U001')).toBe(true) + // The page never puts the code in a request body of a save. + await page.getByRole('button', { name: 'Save' }).click() + await expect.poll(() => a.s.calls.some(c => c.method === 'PUT')).toBe(true) + expect(JSON.stringify(a.s.calls.find(c => c.method === 'PUT').body)).not.toContain('LA-K7M2QX9P') + }) + + test('a new binding explains that pairing needs a saved one', async ({ page }) => { + await api(page, []).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Slack app' }).click() + await expect(page.getByTestId('connector-pairing')).toContainText('Save this bot first') + await expect(page.getByTestId('connector-access')).toContainText('Save this bot first') + await expect(page.getByTestId('connector-nocaps')).toBeVisible() + }) + + test('shows what each sender may do and the latest decisions, read only', async ({ page }) => { + const a = api(page, [SET]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + const box = page.getByTestId('connector-access') + await expect(box.getByTestId('connector-access-anyone')).toContainText('May: Save to memory') + await expect(box.getByTestId('connector-access-anyone')).toContainText('May not: Receive files') + await expect(box.getByTestId('connector-access-role:workspace_admin')).toContainText('Workspace admin') + await expect(box.getByTestId('connector-audit')).toContainText('allowed') + await expect(box.getByTestId('connector-audit')).toContainText('denied') + await expect(box.getByTestId('connector-audit')).toContainText('Receive files') + await box.getByTestId('connector-access-anyone').getByText('May not:').click() + await expect(box.getByTestId('connector-access-anyone')).toContainText('not enabled for this bot') + await expect(box.getByRole('checkbox')).toHaveCount(0) + await shot(page, 'perm-5-access') + }) + + test('trigger approval says it applies only while triggers are allowed', async ({ page }) => { + await api(page, [SET]).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.getByTestId('connector-approval-off')).toBeVisible() + await page.getByTestId('connector-cap-switch-triggers.propose').click() + await expect(page.getByTestId('connector-approval-off')).toHaveCount(0) + }) + + test('an older host that sends no capability list shows no permission section', async ({ page }) => { + await connectorApi(page, [LEGACY], 'slack', [], undefined, { capabilities: null }).install() + await page.goto(PAGE) + await expect(page.getByTestId('connector-row')).toContainText('Team app') + await expect(page.getByTestId('connector-nocaps')).toHaveCount(0) + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.getByTestId('connector-permissions')).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/e2e/slack-addon.spec.js b/core/http/react-ui/e2e/slack-addon.spec.js index 2d199ceae0..907b452354 100644 --- a/core/http/react-ui/e2e/slack-addon.spec.js +++ b/core/http/react-ui/e2e/slack-addon.spec.js @@ -103,7 +103,7 @@ test.describe('Slack add-on', () => { await expect(page.getByText('Connected to localai (Acme).')).toBeVisible() await page.getByRole('button', { name: 'Senders' }).click() await expect(page.getByTestId('sender-log')).toContainText('Newcomer · 2 messages · U042') - await page.getByRole('button', { name: 'Allow' }).click() + await page.getByRole('button', { name: 'Allow', exact: true }).click() await expect(page.getByTestId('sender-log')).toContainText('Accepted') expect(a.s.calls.some(c => c.url === '/api/chats/connectors/b1/senders/U042/allow')).toBe(true) }) diff --git a/core/http/react-ui/e2e/telegram-addon.spec.js b/core/http/react-ui/e2e/telegram-addon.spec.js index ba7ce3efbf..f44114afb0 100644 --- a/core/http/react-ui/e2e/telegram-addon.spec.js +++ b/core/http/react-ui/e2e/telegram-addon.spec.js @@ -76,7 +76,7 @@ test.describe('Telegram add-on', () => { await page.getByRole('button', { name: 'Senders' }).click() await expect(page.getByTestId('sender-log')).toContainText('Newcomer · 2 messages · 42') await expect(page.getByTestId('sender-log')).toContainText('Ignored') - await page.getByRole('button', { name: 'Allow' }).click() + await page.getByRole('button', { name: 'Allow', exact: true }).click() await expect(page.getByTestId('sender-log')).toContainText('Accepted') expect(a.s.calls.some(c => c.url === '/api/chats/connectors/b1/senders/42/allow')).toBe(true) }) diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go index f9e3f50197..9d7f75d3e2 100644 --- a/core/http/routes/studio_connectors_test.go +++ b/core/http/routes/studio_connectors_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "github.com/mudler/LocalAI/addons/telegram/transport" + "github.com/mudler/LocalAI/pkg/addons/contract" "github.com/mudler/LocalAI/tests/studiobot" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -99,6 +100,20 @@ var _ = Describe("Chats connectors endpoints", func() { Expect(s.do("alice", http.MethodGet, "/api/chats/connectors/"+id, nil).Code).To(Equal(http.StatusNotFound)) }) + It("lists the capabilities with their texts next to the kinds", func() { + caps := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/connectors/kinds", nil))["capabilities"].([]any) + Expect(caps).To(HaveLen(len(contract.ConnectorCapabilityNames()))) + byName := map[string]map[string]any{} + for _, c := range caps { + m := c.(map[string]any) + byName[m["name"].(string)] = m + Expect(m["title"]).NotTo(BeEmpty()) + Expect(m["description"]).NotTo(BeEmpty()) + } + Expect(byName["memory.write"]).To(And(HaveKeyWithValue("sensitive", false), HaveKeyWithValue("usual", true))) + Expect(byName["profile.write"]).To(And(HaveKeyWithValue("sensitive", true), HaveKeyWithValue("usual", false))) + }) + It("lists the kinds that a running add-on serves and marks a binding whose add-on does not run", func() { kinds := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/connectors/kinds", nil))["kinds"].([]any) Expect(kinds).To(HaveLen(1)) diff --git a/core/schema/chats.go b/core/schema/chats.go index 911e5535b4..d8db4a1c68 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1343,6 +1343,18 @@ type ChatsConnectorRole struct { Description string `json:"description,omitempty"` } +// ChatsConnectorCapability describes one capability of the closed list. +type ChatsConnectorCapability struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + // Sensitive capabilities are never granted to a role: only a paired + // sender can use them. + Sensitive bool `json:"sensitive"` + // Usual capabilities are what a one-click preset turns on. + Usual bool `json:"usual"` +} + // ChatsConnectorPermission is a capability that a kind lists. Sensitive ones // are never granted to a role: only a paired sender can use them. type ChatsConnectorPermission struct { @@ -1423,6 +1435,9 @@ type ChatsConnectorOptionValue struct { // ChatsConnectorKindList lists the connector kinds of the add-ons that run now. type ChatsConnectorKindList struct { Kinds []ChatsConnectorKind `json:"kinds"` + // Capabilities is the closed list of capabilities that a binding can + // enable, with a title and a description of each, for a settings page. + Capabilities []ChatsConnectorCapability `json:"capabilities,omitempty"` } // ChatsConnectorWrite creates or replaces a connector binding. token is needed to diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 41f475c13d..c7b73e5e8c 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -523,6 +523,15 @@ sender who may not send files get a short refusal and no turn. A binding that was stored before this check has no capabilities. Enable what you want with `caps`, and pair the accounts that need the sensitive ones. +The settings page of every connector add-on edits all of this: a switch for each +capability, with a description and a mark on the sensitive ones; the role matrix; the +paired senders, with a button that shows a new code once; the approved channels for +talk-back; and the access view with the latest decisions. A binding with no capability +shows a notice that says why the features are off. The button "Allow the usual" turns +on the everyday capabilities that stay inside LocalAI and are not sensitive. The +kind list also returns `capabilities`, the closed list with a title, a description +and the `sensitive` and `usual` flags of each, so a page needs no list of its own. + To see the result, call `GET /api/chats/connectors//access`. It runs the same check for a sender on the allowlist, a paired sender and a sender of each role the kind declares, and lists the latest decisions this server made (sender IDs, roles, diff --git a/pkg/addons/contract/permissions.go b/pkg/addons/contract/permissions.go index 9705deb6a7..5867360ac3 100644 --- a/pkg/addons/contract/permissions.go +++ b/pkg/addons/contract/permissions.go @@ -72,6 +72,54 @@ func ConnectorCapabilityNames() []string { return out } +// CapabilityInfo describes one capability for a settings page: a plain title +// and a sentence that says what the owner allows. It adds no new rules. +type CapabilityInfo struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Sensitive bool `json:"sensitive"` + // Usual marks the capabilities that a settings page turns on with its + // one-click preset. A sensitive capability is never usual. + Usual bool `json:"usual"` +} + +// capabilityText is the title and description of each capability. Titles are +// short verbs; descriptions say what changes and who can cause it. +var capabilityText = map[string][2]string{ + CapProposalsConfirm: {"Confirm changes from the chat", "A sender can confirm a change that the agent proposed, such as a memory or a trigger, by answering in the same chat."}, + CapMemoryWrite: {"Save to memory", "The agent may remember what a sender asks it to remember."}, + CapFilesUpload: {"Receive files", "The agent may read files that senders attach to a message."}, + CapTriggersPropose: {"Propose triggers", "The agent may propose a timer or an event trigger when a sender asks for one."}, + CapTriggersRun: {"Fire event triggers", "A message from a sender may start a run of an event trigger."}, + CapTalkbackChannels: {"Post to approved channels", "The agent may write to the approved channels below when a sender asks, besides the chat it answers in."}, + CapTalkbackNewChan: {"Post to a new channel", "The agent may write to a channel that is not approved yet."}, + CapProfileWrite: {"Change the agent profile", "The agent may change its own profile."}, + CapSkillsWrite: {"Write skills", "The agent may write or change skills."}, + CapPagesWrite: {"Write pages", "The agent may write or change pages."}, + CapDashboardWrite: {"Write the dashboard", "The agent may change the dashboard."}, + CapToolsWrite: {"Change things outside LocalAI", "An add-on tool may change something with the credential of the binding, such as a comment on an issue."}, +} + +// usualCapabilities are what an owner normally allows on a new binding: the +// everyday ones that stay inside LocalAI and the approved channels. +var usualCapabilities = map[string]bool{ + CapProposalsConfirm: true, CapMemoryWrite: true, CapFilesUpload: true, + CapTriggersPropose: true, CapTriggersRun: true, CapTalkbackChannels: true, +} + +// ConnectorCapabilities returns the closed list with its texts, in the order of +// ConnectorCapabilityNames. +func ConnectorCapabilities() []CapabilityInfo { + names := ConnectorCapabilityNames() + out := make([]CapabilityInfo, 0, len(names)) + for _, n := range names { + text := capabilityText[n] + out = append(out, CapabilityInfo{Name: n, Title: text[0], Description: text[1], Sensitive: capabilities[n], Usual: usualCapabilities[n] && !capabilities[n]}) + } + return out +} + // ConnectorRole is a role that a kind can give to a sender, such as the admin // of a workspace. The add-on decides who has which role and sends the role IDs // with each message; the owner decides what each role may do. diff --git a/pkg/addons/contract/permissions_test.go b/pkg/addons/contract/permissions_test.go index 17fde63de0..dce011210c 100644 --- a/pkg/addons/contract/permissions_test.go +++ b/pkg/addons/contract/permissions_test.go @@ -68,6 +68,23 @@ var _ = Describe("the roles and permissions of a connector kind", func() { Expect(ValidateManifest(m, host())).NotTo(Succeed()) }) + It("describes every capability, and never makes a sensitive one usual", func() { + list := ConnectorCapabilities() + Expect(list).To(HaveLen(len(ConnectorCapabilityNames()))) + usual := 0 + for _, c := range list { + Expect(c.Title).NotTo(BeEmpty(), c.Name) + Expect(c.Description).NotTo(BeEmpty(), c.Name) + Expect(c.Sensitive).To(Equal(SensitiveCapability(c.Name))) + if c.Usual { + usual++ + Expect(c.Sensitive).To(BeFalse(), c.Name) + } + } + Expect(usual).To(BeNumerically(">", 0)) + Expect(capabilityText).To(HaveLen(len(capabilities))) + }) + It("never grants a sensitive capability to a role", func() { kind().Permissions = []string{CapProfileWrite, CapMemoryWrite} Expect(kind().Grantable(CapProfileWrite)).To(BeFalse()) diff --git a/swagger/docs.go b/swagger/docs.go index a4dd696005..0040188b23 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -19331,7 +19331,7 @@ const docTemplate = `{ "title": { "description": "Title and Description say what the group is and what the model can do\nwhen its switch is on; WhenOff says what it cannot do when it is off.\nThey are English; the interface may translate them by group.", "type": "string", - "example": "Web" + "example": "Look things up online" }, "tools": { "type": "array", @@ -19859,6 +19859,28 @@ const docTemplate = `{ } } }, + "schema.ChatsConnectorCapability": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sensitive": { + "description": "Sensitive capabilities are never granted to a role: only a paired\nsender can use them.", + "type": "boolean" + }, + "title": { + "type": "string" + }, + "usual": { + "description": "Usual capabilities are what a one-click preset turns on.", + "type": "boolean" + } + } + }, "schema.ChatsConnectorChannel": { "type": "object", "properties": { @@ -19982,6 +20004,13 @@ const docTemplate = `{ "schema.ChatsConnectorKindList": { "type": "object", "properties": { + "capabilities": { + "description": "Capabilities is the closed list of capabilities that a binding can\nenable, with a title and a description of each, for a settings page.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorCapability" + } + }, "kinds": { "type": "array", "items": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 3cfaebd2a6..cda50ec015 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -19328,7 +19328,7 @@ "title": { "description": "Title and Description say what the group is and what the model can do\nwhen its switch is on; WhenOff says what it cannot do when it is off.\nThey are English; the interface may translate them by group.", "type": "string", - "example": "Web" + "example": "Look things up online" }, "tools": { "type": "array", @@ -19856,6 +19856,28 @@ } } }, + "schema.ChatsConnectorCapability": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sensitive": { + "description": "Sensitive capabilities are never granted to a role: only a paired\nsender can use them.", + "type": "boolean" + }, + "title": { + "type": "string" + }, + "usual": { + "description": "Usual capabilities are what a one-click preset turns on.", + "type": "boolean" + } + } + }, "schema.ChatsConnectorChannel": { "type": "object", "properties": { @@ -19979,6 +20001,13 @@ "schema.ChatsConnectorKindList": { "type": "object", "properties": { + "capabilities": { + "description": "Capabilities is the closed list of capabilities that a binding can\nenable, with a title and a description of each, for a settings page.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorCapability" + } + }, "kinds": { "type": "array", "items": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 06fff0594c..797e07c716 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -3596,7 +3596,7 @@ definitions: Title and Description say what the group is and what the model can do when its switch is on; WhenOff says what it cannot do when it is off. They are English; the interface may translate them by group. - example: Web + example: Look things up online type: string tools: example: @@ -4033,6 +4033,23 @@ definitions: mode: type: string type: object + schema.ChatsConnectorCapability: + properties: + description: + type: string + name: + type: string + sensitive: + description: |- + Sensitive capabilities are never granted to a role: only a paired + sender can use them. + type: boolean + title: + type: string + usual: + description: Usual capabilities are what a one-click preset turns on. + type: boolean + type: object schema.ChatsConnectorChannel: properties: approved: @@ -4132,6 +4149,13 @@ definitions: type: object schema.ChatsConnectorKindList: properties: + capabilities: + description: |- + Capabilities is the closed list of capabilities that a binding can + enable, with a title and a description of each, for a settings page. + items: + $ref: '#/definitions/schema.ChatsConnectorCapability' + type: array kinds: items: $ref: '#/definitions/schema.ChatsConnectorKind'