From e7b5e5ad36142b257c2c6c4bed58357397f4177e Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 11:32:47 +0000 Subject: [PATCH] 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 --- core/http/react-ui/e2e/addons-fixtures.js | 2 +- .../e2e/settings-connector-files.spec.js | 45 +++++++++++++++++++ .../settings/ConnectorFilesSettings.jsx | 45 +++++++++++++++++++ core/http/react-ui/src/pages/Settings.jsx | 2 + core/http/react-ui/src/utils/fileSize.js | 20 +++++++++ .../react-ui/src/utils/hostSettingsClient.js | 7 ++- .../react-ui/tests/unit/hostsettings.test.mjs | 45 +++++++++++++++++-- 7 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 core/http/react-ui/e2e/settings-connector-files.spec.js create mode 100644 core/http/react-ui/src/components/settings/ConnectorFilesSettings.jsx create mode 100644 core/http/react-ui/src/utils/fileSize.js diff --git a/core/http/react-ui/e2e/addons-fixtures.js b/core/http/react-ui/e2e/addons-fixtures.js index 595949b933..776dbc76e5 100644 --- a/core/http/react-ui/e2e/addons-fixtures.js +++ b/core/http/react-ui/e2e/addons-fixtures.js @@ -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) diff --git a/core/http/react-ui/e2e/settings-connector-files.spec.js b/core/http/react-ui/e2e/settings-connector-files.spec.js new file mode 100644 index 0000000000..7f540df6d6 --- /dev/null +++ b/core/http/react-ui/e2e/settings-connector-files.spec.js @@ -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') + }) +}) diff --git a/core/http/react-ui/src/components/settings/ConnectorFilesSettings.jsx b/core/http/react-ui/src/components/settings/ConnectorFilesSettings.jsx new file mode 100644 index 0000000000..b4ebb32eb4 --- /dev/null +++ b/core/http/react-ui/src/components/settings/ConnectorFilesSettings.jsx @@ -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
+ +

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)}.}

+ {files?.inline_bytes > 0 &&

Files up to {formatFileSize(files.inline_bytes)} are sent now; larger files need the add-on to support streaming.

} + {bad &&

Use a size from {formatFileSize(MIN_BYTES)} to {formatFileSize(MAX_BYTES)}, or leave it empty.

} + {owner.error &&
Owner settings unavailable or changed: {owner.error.message} + + {owner.uncertain && } +
} + +
+} diff --git a/core/http/react-ui/src/pages/Settings.jsx b/core/http/react-ui/src/pages/Settings.jsx index d8269a370b..c6f9eafa3b 100644 --- a/core/http/react-ui/src/pages/Settings.jsx +++ b/core/http/react-ui/src/pages/Settings.jsx @@ -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() {

{active.label}{active.hint}

{group === 'backends' && settings.addon_host_settings_authority && } + {group === 'backends' && settings.addon_host_settings_authority && } {group === 'memory' && } {group === 'access' && (

Sign-in, users, invites and per-user API keys are under Users and keys when authentication is on.

diff --git a/core/http/react-ui/src/utils/fileSize.js b/core/http/react-ui/src/utils/fileSize.js new file mode 100644 index 0000000000..accc8d0c33 --- /dev/null +++ b/core/http/react-ui/src/utils/fileSize.js @@ -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) +} diff --git a/core/http/react-ui/src/utils/hostSettingsClient.js b/core/http/react-ui/src/utils/hostSettingsClient.js index d9b2aaccb7..9bc24d86a2 100644 --- a/core/http/react-ui/src/utils/hostSettingsClient.js +++ b/core/http/react-ui/src/utils/hostSettingsClient.js @@ -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 diff --git a/core/http/react-ui/tests/unit/hostsettings.test.mjs b/core/http/react-ui/tests/unit/hostsettings.test.mjs index 54bb9d852c..6dcf9347b7 100644 --- a/core/http/react-ui/tests/unit/hostsettings.test.mjs +++ b/core/http/react-ui/tests/unit/hostsettings.test.mjs @@ -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 } +})