fix(connectors): do not list the chosen sender as unchecked

The panel "What this bot can do" showed "Not checked yet: who is
sending" although the sender is the one picked in the selector. It now
lists only the links that were really not checked, and hides the line
when none remain. Capability titles are shown as sentences with the
product name spelled LocalAI. The test host messages now use the words
of the real host, so the specs and the screenshots read as in use.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 16:07:23 +00:00
1 parent 4298715291
commit 59d9c7556e
5 files changed
+49 -15

No files matched your search

@@ -8,7 +8,7 @@ import Icon from '../../host-compat/src/components/Icon'
import { basePath } from '../../host-compat/src/utils/basePath.js'
import { FixNotice, Select } from './controls'
import { connectorsApi } from './api'
import { THEN_CHECK, fixAction, fixLabelKey, pickSubject, previewBody, previewId, previewKey, senderOptions } from './access'
import { fixAction, remainingChecks, sentenceCase, fixLabelKey, pickSubject, previewBody, previewId, previewKey, senderOptions } from './access'
export const CAN_TEXT = {
'can.title': 'What this bot can do',
@@ -152,8 +152,8 @@ function Row({ d, title, s, ctx }) {
<span className="cn-can__title">{title(d.capability)}</span>
{d.allowed ? (via && <span className="cn-muted">{via}</span>) : (<>
<span className="cn-can__why" data-testid="connector-can-why">{d.message || d.reason || s('can.noMessage')}</span>
{(d.then_check || []).length > 0 && (
<span className="cn-muted cn-can__then">{s('can.then', { list: d.then_check.filter((x) => THEN_CHECK.includes(x)).map((x) => s(`can.then.${x}`)).join(', ') })}</span>
{remainingChecks(d.then_check).length > 0 && (
<span className="cn-muted cn-can__then">{s('can.then', { list: remainingChecks(d.then_check).map((x) => s(`can.then.${x}`)).join(', ') })}</span>
)}
</>)}
</div>
@@ -166,7 +166,7 @@ export default function CapabilitiesPanel({ form, kindInfo, capabilities, lists,
const options = senderOptions(form, kindInfo, lists)
const [choice, setChoice] = useState('listed')
const selected = options.some((o) => o.id === choice) ? choice : options[0].id
const title = (name) => (capabilities || []).find((c) => c.name === name)?.title || name
const title = (name) => sentenceCase((capabilities || []).find((c) => c.name === name)?.title || name)
const label = (o) => (o.key === 'role' ? s('can.sender.role', { title: o.title }) : s(`can.sender.${o.key}`))
const subject = pickSubject(preview.view, selected)
const decisions = subject?.decisions || []
+11 -1
View File
@@ -156,6 +156,16 @@ export function fixLabelKey(fix) {
return `fix.${fix.kind}`
}
// A title as a sentence, with the product name right.
export function sentenceCase(text) {
const t = String(text || '').replace(/\blocalai\b/gi, 'LocalAI')
return t.charAt(0).toUpperCase() + t.slice(1)
}
// The links still to check for a blocked row. The sender is known (it is the
// one chosen in the selector), so "sender" is never shown as unchecked.
export const remainingChecks = (thenCheck) => (thenCheck || []).filter((x) => x !== 'sender' && THEN_CHECK.includes(x))
// The links after the first problem that the check did not look at, as the
// words of the page.
export const THEN_CHECK = ['server', 'tools', 'agent', 'connection', 'sender', 'approval']
@@ -233,7 +243,7 @@ export function readinessRows(ctx) {
const on = (n) => caps.includes(n)
const view = preview.status === 'ready' ? preview.view : null
const pending = preview.status === 'loading' || preview.status === undefined ? 'checking' : preview.status === 'error' ? 'unknown' : ''
const titleOf = (n) => capabilities.find((c) => c.name === n)?.title || n
const titleOf = (n) => sentenceCase(capabilities.find((c) => c.name === n)?.title || n)
const afterSave = (row, ...fields) => (row.state === 'ok' && fields.some((f) => changed.has(f)) ? { ...row, state: 'after_save', fix: { kind: 'save', label: 'fix.save' } } : row)
const rows = []
+14 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
changedFields, fixAction, isProblem, pickSubject, previewBody, previewId, readinessRows, reachable, senderOptions, unusable, visibleRows,
changedFields, fixAction, remainingChecks, sentenceCase, isProblem, pickSubject, previewBody, previewId, readinessRows, reachable, senderOptions, unusable, visibleRows,
} from '../../../internal/connector-ui/src/access.js'
import { connectorForm, emptyConnector } from '../../../internal/connector-ui/src/connector.js'
@@ -190,3 +190,16 @@ test('access: changed fields compare the form with the saved binding', () => {
assert.deepEqual([...changedFields({ ...saved, agent_id: 'a2', notify_chat: '1' }, saved)].sort(), ['agent_id', 'notify_chat'])
assert.ok(changedFields(saved, null).has('caps'))
})
test('access: the unchecked links never list the sender, which the selector already names', () => {
assert.deepEqual(remainingChecks(['connection', 'sender']), ['connection'])
assert.deepEqual(remainingChecks(['sender']), [])
assert.deepEqual(remainingChecks(['sender', 'approval']), ['approval'])
assert.deepEqual(remainingChecks(undefined), [])
})
test('access: a title is a sentence with the product name right', () => {
assert.equal(sentenceCase('change things outside localai'), 'Change things outside LocalAI')
assert.equal(sentenceCase('Post to approved channels'), 'Post to approved channels')
assert.equal(sentenceCase(''), '')
})
+13 -5
View File
@@ -50,24 +50,32 @@ const AGENT_GRANTS = ['memory.write', 'files.upload', 'triggers.propose', 'trigg
// A stand-in for the host's access preview: the same shape and the same order
// of checks (agent permission, connection permission, sender trust), written
// from the form values of the request.
// The words the host uses for each capability in its sentences.
const WORDS = {
'proposals.confirm': 'confirming proposals', 'memory.write': 'saving to memory', 'files.upload': 'receiving files',
'triggers.propose': 'reminders and scheduled tasks', 'triggers.run': 'running scheduled tasks',
'talkback.channels': 'writing to other channels', 'talkback.new_channel': 'starting conversations in new channels',
'tools.write': 'changes made by add-on tools',
}
export function previewFor(body, roles = []) {
const agent = AGENTS.find(a => a.id === body.agent_id)
const decide = (c, who) => {
if (!body.agent_id) return { capability: c.name, allowed: false, code: 'no_agent', blocked_by: 'connection', message: 'This connection has no agent.', fix: { kind: 'open_connection', target: 'agent_id' }, then_check: ['sender'] }
if (agent && AGENT_GRANTS.includes(c.name) && !agent.builtins.includes(c.name)) {
return { capability: c.name, allowed: false, code: 'agent_permission_off', blocked_by: 'agent', message: `The agent ${agent.name} does not have permission for ${c.title.toLowerCase()}.`, fix: { kind: 'open_agent', target: agent.id, field: c.name }, then_check: ['connection', 'sender'] }
return { capability: c.name, allowed: false, code: 'agent_permission_off', blocked_by: 'agent', message: `The agent has not been given permission for ${WORDS[c.name]}.`, fix: { kind: 'open_agent', target: agent.id, field: c.name }, then_check: ['connection', 'sender'] }
}
if (!(body.caps || []).includes(c.name)) {
return { capability: c.name, allowed: false, code: 'connection_permission_off', blocked_by: 'connection', message: `This connection does not allow ${c.title.toLowerCase()}.`, fix: { kind: 'open_connection', target: 'caps', field: c.name }, then_check: ['sender'] }
return { capability: c.name, allowed: false, code: 'connection_permission_off', blocked_by: 'connection', message: `This connection does not allow ${WORDS[c.name]}.`, fix: { kind: 'open_connection', target: 'caps', field: c.name }, then_check: ['sender'] }
}
if (who === 'anyone') return { capability: c.name, allowed: false, code: 'channel_not_trusted', blocked_by: 'sender', message: 'A member of a channel is not trusted one by one. Add the person to the allowed people.', fix: { kind: 'add_person', target: 'allow' } }
if (who === 'anyone') return { capability: c.name, allowed: false, code: 'channel_not_trusted', blocked_by: 'sender', message: `This person is let in through a channel, or because the bot accepts anyone, so they are not trusted for ${WORDS[c.name]}. Add them by name or pair them.`, fix: { kind: 'add_person', target: 'allow' } }
if (who.startsWith('role:')) {
const id = who.slice(5)
return (body.grants || []).some(g => g.role === id && g.caps.includes(c.name)) && !c.sensitive
? { capability: c.name, allowed: true, via: who }
: { capability: c.name, allowed: false, code: 'role_not_granted', blocked_by: 'sender', message: 'This role has not been given this permission.', fix: { kind: 'open_connection', target: 'grants' } }
: { capability: c.name, allowed: false, code: 'role_not_granted', blocked_by: 'sender', message: `The role of this person was not given ${WORDS[c.name]}.`, fix: { kind: 'open_connection', target: 'grants' } }
}
if (c.sensitive && who !== 'paired') return { capability: c.name, allowed: false, code: 'sender_needs_pairing', blocked_by: 'sender', message: 'This needs a person you paired with the bot.', fix: { kind: 'pair_sender', target: 'pairing' } }
if (c.sensitive && who !== 'paired') return { capability: c.name, allowed: false, code: 'sender_needs_pairing', blocked_by: 'sender', message: `This person has to be paired with the bot by the owner before ${WORDS[c.name]}.`, fix: { kind: 'pair_sender', target: 'pairing' } }
return { capability: c.name, allowed: true, via: who === 'paired' ? 'paired' : 'allowlist' }
}
const subjects = [
@@ -325,14 +325,17 @@ test.describe('Connector permissions', () => {
// The agent lacks the permission: the first blocker is the agent, and the fix opens the agent in a new tab.
const trig = can.getByTestId('connector-can-triggers.propose')
await expect(trig).toHaveAttribute('data-allowed', 'false')
await expect(trig.getByTestId('connector-can-why')).toContainText('does not have permission')
await expect(trig.getByTestId('connector-can-why')).toContainText('has not been given permission')
await expect(trig.getByTestId('connector-fix')).toHaveAttribute('href', '/app/agents/a3/edit')
await expect(trig.getByTestId('connector-fix')).toHaveAttribute('target', '_blank')
await expect(trig.getByTestId('connector-fix')).toHaveCount(1)
// Not ticked: the fix scrolls to the switch and focuses it.
const files = can.getByTestId('connector-can-files.upload')
await expect(files.getByTestId('connector-can-why')).toContainText('does not allow')
await expect(files).toContainText('Not checked yet')
// The sender is known (the selector), so only the other links are listed; none remain here.
await expect(files).not.toContainText('Not checked yet')
await expect(trig).toContainText('Not checked yet: the permissions of this connection.')
await expect(trig).not.toContainText('who is sending')
await files.getByRole('button', { name: 'Show the setting' }).click()
await expect(page.getByTestId('connector-cap-switch-files.upload')).toBeFocused()
await shot(page, 'can-1-listed')
@@ -357,7 +360,7 @@ test.describe('Connector permissions', () => {
await expect(page.getByTestId('connector-pair-start')).toBeFocused()
// A member of the listed channel is not trusted one by one: add the person.
await can.getByTestId('connector-can-sender').selectOption('channel_member')
await expect(can.getByTestId('connector-can-memory.write').getByTestId('connector-can-why')).toContainText('not trusted one by one')
await expect(can.getByTestId('connector-can-memory.write').getByTestId('connector-can-why')).toContainText('not trusted for saving to memory')
await can.getByTestId('connector-can-memory.write').getByRole('button', { name: 'Add a person' }).click()
await expect(page.getByTestId('connector-allow').getByRole('textbox')).toBeFocused()
})
@@ -483,7 +486,7 @@ test.describe('Connector permissions', () => {
// The agent lacks Propose triggers: the row shows the host sentence and a link to the agent.
const trig = ready.getByTestId('connector-ready-triggers')
await expect(trig).toHaveAttribute('data-state', 'attention')
await expect(trig).toContainText('does not have permission')
await expect(trig).toContainText('has not been given permission')
await expect(trig.getByTestId('connector-fix')).toHaveAttribute('href', '/app/agents/a3/edit')
// Choosing an agent that has it changes the row without a save; the row then works after a save.
await page.getByLabel('Agent', { exact: true }).selectOption('a1')