From 59d9c7556ec29ae5dc30824a6563af4cc3a72547 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 14:16:23 +0000 Subject: [PATCH] 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 --- .../connector-ui/src/CapabilitiesPanel.jsx | 8 ++++---- addons/internal/connector-ui/src/access.js | 12 +++++++++++- addons/telegram/ui/test/access.test.mjs | 15 ++++++++++++++- core/http/react-ui/e2e/connector-fixtures.js | 18 +++++++++++++----- .../react-ui/e2e/connector-permissions.spec.js | 11 +++++++---- 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/addons/internal/connector-ui/src/CapabilitiesPanel.jsx b/addons/internal/connector-ui/src/CapabilitiesPanel.jsx index 2ca2f90242..8d91c05eb5 100644 --- a/addons/internal/connector-ui/src/CapabilitiesPanel.jsx +++ b/addons/internal/connector-ui/src/CapabilitiesPanel.jsx @@ -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 }) { {title(d.capability)} {d.allowed ? (via && {via}) : (<> {d.message || d.reason || s('can.noMessage')} - {(d.then_check || []).length > 0 && ( - {s('can.then', { list: d.then_check.filter((x) => THEN_CHECK.includes(x)).map((x) => s(`can.then.${x}`)).join(', ') })} + {remainingChecks(d.then_check).length > 0 && ( + {s('can.then', { list: remainingChecks(d.then_check).map((x) => s(`can.then.${x}`)).join(', ') })} )} )} @@ -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 || [] diff --git a/addons/internal/connector-ui/src/access.js b/addons/internal/connector-ui/src/access.js index 412ab0b3c9..8e53b3765e 100644 --- a/addons/internal/connector-ui/src/access.js +++ b/addons/internal/connector-ui/src/access.js @@ -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 = [] diff --git a/addons/telegram/ui/test/access.test.mjs b/addons/telegram/ui/test/access.test.mjs index e2f58d71b7..b422b2c393 100644 --- a/addons/telegram/ui/test/access.test.mjs +++ b/addons/telegram/ui/test/access.test.mjs @@ -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(''), '') +}) diff --git a/core/http/react-ui/e2e/connector-fixtures.js b/core/http/react-ui/e2e/connector-fixtures.js index 42396d10cf..7ea5393f7b 100644 --- a/core/http/react-ui/e2e/connector-fixtures.js +++ b/core/http/react-ui/e2e/connector-fixtures.js @@ -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 = [ diff --git a/core/http/react-ui/e2e/connector-permissions.spec.js b/core/http/react-ui/e2e/connector-permissions.spec.js index fd4a0b8639..cdcca89bb3 100644 --- a/core/http/react-ui/e2e/connector-permissions.spec.js +++ b/core/http/react-ui/e2e/connector-permissions.spec.js @@ -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')