Confirm before an agent runs your resources, and let editors remove the owner's tools

Saving an agent or workflow that the server refuses with
sponsor_confirmation_required opens a dialog naming each tool, source or
prompt and who reaches it through the agent (its teams, API key and
widget, public link, webhook), then saves again with confirm_sponsor. A
save the caller may not sponsor shows why instead of the raw message.
The tool picker adds a remove-only row for each attached tool the editor
can't list, so the owner's private tools can be taken off the agent.
This commit is contained in:
arc53-machine committed 2026-09-29 16:33:50 +01:00
1 parent 335241e2e6
commit b2f1742409
15 files changed
+932 -46

No files matched your search

+123 -1
View File
@@ -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<unknown>
>(() => 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 ? (
<button
type="button"
data-testid="confirm-sponsor"
onClick={() => onConfirm(confirmation.resources.map((r) => r.key))}
>
{confirmation.resources.map((r) => r.name).join(',')}
</button>
) : null,
}));
vi.mock('../navigation/SectionPills', () => ({
default: () => <div data-testid="section-pills" />,
}));
@@ -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<string, unknown>) => {
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(
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
<Routes>
<Route
path="/agents/edit/:agentId"
element={<NewAgent mode="edit" />}
/>
</Routes>
</MemoryRouter>,
);
});
};
// 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<HTMLInputElement>(
'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<HTMLButtonElement>(
'[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(
+91 -20
View File
@@ -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<ToolSummary[]>([]);
// 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<ToolSummary[]>([]);
// 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<Set<string>>(
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<string | null> => {
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')}
</Button>
@@ -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' }) {
</Card>
)}
</div>
<SponsorConfirmModal
confirmation={sponsorRequest?.confirmation ?? null}
onCancel={() => setSponsorRequest(null)}
onConfirm={(keys) => {
const retry = sponsorRequest?.retry;
setSponsorRequest(null);
retry?.(keys);
}}
/>
<ConfirmationModal
message={t('agents.deleteConfirmation')}
modalState={deleteConfirmation}
@@ -1754,7 +1825,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
shape="pill"
disabled={!isPublishable()}
loading={publishLoading}
onClick={handlePublish}
onClick={() => handlePublish()}
>
{t('agents.form.buttons.publish')}
</Button>
@@ -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<string, unknown>) =>
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['audience']> = {},
): 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(
<SponsorConfirmModal
confirmation={value}
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
});
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();
});
});
@@ -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<SponsorResource['type'], typeof Wrench> = {
tool: Wrench,
source: Database,
prompt: ScrollText,
};
function IconSquare({ icon: Icon }: { icon: typeof Wrench }) {
return (
<span className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md">
<Icon className="size-4" />
</span>
);
}
/**
* 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 (
<Modal
open
onOpenChange={(open) => {
if (!open) onCancel();
}}
isPerformingTask={pending}
title={t('agents.form.sponsorConfirm.title')}
description={t('agents.form.sponsorConfirm.description')}
footer={
<ModalActions
cancelLabel={t('cancel')}
onCancel={onCancel}
submitLabel={t('agents.form.sponsorConfirm.confirm')}
onSubmit={() => onConfirm(resources.map((item) => item.key))}
pending={pending}
/>
}
>
<div className="flex flex-col gap-6">
<Card variant="outline" padding="none" className="overflow-hidden">
<ListRows>
{resources.map((item) => (
<ListRow
key={item.key}
leading={<IconSquare icon={TYPE_ICONS[item.type] ?? Wrench} />}
title={item.name || t('agents.form.sponsors.unknownItem')}
description={t(`agents.form.sponsorConfirm.types.${item.type}`)}
/>
))}
</ListRows>
</Card>
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sponsorConfirm.audienceTitle')}
/>
<ul className="flex flex-col gap-2">
{audienceRows.map(({ key, icon: Icon, text }) => (
<li
key={key}
className="text-foreground flex items-start gap-2 text-sm"
>
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span className="min-w-0">{text}</span>
</li>
))}
</ul>
</section>
<Alert role="note">
<Info />
<AlertDescription>
{t('agents.form.sponsorConfirm.stopNote')}
</AlertDescription>
</Alert>
</div>
</Modal>
);
}
@@ -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('<html>', { 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']);
});
});
+121
View File
@@ -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 = {
/** `"<type>:<id>"`, 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<SponsorRefusal | null> {
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: <ToolIcon name={tool.name} className="size-5" />,
group: labels.group,
description: labels.description,
});
}
return extra.length ? [...items, ...extra] : items;
}
+14 -1
View File
@@ -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 = {
/** `"<type>:<id>"`, 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 = {
@@ -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<string[]>([]);
// 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<boolean> => {
async (
navigateAfterSuccess: boolean,
confirmSponsor?: string[],
): Promise<boolean> => {
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() {
}}
/>
</AgentPreviewSheet>
<SponsorConfirmModal
confirmation={sponsorRequest?.confirmation ?? null}
onCancel={() => setSponsorRequest(null)}
onConfirm={(keys) => {
const retry = sponsorRequest?.retry;
setSponsorRequest(null);
retry?.(keys);
}}
/>
<ConfirmationModal
message={
workflowName
+23 -3
View File
@@ -2254,7 +2254,9 @@
"groupBuiltin": "Integriert",
"groupDefault": "Integriert",
"groupCustom": "Benutzerdefiniert",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "An diesem Agenten",
"attachedHint": "Von jemand anderem hinzugefügt. Du kannst es entfernen."
},
"modelsPopup": {
"title": "Modelle auswählen",
@@ -2363,11 +2365,29 @@
"published": "Veröffentlicht"
},
"sponsors": {
"attachNote": "Tools, Quellen und Prompts, die du aus deiner eigenen Bibliothek hinzufügst, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff.",
"attachNote": "Tools, Quellen und Prompts, die du hinzufügst und die der Eigentümer nicht nutzen kann, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff. Du musst sie besitzen oder bearbeiten können und wirst um Bestätigung gebeten.",
"publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.",
"addedBy": "Hinzugefügt von {{person}}: {{names}}",
"unavailable": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und hat keinen Zugriff mehr. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
"unknownItem": "Unbenanntes Element"
"unknownItem": "Unbenanntes Element",
"notAllowed": "Du kannst {{names}} diesem Agenten nicht hinzufügen. Der Eigentümer kann sie nicht nutzen, und du kannst nur teilen, was du besitzt oder bearbeiten kannst."
},
"sponsorConfirm": {
"title": "Mit deinem Zugriff ausführen?",
"description": "Der Eigentümer des Agenten kann diese nicht nutzen. Wenn du sie hinzufügst, laufen sie für alle, die diesen Agenten nutzen, mit deinem Zugriff.",
"audienceTitle": "Wer sie über diesen Agenten nutzen kann",
"audienceTeams": "Mitglieder von {{teams}}",
"audienceApiKey": "Alle mit dem API-Schlüssel des Agenten, auch über sein Website-Widget",
"audiencePublicLink": "Alle mit dem öffentlichen Link des Agenten",
"audienceWebhook": "Alles, was den Webhook des Agenten aufruft",
"audienceEditors": "Alle, die diesen Agenten öffnen können",
"stopNote": "Sie laufen nicht mehr, wenn du den Bearbeitungszugriff auf sie oder auf diesen Agenten verlierst. Bis jemand, der sie bearbeiten kann, sie erneut hinzufügt, bleiben sie angehängt, tun aber nichts.",
"confirm": "Mit meinem Zugriff hinzufügen",
"types": {
"tool": "Tool",
"source": "Quelle",
"prompt": "Prompt"
}
}
},
"logs": {
+23 -3
View File
@@ -2270,7 +2270,9 @@
"groupBuiltin": "Built-in",
"groupDefault": "Built-in",
"groupCustom": "Custom",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "On this agent",
"attachedHint": "Added by someone else. You can remove it."
},
"modelsPopup": {
"title": "Select Models",
@@ -2381,11 +2383,29 @@
"published": "Published"
},
"sponsors": {
"attachNote": "Tools, sources and prompts you add from your own library run with your access for everyone who uses this agent.",
"attachNote": "Tools, sources and prompts you add that the owner can't use run with your access for everyone who uses this agent. You need to own them or be able to edit them, and you'll be asked to confirm.",
"publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.",
"addedBy": "Added by {{person}}: {{names}}",
"unavailable": "Not running: {{names}}. {{person}} added them and no longer has access. Remove them or choose ones the owner can use.",
"unknownItem": "Unnamed item"
"unknownItem": "Unnamed item",
"notAllowed": "You can't add {{names}} to this agent. The owner can't use them, and you can only share what you own or can edit."
},
"sponsorConfirm": {
"title": "Run with your access?",
"description": "The agent's owner can't use these. If you add them, they run with your access for everyone who uses this agent.",
"audienceTitle": "Who can use them through this agent",
"audienceTeams": "Members of {{teams}}",
"audienceApiKey": "Anyone with the agent's API key, including its website widget",
"audiencePublicLink": "Anyone with the agent's public link",
"audienceWebhook": "Anything that calls the agent's webhook",
"audienceEditors": "Everyone who can open this agent",
"stopNote": "They stop running if you lose edit access to them or to this agent. Until someone who can edit them adds them again, they stay attached but do nothing.",
"confirm": "Add with my access",
"types": {
"tool": "Tool",
"source": "Source",
"prompt": "Prompt"
}
}
},
"logs": {
+23 -3
View File
@@ -2254,7 +2254,9 @@
"groupBuiltin": "Integradas",
"groupDefault": "Integradas",
"groupCustom": "Personalizadas",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "En este agente",
"attachedHint": "Lo añadió otra persona. Puedes quitarlo."
},
"modelsPopup": {
"title": "Seleccionar Modelos",
@@ -2363,11 +2365,29 @@
"published": "Publicado"
},
"sponsors": {
"attachNote": "Las herramientas, fuentes y prompts que añadas desde tu propia biblioteca se ejecutan con tu acceso para todos los que usen este agente.",
"attachNote": "Las herramientas, fuentes y prompts que añadas y que el propietario no pueda usar se ejecutan con tu acceso para todos los que usen este agente. Debes ser su propietario o poder editarlos, y se te pedirá que lo confirmes.",
"publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.",
"addedBy": "Añadido por {{person}}: {{names}}",
"unavailable": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no tiene acceso. Quítalos o elige otros que el propietario pueda usar.",
"unknownItem": "Elemento sin nombre"
"unknownItem": "Elemento sin nombre",
"notAllowed": "No puedes añadir {{names}} a este agente. El propietario no puede usarlos y solo puedes compartir lo que es tuyo o puedes editar."
},
"sponsorConfirm": {
"title": "¿Ejecutar con tu acceso?",
"description": "El propietario del agente no puede usarlos. Si los añades, se ejecutan con tu acceso para todos los que usen este agente.",
"audienceTitle": "Quién puede usarlos a través de este agente",
"audienceTeams": "Miembros de {{teams}}",
"audienceApiKey": "Cualquiera con la clave API del agente, incluido su widget web",
"audiencePublicLink": "Cualquiera con el enlace público del agente",
"audienceWebhook": "Todo lo que llame al webhook del agente",
"audienceEditors": "Todos los que pueden abrir este agente",
"stopNote": "Dejan de ejecutarse si pierdes el acceso de edición a ellos o a este agente. Hasta que alguien que pueda editarlos los vuelva a añadir, siguen adjuntos pero no hacen nada.",
"confirm": "Añadir con mi acceso",
"types": {
"tool": "Herramienta",
"source": "Fuente",
"prompt": "Prompt"
}
}
},
"logs": {
+23 -3
View File
@@ -2240,7 +2240,9 @@
"groupBuiltin": "ビルトイン",
"groupDefault": "組み込み",
"groupCustom": "カスタム",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "このエージェントに追加済み",
"attachedHint": "他のユーザーが追加しました。削除できます。"
},
"modelsPopup": {
"title": "モデルを選択",
@@ -2348,11 +2350,29 @@
"published": "公開済み"
},
"sponsors": {
"attachNote": "自分のライブラリから追加したツール、ソース、プロンプトは、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。",
"attachNote": "オーナーが使用できないツール、ソース、プロンプトを追加すると、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。自分が所有しているか編集できるものに限られ、追加時に確認を求められます。",
"publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
"addedBy": "{{person}} が追加: {{names}}",
"unavailable": "実行されていません: {{names}}。追加した {{person}} はアクセス権を失いました。削除するか、オーナーが使用できるものを選んでください。",
"unknownItem": "名前のない項目"
"unknownItem": "名前のない項目",
"notAllowed": "{{names}} をこのエージェントに追加できません。オーナーはそれらを使用できず、共有できるのは自分が所有しているか編集できるものだけです。"
},
"sponsorConfirm": {
"title": "あなたのアクセス権で実行しますか?",
"description": "エージェントのオーナーはこれらを使用できません。追加すると、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。",
"audienceTitle": "このエージェントを通じて使用できる人",
"audienceTeams": "{{teams}} のメンバー",
"audienceApiKey": "エージェントの API キーを持つすべての人(ウェブサイトウィジェットを含む)",
"audiencePublicLink": "エージェントの公開リンクを知っているすべての人",
"audienceWebhook": "エージェントの Webhook を呼び出すすべてのもの",
"audienceEditors": "このエージェントを開けるすべての人",
"stopNote": "それらやこのエージェントの編集権限を失うと、実行されなくなります。編集できる人が再度追加するまで、追加されたまま何もしません。",
"confirm": "自分のアクセス権で追加",
"types": {
"tool": "ツール",
"source": "ソース",
"prompt": "プロンプト"
}
}
},
"logs": {
+23 -3
View File
@@ -2344,7 +2344,9 @@
"groupBuiltin": "Встроенные",
"groupDefault": "Встроенные",
"groupCustom": "Пользовательские",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "В этом агенте",
"attachedHint": "Добавлено другим пользователем. Вы можете удалить это."
},
"modelsPopup": {
"title": "Выберите Модели",
@@ -2455,11 +2457,29 @@
"published": "Опубликован"
},
"sponsors": {
"attachNote": "Инструменты, источники и промпты, которые вы добавляете из своей библиотеки, работают с вашим доступом для всех, кто пользуется этим агентом.",
"attachNote": "Инструменты, источники и промпты, которые вы добавляете и которые владелец не может использовать, работают с вашим доступом для всех, кто пользуется этим агентом. Вы должны быть их владельцем или иметь право их редактировать, и вас попросят это подтвердить.",
"publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
"addedBy": "Добавил(а) {{person}}: {{names}}",
"unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не имеет доступа. Удалите их или выберите те, которые может использовать владелец.",
"unknownItem": "Элемент без названия"
"unknownItem": "Элемент без названия",
"notAllowed": "Вы не можете добавить {{names}} к этому агенту. Владелец не может их использовать, а делиться можно только тем, чем вы владеете или что можете редактировать."
},
"sponsorConfirm": {
"title": "Запускать с вашим доступом?",
"description": "Владелец агента не может их использовать. Если вы их добавите, они будут работать с вашим доступом для всех, кто пользуется этим агентом.",
"audienceTitle": "Кто сможет использовать их через этого агента",
"audienceTeams": "Участники {{teams}}",
"audienceApiKey": "Все, у кого есть API-ключ агента, включая виджет на сайте",
"audiencePublicLink": "Все, у кого есть публичная ссылка на агента",
"audienceWebhook": "Всё, что вызывает вебхук агента",
"audienceEditors": "Все, кто может открыть этого агента",
"stopNote": "Они перестанут работать, если вы потеряете право редактировать их или этого агента. Пока кто-то с правом редактирования не добавит их снова, они остаются подключёнными, но ничего не делают.",
"confirm": "Добавить с моим доступом",
"types": {
"tool": "Инструмент",
"source": "Источник",
"prompt": "Промпт"
}
}
},
"logs": {
+23 -3
View File
@@ -2240,7 +2240,9 @@
"groupBuiltin": "內建",
"groupDefault": "內建",
"groupCustom": "自訂",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "已在此智慧代理上",
"attachedHint": "由其他人新增。你可以將其移除。"
},
"modelsPopup": {
"title": "選擇模型",
@@ -2348,11 +2350,29 @@
"published": "已發佈"
},
"sponsors": {
"attachNote": "你從自己的資料庫新增的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。",
"attachNote": "你新增的、擁有者無法使用的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。你必須擁有它們或能夠編輯它們,並且需要確認。",
"publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
"addedBy": "由 {{person}} 新增:{{names}}",
"unavailable": "未執行:{{names}}。新增它們的 {{person}} 已失去存取權限。請移除它們,或選擇擁有者可以使用的項目。",
"unknownItem": "未命名項目"
"unknownItem": "未命名項目",
"notAllowed": "無法將 {{names}} 新增到此智慧代理。擁有者無法使用它們,而你只能分享自己擁有或可以編輯的項目。"
},
"sponsorConfirm": {
"title": "以你的存取權限執行?",
"description": "智慧代理的擁有者無法使用這些項目。如果新增它們,它們會以你的存取權限為所有使用此智慧代理的人執行。",
"audienceTitle": "誰可以透過此智慧代理使用它們",
"audienceTeams": "{{teams}} 的成員",
"audienceApiKey": "任何擁有此智慧代理 API 金鑰的人,包括其網站小工具",
"audiencePublicLink": "任何擁有此智慧代理公開連結的人",
"audienceWebhook": "任何呼叫此智慧代理 Webhook 的程式",
"audienceEditors": "所有可以開啟此智慧代理的人",
"stopNote": "如果你失去對它們或此智慧代理的編輯權限,它們將停止執行。在有編輯權限的人重新新增之前,它們仍附加在智慧代理上,但不起作用。",
"confirm": "以我的存取權限新增",
"types": {
"tool": "工具",
"source": "來源",
"prompt": "提示詞"
}
}
},
"logs": {
+23 -3
View File
@@ -2240,7 +2240,9 @@
"groupBuiltin": "内置",
"groupDefault": "内置",
"groupCustom": "自定义",
"groupConnection": "{{name}} · {{account}}"
"groupConnection": "{{name}} · {{account}}",
"groupAttached": "已在此智能体上",
"attachedHint": "由其他人添加。你可以将其移除。"
},
"modelsPopup": {
"title": "选择模型",
@@ -2348,11 +2350,29 @@
"published": "已发布"
},
"sponsors": {
"attachNote": "你从自己的资料库添加的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。",
"attachNote": "你添加的、所有者无法使用的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。你必须拥有它们或能够编辑它们,并且需要确认。",
"publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
"addedBy": "由 {{person}} 添加:{{names}}",
"unavailable": "未运行:{{names}}。添加它们的 {{person}} 已失去访问权限。请移除它们,或选择所有者可以使用的项目。",
"unknownItem": "未命名项目"
"unknownItem": "未命名项目",
"notAllowed": "无法将 {{names}} 添加到此智能体。所有者无法使用它们,而你只能共享自己拥有或可以编辑的项目。"
},
"sponsorConfirm": {
"title": "以你的访问权限运行?",
"description": "智能体的所有者无法使用这些项目。如果添加它们,它们会以你的访问权限为所有使用此智能体的人运行。",
"audienceTitle": "谁可以通过此智能体使用它们",
"audienceTeams": "{{teams}} 的成员",
"audienceApiKey": "任何拥有此智能体 API 密钥的人,包括其网站小组件",
"audiencePublicLink": "任何拥有此智能体公开链接的人",
"audienceWebhook": "任何调用此智能体 Webhook 的程序",
"audienceEditors": "所有可以打开此智能体的人",
"stopNote": "如果你失去对它们或此智能体的编辑权限,它们将停止运行。在有编辑权限的人重新添加之前,它们仍附加在智能体上,但不起作用。",
"confirm": "以我的访问权限添加",
"types": {
"tool": "工具",
"source": "来源",
"prompt": "提示词"
}
}
},
"logs": {