feat(settings): set the host ceiling of connector files

The owner policy key connector_files_ceiling_bytes gets a field on the
Settings page, next to the add-on upgrade setting. It states the ceiling
in effect and that larger files need the add-on to support streaming.
The host settings client accepts the new key as a number.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:47:51 +00:00
1 parent 2249c51c4c
commit e7b5e5ad36
7 files changed
+159 -7

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ export async function stubAddonRoutes(page) {
const HOST_POLICY_DEFAULTS = {
chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false,
chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '',
chats_tool_providers: {}, auto_upgrade_addons: false,
chats_tool_providers: {}, auto_upgrade_addons: false, connector_files_ceiling_bytes: 0,
}
export function hostPolicy(over = {}) {
const policy = structuredClone(HOST_POLICY_DEFAULTS)
@@ -0,0 +1,45 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSettings } from './settings-fixtures.js'
import { mockAddons } from './addons-fixtures.js'
// The host ceiling of connector files is an owner policy key. The Settings page
// writes it through host-settings-update, like the add-on upgrade setting, and
// states the numbers in effect that the connector list reports.
const MIB = 1024 * 1024
const SHOTS = process.env.UI_SHOTS_DIR
async function open(page, ceiling = 0) {
await mockSettings(page, { addon_host_settings_authority: 'owner_required' })
const state = await mockAddons(page, { hostSettings: { connector_files_ceiling_bytes: ceiling } })
await page.route(/\/api\/chats\/connectors\/kinds$/, route => route.fulfill({ json: { kinds: [{
addon: 'telegram', kind: 'telegram', title: 'Telegram',
files: { inline_bytes: MIB, host_ceiling_bytes: state.hostSettings.policy.connector_files_ceiling_bytes || 100 * MIB, receive: {}, send: {} },
}] } }))
await page.goto('/app/settings')
await page.getByRole('button', { name: /^Backends and galleries/ }).click()
return state
}
test.describe('Settings: the host ceiling of connector files', () => {
test('says what is in effect and that larger files need streaming', async ({ page }) => {
await open(page)
const box = page.getByTestId('owner-connector-files')
await expect(box).toContainText('Now: 100 MiB.')
await expect(page.getByTestId('connector-files-inline')).toHaveText('Files up to 1 MiB are sent now; larger files need the add-on to support streaming.')
await expect(box.getByRole('button', { name: 'Apply file ceiling' })).toBeDisabled()
if (SHOTS) await box.scrollIntoViewIfNeeded().then(() => page.screenshot({ path: `${SHOTS}/settings-ceiling-${process.env.UI_SHOT_NAME || 'light'}.png` }))
})
test('writes the ceiling in bytes as its own owner key, and refuses a size that is too small', async ({ page }) => {
const state = await open(page)
const input = page.getByTestId('connector-files-ceiling')
await input.fill('0.0001')
await expect(page.getByTestId('owner-connector-files')).toContainText('Use a size from 1 KiB to 1 TiB')
await expect(page.getByRole('button', { name: 'Apply file ceiling' })).toBeDisabled()
await input.fill('5')
await page.getByRole('button', { name: 'Apply file ceiling' }).click()
await expect.poll(() => state.log.hostSettings).toEqual([{ connector_files_ceiling_bytes: 5 * MIB }])
await expect(input).toHaveValue('5')
})
})
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from 'react'
import { createHostSettingsClient } from '../../utils/hostSettingsClient'
import { createAddonsApi } from '../../utils/addonsApi'
import { chatConnectorsApi } from '../../utils/api'
import { apiUrl } from '../../utils/basePath'
import { bytesToMib, formatFileSize, mibToBytes } from '../../utils/fileSize'
import { useHostSettings } from '../../hooks/useHostSettings'
const MIN_BYTES = 1024 // mirrors hostsettings.MinFilesCeiling
const MAX_BYTES = 2 ** 40
// The host ceiling of the files that connector add-ons move: the owner policy
// key connector_files_ceiling_bytes. No bot can go above it. The numbers in
// effect come from the server, so the page names no limit of its own.
export default function ConnectorFilesSettings() {
const client = useMemo(() => createHostSettingsClient({ api: createAddonsApi({ base: apiUrl }) }), [])
const owner = useHostSettings(client, 'connector-files')
const [files, setFiles] = useState(null)
const [text, setText] = useState('')
const saved = owner.snapshot?.policy.connector_files_ceiling_bytes
useEffect(() => {
chatConnectorsApi.kinds().then((r) => setFiles(r.kinds?.[0]?.files || null)).catch(() => {})
}, [saved])
useEffect(() => { if (saved !== undefined) setText(bytesToMib(saved)) }, [saved])
const bytes = mibToBytes(text)
const bad = text.trim() !== '' && (bytes < MIN_BYTES || bytes > MAX_BYTES)
return <section aria-label="Connector files" data-testid="owner-connector-files">
<label>Largest file a connector may move, in MiB{' '}
<input className="dk-input" type="number" min="0" step="any" inputMode="decimal" value={text} data-testid="connector-files-ceiling"
placeholder="Default" disabled={!owner.snapshot || owner.busy || !!owner.error} onChange={e => setText(e.target.value)} />
</label>
<p className="st-note">No bot can send or read a larger file, whatever its add-on or its owner setting says. Leave it empty for the default.
{files?.host_ceiling_bytes > 0 && <> Now: {formatFileSize(files.host_ceiling_bytes)}.</>}</p>
{files?.inline_bytes > 0 && <p className="st-note" data-testid="connector-files-inline">Files up to {formatFileSize(files.inline_bytes)} are sent now; larger files need the add-on to support streaming.</p>}
{bad && <p className="st-note" role="alert">Use a size from {formatFileSize(MIN_BYTES)} to {formatFileSize(MAX_BYTES)}, or leave it empty.</p>}
{owner.error && <div role="alert">Owner settings unavailable or changed: {owner.error.message}
<button type="button" disabled={owner.busy} onClick={owner.reload}>Reload and rebase edits</button>
{owner.uncertain && <button type="button" disabled={owner.busy} onClick={() => owner.save({})}>Retry same operation</button>}
</div>}
<button type="button" disabled={!owner.snapshot || owner.busy || !!owner.error || bad || bytes === saved}
onClick={() => owner.save({ connector_files_ceiling_bytes: bytes })}>Apply file ceiling</button>
</section>
}
@@ -11,6 +11,7 @@ import HomeUndoToast from '../components/home/HomeUndoToast'
import SettingField from '../components/settings/SettingField'
import BuiltinToolsSection from '../components/settings/BuiltinToolsSection'
import AddonUpgradeSettings from '../components/settings/AddonUpgradeSettings'
import ConnectorFilesSettings from '../components/settings/ConnectorFilesSettings'
import OwnerBuiltinToolsSection from '../components/settings/OwnerBuiltinToolsSection'
import PendingBar from '../components/settings/PendingBar'
import HistorySheet from '../components/settings/HistorySheet'
@@ -422,6 +423,7 @@ export default function Settings() {
<section aria-labelledby="st-group-title">
<h2 className="st-h2" id="st-group-title">{active.label}<span className="st-sub">{active.hint}</span></h2>
{group === 'backends' && settings.addon_host_settings_authority && <AddonUpgradeSettings />}
{group === 'backends' && settings.addon_host_settings_authority && <ConnectorFilesSettings />}
{group === 'memory' && <MemoryNow resources={resources} settings={settings} onRefresh={fetchResources} />}
{group === 'access' && (
<p className="st-note">Sign-in, users, invites and per-user API keys are under Users and keys when authentication is on.</p>
+20
View File
@@ -0,0 +1,20 @@
const MIB = 1024 * 1024
// A size as the connector sentences write it: bytes, KiB, MiB, GiB or TiB.
export function formatFileSize(n) {
if (n === 1) return '1 byte'
if (n < 1024) return `${n} bytes`
const units = ['KiB', 'MiB', 'GiB', 'TiB']
let v = n / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${Number(v.toFixed(1))} ${units[i]}`
}
// The ceiling is typed in MiB and stored in bytes. Empty means the default.
export const bytesToMib = (bytes) => (bytes > 0 ? String(bytes / MIB) : '')
export function mibToBytes(text) {
const t = String(text ?? '').trim()
const n = Number(t)
return t === '' || !Number.isFinite(n) ? 0 : Math.round(n * MIB)
}
+5 -2
View File
@@ -8,19 +8,22 @@ export class HostSettingsError extends Error {
const POLICY_STRINGS = ['chats_egress_ceiling', 'chats_egress_default', 'chats_egress_ports', 'chats_egress_extra_registries', 'chats_egress_allowed_hosts']
const POLICY_BOOLEANS = ['chats_egress_allow_private', 'auto_upgrade_addons']
const POLICY_KEYS = [...POLICY_STRINGS, ...POLICY_BOOLEANS, 'chats_tool_providers']
const POLICY_NUMBERS = ['connector_files_ceiling_bytes']
const MAX_FILES_CEILING = 2 ** 40 // mirrors contract.MaxDeclaredFileBytes
const POLICY_KEYS = [...POLICY_STRINGS, ...POLICY_BOOLEANS, ...POLICY_NUMBERS, 'chats_tool_providers']
const DEFINITE_ERRORS = ['invalid', 'revision_conflict', 'operation_mismatch']
const plain = value => value !== null && typeof value === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(value))
const exact = (value, keys) => plain(value) && Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
const byteLength = value => new TextEncoder().encode(value).length
function validPolicy(policy) {
// Policy's Go JSON tags have no omitempty; MarshalJSON emits {} for pins.
// Policy.MarshalJSON writes every key (zero means the default) and emits {} for pins.
// Do not turn missing/unknown fields into frontend defaults. A future owner
// schema needs an explicit client migration rather than a partial snapshot.
if (!exact(policy, POLICY_KEYS)) return false
if (!POLICY_STRINGS.every(key => typeof policy[key] === 'string' && policy[key].length <= 8192 && byteLength(policy[key]) <= 8192)) return false
if (!POLICY_BOOLEANS.every(key => typeof policy[key] === 'boolean')) return false
if (!POLICY_NUMBERS.every(key => Number.isSafeInteger(policy[key]) && policy[key] >= 0 && policy[key] <= MAX_FILES_CEILING)) return false
if (!['chats_egress_ceiling', 'chats_egress_default'].every(key => ['', 'off', 'registries', 'all'].includes(policy[key].trim()))) return false
const pins = policy.chats_tool_providers
if (!plain(pins) || Reflect.ownKeys(pins).length > 128) return false
@@ -16,8 +16,8 @@ globalThis.document = dom.window.document
globalThis.IS_REACT_ACT_ENVIRONMENT = true
// React DOM detects input event support at import time, after JSDOM exists.
const { createRoot } = await import('react-dom/client')
// All eight fields are emitted by Go Policy.MarshalJSON (no omitempty).
const defaults = { chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false, chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '', chats_tool_providers: {}, auto_upgrade_addons: false }
// All nine fields are emitted by Go Policy.MarshalJSON.
const defaults = { chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false, chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '', chats_tool_providers: {}, auto_upgrade_addons: false, connector_files_ceiling_bytes: 0 }
const dto = (revision = 1, policy = {}) => ({ schema_version: 1, revision, policy: { ...defaults, ...policy } })
const reply = data => new Response(JSON.stringify({ payload: { data } }), { status: 200 })
@@ -95,6 +95,7 @@ test('owner client rejects partial, mistyped, unknown and oversized owner polici
...Object.keys(defaults).map(key => { const value = dto(); delete value.policy[key]; return value }),
...Object.keys(defaults).map(key => dto(1, { [key]: null })),
dto(1, { chats_egress_allow_private: 'false' }), dto(1, { auto_upgrade_addons: 0 }),
dto(1, { connector_files_ceiling_bytes: '5' }), dto(1, { connector_files_ceiling_bytes: -1 }), dto(1, { connector_files_ceiling_bytes: 1.5 }), dto(1, { connector_files_ceiling_bytes: 2 ** 40 + 1 }),
dto(1, { chats_egress_ports: 443 }), dto(1, { chats_egress_ceiling: 'future' }),
dto(1, { chats_egress_allowed_hosts: 'é'.repeat(4097) }),
dto(1, { chats_tool_providers: new Date() }), dto(1, { future_owner_guard: false }),
@@ -262,15 +263,16 @@ async function providerRender() {
if (!id.startsWith('\0provider-stub:')) return
if (id.endsWith('/UnsavedChangesGuard')) return 'export default function Guard() { return null }'
if (id.endsWith('react-i18next')) return 'export const useTranslation = () => ({ t: key => key, i18n: { language: "en" } })'
return 'export const chatBuiltinToolsApi = { list: () => globalThis.providerCatalog() }; export const settingsApi = { get: () => fetch("/api/settings"), save: () => fetch("/api/settings") }'
return 'export const chatBuiltinToolsApi = { list: () => globalThis.providerCatalog() }; export const chatConnectorsApi = { kinds: () => globalThis.connectorKinds() }; export const settingsApi = { get: () => fetch("/api/settings"), save: () => fetch("/api/settings") }'
},
}] })
const { default: Form } = await server.ssrLoadModule('/src/components/hostcontrols/HostControlsForm.jsx')
await server.ssrLoadModule('/src/components/hostcontrols/ToolProvidersSettings.jsx')
const { default: Skills } = await server.ssrLoadModule('/src/components/addons/AddonSkills.jsx')
const { default: Upgrades } = await server.ssrLoadModule('/src/components/settings/AddonUpgradeSettings.jsx')
const { default: ConnectorFiles } = await server.ssrLoadModule('/src/components/settings/ConnectorFilesSettings.jsx')
const root = createRoot(document.getElementById('root'))
return { Form, Skills, Upgrades, async render(Component, props) { await act(async () => root.render(React.createElement(Component, props))) }, async close() { await act(async () => root.unmount()); await server.close() } }
return { Form, Skills, Upgrades, ConnectorFiles, async render(Component, props) { await act(async () => root.render(React.createElement(Component, props))) }, async close() { await act(async () => root.unmount()); await server.close() } }
}
const buttonNamed = text => [...document.querySelectorAll('button')].find(b => b.textContent === text)
const skillSwitch = name => document.querySelector(`[data-testid="app-skill-${name}-switch"]`)
@@ -382,3 +384,38 @@ test('mounted skills retain conflict edits, require visible rebase and retry unc
assert.equal(calls.filter(c => c.action === 'host-settings-update').length, 2)
} finally { await h.close(); globalThis.fetch = previous }
})
test('mounted connector file ceiling states the size in effect, validates, and writes only its own owner key', async () => {
const previous = globalThis.fetch, calls = []
let revision = 3, ceiling = 0
globalThis.fetch = async (url, opts) => {
assert.equal(url, '/api/addons/dispatch')
const body = JSON.parse(opts.body); calls.push(body)
if (body.action === 'host-settings-update') {
assert.deepEqual(body.arguments, { expectedRevision: 3, patch: { connector_files_ceiling_bytes: 5 * 1024 * 1024 } })
ceiling = body.arguments.patch.connector_files_ceiling_bytes; revision++
}
return reply(dto(revision, { connector_files_ceiling_bytes: ceiling }))
}
globalThis.connectorKinds = async () => ({ kinds: [{ kind: 'x', files: { inline_bytes: 1024 * 1024, host_ceiling_bytes: ceiling || 100 * 1024 * 1024 } }] })
const h = await providerRender()
try {
await h.render(h.ConnectorFiles, {})
const input = () => document.querySelector('[data-testid="connector-files-ceiling"]')
const setValue = async value => {
const setter = Object.getOwnPropertyDescriptor(globalThis.window.HTMLInputElement.prototype, 'value').set
await act(async () => { setter.call(input(), value); input().dispatchEvent(new globalThis.window.Event('input', { bubbles: true })) })
}
assert.match(document.body.textContent, /Now: 100 MiB\./)
assert.match(document.querySelector('[data-testid="connector-files-inline"]').textContent, /Files up to 1 MiB are sent now; larger files need the add-on to support streaming\./)
assert.equal(buttonNamed('Apply file ceiling').disabled, true, 'nothing changed')
await setValue('0.0001')
assert.match(document.body.textContent, /Use a size from 1 KiB to 1 TiB/)
assert.equal(buttonNamed('Apply file ceiling').disabled, true)
await setValue('5')
await act(async () => buttonNamed('Apply file ceiling').click())
assert.equal(ceiling, 5 * 1024 * 1024)
assert.match(document.body.textContent, /Now: 5 MiB\./)
assert.equal(input().value, '5')
} finally { await h.close(); globalThis.fetch = previous; delete globalThis.connectorKinds }
})