fix(agent): surface missing tool credentials

This commit is contained in:
Yansong Zhang 2026-09-03 15:26:30 +08:00
parent 0334f4426d
commit a54222e7fa
5 changed files with 139 additions and 17 deletions

View File

@ -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)

View File

@ -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"

View File

@ -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():

View File

@ -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(<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />, {
wrapper: createWrapper(),
})
const button = screen.getByRole('button')
expect(button.textContent).toContain('plugin.auth.workspaceDefault')
expect(button.textContent).toContain('plugin.auth.unavailable')
})
})

View File

@ -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
}