From 96290c2a4fa2e6476f5780862191e8a558132ab2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:29:04 +0530 Subject: [PATCH] feat(local): allow, ask or block each plugin tool, as Claude does Each tool row trades its switch and Ask/Always allow control for three icons with tooltips that say what each does, or why the approval rules close it. Descriptions move to a hover on the name, and a blocked tool's name is muted. Read-only and Can make changes fold away, with a count and a menu of the state their tools share (Custom while they differ) that sets every tool at once. A pick is one write of enabled and approval. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/plugins.md | 7 +- .../frontend/src/components/ui/icons.tsx | 4 + .../frontend/src/features/plugins/api.ts | 5 +- .../plugins/details/group-permission-menu.tsx | 82 ++++++++ .../plugins/details/permission-copy.ts | 102 +++++++++ .../plugins/details/plugin-details-card.tsx | 45 +--- .../plugins/details/plugin-tool-row.tsx | 193 +++++------------- .../features/plugins/details/tool-group.tsx | 89 ++++++++ .../details/tool-permission-control.tsx | 91 +++++++++ .../plugins/details/tool-permission.ts | 46 +++++ .../plugins/details/use-plugin-changes.ts | 4 +- .../plugins/plugins-settings.test.tsx | 96 ++++++++- surfsense_local/frontend/translations/de.json | 21 +- surfsense_local/frontend/translations/en.json | 21 +- surfsense_local/frontend/translations/es.json | 21 +- surfsense_local/frontend/translations/fr.json | 21 +- surfsense_local/frontend/translations/hi.json | 21 +- surfsense_local/frontend/translations/ja.json | 21 +- surfsense_local/frontend/translations/ko.json | 21 +- .../frontend/translations/pt-BR.json | 21 +- surfsense_local/frontend/translations/ru.json | 21 +- .../frontend/translations/zh-CN.json | 21 +- 22 files changed, 719 insertions(+), 255 deletions(-) create mode 100644 surfsense_local/frontend/src/features/plugins/details/group-permission-menu.tsx create mode 100644 surfsense_local/frontend/src/features/plugins/details/permission-copy.ts create mode 100644 surfsense_local/frontend/src/features/plugins/details/tool-group.tsx create mode 100644 surfsense_local/frontend/src/features/plugins/details/tool-permission-control.tsx create mode 100644 surfsense_local/frontend/src/features/plugins/details/tool-permission.ts diff --git a/docs/architecture/plugins.md b/docs/architecture/plugins.md index 3a6d1eaab..0b9dc08b8 100644 --- a/docs/architecture/plugins.md +++ b/docs/architecture/plugins.md @@ -113,7 +113,7 @@ A plugin's server gets the arguments of the calls opencode makes to it, and noth | `PUT` | `/plugins/{id}` | `{enabled}`: off pauses a plugin, keeping its key and tool settings | | `POST` | `/plugins/{id}/check` | Lists the server again and records the check | | `PUT` | `/plugins/{id}/key` | `{token}`: the server lists its tools with the new key first, and only a key it accepts replaces the old one, recorded as a check; the tool settings stay. A refusal is the same `422` or `502` as connecting | -| `PUT` | `/plugins/{id}/tools/{tool}` | `{enabled}` or `{approval}` | +| `PUT` | `/plugins/{id}/tools/{tool}` | `{enabled}`, `{approval}`, or both | | `GET` / `PUT` | `/plugins/third-party` | `{allowed}` | | `POST` | `/plugins/{id}/disconnect` | Drops the key or sign-in and sets `disconnected`; the row, its `credential_kind` and its tool settings stay, and its tools are not offered ([`disconnect.py`](../../surfsense_local/backend/modules/plugins/installed/disconnect.py)). Refresh tools on it is `409 plugin_disconnected` | | `POST` | `/plugins/{id}/connect` | Connects a disconnected plugin that needs nothing entered, listing it as at first connect. One with a key is `422 plugin_needs_key` and connects again through `/key`; an OAuth one is `422 plugin_needs_account` and connects again through `/sign-in` | @@ -130,13 +130,13 @@ A plugin's server gets the arguments of the calls opencode makes to it, and noth - **Add custom plugin** opens a dialog with Name, Server URL and an optional Key, and switches to Yours once the plugin is connected; connecting from Browse stays on Browse. Each refusal is shown in the app's words by its `code` ([`refusal-message.ts`](../../surfsense_local/frontend/src/features/plugins/refusal-message.ts)); a host not allowed raises the egress prompt and retries once allowed. While third-party plugins are off, a custom plugin's details say "Not used until third-party plugins are allowed". - **A sign-in** started by Connect, Add custom plugin or Sign in again opens the provider's page in the browser and shows "Waiting for sign-in…" with Cancel, polling each second until it connects or fails, "The sign-in didn’t finish. Try again." ([`use-plugin-sign-in.ts`](../../surfsense_local/frontend/src/features/plugins/sign-in/use-plugin-sign-in.ts), [`sign-in-progress.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in/sign-in-progress.tsx)). Leaving the screen abandons it. Electron opens only a URL shaped as SurfSense's authorization request: `response_type=code`, S256 PKCE, SurfSense's `state`, and one of its two callbacks as `redirect_uri` ([`external-url.ts`](../../surfsense_local/electron/src/main/external-url.ts)). - **A plugin's public page** opens only from its entry in Browse, in place of the list, with **Back** ([`plugin-public-page.tsx`](../../surfsense_local/frontend/src/features/plugins/public-page/plugin-public-page.tsx)). One page serves first-party and registry plugins, built from the list entry alone, so it is the same whether the plugin is connected or not: its icon and name, beside which a third-party plugin shows a checkmark when it is verified or a small Community badge otherwise, each with a tooltip saying what it means, as on its card ([`publisher-mark.tsx`](../../surfsense_local/frontend/src/features/plugins/public-page/publisher-mark.tsx)), and its publisher, "By SurfSense" or "By" its author, with **Connect**, or "Connected"; Connect on a community plugin first asks, naming who runs it and that the user's requests and sign-in pass through their server ([`community-reminder-dialog.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/community-reminder-dialog.tsx)); **About**, its `about`, a fuller account than its card's `description`, which never stands in for it; **Tools**, its listed tool names as chips in two columns, with no switch; and **Details**: **Publisher**, who runs it, SurfSense or its author; **Authentication** (None, SurfSense license, API key, or "Your {plugin} account" for one that signs in with an account), **Connects to**, its server's host, **Signs in with**, an OAuth plugin's other hosts, **Added**, the month it was added, and its homepage and privacy policy as links that open at once in the browser, by the plugin's id ([egress](egress.md#not-egress)). One Connect serves every listed plugin, on its row and its page ([`connect-listed-plugin.tsx`](../../surfsense_local/frontend/src/features/plugins/browse/connect-listed-plugin.tsx)). A connected plugin's settings are in Yours, below. -- **A plugin's details**, opened from its row in place of the list, with **Back** ([`plugin-details.tsx`](../../surfsense_local/frontend/src/features/plugins/details/plugin-details.tsx), [`plugin-details-card.tsx`](../../surfsense_local/frontend/src/features/plugins/details/plugin-details-card.tsx)): its icon and name, its type as Yours shows it, **Disconnect**, and a menu of more actions ([`plugin-actions-menu.tsx`](../../surfsense_local/frontend/src/features/plugins/details/plugin-actions-menu.tsx)): **Refresh tools** and **Remove**. Disconnect asks first, then drops the key or sign-in and keeps the plugin, with its tool settings, in Yours. A disconnected plugin says "Disconnected", shows **Connect** in place of Disconnect, and its menu holds only Remove: Connect signs an OAuth plugin in again, asks one with a key for it in a Connect dialog, and connects any other at once. Remove asks first, then deletes it and returns to the list. Under them, the last check: "Connected", "Off", "Couldn’t connect", "Needs a new key", "Signed out" with **Sign in again** for an OAuth plugin ([`sign-in-again-button.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in/sign-in-again-button.tsx)), or for a refused license "Needs a license", "License expired", "License revoked" or "License not recognized" with **License settings**, which opens Settings › License, and on "Needs a new key" **Replace key**, a dialog for the new key that says when the server refuses it. A plugin removed from the registry says "No longer listed:" and the reason. When a check switched tools off, the card says some tools are new or changed and stay off until allowed, and each such tool is marked New or Changed. Its tools are split into Read-only and Can make changes, each with a switch and, where the rules leave a choice, Ask or Always allow; a tool that always asks says "Always asks", and a first-party read-only one "Doesn’t ask" ([`tool-kind.ts`](../../surfsense_local/frontend/src/features/plugins/details/tool-kind.ts) mirrors `needs_approval()`). +- **A plugin's details**, opened from its row in place of the list, with **Back** ([`plugin-details.tsx`](../../surfsense_local/frontend/src/features/plugins/details/plugin-details.tsx), [`plugin-details-card.tsx`](../../surfsense_local/frontend/src/features/plugins/details/plugin-details-card.tsx)): its icon and name, its type as Yours shows it, **Disconnect**, and a menu of more actions ([`plugin-actions-menu.tsx`](../../surfsense_local/frontend/src/features/plugins/details/plugin-actions-menu.tsx)): **Refresh tools** and **Remove**. Disconnect asks first, then drops the key or sign-in and keeps the plugin, with its tool settings, in Yours. A disconnected plugin says "Disconnected", shows **Connect** in place of Disconnect, and its menu holds only Remove: Connect signs an OAuth plugin in again, asks one with a key for it in a Connect dialog, and connects any other at once. Remove asks first, then deletes it and returns to the list. Under them, the last check: "Connected", "Off", "Couldn’t connect", "Needs a new key", "Signed out" with **Sign in again** for an OAuth plugin ([`sign-in-again-button.tsx`](../../surfsense_local/frontend/src/features/plugins/sign-in/sign-in-again-button.tsx)), or for a refused license "Needs a license", "License expired", "License revoked" or "License not recognized" with **License settings**, which opens Settings › License, and on "Needs a new key" **Replace key**, a dialog for the new key that says when the server refuses it. A plugin removed from the registry says "No longer listed:" and the reason. When a check switched tools off, the card says some tools are new or changed and stay off until allowed, and each such tool is marked New or Changed. Its tools are split into Read-only and Can make changes, each group folding away, with a count and a menu of the state its tools share, Always allow, Needs approval or Blocked, which sets every tool in it when picked, and Custom, checked while they differ ([`tool-group.tsx`](../../surfsense_local/frontend/src/features/plugins/details/tool-group.tsx)). Each tool shows its name, its description on hover, and three icons, Always allowed, Needs approval and Blocked, one write each ([`tool-permission.ts`](../../surfsense_local/frontend/src/features/plugins/details/tool-permission.ts)); Blocked switches it off. An icon the rules close is dimmed and its tooltip says why: Always allowed on a tool that always asks, Needs approval on a first-party read-only one ([`tool-kind.ts`](../../surfsense_local/frontend/src/features/plugins/details/tool-kind.ts) mirrors `needs_approval()`). - **Turning a plugin on or off** happens in an Agentic chat's composer: **+ › Plugins** lists every connected plugin that is not disconnected, each with a switch, under "Applies to all Agentic chats", since the switch is the plugin's `enabled` for every chat ([`plugins-menu.tsx`](../../surfsense_local/frontend/src/features/plugins/in-thread/plugins-menu.tsx)). A third-party plugin's switch is held off while third-party plugins are off. Above them, **Add plugin ›** opens Settings › Plugins on Browse (**Browse plugins**) or with the Add custom plugin dialog (**Add custom plugin**), and **Manage plugins** opens it as it is. In a Basic chat, Plugins is held, its hint saying plugins work in Agentic chats. ## Tests - **Backend:** [`tests/integration/plugins/routes/`](../../surfsense_local/backend/tests/integration/plugins/routes/) connect real MCP servers on loopback ([`tests/mcp_test_server.py`](../../surfsense_local/backend/tests/mcp_test_server.py)) through the routes: each refusal, a key that reaches the server and never comes back out, the switches, disconnecting with the tool settings kept and connecting again with nothing, a key or a sign-in, removing, the third-party switch, checks: a server gone down, a refused key, and tools marked added or changed until allowed, replacing a key: one accepted keeps the tool settings, one refused is not saved, and SurfSense's own plugins against a stand-in plugin host ([`test_first_party_plugins.py`](../../surfsense_local/backend/tests/integration/plugins/routes/test_first_party_plugins.py)): the list with no network, the license key on every request, a missing or expired license connecting with its reason, a license imported later used at the next check, no key once the plugin is off the plugin host, and a custom plugin kept off a first-party id. [`test_oauth_sign_in.py`](../../surfsense_local/backend/tests/integration/plugins/routes/test_oauth_sign_in.py) signs in against a stand-in authorization server and an MCP server that answers only its tokens ([`tests/oauth_stand_in.py`](../../surfsense_local/backend/tests/oauth_stand_in.py)): a custom server and a registry plugin, a client registered in advance with its scopes, a state the app did not issue, a provider's refusal, a sign-in abandoned or cancelled leaving no plugin, the authorization server's host asked about first, a packaged app signing in as SurfSense's client, a refused token refreshed once, a refused refresh marking the plugin, signing in again keeping tool settings, and no token in a log line or a response; [`test_sign_in_client.py`](../../surfsense_local/backend/tests/unit/plugins/remote/test_sign_in_client.py) holds which client each build signs in as. The registry's tests are in [plugin registry](plugin-registry.md#tests). [`tests/unit/plugins/lists/`](../../surfsense_local/backend/tests/unit/plugins/lists/) checks the packaged list and each rule an entry breaks. [`tests/integration/plugins/gateway/`](../../surfsense_local/backend/tests/integration/plugins/gateway/) holds readiness and tools added later, and [`tests/unit/plugins/`](../../surfsense_local/backend/tests/unit/plugins/) the MCP client, the approval rules and URL spelling. The agent's side is in [agent](agent.md#tests). -- **Frontend:** [`plugins-settings.test.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx) drives the screen against a stand-in API: listing, adding with a key, a refusal, the tool and plugin switches, approval, disconnecting and connecting again from the row or with a key, removing, the third-party consent, the check's status with Refresh tools, replacing a refused key, tools marked New or Changed, SurfSense's plugins listed and connected, each license status with License settings, the tab it opens on, each row's type and status, a plugin's details and Back, the empty state, one Add custom plugin button, searching both lists with connected plugins first, the tabs held while searching, a skeleton while results settle, third-party plugins left out while off, a result's page and Back, Browse's order with the switch in Third-party, the registry fetched only once allowed and only with its host allowed, its entries listed and connected with a key or a sign-in, Cancel and a failed sign-in, a server by URL that signs in, Sign in again, and a removed plugin's reason. [`external-url.test.ts`](../../surfsense_local/electron/src/main/external-url.test.ts) holds which sign-in pages Electron opens. [`plugin-step-line.test.tsx`](../../surfsense_local/frontend/src/features/plugins/in-thread/plugin-step-line.test.tsx) holds a plugin step's name, its whole result and Save to Sources. +- **Frontend:** [`plugins-settings.test.tsx`](../../surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx) drives the screen against a stand-in API: listing, adding with a key, a refusal, each tool's permission, a group's menu and folding, the plugin switch, disconnecting and connecting again from the row or with a key, removing, the third-party consent, the check's status with Refresh tools, replacing a refused key, tools marked New or Changed, SurfSense's plugins listed and connected, each license status with License settings, the tab it opens on, each row's type and status, a plugin's details and Back, the empty state, one Add custom plugin button, searching both lists with connected plugins first, the tabs held while searching, a skeleton while results settle, third-party plugins left out while off, a result's page and Back, Browse's order with the switch in Third-party, the registry fetched only once allowed and only with its host allowed, its entries listed and connected with a key or a sign-in, Cancel and a failed sign-in, a server by URL that signs in, Sign in again, and a removed plugin's reason. [`external-url.test.ts`](../../surfsense_local/electron/src/main/external-url.test.ts) holds which sign-in pages Electron opens. [`plugin-step-line.test.tsx`](../../surfsense_local/frontend/src/features/plugins/in-thread/plugin-step-line.test.tsx) holds a plugin step's name, its whole result and Save to Sources. ## Known gaps @@ -152,5 +152,4 @@ A plugin's server gets the arguments of the calls opencode makes to it, and noth - Plugins reach Agentic threads only; Basic threads get none until the chat engine's router step ([proposal](../proposals/plugins/README.md#the-chat-engines-router-step)). - A server's `notifications/tools/list_changed` is not read; a changed tool is seen at the next check. - Settings › Network lists a plugin's hosts without the plugin's name. -- Tools are switched one at a time; Read-only and Can make changes have no switch for the whole group. - No organisation policy can turn plugins off or hold third-party plugins off ([proposal](../proposals/plugins/README.md#organisation-policy)). diff --git a/surfsense_local/frontend/src/components/ui/icons.tsx b/surfsense_local/frontend/src/components/ui/icons.tsx index 3faf73a40..169b658ee 100644 --- a/surfsense_local/frontend/src/components/ui/icons.tsx +++ b/surfsense_local/frontend/src/components/ui/icons.tsx @@ -13,6 +13,7 @@ import { ArrowUp02Icon as ArrowUp02IconData, ChartScatterIcon as ChartScatterIconData, AudioWaveformIcon as AudioWaveformIconData, + BanIcon as BanIconData, BotIcon as BotIconData, Bug01Icon as Bug01IconData, CancelCircleHalfDotIcon as CancelCircleHalfDotIconData, @@ -50,6 +51,7 @@ import { Image01Icon as Image01IconData, InformationCircleIcon as InformationCircleIconData, GridViewIcon as GridViewIconData, + HandIcon as HandIconData, LayoutGridIcon as LayoutGridIconData, LicenseIcon as LicenseIconData, LightbulbIcon as LightbulbIconData, @@ -134,6 +136,7 @@ export const ArrowExpand01Icon = createIcon(ArrowExpand01IconData) export const ArrowLeftIcon = createIcon(ArrowLeftIconData) export const ArrowRightIcon = createIcon(ArrowRightIconData) export const ArrowUp02Icon = createIcon(ArrowUp02IconData) +export const BanIcon = createIcon(BanIconData) export const BotIcon = createIcon(BotIconData) export const BugIcon = createIcon(Bug01IconData) export const CancelCircleHalfDotIcon = createIcon(CancelCircleHalfDotIconData) @@ -176,6 +179,7 @@ export const PlayIcon = createIcon(PlayIconData) export const InformationCircleIcon = createIcon(InformationCircleIconData) export const LayoutGridIcon = createIcon(LayoutGridIconData) export const GridViewIcon = createIcon(GridViewIconData) +export const HandIcon = createIcon(HandIconData) export const LicenseIcon = createIcon(LicenseIconData) export const ExternalLinkIcon = createIcon(ArrowUpRight01IconData) export const LightbulbIcon = createIcon(LightbulbIconData) diff --git a/surfsense_local/frontend/src/features/plugins/api.ts b/surfsense_local/frontend/src/features/plugins/api.ts index f7139bdd9..349df5c2f 100644 --- a/surfsense_local/frontend/src/features/plugins/api.ts +++ b/surfsense_local/frontend/src/features/plugins/api.ts @@ -2,6 +2,9 @@ import { apiUrl, requestJson, requestVoid } from "@/lib/api" export type ToolApproval = "ask" | "always" +/** One write can switch a tool on and set how it asks, so a pick is never half applied. */ +export type ToolChange = { enabled?: boolean; approval?: ToolApproval } + export type PluginTool = { name: string title: string | null @@ -197,7 +200,7 @@ export function setPluginEnabled( export function setTool( id: string, tool: string, - change: { enabled: boolean } | { approval: ToolApproval } + change: ToolChange ): Promise { return requestJson( `/plugins/${id}/tools/${encodeURIComponent(tool)}`, diff --git a/surfsense_local/frontend/src/features/plugins/details/group-permission-menu.tsx b/surfsense_local/frontend/src/features/plugins/details/group-permission-menu.tsx new file mode 100644 index 000000000..2b1817f8e --- /dev/null +++ b/surfsense_local/frontend/src/features/plugins/details/group-permission-menu.tsx @@ -0,0 +1,82 @@ +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ChevronDownIcon, EllipsisIcon } from "@/components/ui/icons" +import { intl } from "@/i18n/intl" + +import { PERMISSION_ICON, groupStateLabel } from "./permission-copy" +import { PERMISSIONS, type ToolPermission } from "./tool-permission" + +const CUSTOM = "custom" + +/** The group's state, as Claude shows it; picking one sets every tool. Custom only reports tools that differ. */ +export function GroupPermissionMenu({ + group, + current, + onPick, +}: { + group: string + current: ToolPermission | null + onPick: (permission: ToolPermission) => void +}) { + const CurrentIcon = current ? PERMISSION_ICON[current] : EllipsisIcon + const customLabel = intl.formatMessage({ + id: "plugins_group_custom_label", + defaultMessage: "Custom", + }) + return ( + + + + {current ? groupStateLabel(current) : customLabel} + + + } + /> + + { + if (value !== CUSTOM) onPick(value as ToolPermission) + }} + > + {PERMISSIONS.map((permission) => { + const Icon = PERMISSION_ICON[permission] + return ( + + + {groupStateLabel(permission)} + + ) + })} + + + {customLabel} + + + + + ) +} diff --git a/surfsense_local/frontend/src/features/plugins/details/permission-copy.ts b/surfsense_local/frontend/src/features/plugins/details/permission-copy.ts new file mode 100644 index 000000000..fb96e9f3c --- /dev/null +++ b/surfsense_local/frontend/src/features/plugins/details/permission-copy.ts @@ -0,0 +1,102 @@ +import { BanIcon, CheckCircle2Icon, HandIcon } from "@/components/ui/icons" +import { intl } from "@/i18n/intl" + +import type { ToolPermission } from "./tool-permission" + +export const PERMISSION_ICON = { + allow: CheckCircle2Icon, + ask: HandIcon, + block: BanIcon, +} as const + +export function permissionTitle(permission: ToolPermission) { + if (permission === "allow") { + return intl.formatMessage({ + id: "plugins_permission_allow_title", + defaultMessage: "Always allowed", + }) + } + if (permission === "ask") { + return intl.formatMessage({ + id: "plugins_permission_ask_title", + defaultMessage: "Needs approval", + }) + } + return intl.formatMessage({ + id: "plugins_permission_block_title", + defaultMessage: "Blocked", + }) +} + +/** What the choice does, or why the approval rules leave it closed for this tool. */ +export function permissionBody(permission: ToolPermission, open: boolean) { + if (permission === "allow") { + return open + ? intl.formatMessage({ + id: "plugins_permission_allow_body", + defaultMessage: "The agent uses it without asking", + }) + : intl.formatMessage({ + id: "plugins_permission_allow_closed_body", + defaultMessage: + "Not available: the server doesn’t say this tool is safe", + }) + } + if (permission === "ask") { + return open + ? intl.formatMessage({ + id: "plugins_permission_ask_body", + defaultMessage: "The agent asks before each use", + }) + : intl.formatMessage({ + id: "plugins_permission_ask_closed_body", + defaultMessage: "Not needed: this tool only reads", + }) + } + return intl.formatMessage({ + id: "plugins_permission_block_body", + defaultMessage: "The agent can’t see or use it", + }) +} + +export function permissionAria(permission: ToolPermission, tool: string) { + if (permission === "allow") { + return intl.formatMessage( + { id: "plugins_tool_always_aria", defaultMessage: "Always allow {tool}" }, + { tool } + ) + } + if (permission === "ask") { + return intl.formatMessage( + { + id: "plugins_tool_ask_aria", + defaultMessage: "Ask before using {tool}", + }, + { tool } + ) + } + return intl.formatMessage( + { id: "plugins_tool_block_aria", defaultMessage: "Block {tool}" }, + { tool } + ) +} + +/** The group menu's words: the state every tool in it shares. */ +export function groupStateLabel(permission: ToolPermission) { + if (permission === "allow") { + return intl.formatMessage({ + id: "plugins_group_allow_label", + defaultMessage: "Always allow", + }) + } + if (permission === "ask") { + return intl.formatMessage({ + id: "plugins_group_needs_approval_label", + defaultMessage: "Needs approval", + }) + } + return intl.formatMessage({ + id: "plugins_group_blocked_label", + defaultMessage: "Blocked", + }) +} diff --git a/surfsense_local/frontend/src/features/plugins/details/plugin-details-card.tsx b/surfsense_local/frontend/src/features/plugins/details/plugin-details-card.tsx index 2009b9909..1fe7700fd 100644 --- a/surfsense_local/frontend/src/features/plugins/details/plugin-details-card.tsx +++ b/surfsense_local/frontend/src/features/plugins/details/plugin-details-card.tsx @@ -4,53 +4,17 @@ import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" import { intl } from "@/i18n/intl" -import type { Plugin, PluginTool, ToolApproval } from "../api" +import type { Plugin, ToolChange } from "../api" import { ConnectPluginButton } from "./connect-plugin-button" import { DisconnectPluginButton } from "./disconnect-plugin-button" import { PluginIcon } from "../plugin-icon" import { pluginTypeLabel } from "../yours/plugin-type-label" import { PluginActionsMenu } from "./plugin-actions-menu" -import { PluginToolRow } from "./plugin-tool-row" import { RemovePluginDialog } from "./remove-plugin-dialog" import { ReplaceKeyButton } from "./replace-key-button" import { SignInAgainButton } from "../sign-in/sign-in-again-button" import { isReadOnly } from "./tool-kind" - -function ToolGroup({ - plugin, - label, - tools, - onTool, -}: { - plugin: Plugin - label: string - tools: PluginTool[] - onTool: ( - tool: string, - change: { enabled: boolean } | { approval: ToolApproval } - ) => void -}) { - const labelId = useId() - if (tools.length === 0) return null - return ( -
-

- {label} -

-
    - {tools.map((tool) => ( - onTool(tool.name, { enabled })} - onApproval={(approval) => onTool(tool.name, { approval })} - /> - ))} -
-
- ) -} +import { ToolGroup } from "./tool-group" function checkLabel(plugin: Plugin) { if (plugin.check_failure === "unreachable") { @@ -133,10 +97,7 @@ export function PluginDetailsCard({ removing: boolean checking: boolean onCheck: () => void - onTool: ( - tool: string, - change: { enabled: boolean } | { approval: ToolApproval } - ) => void + onTool: (tool: string, change: ToolChange) => void onDisconnect: () => void onRemove: () => void onOpenLicense?: () => void diff --git a/surfsense_local/frontend/src/features/plugins/details/plugin-tool-row.tsx b/surfsense_local/frontend/src/features/plugins/details/plugin-tool-row.tsx index 3738d1a49..660d09be5 100644 --- a/surfsense_local/frontend/src/features/plugins/details/plugin-tool-row.tsx +++ b/surfsense_local/frontend/src/features/plugins/details/plugin-tool-row.tsx @@ -1,155 +1,70 @@ import { Badge } from "@/components/ui/badge" -import { SegmentedControl } from "@/components/ui/segmented-control" -import { Switch } from "@/components/ui/switch" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { intl } from "@/i18n/intl" +import { cn } from "@/lib/utils" -import type { Plugin, PluginTool, ToolApproval } from "../api" -import { approvalOf } from "./tool-kind" +import type { Plugin, PluginTool } from "../api" +import { ToolPermissionControl } from "./tool-permission-control" +import type { ToolPermission } from "./tool-permission" -const APPROVALS: ToolApproval[] = ["ask", "always"] - -function approvalLabel(approval: ToolApproval) { - return approval === "ask" - ? intl.formatMessage({ - id: "plugins_tool_ask_label", - defaultMessage: "Ask", - }) - : intl.formatMessage({ - id: "plugins_tool_always_label", - defaultMessage: "Always allow", - }) -} - -function approvalAria(approval: ToolApproval, tool: string) { - return approval === "ask" - ? intl.formatMessage( - { - id: "plugins_tool_ask_aria", - defaultMessage: "Ask before using {tool}", - }, - { tool } - ) - : intl.formatMessage( - { - id: "plugins_tool_always_aria", - defaultMessage: "Always allow {tool}", - }, - { tool } - ) -} - -function Approval({ - plugin, - tool, - label, - onApproval, -}: { - plugin: Plugin - tool: PluginTool - label: string - onApproval: (approval: ToolApproval) => void -}) { - const kind = approvalOf(plugin, tool) - if (kind !== "setting") { - return ( - - {kind === "always_asks" - ? intl.formatMessage({ - id: "plugins_tool_always_asks_label", - defaultMessage: "Always asks", - }) - : intl.formatMessage({ - id: "plugins_tool_never_asks_label", - defaultMessage: "Doesn’t ask", - })} - - ) - } - return ( - - {APPROVALS.map((approval) => ( - - ))} - - ) -} - -/** One tool: what it does, whether the agent may use it, and whether it asks first. */ +/** One tool: its name, its description on hover, and whether the agent may use it. */ export function PluginToolRow({ plugin, tool, - onEnabled, - onApproval, + onPick, }: { plugin: Plugin tool: PluginTool - onEnabled: (enabled: boolean) => void - onApproval: (approval: ToolApproval) => void + onPick: (permission: ToolPermission) => void }) { const label = tool.title ?? tool.name return ( -
  • -
    -

    - {label} - {tool.change ? ( - - {tool.change === "added" - ? intl.formatMessage({ - id: "plugins_tool_added_label", - defaultMessage: "New", - }) - : intl.formatMessage({ - id: "plugins_tool_changed_label", - defaultMessage: "Changed", - })} - - ) : null} -

    -

    - {tool.description} -

    -
    -
    - - -
    +
  • +

    + + }> + {label} + + {/* Server text, often long: kept off the row, as Claude does. */} + + {tool.description} + + + {tool.description} + {tool.change ? ( + + {tool.change === "added" + ? intl.formatMessage({ + id: "plugins_tool_added_label", + defaultMessage: "New", + }) + : intl.formatMessage({ + id: "plugins_tool_changed_label", + defaultMessage: "Changed", + })} + + ) : null} +

    +
  • ) } diff --git a/surfsense_local/frontend/src/features/plugins/details/tool-group.tsx b/surfsense_local/frontend/src/features/plugins/details/tool-group.tsx new file mode 100644 index 000000000..e27a7d9ef --- /dev/null +++ b/surfsense_local/frontend/src/features/plugins/details/tool-group.tsx @@ -0,0 +1,89 @@ +import { useId, useState } from "react" + +import { Badge } from "@/components/ui/badge" +import { ChevronDownIcon } from "@/components/ui/icons" +import { intl } from "@/i18n/intl" +import { cn } from "@/lib/utils" + +import type { Plugin, PluginTool, ToolChange } from "../api" +import { GroupPermissionMenu } from "./group-permission-menu" +import { PluginToolRow } from "./plugin-tool-row" +import { + canPick, + changeFor, + groupPermission, + permissionOf, + type ToolPermission, +} from "./tool-permission" + +/** Read-only or Can make changes: folds away, and its menu sets every tool in it. */ +export function ToolGroup({ + plugin, + label, + tools, + onTool, +}: { + plugin: Plugin + label: string + tools: PluginTool[] + onTool: (tool: string, change: ToolChange) => void +}) { + const labelId = useId() + const listId = useId() + const [open, setOpen] = useState(true) + if (tools.length === 0) return null + + const pickAll = (permission: ToolPermission) => { + for (const tool of tools) { + // A tool the rules hold to asking, or to never asking, only needs switching on. + const unchanged = canPick(plugin, tool, permission) + ? permissionOf(plugin, tool) === permission + : tool.enabled + if (!unchanged) onTool(tool.name, changeFor(plugin, tool, permission)) + } + } + + return ( +
    +
    + + +
    + {open ? ( +
      + {tools.map((tool) => ( + + onTool(tool.name, changeFor(plugin, tool, permission)) + } + /> + ))} +
    + ) : null} +
    + ) +} diff --git a/surfsense_local/frontend/src/features/plugins/details/tool-permission-control.tsx b/surfsense_local/frontend/src/features/plugins/details/tool-permission-control.tsx new file mode 100644 index 000000000..b74f49d0e --- /dev/null +++ b/surfsense_local/frontend/src/features/plugins/details/tool-permission-control.tsx @@ -0,0 +1,91 @@ +import { SegmentedControl } from "@/components/ui/segmented-control" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { intl } from "@/i18n/intl" +import { cn } from "@/lib/utils" + +import type { Plugin, PluginTool } from "../api" +import { + PERMISSION_ICON, + permissionAria, + permissionBody, + permissionTitle, +} from "./permission-copy" +import { + PERMISSIONS, + canPick, + permissionOf, + type ToolPermission, +} from "./tool-permission" + +/** Allow, ask or block as three icons; each says in a tooltip what it does, or why it is closed. */ +export function ToolPermissionControl({ + plugin, + tool, + label, + onPick, +}: { + plugin: Plugin + tool: PluginTool + label: string + onPick: (permission: ToolPermission) => void +}) { + const current = permissionOf(plugin, tool) + return ( + + {PERMISSIONS.map((permission) => { + const open = canPick(plugin, tool, permission) + const Icon = PERMISSION_ICON[permission] + return ( + + + } + > + onPick(permission)} + /> + + + + + +

    {permissionTitle(permission)}

    +

    + {permissionBody(permission, open)} +

    +
    +
    + ) + })} +
    + ) +} diff --git a/surfsense_local/frontend/src/features/plugins/details/tool-permission.ts b/surfsense_local/frontend/src/features/plugins/details/tool-permission.ts new file mode 100644 index 000000000..4d8ee7937 --- /dev/null +++ b/surfsense_local/frontend/src/features/plugins/details/tool-permission.ts @@ -0,0 +1,46 @@ +import type { Plugin, PluginTool, ToolChange } from "../api" +import { approvalOf } from "./tool-kind" + +/** The one choice a tool row offers, over the stored `enabled` and `approval`. */ +export type ToolPermission = "allow" | "ask" | "block" + +export const PERMISSIONS: ToolPermission[] = ["allow", "ask", "block"] + +export function permissionOf(plugin: Plugin, tool: PluginTool): ToolPermission { + if (!tool.enabled) return "block" + const kind = approvalOf(plugin, tool) + if (kind === "always_asks") return "ask" + if (kind === "never_asks") return "allow" + return tool.approval === "always" ? "allow" : "ask" +} + +/** The approval rules leave some tools no say in asking; block is always open. */ +export function canPick( + plugin: Plugin, + tool: PluginTool, + permission: ToolPermission +) { + const kind = approvalOf(plugin, tool) + if (permission === "allow") return kind !== "always_asks" + if (permission === "ask") return kind !== "never_asks" + return true +} + +export function changeFor( + plugin: Plugin, + tool: PluginTool, + permission: ToolPermission +): ToolChange { + if (permission === "block") return { enabled: false } + if (approvalOf(plugin, tool) !== "setting") return { enabled: true } + return { + enabled: true, + approval: permission === "allow" ? "always" : "ask", + } +} + +/** The group's own choice when every tool shares it; null when they differ. */ +export function groupPermission(plugin: Plugin, tools: PluginTool[]) { + const picks = new Set(tools.map((tool) => permissionOf(plugin, tool))) + return picks.size === 1 ? [...picks][0] : null +} diff --git a/surfsense_local/frontend/src/features/plugins/details/use-plugin-changes.ts b/surfsense_local/frontend/src/features/plugins/details/use-plugin-changes.ts index 0f25e4de0..69450a2a5 100644 --- a/surfsense_local/frontend/src/features/plugins/details/use-plugin-changes.ts +++ b/surfsense_local/frontend/src/features/plugins/details/use-plugin-changes.ts @@ -9,7 +9,7 @@ import { setThirdPartyAllowed, setTool, thirdPartyQueryKey, - type ToolApproval, + type ToolChange, } from "../api" /** Every change the screen makes; each reads the plugins back once it lands. */ @@ -27,7 +27,7 @@ export function usePluginChanges() { }: { plugin: string tool: string - change: { enabled: boolean } | { approval: ToolApproval } + change: ToolChange }) => setTool(plugin, tool, change), onSettled: refresh, }), diff --git a/surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx b/surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx index a71ceca36..e1ac8b36c 100644 --- a/surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx +++ b/surfsense_local/frontend/src/features/plugins/plugins-settings.test.tsx @@ -663,13 +663,13 @@ describe("plugins settings", () => { ) }) - it("switches a tool off, and lets only a read-only one skip asking", async () => { + it("blocks a tool, and lets only a read-only one be always allowed", async () => { const calls = api({ allowed: true }) const user = userEvent.setup() render() const plugin = await details(user, "Notes") - await user.click(within(plugin).getByRole("switch", { name: "Use count" })) + await user.click(within(plugin).getByRole("radio", { name: "Block count" })) await user.click( within(plugin).getByRole("radio", { name: "Always allow Search notes" }) ) @@ -684,14 +684,96 @@ describe("plugins settings", () => { { method: "PUT", path: "/plugins/notes/tools/search", - body: { approval: "always" }, + body: { enabled: true, approval: "always" }, }, ]) ) expect( - within(plugin).queryByRole("radio", { name: "Always allow count" }) - ).toBeNull() - expect(within(plugin).getByText("Always asks")).toBeTruthy() + ( + within(plugin).getByRole("radio", { + name: "Always allow count", + }) as HTMLInputElement + ).disabled + ).toBe(true) + }) + + it("sets every tool in a group from its menu", async () => { + const calls = api({ allowed: true }) + const user = userEvent.setup() + render() + + const plugin = await details(user, "Notes") + const readOnly = within(plugin).getByRole("group", { name: "Read-only" }) + await user.click( + within(readOnly).getByRole("button", { + name: "Permission for every Read-only tool", + }) + ) + await user.click( + await screen.findByRole("menuitemradio", { name: "Blocked" }) + ) + + await waitFor(() => + expect(changes(calls)).toEqual([ + { + method: "PUT", + path: "/plugins/notes/tools/search", + body: { enabled: false }, + }, + ]) + ) + }) + + it("says Custom when a group's tools differ", async () => { + const plugin = notes() + api({ + plugins: [ + { + ...plugin, + tools: [ + ...plugin.tools, + { + name: "recent", + title: "Recent notes", + description: "List the latest notes.", + annotations: { readOnlyHint: true }, + enabled: false, + approval: "ask", + change: null, + }, + ], + }, + ], + allowed: true, + }) + const user = userEvent.setup() + render() + + const card = await details(user, "Notes") + const readOnly = within(card).getByRole("group", { name: "Read-only" }) + const menu = within(readOnly).getByRole("button", { + name: "Permission for every Read-only tool", + }) + expect(menu.textContent).toContain("Custom") + await user.click(menu) + expect( + ( + await screen.findByRole("menuitemradio", { name: "Custom" }) + ).getAttribute("aria-checked") + ).toBe("true") + }) + + it("folds a group away and back", async () => { + api({ allowed: true }) + const user = userEvent.setup() + render() + + const plugin = await details(user, "Notes") + const fold = within(plugin).getByRole("button", { name: /^Read-only/ }) + await user.click(fold) + expect(within(plugin).queryByText("Search notes")).toBeNull() + await user.click(fold) + expect(within(plugin).getByText("Search notes")).toBeTruthy() }) it("says a plugin switched off is off, and leaves on and off to the composer", async () => { @@ -1231,7 +1313,7 @@ describe("plugins settings", () => { const plugin = await details(user, "Notes") expect( - within(plugin).getByRole("switch", { name: /Search notes/ }) + within(plugin).getByRole("radiogroup", { name: /Search notes/ }) ).toBeTruthy() expect(screen.queryByRole("region", { name: "About" })).toBeNull() expect(screen.queryByRole("region", { name: "Details" })).toBeNull() diff --git a/surfsense_local/frontend/translations/de.json b/surfsense_local/frontend/translations/de.json index 8d4f8ced1..4e0265220 100644 --- a/surfsense_local/frontend/translations/de.json +++ b/surfsense_local/frontend/translations/de.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "Dieser Server war nicht erreichbar. Prüfe die URL und ob der Server läuft.", "plugins_first_party_license_label": "Lizenz erforderlich", "plugins_first_party_title": "Von SurfSense", + "plugins_group_allow_label": "Immer erlauben", + "plugins_group_blocked_label": "Blockiert", + "plugins_group_custom_label": "Benutzerdefiniert", + "plugins_group_needs_approval_label": "Braucht Freigabe", + "plugins_group_permission_aria": "Berechtigung für alle Tools unter {group}", "plugins_key_body": "SurfSense probiert den neuen Schlüssel aus, bevor es ihn speichert. Deine Tool-Einstellungen bleiben.", "plugins_key_cancel_button": "Abbrechen", "plugins_key_connect_body": "Füge den Schlüssel ein, den du für {plugin} verwendest. Deine Tool-Einstellungen bleiben erhalten.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "Keine", "plugins_page_tools_title": "Tools", "plugins_page_tools_unlisted_body": "Für dieses Plugin sind keine Tools aufgeführt.", + "plugins_permission_allow_body": "Der Agent verwendet es, ohne zu fragen", + "plugins_permission_allow_closed_body": "Nicht verfügbar: Der Server gibt nicht an, dass dieses Tool sicher ist", + "plugins_permission_allow_title": "Immer erlaubt", + "plugins_permission_ask_body": "Der Agent fragt vor jeder Verwendung", + "plugins_permission_ask_closed_body": "Nicht nötig: Dieses Tool liest nur", + "plugins_permission_ask_title": "Braucht Freigabe", + "plugins_permission_block_body": "Der Agent kann es weder sehen noch verwenden", + "plugins_permission_block_title": "Blockiert", "plugins_registry_author_label": "Von {author}", "plugins_registry_community_label": "Community", "plugins_registry_community_tooltip": "Community-Plugin. Nicht von SurfSense verifiziert.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "Drittanbieter-Plugins erlauben", "plugins_tool_added_label": "Neu", "plugins_tool_always_aria": "{tool} immer erlauben", - "plugins_tool_always_asks_label": "Fragt immer", - "plugins_tool_always_label": "Immer erlauben", - "plugins_tool_approval_aria": "Freigabe für {tool}", "plugins_tool_ask_aria": "Vor der Verwendung von {tool} fragen", - "plugins_tool_ask_label": "Fragen", + "plugins_tool_block_aria": "{tool} blockieren", "plugins_tool_changed_label": "Geändert", - "plugins_tool_never_asks_label": "Fragt nicht", - "plugins_tool_use_aria": "{tool} verwenden", + "plugins_tool_permission_aria": "Berechtigung für {tool}", "resources_breakdown_body": "Was jeder Teil von SurfSense gerade nutzt.", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "Engine", diff --git a/surfsense_local/frontend/translations/en.json b/surfsense_local/frontend/translations/en.json index f912217b8..1a6d2a5ab 100644 --- a/surfsense_local/frontend/translations/en.json +++ b/surfsense_local/frontend/translations/en.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "Couldn’t reach this server. Check the URL and that the server is running.", "plugins_first_party_license_label": "License required", "plugins_first_party_title": "By SurfSense", + "plugins_group_allow_label": "Always allow", + "plugins_group_blocked_label": "Blocked", + "plugins_group_custom_label": "Custom", + "plugins_group_needs_approval_label": "Needs approval", + "plugins_group_permission_aria": "Permission for every {group} tool", "plugins_key_body": "SurfSense tries the new key before saving it. Your tool settings stay.", "plugins_key_cancel_button": "Cancel", "plugins_key_connect_body": "Paste the key you use for {plugin}. Your tool settings are kept.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "None", "plugins_page_tools_title": "Tools", "plugins_page_tools_unlisted_body": "Tools aren’t listed for this plugin.", + "plugins_permission_allow_body": "The agent uses it without asking", + "plugins_permission_allow_closed_body": "Not available: the server doesn’t say this tool is safe", + "plugins_permission_allow_title": "Always allowed", + "plugins_permission_ask_body": "The agent asks before each use", + "plugins_permission_ask_closed_body": "Not needed: this tool only reads", + "plugins_permission_ask_title": "Needs approval", + "plugins_permission_block_body": "The agent can’t see or use it", + "plugins_permission_block_title": "Blocked", "plugins_registry_author_label": "By {author}", "plugins_registry_community_label": "Community", "plugins_registry_community_tooltip": "Community plugin. Not verified by SurfSense.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "Allow third-party plugins", "plugins_tool_added_label": "New", "plugins_tool_always_aria": "Always allow {tool}", - "plugins_tool_always_asks_label": "Always asks", - "plugins_tool_always_label": "Always allow", - "plugins_tool_approval_aria": "Approval for {tool}", "plugins_tool_ask_aria": "Ask before using {tool}", - "plugins_tool_ask_label": "Ask", + "plugins_tool_block_aria": "Block {tool}", "plugins_tool_changed_label": "Changed", - "plugins_tool_never_asks_label": "Doesn’t ask", - "plugins_tool_use_aria": "Use {tool}", + "plugins_tool_permission_aria": "Permission for {tool}", "resources_breakdown_body": "What each part of SurfSense is using right now.", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "Engine", diff --git a/surfsense_local/frontend/translations/es.json b/surfsense_local/frontend/translations/es.json index 10d6d4095..f022128bb 100644 --- a/surfsense_local/frontend/translations/es.json +++ b/surfsense_local/frontend/translations/es.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "No se pudo acceder a este servidor. Revisa la URL y que el servidor esté en marcha.", "plugins_first_party_license_label": "Requiere licencia", "plugins_first_party_title": "De SurfSense", + "plugins_group_allow_label": "Permitir siempre", + "plugins_group_blocked_label": "Bloqueadas", + "plugins_group_custom_label": "Personalizado", + "plugins_group_needs_approval_label": "Requieren aprobación", + "plugins_group_permission_aria": "Permiso para todas las herramientas de {group}", "plugins_key_body": "SurfSense prueba la clave nueva antes de guardarla. Tus ajustes de herramientas se mantienen.", "plugins_key_cancel_button": "Cancelar", "plugins_key_connect_body": "Pega la clave que usas para {plugin}. Tus ajustes de herramientas se conservan.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "Ninguno", "plugins_page_tools_title": "Herramientas", "plugins_page_tools_unlisted_body": "Este plugin no tiene herramientas en la lista.", + "plugins_permission_allow_body": "El agente la usa sin preguntar", + "plugins_permission_allow_closed_body": "No disponible: el servidor no indica que esta herramienta sea segura", + "plugins_permission_allow_title": "Siempre permitida", + "plugins_permission_ask_body": "El agente pregunta antes de cada uso", + "plugins_permission_ask_closed_body": "No hace falta: esta herramienta solo lee", + "plugins_permission_ask_title": "Requiere aprobación", + "plugins_permission_block_body": "El agente no puede verla ni usarla", + "plugins_permission_block_title": "Bloqueada", "plugins_registry_author_label": "De {author}", "plugins_registry_community_label": "Comunidad", "plugins_registry_community_tooltip": "Plugin de la comunidad. No verificado por SurfSense.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "Permitir plugins de terceros", "plugins_tool_added_label": "Nueva", "plugins_tool_always_aria": "Permitir siempre {tool}", - "plugins_tool_always_asks_label": "Siempre pregunta", - "plugins_tool_always_label": "Permitir siempre", - "plugins_tool_approval_aria": "Aprobación de {tool}", "plugins_tool_ask_aria": "Preguntar antes de usar {tool}", - "plugins_tool_ask_label": "Preguntar", + "plugins_tool_block_aria": "Bloquear {tool}", "plugins_tool_changed_label": "Cambiada", - "plugins_tool_never_asks_label": "No pregunta", - "plugins_tool_use_aria": "Usar {tool}", + "plugins_tool_permission_aria": "Permiso para {tool}", "resources_breakdown_body": "Lo que usa ahora cada parte de SurfSense.", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "Motor", diff --git a/surfsense_local/frontend/translations/fr.json b/surfsense_local/frontend/translations/fr.json index 24e372abc..8c350dbbc 100644 --- a/surfsense_local/frontend/translations/fr.json +++ b/surfsense_local/frontend/translations/fr.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "Impossible de joindre ce serveur. Vérifiez l’URL et que le serveur fonctionne.", "plugins_first_party_license_label": "Licence requise", "plugins_first_party_title": "Par SurfSense", + "plugins_group_allow_label": "Toujours autoriser", + "plugins_group_blocked_label": "Bloqués", + "plugins_group_custom_label": "Personnalisé", + "plugins_group_needs_approval_label": "Nécessitent une approbation", + "plugins_group_permission_aria": "Autorisation pour tous les outils de {group}", "plugins_key_body": "SurfSense essaie la nouvelle clé avant de l’enregistrer. Vos réglages d’outils sont conservés.", "plugins_key_cancel_button": "Annuler", "plugins_key_connect_body": "Collez la clé que vous utilisez pour {plugin}. Vos réglages d’outils sont conservés.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "Aucune", "plugins_page_tools_title": "Outils", "plugins_page_tools_unlisted_body": "Aucun outil n’est indiqué pour ce plugin.", + "plugins_permission_allow_body": "L’agent l’utilise sans demander", + "plugins_permission_allow_closed_body": "Indisponible : le serveur n’indique pas que cet outil est sûr", + "plugins_permission_allow_title": "Toujours autorisé", + "plugins_permission_ask_body": "L’agent demande avant chaque utilisation", + "plugins_permission_ask_closed_body": "Inutile : cet outil ne fait que lire", + "plugins_permission_ask_title": "Nécessite une approbation", + "plugins_permission_block_body": "L’agent ne peut ni le voir ni l’utiliser", + "plugins_permission_block_title": "Bloqué", "plugins_registry_author_label": "Par {author}", "plugins_registry_community_label": "Communauté", "plugins_registry_community_tooltip": "Plugin de la communauté. Non vérifié par SurfSense.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "Autoriser les plugins tiers", "plugins_tool_added_label": "Nouveau", "plugins_tool_always_aria": "Toujours autoriser {tool}", - "plugins_tool_always_asks_label": "Demande toujours", - "plugins_tool_always_label": "Toujours autoriser", - "plugins_tool_approval_aria": "Approbation pour {tool}", "plugins_tool_ask_aria": "Demander avant d’utiliser {tool}", - "plugins_tool_ask_label": "Demander", + "plugins_tool_block_aria": "Bloquer {tool}", "plugins_tool_changed_label": "Modifié", - "plugins_tool_never_asks_label": "Ne demande pas", - "plugins_tool_use_aria": "Utiliser {tool}", + "plugins_tool_permission_aria": "Autorisation pour {tool}", "resources_breakdown_body": "Ce que chaque partie de SurfSense utilise en ce moment.", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "Moteur", diff --git a/surfsense_local/frontend/translations/hi.json b/surfsense_local/frontend/translations/hi.json index 8af67f887..e3790551e 100644 --- a/surfsense_local/frontend/translations/hi.json +++ b/surfsense_local/frontend/translations/hi.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "इस सर्वर तक नहीं पहुँचा जा सका। URL जाँचें और देखें कि सर्वर चल रहा है।", "plugins_first_party_license_label": "लाइसेंस ज़रूरी", "plugins_first_party_title": "SurfSense की ओर से", + "plugins_group_allow_label": "हमेशा अनुमति दें", + "plugins_group_blocked_label": "ब्लॉक हैं", + "plugins_group_custom_label": "कस्टम", + "plugins_group_needs_approval_label": "मंज़ूरी चाहिए", + "plugins_group_permission_aria": "{group} के सभी टूल के लिए अनुमति", "plugins_key_body": "SurfSense नई कुंजी को सहेजने से पहले आज़माता है। आपकी टूल सेटिंग्स वैसी ही रहती हैं।", "plugins_key_cancel_button": "रद्द करें", "plugins_key_connect_body": "{plugin} के लिए इस्तेमाल होने वाली कुंजी पेस्ट करें। आपकी टूल सेटिंग्स बनी रहेंगी।", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "कोई नहीं", "plugins_page_tools_title": "टूल", "plugins_page_tools_unlisted_body": "इस प्लगइन के टूल सूचीबद्ध नहीं हैं।", + "plugins_permission_allow_body": "एजेंट बिना पूछे इसे इस्तेमाल करता है", + "plugins_permission_allow_closed_body": "उपलब्ध नहीं: सर्वर यह नहीं बताता कि यह टूल सुरक्षित है", + "plugins_permission_allow_title": "हमेशा अनुमति है", + "plugins_permission_ask_body": "एजेंट हर बार इस्तेमाल से पहले पूछता है", + "plugins_permission_ask_closed_body": "ज़रूरी नहीं: यह टूल सिर्फ़ पढ़ता है", + "plugins_permission_ask_title": "मंज़ूरी चाहिए", + "plugins_permission_block_body": "एजेंट इसे न देख सकता है, न इस्तेमाल कर सकता है", + "plugins_permission_block_title": "ब्लॉक है", "plugins_registry_author_label": "{author} की ओर से", "plugins_registry_community_label": "कम्युनिटी", "plugins_registry_community_tooltip": "कम्युनिटी प्लगइन। SurfSense द्वारा सत्यापित नहीं।", @@ -964,14 +977,10 @@ "plugins_third_party_label": "थर्ड-पार्टी प्लगइन की अनुमति दें", "plugins_tool_added_label": "नया", "plugins_tool_always_aria": "{tool} को हमेशा अनुमति दें", - "plugins_tool_always_asks_label": "हमेशा पूछता है", - "plugins_tool_always_label": "हमेशा अनुमति दें", - "plugins_tool_approval_aria": "{tool} के लिए अनुमति", "plugins_tool_ask_aria": "{tool} इस्तेमाल करने से पहले पूछें", - "plugins_tool_ask_label": "पूछें", + "plugins_tool_block_aria": "{tool} को ब्लॉक करें", "plugins_tool_changed_label": "बदला गया", - "plugins_tool_never_asks_label": "नहीं पूछता", - "plugins_tool_use_aria": "{tool} इस्तेमाल करें", + "plugins_tool_permission_aria": "{tool} के लिए अनुमति", "resources_breakdown_body": "SurfSense का हर हिस्सा अभी क्या इस्तेमाल कर रहा है।", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "इंजन", diff --git a/surfsense_local/frontend/translations/ja.json b/surfsense_local/frontend/translations/ja.json index fb711c500..1b270cd30 100644 --- a/surfsense_local/frontend/translations/ja.json +++ b/surfsense_local/frontend/translations/ja.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "このサーバーに接続できませんでした。URLとサーバーが起動しているかを確認してください。", "plugins_first_party_license_label": "ライセンスが必要", "plugins_first_party_title": "SurfSense提供", + "plugins_group_allow_label": "常に許可", + "plugins_group_blocked_label": "ブロック中", + "plugins_group_custom_label": "カスタム", + "plugins_group_needs_approval_label": "承認が必要", + "plugins_group_permission_aria": "{group}のすべてのツールの権限", "plugins_key_body": "SurfSenseは新しいキーを試してから保存します。ツールの設定はそのまま残ります。", "plugins_key_cancel_button": "キャンセル", "plugins_key_connect_body": "{plugin}で使っているキーを貼り付けてください。ツールの設定はそのまま残ります。", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "なし", "plugins_page_tools_title": "ツール", "plugins_page_tools_unlisted_body": "このプラグインのツールは掲載されていません。", + "plugins_permission_allow_body": "エージェントは確認せずに使用します", + "plugins_permission_allow_closed_body": "利用不可:このツールが安全かどうかをサーバーが示していません", + "plugins_permission_allow_title": "常に許可", + "plugins_permission_ask_body": "エージェントは使用のたびに確認します", + "plugins_permission_ask_closed_body": "不要:このツールは読み取りのみです", + "plugins_permission_ask_title": "承認が必要", + "plugins_permission_block_body": "エージェントはこのツールを表示も使用もできません", + "plugins_permission_block_title": "ブロック中", "plugins_registry_author_label": "{author}提供", "plugins_registry_community_label": "コミュニティ", "plugins_registry_community_tooltip": "コミュニティのプラグイン。SurfSense による認証はありません。", @@ -964,14 +977,10 @@ "plugins_third_party_label": "サードパーティのプラグインを許可", "plugins_tool_added_label": "新規", "plugins_tool_always_aria": "{tool}を常に許可", - "plugins_tool_always_asks_label": "常に確認", - "plugins_tool_always_label": "常に許可", - "plugins_tool_approval_aria": "{tool}の承認", "plugins_tool_ask_aria": "{tool}を使う前に確認", - "plugins_tool_ask_label": "確認", + "plugins_tool_block_aria": "{tool}をブロック", "plugins_tool_changed_label": "変更あり", - "plugins_tool_never_asks_label": "確認なし", - "plugins_tool_use_aria": "{tool}を使用", + "plugins_tool_permission_aria": "{tool}の権限", "resources_breakdown_body": "SurfSenseの各部分が現在使っている量です。", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "エンジン", diff --git a/surfsense_local/frontend/translations/ko.json b/surfsense_local/frontend/translations/ko.json index aaad98f7f..52130cdf0 100644 --- a/surfsense_local/frontend/translations/ko.json +++ b/surfsense_local/frontend/translations/ko.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "이 서버에 연결할 수 없습니다. URL과 서버가 실행 중인지 확인하세요.", "plugins_first_party_license_label": "라이선스 필요", "plugins_first_party_title": "SurfSense 제공", + "plugins_group_allow_label": "항상 허용", + "plugins_group_blocked_label": "차단됨", + "plugins_group_custom_label": "사용자 지정", + "plugins_group_needs_approval_label": "승인 필요", + "plugins_group_permission_aria": "{group}의 모든 도구 권한", "plugins_key_body": "SurfSense는 새 키를 저장하기 전에 먼저 확인합니다. 도구 설정은 그대로 유지됩니다.", "plugins_key_cancel_button": "취소", "plugins_key_connect_body": "{plugin}에 사용하는 키를 붙여 넣으세요. 도구 설정은 유지됩니다.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "없음", "plugins_page_tools_title": "도구", "plugins_page_tools_unlisted_body": "이 플러그인의 도구가 나와 있지 않습니다.", + "plugins_permission_allow_body": "에이전트가 확인 없이 사용합니다", + "plugins_permission_allow_closed_body": "사용할 수 없음: 서버가 이 도구가 안전하다고 밝히지 않습니다", + "plugins_permission_allow_title": "항상 허용됨", + "plugins_permission_ask_body": "에이전트가 사용할 때마다 확인합니다", + "plugins_permission_ask_closed_body": "필요 없음: 이 도구는 읽기만 합니다", + "plugins_permission_ask_title": "승인 필요", + "plugins_permission_block_body": "에이전트가 보거나 사용할 수 없습니다", + "plugins_permission_block_title": "차단됨", "plugins_registry_author_label": "{author} 제공", "plugins_registry_community_label": "커뮤니티", "plugins_registry_community_tooltip": "커뮤니티 플러그인. SurfSense에서 인증하지 않았습니다.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "서드파티 플러그인 허용", "plugins_tool_added_label": "새로 추가", "plugins_tool_always_aria": "{tool} 항상 허용", - "plugins_tool_always_asks_label": "항상 확인", - "plugins_tool_always_label": "항상 허용", - "plugins_tool_approval_aria": "{tool} 승인", "plugins_tool_ask_aria": "{tool} 사용 전 확인", - "plugins_tool_ask_label": "확인", + "plugins_tool_block_aria": "{tool} 차단", "plugins_tool_changed_label": "변경됨", - "plugins_tool_never_asks_label": "확인 안 함", - "plugins_tool_use_aria": "{tool} 사용", + "plugins_tool_permission_aria": "{tool} 권한", "resources_breakdown_body": "SurfSense의 각 부분이 지금 사용하는 양입니다.", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "엔진", diff --git a/surfsense_local/frontend/translations/pt-BR.json b/surfsense_local/frontend/translations/pt-BR.json index ff30adf05..5826d03d0 100644 --- a/surfsense_local/frontend/translations/pt-BR.json +++ b/surfsense_local/frontend/translations/pt-BR.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "Não foi possível acessar este servidor. Verifique a URL e se o servidor está em execução.", "plugins_first_party_license_label": "Requer licença", "plugins_first_party_title": "Da SurfSense", + "plugins_group_allow_label": "Sempre permitir", + "plugins_group_blocked_label": "Bloqueadas", + "plugins_group_custom_label": "Personalizado", + "plugins_group_needs_approval_label": "Requerem aprovação", + "plugins_group_permission_aria": "Permissão para todas as ferramentas de {group}", "plugins_key_body": "O SurfSense testa a chave nova antes de salvá-la. Suas configurações de ferramentas continuam.", "plugins_key_cancel_button": "Cancelar", "plugins_key_connect_body": "Cole a chave que você usa no {plugin}. Suas configurações de ferramentas são mantidas.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "Nenhum", "plugins_page_tools_title": "Ferramentas", "plugins_page_tools_unlisted_body": "As ferramentas deste plugin não estão listadas.", + "plugins_permission_allow_body": "O agente usa sem perguntar", + "plugins_permission_allow_closed_body": "Indisponível: o servidor não diz que esta ferramenta é segura", + "plugins_permission_allow_title": "Sempre permitida", + "plugins_permission_ask_body": "O agente pergunta antes de cada uso", + "plugins_permission_ask_closed_body": "Desnecessário: esta ferramenta só lê", + "plugins_permission_ask_title": "Requer aprovação", + "plugins_permission_block_body": "O agente não pode ver nem usar", + "plugins_permission_block_title": "Bloqueada", "plugins_registry_author_label": "Por {author}", "plugins_registry_community_label": "Comunidade", "plugins_registry_community_tooltip": "Plugin da comunidade. Não verificado pelo SurfSense.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "Permitir plugins de terceiros", "plugins_tool_added_label": "Nova", "plugins_tool_always_aria": "Sempre permitir {tool}", - "plugins_tool_always_asks_label": "Sempre pergunta", - "plugins_tool_always_label": "Sempre permitir", - "plugins_tool_approval_aria": "Aprovação de {tool}", "plugins_tool_ask_aria": "Perguntar antes de usar {tool}", - "plugins_tool_ask_label": "Perguntar", + "plugins_tool_block_aria": "Bloquear {tool}", "plugins_tool_changed_label": "Alterada", - "plugins_tool_never_asks_label": "Não pergunta", - "plugins_tool_use_aria": "Usar {tool}", + "plugins_tool_permission_aria": "Permissão para {tool}", "resources_breakdown_body": "O que cada parte do SurfSense está usando agora.", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "Motor", diff --git a/surfsense_local/frontend/translations/ru.json b/surfsense_local/frontend/translations/ru.json index 72134ebe4..4bf489df6 100644 --- a/surfsense_local/frontend/translations/ru.json +++ b/surfsense_local/frontend/translations/ru.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "Не удалось связаться с сервером. Проверьте URL и что сервер запущен.", "plugins_first_party_license_label": "Нужна лицензия", "plugins_first_party_title": "От SurfSense", + "plugins_group_allow_label": "Всегда разрешать", + "plugins_group_blocked_label": "Заблокированы", + "plugins_group_custom_label": "Настроено", + "plugins_group_needs_approval_label": "Нужно подтверждение", + "plugins_group_permission_aria": "Разрешение для всех инструментов в группе «{group}»", "plugins_key_body": "SurfSense проверяет новый ключ перед сохранением. Настройки инструментов сохранятся.", "plugins_key_cancel_button": "Отмена", "plugins_key_connect_body": "Вставьте ключ, который вы используете для {plugin}. Настройки инструментов сохранятся.", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "Не нужен", "plugins_page_tools_title": "Инструменты", "plugins_page_tools_unlisted_body": "Инструменты этого плагина не указаны.", + "plugins_permission_allow_body": "Агент использует его без вопросов", + "plugins_permission_allow_closed_body": "Недоступно: сервер не сообщает, что этот инструмент безопасен", + "plugins_permission_allow_title": "Всегда разрешён", + "plugins_permission_ask_body": "Агент спрашивает перед каждым использованием", + "plugins_permission_ask_closed_body": "Не нужно: этот инструмент только читает", + "plugins_permission_ask_title": "Нужно подтверждение", + "plugins_permission_block_body": "Агент не видит его и не может использовать", + "plugins_permission_block_title": "Заблокирован", "plugins_registry_author_label": "Автор: {author}", "plugins_registry_community_label": "Сообщество", "plugins_registry_community_tooltip": "Плагин сообщества. Не проверен SurfSense.", @@ -964,14 +977,10 @@ "plugins_third_party_label": "Разрешить сторонние плагины", "plugins_tool_added_label": "Новый", "plugins_tool_always_aria": "Всегда разрешать {tool}", - "plugins_tool_always_asks_label": "Всегда спрашивает", - "plugins_tool_always_label": "Всегда разрешать", - "plugins_tool_approval_aria": "Подтверждение для {tool}", "plugins_tool_ask_aria": "Спрашивать перед использованием {tool}", - "plugins_tool_ask_label": "Спрашивать", + "plugins_tool_block_aria": "Заблокировать {tool}", "plugins_tool_changed_label": "Изменён", - "plugins_tool_never_asks_label": "Не спрашивает", - "plugins_tool_use_aria": "Использовать {tool}", + "plugins_tool_permission_aria": "Разрешение для {tool}", "resources_breakdown_body": "Что сейчас использует каждая часть SurfSense.", "resources_breakdown_cpu_label": "ЦП", "resources_breakdown_engine_label": "Движок", diff --git a/surfsense_local/frontend/translations/zh-CN.json b/surfsense_local/frontend/translations/zh-CN.json index 8d1cbfa90..22f675246 100644 --- a/surfsense_local/frontend/translations/zh-CN.json +++ b/surfsense_local/frontend/translations/zh-CN.json @@ -833,6 +833,11 @@ "plugins_error_plugin_unreachable": "无法访问此服务器。请检查网址,并确认服务器正在运行。", "plugins_first_party_license_label": "需要许可证", "plugins_first_party_title": "SurfSense 提供", + "plugins_group_allow_label": "始终允许", + "plugins_group_blocked_label": "已阻止", + "plugins_group_custom_label": "自定义", + "plugins_group_needs_approval_label": "需要批准", + "plugins_group_permission_aria": "{group} 中所有工具的权限", "plugins_key_body": "SurfSense 会先试用新密钥再保存。你的工具设置保持不变。", "plugins_key_cancel_button": "取消", "plugins_key_connect_body": "粘贴你在 {plugin} 中使用的密钥。你的工具设置会保留。", @@ -895,6 +900,14 @@ "plugins_page_sign_in_none_label": "无", "plugins_page_tools_title": "工具", "plugins_page_tools_unlisted_body": "此插件未列出工具。", + "plugins_permission_allow_body": "智能体会直接使用,不询问", + "plugins_permission_allow_closed_body": "不可用:服务器未说明此工具是安全的", + "plugins_permission_allow_title": "始终允许", + "plugins_permission_ask_body": "智能体每次使用前都会询问", + "plugins_permission_ask_closed_body": "无需询问:此工具只读取", + "plugins_permission_ask_title": "需要批准", + "plugins_permission_block_body": "智能体无法看到或使用它", + "plugins_permission_block_title": "已阻止", "plugins_registry_author_label": "{author} 提供", "plugins_registry_community_label": "社区", "plugins_registry_community_tooltip": "社区插件。未经 SurfSense 验证。", @@ -964,14 +977,10 @@ "plugins_third_party_label": "允许第三方插件", "plugins_tool_added_label": "新增", "plugins_tool_always_aria": "始终允许 {tool}", - "plugins_tool_always_asks_label": "始终询问", - "plugins_tool_always_label": "始终允许", - "plugins_tool_approval_aria": "{tool} 的批准方式", "plugins_tool_ask_aria": "使用 {tool} 前询问", - "plugins_tool_ask_label": "询问", + "plugins_tool_block_aria": "阻止 {tool}", "plugins_tool_changed_label": "已变更", - "plugins_tool_never_asks_label": "不询问", - "plugins_tool_use_aria": "使用 {tool}", + "plugins_tool_permission_aria": "{tool} 的权限", "resources_breakdown_body": "SurfSense 各部分当前的占用。", "resources_breakdown_cpu_label": "CPU", "resources_breakdown_engine_label": "引擎",