feat(ui): name the network from the add-on's declaration

The Network row of the chat Tools menu, the consent and blocked cards and
the network-policy block on an add-on's Settings tab take the network
title and the preset titles from the API (network, as the add-on
declares it) and no longer say "Sandbox". The setting labels are
neutral. A second add-on with another ID and title gets the same row,
card and block.

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 10:58:46 +00:00
1 parent f9fdd167a3
commit b930e55528
6 files changed
+83 -23

No files matched your search

+40 -4
View File
@@ -3,7 +3,7 @@ import { mockChat } from './chat-fixtures.js'
import { mockSettings } from './settings-fixtures.js'
import { app, installedAddon, mockAddons } from './addons-fixtures.js'
// The network of a chat's Sandbox commands: the Network row of the Tools menu,
// The network of a chat's commands: the Network row of the Tools menu,
// the card a blocked host asks with, and the network-policy host control on
// the Settings tab of the add-on that runs commands. The control is drawn for
// any add-on the apps list entitles to it: the specs use the Sandbox and a
@@ -32,7 +32,7 @@ const DETAIL = {
}
const sandbox = { addons: [{ id: 'sandbox', tools: 2, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated', execution_status: { readiness: 'available', isolation: 'isolated' } }
const view = (over = {}) => ({
preset: 'off', ceiling: 'registries', effective: 'off', applies: true, applies_reason: '', addon: 'sandbox',
preset: 'off', ceiling: 'registries', effective: 'off', applies: true, applies_reason: '', addon: 'sandbox', network: { title: 'Sandbox', presets: [] },
registries: [{ id: 'pypi', title: 'Python (PyPI)', hosts: ['pypi.org', 'files.pythonhosted.org'] }], allowed_hosts: [], rules: [], cards: [], ...over,
})
@@ -77,7 +77,7 @@ async function stubChat(page, { egress, running = false, messages = [] }) {
return { state, push }
}
test.describe('Network of the Sandbox commands', () => {
test.describe('Network of the commands', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
test('the Tools menu has a Network row up to the limit, and a panel with the rules', async ({ page }) => {
@@ -241,6 +241,42 @@ test.describe('Network of the Sandbox commands', () => {
})
})
// A second add-on, with another ID and title than the shipped Sandbox, that
// declares capabilities.network. The host draws the same row and card from its
// declaration: its title in the text and its titles for the presets.
test.describe('The network of a second add-on that declares it', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
const declared = { title: 'Runbox', presets: [{ id: 'off', title: 'Closed' }, { id: 'all', title: 'Open web' }] }
const card = { id: 'k9', host: 'example.com', port: 443, kind: 'connect', state: 'pending', asked_at: '2026-10-30T10:01:00Z' }
const runbox = (over = {}) => view({ addon: 'devbox', network: declared, ceiling: 'all', ...over })
test('the Tools menu names the preset buttons from the declaration', async ({ page }) => {
await stubChat(page, { egress: runbox() })
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
await expect(page.getByTestId('chat-network-off')).toHaveText('Closed')
await expect(page.getByTestId('chat-network-all')).toHaveText('Open web')
await expect(page.getByTestId('chat-network-registries')).toHaveText('Package registries')
await expect(page.getByTestId('chat-network')).toContainText('Commands reach no network.')
await expect(page.getByTestId('chat-network')).not.toContainText('Sandbox')
})
test('a blocked host explains the limit without naming another add-on', async ({ page }) => {
await stubChat(page, { egress: runbox({ ceiling: 'off', cards: [{ ...card, above_ceiling: true }] }), running: true })
await page.goto('/app/chats/c1')
const blocked = page.getByTestId('chat-network-blocked')
await expect(blocked).toContainText('Network access is off for commands on this server.', { timeout: 15_000 })
await expect(blocked).not.toContainText('Sandbox')
})
test('the consent card asks with the preset titles of the add-on', async ({ page }) => {
await stubChat(page, { egress: runbox({ preset: 'off', cards: [card] }), running: true })
await page.goto('/app/chats/c1')
await expect(page.getByTestId('chat-network-consent')).toContainText('This chat: Closed. Limit set by your administrator: Open web.', { timeout: 15_000 })
await expect(page.getByTestId('chat-network-consent-once')).toBeVisible()
})
})
test.describe('A card the administrator\'s limit cannot admit', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
const card = { id: 'k9', host: 'example.com', port: 443, kind: 'connect', state: 'pending', asked_at: '2026-10-30T10:01:00Z' }
@@ -300,7 +336,7 @@ async function stubNetworkAdmin(page, { settings = {}, egress = {}, events = [],
{ id: 'web_search', label: 'web_search', description: 'Search the web', setting: 'chats_web_search', provider: 'builtin', candidates: [] },
], skills: [] } }))
await page.route('**/api/chats/admin/egress', route => route.fulfill({
json: { ceiling: 'registries', default: 'off', allow_private: false, ports: [80, 443], extra_registries: [], allowed_hosts: [], audit_days: 30, registries: [], sandbox: 'sandbox', isolation: 'isolated', allowed_24h: 0, blocked_24h: 0, ...egress },
json: { ceiling: 'registries', default: 'off', allow_private: false, ports: [80, 443], extra_registries: [], allowed_hosts: [], audit_days: 30, registries: [], sandbox: 'sandbox', network: { title: 'Sandbox', presets: [] }, isolation: 'isolated', allowed_24h: 0, blocked_24h: 0, ...egress },
}))
await page.route(/\/api\/chats\/admin\/egress\/events(\?.*)?$/, route => route.fulfill({ json: { events } }))
return state
@@ -6,7 +6,7 @@ import Button from '../ui/Button'
import Callout from '../ui/Callout'
import { useAuth } from '../../context/AuthContext'
import { chatEgressApi } from '../../utils/api'
import { cardAnswers, cardStateKey, hostPort } from '../../utils/chatEgress'
import { cardAnswers, cardStateKey, hostPort, presetLabel } from '../../utils/chatEgress'
import { addonSettingsAddress } from '../../utils/addonSettings'
import { useAddonTitle } from '../../addons/useAddonRegistry'
import './network.css'
@@ -70,7 +70,7 @@ export default function NetworkConsentCard({ card, chatId, egress }) {
return (
<Callout tone="warn" icon="globe" className="cmd-consent" role="status" title={t(off ? 'egress.blocked.titleOff' : 'egress.blocked.titleCapped')} testId="chat-network-blocked">
<p>
{t(off ? 'egress.blocked.bodyOff' : 'egress.blocked.bodyCapped', { limit: t(`egress.preset.${egress?.ceiling || 'off'}`) })}{' '}
{t(off ? 'egress.blocked.bodyOff' : 'egress.blocked.bodyCapped', { limit: presetLabel(egress, egress?.ceiling || 'off', t) })}{' '}
<code className="chat-network__host" data-testid="chat-network-consent-host">{target}</code>
</p>
<AdminHint isAdmin={isAdmin} egress={egress} testId="chat-network-blocked-admin" />
@@ -90,7 +90,7 @@ export default function NetworkConsentCard({ card, chatId, egress }) {
<p>{t('egress.consent.body')} <code className="chat-network__host" data-testid="chat-network-consent-host">{target}</code></p>
{egress && (
<p className="cmd-consent__hint">
{t('egress.consent.preset', { preset: t(`egress.preset.${egress.preset || 'off'}`), limit: t(`egress.preset.${egress.ceiling || 'off'}`) })}
{t('egress.consent.preset', { preset: presetLabel(egress, egress.preset || 'off', t), limit: presetLabel(egress, egress.ceiling || 'off', t) })}
</p>
)}
{card.private && !isAdmin && <p className="cmd-consent__hint" data-testid="chat-network-consent-admin">{t('egress.consent.adminOnly')}</p>}
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import Button from '../ui/Button'
import { useAuth } from '../../context/AuthContext'
import { AdminHint, CardAnswerButtons } from './NetworkConsentCard'
import { askedAboveLimit, cardAnswers, ceilingKind, cardStateKey, hostPort, networkMode, presetChoices, sortedCards } from '../../utils/chatEgress'
import { askedAboveLimit, cardAnswers, ceilingKind, cardStateKey, hostPort, networkMode, presetChoices, presetLabel, sortedCards } from '../../utils/chatEgress'
import './network.css'
// The Network row of the chat's Tools menu: how much of the network the
@@ -22,7 +22,7 @@ export default function NetworkRow({ egress }) {
if (mode === 'host' || mode === 'unverified') return (
<div className="cmd-notice__line" data-testid="chat-network-unenforced">
<p>{t(mode === 'host' ? 'egress.hostNetwork' : 'egress.unverified')}</p>
<p>{t('egress.configuredPolicy', { preset: t(`egress.preset.${egress.preset || 'off'}`) })}</p>
<p>{t('egress.configuredPolicy', { preset: presetLabel(egress, egress.preset || 'off', t) })}</p>
</div>
)
const choices = presetChoices(egress)
@@ -41,7 +41,7 @@ export default function NetworkRow({ egress }) {
disabled={c.limited} title={c.limited ? t('egress.limited') : t(`egress.presetHint.${c.id}`)}
onClick={() => { if (c.id !== egress.preset) egress.setPreset(c.id) }} data-testid={`chat-network-${c.id}`}
>
{t(`egress.preset.${c.id}`)}
{c.title || t(`egress.preset.${c.id}`)}
</button>
))}
</div>
@@ -51,12 +51,12 @@ export default function NetworkRow({ egress }) {
? t('egress.blocked.rowOff')
: <>
{t(`egress.presetHint.${egress.effective || 'off'}`)}
{kind === 'capped' && <> {t('egress.limitedBy', { limit: t(`egress.preset.${egress.ceiling}`) })}</>}
{kind === 'capped' && <> {t('egress.limitedBy', { limit: presetLabel(egress, egress.ceiling, t) })}</>}
</>}
</p>
{asked && (
<p className="chat-network__note" data-testid="chat-network-asks">
{t('egress.asksAbove', { choice: t(`egress.preset.${asked}`), limit: t(`egress.preset.${egress.ceiling || 'off'}`) })}
{t('egress.asksAbove', { choice: presetLabel(egress, asked, t), limit: presetLabel(egress, egress.ceiling || 'off', t) })}
</p>
)}
<p className="chat-network__note" data-testid="chat-network-limit-note">{t('egress.limitNote')}</p>
@@ -5,14 +5,14 @@ import { FIELD_BY_KEY } from '../../utils/settingsSchema'
import { NETWORK_POLICY_FIELDS } from '../../addons/hostControls'
// The network-policy control on an add-on's Settings tab: the network of the
// commands the add-on runs. The same section and rows the Settings page drew
// commands the add-on runs, named from the title it declares (app.network). The same section and rows the Settings page drew
// before, bound to the same runtime settings and saved with the form around
// them.
export default function NetworkPolicySettings({ settings, initial, onChange, pending }) {
export default function NetworkPolicySettings({ app, settings, initial, onChange, pending }) {
return (
<div className="ad-host-control" data-testid="host-control-network-policy">
<p className="st-note">Allowed hosts accept exact names, *.example.com (descendants only; add example.com separately for the apex), or * (public destinations only). Wildcards still obey the ceiling, allowed ports and private-address guard. Extra registries require exact names. The owner validates every change.</p>
<EgressSection settings={settings} initial={initial} onChange={onChange} showKey={false} />
<EgressSection settings={settings} initial={initial} onChange={onChange} showKey={false} provider={app?.network?.title || app?.title || ''} />
<div className="st-list">
{NETWORK_POLICY_FIELDS.filter(key => key !== 'chats_egress_audit_days').map(key => FIELD_BY_KEY[key] && (
<SettingField key={key} field={FIELD_BY_KEY[key]} settings={settings} onChange={onChange} pending={pending.has(key)} showKey={false} />
+16 -3
View File
@@ -23,12 +23,25 @@ export function askedAboveLimit(egress) {
return aboveCeiling(preset, egress?.ceiling || 'off') ? preset : ''
}
// The three choices of the Network row: each with whether it is what applies
// and whether the limit blocks it.
// The title the add-on that runs the commands declares for a preset, or ''
// when it declares none (the page then uses its own text).
export function declaredPresetTitle(egress, id) {
const list = egress?.network?.presets
return (Array.isArray(list) && list.find(p => p.id === id)?.title) || ''
}
// The three choices of the Network row: each with whether it is what applies,
// whether the limit blocks it and the title the add-on declares for it ('' for
// the page's own text).
export function presetChoices(egress) {
const ceiling = egress?.ceiling || 'off'
const chosen = appliedPreset(egress)
return PRESETS.map(p => ({ id: p, chosen: p === chosen, limited: aboveCeiling(p, ceiling) }))
return PRESETS.map(p => ({ id: p, chosen: p === chosen, limited: aboveCeiling(p, ceiling), title: declaredPresetTitle(egress, p) }))
}
// The text of a preset: the add-on's title for it, else the page's own.
export function presetLabel(egress, id, t) {
return declaredPresetTitle(egress, id) || t(`egress.preset.${id}`)
}
// What the Tools menu shows: the row, a line saying the settings do not
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { aboveCeiling, askedAboveLimit, ceilingKind, cardAnswers, cardStateKey, cardsByTool, networkMode, presetChoices, sortedCards } from '../../src/utils/chatEgress.js'
import { aboveCeiling, askedAboveLimit, ceilingKind, cardAnswers, cardStateKey, cardsByTool, declaredPresetTitle, networkMode, presetChoices, presetLabel, sortedCards } from '../../src/utils/chatEgress.js'
import { payloadFor, runChecks, EGRESS_CONFIRM_KEY, EGRESS_PRIVATE_PHRASE } from '../../src/utils/settingsPending.js'
import { FIELD_BY_KEY } from '../../src/utils/settingsSchema.js'
@@ -10,13 +10,24 @@ test('chat egress: the presets above the limit are not offered', () => {
assert.equal(aboveCeiling('registries', 'off'), true)
assert.equal(aboveCeiling('off', undefined), false)
assert.deepEqual(presetChoices({ preset: 'off', ceiling: 'registries' }), [
{ id: 'off', chosen: true, limited: false },
{ id: 'registries', chosen: false, limited: false },
{ id: 'all', chosen: false, limited: true },
{ id: 'off', chosen: true, limited: false, title: '' },
{ id: 'registries', chosen: false, limited: false, title: '' },
{ id: 'all', chosen: false, limited: true, title: '' },
])
})
test('chat egress: the Tools menu shows the row only where the Sandbox serves, and says so on this machine', () => {
test('chat egress: the add-on that runs the commands gives titles to its presets', () => {
const egress = { preset: 'off', ceiling: 'all', network: { title: 'Runbox', presets: [{ id: 'off', title: 'Closed' }] } }
assert.equal(declaredPresetTitle(egress, 'off'), 'Closed')
assert.equal(declaredPresetTitle(egress, 'all'), '')
assert.deepEqual(presetChoices(egress).map(c => c.title), ['Closed', '', ''])
const t = (key) => `host:${key}`
assert.equal(presetLabel(egress, 'off', t), 'Closed')
assert.equal(presetLabel(egress, 'all', t), 'host:egress.preset.all')
assert.equal(presetLabel(undefined, 'off', t), 'host:egress.preset.off')
})
test('chat egress: the Tools menu shows the row only where an add-on with the control serves, and says so on this machine', () => {
assert.equal(networkMode({ loaded: false, applies: true }), 'hidden')
assert.equal(networkMode({ loaded: true, applies: true, addon: 'devbox' }), 'row')
// The policy applies, but no add-on draws the network row for it.