feat(connectors): copy or fill permissions from the agent

The permissions form of a connection gets two buttons that fill it from
the agent of the binding. "Allow the usual set" ticks the usual
capabilities the agent holds and says which ones it left out because
the agent does not hold them. "Same as the agent" copies, once, every
capability the agent holds that has a binding capability, after a
confirmation that lists the sensitive ones. Both only fill the form, and
are off with a reason when the binding has no agent or its permissions
cannot be read. A capability the agent does not hold cannot be switched
on.

The form also says when ticked capabilities cannot be used yet: the host
lets a sender use one only with a pairing or a role that holds it, so
with nobody paired and no role grant the ticked ones do nothing. The
notice links to pairing. A link with ?binding=<id> opens that binding at
its permissions.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 10:17:25 +00:00
1 parent 3edcec1813
commit 9a98a39748
10 files changed
+408 -21

No files matched your search

@@ -4,7 +4,7 @@
// log live in core; this page edits them through the core API. An add-on
// gives its own words (an i18n namespace), the fields of its token, and its
// allowlists; the rest is the same for every platform.
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog'
import Icon from '../../host-compat/src/components/Icon'
@@ -126,6 +126,20 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
}, [kind])
useEffect(() => { agentsApi.list().then((r) => setAgents(r.agents || [])).catch(() => {}) }, [])
// A link with ?binding=<id> (from the Tools list of an agent) opens that
// binding and brings its permissions into view, once.
const linked = useRef(false)
useEffect(() => {
if (linked.current || items.length === 0) return
const id = new URLSearchParams(window.location.search).get('binding')
const c = id && items.find((x) => x.id === id)
if (!c) return
linked.current = true
open({ ...connectorForm(c), ...(extraForm ? extraForm(c) : {}) })
setTimeout(() => document.querySelector('[data-testid="connector-permissions"]')?.scrollIntoView?.({ block: 'start' }), 50)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])
const agentName = (id) => agents.find((a) => a.id === id)?.name || id
const open = (c) => { setForm(c); setConfirmed(Boolean(c.accept_any)); setNote('') }
const extra = (form) => ({ ...(messaging ? {} : { enabled: false, accept_any: false }), ...(extraBody ? extraBody(form) : {}) })
@@ -244,7 +258,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
</Field>
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} s={answerT(t)} />
<PermissionFields form={form} setForm={setForm} kind={kindInfo} capabilities={capabilities} s={s} onPaired={pairedChanged} />
<PermissionFields form={form} setForm={setForm} kind={kindInfo} capabilities={capabilities} s={s} onPaired={pairedChanged} agent={agents.find((a) => a.id === form.agent_id)} />
<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} />}
</>)}
@@ -60,7 +60,7 @@ export default function PairingFields({ binding, s, onPaired }) {
setPaired(list)
setCode(null)
setDone(fresh.sender_id)
onPaired?.()
onPaired?.(list)
stop()
}
} catch (_e) { /* the next tick tries again */ }
@@ -88,7 +88,7 @@ export default function PairingFields({ binding, s, onPaired }) {
const list = b?.paired || paired.filter((p) => p.sender_id !== sender)
known.current = new Set(list.map((p) => p.sender_id))
setPaired(list)
onPaired?.()
onPaired?.(list)
} catch (e) { setError(e?.message || '') }
}
const full = paired.length >= MAX_PAIRED
@@ -5,9 +5,10 @@
// approved channels for talk-back, and the paired senders. A binding that was
// saved before the host enforced permissions has none switched on, so it denies
// everything gated; the notice and the preset below say so and fix it in a click.
import { useCallback, useState } from 'react'
import { Field, Hint, Notice, Switch, TextInput } from './controls'
import Icon from '../../host-compat/src/components/Icon'
import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, channelsProblem, grantableFor, hasNoCaps, roleHas, setCap, setGrant, usualCaps } from './connector'
import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unusableCaps, usualFor, withAddedCaps, withCopiedCaps } from './connector'
import PairingFields from './PairingFields'
export const PERMISSION_TEXT = {
@@ -19,6 +20,25 @@ export const PERMISSION_TEXT = {
'perm.usualHelp': 'Switches on the everyday permissions that stay inside LocalAI: {{names}}. Sensitive ones are never included.',
'perm.usualDone': 'The usual permissions are on. Save to keep them.',
'perm.usualSaved': 'The usual permissions are saved for {{name}}.',
'perm.usualSet': 'Allow the usual set',
'perm.usualSetHelp': 'Ticks the everyday permissions that stay inside LocalAI, for what the agent has. Sensitive ones are never included.',
'perm.sameAs': 'Same as the agent',
'perm.sameAsHelp': 'Copies the permissions the agent has now, sensitive ones too. It is a copy of today and does not follow later changes to the agent.',
'perm.block.noAgent': 'Choose an agent first.',
'perm.block.unreadable': 'The permissions of this agent could not be read.',
'perm.block.nothing': 'This agent has no permission that a connection can use.',
'perm.usualTicked': 'Ticked {{count}}: {{names}}. Save to keep them.',
'perm.usualNone': 'Nothing new to tick: the usual permissions the agent has are already on.',
'perm.usualSkipped': 'Not ticked, because the agent does not have them: {{names}}.',
'perm.sameTicked': 'Copied {{count}} permissions of {{agent}}: {{names}}. This is a copy of today and does not follow later changes to the agent. Save to keep them.',
'perm.agentLacks': 'The agent does not have this.',
'perm.confirmTitle': 'Copy the sensitive permissions too?',
'perm.confirmText': 'The agent has these sensitive permissions. Once they are on, a sender you paired could use them through this bot:',
'perm.confirmYes': 'Copy them too',
'perm.confirmNo': 'Cancel',
'perm.stuckTitle': 'You ticked these, but no sender can use them yet',
'perm.stuckText': 'A sender can use a permission only when you paired them with this bot, or when a role that you gave the permission to applies. Nobody is paired yet and no role has these: {{names}}.',
'perm.stuckPair': 'Pair your account',
'perm.sensitive': 'Paired senders only',
'perm.sensitiveHelp': 'Sensitive. Never given to a role. Only a sender you paired can use it, and every use is recorded.',
'perm.capsLegend': 'Permissions of this bot',
@@ -52,14 +72,18 @@ export function permissionT(t) {
return (key, opts = {}) => t(key, { defaultValue: PERMISSION_TEXT[key], ...opts })
}
function Cap({ cap, on, onChange, s }) {
function Cap({ cap, on, onChange, s, lacks }) {
// A permission the agent does not have cannot be switched on; one that is
// already on can still be switched off.
const blocked = lacks && !on
return (
<li className="cn-cap" data-testid={`connector-cap-${cap.name}`}>
<Switch checked={on} onChange={onChange} testId={`connector-cap-switch-${cap.name}`}>
<li className="cn-cap" data-testid={`connector-cap-${cap.name}`} data-lacks={lacks ? 'true' : undefined}>
<Switch checked={on} onChange={onChange} disabled={blocked} describedBy={lacks ? `cn-lacks-${cap.name}` : undefined} 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>
{lacks && <p className="cn-cap__lacks" id={`cn-lacks-${cap.name}`} data-testid={`connector-cap-lacks-${cap.name}`}>{s('perm.agentLacks')}</p>}
</li>
)
}
@@ -152,46 +176,128 @@ function Channels({ form, setForm, s }) {
* It stands alone so the list of bindings can show it too.
*/
export function NoCapsNotice({ capabilities, onUsual, busy, s, name }) {
// Inside the form the presets below do the same job, limited to the agent.
const usual = (capabilities || []).filter((c) => c.usual && !c.sensitive)
return (
<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>
{onUsual && (
<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>
)
}
/**
* Two buttons that fill the permissions of the form from the agent of the
* binding: the usual set (what the agent has), and a copy of everything the
* agent has. They only change the form; the owner saves as for any change.
*/
function Presets({ form, setForm, capabilities, agent, s }) {
const [done, setDone] = useState(null)
const [pending, setPending] = useState(null)
const held = agentHeld(agent)
const block = presetBlock(form.agent_id, held, capabilities)
const title = (name) => capabilities.find((c) => c.name === name)?.title || name
const names = (list) => list.map(title).join(', ')
const reasonId = 'cn-preset-reason'
const usual = () => {
const { caps, skipped } = usualFor(capabilities, held)
const fresh = caps.filter((n) => !(form.caps || []).includes(n))
setPending(null)
setForm(withAddedCaps(form, caps))
setDone({ kind: 'usual', text: [fresh.length ? s('perm.usualTicked', { count: fresh.length, names: names(fresh) }) : s('perm.usualNone'), skipped.length ? s('perm.usualSkipped', { names: names(skipped) }) : ''].filter(Boolean).join(' ') })
}
const copy = (caps) => {
setPending(null)
setForm(withCopiedCaps(form, caps))
setDone({ kind: 'same', text: s('perm.sameTicked', { count: caps.length, agent: agent?.name || '', names: names(caps) }) })
}
const same = () => {
const { caps, sensitive } = copyFromAgent(capabilities, held)
if (sensitive.length > 0) { setDone(null); setPending({ caps, sensitive }); return }
copy(caps)
}
return (
<div className="cn-presets" data-testid="connector-presets">
<div className="cn-presets__row">
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" data-testid="connector-usual" disabled={Boolean(block)}
aria-describedby={block ? reasonId : undefined} onClick={usual}>{s('perm.usualSet')}</button>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" data-testid="connector-same" disabled={Boolean(block)}
aria-describedby={block ? reasonId : undefined} onClick={same}>{s('perm.sameAs')}</button>
</div>
{block ? <p className="dk-hint" id={reasonId} data-testid="connector-preset-reason">{s(`perm.block.${block}`)}</p>
: <p className="dk-hint">{s('perm.usualSetHelp')} {s('perm.sameAsHelp')}</p>}
{pending && (
<Notice tone="warn" role="alertdialog" testId="connector-same-confirm">
<p className="cn-notice__title">{s('perm.confirmTitle')}</p>
<p className="cn-notice__text">{s('perm.confirmText')}</p>
<ul className="cn-confirm__list">
{pending.sensitive.map((n) => <li key={n}><strong>{title(n)}</strong>: {capabilities.find((c) => c.name === n)?.description}</li>)}
</ul>
<div className="cn-presets__row">
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" data-testid="connector-same-yes" onClick={() => copy(pending.caps)}>{s('perm.confirmYes')}</button>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" data-testid="connector-same-no" onClick={() => setPending(null)}>{s('perm.confirmNo')}</button>
</div>
</Notice>
)}
{done && <p className="cn-presets__done" role="status" data-testid={`connector-preset-done-${done.kind}`}>{done.text}</p>}
</div>
)
}
/**
* @param {object} props
* @param {object} props.form the form of the binding
* @param {object} props.agent the saved agent of the binding, when it is known
* @param {Function} props.setForm
* @param {object} props.kind the kind from the kinds call (roles, permissions)
* @param {Array} props.capabilities the closed list from the kinds call
* @param {Function} props.s the translation function with the defaults
* @param {Function} props.onPaired called when pairing changed the binding
*/
export default function PermissionFields({ form, setForm, kind, capabilities, s, onPaired }) {
export default function PermissionFields({ form, setForm, kind, capabilities, s, onPaired, agent }) {
const caps = capabilities || []
if (caps.length === 0) return null
const on = (name) => (form.caps || []).includes(name)
const usual = usualCaps(caps)
const held = agentHeld(agent)
// What the pairing list is now: the saved one, and what pairing here changes.
const [paired, setPaired] = useState(form.paired || [])
const pairedHere = useCallback((list) => { if (Array.isArray(list)) setPaired(list); onPaired?.() }, [onPaired])
const stuck = unusableCaps(form, paired)
const title = (name) => caps.find((c) => c.name === name)?.title || name
const toPairing = () => {
const el = document.querySelector('[data-testid="connector-pair-start"]') || document.querySelector('[data-testid="connector-pairing"]')
el?.scrollIntoView?.({ block: 'center' })
el?.focus?.()
}
return (
<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])] })} />
<NoCapsNotice capabilities={caps} s={s} />
)}
{stuck.length > 0 && (
<Notice tone="warn" role="note" testId="connector-stuck">
<p className="cn-notice__title">{s('perm.stuckTitle')}</p>
<p className="cn-notice__text">{s('perm.stuckText', { names: stuck.map(title).join(', ') })}</p>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" data-testid="connector-stuck-pair" onClick={toPairing}>{s('perm.stuckPair')}</button>
</Notice>
)}
<Presets key={`${form.id || 'new'}:${form.agent_id || ''}`} form={form} setForm={setForm} capabilities={caps} agent={agent} s={s} />
<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))} />)}
{caps.map((c) => <Cap key={c.name} cap={c} on={on(c.name)} s={s} lacks={Boolean(form.agent_id) && held !== null && !agentHas(held, c.name)} 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} />
<PairingFields binding={form} s={s} onPaired={pairedHere} />
</fieldset>
)
}
@@ -191,3 +191,11 @@ fieldset.cn-group {
.cn-access__no summary { cursor: pointer; color: var(--color-text-muted); }
.cn-access__no ul { margin: var(--dk-space-1) 0 0; padding-left: var(--dk-space-5); }
.cn-row:has(.cn-nocaps) { align-items: flex-start; }
/* The presets of the permissions: fill the form from the agent of the binding. */
.cn-presets { display: flex; flex-direction: column; gap: var(--dk-space-2); margin-block: var(--dk-space-2); }
.cn-presets__row { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); }
.cn-presets__done { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-primary); }
.cn-confirm__list { margin: 0; padding-left: var(--dk-space-5); font-size: var(--fs-small); line-height: var(--lh-small); }
.cn-cap__lacks { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-muted); }
.cn-cap[data-lacks="true"] .cn-cap__title { color: var(--color-text-muted); }
@@ -70,6 +70,82 @@ export function usualCaps(capabilities) {
return (capabilities || []).filter((c) => c.usual && !c.sensitive).map((c) => c.name)
}
// The binding capabilities that are also grants of a saved agent. A sender can
// only use one of them when the agent holds the grant too, so the presets only
// tick what the agent holds. proposals.confirm and tools.write are not grants:
// they belong to the binding alone.
export const AGENT_GRANT_CAPS = [
'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write',
'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel',
]
// What an agent without a grant list holds for its messaging connections (the
// server rule is core/services/studio/connectorgrants.go). As soon as the list
// names any grant, only the named ones count.
export const DEFAULT_CONNECTOR_GRANTS = ['triggers.propose', 'triggers.run', 'talkback.channels', 'files.upload']
// The grants the agent holds for a connection, or null when its permissions
// cannot be read (no agent, an agent that is not in the list, a host that does
// not send them).
export function agentHeld(agent) {
if (!agent || !Array.isArray(agent.builtins)) return null
return agent.builtins.length > 0 ? agent.builtins : DEFAULT_CONNECTOR_GRANTS
}
// Whether the agent lets a capability be switched on: a capability that is not
// a grant of the agent is not gated. held is agentHeld(agent); null gates nothing.
export const agentHas = (held, name) => held === null || !AGENT_GRANT_CAPS.includes(name) || held.includes(name)
// Why a preset cannot be used: 'noAgent', 'unreadable', 'nothing' or ''.
export function presetBlock(agentId, held, capabilities) {
if (!agentId) return 'noAgent'
if (held === null) return 'unreadable'
if (!(capabilities || []).some((c) => AGENT_GRANT_CAPS.includes(c.name) && held.includes(c.name))) return 'nothing'
return ''
}
// "Allow the usual set": the caps the host marks usual (never a sensitive one)
// that the agent holds. skipped are the usual ones the agent does not hold.
export function usualFor(capabilities, held) {
const usual = usualCaps(capabilities)
return { caps: usual.filter((n) => agentHas(held, n)), skipped: usual.filter((n) => !agentHas(held, n)) }
}
// "Same as the agent": every cap the agent holds that is a capability of the
// host's list, sensitive ones too. sensitive names the ones that are.
export function copyFromAgent(capabilities, held) {
const list = (capabilities || []).filter((c) => AGENT_GRANT_CAPS.includes(c.name) && (held || []).includes(c.name))
return { caps: list.map((c) => c.name), sensitive: list.filter((c) => c.sensitive).map((c) => c.name) }
}
// The form after the caps of the agent were copied in. The caps that are not
// grants of an agent stay as they were; the others become exactly the copy, and
// a role never keeps a cap the ceiling lost.
export function withCopiedCaps(form, caps) {
const keep = (form.caps || []).filter((n) => !AGENT_GRANT_CAPS.includes(n))
const next = [...new Set([...keep, ...caps])]
const grants = (form.grants || []).map((g) => ({ ...g, caps: g.caps.filter((x) => next.includes(x)) })).filter((g) => g.caps.length > 0)
return { ...form, caps: next, grants }
}
// The form with these caps added to the ones it has.
export function withAddedCaps(form, caps) {
return { ...form, caps: [...new Set([...(form.caps || []), ...caps])] }
}
// The ticked caps that no sender can use yet. The host lets a sender use a cap
// only when the sender is paired, or has a role the owner granted the cap to
// (a sensitive cap needs a pairing always; see Authorize in
// core/services/studio/connectoraccess.go). So with nobody paired, a ticked
// cap that no role holds is dead, however it is ticked.
export function unusableCaps(form, paired, now = Date.now()) {
const caps = form?.caps || []
const active = (paired || []).some((p) => !p.expires_at || new Date(p.expires_at).getTime() > now)
if (caps.length === 0 || active) return []
const granted = new Set((form.grants || []).flatMap((g) => g.caps || []))
return caps.filter((c) => !granted.has(c))
}
// The form after cap name was turned on or off. Turning one off also takes it
// from every role grant, so a role never holds more than the ceiling.
export function setCap(form, name, on) {
@@ -72,11 +72,11 @@ export function Select({ children, className = '', ...rest }) {
// A switch for an on/off setting. `label` names it for assistive technology;
// when `children` is set, it is drawn as the visible label next to the switch.
export function Switch({ checked, onChange, label, children, testId, disabled }) {
export function Switch({ checked, onChange, label, children, testId, disabled, describedBy }) {
const id = useId()
const button = (
<button type="button" role="switch" className="dk-switch" aria-checked={checked ? 'true' : 'false'}
aria-label={children ? undefined : label} aria-labelledby={children ? id : undefined}
aria-label={children ? undefined : label} aria-labelledby={children ? id : undefined} aria-describedby={describedBy}
data-testid={testId} disabled={disabled} onClick={() => onChange(!checked)} />
)
if (!children) return button
@@ -2,6 +2,7 @@ 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,
AGENT_GRANT_CAPS, unusableCaps, DEFAULT_CONNECTOR_GRANTS, agentHas, agentHeld, copyFromAgent, presetBlock, usualFor, withAddedCaps, withCopiedCaps,
} from '../../../internal/connector-ui/src/connector.js'
const CAPS = [
@@ -77,3 +78,63 @@ test('permissions: a bad channel list blocks saving', () => {
assert.equal(canSave(ok, false), true)
assert.equal(canSave({ ...ok, channels: [{ id: '' }] }, false), false)
})
const ALL = [
{ name: 'proposals.confirm', title: 'Confirm', description: 'x', sensitive: false, usual: true },
{ name: 'memory.write', title: 'Save to memory', description: 'x', sensitive: false, usual: true },
{ name: 'files.upload', title: 'Receive files', description: 'x', sensitive: false, usual: true },
{ name: 'talkback.channels', title: 'Post', description: 'x', sensitive: false, usual: true },
{ name: 'talkback.new_channel', title: 'New channel', description: 'x', sensitive: true, usual: false },
{ name: 'profile.write', title: 'Profile', description: 'x', sensitive: true, usual: false },
{ name: 'tools.write', title: 'Tools', description: 'x', sensitive: false, usual: false },
]
test('presets: the grants of an agent are read from its list, an empty list means the messaging defaults, and no list means unreadable', () => {
assert.deepEqual(agentHeld({ builtins: ['memory.write'] }), ['memory.write'])
assert.deepEqual(agentHeld({ builtins: [] }), DEFAULT_CONNECTOR_GRANTS)
assert.equal(agentHeld({}), null)
assert.equal(agentHeld(undefined), null)
assert.equal(agentHas(null, 'memory.write'), true, 'an agent that cannot be read gates nothing')
assert.equal(agentHas(['files.upload'], 'memory.write'), false)
assert.equal(agentHas(['files.upload'], 'proposals.confirm'), true, 'not a grant of an agent')
assert.equal(AGENT_GRANT_CAPS.includes('proposals.confirm') || AGENT_GRANT_CAPS.includes('tools.write'), false)
})
test('presets: the buttons are blocked with a reason when there is no agent, no readable permissions or nothing to use', () => {
assert.equal(presetBlock('', ['memory.write'], ALL), 'noAgent')
assert.equal(presetBlock('a1', null, ALL), 'unreadable')
assert.equal(presetBlock('a1', ['unknown.grant'], ALL), 'nothing')
assert.equal(presetBlock('a1', ['memory.write'], ALL), '')
})
test('presets: the usual set is limited to what the agent has, and never holds a sensitive cap', () => {
const r = usualFor(ALL, ['memory.write', 'talkback.new_channel', 'profile.write'])
assert.deepEqual(r.caps, ['proposals.confirm', 'memory.write'])
assert.deepEqual(r.skipped, ['files.upload', 'talkback.channels'])
assert.deepEqual(usualFor(ALL, null).skipped, [], 'an agent that cannot be read skips nothing')
assert.deepEqual(withAddedCaps({ caps: ['tools.write', 'memory.write'] }, r.caps).caps, ['tools.write', 'memory.write', 'proposals.confirm'])
})
test('presets: copying the agent takes every cap it holds, names the sensitive ones, and keeps the caps that are not grants', () => {
const c = copyFromAgent(ALL, ['memory.write', 'talkback.new_channel', 'profile.write', 'act.send', 'saved_agents'])
assert.deepEqual(c.caps, ['memory.write', 'talkback.new_channel', 'profile.write'])
assert.deepEqual(c.sensitive, ['talkback.new_channel', 'profile.write'])
assert.deepEqual(copyFromAgent(ALL, ['memory.write']).sensitive, [])
const form = { caps: ['proposals.confirm', 'files.upload', 'tools.write'], grants: [{ role: 'admin', caps: ['files.upload', 'tools.write'] }] }
const next = withCopiedCaps(form, ['memory.write'])
assert.deepEqual(next.caps, ['proposals.confirm', 'tools.write', 'memory.write'], 'files.upload is replaced, the others stay')
assert.deepEqual(next.grants, [{ role: 'admin', caps: ['tools.write'] }], 'a role never keeps a cap the ceiling lost')
assert.deepEqual(form.caps, ['proposals.confirm', 'files.upload', 'tools.write'], 'the form is not changed')
})
test('stuck caps: a ticked cap is dead while nobody is paired and no role holds it', () => {
const now = Date.parse('2026-11-04T10:00:00Z')
const live = [{ sender_id: 'U1', expires_at: '2026-12-01T00:00:00Z' }]
const ended = [{ sender_id: 'U1', expires_at: '2026-11-01T00:00:00Z' }]
const form = { caps: ['memory.write', 'triggers.propose'], grants: [{ role: 'admin', caps: ['memory.write'] }] }
assert.deepEqual(unusableCaps(form, [], now), ['triggers.propose'], 'a role that holds the cap makes it usable')
assert.deepEqual(unusableCaps(form, live, now), [], 'a live pairing makes every cap usable')
assert.deepEqual(unusableCaps(form, ended, now), ['triggers.propose'], 'an ended pairing counts for nothing')
assert.deepEqual(unusableCaps({ caps: [], grants: [] }, [], now), [])
assert.deepEqual(unusableCaps({ caps: ['memory.write'] }, undefined, now), ['memory.write'])
})
+3 -2
View File
@@ -3,8 +3,9 @@
// the sender log in memory.
export const AGENTS = [
{ id: 'a1', name: 'Support', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [] },
{ id: 'a2', name: 'No model', model: '', mcp_servers: [], addons: [], collections: [], skills: [] },
{ id: 'a1', name: 'Support', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel', 'profile.write'] },
{ id: 'a2', name: 'No model', model: '', mcp_servers: [], addons: [], collections: [], skills: [], builtins: [] },
{ id: 'a3', name: 'Careful', model: 'qwen3-8b', mcp_servers: [], addons: [], collections: [], skills: [], builtins: ['memory.write', 'files.upload'] },
]
const NEWCOMER = { id: '42', name: 'Newcomer', count: 2, allowed: false, first: '2026-10-27T08:00:00Z', last: '2026-10-27T08:05:00Z' }
@@ -73,6 +73,117 @@ test.describe('Connector permissions', () => {
await shot(page, 'perm-2-form-switches')
})
test('"Allow the usual set" ticks the usual caps the agent has, disables the ones it lacks and says what it did', async ({ page }) => {
const a = api(page, [{ ...LEGACY, agent_id: 'a3' }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
const box = page.getByTestId('connector-permissions')
// The agent holds two of the permissions; the others are shown off and cannot be switched on.
await expect(box.getByTestId('connector-cap-switch-triggers.propose')).toBeDisabled()
await expect(box.getByTestId('connector-cap-switch-triggers.propose')).toHaveAccessibleDescription('The agent does not have this.')
await expect(box.getByTestId('connector-cap-switch-memory.write')).toBeEnabled()
await box.getByRole('button', { name: 'Allow the usual set' }).click()
await expect(box.getByTestId('connector-cap-switch-memory.write')).toHaveAttribute('aria-checked', 'true')
await expect(box.getByTestId('connector-cap-switch-files.upload')).toHaveAttribute('aria-checked', 'true')
await expect(box.getByTestId('connector-cap-switch-proposals.confirm')).toHaveAttribute('aria-checked', 'true')
await expect(box.getByTestId('connector-cap-switch-triggers.propose')).toHaveAttribute('aria-checked', 'false')
await expect(box.getByTestId('connector-cap-switch-talkback.new_channel')).toHaveAttribute('aria-checked', 'false')
const done = box.getByTestId('connector-preset-done-usual')
await expect(done).toContainText('Ticked 3')
await expect(done).toContainText('Not ticked, because the agent does not have them')
await expect(done).toContainText('Propose triggers')
expect(a.s.calls.some(c => c.method === 'PUT')).toBe(false)
await shot(page, 'perm-4-usual-set')
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.caps.sort()).toEqual(['files.upload', 'memory.write', 'proposals.confirm'])
})
test('"Same as the agent" copies what the agent has, asks first when that includes a sensitive permission, and is not a live link', async ({ page }) => {
const a = api(page, [{ ...LEGACY, caps: ['tools.write'], agent_id: 'a1' }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
const box = page.getByTestId('connector-permissions')
await expect(box).toContainText('does not follow later changes')
await box.getByRole('button', { name: 'Same as the agent' }).click()
const ask = box.getByTestId('connector-same-confirm')
await expect(ask).toBeVisible()
await expect(ask.getByRole('listitem')).toHaveCount(1)
await expect(ask).toContainText('Post to a new channel')
await expect(ask).not.toContainText('Save to memory')
// Nothing changed until the owner confirms.
await expect(box.getByTestId('connector-cap-switch-memory.write')).toHaveAttribute('aria-checked', 'false')
await shot(page, 'perm-5-same-confirm')
await ask.getByRole('button', { name: 'Cancel' }).click()
await expect(ask).toHaveCount(0)
await expect(box.getByTestId('connector-cap-switch-memory.write')).toHaveAttribute('aria-checked', 'false')
await box.getByRole('button', { name: 'Same as the agent' }).click()
await box.getByRole('button', { name: 'Copy them too' }).click()
for (const n of ['memory.write', 'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel']) {
await expect(box.getByTestId(`connector-cap-switch-${n}`)).toHaveAttribute('aria-checked', 'true')
}
// A cap that is not a grant of the agent stays as it was.
await expect(box.getByTestId('connector-cap-switch-tools.write')).toHaveAttribute('aria-checked', 'true')
await expect(box.getByTestId('connector-preset-done-same')).toContainText('Copied 6 permissions of Support')
await shot(page, 'perm-6-same-done')
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.caps.length).toBe(7)
})
test('a copy without sensitive permissions needs no question, and both buttons say why they are off without an agent or without readable permissions', async ({ page }) => {
const a = api(page, [{ ...LEGACY, agent_id: 'a3' }, { ...LEGACY, id: 'b2', name: 'Orphan', agent_id: 'gone' }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).first().click()
const box = page.getByTestId('connector-permissions')
await box.getByRole('button', { name: 'Same as the agent' }).click()
await expect(box.getByTestId('connector-same-confirm')).toHaveCount(0)
await expect(box.getByTestId('connector-preset-done-same')).toContainText('Copied 2 permissions of Careful')
await page.getByRole('button', { name: 'Cancel' }).click()
await page.getByRole('button', { name: 'Edit' }).nth(1).click()
const orphan = page.getByTestId('connector-permissions')
await expect(orphan.getByRole('button', { name: 'Same as the agent' })).toBeDisabled()
await expect(orphan.getByRole('button', { name: 'Allow the usual set' })).toBeDisabled()
await expect(orphan.getByRole('button', { name: 'Allow the usual set' })).toHaveAccessibleDescription('The permissions of this agent could not be read.')
await page.getByLabel('Agent', { exact: true }).selectOption('')
await expect(orphan.getByTestId('connector-preset-reason')).toContainText('Choose an agent first.')
})
test('a link with the id of a binding opens it at its permissions', async ({ page }) => {
await api(page, [{ ...LEGACY, agent_id: 'a3' }]).install()
await page.goto(`${PAGE}?binding=b1`)
await expect(page.getByTestId('connector-permissions')).toBeVisible()
await expect(page.getByRole('form', { name: /Edit/ })).toBeVisible()
})
test('ticked permissions that no sender can use yet are said on the page, with a way to pair, until someone is paired or a role holds them', async ({ page }) => {
const a = api(page, [{ ...LEGACY, agent_id: 'a1', caps: ['triggers.propose', 'memory.write'] }])
await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
const box = page.getByTestId('connector-permissions')
const notice = box.getByTestId('connector-stuck')
await expect(notice).toContainText('You ticked these, but no sender can use them yet')
await expect(notice).toContainText('Propose triggers, Save to memory')
await shot(page, 'perm-7-nobody-paired')
await notice.getByRole('button', { name: 'Pair your account' }).click()
await expect(box.getByTestId('connector-pair-start')).toBeFocused()
})
test('the notice goes away when the binding has a live pairing, and stays when the pairing ended', async ({ page }) => {
await api(page, [SET]).install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
await expect(page.getByTestId('connector-permissions').getByTestId('connector-stuck')).toHaveCount(0)
await page.getByRole('button', { name: 'Cancel' }).click()
const ended = { ...SET, paired: [{ sender_id: 'U001', paired_at: '2026-01-01T10:00:00Z', expires_at: '2026-02-01T10:00:00Z' }], grants: [] }
await api(page, [ended]).install()
await page.reload()
await page.getByRole('button', { name: 'Edit' }).click()
await expect(page.getByTestId('connector-permissions').getByTestId('connector-stuck')).toContainText('Save to memory')
})
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()
+11 -1
View File
@@ -594,7 +594,17 @@ capability, with a description and a mark on the sensitive ones; the role matrix
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
on the everyday capabilities that stay inside LocalAI and are not sensitive. In the
form, **Allow the usual set** ticks only the usual ones that the agent of the binding
holds, and says what it did. **Same as the agent** copies, once, every capability that
the agent holds and that has a binding capability, the sensitive ones too after a
confirmation that lists them; it does not follow later changes to the agent. A
capability the agent does not hold cannot be switched on. Both buttons only fill the form
and are off, with the reason, when the binding has no agent or the permissions of the
agent cannot be read. A link with `?binding=<id>` opens that binding at its permissions.
When capabilities are ticked but nobody is paired and no role holds them, the form says that
no sender can use them yet and points to pairing: the host lets a sender use a capability only
with a pairing or a role that holds it. A new binding still starts with no capability. 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.