From a4ba5cf7bf54ef72ae1ad1b7c6fbc2ef227112e9 Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Tue, 1 Sep 2026 12:09:24 -0700 Subject: [PATCH 1/7] MAINT: Remove additional initializer persistence Use .pyrit_conf as the single source for configured initializer startup behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 348ff2fc-5014-444c-a6bc-d306afa6821c --- doc/code/setup/1_configuration.ipynb | 2 +- doc/code/setup/1_configuration.py | 2 +- doc/gui/0_gui.md | 9 +- frontend/e2e/touch-targets.spec.ts | 3 +- .../AdditionalInitializers.styles.ts | 79 --- .../AdditionalInitializers.test.tsx | 450 ------------------ .../Initializers/AdditionalInitializers.tsx | 274 ----------- .../AvailableInitializersDialog.tsx | 2 +- .../BaselineInitializers.test.tsx | 68 --- .../Initializers/BaselineInitializers.tsx | 66 --- .../ConfiguredInitializers.test.tsx | 108 +++++ .../Initializers/ConfiguredInitializers.tsx | 84 ++++ .../InitializerParametersDialog.test.tsx | 321 ------------- .../InitializerParametersDialog.tsx | 148 ------ .../Initializers/Initializers.styles.ts | 34 +- .../Initializers/Initializers.test.tsx | 206 +------- .../components/Initializers/Initializers.tsx | 111 +---- frontend/src/services/api.ts | 25 - frontend/src/types/index.ts | 37 +- pyrit/backend/main.py | 9 +- pyrit/backend/models/initializers.py | 63 +-- pyrit/backend/routes/initializers.py | 132 +---- pyrit/backend/services/initializer_service.py | 197 +------- ...8b1a_drop_additional_initializers_table.py | 38 ++ pyrit/memory/memory_interface.py | 49 -- pyrit/memory/memory_models.py | 45 -- pyrit/models/__init__.py | 2 - pyrit/models/additional_initializer.py | 39 -- pyrit/setup/configuration_loader.py | 6 +- pyrit/setup/initialization.py | 2 +- .../unit/backend/test_initializer_service.py | 448 ++--------------- tests/unit/backend/test_main.py | 55 +-- .../test_additional_initializer_memory.py | 62 --- tests/unit/memory/test_migration.py | 17 + .../models/test_additional_initializer.py | 27 -- 35 files changed, 382 insertions(+), 2838 deletions(-) delete mode 100644 frontend/src/components/Initializers/AdditionalInitializers.styles.ts delete mode 100644 frontend/src/components/Initializers/AdditionalInitializers.test.tsx delete mode 100644 frontend/src/components/Initializers/AdditionalInitializers.tsx delete mode 100644 frontend/src/components/Initializers/BaselineInitializers.test.tsx delete mode 100644 frontend/src/components/Initializers/BaselineInitializers.tsx create mode 100644 frontend/src/components/Initializers/ConfiguredInitializers.test.tsx create mode 100644 frontend/src/components/Initializers/ConfiguredInitializers.tsx delete mode 100644 frontend/src/components/Initializers/InitializerParametersDialog.test.tsx delete mode 100644 frontend/src/components/Initializers/InitializerParametersDialog.tsx create mode 100644 pyrit/memory/alembic/versions/0f2e4d6c8b1a_drop_additional_initializers_table.py delete mode 100644 pyrit/models/additional_initializer.py delete mode 100644 tests/unit/memory/test_additional_initializer_memory.py delete mode 100644 tests/unit/models/test_additional_initializer.py diff --git a/doc/code/setup/1_configuration.ipynb b/doc/code/setup/1_configuration.ipynb index 94c4fc3f61..9262894c7f 100644 --- a/doc/code/setup/1_configuration.ipynb +++ b/doc/code/setup/1_configuration.ipynb @@ -557,7 +557,7 @@ "All you need to do is create a `PyRITInitializer` class (e.g. myinitializer.py). Then you can use `set_global_variable` and use it everywhere. Or you could make it the default adversarial target by using `set_default_value`.\n", "\n", "\n", - "### Additional Initializer information\n", + "### More Initializer information\n", "\n", "- For more information on how default values work, see the [default values](./default_values.md) section.\n", "- For more information on how initializers work, see the [initializers](./pyrit_initializer.ipynb) section" diff --git a/doc/code/setup/1_configuration.py b/doc/code/setup/1_configuration.py index 2504b9d0d7..2832a2d1f4 100644 --- a/doc/code/setup/1_configuration.py +++ b/doc/code/setup/1_configuration.py @@ -212,7 +212,7 @@ # All you need to do is create a `PyRITInitializer` class (e.g. myinitializer.py). Then you can use `set_global_variable` and use it everywhere. Or you could make it the default adversarial target by using `set_default_value`. # # -# ### Additional Initializer information +# ### More Initializer information # # - For more information on how default values work, see the [default values](./default_values.md) section. # - For more information on how initializers work, see the [initializers](./pyrit_initializer.ipynb) section diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 85097721fd..44273a1e57 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -189,14 +189,11 @@ Use **Reload** to discard local edits and fetch the latest source content. Saved ### Initializers -The **Initializers** page (in the left navigation) lets you review and extend how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. +The **Initializers** page (in the left navigation) lets you review how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. -The page has two sections: +**Configured initializers** are read-only. They come from your active configuration file (`~/.pyrit/.pyrit_conf`) and run in the order shown. Update the configuration file and restart the backend to change this sequence. -- **Baseline initializers** are read-only. They come from your active configuration file (`~/.pyrit/.pyrit_conf`) and run first, in order. -- **Additional initializers** are added in the GUI and saved to the memory database. They run after the baseline, in the order shown. You can add more than one initializer of the same type — each is its own invocation. - -Use **Apply now** to re-run a single initializer immediately against the running backend — handy for picking up an environment or setting change without a restart. Saved additional initializers and `.pyrit_conf` edits otherwise take effect the next time the backend starts. +Use **Apply now** to re-run a configured initializer immediately against the running backend — handy for picking up an environment or setting change without a restart. --- diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index b3942e0c20..5746b5b3e6 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -171,8 +171,7 @@ async function installTouchTargetMocks(page: Page): Promise { if (apiPath === "/initializers/settings" && method === "GET") { await route.fulfill( jsonResponse({ - baseline: [], - additional: [], + configured: [], }) ); return; diff --git a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts deleted file mode 100644 index 9cdad1f625..0000000000 --- a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { makeStyles, tokens } from '@fluentui/react-components' - -export const useAdditionalInitializersStyles = makeStyles({ - list: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalL, - width: '100%', - }, - card: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - padding: tokens.spacingVerticalL, - border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground1, - }, - cardHeader: { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - flexWrap: 'wrap', - gap: tokens.spacingHorizontalM, - }, - titleGroup: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - }, - parameterList: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXXS, - marginBottom: tokens.spacingVerticalS, - }, - parameterHint: { - color: tokens.colorNeutralForeground3, - }, - parametersEditor: { - fontFamily: 'Consolas, "Courier New", monospace', - minHeight: '10rem', - width: '100%', - }, - parametersBlock: { - margin: 0, - marginTop: tokens.spacingVerticalXS, - padding: tokens.spacingVerticalM, - borderRadius: tokens.borderRadiusMedium, - backgroundColor: tokens.colorNeutralBackground3, - overflowX: 'auto', - fontFamily: 'Consolas, "Courier New", monospace', - }, - dialogContent: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalS, - }, - actionsRow: { - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - gap: tokens.spacingHorizontalS, - }, - errorText: { - color: tokens.colorPaletteRedForeground1, - marginTop: tokens.spacingVerticalXS, - }, - envVarText: { - color: tokens.colorNeutralForeground3, - display: 'block', - marginTop: tokens.spacingVerticalXXS, - }, - parameterFields: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - }, -}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.test.tsx b/frontend/src/components/Initializers/AdditionalInitializers.test.tsx deleted file mode 100644 index 74a909d626..0000000000 --- a/frontend/src/components/Initializers/AdditionalInitializers.test.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { useState } from 'react' - -import type { AdditionalInitializerSetting, RegisteredInitializer } from '@/types' - -import AdditionalInitializers from './AdditionalInitializers' - -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -const targetInitializer: RegisteredInitializer = { - initializer_name: 'target', - initializer_type: 'TargetInitializer', - description: 'Registers targets.', - required_env_vars: ['AZURE_OPENAI_ENDPOINT'], - supported_parameters: [ - { - name: 'tags', - type_name: 'list[str]', - required: false, - default: null, - choices: null, - is_list: true, - description: 'Target tags.', - }, - ], -} - -const scorerInitializer: RegisteredInitializer = { - initializer_name: 'scorer', - initializer_type: 'ScorerInitializer', - description: 'Registers scorers.', - required_env_vars: [], - supported_parameters: [ - { - name: 'mode', - type_name: 'str', - required: false, - default: null, - choices: null, - is_list: false, - description: 'Scorer mode.', - }, - ], -} - -const noParamInitializer: RegisteredInitializer = { - initializer_name: 'load_default_datasets', - initializer_type: 'DatasetInitializer', - description: 'Loads default datasets.', - required_env_vars: [], - supported_parameters: [], -} - -const taggedTargetInitializer: RegisteredInitializer = { - initializer_name: 'tagged_target', - initializer_type: 'TargetInitializer', - description: 'Registers targets with tags.', - required_env_vars: [], - supported_parameters: [ - { - name: 'tags', - type_name: 'list[str]', - required: false, - default: null, - choices: ['default', 'scorer', 'all'], - is_list: true, - description: 'Target tags.', - }, - ], -} - -const requiredParamInitializer: RegisteredInitializer = { - initializer_name: 'required_param', - initializer_type: 'DatasetInitializer', - description: 'Requires a label.', - required_env_vars: [], - supported_parameters: [ - { - name: 'label', - type_name: 'str', - required: true, - default: null, - choices: null, - is_list: false, - description: 'A required label.', - }, - ], -} - -const refreshInitializer: RegisteredInitializer = { - initializer_name: 'refresh_datasets', - initializer_type: 'DatasetInitializer', - description: 'Refreshes datasets.', - required_env_vars: [], - supported_parameters: [ - { - name: 'days', - type_name: 'int', - required: false, - default: null, - choices: null, - is_list: false, - description: 'Number of days.', - }, - { - name: 'dataset_names', - type_name: 'list[str]', - required: false, - default: null, - choices: null, - is_list: true, - description: 'Dataset names.', - }, - ], -} - -const sampleItems: AdditionalInitializerSetting[] = [ - { - id: 'additional-1', - initializer_name: 'target', - parameters: { tags: ['default'] }, - order_index: 2, - }, - { - id: 'additional-2', - initializer_name: 'scorer', - parameters: null, - order_index: null, - }, -] - -describe('AdditionalInitializers', () => { - const defaultProps = { - items: sampleItems, - registeredInitializers: [targetInitializer, scorerInitializer], - creating: false, - onAdd: jest.fn().mockResolvedValue(true), - onSave: jest.fn().mockResolvedValue(true), - onClearSaveError: jest.fn(), - onApply: jest.fn().mockResolvedValue(undefined), - onRemove: jest.fn().mockResolvedValue(undefined), - } - - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should render additional initializer rows and metadata', () => { - render( - - - , - ) - - expect(screen.getByRole('list', { name: 'Additional initializers' })).toBeInTheDocument() - expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('target') - expect(screen.getByText('Required env vars: AZURE_OPENAI_ENDPOINT')).toBeInTheDocument() - expect(screen.getByText('tags (list[str], optional)')).toBeInTheDocument() - }) - - it('should show the saved parameters read-only without an inline editor', () => { - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - expect(within(row).getByText(/"tags"/)).toBeInTheDocument() - expect(within(row).queryByRole('textbox', { name: 'Parameters JSON' })).not.toBeInTheDocument() - }) - - it('should show the description as hover text on the initializer name', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() - - await user.hover(within(screen.getByTestId('initializer-row-additional-1')).getByText('target')) - - expect(await screen.findByRole('tooltip')).toHaveTextContent('Registers targets.') - }) - - it('should call onSave from the edit dialog, preserving the existing order_index', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Edit target initializer') - const editor = within(dialog).getByTestId('param-tags') - fireEvent.change(editor, { target: { value: 'extra' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) - - expect(defaultProps.onSave).toHaveBeenCalledWith('additional-1', { - parameters: { tags: ['extra'] }, - order_index: 2, - }) - }) - - it('should call onApply with the saved parameters', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - await user.click(within(row).getByRole('button', { name: 'Apply now' })) - - expect(defaultProps.onApply).toHaveBeenCalledWith('additional-1', 'target', { tags: ['default'] }) - }) - - it('should call onRemove with the additional initializer id after confirming', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - await user.click(within(screen.getByTestId('initializer-row-additional-1')).getByRole('button', { name: 'Remove' })) - - const dialog = await screen.findByRole('dialog', { hidden: true }) - expect(within(dialog).getByText(/remove the/i)).toBeInTheDocument() - expect(within(dialog).getByText('target')).toBeInTheDocument() - - await user.click(within(dialog).getByRole('button', { name: 'Remove', hidden: true })) - - expect(defaultProps.onRemove).toHaveBeenCalledWith('additional-1') - }) - - it('should not call onRemove when the confirmation dialog is cancelled', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - await user.click(within(screen.getByTestId('initializer-row-additional-1')).getByRole('button', { name: 'Remove' })) - - const dialog = await screen.findByRole('dialog', { hidden: true }) - await user.click(within(dialog).getByRole('button', { name: 'Cancel', hidden: true })) - - expect(defaultProps.onRemove).not.toHaveBeenCalled() - }) - - it('should show a validation error when a required parameter is missing', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'required_param' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add required_param initializer') - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent( - 'label is required.', - ) - expect(defaultProps.onAdd).not.toHaveBeenCalled() - }) - - it('should submit typed number and list parameters from the add dialog', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'refresh_datasets' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add refresh_datasets initializer') - fireEvent.change(within(dialog).getByTestId('param-days'), { target: { value: '7' } }) - fireEvent.change(within(dialog).getByTestId('param-dataset_names'), { target: { value: 'harmbench, xstest' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(defaultProps.onAdd).toHaveBeenCalledWith('refresh_datasets', { - days: 7, - dataset_names: ['harmbench', 'xstest'], - }) - }) - - it('should submit selected choices from a multiselect parameter', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'tagged_target' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add tagged_target initializer') - await user.click(within(dialog).getByTestId('param-tags-default')) - await user.click(within(dialog).getByTestId('param-tags-scorer')) - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(defaultProps.onAdd).toHaveBeenCalledWith('tagged_target', { tags: ['default', 'scorer'] }) - }) - - it('should keep the edit dialog open and show an inline error when save fails', async () => { - const user = userEvent.setup() - const onSave = jest.fn() - const onClearSaveError = jest.fn() - - function TestComponent() { - const [saveErrors, setSaveErrors] = useState>({}) - - return ( - { - onSave(id, request) - setSaveErrors({ [id]: 'Mock save failure' }) - return false - }} - onClearSaveError={(id) => { - onClearSaveError(id) - setSaveErrors({}) - }} - /> - ) - } - - render( - - - , - ) - - const row = screen.getByTestId('initializer-row-additional-1') - fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Edit target initializer') - const editor = within(dialog).getByTestId('param-tags') - fireEvent.change(editor, { target: { value: 'modified' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) - - expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument() - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent('Mock save failure') - expect(editor).toHaveValue('modified') - - await user.click(within(dialog).getByRole('button', { name: 'Cancel', hidden: true })) - - expect(onClearSaveError).toHaveBeenCalledWith('additional-1') - }) - - it('should hide the parameters editor and submit null for a no-parameter initializer', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'load_default_datasets' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add load_default_datasets initializer') - expect(within(dialog).getByText('This initializer takes no parameters.')).toBeInTheDocument() - expect( - within(dialog).queryByRole('textbox', { name: 'Parameters JSON', hidden: true }), - ).not.toBeInTheDocument() - - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(defaultProps.onAdd).toHaveBeenCalledWith('load_default_datasets', null) - }) - - it('should show a server error inside the add dialog when onAdd fails', async () => { - const user = userEvent.setup() - - const props = { - ...defaultProps, - registeredInitializers: [refreshInitializer], - onAdd: jest.fn().mockRejectedValue(new Error('Invalid days value.')), - } - - render( - - - , - ) - - fireEvent.change(screen.getByRole('combobox', { name: 'Initializer to add' }), { - target: { value: 'refresh_datasets' }, - }) - await user.click(screen.getByRole('button', { name: 'Add initializer' })) - - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText('Add refresh_datasets initializer') - fireEvent.change(within(dialog).getByTestId('param-days'), { target: { value: '12' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent( - 'Invalid days value.', - ) - expect(dialog).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.tsx b/frontend/src/components/Initializers/AdditionalInitializers.tsx deleted file mode 100644 index 992292ca3c..0000000000 --- a/frontend/src/components/Initializers/AdditionalInitializers.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useState } from 'react' - -import { - Button, - Select, - Text, - Tooltip, -} from '@fluentui/react-components' -import { AddRegular } from '@fluentui/react-icons' - -import type { - AdditionalInitializerSetting, - RegisteredInitializer, - UpdateAdditionalInitializerRequest, -} from '@/types' - -import { toApiError } from '@/services/errors' -import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' -import { formatInitializerParameters, formatSupportedParameterSummary } from './initializerFormatting' -import { resolveRegisteredInitializer } from './initializerLookup' -import InitializerParametersDialog from './InitializerParametersDialog' -import { useInitializersStyles } from './Initializers.styles' -import ConfirmDialog from '../ConfirmDialog' - -interface AdditionalInitializersProps { - items: AdditionalInitializerSetting[] - registeredInitializers: RegisteredInitializer[] - creating: boolean - savingInitializerId?: string | null - saveErrors?: Record - applyingInitializerId?: string | null - deletingInitializerId?: string | null - onAdd: (initializerName: string, parameters: Record | null) => Promise - onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise - onClearSaveError: (id: string) => void - onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise - onRemove: (id: string) => Promise -} - -interface AdditionalInitializerCardProps { - item: AdditionalInitializerSetting - initializer: RegisteredInitializer - isSaving: boolean - isApplying: boolean - isDeleting: boolean - saveError?: string | null - onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise - onClearSaveError: (id: string) => void - onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise - onRemove: (id: string) => Promise -} - -function AdditionalInitializerCard({ - item, - initializer, - isSaving, - isApplying, - isDeleting, - saveError, - onSave, - onClearSaveError, - onApply, - onRemove, -}: AdditionalInitializerCardProps) { - const styles = useAdditionalInitializersStyles() - const [editOpen, setEditOpen] = useState(false) - const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false) - const isBusy = isSaving || isApplying || isDeleting - - const handleEditSubmit = async (parameters: Record | null): Promise => { - const saved = await onSave(item.id, { parameters, order_index: item.order_index ?? null }) - if (saved) { - setEditOpen(false) - } - } - - const handleEditOpenChange = (open: boolean): void => { - setEditOpen(open) - if (!open) { - onClearSaveError(item.id) - } - } - - return ( -
-
-
- - {item.initializer_name} - - {initializer.required_env_vars.length > 0 && ( - - Required env vars: {initializer.required_env_vars.join(', ')} - - )} -
-
- -
- {formatSupportedParameterSummary(initializer).map((summary: string) => ( - - {summary} - - ))} -
- -
- Parameters -
{formatInitializerParameters(item.parameters)}
-
- -
- - - -
- - { - setConfirmRemoveOpen(false) - void onRemove(item.id) - }} - onCancel={() => setConfirmRemoveOpen(false)} - > - Are you sure you want to remove the {item.initializer_name} initializer? This action cannot be undone. - - - {editOpen && ( - - )} -
- ) -} - -export default function AdditionalInitializers({ - items, - registeredInitializers, - creating, - savingInitializerId = null, - saveErrors = {}, - applyingInitializerId = null, - deletingInitializerId = null, - onAdd, - onSave, - onClearSaveError, - onApply, - onRemove, -}: AdditionalInitializersProps) { - const pageStyles = useInitializersStyles() - const listStyles = useAdditionalInitializersStyles() - const [selectedInitializerName, setSelectedInitializerName] = useState('') - const [addDialogOpen, setAddDialogOpen] = useState(false) - const [addError, setAddError] = useState(null) - const initializerName = selectedInitializerName || registeredInitializers[0]?.initializer_name || '' - const selectedInitializer = registeredInitializers.find( - (initializer) => initializer.initializer_name === initializerName, - ) ?? null - - const handleAdd = async (parameters: Record | null): Promise => { - if (!initializerName) { - return - } - setAddError(null) - try { - const added = await onAdd(initializerName, parameters) - if (added) { - setAddDialogOpen(false) - } - } catch (e) { - setAddError(toApiError(e).detail) - } - } - - return ( -
-
- - Additional initializers - - - Add and edit initializer invocations that run after the baseline. - -
- -
- - -
- - {items.length === 0 ? ( - No additional initializers are configured. - ) : ( -
- {items.map((item: AdditionalInitializerSetting) => ( - - ))} -
- )} - - {addDialogOpen && ( - { - setAddDialogOpen(open) - if (!open) { - setAddError(null) - } - }} - /> - )} -
- ) -} diff --git a/frontend/src/components/Initializers/AvailableInitializersDialog.tsx b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx index 82255129a9..067b3dc08f 100644 --- a/frontend/src/components/Initializers/AvailableInitializersDialog.tsx +++ b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx @@ -57,7 +57,7 @@ export default function AvailableInitializersDialog({ {registeredInitializers.map((initializer: RegisteredInitializer) => (
diff --git a/frontend/src/components/Initializers/BaselineInitializers.test.tsx b/frontend/src/components/Initializers/BaselineInitializers.test.tsx deleted file mode 100644 index e13b780670..0000000000 --- a/frontend/src/components/Initializers/BaselineInitializers.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { render, screen, within } from '@testing-library/react' -import { FluentProvider, webLightTheme } from '@fluentui/react-components' - -import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' - -import BaselineInitializers from './BaselineInitializers' - -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -const registeredInitializers: RegisteredInitializer[] = [ - { - initializer_name: 'target', - initializer_type: 'TargetInitializer', - description: 'Registers targets.', - required_env_vars: ['AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_KEY'], - supported_parameters: [], - }, -] - -describe('BaselineInitializers', () => { - it('renders the empty state when there are no baseline initializers', () => { - render( - - - , - ) - - expect(screen.getByText('No baseline initializers are configured.')).toBeInTheDocument() - expect(screen.queryByRole('list', { name: 'Baseline initializers' })).not.toBeInTheDocument() - }) - - it('renders each baseline row with description, env vars, order, and parameters', () => { - const items: BaselineInitializerSetting[] = [ - { initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }, - ] - - render( - - - , - ) - - const row = screen.getByTestId('baseline-initializer-row-target') - expect(within(row).getByText('target')).toBeInTheDocument() - expect(within(row).getByText('Registers targets.')).toBeInTheDocument() - expect(within(row).getByText(/AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY/)).toBeInTheDocument() - expect(within(row).getByText('Order: 0')).toBeInTheDocument() - expect(within(row).getByText(/"tags"/)).toBeInTheDocument() - }) - - it('falls back to a placeholder for a name that is no longer registered', () => { - const items: BaselineInitializerSetting[] = [ - { initializer_name: 'ghost', parameters: null, order_index: 1 }, - ] - - render( - - - , - ) - - const row = screen.getByTestId('baseline-initializer-row-ghost') - expect(within(row).getByText('Initializer is no longer registered.')).toBeInTheDocument() - expect(within(row).getByText(/Required env vars: None/)).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/Initializers/BaselineInitializers.tsx b/frontend/src/components/Initializers/BaselineInitializers.tsx deleted file mode 100644 index 46910182ff..0000000000 --- a/frontend/src/components/Initializers/BaselineInitializers.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Text } from '@fluentui/react-components' - -import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' - -import { formatInitializerParameters } from './initializerFormatting' -import { resolveRegisteredInitializer } from './initializerLookup' -import { useInitializersStyles } from './Initializers.styles' - -interface BaselineInitializersProps { - items: BaselineInitializerSetting[] - registeredInitializers: RegisteredInitializer[] -} - -export default function BaselineInitializers({ - items, - registeredInitializers, -}: BaselineInitializersProps) { - const styles = useInitializersStyles() - - return ( -
-
- - Baseline initializers - - - Read-only initializers from the .pyrit_conf baseline. - -
- {items.length === 0 ? ( - No baseline initializers are configured. - ) : ( -
- {items.map((item: BaselineInitializerSetting) => { - const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) - return ( -
-
-
- {item.initializer_name} - {initializer.description || 'No description available.'} - - Required env vars: {initializer.required_env_vars.length > 0 - ? initializer.required_env_vars.join(', ') - : 'None'} - - Order: {item.order_index} -
-
-
- Parameters -
{formatInitializerParameters(item.parameters)}
-
-
- ) - })} -
- )} -
- ) -} diff --git a/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx b/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx new file mode 100644 index 0000000000..69c3f15629 --- /dev/null +++ b/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { ConfiguredInitializerSetting, RegisteredInitializer } from '@/types' + +import ConfiguredInitializers from './ConfiguredInitializers' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const registeredInitializers: RegisteredInitializer[] = [ + { + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: ['AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_KEY'], + supported_parameters: [], + }, +] + +const onApply = jest.fn() + +describe('ConfiguredInitializers', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the empty state when no initializers are configured', () => { + render( + + + , + ) + + expect(screen.getByText('No initializers are configured in .pyrit_conf.')).toBeInTheDocument() + expect(screen.queryByRole('list', { name: 'Configured initializers' })).not.toBeInTheDocument() + }) + + it('renders each configured row with description, env vars, order, and parameters', () => { + const items: ConfiguredInitializerSetting[] = [ + { initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }, + ] + + render( + + + , + ) + + const row = screen.getByTestId('configured-initializer-row-0') + expect(within(row).getByText('target')).toBeInTheDocument() + expect(within(row).getByText('Registers targets.')).toBeInTheDocument() + expect(within(row).getByText(/AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY/)).toBeInTheDocument() + expect(within(row).getByText('Order: 0')).toBeInTheDocument() + expect(within(row).getByText(/"tags"/)).toBeInTheDocument() + }) + + it('applies the configured initializer with its parameters', async () => { + const user = userEvent.setup() + const items: ConfiguredInitializerSetting[] = [ + { initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }, + ] + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Apply now' })) + + expect(onApply).toHaveBeenCalledWith('target:0', 'target', { tags: ['default'] }) + }) + + it('falls back to a placeholder for a name that is no longer registered', () => { + const items: ConfiguredInitializerSetting[] = [ + { initializer_name: 'ghost', parameters: null, order_index: 1 }, + ] + + render( + + + , + ) + + const row = screen.getByTestId('configured-initializer-row-1') + expect(within(row).getByText('Initializer is no longer registered.')).toBeInTheDocument() + expect(within(row).getByText(/Required env vars: None/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Initializers/ConfiguredInitializers.tsx b/frontend/src/components/Initializers/ConfiguredInitializers.tsx new file mode 100644 index 0000000000..380559a855 --- /dev/null +++ b/frontend/src/components/Initializers/ConfiguredInitializers.tsx @@ -0,0 +1,84 @@ +import { Button, Text } from '@fluentui/react-components' + +import type { ConfiguredInitializerSetting, RegisteredInitializer } from '@/types' + +import { formatInitializerParameters } from './initializerFormatting' +import { resolveRegisteredInitializer } from './initializerLookup' +import { useInitializersStyles } from './Initializers.styles' + +interface ConfiguredInitializersProps { + items: ConfiguredInitializerSetting[] + registeredInitializers: RegisteredInitializer[] + applyingInitializerKey?: string | null + onApply: ( + key: string, + initializerName: string, + parameters?: Record | null, + ) => Promise +} + +export default function ConfiguredInitializers({ + items, + registeredInitializers, + applyingInitializerKey = null, + onApply, +}: ConfiguredInitializersProps) { + const styles = useInitializersStyles() + + return ( +
+
+ + Configured initializers + + + Read-only startup sequence from the active .pyrit_conf. + +
+ {items.length === 0 ? ( + No initializers are configured in .pyrit_conf. + ) : ( +
+ {items.map((item: ConfiguredInitializerSetting) => { + const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) + const initializerKey = `${item.initializer_name}:${item.order_index}` + const isApplying = applyingInitializerKey === initializerKey + return ( +
+
+
+ {item.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + + Order: {item.order_index} +
+ +
+
+ Parameters +
{formatInitializerParameters(item.parameters)}
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx deleted file mode 100644 index ceb170aa02..0000000000 --- a/frontend/src/components/Initializers/InitializerParametersDialog.test.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { FluentProvider, webLightTheme } from '@fluentui/react-components' - -import type { RegisteredInitializer } from '@/types' - -import InitializerParametersDialog from './InitializerParametersDialog' - -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -) - -const allKindsInitializer: RegisteredInitializer = { - initializer_name: 'kitchen_sink', - initializer_type: 'DemoInitializer', - description: 'Every control kind.', - required_env_vars: ['DEMO_TOKEN'], - supported_parameters: [ - { name: 'flag', type_name: 'bool', required: false, default: null, choices: null, is_list: false }, - { name: 'level', type_name: 'str', required: false, default: null, choices: ['low', 'high'], is_list: false }, - { name: 'tags', type_name: 'list[str]', required: false, default: null, choices: ['a', 'b'], is_list: true }, - { name: 'names', type_name: 'list[str]', required: false, default: null, choices: null, is_list: true }, - { name: 'days', type_name: 'int', required: false, default: null, choices: null, is_list: false }, - { name: 'label', type_name: 'str', required: false, default: null, choices: null, is_list: false }, - ], -} - -const numericInitializer: RegisteredInitializer = { - initializer_name: 'refresh_datasets', - initializer_type: 'DatasetInitializer', - description: 'Refreshes datasets.', - required_env_vars: [], - supported_parameters: [ - { name: 'days', type_name: 'int', required: false, default: null, choices: null, is_list: false }, - { name: 'names', type_name: 'list[str]', required: false, default: null, choices: null, is_list: true }, - ], -} - -const requiredInitializer: RegisteredInitializer = { - initializer_name: 'required_param', - initializer_type: 'DemoInitializer', - description: 'Requires a label.', - required_env_vars: [], - supported_parameters: [ - { name: 'label', type_name: 'str', required: true, default: null, choices: null, is_list: false }, - ], -} - -const noParamInitializer: RegisteredInitializer = { - initializer_name: 'load_default_datasets', - initializer_type: 'DatasetInitializer', - description: 'Loads default datasets.', - required_env_vars: [], - supported_parameters: [], -} - -describe('InitializerParametersDialog', () => { - const baseProps = { - open: true, - mode: 'add' as const, - onSubmit: jest.fn().mockResolvedValue(undefined), - onOpenChange: jest.fn(), - } - - beforeEach(() => { - jest.clearAllMocks() - }) - - it('renders one control of the right kind for each parameter', () => { - render( - - - , - ) - - expect(screen.getByText('Add kitchen_sink initializer')).toBeInTheDocument() - expect(screen.getByText(/Required env vars: DEMO_TOKEN/)).toBeInTheDocument() - expect(screen.getByTestId('param-flag').tagName).toBe('SELECT') - expect(screen.getByTestId('param-flag')).toHaveValue('') - expect(screen.getByTestId('param-level').tagName).toBe('SELECT') - expect(screen.getByTestId('param-tags-a')).toBeInTheDocument() - expect(screen.getByTestId('param-tags-b')).toBeInTheDocument() - expect(screen.getByTestId('param-names')).toBeInTheDocument() - expect(screen.getByTestId('param-days')).toHaveAttribute('type', 'number') - expect(screen.getByTestId('param-label')).toHaveAttribute('type', 'text') - }) - - it('should give each multiselect checkbox its own accessible name', () => { - render( - - - , - ) - - expect(screen.getByRole('group', { name: 'tags' })).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'a' })).toBeInTheDocument() - expect(screen.getByRole('checkbox', { name: 'b' })).toBeInTheDocument() - }) - - it('shows a no-parameters message and submits null for a parameterless initializer', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - expect(screen.getByText('This initializer takes no parameters.')).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(null) - }) - - it('blocks submit and shows an error when a required field is empty', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(await screen.findByRole('alert')).toHaveTextContent('label is required.') - expect(onSubmit).not.toHaveBeenCalled() - }) - - it('coerces typed number and comma-separated list values on submit', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - fireEvent.change(screen.getByTestId('param-days'), { target: { value: '7' } }) - fireEvent.change(screen.getByTestId('param-names'), { target: { value: 'x, y' } }) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith({ days: 7, names: ['x', 'y'] }) - }) - - it('submits toggled boolean and selected multiselect values', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - fireEvent.change(screen.getByTestId('param-flag'), { target: { value: 'true' } }) - await user.click(screen.getByTestId('param-tags-a')) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ flag: true, tags: ['a'] })) - }) - - it('toggles the intended multiselect option when clicking checkbox label text', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByText('b')) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['b'] })) - }) - - it('leaves an optional boolean unset omitted from the submitted parameters', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Add' })) - - // Every other optional field is also left blank, so the whole payload is null; - // the key assertion is that the omitted boolean doesn't silently coerce to false. - expect(onSubmit).toHaveBeenCalledWith(null) - }) - - it('unchecks a multiselect choice and picks a select value', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByTestId('param-tags-a')) - await user.click(screen.getByTestId('param-tags-a')) - fireEvent.change(screen.getByTestId('param-level'), { target: { value: 'high' } }) - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ level: 'high' })) - expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('tags') - }) - - it('prefills existing parameters in edit mode', () => { - render( - - - , - ) - - expect(screen.getByText('Edit refresh_datasets initializer')).toBeInTheDocument() - expect(screen.getByTestId('param-days')).toHaveValue(5) - expect(screen.getByTestId('param-names')).toHaveValue('alpha, beta') - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() - }) - - it('does not pin absent declaration defaults when editing persisted parameters', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - const initializer: RegisteredInitializer = { - ...numericInitializer, - supported_parameters: [ - { - name: 'days', - type_name: 'int', - required: false, - default: '7', - choices: null, - is_list: false, - }, - ], - } - - render( - - - , - ) - - expect(screen.getByTestId('param-days')).toHaveValue(null) - await user.click(screen.getByRole('button', { name: 'Save' })) - expect(onSubmit).toHaveBeenCalledWith(null) - }) - - it('calls onOpenChange(false) when cancelled', async () => { - const user = userEvent.setup() - const onOpenChange = jest.fn() - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Cancel' })) - - expect(onOpenChange).toHaveBeenCalledWith(false) - }) - - it('disables the actions and shows progress text while submitting', () => { - render( - - - , - ) - - expect(screen.getByRole('button', { name: 'Add...' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() - }) - - it('displays an external error passed via externalError prop', () => { - render( - - - , - ) - - expect(screen.getByRole('alert')).toHaveTextContent('Server rejected the request.') - }) - - it('prefers validation error over externalError', async () => { - const user = userEvent.setup() - const onSubmit = jest.fn().mockResolvedValue(undefined) - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Add', hidden: true })) - - expect(await screen.findByRole('alert')).toHaveTextContent('label is required.') - }) -}) diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.tsx deleted file mode 100644 index 6e89534a97..0000000000 --- a/frontend/src/components/Initializers/InitializerParametersDialog.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useRef, useState } from 'react' -import { - Button, - Dialog, - DialogActions, - DialogBody, - DialogContent, - DialogSurface, - DialogTitle, - Text, -} from '@fluentui/react-components' - -import ParameterField from '@/components/Parameters/ParameterField' -import { buildParametersFromForm, getInitialFormValues, type ParameterFormValue } from '@/components/Parameters/parameterForm' -import type { RegisteredInitializer } from '@/types' - -import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' - -interface InitializerParametersDialogProps { - open: boolean - mode: 'add' | 'edit' - initializer: RegisteredInitializer | null - initialParameters?: Record | null - submitting?: boolean - externalError?: string | null - onSubmit: (parameters: Record | null) => void | Promise - onOpenChange: (open: boolean) => void -} - -export default function InitializerParametersDialog({ - open, - mode, - initializer, - initialParameters = null, - submitting = false, - externalError = null, - onSubmit, - onOpenChange, -}: InitializerParametersDialogProps) { - const styles = useAdditionalInitializersStyles() - const parameters = initializer?.supported_parameters ?? [] - const [values, setValues] = useState>(() => - getInitialFormValues(parameters, initialParameters, { prefillDefaults: mode === 'add' }), - ) - const [error, setError] = useState(null) - const submitInProgressRef = useRef(false) - - const acceptsParameters = parameters.length > 0 - - const updateValue = (name: string, value: ParameterFormValue): void => { - setValues((prev) => ({ ...prev, [name]: value })) - setError(null) - } - - const handleSubmit = async (): Promise => { - submitInProgressRef.current = true - try { - await submitForm() - } finally { - submitInProgressRef.current = false - } - } - - const submitForm = async (): Promise => { - if (!acceptsParameters) { - setError(null) - await onSubmit(null) - return - } - - const result = buildParametersFromForm(parameters, values) - if (!result.ok) { - setError(result.error) - return - } - - setError(null) - await onSubmit(result.parameters) - } - - const initializerName = initializer?.initializer_name ?? '' - const title = mode === 'add' ? `Add ${initializerName} initializer` : `Edit ${initializerName} initializer` - const submitLabel = mode === 'add' ? 'Add' : 'Save' - - return ( - { - if (!data.open && submitInProgressRef.current) { - return - } - onOpenChange(data.open) - }} - > - - - {title} - - {initializer && ( - <> - {initializer.description || 'No description available.'} - {initializer.required_env_vars.length > 0 && ( - - Required env vars: {initializer.required_env_vars.join(', ')} - - )} - - )} - {acceptsParameters ? ( -
- {parameters.map((parameter) => ( - - ))} -
- ) : ( - - This initializer takes no parameters. - - )} - {(error || externalError) && ( - - {error || externalError} - - )} -
- - - - -
-
-
- ) -} diff --git a/frontend/src/components/Initializers/Initializers.styles.ts b/frontend/src/components/Initializers/Initializers.styles.ts index 9de345bfe6..6e2c22c5b7 100644 --- a/frontend/src/components/Initializers/Initializers.styles.ts +++ b/frontend/src/components/Initializers/Initializers.styles.ts @@ -1,10 +1,6 @@ import { makeStyles, tokens } from '@fluentui/react-components' -import { - MINIMUM_TOUCH_TARGET_SIZE, - TOUCH_INPUT_QUERY, - mobileTouchTargetHeight, -} from '@/styles/touchTargets' +import { mobileTouchTargetHeight } from '@/styles/touchTargets' export const useInitializersStyles = makeStyles({ root: { @@ -50,30 +46,10 @@ export const useInitializersStyles = makeStyles({ flexDirection: 'column', gap: tokens.spacingVerticalXXS, }, - addInitializerRow: { - display: 'flex', - alignItems: 'center', - gap: tokens.spacingHorizontalM, - flexWrap: 'wrap', - }, - addInitializerSelect: { - minWidth: '220px', - ...mobileTouchTargetHeight, - '& > select': { - [TOUCH_INPUT_QUERY]: { - minHeight: MINIMUM_TOUCH_TARGET_SIZE, - }, - }, - }, touchTarget: { ...mobileTouchTargetHeight, }, - baselineList: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - }, - baselineGroup: { + configuredGroup: { display: 'flex', flexDirection: 'column', border: `1px solid ${tokens.colorNeutralStroke2}`, @@ -81,7 +57,7 @@ export const useInitializersStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackground1, overflow: 'hidden', }, - baselineGroupItem: { + configuredGroupItem: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, @@ -98,7 +74,7 @@ export const useInitializersStyles = makeStyles({ maxHeight: '60vh', overflowY: 'auto', }, - baselineCard: { + availableCard: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, @@ -107,7 +83,7 @@ export const useInitializersStyles = makeStyles({ borderRadius: tokens.borderRadiusLarge, backgroundColor: tokens.colorNeutralBackground1, }, - baselineHeader: { + configuredHeader: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', diff --git a/frontend/src/components/Initializers/Initializers.test.tsx b/frontend/src/components/Initializers/Initializers.test.tsx index 3ddc8b6954..12db5da6a0 100644 --- a/frontend/src/components/Initializers/Initializers.test.tsx +++ b/frontend/src/components/Initializers/Initializers.test.tsx @@ -1,11 +1,10 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import { initializersApi } from '@/services/api' import type { - AdditionalInitializerSetting, - BaselineInitializerSetting, + ConfiguredInitializerSetting, InitializerSettingsResponse, RegisteredInitializer, } from '@/types' @@ -16,9 +15,6 @@ jest.mock('@/services/api', () => ({ initializersApi: { getSettings: jest.fn(), listRegistered: jest.fn(), - createAdditional: jest.fn(), - updateAdditional: jest.fn(), - deleteAdditional: jest.fn(), applyNow: jest.fn(), }, })) @@ -29,19 +25,6 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ) -async function openDialogByButton( - _user: ReturnType, - buttonName: RegExp | string, - dialogName: string, -): Promise { - const trigger = await screen.findByRole('button', { name: buttonName }) - await waitFor(() => expect(trigger).toBeEnabled()) - fireEvent.click(trigger) - const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) - await within(dialog).findByText(dialogName) - return dialog -} - const targetInitializer: RegisteredInitializer = { initializer_name: 'target', initializer_type: 'TargetInitializer', @@ -60,40 +43,14 @@ const targetInitializer: RegisteredInitializer = { ], } -const scorerInitializer: RegisteredInitializer = { - initializer_name: 'scorer', - initializer_type: 'ScorerInitializer', - description: 'Registers scorers.', - required_env_vars: [], - supported_parameters: [ - { - name: 'tags', - type_name: 'list[str]', - required: false, - default: null, - choices: null, - is_list: true, - description: 'Scorer tags.', - }, - ], -} - -const baselineItem: BaselineInitializerSetting = { +const configuredItem: ConfiguredInitializerSetting = { initializer_name: 'target', - parameters: { tags: ['baseline'] }, + parameters: { tags: ['configured'] }, order_index: 0, } -const additionalItem: AdditionalInitializerSetting = { - id: 'additional-1', - initializer_name: 'scorer', - parameters: { mode: 'strict' }, - order_index: 10, -} - const sampleSettings: InitializerSettingsResponse = { - baseline: [baselineItem], - additional: [additionalItem], + configured: [configuredItem], } function renderInitializers(): void { @@ -109,26 +66,13 @@ describe('Initializers', () => { jest.clearAllMocks() mockedInitializersApi.getSettings.mockResolvedValue(sampleSettings) mockedInitializersApi.listRegistered.mockResolvedValue({ - items: [targetInitializer, scorerInitializer], + items: [targetInitializer], pagination: { limit: 200, has_more: false }, }) - mockedInitializersApi.createAdditional.mockResolvedValue({ - id: 'additional-2', - initializer_name: 'target', - parameters: null, - order_index: null, - }) - mockedInitializersApi.updateAdditional.mockResolvedValue({ - id: 'additional-1', - initializer_name: 'scorer', - parameters: { mode: 'relaxed' }, - order_index: 11, - }) - mockedInitializersApi.deleteAdditional.mockResolvedValue() mockedInitializersApi.applyNow.mockResolvedValue({ - initializer_name: 'scorer', + initializer_name: 'target', status: 'applied', - applied_parameters: { mode: 'strict' }, + applied_parameters: { tags: ['configured'] }, }) }) @@ -140,14 +84,11 @@ describe('Initializers', () => { expect(screen.getByText('Loading initializer settings...')).toBeInTheDocument() }) - it('should render baseline and additional initializers', async () => { + it('should render initializers configured in .pyrit_conf', async () => { renderInitializers() - expect(await screen.findByRole('heading', { level: 1, name: 'Initializers' })).toBeInTheDocument() - expect(await screen.findByRole('heading', { level: 2, name: 'Baseline initializers' })).toBeInTheDocument() - expect(screen.getByRole('heading', { level: 2, name: 'Additional initializers' })).toBeInTheDocument() - expect(screen.getByTestId('baseline-initializer-row-target')).toHaveTextContent('Registers targets.') - expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('scorer') + expect(await screen.findByRole('heading', { level: 2, name: 'Configured initializers' })).toBeInTheDocument() + expect(screen.getByTestId('configured-initializer-row-0')).toHaveTextContent('Registers targets.') }) it('should refresh settings when the refresh button is clicked', async () => { @@ -167,137 +108,38 @@ describe('Initializers', () => { }) }) - it('should render a read-only catalog of all registered initializers in a dialog', async () => { - const user = userEvent.setup() - renderInitializers() - - await screen.findByRole('button', { name: 'Browse available initializers' }) - await openDialogByButton(user, 'Browse available initializers', 'Available initializers') - - const catalogTarget = screen.getByTestId('available-initializer-row-target') - expect(catalogTarget).toHaveTextContent('Registers targets.') - expect(catalogTarget).toHaveTextContent('tags') - expect(screen.getByTestId('available-initializer-row-scorer')).toBeInTheDocument() - }) - - it('should create the selected initializer and show success feedback', async () => { - const user = userEvent.setup() - renderInitializers() - - await screen.findByTestId('initializer-row-additional-1') - const dialog = await openDialogByButton(user, 'Add initializer', 'Add target initializer') - expect(dialog).toBeInTheDocument() - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) - - await waitFor(() => { - expect(mockedInitializersApi.createAdditional).toHaveBeenCalledWith({ - initializer_name: 'target', - parameters: null, - }) - expect(screen.getByText('Added target initializer.')).toBeInTheDocument() - }) - }) - - it('should let the user choose a non-target initializer to add', async () => { + it('should render a read-only catalog of all registered initializers', async () => { const user = userEvent.setup() renderInitializers() - await screen.findByTestId('initializer-row-additional-1') - const combobox = screen.getByRole('combobox', { name: 'Initializer to add' }) - await user.selectOptions(combobox, 'scorer') - await waitFor(() => expect(combobox).toHaveValue('scorer')) - const dialog = await openDialogByButton(user, /Add initializer|Adding/, 'Add scorer initializer') - expect(dialog).toBeInTheDocument() - await user.click(await within(dialog).findByRole('button', { name: 'Add', hidden: true })) + await user.click(await screen.findByRole('button', { name: 'Browse available initializers' })) - await waitFor(() => { - expect(mockedInitializersApi.createAdditional).toHaveBeenCalledWith({ - initializer_name: 'scorer', - parameters: null, - }) - expect(screen.getByText('Added scorer initializer.')).toBeInTheDocument() - }) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Available initializers')).toBeInTheDocument() + expect(screen.getByTestId('available-initializer-row-target')).toHaveTextContent('Registers targets.') }) - it('should save an additional initializer from the edit dialog', async () => { + it('should apply a configured initializer', async () => { const user = userEvent.setup() renderInitializers() - await screen.findByTestId('initializer-row-additional-1') - const dialog = await openDialogByButton(user, 'Edit', 'Edit scorer initializer') - fireEvent.change(within(dialog).getByTestId('param-tags'), { target: { value: 'relaxed' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) + const configuredRow = await screen.findByTestId('configured-initializer-row-0') + await user.click(within(configuredRow).getByRole('button', { name: 'Apply now' })) await waitFor(() => { - expect(mockedInitializersApi.updateAdditional).toHaveBeenCalledWith('additional-1', { - parameters: { tags: ['relaxed'] }, - order_index: 10, + expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('target', { + parameters: { tags: ['configured'] }, }) - expect(screen.getByText('Saved additional initializer.')).toBeInTheDocument() + expect(screen.getByText('Applied target.')).toBeInTheDocument() }) }) - it('should show save errors in the edit dialog and preserve edits', async () => { - const user = userEvent.setup() - mockedInitializersApi.updateAdditional.mockRejectedValue(new Error('Mock save failure')) - renderInitializers() - - await screen.findByTestId('initializer-row-additional-1') - const dialog = await openDialogByButton(user, 'Edit', 'Edit scorer initializer') - const editor = within(dialog).getByTestId('param-tags') - fireEvent.change(editor, { target: { value: 'relaxed' } }) - await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) - - expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument() - expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent('Mock save failure') - expect(editor).toHaveValue('relaxed') - }) - - it('should apply an additional initializer', async () => { - const user = userEvent.setup() - renderInitializers() - - const additionalRow = await screen.findByTestId('initializer-row-additional-1') - await user.click(within(additionalRow).getByRole('button', { name: 'Apply now' })) - - await waitFor(() => { - expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('scorer', { - parameters: { mode: 'strict' }, - }) - expect(screen.getByText('Applied scorer.')).toBeInTheDocument() - }) - }) - - it('should not render an apply button on baseline initializers', async () => { - renderInitializers() - - const baselineRow = await screen.findByTestId('baseline-initializer-row-target') - expect(within(baselineRow).queryByRole('button', { name: 'Apply now' })).not.toBeInTheDocument() - }) - - it('should keep saved settings visible when catalog loading fails', async () => { + it('should keep configured settings visible when catalog loading fails', async () => { mockedInitializersApi.listRegistered.mockRejectedValue(new Error('Service Unavailable')) renderInitializers() - expect(await screen.findByTestId('baseline-initializer-row-target')).toBeInTheDocument() - expect(screen.getByTestId('initializer-row-additional-1')).toBeInTheDocument() + expect(await screen.findByTestId('configured-initializer-row-0')).toBeInTheDocument() expect(screen.getByText('Service Unavailable')).toBeInTheDocument() }) - - it('should remove an additional initializer and show success feedback', async () => { - const user = userEvent.setup() - renderInitializers() - - const row = await screen.findByTestId('initializer-row-additional-1') - await user.click(within(row).getByRole('button', { name: 'Remove' })) - - const dialog = await screen.findByRole('dialog', { hidden: true }) - await user.click(within(dialog).getByRole('button', { name: 'Remove', hidden: true })) - - await waitFor(() => { - expect(mockedInitializersApi.deleteAdditional).toHaveBeenCalledWith('additional-1') - expect(screen.getByText('Removed additional initializer.')).toBeInTheDocument() - }) - }) }) diff --git a/frontend/src/components/Initializers/Initializers.tsx b/frontend/src/components/Initializers/Initializers.tsx index 55c7547d7f..e24a72aadb 100644 --- a/frontend/src/components/Initializers/Initializers.tsx +++ b/frontend/src/components/Initializers/Initializers.tsx @@ -5,11 +5,10 @@ import { ArrowSyncRegular } from '@fluentui/react-icons' import { initializersApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { InitializerSettingsResponse, RegisteredInitializer, UpdateAdditionalInitializerRequest } from '@/types' +import type { InitializerSettingsResponse, RegisteredInitializer } from '@/types' -import AdditionalInitializers from './AdditionalInitializers' import AvailableInitializersDialog from './AvailableInitializersDialog' -import BaselineInitializers from './BaselineInitializers' +import ConfiguredInitializers from './ConfiguredInitializers' import { useInitializersStyles } from './Initializers.styles' interface StatusMessage { @@ -18,8 +17,7 @@ interface StatusMessage { } const EMPTY_SETTINGS: InitializerSettingsResponse = { - baseline: [], - additional: [], + configured: [], } export default function Initializers() { @@ -29,11 +27,7 @@ export default function Initializers() { const [loading, setLoading] = useState(true) const [statusMessage, setStatusMessage] = useState(null) const [refetchCount, setRefetchCount] = useState(0) - const [creating, setCreating] = useState(false) - const [savingInitializerId, setSavingInitializerId] = useState(null) - const [saveErrors, setSaveErrors] = useState>({}) - const [applyingInitializerId, setApplyingInitializerId] = useState(null) - const [deletingInitializerId, setDeletingInitializerId] = useState(null) + const [applyingInitializerKey, setApplyingInitializerKey] = useState(null) useEffect(() => { let cancelled = false @@ -79,89 +73,19 @@ export default function Initializers() { setRefetchCount((currentCount: number) => currentCount + 1) } - const refetchSettingsOnly = async (): Promise => { - const response = await initializersApi.getSettings() - setSettings(response) - } - - const handleAdd = async ( - initializerName: string, - parameters: Record | null, - ): Promise => { - setCreating(true) - try { - await initializersApi.createAdditional({ initializer_name: initializerName, parameters }) - setStatusMessage({ intent: 'success', text: `Added ${initializerName} initializer.` }) - await refetchSettingsOnly() - return true - } catch (error) { - const detail = toApiError(error).detail - setStatusMessage({ intent: 'error', text: detail }) - throw error - } finally { - setCreating(false) - } - } - - const handleSave = async ( - id: string, - request: UpdateAdditionalInitializerRequest, - ): Promise => { - setSavingInitializerId(id) - setSaveErrors((currentErrors) => { - const remainingErrors = { ...currentErrors } - delete remainingErrors[id] - return remainingErrors - }) - try { - await initializersApi.updateAdditional(id, request) - setStatusMessage({ intent: 'success', text: 'Saved additional initializer.' }) - await refetchSettingsOnly() - return true - } catch (error) { - const detail = toApiError(error).detail - setStatusMessage({ intent: 'error', text: detail }) - setSaveErrors((currentErrors) => ({ ...currentErrors, [id]: detail })) - return false - } finally { - setSavingInitializerId(null) - } - } - - const clearSaveError = (id: string): void => { - setSaveErrors((currentErrors) => { - const remainingErrors = { ...currentErrors } - delete remainingErrors[id] - return remainingErrors - }) - } - const handleApply = async ( - id: string, + key: string, initializerName: string, parameters?: Record | null, ): Promise => { - setApplyingInitializerId(id) + setApplyingInitializerKey(key) try { await initializersApi.applyNow(initializerName, { parameters }) setStatusMessage({ intent: 'success', text: `Applied ${initializerName}.` }) } catch (error) { setStatusMessage({ intent: 'error', text: toApiError(error).detail }) } finally { - setApplyingInitializerId(null) - } - } - - const handleRemove = async (id: string): Promise => { - setDeletingInitializerId(id) - try { - await initializersApi.deleteAdditional(id) - setStatusMessage({ intent: 'success', text: 'Removed additional initializer.' }) - await refetchSettingsOnly() - } catch (error) { - setStatusMessage({ intent: 'error', text: toApiError(error).detail }) - } finally { - setDeletingInitializerId(null) + setApplyingInitializerKey(null) } } @@ -171,8 +95,7 @@ export default function Initializers() {
Initializers - Browse every registered initializer, review the read-only baseline that ran at startup, and manage - additional initializer invocations that run after it. + Browse every registered initializer and review the startup sequence from the active .pyrit_conf.
@@ -204,23 +127,11 @@ export default function Initializers() {
) : ( <> - - )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0fc41d0853..c63950eacb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -16,9 +16,6 @@ import type { ListRegisteredInitializersResponse, CustomInitializerListResponse, RegisterInitializerRequest, - AdditionalInitializer, - CreateAdditionalInitializerRequest, - UpdateAdditionalInitializerRequest, CreateAttackRequest, CreateAttackResponse, AttackSummary, @@ -275,28 +272,6 @@ export const initializersApi = { await apiClient.delete(`/initializers/${encodeURIComponent(initializerName)}`) }, - createAdditional: async ( - request: CreateAdditionalInitializerRequest, - ): Promise => { - const response = await apiClient.post('/initializers/settings', request) - return response.data - }, - - updateAdditional: async ( - id: string, - request: UpdateAdditionalInitializerRequest, - ): Promise => { - const response = await apiClient.put( - `/initializers/settings/${encodeURIComponent(id)}`, - request, - ) - return response.data - }, - - deleteAdditional: async (id: string): Promise => { - await apiClient.delete(`/initializers/settings/${encodeURIComponent(id)}`) - }, - applyNow: async ( initializerName: string, request?: ApplyInitializerRequest, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3d5d188868..59da4b560f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -184,45 +184,16 @@ export interface RegisteredInitializer { supported_parameters: Parameter[] } -/** A read-only initializer from the `.pyrit_conf` baseline, referenced by registry name. */ -export interface BaselineInitializerSetting { +/** A read-only initializer invocation from the active `.pyrit_conf`. */ +export interface ConfiguredInitializerSetting { initializer_name: string parameters?: Record | null order_index: number } -/** A persisted additional initializer, referenced by registry name. */ -export interface AdditionalInitializerSetting { - id: string - initializer_name: string - parameters?: Record | null - order_index?: number | null -} - export interface InitializerSettingsResponse { - /** Read-only initializers from the `.pyrit_conf` baseline, in run order. */ - baseline: BaselineInitializerSetting[] - /** Persisted additional initializers that run after the baseline, in run order. */ - additional: AdditionalInitializerSetting[] -} - -/** The persisted domain row returned by create/update of an additional initializer. */ -export interface AdditionalInitializer { - id: string - initializer_name: string - parameters?: Record | null - order_index?: number | null -} - -export interface CreateAdditionalInitializerRequest { - initializer_name: string - parameters?: Record | null - order_index?: number | null -} - -export interface UpdateAdditionalInitializerRequest { - parameters?: Record | null - order_index?: number | null + /** Read-only initializers from the active `.pyrit_conf`, in run order. */ + configured: ConfiguredInitializerSetting[] } export interface ListRegisteredInitializersResponse { diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py index 68721a731e..b0df310797 100644 --- a/pyrit/backend/main.py +++ b/pyrit/backend/main.py @@ -22,7 +22,7 @@ import pyrit from pyrit.backend.middleware import RequestIdMiddleware, SecurityHeadersMiddleware, register_error_handlers from pyrit.backend.middleware.auth import EntraAuthMiddleware -from pyrit.backend.models.initializers import BaselineInitializerSetting +from pyrit.backend.models.initializers import ConfiguredInitializerSetting from pyrit.backend.routes import ( attacks, auth, @@ -39,7 +39,6 @@ ) from pyrit.backend.services.configuration_file_service import ConfigurationFileService from pyrit.backend.services.environment_file_service import EnvironmentFileService -from pyrit.backend.services.initializer_service import get_initializer_service from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH from pyrit.registry import InitializerRegistry from pyrit.setup.configuration_loader import ConfigurationLoader @@ -83,16 +82,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await asyncio.to_thread(initializer_registry.register_stored_initializers) await config.initialize_pyrit_async(raise_on_initializer_error=False) - # Persisted additional initializers run after the .pyrit_conf baseline, in stored order. - app.state.baseline_initializers = [ - BaselineInitializerSetting( + app.state.configured_initializers = [ + ConfiguredInitializerSetting( initializer_name=initializer.name, parameters=initializer.args, order_index=order_index, ) for order_index, initializer in enumerate(config.initializer_configs) ] - await get_initializer_service().run_additional_initializers_async() # Expose config values to route handlers via app.state default_labels: dict[str, str] = {} diff --git a/pyrit/backend/models/initializers.py b/pyrit/backend/models/initializers.py index e45c1dc1a4..6ec9a9ba19 100644 --- a/pyrit/backend/models/initializers.py +++ b/pyrit/backend/models/initializers.py @@ -18,17 +18,14 @@ from pyrit.models.catalog.initializer import RegisteredInitializer __all__ = [ - "AdditionalInitializerSetting", "ApplyInitializerRequest", "ApplyInitializerResponse", - "BaselineInitializerSetting", - "CreateAdditionalInitializerRequest", "CustomInitializerListResponse", "CustomInitializerResponse", + "ConfiguredInitializerSetting", "InitializerSettingsResponse", "ListRegisteredInitializersResponse", "RegisterInitializerRequest", - "UpdateAdditionalInitializerRequest", ] @@ -65,64 +62,20 @@ class CustomInitializerListResponse(BaseModel): items: list[CustomInitializerResponse] = Field(..., description="Stored custom initializer definitions.") -class BaselineInitializerSetting(BaseModel): - """A read-only baseline initializer entry, referencing its registry definition by name.""" +class ConfiguredInitializerSetting(BaseModel): + """A read-only initializer invocation from ``.pyrit_conf``.""" initializer_name: str = Field(..., description="Registry name of the initializer this entry configures.") - parameters: dict[str, Any] | None = Field(default=None, description="Baseline parameters from the config.") - order_index: int = Field(..., ge=0, description="Zero-based position in the baseline startup sequence.") - - -class AdditionalInitializerSetting(BaseModel): - """A persisted additional initializer entry, referencing its registry definition by name.""" - - id: str = Field(..., description="Stable unique row id.") - initializer_name: str = Field(..., description="Registry name of the initializer this entry configures.") - parameters: dict[str, Any] | None = Field(default=None, description="Persisted parameters for this invocation.") - order_index: int | None = Field( - default=None, - ge=0, - description="Optional zero-based position among the additional initializers.", - ) + parameters: dict[str, Any] | None = Field(default=None, description="Parameters from the active config.") + order_index: int = Field(..., ge=0, description="Zero-based position in the startup sequence.") class InitializerSettingsResponse(BaseModel): - """Response describing the read-only baseline plus the editable additional initializers.""" + """Response describing the initializers configured in ``.pyrit_conf``.""" - baseline: list[BaselineInitializerSetting] = Field( + configured: list[ConfiguredInitializerSetting] = Field( ..., - description="Read-only initializers from the ``.pyrit_conf`` baseline, in run order.", - ) - additional: list[AdditionalInitializerSetting] = Field( - ..., - description="Persisted additional initializers that run after the baseline, in run order.", - ) - - -class CreateAdditionalInitializerRequest(BaseModel): - """Request body for adding a new additional initializer.""" - - initializer_name: str = Field( - ..., - pattern=REGISTRY_NAME_PATTERN, - description="Registry name of the initializer to add.", - ) - parameters: dict[str, Any] | None = Field(default=None, description="Parameters to persist for this invocation.") - order_index: int | None = Field( - default=None, - ge=0, - description="Optional zero-based position among the additional initializers.", - ) - - -class UpdateAdditionalInitializerRequest(BaseModel): - """Request body for updating one existing additional initializer.""" - - parameters: dict[str, Any] | None = Field(default=None, description="Parameters to persist for this invocation.") - order_index: int | None = Field( - default=None, - ge=0, - description="Optional zero-based position among the additional initializers.", + description="Read-only initializers from the active ``.pyrit_conf``, in run order.", ) diff --git a/pyrit/backend/routes/initializers.py b/pyrit/backend/routes/initializers.py index e97f052400..59389110d4 100644 --- a/pyrit/backend/routes/initializers.py +++ b/pyrit/backend/routes/initializers.py @@ -4,14 +4,11 @@ """ Initializer API routes. -Provides endpoints for listing, registering, and removing initializers. +Provides endpoints for listing, applying, registering, and removing initializers. Route structure: GET /api/initializers — list all initializers - GET /api/initializers/settings — list baseline + additional initializers - POST /api/initializers/settings — add an additional initializer - PUT /api/initializers/settings/{id} — update an additional initializer - DELETE /api/initializers/settings/{id} — remove an additional initializer + GET /api/initializers/settings — list initializers configured in .pyrit_conf POST /api/initializers/{name}/apply — apply an initializer immediately GET /api/initializers/{name} — get single initializer detail POST /api/initializers — register initializer from script @@ -26,16 +23,13 @@ from pyrit.backend.models.initializers import ( ApplyInitializerRequest, ApplyInitializerResponse, - BaselineInitializerSetting, - CreateAdditionalInitializerRequest, CustomInitializerListResponse, + ConfiguredInitializerSetting, InitializerSettingsResponse, ListRegisteredInitializersResponse, RegisterInitializerRequest, - UpdateAdditionalInitializerRequest, ) from pyrit.backend.services.initializer_service import get_initializer_service -from pyrit.models import AdditionalInitializer from pyrit.models.catalog.initializer import RegisteredInitializer router = APIRouter(prefix="/initializers", tags=["initializers"]) @@ -54,22 +48,20 @@ def _custom_storage_unavailable() -> HTTPException: ) -def _baseline_initializers(request: Request) -> list[BaselineInitializerSetting]: +def _configured_initializers(request: Request) -> list[ConfiguredInitializerSetting]: """ - Read the read-only baseline initializer list captured at backend startup. + Read the initializer list captured from ``.pyrit_conf`` at backend startup. - The startup lifespan (see ``pyrit.backend.main.lifespan``) stashes the ``.pyrit_conf`` - baseline on ``app.state`` so routes can display it without importing the configuration - loader. Falls back to an empty list when the app was not started via the lifespan - (e.g. isolated route tests). + Falls back to an empty list when the app was not started via the lifespan, such + as in isolated route tests. Args: request: The incoming FastAPI request. Returns: - list[BaselineInitializerSetting]: The baseline initializers, or an empty list. + list[ConfiguredInitializerSetting]: The configured initializers, or an empty list. """ - return list(getattr(request.app.state, "baseline_initializers", [])) + return list(getattr(request.app.state, "configured_initializers", [])) def _check_custom_initializers_allowed(request: Request) -> None: @@ -122,113 +114,15 @@ async def get_initializer_settings( # pyrit-async-suffix-exempt request: Request, ) -> InitializerSettingsResponse: """ - List the read-only ``.pyrit_conf`` baseline plus the persisted additional initializers. + List the initializers from the active ``.pyrit_conf``. Args: - request: The incoming FastAPI request (carries the startup baseline on ``app.state``). - - Returns: - InitializerSettingsResponse: The read-only baseline and editable additional lists. - """ - service = get_initializer_service() - return await service.list_initializer_settings_async( - baseline_initializers=_baseline_initializers(request), - ) - - -@router.post( - "/settings", - response_model=AdditionalInitializer, - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_admin)], - responses={ - 400: {"model": ProblemDetail, "description": "Invalid initializer settings"}, - 404: {"model": ProblemDetail, "description": "Initializer not found"}, - }, -) -async def create_additional_initializer( # pyrit-async-suffix-exempt - body: CreateAdditionalInitializerRequest, -) -> AdditionalInitializer: - """ - Add a new additional initializer. - - Args: - body: The additional initializer to add. - - Returns: - AdditionalInitializer: The newly persisted row. - """ - service = get_initializer_service() - try: - return await service.create_additional_initializer_async( - initializer_name=body.initializer_name, - parameters=body.parameters, - order_index=body.order_index, - ) - except KeyError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Initializer '{body.initializer_name}' not found", - ) from None - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None - - -@router.put( - "/settings/{additional_initializer_id}", - response_model=AdditionalInitializer, - dependencies=[Depends(require_admin)], - responses={ - 400: {"model": ProblemDetail, "description": "Invalid initializer settings"}, - 404: {"model": ProblemDetail, "description": "Additional initializer not found"}, - }, -) -async def update_additional_initializer( # pyrit-async-suffix-exempt - additional_initializer_id: str, - body: UpdateAdditionalInitializerRequest, -) -> AdditionalInitializer: - """ - Update one existing additional initializer by id. - - Args: - additional_initializer_id: The additional initializer row id to update. - body: The updated payload. + request: The incoming FastAPI request. Returns: - AdditionalInitializer: The updated row. - """ - service = get_initializer_service() - try: - return await service.update_additional_initializer_async( - initializer_id=additional_initializer_id, - parameters=body.parameters, - order_index=body.order_index, - ) - except KeyError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Additional initializer '{additional_initializer_id}' not found", - ) from None - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None - - -@router.delete( - "/settings/{additional_initializer_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_admin)], -) -async def delete_additional_initializer( # pyrit-async-suffix-exempt - additional_initializer_id: str, -) -> None: + InitializerSettingsResponse: The configured initializer list. """ - Delete one additional initializer by id. - - Args: - additional_initializer_id: The additional initializer row id to delete. - """ - service = get_initializer_service() - await service.delete_additional_initializer_async(initializer_id=additional_initializer_id) + return InitializerSettingsResponse(configured=_configured_initializers(request)) @router.post( diff --git a/pyrit/backend/services/initializer_service.py b/pyrit/backend/services/initializer_service.py index 9f3331f152..bc0f2abe2e 100644 --- a/pyrit/backend/services/initializer_service.py +++ b/pyrit/backend/services/initializer_service.py @@ -2,32 +2,21 @@ # Licensed under the MIT license. """ -Initializer service for catalog, registration, additional-initializer settings, and apply-now. - -Provides access to the ``InitializerRegistry`` (listing, registering, and unregistering -initializers) plus the persisted *additional initializers* stored in Central Memory. Additional -initializers run after the ``.pyrit_conf`` baseline; multiple rows may reference the same -initializer name (each is its own invocation, identified by ``id``). +Initializer service for catalog, registration, and apply-now operations. """ import asyncio import logging -from collections.abc import Sequence from functools import lru_cache from typing import Any from pyrit.backend.models.common import PaginationInfo from pyrit.backend.models.initializers import ( - AdditionalInitializerSetting, ApplyInitializerResponse, - BaselineInitializerSetting, CustomInitializerListResponse, CustomInitializerResponse, - InitializerSettingsResponse, ListRegisteredInitializersResponse, ) -from pyrit.memory import CentralMemory -from pyrit.models import AdditionalInitializer from pyrit.models.catalog.initializer import RegisteredInitializer from pyrit.registry import InitializerMetadata, InitializerRegistry from pyrit.setup.pyrit_initializer import PyRITInitializer @@ -56,16 +45,12 @@ def _metadata_to_registered_initializer(metadata: InitializerMetadata) -> Regist class InitializerService: """ - Service for listing, registering, configuring, and applying initializers. - - Uses ``InitializerRegistry`` for metadata/building and Central Memory for - persisted additional-initializer rows. + Service for listing, registering, and applying initializers. """ def __init__(self) -> None: """Initialize the initializer service.""" self._registry = InitializerRegistry.get_registry_singleton() - self._memory = CentralMemory.get_memory_instance() async def list_initializers_async( self, @@ -107,119 +92,6 @@ async def get_initializer_async(self, *, initializer_name: str) -> RegisteredIni metadata = self._get_metadata_by_name().get(initializer_name) return _metadata_to_registered_initializer(metadata) if metadata else None - async def list_initializer_settings_async( - self, - *, - baseline_initializers: Sequence[BaselineInitializerSetting], - ) -> InitializerSettingsResponse: - """ - List the read-only ``.pyrit_conf`` baseline plus the persisted additional initializers. - - Args: - baseline_initializers: The initializer list the backend was started with. - - Returns: - InitializerSettingsResponse: The read-only baseline and editable additional lists. - Each entry references its initializer by ``initializer_name``; clients resolve - catalog metadata from the registered-initializers list. - """ - additional = [ - AdditionalInitializerSetting( - id=initializer.id, - initializer_name=initializer.initializer_name, - parameters=initializer.parameters, - order_index=initializer.order_index, - ) - for initializer in self._memory.get_additional_initializers() - ] - - return InitializerSettingsResponse(baseline=list(baseline_initializers), additional=additional) - - async def create_additional_initializer_async( - self, - *, - initializer_name: str, - parameters: dict[str, Any] | None, - order_index: int | None, - ) -> AdditionalInitializer: - """ - Validate and persist a new additional initializer. - - Args: - initializer_name: The initializer registry name. - parameters: Optional parameters to persist. - order_index: Optional zero-based position among the additional initializers. - When ``None``, the initializer is appended after the existing ones so - additional initializers run in the order they were added. - - Returns: - AdditionalInitializer: The newly persisted row. - """ - self._validate_initializer_parameters(initializer_name=initializer_name, parameters=parameters) - if order_index is None: - order_index = self._next_order_index() - initializer = AdditionalInitializer( - initializer_name=initializer_name, - parameters=parameters, - order_index=order_index, - ) - self._memory.add_additional_initializer(initializer=initializer) - return initializer - - async def update_additional_initializer_async( - self, - *, - initializer_id: str, - parameters: dict[str, Any] | None, - order_index: int | None, - ) -> AdditionalInitializer: - """ - Validate and update one existing additional initializer by id. - - Args: - initializer_id: The additional initializer row id to update. - parameters: Optional parameters to persist. - order_index: Optional zero-based position among the additional initializers. - - Returns: - AdditionalInitializer: The updated row. - - Raises: - KeyError: If no additional initializer with the given id exists. - """ - existing = self._get_additional_initializer_by_id(initializer_id) - self._validate_initializer_parameters( - initializer_name=existing.initializer_name, - parameters=parameters, - ) - updated = AdditionalInitializer( - id=existing.id, - initializer_name=existing.initializer_name, - parameters=parameters, - order_index=order_index if order_index is not None else existing.order_index, - ) - self._memory.add_additional_initializer(initializer=updated) - return updated - - def _next_order_index(self) -> int: - existing_indices = [ - initializer.order_index - for initializer in self._memory.get_additional_initializers() - if initializer.order_index is not None - ] - if not existing_indices: - return 0 - return max(existing_indices) + 1 - - async def delete_additional_initializer_async(self, *, initializer_id: str) -> None: - """ - Delete one additional initializer by id. - - Args: - initializer_id: The additional initializer row id to delete. - """ - self._memory.delete_additional_initializer(initializer_id=initializer_id) - async def apply_initializer_async( self, *, @@ -253,37 +125,6 @@ async def apply_initializer_async( applied_parameters=parameters, ) - async def run_additional_initializers_async(self) -> None: - """ - Run all persisted additional initializers in stored order, after the baseline. - - Intended for the backend startup lifespan: the ``.pyrit_conf`` baseline runs first via - the configuration loader, then this appends the user's additional initializers. - - Failures are isolated per initializer: a persisted row that fails to build, validate, or - initialize (e.g. a missing required environment variable) is logged and skipped so one bad - row cannot abort backend startup or block the remaining initializers. The bad row stays in - Central Memory so it can be fixed or removed from the GUI once the backend is up. - """ - initializers = self._memory.get_additional_initializers() - if not initializers: - return - - logger.info("Running %d additional initializer(s)...", len(initializers)) - for initializer in initializers: - try: - await asyncio.to_thread( - self._build_and_run_initializer, - initializer_name=initializer.initializer_name, - parameters=initializer.parameters, - ) - except Exception: - logger.exception( - "Skipping additional initializer '%s' (id=%s): it failed to run.", - initializer.initializer_name, - initializer.id, - ) - async def register_initializer_async( self, *, @@ -358,40 +199,6 @@ def _build_and_run_initializer( initializer.validate() asyncio.run(initializer.initialize_async()) - def _get_additional_initializer_by_id(self, initializer_id: str) -> AdditionalInitializer: - """ - Look up a persisted additional initializer by id. - - Args: - initializer_id: The additional initializer row id. - - Returns: - AdditionalInitializer: The matching row. - - Raises: - KeyError: If no row with the given id exists. - """ - for initializer in self._memory.get_additional_initializers(): - if initializer.id == initializer_id: - return initializer - raise KeyError(initializer_id) - - def _validate_initializer_parameters( - self, - *, - initializer_name: str, - parameters: dict[str, Any] | None, - ) -> None: - """ - Ensure the initializer exists and its parameters are valid. - - Args: - initializer_name: The initializer registry name. - parameters: Optional initializer parameters to validate. - """ - instance = self._registry.create_and_configure(initializer_name, initializer_params=parameters or None) - self._validate_parameter_values(instance=instance, parameters=parameters) - @staticmethod def _validate_parameter_values(*, instance: PyRITInitializer, parameters: dict[str, Any] | None) -> None: """ diff --git a/pyrit/memory/alembic/versions/0f2e4d6c8b1a_drop_additional_initializers_table.py b/pyrit/memory/alembic/versions/0f2e4d6c8b1a_drop_additional_initializers_table.py new file mode 100644 index 0000000000..0d4b640b01 --- /dev/null +++ b/pyrit/memory/alembic/versions/0f2e4d6c8b1a_drop_additional_initializers_table.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Drop the obsolete additional initializers table. + +Revision ID: 0f2e4d6c8b1a +Revises: 8e2c4a6b0d13 +Create Date: 2026-09-01 12:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0f2e4d6c8b1a" +down_revision: str | None = "8e2c4a6b0d13" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Apply this schema upgrade.""" + op.drop_table("AdditionalInitializers") + + +def downgrade() -> None: + """Revert this schema upgrade.""" + op.create_table( + "AdditionalInitializers", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("initializer_name", sa.String(length=64), nullable=False), + sa.Column("parameters", sa.JSON(), nullable=True), + sa.Column("order_index", sa.INTEGER(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py index 758c03bdcd..5c6d63f4fd 100644 --- a/pyrit/memory/memory_interface.py +++ b/pyrit/memory/memory_interface.py @@ -29,7 +29,6 @@ from pyrit.memory.memory_embedding import MemoryEmbedding from pyrit.memory.memory_models import ( - AdditionalInitializerEntry, AtomicAttackIdentifierEntry, AttackIdentifierEntry, AttackResultEntry, @@ -58,7 +57,6 @@ ) from pyrit.models import ( MEDIA_PATH_DATA_TYPES, - AdditionalInitializer, AtomicAttackIdentifier, AttackIdentifier, AttackOutcome, @@ -466,53 +464,6 @@ def get_all_embeddings(self) -> Sequence[EmbeddingDataEntry]: result: Sequence[EmbeddingDataEntry] = self._query_entries(EmbeddingDataEntry) return result - def add_additional_initializer(self, *, initializer: AdditionalInitializer) -> None: - """ - Insert or replace an additional initializer, keyed by its ``id``. - - Args: - initializer: The additional initializer to persist. - """ - self._update_entry(AdditionalInitializerEntry.from_domain_model(initializer)) - - def get_additional_initializers(self) -> Sequence[AdditionalInitializer]: - """ - Load all additional initializers in run order. - - Returns: - Sequence[AdditionalInitializer]: The persisted initializers ordered by - ``order_index`` then ``id`` for a stable, deterministic startup sequence. - """ - entries = self._query_entries( - AdditionalInitializerEntry, - order_by=AdditionalInitializerEntry.order_index.asc(), - ) - return sorted( - (entry.to_domain_model() for entry in entries), - key=lambda item: (item.order_index is None, item.order_index or 0, item.id), - ) - - def delete_additional_initializer(self, *, initializer_id: str) -> None: - """ - Delete an additional initializer by id when it exists. - - Args: - initializer_id: The additional initializer row id to delete. - - Raises: - SQLAlchemyError: If the delete operation fails. - """ - with closing(self.get_session()) as session: - try: - session.query(AdditionalInitializerEntry).filter( - AdditionalInitializerEntry.id == initializer_id - ).delete(synchronize_session=False) - session.commit() - except SQLAlchemyError as e: - session.rollback() - logger.exception(f"Error deleting additional initializer '{initializer_id}': {e}") - raise - @abc.abstractmethod def _init_storage_io(self) -> None: """ diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py index f9723ce7cb..12a1dea54d 100644 --- a/pyrit/memory/memory_models.py +++ b/pyrit/memory/memory_models.py @@ -38,7 +38,6 @@ from pyrit.common.utils import to_sha256 from pyrit.models import ( SEED_RESPONSE_JSON_SCHEMA_METADATA_KEY, - AdditionalInitializer, AtomicAttackEvaluationIdentifier, AtomicAttackIdentifier, AttackIdentifier, @@ -423,50 +422,6 @@ def __init_subclass__(cls, **kwargs: Any) -> None: ) -class AdditionalInitializerEntry(DomainBackedEntry[AdditionalInitializer]): - """Persistence row for an ``AdditionalInitializer``.""" - - __tablename__ = "AdditionalInitializers" - __table_args__ = {"extend_existing": True} - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - initializer_name: Mapped[str] = mapped_column(String(64), nullable=False) - parameters: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) - order_index: Mapped[int | None] = mapped_column(INTEGER, nullable=True) - - @classmethod - def from_domain_model(cls, domain_model: AdditionalInitializer) -> Self: - """ - Build an unsaved additional-initializer row from its domain model. - - Args: - domain_model (AdditionalInitializer): The domain model this entry persists. - - Returns: - Self: A new, unsaved row. - """ - return cls( - id=domain_model.id, - initializer_name=domain_model.initializer_name, - parameters=domain_model.parameters, - order_index=domain_model.order_index, - ) - - def to_domain_model(self) -> AdditionalInitializer: - """ - Convert this row back into its domain model. - - Returns: - AdditionalInitializer: The reconstructed additional initializer. - """ - return AdditionalInitializer( - id=self.id, - initializer_name=self.initializer_name, - parameters=self.parameters, - order_index=self.order_index, - ) - - T = TypeVar("T", bound=ComponentIdentifier) diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py index 5b666173d8..757942b3b7 100644 --- a/pyrit/models/__init__.py +++ b/pyrit/models/__init__.py @@ -22,7 +22,6 @@ from pyrit.common.lazy_imports import get_lazy_dir, resolve_lazy_export if TYPE_CHECKING: - from pyrit.models.additional_initializer import AdditionalInitializer from pyrit.models.catalog import ( ScenarioDatasetSizeCap, ScenarioDatasetSummary, @@ -165,7 +164,6 @@ _LAZY_EXPORTS: dict[str, str] = { "ALLOWED_CHAT_MESSAGE_ROLES": "pyrit.models.messages.chat_message", - "AdditionalInitializer": "pyrit.models.additional_initializer", "AtomicAttackEvaluationIdentifier": "pyrit.models.identifiers", "AtomicAttackIdentifier": "pyrit.models.identifiers", "AttackIdentifier": "pyrit.models.identifiers", diff --git a/pyrit/models/additional_initializer.py b/pyrit/models/additional_initializer.py deleted file mode 100644 index cc0d5b77b2..0000000000 --- a/pyrit/models/additional_initializer.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Persisted additional initializers that run after the ``.pyrit_conf`` baseline.""" - -from typing import Any -from uuid import uuid4 - -from pydantic import BaseModel, Field, field_validator - -from pyrit.models.identifiers import validate_registry_name - - -class AdditionalInitializer(BaseModel): - """ - A user-added initializer that runs after the ``.pyrit_conf`` baseline, in stored order. - - Unlike the config baseline (which is fixed in ``.pyrit_conf``), additional initializers are - persisted in Central Memory and appended to the startup sequence. Multiple rows may reference - the same ``initializer_name``; each is its own invocation, identified by ``id``. - """ - - id: str = Field(default_factory=lambda: str(uuid4()), description="Stable unique row id.") - initializer_name: str = Field(..., description="Initializer registry name.") - parameters: dict[str, Any] | None = Field( - default=None, - description="JSON-serializable parameters for this initializer invocation.", - ) - order_index: int | None = Field( - default=None, - ge=0, - description="Optional zero-based position among the additional initializers.", - ) - - @field_validator("initializer_name") - @classmethod - def _validate_initializer_name(cls, value: str) -> str: - validate_registry_name(value) - return value diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py index 712e114257..fe7d2eb5a6 100644 --- a/pyrit/setup/configuration_loader.py +++ b/pyrit/setup/configuration_loader.py @@ -661,10 +661,8 @@ async def initialize_pyrit_async(self, *, raise_on_initializer_error: bool = Tru """ Initialize PyRIT with the loaded configuration. - Resolves the ``.pyrit_conf`` baseline initializers to instances and calls the core - ``initialize_pyrit_async`` function. This method is intentionally unaware of any - persisted additional initializers: consumers such as ``pyrit.backend.main.lifespan`` - run those after the baseline. + Resolves the ``.pyrit_conf`` initializers to instances and calls the core + ``initialize_pyrit_async`` function. Args: raise_on_initializer_error: Whether initializer resolution, loading, validation, or execution diff --git a/pyrit/setup/initialization.py b/pyrit/setup/initialization.py index 22bbfbb847..5226ea2075 100644 --- a/pyrit/setup/initialization.py +++ b/pyrit/setup/initialization.py @@ -167,7 +167,7 @@ async def initialize_pyrit_async( # Combine directly provided initializers with those loaded from scripts. all_initializers: list[PyRITInitializer] = list(initializers) if initializers else [] - # Load additional initializers from scripts — the registry owns turning + # Load initializers from scripts — the registry owns turning # external script files into initializer instances. if initialization_scripts: from pyrit.registry import InitializerRegistry diff --git a/tests/unit/backend/test_initializer_service.py b/tests/unit/backend/test_initializer_service.py index 053d109eb1..d5dbe783a7 100644 --- a/tests/unit/backend/test_initializer_service.py +++ b/tests/unit/backend/test_initializer_service.py @@ -5,7 +5,6 @@ Tests for backend initializer service and routes. """ -from typing import Literal from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -18,13 +17,12 @@ from pyrit.backend.models.common import PaginationInfo from pyrit.backend.models.initializers import ( ApplyInitializerResponse, - BaselineInitializerSetting, - InitializerSettingsResponse, + ConfiguredInitializerSetting, ListRegisteredInitializersResponse, RegisteredInitializer, ) from pyrit.backend.services.initializer_service import InitializerService, get_initializer_service -from pyrit.models import AdditionalInitializer, Parameter +from pyrit.models import Parameter from pyrit.registry import InitializerMetadata @@ -203,218 +201,8 @@ async def test_get_initializer_returns_none_for_missing(self) -> None: assert result is None -class TestInitializerServiceSettings: - """Tests for baseline + additional initializer settings behavior.""" - - async def test_list_initializer_settings_returns_baseline_and_additional(self) -> None: - metadata = [ - _make_initializer_metadata(registry_name="target", class_name="TargetInitializer"), - _make_initializer_metadata(registry_name="widget", class_name="WidgetInitializer"), - _make_initializer_metadata(registry_name="custom", class_name="CustomInitializer"), - ] - baseline_initializers = [ - BaselineInitializerSetting( - initializer_name="target", - parameters={"tags": ["baseline"]}, - order_index=0, - ), - BaselineInitializerSetting( - initializer_name="widget", - parameters={"mode": "baseline"}, - order_index=1, - ), - ] - additional = [ - AdditionalInitializer(id="a1", initializer_name="custom", parameters={"tags": ["extra"]}, order_index=0), - AdditionalInitializer(id="a2", initializer_name="target", order_index=1), - ] - - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._registry.get_all_registered_class_metadata.return_value = metadata - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = additional - - result = await service.list_initializer_settings_async(baseline_initializers=baseline_initializers) - - assert [item.initializer_name for item in result.baseline] == ["target", "widget"] - assert [item.order_index for item in result.baseline] == [0, 1] - assert result.baseline[0].parameters == {"tags": ["baseline"]} - - assert [item.id for item in result.additional] == ["a1", "a2"] - assert [item.initializer_name for item in result.additional] == ["custom", "target"] - assert result.additional[0].parameters == {"tags": ["extra"]} - - async def test_list_initializer_settings_shows_all_configured_baseline_initializers(self) -> None: - """The read-only baseline list reflects exactly what ``.pyrit_conf`` configured to run, - preserving order, with no initializer types filtered out.""" - metadata = [ - _make_initializer_metadata(registry_name="target", class_name="TargetInitializer"), - _make_initializer_metadata(registry_name="scorer", class_name="ScorerInitializer"), - _make_initializer_metadata(registry_name="technique", class_name="TechniqueInitializer"), - _make_initializer_metadata(registry_name="load_default_datasets", class_name="LoadDefaultDatasets"), - ] - baseline_initializers = [ - BaselineInitializerSetting(initializer_name="technique", order_index=0), - BaselineInitializerSetting(initializer_name="target", order_index=1), - BaselineInitializerSetting(initializer_name="scorer", order_index=2), - BaselineInitializerSetting(initializer_name="load_default_datasets", order_index=3), - ] - - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._registry.get_all_registered_class_metadata.return_value = metadata - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [] - - result = await service.list_initializer_settings_async(baseline_initializers=baseline_initializers) - - assert [item.initializer_name for item in result.baseline] == [ - "technique", - "target", - "scorer", - "load_default_datasets", - ] - assert [item.order_index for item in result.baseline] == [0, 1, 2, 3] - - async def test_list_initializer_settings_passes_through_unregistered_names(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._registry.get_all_registered_class_metadata.return_value = [] - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [ - AdditionalInitializer(id="a1", initializer_name="gone") - ] - - result = await service.list_initializer_settings_async(baseline_initializers=[]) - - assert result.additional[0].initializer_name == "gone" - - async def test_create_additional_initializer_validates_and_persists(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - - result = await service.create_additional_initializer_async( - initializer_name="target", - parameters={"tags": ["saved"]}, - order_index=2, - ) - - service._registry.create_and_configure.assert_called_once_with( - "target", - initializer_params={"tags": ["saved"]}, - ) - service._memory.add_additional_initializer.assert_called_once() - assert result.initializer_name == "target" - assert result.parameters == {"tags": ["saved"]} - assert result.order_index == 2 - assert result.id - - async def test_create_additional_initializer_appends_after_existing_when_order_index_missing(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [ - AdditionalInitializer(id="a1", initializer_name="target", order_index=0), - AdditionalInitializer(id="a2", initializer_name="widget", order_index=3), - ] - - result = await service.create_additional_initializer_async( - initializer_name="target", - parameters=None, - order_index=None, - ) - - assert result.order_index == 4 - - async def test_create_additional_initializer_starts_at_zero_when_none_exist(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [] - - result = await service.create_additional_initializer_async( - initializer_name="target", - parameters=None, - order_index=None, - ) - - assert result.order_index == 0 - - async def test_update_additional_initializer_preserves_existing_order_when_missing(self) -> None: - existing = AdditionalInitializer( - id="a1", initializer_name="target", parameters={"tags": ["old"]}, order_index=7 - ) - - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [existing] - - result = await service.update_additional_initializer_async( - initializer_id="a1", - parameters={"tags": ["new"]}, - order_index=None, - ) - - assert result.order_index == 7 - - async def test_update_additional_initializer_preserves_id_and_name(self) -> None: - existing = AdditionalInitializer(id="a1", initializer_name="target", parameters={"tags": ["old"]}) - - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [existing] - - result = await service.update_additional_initializer_async( - initializer_id="a1", - parameters={"tags": ["new"]}, - order_index=5, - ) - - service._registry.create_and_configure.assert_called_once_with( - "target", - initializer_params={"tags": ["new"]}, - ) - service._memory.add_additional_initializer.assert_called_once() - assert result == AdditionalInitializer( - id="a1", - initializer_name="target", - parameters={"tags": ["new"]}, - order_index=5, - ) - - async def test_update_additional_initializer_raises_key_error_when_missing(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [] - - with pytest.raises(KeyError): - await service.update_additional_initializer_async( - initializer_id="missing", parameters=None, order_index=None - ) - - async def test_delete_additional_initializer_calls_memory(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._memory = MagicMock() - service._registry = MagicMock() - - await service.delete_additional_initializer_async(initializer_id="a1") - - service._memory.delete_additional_initializer.assert_called_once_with(initializer_id="a1") +class TestInitializerServiceApply: + """Tests for applying initializers immediately.""" async def test_apply_initializer_uses_explicit_parameters(self) -> None: initializer = MagicMock() @@ -425,7 +213,6 @@ async def test_apply_initializer_uses_explicit_parameters(self) -> None: service = InitializerService() service._registry = MagicMock() service._registry.create_and_configure.return_value = initializer - service._memory = MagicMock() result = await service.apply_initializer_async( initializer_name="target", @@ -453,7 +240,6 @@ async def test_apply_initializer_with_no_parameters(self) -> None: service = InitializerService() service._registry = MagicMock() service._registry.create_and_configure.return_value = initializer - service._memory = MagicMock() result = await service.apply_initializer_async(initializer_name="target") @@ -468,74 +254,10 @@ async def test_apply_initializer_propagates_validation_errors(self) -> None: service = InitializerService() service._registry = MagicMock() service._registry.create_and_configure.side_effect = ValueError("Unknown parameter") - service._memory = MagicMock() with pytest.raises(ValueError, match="Unknown parameter"): await service.apply_initializer_async(initializer_name="target") - async def test_run_additional_initializers_runs_each_in_order(self) -> None: - first = MagicMock() - first.validate = MagicMock() - first.initialize_async = AsyncMock(return_value=None) - second = MagicMock() - second.validate = MagicMock() - second.initialize_async = AsyncMock(return_value=None) - - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._registry.create_and_configure.side_effect = [first, second] - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [ - AdditionalInitializer(id="a1", initializer_name="target", parameters={"tags": ["one"]}, order_index=0), - AdditionalInitializer(id="a2", initializer_name="widget", order_index=1), - ] - - await service.run_additional_initializers_async() - - assert service._registry.create_and_configure.call_args_list[0].args == ("target",) - assert service._registry.create_and_configure.call_args_list[0].kwargs == { - "initializer_params": {"tags": ["one"]} - } - assert service._registry.create_and_configure.call_args_list[1].args == ("widget",) - first.initialize_async.assert_awaited_once() - second.initialize_async.assert_awaited_once() - - async def test_run_additional_initializers_no_op_when_empty(self) -> None: - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [] - - await service.run_additional_initializers_async() - - service._registry.create_and_configure.assert_not_called() - - async def test_run_additional_initializers_isolates_failures(self) -> None: - failing = MagicMock() - failing.supported_parameters = [] - failing.validate = MagicMock(side_effect=ValueError("missing required environment variable")) - healthy = MagicMock() - healthy.supported_parameters = [] - healthy.validate = MagicMock() - healthy.initialize_async = AsyncMock(return_value=None) - - with patch.object(InitializerService, "__init__", lambda self: None): - service = InitializerService() - service._registry = MagicMock() - service._registry.create_and_configure.side_effect = [failing, healthy] - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [ - AdditionalInitializer(id="bad", initializer_name="target", order_index=0), - AdditionalInitializer(id="good", initializer_name="widget", order_index=1), - ] - - await service.run_additional_initializers_async() - - failing.initialize_async.assert_not_called() - healthy.initialize_async.assert_awaited_once() - # ============================================================================ # Route Tests @@ -550,49 +272,11 @@ def _service_with_parameters(parameters: list[Parameter]) -> InitializerService: with patch.object(InitializerService, "__init__", lambda self: None): service = InitializerService() service._registry = MagicMock() - service._memory = MagicMock() - service._memory.get_additional_initializers.return_value = [] configured = MagicMock() configured.supported_parameters = parameters service._registry.create_and_configure.return_value = configured return service - async def test_create_rejects_value_that_violates_declared_type(self) -> None: - service = self._service_with_parameters([Parameter(name="days", description="d", default=30, param_type=int)]) - - with pytest.raises(ValueError, match="days"): - await service.create_additional_initializer_async( - initializer_name="refresh_datasets", - parameters={"days": "abc"}, - order_index=None, - ) - - service._memory.add_additional_initializer.assert_not_called() - - async def test_create_accepts_value_that_matches_declared_type(self) -> None: - service = self._service_with_parameters([Parameter(name="days", description="d", default=30, param_type=int)]) - - result = await service.create_additional_initializer_async( - initializer_name="refresh_datasets", - parameters={"days": 7}, - order_index=0, - ) - - assert result.parameters == {"days": 7} - service._memory.add_additional_initializer.assert_called_once() - - async def test_create_rejects_out_of_set_list_value(self) -> None: - service = self._service_with_parameters( - [Parameter(name="tags", description="d", default=["a"], param_type=list[Literal["a", "b"]])] - ) - - with pytest.raises(ValueError, match="tags"): - await service.create_additional_initializer_async( - initializer_name="target", - parameters={"tags": ["bogus"]}, - order_index=None, - ) - async def test_apply_rejects_value_that_violates_declared_type(self) -> None: service = self._service_with_parameters([Parameter(name="days", description="d", default=30, param_type=int)]) @@ -701,104 +385,38 @@ def test_get_initializer_returns_404_when_not_found(self, client: TestClient) -> assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_initializer_settings_returns_200(self, client: TestClient) -> None: - with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service: - mock_service = MagicMock() - mock_service.list_initializer_settings_async = AsyncMock( - return_value=InitializerSettingsResponse(baseline=[], additional=[]) - ) - mock_get_service.return_value = mock_service - - response = client.get("/api/initializers/settings") - - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["baseline"] == [] - assert body["additional"] == [] - - def test_post_additional_initializer_returns_created_row(self, client: TestClient) -> None: - created = AdditionalInitializer( - id="a1", - initializer_name="target", - parameters={"tags": ["saved"]}, - order_index=2, - ) - - with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service: - mock_service = MagicMock() - mock_service.create_additional_initializer_async = AsyncMock(return_value=created) - mock_get_service.return_value = mock_service - - response = client.post( - "/api/initializers/settings", - json={"initializer_name": "target", "parameters": {"tags": ["saved"]}, "order_index": 2}, - ) - - assert response.status_code == status.HTTP_201_CREATED - body = response.json() - assert body["id"] == "a1" - assert body["initializer_name"] == "target" - mock_service.create_additional_initializer_async.assert_called_once_with( + app.state.configured_initializers = [ + ConfiguredInitializerSetting( initializer_name="target", - parameters={"tags": ["saved"]}, - order_index=2, - ) - - def test_post_additional_initializer_returns_404_for_missing_initializer(self, client: TestClient) -> None: - with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service: - mock_service = MagicMock() - mock_service.create_additional_initializer_async = AsyncMock(side_effect=KeyError("missing")) - mock_get_service.return_value = mock_service - - response = client.post("/api/initializers/settings", json={"initializer_name": "unknown"}) - - assert response.status_code == status.HTTP_404_NOT_FOUND - - def test_put_additional_initializer_returns_updated_row(self, client: TestClient) -> None: - updated = AdditionalInitializer( - id="a1", - initializer_name="target", - parameters={"tags": ["new"]}, - order_index=5, - ) - - with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service: - mock_service = MagicMock() - mock_service.update_additional_initializer_async = AsyncMock(return_value=updated) - mock_get_service.return_value = mock_service - - response = client.put( - "/api/initializers/settings/a1", - json={"parameters": {"tags": ["new"]}, "order_index": 5}, + parameters={"tags": ["default"]}, + order_index=0, ) - + ] + try: + response = client.get("/api/initializers/settings") assert response.status_code == status.HTTP_200_OK - assert response.json()["parameters"] == {"tags": ["new"]} - mock_service.update_additional_initializer_async.assert_called_once_with( - initializer_id="a1", - parameters={"tags": ["new"]}, - order_index=5, - ) - - def test_put_additional_initializer_returns_404_when_missing(self, client: TestClient) -> None: - with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service: - mock_service = MagicMock() - mock_service.update_additional_initializer_async = AsyncMock(side_effect=KeyError("missing")) - mock_get_service.return_value = mock_service - - response = client.put("/api/initializers/settings/missing", json={}) - - assert response.status_code == status.HTTP_404_NOT_FOUND - - def test_delete_additional_initializer_returns_204(self, client: TestClient) -> None: - with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service: - mock_service = MagicMock() - mock_service.delete_additional_initializer_async = AsyncMock(return_value=None) - mock_get_service.return_value = mock_service - - response = client.delete("/api/initializers/settings/a1") - - assert response.status_code == status.HTTP_204_NO_CONTENT - mock_service.delete_additional_initializer_async.assert_called_once_with(initializer_id="a1") + assert response.json() == { + "configured": [ + { + "initializer_name": "target", + "parameters": {"tags": ["default"]}, + "order_index": 0, + } + ] + } + finally: + del app.state.configured_initializers + + def test_initializer_settings_mutation_routes_are_removed(self, client: TestClient) -> None: + assert client.post("/api/initializers/settings", json={}).status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert client.put("/api/initializers/settings/item", json={}).status_code in { + status.HTTP_404_NOT_FOUND, + status.HTTP_405_METHOD_NOT_ALLOWED, + } + assert client.delete("/api/initializers/settings/item").status_code in { + status.HTTP_404_NOT_FOUND, + status.HTTP_405_METHOD_NOT_ALLOWED, + } def test_post_apply_initializer_returns_200(self, client: TestClient) -> None: apply_result = ApplyInitializerResponse( diff --git a/tests/unit/backend/test_main.py b/tests/unit/backend/test_main.py index 170e314288..b754762b8c 100644 --- a/tests/unit/backend/test_main.py +++ b/tests/unit/backend/test_main.py @@ -27,11 +27,9 @@ class TestLifespan: async def test_lifespan_yields(self) -> None: """Test that lifespan delegates to ConfigurationLoader and yields.""" fake_config = ConfigurationLoader() - service = MagicMock(run_additional_initializers_async=AsyncMock()) with ( patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config), patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()) as init_mock, - patch("pyrit.backend.main.get_initializer_service", return_value=service), patch("pyrit.backend.main.setup_frontend"), ): async with lifespan(app): @@ -41,7 +39,6 @@ async def test_lifespan_yields(self) -> None: assert app.state.default_labels == {} assert app.state.max_concurrent_scenario_runs == fake_config.max_concurrent_scenario_runs assert app.state.allow_custom_initializers is False - service.run_additional_initializers_async.assert_awaited_once_with() async def test_lifespan_warns_when_custom_initializers_allowed(self) -> None: """Test that lifespan logs a warning when allow_custom_initializers is enabled.""" @@ -49,12 +46,6 @@ async def test_lifespan_warns_when_custom_initializers_allowed(self) -> None: with ( patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config), patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()), - patch( - "pyrit.backend.main.get_initializer_service", - return_value=MagicMock( - run_additional_initializers_async=AsyncMock(), - ), - ), patch("pyrit.backend.main.setup_frontend"), patch.object(logging.getLogger("pyrit.backend.main"), "warning") as mock_warning, ): @@ -69,12 +60,6 @@ async def test_lifespan_populates_default_labels_from_operator_and_operation(sel with ( patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config), patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()), - patch( - "pyrit.backend.main.get_initializer_service", - return_value=MagicMock( - run_additional_initializers_async=AsyncMock(), - ), - ), patch("pyrit.backend.main.setup_frontend"), ): async with lifespan(app): @@ -82,6 +67,26 @@ async def test_lifespan_populates_default_labels_from_operator_and_operation(sel assert app.state.default_labels == {"operator": "alice", "operation": "op-42"} + async def test_lifespan_exposes_configured_initializers(self) -> None: + """Test that the active config initializer sequence is exposed to API routes.""" + fake_config = ConfigurationLoader( + initializers=[ + {"name": "target", "args": {"tags": ["default"]}}, + "scorer", + ] + ) + with ( + patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config), + patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()), + patch("pyrit.backend.main.setup_frontend"), + ): + async with lifespan(app): + pass + + assert [item.initializer_name for item in app.state.configured_initializers] == ["target", "scorer"] + assert app.state.configured_initializers[0].parameters == {"tags": ["default"]} + assert [item.order_index for item in app.state.configured_initializers] == [0, 1] + async def test_lifespan_loads_explicit_config_as_override(self) -> None: """Test that PYRIT_CONFIG_FILE overlays the default configuration.""" fake_config = ConfigurationLoader() @@ -89,12 +94,6 @@ async def test_lifespan_loads_explicit_config_as_override(self) -> None: patch.dict(os.environ, {"PYRIT_CONFIG_FILE": "/tmp/foo.yaml"}, clear=False), patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config) as load_mock, patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()), - patch( - "pyrit.backend.main.get_initializer_service", - return_value=MagicMock( - run_additional_initializers_async=AsyncMock(), - ), - ), patch("pyrit.backend.main.setup_frontend"), ): async with lifespan(app): @@ -106,12 +105,10 @@ async def test_lifespan_configures_custom_initializer_source_from_config(self) - """Test that YAML config determines the custom script source.""" fake_config = ConfigurationLoader(custom_initializers_source="C:/yaml/initializers") registry = MagicMock() - service = MagicMock(run_additional_initializers_async=AsyncMock()) with ( patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config), patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()), patch("pyrit.backend.main.InitializerRegistry.get_registry_singleton", return_value=registry), - patch("pyrit.backend.main.get_initializer_service", return_value=service), patch("pyrit.backend.main.setup_frontend"), ): async with lifespan(app): @@ -126,24 +123,22 @@ async def test_lifespan_registers_stored_initializers_when_enabled(self) -> None call_order: list[str] = [] registry = MagicMock() registry.register_stored_initializers.side_effect = lambda: call_order.append("custom") - service = MagicMock(run_additional_initializers_async=AsyncMock()) async def initialize_async(*, raise_on_initializer_error: bool) -> None: assert raise_on_initializer_error is False - call_order.append("baseline") + call_order.append("configured") with ( patch.object(ConfigurationLoader, "load_with_overrides", return_value=fake_config), patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock(side_effect=initialize_async)), patch("pyrit.backend.main.InitializerRegistry.get_registry_singleton", return_value=registry), - patch("pyrit.backend.main.get_initializer_service", return_value=service), patch("pyrit.backend.main.setup_frontend"), ): async with lifespan(app): pass registry.register_stored_initializers.assert_called_once_with() - assert call_order == ["custom", "baseline"] + assert call_order == ["custom", "configured"] async def test_lifespan_downloads_blob_config_to_temporary_file(self) -> None: """Test that an Azure Blob config URI is materialized and removed after loading.""" @@ -171,12 +166,6 @@ def load_config(*, config_file: Path, env_akv_ref: list[str] | None = None) -> C ), patch.object(ConfigurationLoader, "load_with_overrides", side_effect=load_config), patch.object(ConfigurationLoader, "initialize_pyrit_async", new=AsyncMock()), - patch( - "pyrit.backend.main.get_initializer_service", - return_value=MagicMock( - run_additional_initializers_async=AsyncMock(), - ), - ), patch("pyrit.backend.main.setup_frontend"), ): async with lifespan(app): diff --git a/tests/unit/memory/test_additional_initializer_memory.py b/tests/unit/memory/test_additional_initializer_memory.py deleted file mode 100644 index 3195f71124..0000000000 --- a/tests/unit/memory/test_additional_initializer_memory.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import pytest - -from pyrit.memory.memory_models import AdditionalInitializerEntry -from pyrit.models import AdditionalInitializer - - -@pytest.mark.usefixtures("patch_central_database") -class TestAdditionalInitializerMemory: - def test_additional_initializer_entry_round_trips_real_domain_model(self, sqlite_instance) -> None: - initializer = AdditionalInitializer( - initializer_name="target", - parameters={"tags": ["default"]}, - order_index=3, - ) - - sqlite_instance.add_additional_initializer(initializer=initializer) - - entries = sqlite_instance._query_entries(AdditionalInitializerEntry) - - assert len(entries) == 1 - assert entries[0].to_domain_model() == initializer - assert sqlite_instance.get_additional_initializers() == [initializer] - - def test_add_additional_initializer_upserts_by_id(self, sqlite_instance) -> None: - initializer = AdditionalInitializer(initializer_name="target", order_index=1) - sqlite_instance.add_additional_initializer(initializer=initializer) - - updated = AdditionalInitializer(id=initializer.id, initializer_name="target", order_index=4) - sqlite_instance.add_additional_initializer(initializer=updated) - - assert sqlite_instance.get_additional_initializers() == [updated] - - def test_multiple_rows_per_initializer_name_are_kept(self, sqlite_instance) -> None: - first = AdditionalInitializer(initializer_name="target", order_index=0) - second = AdditionalInitializer(initializer_name="target", order_index=1) - - sqlite_instance.add_additional_initializer(initializer=first) - sqlite_instance.add_additional_initializer(initializer=second) - - assert sqlite_instance.get_additional_initializers() == [first, second] - - def test_get_additional_initializers_orders_by_order_index_then_id(self, sqlite_instance) -> None: - second = AdditionalInitializer(initializer_name="target", order_index=5) - first = AdditionalInitializer(initializer_name="scorer", order_index=2) - - sqlite_instance.add_additional_initializer(initializer=second) - sqlite_instance.add_additional_initializer(initializer=first) - - assert sqlite_instance.get_additional_initializers() == [first, second] - - def test_delete_additional_initializer_is_idempotent(self, sqlite_instance) -> None: - initializer = AdditionalInitializer(initializer_name="target") - - sqlite_instance.delete_additional_initializer(initializer_id=initializer.id) - sqlite_instance.add_additional_initializer(initializer=initializer) - sqlite_instance.delete_additional_initializer(initializer_id=initializer.id) - sqlite_instance.delete_additional_initializer(initializer_id=initializer.id) - - assert sqlite_instance.get_additional_initializers() == [] diff --git a/tests/unit/memory/test_migration.py b/tests/unit/memory/test_migration.py index 7cddacb318..ccbbb0f500 100644 --- a/tests/unit/memory/test_migration.py +++ b/tests/unit/memory/test_migration.py @@ -189,6 +189,23 @@ def test_scenario_progress_migration_adds_composite_index(): engine.dispose() +def test_migration_head_removes_additional_initializers_table(): + """The migration head removes the obsolete second initializer configuration source.""" + with tempfile.TemporaryDirectory() as temp_dir: + db_path = os.path.join(temp_dir, "additional-initializers-removal.db") + engine = create_engine(f"sqlite:///{db_path}") + try: + with engine.begin() as connection: + config = _config_for(connection) + command.upgrade(config, "4c9a6e1f2b7d") + assert "AdditionalInitializers" in set(inspect(connection).get_table_names()) + + command.upgrade(config, "head") + assert "AdditionalInitializers" not in set(inspect(connection).get_table_names()) + finally: + engine.dispose() + + def test_migration_online_mode(): """ Test that online migration configuration is valid. diff --git a/tests/unit/models/test_additional_initializer.py b/tests/unit/models/test_additional_initializer.py deleted file mode 100644 index 6812c48731..0000000000 --- a/tests/unit/models/test_additional_initializer.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import pytest -from pydantic import ValidationError - -from pyrit.models import AdditionalInitializer - - -def test_additional_initializer_defaults() -> None: - initializer = AdditionalInitializer(initializer_name="target") - - assert initializer.parameters is None - assert initializer.order_index is None - assert initializer.id - - -def test_additional_initializer_generates_unique_ids() -> None: - first = AdditionalInitializer(initializer_name="target") - second = AdditionalInitializer(initializer_name="target") - - assert first.id != second.id - - -def test_additional_initializer_rejects_invalid_registry_name() -> None: - with pytest.raises(ValidationError, match="Invalid registry name"): - AdditionalInitializer(initializer_name="Not Valid") From 3142031c38b3fbc92d9672c1c66ca83bbc41763f Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Tue, 1 Sep 2026 12:24:48 -0700 Subject: [PATCH 2/7] TEST: Align initializer authorization coverage Remove assertions for the deleted settings mutation routes after rebasing onto the config editor changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 348ff2fc-5014-444c-a6bc-d306afa6821c --- pyrit/backend/routes/initializers.py | 2 +- .../unit/backend/test_initializer_service.py | 19 ++----------------- tests/unit/backend/test_main.py | 2 +- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/pyrit/backend/routes/initializers.py b/pyrit/backend/routes/initializers.py index 59389110d4..3eb2cf5880 100644 --- a/pyrit/backend/routes/initializers.py +++ b/pyrit/backend/routes/initializers.py @@ -23,8 +23,8 @@ from pyrit.backend.models.initializers import ( ApplyInitializerRequest, ApplyInitializerResponse, - CustomInitializerListResponse, ConfiguredInitializerSetting, + CustomInitializerListResponse, InitializerSettingsResponse, ListRegisteredInitializersResponse, RegisterInitializerRequest, diff --git a/tests/unit/backend/test_initializer_service.py b/tests/unit/backend/test_initializer_service.py index d5dbe783a7..56be29ce12 100644 --- a/tests/unit/backend/test_initializer_service.py +++ b/tests/unit/backend/test_initializer_service.py @@ -449,28 +449,13 @@ def test_post_apply_initializer_returns_400_for_invalid_parameters(self, client: assert response.status_code == status.HTTP_400_BAD_REQUEST - @pytest.mark.parametrize( - ("method", "path", "json_body"), - [ - ("POST", "/api/initializers/settings", {"initializer_name": "target"}), - ("PUT", "/api/initializers/settings/a1", {}), - ("DELETE", "/api/initializers/settings/a1", None), - ("POST", "/api/initializers/target/apply", None), - ], - ) - def test_initializer_execution_routes_require_admin( - self, - client: TestClient, - method: str, - path: str, - json_body: dict[str, str] | None, - ) -> None: + def test_apply_initializer_requires_admin(self, client: TestClient) -> None: def reject_non_admin() -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Administrator access is required") app.dependency_overrides[require_admin] = reject_non_admin - response = client.request(method, path, json=json_body) + response = client.post("/api/initializers/target/apply") assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/unit/backend/test_main.py b/tests/unit/backend/test_main.py index b754762b8c..d91c59d0f6 100644 --- a/tests/unit/backend/test_main.py +++ b/tests/unit/backend/test_main.py @@ -118,7 +118,7 @@ async def test_lifespan_configures_custom_initializer_source_from_config(self) - registry.register_stored_initializers.assert_not_called() async def test_lifespan_registers_stored_initializers_when_enabled(self) -> None: - """Test that enabled custom initializers are registered before baseline initialization.""" + """Test that enabled custom initializers are registered before configured initialization.""" fake_config = ConfigurationLoader(allow_custom_initializers=True) call_order: list[str] = [] registry = MagicMock() From 7dc9e854832e5a6a73978a58bda10a62b81046a7 Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Tue, 1 Sep 2026 12:51:59 -0700 Subject: [PATCH 3/7] UI: Move initializers into configuration Show configured initializers as a read-only Configuration tab and remove the runtime apply path so initializer execution remains a startup concern. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 348ff2fc-5014-444c-a6bc-d306afa6821c --- doc/gui/0_gui.md | 11 +- frontend/e2e/touch-targets.spec.ts | 10 +- frontend/src/App.tsx | 4 +- .../Configuration/Configuration.test.tsx | 25 ++++ .../Configuration/Configuration.tsx | 7 +- .../ConfiguredInitializers.test.tsx | 27 ---- .../Initializers/ConfiguredInitializers.tsx | 39 ++---- .../Initializers/Initializers.styles.ts | 22 +-- .../Initializers/Initializers.test.tsx | 21 --- .../components/Initializers/Initializers.tsx | 44 ++---- .../src/components/Sidebar/Navigation.tsx | 13 -- frontend/src/services/api.ts | 12 -- frontend/src/types/index.ts | 10 -- pyrit/backend/models/initializers.py | 24 +--- pyrit/backend/routes/initializers.py | 43 +----- pyrit/backend/services/initializer_service.py | 86 +----------- pyrit/setup/initialization.py | 2 +- .../unit/backend/test_initializer_service.py | 132 +----------------- 18 files changed, 73 insertions(+), 459 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 44273a1e57..9680a3b234 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -179,22 +179,15 @@ Targets can also be auto-populated by adding the `target` initializer to your `~ ### Configuration Editor -The **Configuration** page provides administrator-only editing for the files and scripts used to configure PyRIT. It has three tabs: +The **Configuration** page provides administrator-only editing for the files and scripts used to configure PyRIT. It has four tabs: - **PyRIT Configuration** edits the active `.pyrit_conf` YAML file. The source may be a local file or an Azure Blob URI. Saving validates the configuration before replacing it. - **Environment & Secrets** lists the configured local dotenv files and Azure Key Vault bootstrap secrets. Content is loaded only after selecting a source. Saves validate the dotenv document and reject the update if the source changed since it was loaded. +- **Initializers** shows the read-only startup sequence from the active `.pyrit_conf`, in run order, along with the catalog of registered initializers. - **Custom Initializers** registers or removes Python initializer scripts. This tab requires `allow_custom_initializers: true`; scripts are stored in the configured local directory or Azure Blob container and must define a concrete `PyRITInitializer` subclass. Use **Reload** to discard local edits and fetch the latest source content. Saved configuration and environment changes take effect after restarting PyRIT. Custom initializer scripts execute under the backend service identity, so only trusted administrators should manage them. -### Initializers - -The **Initializers** page (in the left navigation) lets you review how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. - -**Configured initializers** are read-only. They come from your active configuration file (`~/.pyrit/.pyrit_conf`) and run in the order shown. Update the configuration file and restart the backend to change this sequence. - -Use **Apply now** to re-run a configured initializer immediately against the running backend — handy for picking up an environment or setting change without a restart. - --- ## Connection Health diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 5746b5b3e6..0ee5782633 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -454,10 +454,11 @@ test.describe("Mobile touch targets", () => { }); test("keeps the Initializer selector at least 44px", async ({ page }) => { - await page.goto("/initializers"); + await page.goto("/config"); + await page.getByRole("tab", { name: "Initializers", exact: true }).click(); await expectMinimumTouchTarget( - page.getByRole("combobox", { name: "Initializer to add" }) + page.getByRole("button", { name: "Browse available initializers" }) ); await expectNoDocumentOverflow(page); }); @@ -658,9 +659,10 @@ test("preserves compact desktop controls and existing sidebar dimensions", async page.getByRole("button", { name: "Expand inner targets" }) ); - await page.goto("/initializers"); + await page.goto("/config"); + await page.getByRole("tab", { name: "Initializers", exact: true }).click(); await expectCompactDesktopTarget( - page.getByRole("combobox", { name: "Initializer to add" }) + page.getByRole("button", { name: "Browse available initializers" }) ); await startChatWithMessages(page); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d03806696f..0564c0877d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import ChatWindow from './components/Chat/ChatWindow' import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' -import Initializers from './components/Initializers/Initializers' import Configuration from './components/Configuration/Configuration' import AttackHistory from './components/History/AttackHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' @@ -43,7 +42,6 @@ const VIEW_PATHS: Record = { chat: '/chat', history: '/history', targets: '/targets', - initializers: '/initializers', scenarios: '/scanner', configuration: '/config', } @@ -491,7 +489,7 @@ function App() { /> } /> - } /> + } /> } /> ({ updateEnvironmentFile: jest.fn(), }, initializersApi: { + getSettings: jest.fn(), + listRegistered: jest.fn(), listCustom: jest.fn(), register: jest.fn(), unregister: jest.fn(), @@ -64,6 +66,19 @@ describe('Configuration', () => { }) mockedInitializersApi.register.mockResolvedValue() mockedInitializersApi.unregister.mockResolvedValue() + mockedInitializersApi.getSettings.mockResolvedValue({ + configured: [{ initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }], + }) + mockedInitializersApi.listRegistered.mockResolvedValue({ + items: [{ + initializer_name: 'target', + initializer_type: 'TargetInitializer', + description: 'Registers targets.', + required_env_vars: [], + supported_parameters: [], + }], + pagination: { limit: 200, has_more: false }, + }) }) it('should load and display configuration content', async () => { @@ -204,4 +219,14 @@ describe('Configuration', () => { }) }) + it('should show configured initializers without a runtime apply action', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('tab', { name: 'Initializers' })) + + expect(await screen.findByTestId('configured-initializer-row-0')).toHaveTextContent('Registers targets.') + expect(screen.queryByRole('button', { name: 'Apply now' })).not.toBeInTheDocument() + }) + }) diff --git a/frontend/src/components/Configuration/Configuration.tsx b/frontend/src/components/Configuration/Configuration.tsx index 66980e0ac7..322604798f 100644 --- a/frontend/src/components/Configuration/Configuration.tsx +++ b/frontend/src/components/Configuration/Configuration.tsx @@ -16,6 +16,7 @@ import { ArrowSyncRegular, SaveRegular } from '@fluentui/react-icons' import { configurationApi } from '@/services/api' import { toApiError } from '@/services/errors' import EditorWorkspace from '@/components/EditorWorkspace' +import Initializers from '@/components/Initializers/Initializers' import { useConfigurationStyles } from './Configuration.styles' import CustomInitializerFiles from './CustomInitializerFiles' @@ -27,7 +28,7 @@ interface StatusMessage { text: string } -type ConfigurationTab = 'configuration' | 'environment' | 'custom-initializers' +type ConfigurationTab = 'configuration' | 'environment' | 'initializers' | 'custom-initializers' export default function Configuration() { const styles = useConfigurationStyles() @@ -102,6 +103,7 @@ export default function Configuration() { if ( data.value === 'configuration' || data.value === 'environment' + || data.value === 'initializers' || data.value === 'custom-initializers' ) { setSelectedTab(data.value) @@ -117,6 +119,7 @@ export default function Configuration() { PyRIT Configuration Environment & Secrets + Initializers Custom Initializers @@ -128,6 +131,8 @@ export default function Configuration() { {selectedTab === 'custom-initializers' ? ( + ) : selectedTab === 'initializers' ? ( + ) : selectedTab === 'environment' ? ( ) : loading ? ( diff --git a/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx b/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx index 69c3f15629..266f1b7e00 100644 --- a/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx +++ b/frontend/src/components/Initializers/ConfiguredInitializers.test.tsx @@ -1,5 +1,4 @@ import { render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import type { ConfiguredInitializerSetting, RegisteredInitializer } from '@/types' @@ -20,8 +19,6 @@ const registeredInitializers: RegisteredInitializer[] = [ }, ] -const onApply = jest.fn() - describe('ConfiguredInitializers', () => { beforeEach(() => { jest.clearAllMocks() @@ -33,7 +30,6 @@ describe('ConfiguredInitializers', () => { , ) @@ -52,7 +48,6 @@ describe('ConfiguredInitializers', () => { , ) @@ -65,27 +60,6 @@ describe('ConfiguredInitializers', () => { expect(within(row).getByText(/"tags"/)).toBeInTheDocument() }) - it('applies the configured initializer with its parameters', async () => { - const user = userEvent.setup() - const items: ConfiguredInitializerSetting[] = [ - { initializer_name: 'target', parameters: { tags: ['default'] }, order_index: 0 }, - ] - - render( - - - , - ) - - await user.click(screen.getByRole('button', { name: 'Apply now' })) - - expect(onApply).toHaveBeenCalledWith('target:0', 'target', { tags: ['default'] }) - }) - it('falls back to a placeholder for a name that is no longer registered', () => { const items: ConfiguredInitializerSetting[] = [ { initializer_name: 'ghost', parameters: null, order_index: 1 }, @@ -96,7 +70,6 @@ describe('ConfiguredInitializers', () => { , ) diff --git a/frontend/src/components/Initializers/ConfiguredInitializers.tsx b/frontend/src/components/Initializers/ConfiguredInitializers.tsx index 380559a855..18093b3ec6 100644 --- a/frontend/src/components/Initializers/ConfiguredInitializers.tsx +++ b/frontend/src/components/Initializers/ConfiguredInitializers.tsx @@ -1,4 +1,4 @@ -import { Button, Text } from '@fluentui/react-components' +import { Text } from '@fluentui/react-components' import type { ConfiguredInitializerSetting, RegisteredInitializer } from '@/types' @@ -9,19 +9,11 @@ import { useInitializersStyles } from './Initializers.styles' interface ConfiguredInitializersProps { items: ConfiguredInitializerSetting[] registeredInitializers: RegisteredInitializer[] - applyingInitializerKey?: string | null - onApply: ( - key: string, - initializerName: string, - parameters?: Record | null, - ) => Promise } export default function ConfiguredInitializers({ items, registeredInitializers, - applyingInitializerKey = null, - onApply, }: ConfiguredInitializersProps) { const styles = useInitializersStyles() @@ -42,7 +34,6 @@ export default function ConfiguredInitializers({ {items.map((item: ConfiguredInitializerSetting) => { const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) const initializerKey = `${item.initializer_name}:${item.order_index}` - const isApplying = applyingInitializerKey === initializerKey return (
-
-
- {item.initializer_name} - {initializer.description || 'No description available.'} - - Required env vars: {initializer.required_env_vars.length > 0 - ? initializer.required_env_vars.join(', ') - : 'None'} - - Order: {item.order_index} -
- +
+ {item.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + + Order: {item.order_index}
Parameters diff --git a/frontend/src/components/Initializers/Initializers.styles.ts b/frontend/src/components/Initializers/Initializers.styles.ts index 6e2c22c5b7..07c344252a 100644 --- a/frontend/src/components/Initializers/Initializers.styles.ts +++ b/frontend/src/components/Initializers/Initializers.styles.ts @@ -6,18 +6,12 @@ export const useInitializersStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', - height: '100%', + flex: 1, width: '100%', minWidth: 0, + minHeight: 0, maxWidth: '100%', gap: tokens.spacingVerticalL, - padding: tokens.spacingVerticalXXL, - overflowX: 'hidden', - overflowY: 'auto', - backgroundColor: tokens.colorNeutralBackground2, - '@media (max-width: 600px)': { - padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, - }, }, header: { display: 'flex', @@ -26,11 +20,6 @@ export const useInitializersStyles = makeStyles({ flexWrap: 'wrap', gap: tokens.spacingVerticalM, }, - headerText: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXS, - }, headerActions: { display: 'flex', alignItems: 'center', @@ -83,13 +72,6 @@ export const useInitializersStyles = makeStyles({ borderRadius: tokens.borderRadiusLarge, backgroundColor: tokens.colorNeutralBackground1, }, - configuredHeader: { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: tokens.spacingHorizontalM, - flexWrap: 'wrap', - }, titleGroup: { display: 'flex', flexDirection: 'column', diff --git a/frontend/src/components/Initializers/Initializers.test.tsx b/frontend/src/components/Initializers/Initializers.test.tsx index 12db5da6a0..9e59f3449c 100644 --- a/frontend/src/components/Initializers/Initializers.test.tsx +++ b/frontend/src/components/Initializers/Initializers.test.tsx @@ -15,7 +15,6 @@ jest.mock('@/services/api', () => ({ initializersApi: { getSettings: jest.fn(), listRegistered: jest.fn(), - applyNow: jest.fn(), }, })) @@ -69,11 +68,6 @@ describe('Initializers', () => { items: [targetInitializer], pagination: { limit: 200, has_more: false }, }) - mockedInitializersApi.applyNow.mockResolvedValue({ - initializer_name: 'target', - status: 'applied', - applied_parameters: { tags: ['configured'] }, - }) }) it('should show loading state initially', () => { @@ -119,21 +113,6 @@ describe('Initializers', () => { expect(screen.getByTestId('available-initializer-row-target')).toHaveTextContent('Registers targets.') }) - it('should apply a configured initializer', async () => { - const user = userEvent.setup() - renderInitializers() - - const configuredRow = await screen.findByTestId('configured-initializer-row-0') - await user.click(within(configuredRow).getByRole('button', { name: 'Apply now' })) - - await waitFor(() => { - expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('target', { - parameters: { tags: ['configured'] }, - }) - expect(screen.getByText('Applied target.')).toBeInTheDocument() - }) - }) - it('should keep configured settings visible when catalog loading fails', async () => { mockedInitializersApi.listRegistered.mockRejectedValue(new Error('Service Unavailable')) diff --git a/frontend/src/components/Initializers/Initializers.tsx b/frontend/src/components/Initializers/Initializers.tsx index e24a72aadb..06a7d2f3a5 100644 --- a/frontend/src/components/Initializers/Initializers.tsx +++ b/frontend/src/components/Initializers/Initializers.tsx @@ -12,7 +12,7 @@ import ConfiguredInitializers from './ConfiguredInitializers' import { useInitializersStyles } from './Initializers.styles' interface StatusMessage { - intent: 'success' | 'error' + intent: 'error' text: string } @@ -27,7 +27,6 @@ export default function Initializers() { const [loading, setLoading] = useState(true) const [statusMessage, setStatusMessage] = useState(null) const [refetchCount, setRefetchCount] = useState(0) - const [applyingInitializerKey, setApplyingInitializerKey] = useState(null) useEffect(() => { let cancelled = false @@ -73,31 +72,12 @@ export default function Initializers() { setRefetchCount((currentCount: number) => currentCount + 1) } - const handleApply = async ( - key: string, - initializerName: string, - parameters?: Record | null, - ): Promise => { - setApplyingInitializerKey(key) - try { - await initializersApi.applyNow(initializerName, { parameters }) - setStatusMessage({ intent: 'success', text: `Applied ${initializerName}.` }) - } catch (error) { - setStatusMessage({ intent: 'error', text: toApiError(error).detail }) - } finally { - setApplyingInitializerKey(null) - } - } - return ( -
+
-
- Initializers - - Browse every registered initializer and review the startup sequence from the active .pyrit_conf. - -
+ + Browse every registered initializer and review the startup sequence from the active .pyrit_conf. +
) : ( - <> - - + )} -
+ ) } diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index 816eb40fe7..f79620b8e3 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -15,7 +15,6 @@ import { HistoryRegular, PersonFeedbackRegular, ScriptRegular, - WrenchRegular, OpenRegular, WeatherMoonRegular, WeatherSunnyRegular, @@ -30,7 +29,6 @@ export type ViewName = | 'chat' | 'history' | 'targets' - | 'initializers' | 'configuration' | 'scenarios' @@ -131,17 +129,6 @@ export default function Navigation({ onClick={() => onNavigate('targets')} /> -