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.