diff --git a/frontend/src/agents/NewAgent.test.tsx b/frontend/src/agents/NewAgent.test.tsx index 74301b4c..4c3e41d1 100644 --- a/frontend/src/agents/NewAgent.test.tsx +++ b/frontend/src/agents/NewAgent.test.tsx @@ -34,6 +34,9 @@ const mocks = vi.hoisted(() => { connections: [] as unknown[], catalog: [] as unknown[], deleteAgent: vi.fn(() => jsonResponse({})), + updateAgent: vi.fn< + (id: string, data: FormData, token: string | null) => Promise + >(() => jsonResponse({})), guardrailsProps: vi.fn(), detailsProps: vi.fn(), }; @@ -65,7 +68,7 @@ vi.mock('../api/services/userService', () => ({ getAgentFolders: () => jsonResponse({ folders: [] }), getAgent: mocks.getAgent, createAgent: mocks.createAgent, - updateAgent: () => jsonResponse({}), + updateAgent: mocks.updateAgent, deleteAgent: mocks.deleteAgent, createPrompt: () => jsonResponse({}), }, @@ -149,6 +152,24 @@ vi.mock('../modals/ConfirmationModal', () => ({ ) : null, })); vi.mock('../preferences/PromptsModal', () => ({ default: () => null })); +vi.mock('./components/SponsorConfirmModal', () => ({ + default: ({ + confirmation, + onConfirm, + }: { + confirmation: { resources: { key: string; name: string }[] } | null; + onConfirm: (keys: string[]) => void; + }) => + confirmation ? ( + + ) : null, +})); vi.mock('../navigation/SectionPills', () => ({ default: () =>
, })); @@ -202,6 +223,8 @@ describe('NewAgent form', () => { mocks.dispatch.mockClear(); mocks.getAgent.mockClear(); mocks.createAgent.mockClear(); + mocks.updateAgent.mockReset(); + mocks.updateAgent.mockImplementation(() => jsonResponse({})); mocks.tools = null; mocks.connections = []; mocks.catalog = []; @@ -362,6 +385,105 @@ describe('NewAgent form', () => { expect(groups).toEqual(['Telegram', 'agents.form.toolsPopup.groupCustom']); }); + const renderEdit = async (agent: Record) => { + mocks.getAgent.mockImplementationOnce(() => + jsonResponse({ + id: 'agent-1', + name: 'Shared', + description: 'd', + status: 'published', + agent_type: 'classic', + prompt_id: 'default', + access: 'editor', + allowed_actions: ['edit', 'view'], + ...agent, + }), + ); + await act(async () => { + root.render( + + + } + /> + + , + ); + }); + }; + + // An editor doesn't list the owner's private tools, but must still be able + // to take one off the agent. + it("adds a remove-only row for an attached tool the editor can't list", async () => { + mocks.tools = [{ id: 'mine', name: 'api_tool', display_name: 'My API' }]; + await renderEdit({ + tools: ['owners'], + tool_details: [ + { id: 'owners', name: 'jira', display_name: 'Owner Jira' }, + ], + }); + const toolPicker = Array.from( + container.querySelectorAll('[data-testid="picker"]'), + ).find((picker) => picker.textContent?.includes('Owner Jira'))!; + const groups = Array.from(toolPicker.querySelectorAll('[data-group]')).map( + (item) => item.getAttribute('data-group'), + ); + expect(groups).toEqual([ + 'agents.form.toolsPopup.groupCustom', + 'agents.form.toolsPopup.groupAttached', + ]); + }); + + it('asks before sponsoring and retries the save with the confirmation', async () => { + mocks.updateAgent.mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ + success: false, + code: 'sponsor_confirmation_required', + message: 'confirm', + resources: [ + { key: 'tool:t1', type: 'tool', id: 't1', name: 'Jira' }, + ], + audience: { + teams: ['Support'], + api_key: true, + public_link: false, + webhook: false, + }, + }), + { status: 409 }, + ), + ), + ); + await renderEdit({}); + const name = container.querySelector( + 'input[placeholder="agents.form.placeholders.agentName"]', + )!; + await act(async () => setNativeValue(name, 'Renamed')); + await act(async () => buttonByText('agents.form.buttons.save').click()); + const confirm = container.querySelector( + '[data-testid="confirm-sponsor"]', + )!; + expect(confirm.textContent).toBe('Jira'); + // The refusal is a question, not an error. + expect(container.querySelector('[data-variant="destructive"]')).toBeNull(); + expect(mocks.updateAgent).toHaveBeenCalledTimes(1); + const first = mocks.updateAgent.mock.calls[0][1]; + expect(first.get('confirm_sponsor')).toBeNull(); + + await act(async () => confirm.click()); + expect(mocks.updateAgent).toHaveBeenCalledTimes(2); + const retried = mocks.updateAgent.mock.calls[1][1]; + expect(JSON.parse(retried.get('confirm_sponsor') as string)).toEqual([ + 'tool:t1', + ]); + expect( + container.querySelector('[data-testid="confirm-sponsor"]'), + ).toBeNull(); + }); + it('labels every picker with a floating label', async () => { await render(); const labels = Array.from( diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index cfa89e10..3382f068 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -99,7 +99,14 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice'; import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar'; import AgentPreviewSheet from './components/AgentPreviewSheet'; import SectionShell from '../navigation/SectionShell'; +import SponsorConfirmModal from './components/SponsorConfirmModal'; import SponsoredResourcesNotice from './components/SponsoredResourcesNotice'; +import { + readSponsorRefusal, + type SponsorConfirmation, + sponsorNotAllowedMessage, + withAttachedToolRows, +} from './sponsorConsent'; import { Agent, ResourceSponsor, ToolSummary } from './types'; import WorkflowBuilder from './workflow/WorkflowBuilder'; @@ -129,7 +136,7 @@ const extractApiError = async ( }; export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const dispatch = useDispatch(); const { agentId } = useParams(); @@ -191,6 +198,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { new Set(), ); const [selectedTools, setSelectedTools] = useState([]); + // Tools on the agent when it loaded: the owner's private ones don't come + // back in the caller's own tool list, so the picker adds a row for each. + const [attachedTools, setAttachedTools] = useState([]); + // A save the server refused until the caller agrees that what they added + // runs with their access; ``retry`` repeats it with ``confirm_sponsor``. + const [sponsorRequest, setSponsorRequest] = useState<{ + confirmation: SponsorConfirmation; + retry: (keys: string[]) => void; + } | null>(null); const [selectedModelIds, setSelectedModelIds] = useState>( new Set(), ); @@ -303,6 +319,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { // same owner-agnostic details the pickers show. const resolveSponsoredName = useCallback( (sponsor: ResourceSponsor): string => { + if (sponsor.name) return sponsor.name; if (sponsor.type === 'source') return resolveSourceLabel(sponsor.id); if (sponsor.type === 'prompt') { return ( @@ -338,6 +355,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { return [...items, ...unlisted]; }, [resolveSourceLabel, selectedSourceIds, sourceDocs, t]); + // The caller's tools, plus a remove-only row for each tool on the agent + // they can't list (the owner's private tools on a shared agent). + const toolItems = useMemo( + () => + withAttachedToolRows(userTools, attachedTools, { + group: t('agents.form.toolsPopup.groupAttached'), + description: t('agents.form.toolsPopup.attachedHint'), + }), + [attachedTools, t, userTools], + ); + const selectedSourceNames = useMemo( () => Array.from(selectedSourceIds) @@ -421,7 +449,35 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { } }; - const handleSaveDraft = async () => { + /** + * The message for a refused save, or null when the refusal only asks the + * caller to confirm sponsoring what they added (the dialog then opens and + * ``retry`` repeats the save with their confirmation). + */ + const saveFailureMessage = async ( + response: Response, + fallback: string, + retry: (keys: string[]) => void, + ): Promise => { + const refusal = await readSponsorRefusal(response); + if (refusal?.kind === 'confirm') { + setSponsorRequest({ confirmation: refusal.confirmation, retry }); + return null; + } + if (refusal?.kind === 'notAllowed') + return sponsorNotAllowedMessage(t, i18n.language, refusal.resources); + return extractApiError(response, fallback); + }; + + const appendSponsorConfirmation = ( + formData: FormData, + confirmSponsor?: string[], + ) => { + if (confirmSponsor && confirmSponsor.length > 0) + formData.append('confirm_sponsor', JSON.stringify(confirmSponsor)); + }; + + const handleSaveDraft = async (confirmSponsor?: string[]) => { const formData = new FormData(); formData.append('name', agent.name); formData.append('description', agent.description); @@ -479,6 +535,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { if (effectiveMode === 'new' && validatedFolderId) { formData.append('folder_id', validatedFolderId); } + appendSponsorConfirmation(formData, confirmSponsor); try { setDraftLoading(true); @@ -488,12 +545,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ? await userService.createAgent(formData, token) : await userService.updateAgent(agent.id || '', formData, token); if (!response.ok) { - setSubmitError( - await extractApiError( - response, - t('agents.form.errors.saveDraftFailed'), - ), + const message = await saveFailureMessage( + response, + t('agents.form.errors.saveDraftFailed'), + (keys) => handleSaveDraft(keys), ); + if (message) setSubmitError(message); return; } const data = await response.json(); @@ -514,7 +571,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { } }; - const handlePublish = async () => { + const handlePublish = async (confirmSponsor?: string[]) => { const formData = new FormData(); formData.append('name', agent.name); formData.append('description', agent.description); @@ -572,6 +629,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { if (effectiveMode === 'new' && validatedFolderId) { formData.append('folder_id', validatedFolderId); } + appendSponsorConfirmation(formData, confirmSponsor); try { setPublishLoading(true); @@ -581,12 +639,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ? await userService.createAgent(formData, token) : await userService.updateAgent(agent.id || '', formData, token); if (!response.ok) { - setSubmitError( - await extractApiError( - response, - t('agents.form.errors.publishFailed'), - ), + const message = await saveFailureMessage( + response, + t('agents.form.errors.publishFailed'), + (keys) => handlePublish(keys), ); + if (message) setSubmitError(message); return; } const data = await response.json(); @@ -824,7 +882,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const agentSourceIds = selectedSourceIdsFromAgent(data); setSelectedSourceIds(new Set(agentSourceIds)); - if (data.tool_details) setSelectedTools(data.tool_details); + if (data.tool_details) { + setSelectedTools(data.tool_details); + setAttachedTools(data.tool_details); + } if (data.status === 'draft') setEffectiveMode('draft'); if (data.json_schema) { const jsonText = JSON.stringify(data.json_schema, null, 2); @@ -1006,7 +1067,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { shape="pill" disabled={isDraftBlocked()} loading={draftLoading} - onClick={handleSaveDraft} + onClick={() => handleSaveDraft()} > {t('agents.form.buttons.saveDraft')} @@ -1030,7 +1091,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { shape="pill" disabled={!isPublishable() || !hasChanges} loading={publishLoading} - onClick={handlePublish} + onClick={() => handlePublish()} className="flex-1 sm:flex-none" > {modeConfig[effectiveMode].buttonText} @@ -1187,7 +1248,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { open={isToolsPopupOpen} onOpenChange={setIsToolsPopupOpen} title={t('agents.form.toolsPopup.title')} - items={userTools} + items={toolItems} selectedIds={selectedTools.map((tool) => tool.id)} onToggle={(id) => { const exists = selectedTools.find((t) => t.id === id); @@ -1195,12 +1256,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setSelectedTools(selectedTools.filter((t) => t.id !== id)); return; } - const item = userTools.find((t) => t.id === id); + const item = toolItems.find((t) => t.id === id); const raw = rawUserTools.find((t) => t.id === id); + const attached = attachedTools.find((t) => t.id === id); if (!item) return; setSelectedTools([ ...selectedTools, - { + attached ?? { id: item.id, name: raw?.name || item.label, display_name: item.label, @@ -1638,6 +1700,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { )}
+ setSponsorRequest(null)} + onConfirm={(keys) => { + const retry = sponsorRequest?.retry; + setSponsorRequest(null); + retry?.(keys); + }} + /> handlePublish()} > {t('agents.form.buttons.publish')} diff --git a/frontend/src/agents/components/SponsorConfirmModal.test.tsx b/frontend/src/agents/components/SponsorConfirmModal.test.tsx new file mode 100644 index 00000000..423f612d --- /dev/null +++ b/frontend/src/agents/components/SponsorConfirmModal.test.tsx @@ -0,0 +1,128 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +vi.mock('../../hooks', () => ({ + useMediaQuery: () => ({ isMobile: false, isDesktop: true }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && 'teams' in opts ? `${key}:${opts.teams}` : key, + i18n: { language: 'en' }, + }), +})); + +import type { SponsorConfirmation } from '../sponsorConsent'; +import SponsorConfirmModal from './SponsorConfirmModal'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const confirmation = ( + over: Partial = {}, +): SponsorConfirmation => ({ + resources: [ + { key: 'tool:t1', type: 'tool', id: 't1', name: 'Jira' }, + { key: 'source:s1', type: 'source', id: 's1', name: null }, + ], + audience: { + teams: ['Support', 'Sales'], + api_key: false, + public_link: false, + webhook: false, + ...over, + }, +}); + +describe('SponsorConfirmModal', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async ( + value: SponsorConfirmation | null, + onConfirm = vi.fn(), + onCancel = vi.fn(), + ) => { + await act(async () => { + root.render( + , + ); + }); + return { onConfirm, onCancel }; + }; + + const dialogText = () => + document.querySelector('[data-slot="modal-content"]')?.textContent ?? ''; + + const button = (label: string) => + Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === label, + ) as HTMLButtonElement; + + it('renders nothing without a pending confirmation', async () => { + await render(null); + expect(document.querySelector('[data-slot="modal-content"]')).toBeNull(); + }); + + it('names each resource, with a fallback for an unnamed one', async () => { + await render(confirmation()); + const text = dialogText(); + expect(text).toContain('agents.form.sponsorConfirm.title'); + expect(text).toContain('Jira'); + expect(text).toContain('agents.form.sponsorConfirm.types.tool'); + expect(text).toContain('agents.form.sponsors.unknownItem'); + expect(text).toContain('agents.form.sponsorConfirm.types.source'); + }); + + it('lists the teams and only the outside entry points that are on', async () => { + await render(confirmation({ public_link: true })); + const text = dialogText(); + expect(text).toContain( + 'agents.form.sponsorConfirm.audienceTeams:Support and Sales', + ); + expect(text).toContain('agents.form.sponsorConfirm.audiencePublicLink'); + expect(text).not.toContain('agents.form.sponsorConfirm.audienceApiKey'); + expect(text).not.toContain('agents.form.sponsorConfirm.audienceWebhook'); + }); + + it('shows the API and webhook lines when the agent has them', async () => { + await render(confirmation({ teams: [], api_key: true, webhook: true })); + const text = dialogText(); + expect(text).not.toContain('audienceTeams'); + expect(text).toContain('agents.form.sponsorConfirm.audienceApiKey'); + expect(text).toContain('agents.form.sponsorConfirm.audienceWebhook'); + }); + + it('confirms with every resource key', async () => { + const { onConfirm, onCancel } = await render(confirmation()); + await act(async () => { + button('agents.form.sponsorConfirm.confirm').click(); + }); + expect(onConfirm).toHaveBeenCalledWith(['tool:t1', 'source:s1']); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('cancels without confirming', async () => { + const { onConfirm, onCancel } = await render(confirmation()); + await act(async () => { + button('cancel').click(); + }); + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/agents/components/SponsorConfirmModal.tsx b/frontend/src/agents/components/SponsorConfirmModal.tsx new file mode 100644 index 00000000..30b0252b --- /dev/null +++ b/frontend/src/agents/components/SponsorConfirmModal.tsx @@ -0,0 +1,162 @@ +import { + Database, + Info, + KeyRound, + Link2, + ScrollText, + UsersRound, + Webhook, + Wrench, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Card } from '@/components/ui/card'; +import { ListRow, ListRows } from '@/components/ui/list-row'; +import { Modal, ModalActions } from '@/components/ui/modal'; +import { SectionHeader } from '@/components/ui/section-header'; + +import { intlLocale } from '../../utils/dateTimeUtils'; +import type { SponsorConfirmation, SponsorResource } from '../sponsorConsent'; + +type SponsorConfirmModalProps = { + /** The pending request from a refused save; null keeps the modal closed. */ + confirmation: SponsorConfirmation | null; + /** Retry the save with these `confirm_sponsor` keys. */ + onConfirm: (keys: string[]) => void; + onCancel: () => void; + pending?: boolean; +}; + +const TYPE_ICONS: Record = { + tool: Wrench, + source: Database, + prompt: ScrollText, +}; + +function IconSquare({ icon: Icon }: { icon: typeof Wrench }) { + return ( + + + + ); +} + +/** + * Asks an editor before a save makes tools, sources or prompts the agent's + * owner can't use run with the editor's access, and says who will reach them + * through the agent. Confirming retries the save with `confirm_sponsor`. + */ +export default function SponsorConfirmModal({ + confirmation, + onConfirm, + onCancel, + pending = false, +}: SponsorConfirmModalProps) { + const { t, i18n } = useTranslation(); + if (!confirmation) return null; + + const { resources, audience } = confirmation; + const listFormat = new Intl.ListFormat(intlLocale(i18n.language), { + type: 'conjunction', + }); + const audienceRows: { key: string; icon: typeof Wrench; text: string }[] = []; + if (audience.teams.length > 0) { + audienceRows.push({ + key: 'teams', + icon: UsersRound, + text: t('agents.form.sponsorConfirm.audienceTeams', { + teams: listFormat.format(audience.teams), + interpolation: { escapeValue: false }, + }), + }); + } + if (audience.api_key) { + audienceRows.push({ + key: 'api', + icon: KeyRound, + text: t('agents.form.sponsorConfirm.audienceApiKey'), + }); + } + if (audience.public_link) { + audienceRows.push({ + key: 'link', + icon: Link2, + text: t('agents.form.sponsorConfirm.audiencePublicLink'), + }); + } + if (audience.webhook) { + audienceRows.push({ + key: 'webhook', + icon: Webhook, + text: t('agents.form.sponsorConfirm.audienceWebhook'), + }); + } + if (audienceRows.length === 0) { + audienceRows.push({ + key: 'editors', + icon: UsersRound, + text: t('agents.form.sponsorConfirm.audienceEditors'), + }); + } + + return ( + { + if (!open) onCancel(); + }} + isPerformingTask={pending} + title={t('agents.form.sponsorConfirm.title')} + description={t('agents.form.sponsorConfirm.description')} + footer={ + onConfirm(resources.map((item) => item.key))} + pending={pending} + /> + } + > +
+ + + {resources.map((item) => ( + } + title={item.name || t('agents.form.sponsors.unknownItem')} + description={t(`agents.form.sponsorConfirm.types.${item.type}`)} + /> + ))} + + +
+ +
    + {audienceRows.map(({ key, icon: Icon, text }) => ( +
  • + + {text} +
  • + ))} +
+
+ + + + {t('agents.form.sponsorConfirm.stopNote')} + + +
+
+ ); +} diff --git a/frontend/src/agents/sponsorConsent.test.tsx b/frontend/src/agents/sponsorConsent.test.tsx new file mode 100644 index 00000000..77cf60c7 --- /dev/null +++ b/frontend/src/agents/sponsorConsent.test.tsx @@ -0,0 +1,85 @@ +import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover'; +import { readSponsorRefusal, withAttachedToolRows } from './sponsorConsent'; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('readSponsorRefusal', () => { + const resources = [{ key: 'tool:t1', type: 'tool', id: 't1', name: 'Jira' }]; + const audience = { + teams: ['Support'], + api_key: true, + public_link: false, + webhook: false, + }; + + it('reads a confirmation request and leaves the body readable', async () => { + const response = json(409, { + success: false, + code: 'sponsor_confirmation_required', + message: 'm', + resources, + audience, + }); + expect(await readSponsorRefusal(response)).toEqual({ + kind: 'confirm', + confirmation: { resources, audience }, + }); + expect((await response.json()).message).toBe('m'); + }); + + it('reads a refusal the caller may not sponsor', async () => { + const response = json(403, { + code: 'sponsor_not_allowed', + message: 'no', + resources, + }); + expect(await readSponsorRefusal(response)).toEqual({ + kind: 'notAllowed', + resources, + }); + }); + + it('ignores other failures', async () => { + expect( + await readSponsorRefusal(json(409, { code: 'stale_write' })), + ).toBeNull(); + expect(await readSponsorRefusal(json(403, { message: 'x' }))).toBeNull(); + expect( + await readSponsorRefusal(new Response('', { status: 502 })), + ).toBeNull(); + }); +}); + +describe('withAttachedToolRows', () => { + const labels = { group: 'On this agent', description: 'Remove only' }; + const listed: MultiSelectPopoverItem[] = [ + { id: 'mine', label: 'My tool', group: 'Custom' }, + ]; + + it('adds a removable row for an attached tool the caller cannot list', () => { + const items = withAttachedToolRows( + listed, + [ + { id: 'mine', name: 'api_tool', display_name: 'My tool' }, + { id: 'owners', name: 'jira', display_name: 'Owner Jira' }, + ], + labels, + ); + expect(items.map((item) => item.id)).toEqual(['mine', 'owners']); + const locked = items[1]; + expect(locked.label).toBe('Owner Jira'); + expect(locked.group).toBe('On this agent'); + expect(locked.description).toBe('Remove only'); + expect(locked.disabled).toBeFalsy(); + }); + + it('keeps one row per tool when it is listed or repeated', () => { + const tool = { id: 'owners', name: 'jira', display_name: 'Owner Jira' }; + const items = withAttachedToolRows(listed, [tool, tool], labels); + expect(items.map((item) => item.id)).toEqual(['mine', 'owners']); + }); +}); diff --git a/frontend/src/agents/sponsorConsent.tsx b/frontend/src/agents/sponsorConsent.tsx new file mode 100644 index 00000000..f034b737 --- /dev/null +++ b/frontend/src/agents/sponsorConsent.tsx @@ -0,0 +1,121 @@ +import type { TFunction } from 'i18next'; + +import ToolIcon from '../components/ToolIcon'; +import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover'; +import { intlLocale } from '../utils/dateTimeUtils'; +import { getToolDisplayName } from '../utils/toolUtils'; +import type { ToolSummary } from './types'; + +/** A resource a save would have run with the caller's access. */ +export type SponsorResource = { + /** `":"`, the value `confirm_sponsor` takes. */ + key: string; + type: 'tool' | 'source' | 'prompt'; + id: string; + name: string | null; +}; + +/** Who reaches the agent's resources (409 `audience`). */ +export type SponsorAudience = { + /** Names of the teams the agent is shared with. */ + teams: string[]; + /** The agent has an API key (the API and the website widget). */ + api_key: boolean; + public_link: boolean; + webhook: boolean; +}; + +/** What a save asks the caller to agree to before it goes ahead. */ +export type SponsorConfirmation = { + resources: SponsorResource[]; + audience: SponsorAudience; +}; + +export type SponsorRefusal = + | { kind: 'confirm'; confirmation: SponsorConfirmation } + | { kind: 'notAllowed'; resources: SponsorResource[] }; + +/** + * The sponsor refusal a failed agent or workflow save carries, if any. + * + * A save that would run a resource the owner can't use with the caller's + * access answers 409 `sponsor_confirmation_required` until it is retried + * with `confirm_sponsor`; one the caller may not sponsor at all answers 403 + * `sponsor_not_allowed`. Reads a clone, so the caller can still read the + * body for any other error. + */ +export async function readSponsorRefusal( + response: Response, +): Promise { + if (response.status !== 409 && response.status !== 403) return null; + if (typeof response.clone !== 'function') return null; + let body: { + code?: string; + resources?: SponsorResource[]; + audience?: SponsorAudience; + }; + try { + body = await response.clone().json(); + } catch { + return null; + } + if (body?.code === 'sponsor_confirmation_required' && body.audience) { + return { + kind: 'confirm', + confirmation: { + resources: body.resources ?? [], + audience: body.audience, + }, + }; + } + if (body?.code === 'sponsor_not_allowed') { + return { kind: 'notAllowed', resources: body.resources ?? [] }; + } + return null; +} + +/** The error for a save that attached resources the caller may not sponsor. */ +export function sponsorNotAllowedMessage( + t: TFunction, + language: string, + resources: SponsorResource[], +): string { + const names = new Intl.ListFormat(intlLocale(language), { + type: 'conjunction', + }).format( + resources.map((item) => item.name || t('agents.form.sponsors.unknownItem')), + ); + return t('agents.form.sponsors.notAllowed', { + names, + interpolation: { escapeValue: false }, + }); +} + +/** + * The tool picker's rows plus one per attached tool the caller can't list. + * + * An editor of someone else's agent doesn't see the owner's private tools in + * their own list, so without a row of its own such a tool reads as selected + * with nothing to switch it off. The row only removes it (and puts it back + * before saving); it offers nothing to open or configure. + */ +export function withAttachedToolRows( + items: MultiSelectPopoverItem[], + attached: ToolSummary[], + labels: { group: string; description: string }, +): MultiSelectPopoverItem[] { + const seen = new Set(items.map((item) => item.id)); + const extra: MultiSelectPopoverItem[] = []; + for (const tool of attached) { + if (!tool?.id || seen.has(tool.id)) continue; + seen.add(tool.id); + extra.push({ + id: tool.id, + label: getToolDisplayName(tool), + icon: , + group: labels.group, + description: labels.description, + }); + } + return extra.length ? [...items, ...extra] : items; +} diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 4c19f24d..f718707a 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -6,15 +6,28 @@ export type ToolSummary = { display_name: string; }; +/** Why a sponsored item stopped running (`ResourceSponsor.reason`). */ +export type ResourceSponsorReason = + 'sponsor_cannot_edit_agent' | 'sponsor_cannot_edit_resource'; + /** A tool, source or prompt that runs with the editor's access who added it. */ export type ResourceSponsor = { + /** `":"`, the value `confirm_sponsor` takes. */ + key?: string; type: 'tool' | 'source' | 'prompt'; id: string; + /** The item's name, looked up whoever owns it. */ + name?: string | null; user_id: string; /** The person's email when on file, else their user id. */ label: string; - /** False once that person can no longer edit the agent or use the item. */ + state?: 'active' | 'inactive'; + /** Null while it runs; else whether the person lost the agent or the item. */ + reason?: ResourceSponsorReason | null; + /** False once that person can no longer edit the agent, or own or edit the item. */ active: boolean; + /** The reader may take a stopped item over by confirming it on a save. */ + can_confirm?: boolean; }; export type Agent = { diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 00bdf3c5..2f7b98c8 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -49,6 +49,12 @@ import { agentEditPath, agentsListPath } from '../paths'; import { ActionMenu } from '@/components/ui/dropdown-menu'; import AgentPageHeader from '../AgentPageHeader'; import AgentPreviewSheet from '../components/AgentPreviewSheet'; +import SponsorConfirmModal from '../components/SponsorConfirmModal'; +import { + readSponsorRefusal, + type SponsorConfirmation, + sponsorNotAllowedMessage, +} from '../sponsorConsent'; import WorkflowDetailsSheet, { type WorkflowDetailsSave, } from './components/WorkflowDetailsSheet'; @@ -192,7 +198,7 @@ const NODE_TYPES: NodeTypes = { }; function WorkflowBuilderInner() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const token = useSelector(selectToken); const sourceDocs = useSelector(selectSourceDocs); @@ -222,6 +228,12 @@ function WorkflowBuilderInner() { const [showPrimaryActionSpinner, setShowPrimaryActionSpinner] = useState(false); const [publishErrors, setPublishErrors] = useState([]); + // A save refused until the caller agrees that a node resource the owner + // can't use runs with their access; ``retry`` saves again with that. + const [sponsorRequest, setSponsorRequest] = useState<{ + confirmation: SponsorConfirmation; + retry: (keys: string[]) => void; + } | null>(null); const [errorContext, setErrorContext] = useState<'preview' | 'publish'>( 'publish', ); @@ -1310,7 +1322,10 @@ function WorkflowBuilderInner() { : false; const persistWorkflow = useCallback( - async (navigateAfterSuccess: boolean): Promise => { + async ( + navigateAfterSuccess: boolean, + confirmSponsor?: string[], + ): Promise => { setPublishErrors([]); setErrorContext('publish'); @@ -1329,10 +1344,29 @@ function WorkflowBuilderInner() { if (workflowId) { const updateResponse = await userService.updateWorkflow( workflowId, - workflowPayload, + confirmSponsor && confirmSponsor.length > 0 + ? { ...workflowPayload, confirm_sponsor: confirmSponsor } + : workflowPayload, token, ); if (!updateResponse.ok) { + // A node tool or source the owner can't use would run with the + // caller's access: ask first, then save again with their answer. + const refusal = await readSponsorRefusal(updateResponse); + if (refusal?.kind === 'confirm') { + setSponsorRequest({ + confirmation: refusal.confirmation, + retry: (keys) => { + void persistWorkflow(navigateAfterSuccess, keys); + }, + }); + return false; + } + if (refusal?.kind === 'notAllowed') { + throw new Error( + sponsorNotAllowedMessage(t, i18n.language, refusal.resources), + ); + } const errorData = await updateResponse.json().catch(() => ({})); throw new Error( errorData.message || @@ -1499,6 +1533,7 @@ function WorkflowBuilderInner() { folderId, navigateBackToAgents, t, + i18n.language, ], ); @@ -1946,6 +1981,15 @@ function WorkflowBuilderInner() { }} /> + setSponsorRequest(null)} + onConfirm={(keys) => { + const retry = sponsorRequest?.retry; + setSponsorRequest(null); + retry?.(keys); + }} + />