feat(connectors): edit the permissions of a binding on the shared connector page

Bindings saved before permissions were enforced have no capabilities, so
they deny files from senders, triggers, memory writes and talk-back, and
the owner could only fix that through the REST API.

The shared connector page now has a section for it, so every connector
add-on gets it: a switch and a description for each capability with the
sensitive ones marked, the role matrix (never a sensitive capability, never
above the ceiling), pairing with a code shown once, the approved channels
for talk-back, and a read-only view of the access check with the latest
decisions. A binding with no capability shows a notice that says why the
features are off, with an "Allow the usual" button that turns on the
non-sensitive everyday set.

The kinds endpoint returns the closed list of capabilities with a title,
a description and the sensitive and usual flags. The field is additive.

Assisted-by: Claude:claude-sonnet-5-5 go node playwright swag
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 15:36:21 +00:00
1 parent fd0f676722
commit 15e2321d50
23 files changed
+1085 -12

No files matched your search

@@ -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 (
<li className="cn-access__subject" data-testid={`connector-access-${sub.id}`}>
<span className="cn-access__who">{sub.title}</span>
<span data-testid="connector-access-can">{s('access.can', { list: ok.length ? ok.map((d) => title(d.capability)).join(', ') : s('access.nothing') })}</span>
{no.length > 0 && (
<details className="cn-access__no">
<summary>{s('access.cannot', { list: no.map((d) => title(d.capability)).join(', ') })}</summary>
<ul>{no.map((d) => <li key={d.capability}><span>{title(d.capability)}</span>{d.reason && <span className="cn-muted">: {d.reason}</span>}</li>)}</ul>
</details>
)}
</li>
)
}
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 (
<fieldset className="cn-group" data-testid="connector-access">
<legend>{s('access.title')}</legend>
<p className="dk-hint">{s('access.help')}</p>
{!id ? <p className="cn-muted">{s('access.needSave')}</p> : (<>
{error && <Notice tone="error" role="alert">{error}</Notice>}
<div>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" aria-busy={loading ? 'true' : undefined} disabled={loading} onClick={load}>{s('access.refresh')}</button>
</div>
{view && (<>
<ul className="cn-access" aria-label={s('access.title')}>
{(view.subjects || []).map((sub) => <Subject key={sub.id} sub={sub} title={title} s={s} />)}
</ul>
<h3 className="cn-sub__title">{s('access.recentTitle')}</h3>
<p className="dk-hint">{s('access.recentHelp')}</p>
{(view.recent || []).length === 0 ? <p className="cn-muted">{s('access.recentNone')}</p> : (
<ul className="cn-runs" aria-label={s('access.recentTitle')} data-testid="connector-audit">
{view.recent.slice(0, 20).map((e, i) => (
<li key={i}>
<span className={`cn-status cn-status--${e.allowed ? 'done' : 'failed'}`}>{s(e.allowed ? 'access.allowed' : 'access.denied')}</span>
<span>{title(e.capability)}</span>
<span className="cn-muted">{new Date(e.at).toLocaleString([], { dateStyle: 'short', timeStyle: 'medium' })}</span>
{(e.senders || []).length > 0 && <span className="cn-muted cn-mono">{s('access.who', { senders: e.senders.join(', ') })}</span>}
{(e.roles || []).length > 0 && <span className="cn-muted">{s('access.roles', { roles: e.roles.join(', ') })}</span>}
{e.via && <span className="cn-muted">{s('access.via', { via: e.via })}</span>}
{e.reason && <span className="cn-muted">{e.reason}</span>}
</li>
))}
</ul>
)}
</>)}
</>)}
</fieldset>
)
}
@@ -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.
@@ -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 }) {
<fieldset className="cn-group" data-testid="connector-approval">
<legend>{s('approval.title')}</legend>
<p className="dk-hint">{s('approval.help')}</p>
{triggersOff && <p className="cn-muted" data-testid="connector-approval-off">{s('perm.triggersNeed')}</p>}
<RadioCards name="approval" legend={s('approval.title')} value={a.mode} onChange={(mode) => set({ mode })}
options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} />
{auto && (<>
@@ -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
</Field>
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} s={answerT(t)} />
<ApprovalFields form={form} setForm={setForm} s={answerT(t)} />
<PermissionFields form={form} setForm={setForm} kind={kindInfo} capabilities={capabilities} s={s} onPaired={pairedChanged} />
<ApprovalFields form={form} setForm={setForm} s={s} triggersOff={capabilities.length > 0 && !(form.caps || []).includes('triggers.propose')} />
{capabilities.length > 0 && <AccessView binding={form} capabilities={capabilities} s={s} version={accessVersion} />}
</>)}
<div className="cn-actions">
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
@@ -250,6 +278,9 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
<span className="cn-muted" data-testid="connector-answer-summary">{answerSummary(c, t)}</span>
</>) : <span className="cn-muted">{t('agentOf', { name: agentName(c.agent_id) })}</span>}
{RowInfo && <RowInfo c={c} t={t} />}
{messaging && capabilities.length > 0 && hasNoCaps(c) && (
<NoCapsNotice capabilities={capabilities} s={s} busy={busy} name={c.name} onUsual={() => allowUsual(c)} />
)}
{c.active === false && <span className="cn-muted" data-testid="connector-inactive">{t('inactive', { reason: c.reason })}</span>}
{openSenders === c.id && <Senders id={c.id} onAllowed={load} t={t} />}
</div>
@@ -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 (
<div className="cn-sub" data-testid="connector-pairing">
<h3 className="cn-sub__title">{s('pair.title')}</h3>
<p className="cn-muted">{s('pair.needSave')}</p>
</div>
)
}
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 (
<div className="cn-sub" data-testid="connector-pairing">
<h3 className="cn-sub__title">{s('pair.title')}</h3>
<p className="dk-hint">{s('pair.help', { max: MAX_PAIRED, days: DAYS })}</p>
{error && <Notice tone="error" role="alert">{error}</Notice>}
{paired.length === 0 ? <p className="cn-muted">{s('pair.none')}</p> : (
<ul className="cn-runs" aria-label={s('pair.title')} data-testid="connector-paired">
{paired.map((p) => (
<li key={p.sender_id}>
<span className="cn-mono">{p.sender_id}</span>
<span className="cn-muted">{s('pair.expires', { time: time(p.expires_at) })}</span>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" aria-label={s('pair.removeSender', { sender: p.sender_id })} onClick={() => remove(p.sender_id)}>{s('pair.remove')}</button>
</li>
))}
</ul>
)}
{done && <Notice tone="ok" role="status" testId="connector-pair-done">{s('pair.done', { sender: done })}</Notice>}
{expired && <Notice tone="info" role="status">{s('pair.codeGone')}</Notice>}
{code && (
<Notice tone="info" role="status" testId="connector-pair-code-box">
<p className="cn-notice__title">{s('pair.codeTitle')}</p>
<p className="cn-pairing__code" data-testid="connector-pair-code">{code.code}</p>
<p className="cn-notice__text">{s('pair.codeHelp', { time: time(code.expires) })}</p>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setCode(null)}>{s('pair.dismiss')}</button>
</Notice>
)}
<div className="cn-switchfield">
<div>
<button type="button" className="dk-btn dk-btn--secondary" data-testid="connector-pair-start" disabled={busy || full} onClick={start}>{s('pair.start')}</button>
</div>
<p className="dk-hint">{full ? s('pair.full') : s('pair.startHelp')}</p>
</div>
</div>
)
}
@@ -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 (
<li className="cn-cap" data-testid={`connector-cap-${cap.name}`}>
<Switch checked={on} onChange={onChange} testId={`connector-cap-switch-${cap.name}`}>
<span className="cn-cap__title">{cap.title}</span>
{cap.sensitive && <span className="cn-badge cn-badge--warn" data-testid={`connector-cap-sensitive-${cap.name}`}>{s('perm.sensitive')}</span>}
</Switch>
<Hint more={s('more')} less={s('less')}>{cap.description}{cap.sensitive ? ` ${s('perm.sensitiveHelp')}` : ''}</Hint>
</li>
)
}
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 (
<div className="cn-sub" data-testid="connector-roles">
<h3 className="cn-sub__title">{s('perm.rolesTitle')}</h3>
<p className="dk-hint">{s('perm.rolesHelp')}</p>
{cols.length === 0 ? <p className="cn-muted">{s('perm.rolesNone')}</p> : (
<div className="cn-matrix__wrap">
<table className="cn-matrix" aria-label={s('perm.rolesGrid')}>
<thead>
<tr>
<th scope="col"><span className="cn-vh">{s('perm.rolesTitle')}</span></th>
{cols.map((c) => <th key={c} scope="col">{title(c)}</th>)}
</tr>
</thead>
<tbody>
{roles.map((r) => (
<tr key={r.id}>
<th scope="row">
<span>{r.title}</span>
{r.description && <span className="cn-muted cn-matrix__desc">{r.description}</span>}
</th>
{cols.map((c) => (
<td key={c}>
<input type="checkbox" className="dk-check" data-testid={`connector-grant-${r.id}-${c}`}
aria-label={s('perm.roleCap', { role: r.title, cap: title(c) })}
checked={roleHas(form, r.id, c)} onChange={(e) => setForm(setGrant(form, r.id, c, e.target.checked, capabilities))} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
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 (
<div className="cn-sub" data-testid="connector-channels">
<h3 className="cn-sub__title">{s('perm.channelsTitle')}</h3>
<p className="dk-hint">{s('perm.channelsHelp', { max: MAX_CHANNELS })}</p>
{!(form.caps || []).includes('talkback.channels') ? <p className="cn-muted">{s('perm.channelsOff')}</p> : (<>
<ul className="cn-channels">
{list.map((c, i) => (
<li key={i} className="cn-channel" data-testid="connector-channel">
<Field label={s('perm.channelId')} hint={s('perm.channelIdHelp')}>
{(p) => <TextInput {...p} mono maxLength={MAX_CHANNEL_ID} value={c.id} data-testid="connector-channel-id" onChange={(e) => set(i, { id: e.target.value })} />}
</Field>
<Field label={s('perm.channelLabel')} hint={s('perm.channelLabelHelp')} optional>
{(p) => <TextInput {...p} maxLength={MAX_CHANNEL_LABEL} value={c.label} data-testid="connector-channel-label" onChange={(e) => set(i, { label: e.target.value })} />}
</Field>
<div className="cn-switchfield">
<Switch checked={Boolean(c.approved)} testId="connector-channel-approved" onChange={(on) => set(i, { approved: on })}>{s('perm.channelApproved')}</Switch>
<Hint more={s('more')} less={s('less')}>{s('perm.channelApprovedHelp')}</Hint>
</div>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" aria-label={s('perm.channelRemove', { id: c.id || '' })}
onClick={() => setForm({ ...form, channels: list.filter((_, j) => j !== i) })}>
<Icon name="close" /> {s('perm.remove')}
</button>
</li>
))}
</ul>
{problem && <p className="dk-field-error" role="alert" data-testid="connector-channel-problem">{s(`perm.channelProblem.${problem}`, { max: problem === 'id' ? MAX_CHANNEL_ID : problem === 'label' ? MAX_CHANNEL_LABEL : MAX_CHANNELS })}</p>}
<div>
<button type="button" className="dk-btn dk-btn--secondary" disabled={list.length >= MAX_CHANNELS} data-testid="connector-channel-add"
onClick={() => setForm({ ...form, channels: [...list, { id: '', label: '', approved: false }] })}>
<Icon name="plus" /> {list.length >= MAX_CHANNELS ? s('perm.channelFull') : s('perm.channelAdd')}
</button>
</div>
</>)}
</div>
)
}
/**
* 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 (
<Notice tone="warn" role="note" testId="connector-nocaps" className="cn-nocaps">
<p className="cn-notice__title">{s('perm.noCapsTitle')}</p>
<p className="cn-notice__text">{s('perm.noCapsText')}</p>
{usual.length > 0 && <p className="cn-notice__text">{s('perm.usualHelp', { names: usual.map((c) => c.title).join(', ') })}</p>}
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" data-testid="connector-usual" disabled={busy || usual.length === 0}
aria-label={name ? `${s('perm.usual')}: ${name}` : undefined} onClick={onUsual}>{s('perm.usual')}</button>
</Notice>
)
}
/**
* @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 (
<fieldset className="cn-group" data-testid="connector-permissions">
<legend>{s('perm.title')}</legend>
<p className="dk-hint">{s('perm.help')}</p>
{hasNoCaps(form) && (
<NoCapsNotice capabilities={caps} s={s}
onUsual={() => setForm({ ...form, caps: [...new Set([...(form.caps || []), ...usual])] })} />
)}
<ul className="cn-caps" aria-label={s('perm.capsLegend')}>
{caps.map((c) => <Cap key={c.name} cap={c} on={on(c.name)} s={s} onChange={(v) => setForm(setCap(form, c.name, v))} />)}
</ul>
<Roles form={form} setForm={setForm} kind={kind} capabilities={caps} s={s} />
<Channels form={form} setForm={setForm} s={s} />
<PairingFields binding={form} s={s} onPaired={onPaired} />
</fieldset>
)
}
+2
View File
@@ -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)}`),
}
@@ -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; }
@@ -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)
@@ -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)
})
+3
View File
@@ -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)
}
+31 -2
View File
@@ -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 } }))
},
}
}
@@ -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)
})
})
+1 -1
View File
@@ -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)
})
@@ -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)
})
@@ -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))
+15
View File
@@ -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
@@ -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/<id>/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,
+48
View File
@@ -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.
+17
View File
@@ -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())
+30 -1
View File
@@ -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": {
+30 -1
View File
@@ -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": {
+25 -1
View File
@@ -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'