From a54222e7fa7987f5a0907deb0d7b904dbd2d63be Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Thu, 3 Sep 2026 15:26:30 +0800 Subject: [PATCH] fix(agent): surface missing tool credentials --- api/core/tools/tool_manager.py | 44 +++++++--- .../core/tools/test_tool_manager.py | 86 ++++++++++++++++++- .../nodes/agent_v2/test_dify_tools_builder.py | 1 + .../__tests__/authorized-in-node.spec.tsx | 18 ++++ .../plugin-auth/authorized-in-node.tsx | 7 +- 5 files changed, 139 insertions(+), 17 deletions(-) diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 0f33b104914..fc02ecaf259 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -42,7 +42,7 @@ from core.tools.entities.tool_entities import ( ToolProviderType, emoji_icon_adapter, ) -from core.tools.errors import ToolProviderNotFoundError +from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError from core.tools.mcp_tool.provider import MCPToolProviderController from core.tools.mcp_tool.tool import MCPTool from core.tools.plugin_tool.provider import PluginToolProviderController @@ -233,7 +233,10 @@ class ToolManager: builtin_provider = None logger.info("Error getting builtin provider %s:%s", credential_id, e, exc_info=True) if builtin_provider is None: - raise ToolProviderNotFoundError(f"provider has been deleted: {credential_id}") + raise ToolProviderCredentialValidationError( + f"Tool credential {credential_id} has been deleted. " + "Select or authorize another credential." + ) if builtin_provider is None: with Session(db.engine) as session: @@ -247,7 +250,10 @@ class ToolManager: .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) ) if builtin_provider is None: - raise ToolProviderNotFoundError(f"no default provider for {provider_id}") + raise ToolProviderCredentialValidationError( + f"No workspace credential is configured for tool provider {provider_id}. " + "Authorize the provider or select a credential." + ) else: builtin_provider = db.session.scalar( select(BuiltinToolProvider) @@ -259,7 +265,10 @@ class ToolManager: ) if builtin_provider is None: - raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") + raise ToolProviderCredentialValidationError( + f"No credential is configured for built-in tool provider {provider_id}. " + "Authorize the provider or select a credential." + ) from core.helper.credential_utils import runtime_check_credential_policy_compliance @@ -294,15 +303,24 @@ class ToolManager: system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id) oauth_handler = OAuthHandler() - refreshed_credentials = oauth_handler.refresh_credentials( - tenant_id=tenant_id, - user_id=builtin_provider.user_id, - plugin_id=tool_provider.plugin_id, - provider=provider_name, - redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, - credentials=decrypted_credentials, - ) + try: + refreshed_credentials = oauth_handler.refresh_credentials( + tenant_id=tenant_id, + user_id=builtin_provider.user_id, + plugin_id=tool_provider.plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=system_credentials or {}, + credentials=decrypted_credentials, + ) + except Exception as exc: + logger.warning( + "Failed to refresh OAuth credentials for tool provider %s", provider_id, exc_info=True + ) + raise ToolProviderCredentialValidationError( + f"OAuth credential for tool provider {provider_id} could not be refreshed. " + "Reauthorize or select another credential." + ) from exc # update the credentials builtin_provider.encrypted_credentials = json.dumps( encrypter.encrypt(refreshed_credentials.credentials) diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index a299fe465dc..eeac6f35365 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -25,7 +25,7 @@ from core.tools.entities.tool_entities import ( ToolParameter, ToolProviderType, ) -from core.tools.errors import ToolProviderNotFoundError +from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_manager import ToolManager from models.base import TypeBase @@ -399,7 +399,49 @@ def test_get_tool_runtime_builtin_refreshes_expired_oauth_credentials( cache.delete.assert_called_once() -def test_get_tool_runtime_builtin_plugin_provider_deleted_raises( +def test_get_tool_runtime_builtin_maps_oauth_refresh_failure_to_credential_error( + monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase +): + tool = Mock() + controller = SimpleNamespace( + get_tool=Mock(return_value=tool), + need_credentials=True, + get_credentials_schema_by_type=Mock(return_value=[]), + ) + tenant_id = "00000000-0000-0000-0000-000000000001" + builtin_provider = _builtin_provider( + provider_id="00000000-0000-0000-0000-000000000002", + tenant_id=tenant_id, + credential_type=CredentialType.OAUTH2, + expires_at=1, + ) + tool_database.session.add(builtin_provider) + tool_database.session.commit() + monkeypatch.setattr("core.tools.tool_manager.db", tool_database) + + encrypter = Mock() + encrypter.decrypt.return_value = {"token": "expired"} + with ( + patch.object(ToolManager, "get_builtin_provider", return_value=controller), + patch("core.tools.tool_manager.create_provider_encrypter", return_value=(encrypter, Mock())), + patch("core.tools.tool_manager.time.time", return_value=1000), + patch( + "services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client", + return_value={"client_id": "id"}, + ), + patch("core.plugin.impl.oauth.OAuthHandler") as oauth_handler_cls, + ): + oauth_handler_cls.return_value.refresh_credentials.side_effect = ValueError("refresh token revoked") + with pytest.raises(ToolProviderCredentialValidationError, match="could not be refreshed"): + ToolManager.get_tool_runtime( + provider_type=ToolProviderType.BUILT_IN, + provider_id="time", + tool_name="weekday", + tenant_id=tenant_id, + ) + + +def test_get_tool_runtime_builtin_plugin_credential_deleted_raises( monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase ): plugin_controller = object.__new__(PluginToolProviderController) @@ -409,7 +451,7 @@ def test_get_tool_runtime_builtin_plugin_provider_deleted_raises( monkeypatch.setattr("core.tools.tool_manager.db", tool_database) with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_controller): - with pytest.raises(ToolProviderNotFoundError, match="provider has been deleted"): + with pytest.raises(ToolProviderCredentialValidationError, match="credential .* has been deleted"): ToolManager.get_tool_runtime( provider_type=ToolProviderType.BUILT_IN, provider_id="time", @@ -419,6 +461,44 @@ def test_get_tool_runtime_builtin_plugin_provider_deleted_raises( ) +def test_get_tool_runtime_builtin_plugin_without_workspace_credential_raises( + monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase +): + plugin_controller = object.__new__(PluginToolProviderController) + plugin_controller.entity = SimpleNamespace(credentials_schema=[{"name": "k"}], oauth_schema=None) + plugin_controller.get_tool = Mock(return_value=Mock()) + plugin_controller.get_credentials_schema_by_type = Mock(return_value=[]) + + monkeypatch.setattr("core.tools.tool_manager.db", tool_database) + with patch.object(ToolManager, "get_builtin_provider", return_value=plugin_controller): + with pytest.raises(ToolProviderCredentialValidationError, match="No workspace credential is configured"): + ToolManager.get_tool_runtime( + provider_type=ToolProviderType.BUILT_IN, + provider_id="langgenius/dify-gmail/dify-gmail", + tool_name="send_draft", + tenant_id="00000000-0000-0000-0000-000000000001", + ) + + +def test_get_tool_runtime_hardcoded_provider_without_credential_raises( + monkeypatch: pytest.MonkeyPatch, tool_database: _ToolDatabase +): + controller = SimpleNamespace( + get_tool=Mock(return_value=Mock()), + need_credentials=True, + ) + + monkeypatch.setattr("core.tools.tool_manager.db", tool_database) + with patch.object(ToolManager, "get_builtin_provider", return_value=controller): + with pytest.raises(ToolProviderCredentialValidationError, match="No credential is configured"): + ToolManager.get_tool_runtime( + provider_type=ToolProviderType.BUILT_IN, + provider_id="legacy-provider", + tool_name="legacy-tool", + tenant_id="00000000-0000-0000-0000-000000000001", + ) + + def test_get_tool_runtime_api_path(): api_tool = Mock() api_tool.fork_tool_runtime.return_value = "api-runtime" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py index 46ef8fdeb01..bca7bed6eaa 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py @@ -839,6 +839,7 @@ def test_credential_validation_error_maps_to_credential_invalid(): with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_credential_invalid" + assert "credential validation failed" in str(exc_info.value) def test_generic_value_error_maps_to_config_invalid(): diff --git a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx index 153e669b95e..b0c8297af63 100644 --- a/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx +++ b/web/app/components/plugins/plugin-auth/__tests__/authorized-in-node.spec.tsx @@ -243,4 +243,22 @@ describe('AuthorizedInNode Component', () => { const button = screen.getByRole('button') expect(button.textContent).toContain('plugin.auth.unavailable') }) + + it('should show unavailable when workspace default credential is missing', async () => { + const AuthorizedInNode = (await import('../authorized-in-node')).default + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + const pluginPayload = createPluginPayload() + + render(, { + wrapper: createWrapper(), + }) + + const button = screen.getByRole('button') + expect(button.textContent).toContain('plugin.auth.workspaceDefault') + expect(button.textContent).toContain('plugin.auth.unavailable') + }) }) diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx index 782f920d767..7f0f084ab29 100644 --- a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -48,7 +48,12 @@ const AuthorizedInNode = ({ const defaultCredential = credentials.find((c) => c.is_default) - if (defaultCredential?.not_allowed_to_use) { + if (isLoading) { + color = 'disabled' + } else if (!defaultCredential) { + color = 'error' + defaultUnavailable = true + } else if (defaultCredential.not_allowed_to_use) { color = 'disabled' defaultUnavailable = true }