diff --git a/docs/data-model.md b/docs/data-model.md index bc1d8bd17b..3f731ebf99 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -248,6 +248,43 @@ rather than storing something it cannot describe. That is why the client gates t UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an older daemon. +### Agent provider Paseo tools + +`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are +`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal +configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`. + +Each entry may include a Paseo-tool policy: + +```json +{ + "agents": { + "providers": { + "my-claude": { + "extends": "claude", + "label": "My Claude", + "paseoTools": { + "enabled": true, + "disabledTools": ["browser_evaluate"] + } + } + } + } +} +``` + +Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are +allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool +IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool. +Browser tools also require `daemon.browserTools.enabled` and a connected browser host. + +`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives +Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a +session is created, resumed, imported, or reloaded, so configuration changes affect the next +session rather than an already-running one. + +`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. + ### Git process limits Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per diff --git a/docs/providers.md b/docs/providers.md index 4e199e3b0b..6d0c2b4def 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -57,7 +57,9 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (` Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code. -Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`. +Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers..paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited. + +A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths, not only in the UI. Browser tools remain subject to the daemon browser-tools setting and browser-host availability. Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. diff --git a/packages/app/e2e/browser/provider-removal.spec.ts b/packages/app/e2e/browser/provider-removal.spec.ts index 7e17a1fbe0..fb8a2e7208 100644 --- a/packages/app/e2e/browser/provider-removal.spec.ts +++ b/packages/app/e2e/browser/provider-removal.spec.ts @@ -71,7 +71,10 @@ test.describe("provider removal", () => { await openSettingsHost(page, getServerId()); await openSettingsHostSection(page, getServerId(), "providers"); - await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0); + await page.getByTestId("provider-actions-claude").click(); + await expect(page.getByTestId("provider-configure-tools-claude")).toBeVisible(); + await expect(page.getByTestId("provider-remove-claude")).toHaveCount(0); + await page.keyboard.press("Escape"); await openAddProviderArea(page); await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name); await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 039ed1c4d6..08bdebf479 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2326,9 +2326,37 @@ export const ar: TranslationResources = { updateErrorTitle: "غير قادر على تحديث الموفر", actions: { menu: "{{name}} actions", + configureTools: "تكوين الأدوات", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "تكوين أدوات {{name}}", + searchPlaceholder: "البحث في الأدوات", + updateRequired: { + title: "حدّث المضيف لتهيئة أدوات Paseo", + description: "إعدادات أدوات Paseo لكل مزود غير متاحة على هذا المضيف.", + }, + master: { + title: "تمكين أدوات Paseo", + hint: "السماح لموفر الخدمة هذا باستخدام أدوات Paseo", + accessibilityLabel: "تمكين أدوات Paseo لـ {{name}}", + }, + enableAll: "تمكين الكل", + disableAll: "تعطيل الكل", + globalOverride: { + title: "أدوات Paseo معطلة", + description: "إعداد حقن أدوات Paseo العام يتجاوز اختيارات الموفر والأدوات المحفوظة", + }, + browserUnavailable: { + title: "أدوات المتصفح غير متاحة", + description: + "فعّل أدوات المتصفح في إعدادات المضيف لاستخدامها. تظل الاختيارات المحفوظة دون تغيير", + }, + toolAccessibilityLabel: "أداة Paseo: {{name}}", + noSearchMatches: "لا توجد أدوات تطابق بحثك", + updateErrorTitle: "تعذر تحديث إعدادات أدوات Paseo", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 733929c11e..ca45fc85a4 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2384,9 +2384,38 @@ export const en = { updateErrorTitle: "Unable to update provider", actions: { menu: "{{name}} actions", + configureTools: "Configure tools", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "Configure {{name}} tools", + searchPlaceholder: "Search tools", + updateRequired: { + title: "Update the host to configure Paseo tools", + description: "Per-provider tool settings are unavailable on this host.", + }, + master: { + title: "Enable Paseo tools", + hint: "Allow this provider to use Paseo tools", + accessibilityLabel: "Enable Paseo tools for {{name}}", + }, + enableAll: "Enable all", + disableAll: "Disable all", + globalOverride: { + title: "Paseo tools are disabled", + description: + "The global Inject Paseo tools setting overrides these saved provider and tool choices", + }, + browserUnavailable: { + title: "Browser tools are unavailable", + description: + "Turn on Browser tools in host settings to use these tools. Saved choices remain unchanged", + }, + toolAccessibilityLabel: "{{name}} Paseo tool", + noSearchMatches: "No tools match your search", + updateErrorTitle: "Unable to update Paseo tool settings", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d6f8b9efb9..45a1331991 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2380,9 +2380,39 @@ export const es: TranslationResources = { updateErrorTitle: "No se puede actualizar el proveedor", actions: { menu: "{{name}} actions", + configureTools: "Configurar herramientas", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "Configurar herramientas de {{name}}", + searchPlaceholder: "Buscar herramientas", + updateRequired: { + title: "Actualiza el host para configurar las herramientas de Paseo", + description: + "Los ajustes de herramientas por proveedor no están disponibles en este host.", + }, + master: { + title: "Activar herramientas de Paseo", + hint: "Permitir que este proveedor use herramientas de Paseo", + accessibilityLabel: "Activar herramientas de Paseo para {{name}}", + }, + enableAll: "Activar todas", + disableAll: "Desactivar todas", + globalOverride: { + title: "Las herramientas de Paseo están desactivadas", + description: + "El ajuste global de inyectar herramientas de Paseo anula estas elecciones guardadas", + }, + browserUnavailable: { + title: "Las herramientas del navegador no están disponibles", + description: + "Activa las herramientas del navegador en los ajustes del host para usarlas. Las elecciones guardadas no cambian", + }, + toolAccessibilityLabel: "Herramienta de Paseo: {{name}}", + noSearchMatches: "No hay herramientas que coincidan con tu búsqueda", + updateErrorTitle: "No se pudieron actualizar los ajustes de herramientas de Paseo", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a2075164c6..44640d8f19 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2386,9 +2386,39 @@ export const fr: TranslationResources = { updateErrorTitle: "Impossible de mettre à jour le fournisseur", actions: { menu: "{{name}} actions", + configureTools: "Configurer les outils", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "Configurer les outils de {{name}}", + searchPlaceholder: "Rechercher des outils", + updateRequired: { + title: "Mettez à jour l’hôte pour configurer les outils Paseo", + description: + "Les réglages des outils par fournisseur ne sont pas disponibles sur cet hôte.", + }, + master: { + title: "Activer les outils Paseo", + hint: "Autoriser ce fournisseur à utiliser les outils Paseo", + accessibilityLabel: "Activer les outils Paseo pour {{name}}", + }, + enableAll: "Tout activer", + disableAll: "Tout désactiver", + globalOverride: { + title: "Les outils Paseo sont désactivés", + description: + "Le réglage global d'injection des outils Paseo remplace ces choix enregistrés", + }, + browserUnavailable: { + title: "Les outils du navigateur sont indisponibles", + description: + "Activez les outils du navigateur dans les réglages de l'hôte pour les utiliser. Les choix enregistrés restent inchangés", + }, + toolAccessibilityLabel: "Outil Paseo : {{name}}", + noSearchMatches: "Aucun outil ne correspond à votre recherche", + updateErrorTitle: "Impossible de mettre à jour les réglages des outils Paseo", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f223475338..27da2ff82b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2350,9 +2350,38 @@ export const ja: TranslationResources = { updateErrorTitle: "プロバイダーを更新できません", actions: { menu: "{{name}} actions", + configureTools: "ツールを設定", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "{{name}} のツールを設定", + searchPlaceholder: "ツールを検索", + updateRequired: { + title: "ホストを更新してPaseoツールを設定", + description: "このホストではプロバイダーごとのツール設定を利用できません。", + }, + master: { + title: "Paseo ツールを有効にする", + hint: "このプロバイダーが Paseo ツールを使用できるようにします", + accessibilityLabel: "{{name}} の Paseo ツールを有効にする", + }, + enableAll: "すべて有効にする", + disableAll: "すべて無効にする", + globalOverride: { + title: "Paseo ツールは無効です", + description: + "グローバルの Paseo ツール注入設定が、保存済みのプロバイダーとツールの選択を上書きします", + }, + browserUnavailable: { + title: "ブラウザツールは利用できません", + description: + "使用するにはホスト設定でブラウザツールを有効にしてください。保存済みの選択は変更されません", + }, + toolAccessibilityLabel: "{{name}} Paseo ツール", + noSearchMatches: "検索に一致するツールがありません", + updateErrorTitle: "Paseo ツール設定を更新できません", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7d3d8f5077..2a45c5d260 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2342,9 +2342,38 @@ export const ko: TranslationResources = { updateErrorTitle: "프로바이더를 업데이트할 수 없습니다", actions: { menu: "{{name}} 작업", + configureTools: "도구 구성", remove: "프로바이더 제거", removing: "제거 중...", }, + tools: { + title: "{{name}} 도구 구성", + searchPlaceholder: "도구 검색", + updateRequired: { + title: "Paseo 도구를 구성하려면 호스트를 업데이트하세요", + description: "이 호스트에서는 프로바이더별 도구 설정을 사용할 수 없습니다.", + }, + master: { + title: "Paseo 도구 활성화", + hint: "이 프로바이더가 Paseo 도구를 사용하도록 허용합니다", + accessibilityLabel: "{{name}}에 대한 Paseo 도구 활성화", + }, + enableAll: "모두 활성화", + disableAll: "모두 비활성화", + globalOverride: { + title: "Paseo 도구가 비활성화되었습니다", + description: + "전역 Paseo 도구 주입 설정이 저장된 프로바이더 및 도구 선택 항목보다 우선 적용됩니다.", + }, + browserUnavailable: { + title: "브라우저 도구를 사용할 수 없습니다", + description: + "이 도구들을 사용하려면 호스트 설정에서 브라우저 도구를 켜세요. 저장된 선택 항목은 변경되지 않습니다.", + }, + toolAccessibilityLabel: "{{name}} Paseo 도구", + noSearchMatches: "검색과 일치하는 도구가 없습니다", + updateErrorTitle: "Paseo 도구 설정을 업데이트할 수 없습니다", + }, remove: { confirmTitle: "{{name}}를 제거하시겠습니까?", confirmMessage: diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ea2404a05e..ba09a35a74 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2365,9 +2365,39 @@ export const ptBR: TranslationResources = { updateErrorTitle: "Não foi possível atualizar provedor", actions: { menu: "{{name}} actions", + configureTools: "Configurar ferramentas", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "Configurar ferramentas de {{name}}", + searchPlaceholder: "Buscar ferramentas", + updateRequired: { + title: "Atualize o host para configurar as ferramentas do Paseo", + description: + "As configurações de ferramentas por provedor não estão disponíveis neste host.", + }, + master: { + title: "Ativar ferramentas do Paseo", + hint: "Permitir que este provedor use ferramentas do Paseo", + accessibilityLabel: "Ativar ferramentas do Paseo para {{name}}", + }, + enableAll: "Ativar todas", + disableAll: "Desativar todas", + globalOverride: { + title: "As ferramentas do Paseo estão desativadas", + description: + "A configuração global de injetar ferramentas do Paseo substitui estas escolhas salvas", + }, + browserUnavailable: { + title: "As ferramentas do navegador estão indisponíveis", + description: + "Ative as ferramentas do navegador nas configurações do host para usá-las. As escolhas salvas não mudam", + }, + toolAccessibilityLabel: "Ferramenta do Paseo: {{name}}", + noSearchMatches: "Nenhuma ferramenta corresponde à sua busca", + updateErrorTitle: "Não foi possível atualizar as configurações das ferramentas do Paseo", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 189875688c..67c22f12fb 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2368,9 +2368,38 @@ export const ru: TranslationResources = { updateErrorTitle: "Невозможно обновить провайдера", actions: { menu: "{{name}} actions", + configureTools: "Настроить инструменты", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "Настроить инструменты {{name}}", + searchPlaceholder: "Поиск инструментов", + updateRequired: { + title: "Обновите хост, чтобы настроить инструменты Paseo", + description: "Настройки инструментов для отдельных провайдеров недоступны на этом хосте.", + }, + master: { + title: "Включить инструменты Paseo", + hint: "Разрешить этому провайдеру использовать инструменты Paseo", + accessibilityLabel: "Включить инструменты Paseo для {{name}}", + }, + enableAll: "Включить все", + disableAll: "Отключить все", + globalOverride: { + title: "Инструменты Paseo отключены", + description: + "Глобальная настройка внедрения инструментов Paseo переопределяет эти сохраненные выборы", + }, + browserUnavailable: { + title: "Инструменты браузера недоступны", + description: + "Включите инструменты браузера в настройках хоста. Сохраненные выборы не изменятся", + }, + toolAccessibilityLabel: "Инструмент Paseo: {{name}}", + noSearchMatches: "Нет инструментов, соответствующих поиску", + updateErrorTitle: "Не удалось обновить настройки инструментов Paseo", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b93bb98cb2..69ff43d276 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2298,9 +2298,36 @@ export const zhCN: TranslationResources = { updateErrorTitle: "无法更新 Provider", actions: { menu: "{{name}} actions", + configureTools: "配置工具", remove: "Remove provider", removing: "Removing...", }, + tools: { + title: "配置 {{name}} 工具", + searchPlaceholder: "搜索工具", + updateRequired: { + title: "更新 Host 以配置 Paseo 工具", + description: "此 Host 不支持按 Provider 配置工具。", + }, + master: { + title: "启用 Paseo 工具", + hint: "允许此 Provider 使用 Paseo 工具", + accessibilityLabel: "为 {{name}} 启用 Paseo 工具", + }, + enableAll: "全部启用", + disableAll: "全部禁用", + globalOverride: { + title: "Paseo 工具已禁用", + description: "全局注入 Paseo 工具设置会覆盖这些已保存的 Provider 和工具选择", + }, + browserUnavailable: { + title: "浏览器工具不可用", + description: "在 Host 设置中启用浏览器工具后才能使用。已保存的选择不会改变", + }, + toolAccessibilityLabel: "{{name}} Paseo 工具", + noSearchMatches: "没有匹配搜索的工具", + updateErrorTitle: "无法更新 Paseo 工具设置", + }, remove: { confirmTitle: "Remove {{name}}?", confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", diff --git a/packages/app/src/screens/settings/paseo-tools-policy-sheet.test.tsx b/packages/app/src/screens/settings/paseo-tools-policy-sheet.test.tsx new file mode 100644 index 0000000000..2db5807887 --- /dev/null +++ b/packages/app/src/screens/settings/paseo-tools-policy-sheet.test.tsx @@ -0,0 +1,315 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; + +const { patchConfigMock } = vi.hoisted(() => ({ + patchConfigMock: vi.fn(async () => makeConfig()), +})); + +vi.mock("@getpaseo/protocol/paseo-tool-manifest", () => ({ + PASEO_TOOL_MANIFEST: [ + { + id: "create_workspace", + label: "Create workspace", + description: "Create a workspace.", + group: "Workspaces", + }, + { + id: "list_agents", + label: "List agents", + description: "List agents.", + group: "Agents", + }, + { + id: "browser_snapshot", + label: "Snapshot browser page", + description: "Read a browser page.", + group: "Browser", + browser: true, + }, + ], +})); + +vi.mock("react-native", () => ({ + View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("div", { "data-testid": testID }, children), + Text: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("span", { "data-testid": testID }, children), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" + ? (factory as (theme: Record) => unknown)({ + spacing: { 1: 4, 2: 8, 4: 16, 6: 24 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400", medium: "500" }, + borderRadius: { lg: 8 }, + borderWidth: { 1: 1 }, + colors: { + surface1: "#111", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + }, + }) + : factory, + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + ( + ({ + "settings.providers.tools.title": "Configure {{name}} tools", + "settings.providers.tools.searchPlaceholder": "Search tools", + "settings.providers.tools.master.title": "Enable Paseo tools", + "settings.providers.tools.master.hint": "Allow this provider to use Paseo tools", + "settings.providers.tools.master.accessibilityLabel": "Enable Paseo tools for {{name}}", + "settings.providers.tools.enableAll": "Enable all", + "settings.providers.tools.disableAll": "Disable all", + "settings.providers.tools.globalOverride.title": "Paseo tools are disabled", + "settings.providers.tools.globalOverride.description": "Global override", + "settings.providers.tools.browserUnavailable.title": "Browser tools are unavailable", + "settings.providers.tools.browserUnavailable.description": "Browser tools are off", + "settings.providers.tools.toolAccessibilityLabel": "{{name}} Paseo tool", + "settings.providers.tools.noSearchMatches": "No tools match your search", + "settings.providers.tools.updateErrorTitle": "Unable to update Paseo tool settings", + "workspace.terminal.hostDisconnected": "Host disconnected", + })[key] ?? key + ).replaceAll("{{name}}", values?.name ?? ""), + }), +})); + +vi.mock("@/components/adaptive-modal-sheet", () => ({ + AdaptiveModalSheet: ({ + header, + children, + testID, + }: { + header: { search?: { onChange: (value: string) => void; testID?: string } }; + children?: React.ReactNode; + testID?: string; + }) => + React.createElement( + "div", + { "data-testid": testID }, + header.search + ? React.createElement("input", { + "data-testid": header.search.testID, + onChange: (event: React.ChangeEvent) => + header.search?.onChange(event.target.value), + }) + : null, + children, + ), +})); + +vi.mock("@/components/ui/alert", () => ({ + Alert: ({ + title, + description, + testID, + }: { + title?: string; + description?: string; + testID?: string; + }) => + React.createElement("div", { "data-testid": testID }, `${title ?? ""} ${description ?? ""}`), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + React.createElement( + "button", + { type: "button", disabled, "data-testid": testID, onClick: onPress }, + children, + ), +})); + +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ + value, + onValueChange, + disabled, + testID, + accessibilityLabel, + }: { + value: boolean; + onValueChange?: (value: boolean) => void; + disabled?: boolean; + testID?: string; + accessibilityLabel?: string; + }) => + React.createElement("button", { + type: "button", + role: "switch", + disabled, + "aria-checked": value ? "true" : "false", + "aria-label": accessibilityLabel, + "data-testid": testID, + onClick: () => onValueChange?.(!value), + }), +})); + +import { PaseoToolsPolicySheet } from "./paseo-tools-policy-sheet"; + +function makeConfig(overrides: Partial = {}): MutableDaemonConfig { + return { + relay: { enabled: false }, + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + ...overrides, + }; +} + +describe("PaseoToolsPolicySheet", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + patchConfigMock.mockReset(); + patchConfigMock.mockResolvedValue(makeConfig()); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + function render(config = makeConfig()): void { + act(() => { + root?.render( + , + ); + }); + } + + it("defaults missing policy to enabled and writes a sparse disabledTools patch", async () => { + render(); + + expect( + container + ?.querySelector('[data-testid="paseo-tools-master-switch"]') + ?.getAttribute("aria-checked"), + ).toBe("true"); + + await act(async () => { + container + ?.querySelector('[data-testid="paseo-tool-create_workspace-switch"]') + ?.click(); + }); + + expect(patchConfigMock).toHaveBeenCalledWith({ + providers: { claude: { paseoTools: { disabledTools: ["create_workspace"] } } }, + }); + }); + + it("preserves disabled browser choices while browser tools are unavailable", async () => { + render( + makeConfig({ + providers: { claude: { paseoTools: { disabledTools: ["browser_snapshot"] } } }, + }), + ); + + const browserTool = container?.querySelector( + '[data-testid="paseo-tool-browser_snapshot-switch"]', + ); + expect(browserTool?.disabled).toBe(true); + expect( + container?.querySelector('[data-testid="paseo-tools-browser-unavailable"]'), + ).not.toBeNull(); + + await act(async () => { + container?.querySelector('[data-testid="paseo-tools-disable-all"]')?.click(); + }); + + expect(patchConfigMock).toHaveBeenCalledWith({ + providers: { + claude: { + paseoTools: { disabledTools: ["browser_snapshot", "create_workspace", "list_agents"] }, + }, + }, + }); + }); + + it("filters the grouped list from the sheet search", () => { + render(); + + const search = container?.querySelector( + '[data-testid="paseo-tools-policy-search"]', + ); + expect(search).not.toBeNull(); + + act(() => { + if (!search) return; + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setValue?.call(search, "browser"); + search.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + + expect(container?.querySelector('[data-testid="paseo-tools-group-Browser"]')).not.toBeNull(); + expect(container?.querySelector('[data-testid="paseo-tools-group-Workspaces"]')).toBeNull(); + }); + + it("shows saved choices but disables policy controls under the global kill switch", () => { + render( + makeConfig({ + mcp: { injectIntoAgents: false }, + providers: { claude: { paseoTools: { enabled: false, disabledTools: ["list_agents"] } } }, + }), + ); + + expect(container?.querySelector('[data-testid="paseo-tools-global-override"]')).not.toBeNull(); + expect( + container?.querySelector('[data-testid="paseo-tools-master-switch"]') + ?.disabled, + ).toBe(true); + expect( + container?.querySelector('[data-testid="paseo-tool-list_agents-switch"]') + ?.disabled, + ).toBe(true); + expect( + container + ?.querySelector('[data-testid="paseo-tool-list_agents-switch"]') + ?.getAttribute("aria-checked"), + ).toBe("false"); + }); +}); diff --git a/packages/app/src/screens/settings/paseo-tools-policy-sheet.tsx b/packages/app/src/screens/settings/paseo-tools-policy-sheet.tsx new file mode 100644 index 0000000000..1e5b435a01 --- /dev/null +++ b/packages/app/src/screens/settings/paseo-tools-policy-sheet.tsx @@ -0,0 +1,308 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; +import { + PASEO_TOOL_MANIFEST, + type PaseoToolManifestEntry, +} from "@getpaseo/protocol/paseo-tool-manifest"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { settingsStyles } from "@/styles/settings"; +import { StyleSheet } from "react-native-unistyles"; + +interface PaseoToolGroup { + name: string; + tools: readonly PaseoToolManifestEntry[]; +} + +interface PaseoToolsPolicySheetProps { + providerId: string; + providerLabel: string; + config: MutableDaemonConfig | null; + visible: boolean; + onClose: () => void; + onDismiss: () => void; + patchConfig: (patch: MutableDaemonConfigPatch) => Promise; +} + +interface PaseoToolRowProps { + tool: PaseoToolManifestEntry; + isEnabled: boolean; + disabled: boolean; + isFirst: boolean; + onChange: (tool: PaseoToolManifestEntry, enabled: boolean) => void; +} + +const EMPTY_DISABLED_TOOLS: string[] = []; + +function isBrowserTool(tool: PaseoToolManifestEntry): boolean { + return tool.browser === true; +} + +function toolMatchesQuery(tool: PaseoToolManifestEntry, query: string): boolean { + return [tool.id, tool.label, tool.description].some((value) => + value.toLocaleLowerCase().includes(query), + ); +} + +function groupTools(tools: readonly PaseoToolManifestEntry[]): PaseoToolGroup[] { + const groups = new Map(); + for (const tool of tools) { + const group = groups.get(tool.group); + if (group) group.push(tool); + else groups.set(tool.group, [tool]); + } + return Array.from(groups, ([name, group]) => ({ name, tools: group })); +} + +const TOOL_GROUPS = groupTools(PASEO_TOOL_MANIFEST); + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function PaseoToolRow({ tool, isEnabled, disabled, isFirst, onChange }: PaseoToolRowProps) { + const { t } = useTranslation(); + const handleValueChange = useCallback( + (enabled: boolean) => onChange(tool, enabled), + [onChange, tool], + ); + + return ( + + + {tool.label} + {tool.description} + + + + ); +} + +export function PaseoToolsPolicySheet({ + providerId, + providerLabel, + config, + visible, + onClose, + onDismiss, + patchConfig, +}: PaseoToolsPolicySheetProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [pendingAction, setPendingAction] = useState(null); + const [error, setError] = useState(null); + const policy = config?.providers[providerId]?.paseoTools; + const globalToolsEnabled = config?.mcp.injectIntoAgents !== false; + const browserToolsEnabled = config?.browserTools.enabled === true; + const disabledTools = policy?.disabledTools ?? EMPTY_DISABLED_TOOLS; + const disabledToolSet = useMemo(() => new Set(disabledTools), [disabledTools]); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const visibleGroups = useMemo(() => { + if (!normalizedQuery) return TOOL_GROUPS; + return TOOL_GROUPS.flatMap((group) => { + if (group.name.toLocaleLowerCase().includes(normalizedQuery)) return [group]; + const tools = group.tools.filter((tool) => toolMatchesQuery(tool, normalizedQuery)); + return tools.length > 0 ? [{ ...group, tools }] : []; + }); + }, [normalizedQuery]); + const controlsDisabled = !globalToolsEnabled || pendingAction !== null; + + const updatePolicy = useCallback( + async (action: string, nextPolicy: { enabled?: boolean; disabledTools?: string[] }) => { + setPendingAction(action); + setError(null); + try { + const result = await patchConfig({ + providers: { [providerId]: { paseoTools: nextPolicy } }, + }); + if (!result) throw new Error(t("workspace.terminal.hostDisconnected")); + } catch (updateError) { + setError(getErrorMessage(updateError)); + } finally { + setPendingAction(null); + } + }, + [patchConfig, providerId, t], + ); + + const handleMasterChange = useCallback( + (enabled: boolean) => void updatePolicy("master", { enabled }), + [updatePolicy], + ); + const handleToolChange = useCallback( + (tool: PaseoToolManifestEntry, enabled: boolean) => { + const nextDisabledTools = enabled + ? disabledTools.filter((toolId) => toolId !== tool.id) + : [...disabledTools, tool.id]; + void updatePolicy(tool.id, { disabledTools: nextDisabledTools }); + }, + [disabledTools, updatePolicy], + ); + const handleBulkChange = useCallback( + (enabled: boolean) => { + const editableTools = PASEO_TOOL_MANIFEST.filter( + (tool) => browserToolsEnabled || !isBrowserTool(tool), + ); + const editableToolIds = new Set(editableTools.map((tool) => tool.id)); + const nextDisabledTools = enabled + ? disabledTools.filter((toolId) => !editableToolIds.has(toolId)) + : [ + ...disabledTools, + ...editableTools.filter((tool) => !disabledToolSet.has(tool.id)).map((tool) => tool.id), + ]; + void updatePolicy(enabled ? "enable-all" : "disable-all", { + disabledTools: nextDisabledTools, + }); + }, + [browserToolsEnabled, disabledToolSet, disabledTools, updatePolicy], + ); + const handleEnableAll = useCallback(() => handleBulkChange(true), [handleBulkChange]); + const handleDisableAll = useCallback(() => handleBulkChange(false), [handleBulkChange]); + const header = useMemo( + () => ({ + title: t("settings.providers.tools.title", { name: providerLabel }), + search: { + onChange: setQuery, + resetKey: providerId, + placeholder: t("settings.providers.tools.searchPlaceholder"), + autoFocus: true, + testID: "paseo-tools-policy-search", + }, + }), + [providerId, providerLabel, t], + ); + + return ( + + {!globalToolsEnabled ? ( + + ) : null} + {error ? ( + + ) : null} + + + + + {t("settings.providers.tools.master.title")} + + {t("settings.providers.tools.master.hint")} + + + + + + + + + {!browserToolsEnabled ? ( + + ) : null} + {visibleGroups.map((group) => ( + + + {group.name} + + + {group.tools.map((tool, index) => ( + + ))} + + + ))} + {visibleGroups.length === 0 ? ( + + {t("settings.providers.tools.noSearchMatches")} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + bulkActions: { + flexDirection: "row", + gap: theme.spacing[2], + justifyContent: "flex-end", + }, + group: { + gap: theme.spacing[2], + }, + groupTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + marginLeft: theme.spacing[1], + }, + empty: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[6], + textAlign: "center", + }, +})); diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index d96ec9657e..fe4b269b73 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -7,41 +7,50 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; -const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted( - () => ({ - theme: { - spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 }, - iconSize: { sm: 14, md: 20 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400" }, - borderRadius: { lg: 8 }, - opacity: { 50: 0.5 }, - colors: { - surface1: "#111", - surface2: "#222", - surface3: "#333", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - accent: "#0a84ff", - statusSuccess: "#00ff00", - statusWarning: "#ff9500", - statusDanger: "#ff0000", - palette: { red: { 300: "#ff6b6b" }, white: "#fff" }, - }, - }, - snapshotState: { - entries: undefined as ProviderSnapshotEntry[] | undefined, - isLoading: false, - isRefreshing: false, - }, - configState: { - config: null as MutableDaemonConfig | null, +const { + theme, + snapshotState, + configState, + patchConfigMock, + openProviderSettingsMock, + hostFeatureState, +} = vi.hoisted(() => ({ + theme: { + spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 }, + iconSize: { sm: 14, md: 20 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400" }, + borderRadius: { lg: 8 }, + opacity: { 50: 0.5 }, + colors: { + surface1: "#111", + surface2: "#222", + surface3: "#333", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + accent: "#0a84ff", + statusSuccess: "#00ff00", + statusWarning: "#ff9500", + statusDanger: "#ff0000", + palette: { red: { 300: "#ff6b6b" }, white: "#fff" }, }, - patchConfigMock: vi.fn(async () => undefined), - openProviderSettingsMock: vi.fn(), - }), -); + }, + snapshotState: { + entries: undefined as ProviderSnapshotEntry[] | undefined, + isLoading: false, + isRefreshing: false, + }, + configState: { + config: null as MutableDaemonConfig | null, + }, + patchConfigMock: vi.fn(async () => undefined), + openProviderSettingsMock: vi.fn(), + hostFeatureState: { + providerRemoval: false, + paseoToolPolicies: true, + }, +})); vi.mock("react-native", () => ({ Platform: { OS: "web" }, @@ -94,11 +103,25 @@ vi.mock("react-native-unistyles", () => ({ useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), })); +vi.mock("@/components/ui/alert", () => ({ + Alert: ({ + title, + description, + testID, + }: { + title?: string; + description?: string; + testID?: string; + }) => + React.createElement("div", { "data-testid": testID }, `${title ?? ""} ${description ?? ""}`), +})); + vi.mock("lucide-react-native", () => { const icon = (name: string) => () => React.createElement("span", { "data-icon": name }); return { ChevronRight: icon("ChevronRight"), MoreHorizontal: icon("MoreHorizontal"), + Settings2: icon("Settings2"), Trash2: icon("Trash2"), }; }); @@ -120,6 +143,11 @@ vi.mock("react-i18next", () => ({ "settings.providers.addErrorTitle": "Unable to add provider", "settings.providers.updateErrorTitle": "Unable to update provider", "settings.providers.actions.menu": "{{name}} actions", + "settings.providers.actions.configureTools": "Configure tools", + "settings.providers.tools.updateRequired.title": + "Update the host to configure Paseo tools", + "settings.providers.tools.updateRequired.description": + "Per-provider tool settings are unavailable on this host.", "settings.providers.actions.remove": "Remove provider", "settings.providers.actions.removing": "Removing...", "settings.providers.remove.confirmTitle": "Remove {{name}}?", @@ -242,6 +270,15 @@ vi.mock("@/components/provider-catalog-list", () => ({ ProviderCatalogList: () => null, })); +vi.mock("@/screens/settings/paseo-tools-policy-sheet", () => ({ + PaseoToolsPolicySheet: ({ providerId, visible }: { providerId: string; visible: boolean }) => + React.createElement("div", { + "data-testid": "paseo-tools-policy-sheet", + "data-provider-id": providerId, + "data-visible": visible ? "true" : "false", + }), +})); + vi.mock("@/hooks/use-providers-snapshot", () => ({ useProvidersSnapshot: () => ({ entries: snapshotState.entries, @@ -268,7 +305,8 @@ vi.mock("@/runtime/host-runtime", () => ({ })); vi.mock("@/runtime/host-features", () => ({ - useHostFeature: () => false, + useHostFeature: (_serverId: string, feature: keyof typeof hostFeatureState) => + hostFeatureState[feature], })); vi.mock("@/utils/confirm-dialog", () => ({ @@ -343,6 +381,8 @@ describe("ProvidersSection", () => { snapshotState.isLoading = false; snapshotState.isRefreshing = false; configState.config = null; + hostFeatureState.providerRemoval = false; + hostFeatureState.paseoToolPolicies = true; patchConfigMock.mockReset(); patchConfigMock.mockResolvedValue(undefined); openProviderSettingsMock.mockReset(); @@ -459,4 +499,40 @@ describe("ProvidersSection", () => { providers: { claude: { enabled: false } }, }); }); + + it("opens the Paseo tool policy from the provider actions menu", () => { + snapshotState.entries = [claudeEntry]; + configState.config = makeConfig(); + + render(); + + const configureTools = container?.querySelector( + '[data-testid="provider-configure-tools-claude"]', + ); + expect(configureTools).not.toBeNull(); + + act(() => { + configureTools?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); + + const sheet = container?.querySelector('[data-testid="paseo-tools-policy-sheet"]'); + expect(sheet?.getAttribute("data-provider-id")).toBe("claude"); + expect(sheet?.getAttribute("data-visible")).toBe("true"); + }); + + it("hides tool policy controls and requests a host update when unsupported", () => { + hostFeatureState.paseoToolPolicies = false; + snapshotState.entries = [claudeEntry]; + configState.config = makeConfig(); + + render(); + + expect(container?.querySelector('[data-testid="provider-configure-tools-claude"]')).toBeNull(); + expect(container?.querySelector('[data-testid="paseo-tools-policy-sheet"]')).toBeNull(); + expect( + container?.querySelector('[data-testid="provider-tools-update-required"]')?.textContent, + ).toBe( + "Update the host to configure Paseo tools Per-provider tool settings are unavailable on this host.", + ); + }); }); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 78d142fcfa..925ed9a147 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -23,6 +23,8 @@ import { } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; import { getProviderIcon } from "@/components/provider-icons"; +import { PaseoToolsPolicySheet } from "@/screens/settings/paseo-tools-policy-sheet"; +import { Alert as InlineAlert } from "@/components/ui/alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { @@ -35,7 +37,7 @@ import { SettingsSection } from "@/screens/settings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; -import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native"; +import { ChevronRight, MoreHorizontal, Settings2, Trash2 } from "lucide-react-native"; type ProviderDefinition = ReturnType[number]; type ProviderEntry = NonNullable["entries"]>[number]; @@ -84,8 +86,10 @@ interface ProviderRowProps { isRemoving: boolean; canRemove: boolean; isFirst: boolean; + canConfigureTools: boolean; onPress: (providerId: string) => void; onToggleEnabled: (providerId: string, enabled: boolean) => void; + onConfigureTools: (providerId: string) => void; onRemove: (providerId: string, providerLabel: string) => void; } @@ -101,6 +105,9 @@ interface ProviderActionsMenuProps { foregroundColor: string; foregroundMutedColor: string; dangerColor: string; + canRemove: boolean; + canConfigureTools: boolean; + onConfigureTools: (providerId: string) => void; onRemove: (providerId: string, providerLabel: string) => void; } @@ -112,12 +119,18 @@ function ProviderActionsMenu({ foregroundColor, foregroundMutedColor, dangerColor, + canRemove, + canConfigureTools, + onConfigureTools, onRemove, }: ProviderActionsMenuProps) { const { t } = useTranslation(); const handleRemove = useCallback(() => { onRemove(providerId, providerLabel); }, [onRemove, providerId, providerLabel]); + const handleConfigureTools = useCallback(() => { + onConfigureTools(providerId); + }, [onConfigureTools, providerId]); const triggerStyle = useCallback( ({ pressed, @@ -131,6 +144,10 @@ function ProviderActionsMenu({ [], ); const trashLeading = useMemo(() => , [dangerColor]); + const settingsLeading = useMemo( + () => , + [foregroundMutedColor], + ); return ( @@ -151,16 +168,27 @@ function ProviderActionsMenu({ )} - - {t("settings.providers.actions.remove")} - + {canConfigureTools ? ( + + {t("settings.providers.actions.configureTools")} + + ) : null} + {canRemove ? ( + + {t("settings.providers.actions.remove")} + + ) : null} ); @@ -174,8 +202,10 @@ function ProviderRow({ isRemoving, canRemove, isFirst, + canConfigureTools, onPress, onToggleEnabled, + onConfigureTools, onRemove, }: ProviderRowProps) { const { t } = useTranslation(); @@ -249,20 +279,23 @@ function ProviderRow({ disabled={isToggling || isRemoving} accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })} /> - - {canRemove ? ( + {canConfigureTools || canRemove ? ( + - ) : null} - + + ) : null} )} @@ -325,13 +358,16 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); + const supportsPaseoToolPolicies = useHostFeature(serverId, "paseoToolPolicies"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); const removingProviderIdRef = useRef(null); const [installingProviderId, setInstallingProviderId] = useState(null); + const [toolPolicyProviderId, setToolPolicyProviderId] = useState(null); + const [toolPolicyVisible, setToolPolicyVisible] = useState(false); const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; @@ -360,6 +396,13 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { [patchConfig, t], ); + const handleOpenToolPolicy = useCallback((providerId: string) => { + setToolPolicyProviderId(providerId); + setToolPolicyVisible(true); + }, []); + const handleCloseToolPolicy = useCallback(() => setToolPolicyVisible(false), []); + const handleDismissToolPolicy = useCallback(() => setToolPolicyProviderId(null), []); + const handleRemoveProvider = useCallback( async (providerId: string, providerLabel: string) => { if (removingProviderIdRef.current) return; @@ -418,6 +461,14 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { testID="host-page-providers-card" style={styles.sectionSpacing} > + {hasServer && isConnected && !supportsPaseoToolPolicies ? ( + + ) : null} {!hasServer || !isConnected ? ( {t("settings.providers.unavailable")} @@ -443,8 +494,10 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { isRemoving={removingProviderId === def.id} canRemove={supportsProviderRemoval && entry.source === "custom"} isFirst={index === 0} + canConfigureTools={supportsPaseoToolPolicies} onPress={handleOpenProviderSettings} onToggleEnabled={handleToggleEnabled} + onConfigureTools={handleOpenToolPolicy} onRemove={handleRemoveProvider} /> ); @@ -466,6 +519,20 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { /> ) : null} + {supportsPaseoToolPolicies && toolPolicyProviderId ? ( + provider.id === toolPolicyProviderId)?.label ?? + toolPolicyProviderId + } + config={config} + visible={toolPolicyVisible} + onClose={handleCloseToolPolicy} + onDismiss={handleDismissToolPolicy} + patchConfig={patchConfig} + /> + ) : null} ); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c3c63f3ad9..65688867ba 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -5,6 +5,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { AgentProviderSchema } from "./provider-manifest.js"; import { TOOL_CALL_ICON_NAMES } from "./agent-types.js"; +import { ProviderPaseoToolsPolicySchema } from "./provider-config.js"; import { ChatCreateRequestSchema, ChatListRequestSchema, @@ -103,6 +104,7 @@ const MutableDaemonProviderModelSchema = z const MutableDaemonProviderConfigSchema = z .object({ + paseoTools: ProviderPaseoToolsPolicySchema.optional(), enabled: z.boolean().optional(), additionalModels: z.array(MutableDaemonProviderModelSchema).optional(), }) @@ -3256,6 +3258,8 @@ export const ServerInfoStatusPayloadSchema = z agentProfiles: z.boolean().optional(), // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. agentConfigApply: z.boolean().optional(), + // COMPAT(paseoToolPolicies): added in v0.2.6, remove gate after 2027-01-31. + paseoToolPolicies: z.boolean().optional(), }) .optional(), }) diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 22d22acacf..98cc1e736e 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -94,6 +94,16 @@ describe("wire schema compatibility", () => { }); }); + test("server info accepts the optional Paseo tool policies feature flag", () => { + const parsed = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "paseo-tools-server", + features: { paseoToolPolicies: true }, + }); + + expect(parsed.features?.paseoToolPolicies).toBe(true); + }); + test("assistant timeline message ids are optional on the wire", () => { expect( AgentTimelineItemPayloadSchema.parse({ diff --git a/packages/protocol/src/paseo-tool-manifest.test.ts b/packages/protocol/src/paseo-tool-manifest.test.ts new file mode 100644 index 0000000000..a64c846b0a --- /dev/null +++ b/packages/protocol/src/paseo-tool-manifest.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; +import { PASEO_TOOL_MANIFEST } from "./paseo-tool-manifest.js"; + +const EXPECTED_TOOL_IDS = [ + "create_workspace", + "list_workspaces", + "archive_workspace", + "rename_workspace", + "list_workspace_scripts", + "start_workspace_script", + "stop_workspace_script", + "create_agent", + "send_agent_prompt", + "get_agent_status", + "list_agents", + "cancel_agent", + "archive_agent", + "kill_agent", + "update_agent", + "get_agent_activity", + "set_agent_mode", + "list_pending_permissions", + "respond_to_permission", + "list_terminals", + "create_terminal", + "kill_terminal", + "capture_terminal", + "send_terminal_keys", + "create_schedule", + "create_heartbeat", + "delete_heartbeat", + "list_schedules", + "inspect_schedule", + "pause_schedule", + "resume_schedule", + "delete_schedule", + "update_schedule", + "schedule_logs", + "run_schedule_once", + "list_providers", + "list_models", + "inspect_provider", + "browser_list_tabs", + "browser_new_tab", + "browser_snapshot", + "browser_click", + "browser_fill", + "browser_wait", + "browser_type", + "browser_keypress", + "browser_navigate", + "browser_back", + "browser_forward", + "browser_reload", + "browser_screenshot", + "browser_upload", + "browser_hover", + "browser_select", + "browser_drag", + "browser_logs", + "browser_evaluate", + "browser_scroll", + "browser_resize", + "browser_close_tab", +] as const; + +describe("Paseo tool manifest", () => { + test("lists every current core and browser tool except voice-only speak", () => { + expect(PASEO_TOOL_MANIFEST.map((entry) => entry.id)).toEqual(EXPECTED_TOOL_IDS); + expect(new Set(PASEO_TOOL_MANIFEST.map((entry) => entry.id)).size).toBe( + PASEO_TOOL_MANIFEST.length, + ); + expect(PASEO_TOOL_MANIFEST.some((entry) => entry.id === "speak")).toBe(false); + }); + + test("provides grouped labels and descriptions with browser flags", () => { + expect(PASEO_TOOL_MANIFEST.find((entry) => entry.id === "create_workspace")).toMatchObject({ + label: "Create workspace", + group: "Workspaces", + }); + expect(PASEO_TOOL_MANIFEST.find((entry) => entry.id === "list_providers")).toMatchObject({ + label: "List providers", + group: "Providers", + }); + expect(PASEO_TOOL_MANIFEST.find((entry) => entry.id === "browser_snapshot")).toMatchObject({ + label: "Snapshot browser page", + group: "Browser", + browser: true, + }); + + for (const entry of PASEO_TOOL_MANIFEST) { + expect(entry.label).toMatch(/\S/); + expect(entry.description).toMatch(/\S/); + } + + expect(PASEO_TOOL_MANIFEST.filter((entry) => entry.browser)).toHaveLength(22); + expect(PASEO_TOOL_MANIFEST.filter((entry) => !entry.browser)).toHaveLength(38); + }); +}); diff --git a/packages/protocol/src/paseo-tool-manifest.ts b/packages/protocol/src/paseo-tool-manifest.ts new file mode 100644 index 0000000000..57cba9880e --- /dev/null +++ b/packages/protocol/src/paseo-tool-manifest.ts @@ -0,0 +1,441 @@ +/** + * Declarative metadata for the Paseo tools exposed to agents. This is shared + * UI metadata, not a wire contract; tool IDs intentionally remain open + * strings in provider configuration. + */ + +export type PaseoToolManifestGroup = + | "Workspaces" + | "Agents" + | "Terminals" + | "Schedules" + | "Providers" + | "Browser"; + +export interface PaseoToolManifestEntry { + id: string; + label: string; + description: string; + group: PaseoToolManifestGroup; + browser?: boolean; +} + +export const PASEO_TOOL_MANIFEST = [ + { + id: "create_workspace", + label: "Create workspace", + description: + "Create a workspace using an existing local checkout or a new Paseo-managed worktree.", + group: "Workspaces", + }, + { + id: "list_workspaces", + label: "List workspaces", + description: "List active workspaces.", + group: "Workspaces", + }, + { + id: "archive_workspace", + label: "Archive workspace", + description: "Archive a workspace and everything it owns.", + group: "Workspaces", + }, + { + id: "rename_workspace", + label: "Rename workspace", + description: + "Rename a workspace by setting its user-visible title. Omit workspaceId to rename your current workspace.", + group: "Workspaces", + }, + { + id: "list_workspace_scripts", + label: "List workspace scripts", + description: + "List configured workspace scripts and their lifecycle, service port, proxy URL, health, and terminal ID.", + group: "Workspaces", + }, + { + id: "start_workspace_script", + label: "Start workspace script", + description: + "Start one configured workspace script through Paseo's managed workspace-script launcher.", + group: "Workspaces", + }, + { + id: "stop_workspace_script", + label: "Stop workspace script", + description: "Stop a running workspace script through its supervised terminal lifecycle.", + group: "Workspaces", + }, + { + id: "create_agent", + label: "Create agent", + description: + "Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.", + group: "Agents", + }, + { + id: "send_agent_prompt", + label: "Send agent prompt", + description: + "Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.", + group: "Agents", + }, + { + id: "get_agent_status", + label: "Get agent status", + description: + "Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.", + group: "Agents", + }, + { + id: "list_agents", + label: "List agents", + description: "List recent agents as compact metadata.", + group: "Agents", + }, + { + id: "cancel_agent", + label: "Cancel agent run", + description: "Abort the agent's current run but keep the agent alive for future tasks.", + group: "Agents", + }, + { + id: "archive_agent", + label: "Archive agent", + description: + "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", + group: "Agents", + }, + { + id: "kill_agent", + label: "Kill agent", + description: "Terminate an agent session permanently.", + group: "Agents", + }, + { + id: "update_agent", + label: "Update agent", + description: "Update an agent name, labels, and/or runtime settings.", + group: "Agents", + }, + { + id: "get_agent_activity", + label: "Get agent activity", + description: "Return recent agent timeline entries as a curated summary.", + group: "Agents", + }, + { + id: "set_agent_mode", + label: "Set agent session mode", + description: + "Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).", + group: "Agents", + }, + { + id: "list_pending_permissions", + label: "List pending permissions", + description: + "Return all pending permission requests across all agents with the normalized payloads.", + group: "Agents", + }, + { + id: "respond_to_permission", + label: "Respond to permission", + description: + "Approve or deny a pending permission request with an AgentManager-compatible response payload.", + group: "Agents", + }, + { + id: "list_terminals", + label: "List terminals", + description: "List terminals for a working directory or across all working directories.", + group: "Terminals", + }, + { + id: "create_terminal", + label: "Create terminal", + description: "Create a terminal session for a working directory.", + group: "Terminals", + }, + { + id: "kill_terminal", + label: "Kill terminal", + description: "Kill an existing terminal session.", + group: "Terminals", + }, + { + id: "capture_terminal", + label: "Capture terminal", + description: "Capture plain-text terminal output lines from a terminal session.", + group: "Terminals", + }, + { + id: "send_terminal_keys", + label: "Send terminal keys", + description: "Send literal text or special key tokens to a terminal session.", + group: "Terminals", + }, + { + id: "create_schedule", + label: "Create schedule", + description: "Create a recurring schedule that starts a new agent on a cron cadence.", + group: "Schedules", + }, + { + id: "create_heartbeat", + label: "Create heartbeat", + description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.", + group: "Schedules", + }, + { + id: "delete_heartbeat", + label: "Delete heartbeat", + description: "Delete one of your heartbeats.", + group: "Schedules", + }, + { + id: "list_schedules", + label: "List schedules", + description: "List all schedules managed by the daemon.", + group: "Schedules", + }, + { + id: "inspect_schedule", + label: "Inspect schedule", + description: "Inspect a schedule and its run history.", + group: "Schedules", + }, + { + id: "pause_schedule", + label: "Pause schedule", + description: "Pause an active schedule.", + group: "Schedules", + }, + { + id: "resume_schedule", + label: "Resume schedule", + description: "Resume a paused schedule.", + group: "Schedules", + }, + { + id: "delete_schedule", + label: "Delete schedule", + description: "Delete a schedule permanently.", + group: "Schedules", + }, + { + id: "update_schedule", + label: "Update schedule", + description: + "Update an existing schedule. Only provided fields are changed; omitted fields remain unchanged.", + group: "Schedules", + }, + { + id: "schedule_logs", + label: "Schedule logs", + description: "Get the run history (logs) for a schedule.", + group: "Schedules", + }, + { + id: "run_schedule_once", + label: "Run schedule once", + description: "Run a schedule immediately without changing its cron cadence.", + group: "Schedules", + }, + { + id: "list_providers", + label: "List providers", + description: "List configured agent providers, availability, and their modes.", + group: "Providers", + }, + { + id: "list_models", + label: "List models", + description: "List models for an agent provider.", + group: "Providers", + }, + { + id: "inspect_provider", + label: "Inspect provider", + description: + "Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.", + group: "Providers", + }, + { + id: "browser_list_tabs", + label: "List browser tabs", + description: + "List open Paseo browser tabs for this agent's workspace across connected browser automation hosts. Use returned browserId values with tab-scoped tools.", + group: "Browser", + browser: true, + }, + { + id: "browser_new_tab", + label: "Create browser tab", + description: + "Create a new Paseo browser tab in this agent's workspace on the most recently connected browser automation host, opened in the background without switching the user's view. Pass an http(s) URL or a scheme-less host URL, which is treated as http; the returned browserId is used by tab-scoped tools.", + group: "Browser", + browser: true, + }, + { + id: "browser_snapshot", + label: "Snapshot browser page", + description: + "Return a model-readable snapshot of a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_click", + label: "Click browser element", + description: + "Click an element in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_fill", + label: "Fill browser element", + description: + "Fill an input-like element in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_wait", + label: "Wait for browser condition", + description: + "Wait until a Paseo browser tab contains text or reaches a URL fragment. Use browserId from browser_new_tab or browser_list_tabs; waits up to 5s by default on the browser host.", + group: "Browser", + browser: true, + }, + { + id: "browser_type", + label: "Type into browser", + description: + "Type text into an element, or into the focused element when ref is omitted. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_keypress", + label: "Press browser key", + description: + "Dispatch a keypress to an element, or to the focused element when ref is omitted. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_navigate", + label: "Navigate browser", + description: + "Navigate a Paseo browser tab to a URL. Use browserId from browser_new_tab or browser_list_tabs; pass an http(s) URL or a scheme-less host URL, which is treated as http.", + group: "Browser", + browser: true, + }, + { + id: "browser_back", + label: "Browser back", + description: + "Go back in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs.", + group: "Browser", + browser: true, + }, + { + id: "browser_forward", + label: "Browser forward", + description: + "Go forward in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs.", + group: "Browser", + browser: true, + }, + { + id: "browser_reload", + label: "Browser reload", + description: + "Reload a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs.", + group: "Browser", + browser: true, + }, + { + id: "browser_screenshot", + label: "Capture browser screenshot", + description: + "Capture a PNG screenshot of a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs. Set fullPage to true to capture the full page.", + group: "Browser", + browser: true, + }, + { + id: "browser_upload", + label: "Upload files in browser", + description: + "Set workspace files on a file input in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_hover", + label: "Hover browser element", + description: + "Hover an element in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_select", + label: "Select browser option", + description: + "Set a select element in a Paseo browser tab to a value. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_drag", + label: "Drag browser element", + description: + "Drag one element onto another in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; refs come from the latest browser_snapshot of the same tab and expire when the page changes.", + group: "Browser", + browser: true, + }, + { + id: "browser_logs", + label: "Read browser logs", + description: + "Read recent console messages and browser performance network entries for a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; maxEntries defaults to 50.", + group: "Browser", + browser: true, + }, + { + id: "browser_evaluate", + label: "Evaluate browser JavaScript", + description: + "Evaluate a JavaScript function in a Paseo browser tab. Use browserId from browser_new_tab or browser_list_tabs; when ref is provided, refs come from the latest browser_snapshot and the resolved element is passed as the first argument.", + group: "Browser", + browser: true, + }, + { + id: "browser_scroll", + label: "Scroll browser", + description: + "Scroll a Paseo browser tab by deltaX/deltaY CSS pixels. Use browserId from browser_new_tab or browser_list_tabs; optional ref comes from the latest browser_snapshot and centers the wheel input over that element.", + group: "Browser", + browser: true, + }, + { + id: "browser_resize", + label: "Resize browser viewport", + description: + "Resize a Paseo browser tab's resident webview viewport. Use browserId from browser_new_tab or browser_list_tabs.", + group: "Browser", + browser: true, + }, + { + id: "browser_close_tab", + label: "Close browser tab", + description: + "Close a Paseo browser tab, remove its resident webview, and unregister it from the browser automation host. Use browserId from browser_new_tab or browser_list_tabs.", + group: "Browser", + browser: true, + }, +] as const satisfies readonly PaseoToolManifestEntry[]; diff --git a/packages/protocol/src/paseo-tool-policy-config.test.ts b/packages/protocol/src/paseo-tool-policy-config.test.ts new file mode 100644 index 0000000000..d4a8966207 --- /dev/null +++ b/packages/protocol/src/paseo-tool-policy-config.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { MutableDaemonConfigPatchSchema, MutableDaemonConfigSchema } from "./messages.js"; +import { ProviderOverrideSchema, ProviderPaseoToolsPolicySchema } from "./provider-config.js"; + +describe("provider Paseo-tool policy", () => { + test("accepts arbitrary tool IDs and leaves an empty policy enabled by default", () => { + expect( + ProviderPaseoToolsPolicySchema.parse({ + disabledTools: ["future_tool", "browser_future_tool"], + }), + ).toEqual({ + disabledTools: ["future_tool", "browser_future_tool"], + }); + expect(ProviderPaseoToolsPolicySchema.parse({})).toEqual({}); + expect(ProviderOverrideSchema.parse({}).paseoTools).toBeUndefined(); + }); + + test("accepts paseoTools on persisted provider overrides", () => { + expect( + ProviderOverrideSchema.parse({ + extends: "claude", + paseoTools: { + enabled: false, + disabledTools: ["create_workspace"], + }, + }).paseoTools, + ).toEqual({ + enabled: false, + disabledTools: ["create_workspace"], + }); + }); + + test("accepts paseoTools when reading and patching mutable daemon providers", () => { + expect( + MutableDaemonConfigSchema.parse({ + mcp: { injectIntoAgents: true }, + providers: { + codex: { + paseoTools: { enabled: false, disabledTools: ["future_tool"] }, + }, + }, + }).providers.codex?.paseoTools, + ).toEqual({ + enabled: false, + disabledTools: ["future_tool"], + }); + + expect( + MutableDaemonConfigPatchSchema.parse({ + providers: { + codex: { + paseoTools: { disabledTools: ["browser_future_tool"] }, + }, + }, + }).providers?.codex?.paseoTools, + ).toEqual({ disabledTools: ["browser_future_tool"] }); + }); +}); diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index 2ab879c31f..ccf73f23fd 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -28,6 +28,11 @@ export const ProviderRuntimeSettingsSchema = z.object({ disallowedTools: z.array(z.string()).optional(), }); +export const ProviderPaseoToolsPolicySchema = z.object({ + enabled: z.boolean().optional(), + disabledTools: z.array(z.string()).optional(), +}); + const ProviderProfileThinkingOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -53,6 +58,7 @@ export const ProviderOverrideSchema = z.object({ models: z.array(ProviderProfileModelSchema).optional(), additionalModels: z.array(ProviderProfileModelSchema).optional(), disallowedTools: z.array(z.string()).optional(), + paseoTools: ProviderPaseoToolsPolicySchema.optional(), enabled: z.boolean().optional(), order: z.number().optional(), }); @@ -127,6 +133,7 @@ export const AgentProviderRuntimeSettingsMapSchema = z export type ProviderCommand = z.infer; export type ProviderRuntimeSettings = z.infer; +export type ProviderPaseoToolsPolicy = z.infer; export type ProviderProfileModel = z.infer; export type ProviderOverride = z.infer; export type ProviderOverrides = z.infer; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 821828f4ab..9af1359752 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2233,6 +2233,165 @@ test("createAgent allows best-effort internal MCP when the provider session repo rmSync(workdir, { recursive: true, force: true }); }); +test("uses each provider's current policy for new sessions and snapshots it by agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const policies = new Map([ + ["codex", { disabledTools: ["list_agents"] }], + ["claude", { enabled: false }], + ]); + + class CaptureClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + supportsNativePaseoTools: true, + }; + readonly launchContexts: AgentLaunchContext[] = []; + readonly configs: AgentSessionConfig[] = []; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.configs.push(config); + if (launchContext) this.launchContexts.push(launchContext); + return new TestAgentSession(config); + } + } + + const codex = new CaptureClient("codex"); + const claude = new CaptureClient("claude"); + const policyInputs: Array<{ callerAgentId?: string; paseoToolPolicy?: unknown }> = []; + const paseoTools: PaseoToolCatalog = { + tools: new Map(), + getTool: () => undefined, + executeTool: async () => { + throw new Error("No tools registered in test catalog"); + }, + }; + const manager = new AgentManager({ + clients: { codex, claude }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + resolvePaseoToolPolicy: (provider) => policies.get(provider), + paseoToolCatalogFactory: async (context) => { + policyInputs.push(context); + return paseoTools; + }, + }); + + const codexAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000107", + { workspaceId: undefined }, + ); + const claudeAgent = await manager.createAgent( + { provider: "claude", cwd: workdir }, + "00000000-0000-4000-8000-000000000108", + { workspaceId: undefined }, + ); + + expect(policyInputs).toEqual([ + { callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } }, + ]); + expect(codex.launchContexts[0]?.paseoTools).toBe(paseoTools); + expect(claude.launchContexts[0]?.paseoTools).toBeUndefined(); + expect(codex.configs[0]?.mcpServers?.paseo).toBeUndefined(); + expect(claude.configs[0]?.mcpServers).toBeUndefined(); + expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({ + disabledTools: ["list_agents"], + }); + expect(manager.getPaseoToolPolicy(claudeAgent.id)).toEqual({ enabled: false }); + + policies.set("codex", { disabledTools: ["create_agent"] }); + const nextCodexAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000111", + { workspaceId: undefined }, + ); + + expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({ + disabledTools: ["list_agents"], + }); + expect(manager.getPaseoToolPolicy(nextCodexAgent.id)).toEqual({ + disabledTools: ["create_agent"], + }); + expect(policyInputs).toEqual([ + { callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } }, + { + callerAgentId: nextCodexAgent.id, + paseoToolPolicy: { disabledTools: ["create_agent"] }, + }, + ]); + + await manager.archiveAgent(claudeAgent.id); + expect(manager.getPaseoToolPolicy(claudeAgent.id)).toBeUndefined(); + + rmSync(workdir, { recursive: true, force: true }); +}); + +test("keeps the global Paseo-tools gate outside provider policy and MCP injection", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + + class McpClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + }; + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const enabledClient = new McpClient(); + const enabledManager = new AgentManager({ + clients: { codex: enabledClient }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + resolvePaseoToolPolicy: () => ({ disabledTools: ["list_agents"] }), + }); + const enabledAgent = await enabledManager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000109", + { workspaceId: undefined }, + ); + + expect(enabledClient.lastConfig?.mcpServers?.paseo).toEqual({ + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${enabledAgent.id}`, + }); + + const disabledClient = new McpClient(); + const catalogFactory = vi.fn(); + const disabledManager = new AgentManager({ + clients: { codex: disabledClient }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + paseoToolsEnabled: false, + resolvePaseoToolPolicy: () => ({ enabled: true }), + paseoToolCatalogFactory: catalogFactory, + }); + const disabledAgent = await disabledManager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000110", + { workspaceId: undefined }, + ); + + expect(disabledClient.lastConfig?.mcpServers).toBeUndefined(); + expect(catalogFactory).not.toHaveBeenCalled(); + expect(disabledManager.getPaseoToolPolicy(disabledAgent.id)).toEqual({ enabled: false }); + + rmSync(workdir, { recursive: true, force: true }); +}); + test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f3db4f385e..94600c4f14 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -70,6 +70,8 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; +import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; import { ProviderSubagentStore, type ProviderSubagentDescriptor, @@ -126,6 +128,7 @@ export type AgentRunCancellationResult = interface PreparedSessionConfig { storedConfig: AgentSessionConfig; launchConfig: AgentSessionConfig; + paseoToolPolicy: ProviderPaseoToolsPolicy | undefined; } interface NormalizeConfigOptions { @@ -275,6 +278,7 @@ export interface AgentManagerOptions { mcpAuthToken?: string; paseoToolsEnabled?: boolean; paseoToolCatalogFactory?: PaseoToolCatalogFactory; + resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -660,6 +664,8 @@ export class AgentManager { private readonly mcpAuthToken: string | null; private paseoToolsEnabled = true; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; + private readonly paseoToolPolicies = new Map(); + private resolvePaseoToolPolicy: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -677,6 +683,7 @@ export class AgentManager { this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.mcpAuthToken = options?.mcpAuthToken ?? null; this.configurePaseoTools(options); + this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -757,6 +764,10 @@ export class AgentManager { this.paseoToolCatalogFactory = factory; } + getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { + return this.paseoToolPolicies.get(agentId); + } + /** * Capability token the daemon's own MCP clients must present to the Agent MCP * endpoint when a daemon password is configured. Read by the per-client @@ -1110,7 +1121,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); await this.deleteAgentState(resolvedAgentId); - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( config, resolvedAgentId, options?.env, @@ -1119,10 +1130,12 @@ export class AgentManager { const client = await this.requireAvailableClient({ provider: storedConfig.provider, }); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); const launchContext = await this.buildLaunchContext( resolvedAgentId, client, storedConfig.cwd, + paseoToolPolicy, options?.env, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); @@ -1191,7 +1204,7 @@ export class AgentManager { ...overrides, provider: handle.provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, ); @@ -1203,7 +1216,13 @@ export class AgentManager { `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, ); } - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + paseoToolPolicy, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( handle, @@ -1244,14 +1263,20 @@ export class AgentManager { throw new Error(`Provider '${input.provider}' does not support importing sessions`); } - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( { provider: input.provider, cwd: input.cwd, }, resolvedAgentId, ); - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + paseoToolPolicy, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { @@ -1331,8 +1356,17 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); - const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd); + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( + refreshConfig, + agentId, + ); + this.paseoToolPolicies.set(agentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + agentId, + client, + storedConfig.cwd, + paseoToolPolicy, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = handle @@ -3166,6 +3200,7 @@ export class AgentManager { private discardRetainedAgentState(agentId: string): void { this.timelineStore.delete(agentId); + this.paseoToolPolicies.delete(agentId); for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } @@ -4543,15 +4578,21 @@ export class AgentManager { env?: Record, ): Promise { const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); + const paseoToolPolicy = this.paseoToolsEnabled + ? this.resolvePaseoToolPolicy(storedConfig.provider) + : { enabled: false }; const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, agentId, - mcpBaseUrl: this.mcpBaseUrl, + mcpBaseUrl: + this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy) + ? this.mcpBaseUrl + : null, mcpAuthToken: this.mcpAuthToken, }), ); - return { storedConfig, launchConfig }; + return { storedConfig, launchConfig, paseoToolPolicy }; } private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { @@ -4571,6 +4612,7 @@ export class AgentManager { agentId: string, client: AgentClient, cwd: string, + paseoToolPolicy: ProviderPaseoToolsPolicy | undefined, env?: Record, ): Promise { const context: AgentLaunchContext = { @@ -4583,10 +4625,14 @@ export class AgentManager { }; if ( this.paseoToolsEnabled && + isPaseoToolPolicyEnabled(paseoToolPolicy) && client.capabilities.supportsNativePaseoTools && this.paseoToolCatalogFactory ) { - context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId }); + context.paseoTools = await this.paseoToolCatalogFactory({ + callerAgentId: agentId, + paseoToolPolicy, + }); } return context; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 349eb15c53..c5b97ce978 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -971,8 +971,85 @@ describe("browser MCP tools", () => { logger, }); + const client = await connectInMemoryMcpClient(server); + try { + const listedTools = await client.listTools(); + const toolNames = listedTools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain("browser_list_tabs"); + expect(toolNames).not.toContain("browser_snapshot"); + expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "list_agents"])); + } finally { + await client.close(); + await server.close(); + } + }); + + it("applies provider policy after the browser-tools host gate", async () => { + const agentManager = new BoundaryAgentManagerFake(); + const agentStorage = new BoundaryAgentStorageFake(); + const broker = new FakeBrowserToolsBroker({ + requestId: "req-browser-policy", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }); + const server = await createAgentMcpServer({ + agentManager: agentManager as AgentManager, + agentStorage: agentStorage as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: broker as BrowserToolsBroker, + callerAgentId: "agent-1", + paseoToolPolicy: { disabledTools: ["browser_list_tabs"] }, + logger, + }); + expect(lookupTool(server, "browser_list_tabs")).toBeUndefined(); - expect(lookupTool(server, "browser_snapshot")).toBeUndefined(); + expect(lookupTool(server, "browser_snapshot")).toBeDefined(); + }); + + it("filters policy-disabled tools from MCP listing and calls", async () => { + const agentManager = new BoundaryAgentManagerFake(); + const agentStorage = new BoundaryAgentStorageFake(); + const broker = new FakeBrowserToolsBroker({ + requestId: "req-browser-policy-call", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }); + const server = await createAgentMcpServer({ + agentManager: agentManager as AgentManager, + agentStorage: agentStorage as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: broker as BrowserToolsBroker, + callerAgentId: "agent-1", + paseoToolPolicy: { disabledTools: ["list_agents", "browser_list_tabs"] }, + logger, + }); + const client = await connectInMemoryMcpClient(server); + + try { + const listedTools = await client.listTools(); + const toolNames = listedTools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain("list_agents"); + expect(toolNames).not.toContain("browser_list_tabs"); + expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "browser_snapshot"])); + await expect(client.callTool({ name: "list_agents", arguments: {} })).resolves.toEqual({ + content: [{ type: "text", text: "MCP error -32602: Tool list_agents not found" }], + isError: true, + }); + await expect(client.callTool({ name: "browser_list_tabs", arguments: {} })).resolves.toEqual({ + content: [{ type: "text", text: "MCP error -32602: Tool browser_list_tabs not found" }], + isError: true, + }); + expect(broker.calls).toEqual([]); + } finally { + await client.close(); + await server.close(); + } }); it("wires browser tools through the browser tools broker", async () => { diff --git a/packages/server/src/server/agent/paseo-tool-policy.test.ts b/packages/server/src/server/agent/paseo-tool-policy.test.ts new file mode 100644 index 0000000000..d5bc6c648b --- /dev/null +++ b/packages/server/src/server/agent/paseo-tool-policy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; + +import { isPaseoToolEnabled, resolvePaseoToolPolicy } from "./paseo-tool-policy.js"; + +describe("Paseo tool policy", () => { + test("defaults to all Paseo tools and resolves only the exact provider ID", () => { + const customPolicy = { + enabled: true, + disabledTools: ["list_agents"], + } satisfies ProviderPaseoToolsPolicy; + + expect( + resolvePaseoToolPolicy("custom-claude", { + claude: { paseoTools: { enabled: false } }, + "custom-claude": { paseoTools: customPolicy }, + }), + ).toBe(customPolicy); + expect(resolvePaseoToolPolicy("other-custom", { claude: { paseoTools: customPolicy } })).toBe( + undefined, + ); + expect(isPaseoToolEnabled(undefined, "list_agents")).toBe(true); + }); + + test("applies the provider gate and sparse disabled tools without filtering speak", () => { + expect(isPaseoToolEnabled({ enabled: false }, "list_agents")).toBe(false); + expect(isPaseoToolEnabled({ enabled: false }, "speak")).toBe(true); + expect( + isPaseoToolEnabled({ enabled: true, disabledTools: ["list_agents"] }, "list_agents"), + ).toBe(false); + expect( + isPaseoToolEnabled({ enabled: true, disabledTools: ["list_agents"] }, "create_agent"), + ).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/paseo-tool-policy.ts b/packages/server/src/server/agent/paseo-tool-policy.ts new file mode 100644 index 0000000000..b6eae451c2 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tool-policy.ts @@ -0,0 +1,37 @@ +import { PASEO_TOOL_MANIFEST } from "@getpaseo/protocol/paseo-tool-manifest"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; + +interface ProviderPaseoToolSettings { + paseoTools?: ProviderPaseoToolsPolicy; +} + +export const PASEO_TOOL_NAMES: ReadonlySet = new Set( + PASEO_TOOL_MANIFEST.map((tool) => tool.id), +); + +export function resolvePaseoToolPolicy( + providerId: string, + providerSettings: Readonly> | undefined, +): ProviderPaseoToolsPolicy | undefined { + return providerSettings?.[providerId]?.paseoTools; +} + +export function isPaseoToolEnabled( + policy: ProviderPaseoToolsPolicy | undefined, + toolName: string, +): boolean { + if (toolName === "speak") { + return true; + } + if (!isPaseoToolPolicyEnabled(policy)) { + return false; + } + if (PASEO_TOOL_NAMES.size > 0 && !PASEO_TOOL_NAMES.has(toolName)) { + return true; + } + return !policy?.disabledTools?.includes(toolName); +} + +export function isPaseoToolPolicyEnabled(policy: ProviderPaseoToolsPolicy | undefined): boolean { + return policy?.enabled !== false; +} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index fcc314ac11..d9b864034e 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -92,6 +92,8 @@ import type { PaseoToolExecutionContext, PaseoToolResult, } from "./types.js"; +import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; export interface PaseoToolHostDependencies { agentManager: AgentManager; @@ -128,6 +130,7 @@ export interface PaseoToolHostDependencies { ) => Promise; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; + paseoToolPolicy?: ProviderPaseoToolsPolicy; paseoHome?: string; worktreesRoot?: string; /** @@ -576,6 +579,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. handler: (input: any, context: PaseoToolExecutionContext) => Promise, ) => { + if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { + return; + } tools.set(name, { name, title: config.title, diff --git a/packages/server/src/server/agent/tools/types.ts b/packages/server/src/server/agent/tools/types.ts index 1eb54dabfd..66a4a23da2 100644 --- a/packages/server/src/server/agent/tools/types.ts +++ b/packages/server/src/server/agent/tools/types.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; export interface PaseoToolExecutionContext { signal?: AbortSignal; @@ -36,6 +37,7 @@ export interface PaseoToolCatalog { export interface PaseoToolRuntimeContext { callerAgentId?: string; + paseoToolPolicy?: ProviderPaseoToolsPolicy; enableVoiceTools?: boolean; voiceOnly?: boolean; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b0449544dc..03058e1e83 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -147,6 +147,7 @@ import { import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; +import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -519,6 +520,9 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae const providers: MutableDaemonConfig["providers"] = Object.fromEntries( Object.entries(config.providerOverrides ?? {}).map(([providerId, override]) => { const providerConfig: MutableDaemonConfig["providers"][string] = {}; + if (override.paseoTools) { + providerConfig.paseoTools = override.paseoTools; + } if (override.enabled !== undefined) { providerConfig.enabled = override.enabled; } @@ -850,6 +854,8 @@ export async function createPaseoDaemon( workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, mcpAuthToken: agentMcpAuthToken, + resolvePaseoToolPolicy: (provider) => + resolvePaseoToolPolicy(provider, daemonConfigStore.get().providers), logger, }); @@ -1295,6 +1301,9 @@ export async function createPaseoDaemon( createPaseoWorktree: createAgentCommandDependencies.createPaseoWorktree, browserToolsEnabled: browserToolsPolicy.isEnabled(), browserToolsBroker, + paseoToolPolicy: + runtime.paseoToolPolicy ?? + (runtime.callerAgentId ? agentManager.getPaseoToolPolicy(runtime.callerAgentId) : undefined), paseoHome: config.paseoHome, worktreesRoot: config.worktreesRoot, callerAgentId: runtime.callerAgentId, @@ -1316,7 +1325,12 @@ export async function createPaseoDaemon( const createAgentMcpSession = async (callerAgentId?: string) => { const agentMcpServer = await createAgentMcpServer( - createAgentToolHostDependencies({ callerAgentId }), + createAgentToolHostDependencies({ + callerAgentId, + paseoToolPolicy: callerAgentId + ? agentManager.getPaseoToolPolicy(callerAgentId) + : undefined, + }), ); // Stateless mode: each HTTP request builds a fresh server + transport that is diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 1d9bcb1e8f..04bb670467 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -293,6 +293,37 @@ describe("DaemonConfigStore", () => { }); }); + test("patch persists provider Paseo-tool policy without changing availability", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: { claude: { enabled: false } }, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + + store.patch({ + providers: { + claude: { + paseoTools: { enabled: true, disabledTools: ["list_agents"] }, + }, + }, + }); + + expect(store.get().providers.claude).toEqual({ + enabled: false, + paseoTools: { enabled: true, disabledTools: ["list_agents"] }, + }); + expect(loadPersistedConfig(paseoHome).agents?.providers?.claude).toEqual({ + enabled: false, + paseoTools: { enabled: true, disabledTools: ["list_agents"] }, + }); + }); + test("patch removes provider entries from config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index b14e90c7a0..2aee2efe67 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -924,6 +924,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.providersSnapshotCwd).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); + expect(serverInfo.features?.paseoToolPolicies).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); await server.close(); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7b89e6501f..377bb0cb86 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1630,6 +1630,8 @@ export class VoiceAssistantWebSocketServer { commitBaseClassification: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, + // COMPAT(paseoToolPolicies): added in v0.2.6, remove gate after 2027-01-31. + paseoToolPolicies: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.