From b930e55528882b1790f1358c337ee3515367437e Mon Sep 17 00:00:00 2001
From: Ettore Di Giacinto
Date: Tue, 3 Nov 2026 10:25:29 +0000
Subject: [PATCH] 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
---
core/http/react-ui/e2e/chats-egress.spec.js | 44 +++++++++++++++++--
.../components/chat/NetworkConsentCard.jsx | 6 +--
.../src/components/chat/NetworkRow.jsx | 10 ++---
.../hostcontrols/NetworkPolicySettings.jsx | 6 +--
core/http/react-ui/src/utils/chatEgress.js | 19 ++++++--
.../react-ui/tests/unit/chategress.test.mjs | 21 ++++++---
6 files changed, 83 insertions(+), 23 deletions(-)
diff --git a/core/http/react-ui/e2e/chats-egress.spec.js b/core/http/react-ui/e2e/chats-egress.spec.js
index d7b0249e27..e2f1e16ae3 100644
--- a/core/http/react-ui/e2e/chats-egress.spec.js
+++ b/core/http/react-ui/e2e/chats-egress.spec.js
@@ -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
diff --git a/core/http/react-ui/src/components/chat/NetworkConsentCard.jsx b/core/http/react-ui/src/components/chat/NetworkConsentCard.jsx
index 21cff85216..27d7413fc6 100644
--- a/core/http/react-ui/src/components/chat/NetworkConsentCard.jsx
+++ b/core/http/react-ui/src/components/chat/NetworkConsentCard.jsx
@@ -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 (
- {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) })}{' '}
{target}
@@ -90,7 +90,7 @@ export default function NetworkConsentCard({ card, chatId, egress }) {
{t('egress.consent.body')} {target}
{egress && (
- {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) })}
)}
{card.private && !isAdmin && {t('egress.consent.adminOnly')}
}
diff --git a/core/http/react-ui/src/components/chat/NetworkRow.jsx b/core/http/react-ui/src/components/chat/NetworkRow.jsx
index 8b2ff33b47..1fb17fa3f9 100644
--- a/core/http/react-ui/src/components/chat/NetworkRow.jsx
+++ b/core/http/react-ui/src/components/chat/NetworkRow.jsx
@@ -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 (
{t(mode === 'host' ? 'egress.hostNetwork' : 'egress.unverified')}
-
{t('egress.configuredPolicy', { preset: t(`egress.preset.${egress.preset || 'off'}`) })}
+
{t('egress.configuredPolicy', { preset: presetLabel(egress, egress.preset || 'off', t) })}
)
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}`)}
))}
@@ -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) })}>}
>}
{asked && (
- {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) })}
)}
{t('egress.limitNote')}
diff --git a/core/http/react-ui/src/components/hostcontrols/NetworkPolicySettings.jsx b/core/http/react-ui/src/components/hostcontrols/NetworkPolicySettings.jsx
index f4341c4e53..ebc1f46016 100644
--- a/core/http/react-ui/src/components/hostcontrols/NetworkPolicySettings.jsx
+++ b/core/http/react-ui/src/components/hostcontrols/NetworkPolicySettings.jsx
@@ -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 (
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.
-
+
{NETWORK_POLICY_FIELDS.filter(key => key !== 'chats_egress_audit_days').map(key => FIELD_BY_KEY[key] && (
diff --git a/core/http/react-ui/src/utils/chatEgress.js b/core/http/react-ui/src/utils/chatEgress.js
index 490cbae859..b7ba896203 100644
--- a/core/http/react-ui/src/utils/chatEgress.js
+++ b/core/http/react-ui/src/utils/chatEgress.js
@@ -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
diff --git a/core/http/react-ui/tests/unit/chategress.test.mjs b/core/http/react-ui/tests/unit/chategress.test.mjs
index d1479327ef..f957129b48 100644
--- a/core/http/react-ui/tests/unit/chategress.test.mjs
+++ b/core/http/react-ui/tests/unit/chategress.test.mjs
@@ -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.