From 0d27c14008f27c2b9313de0bbb9956d23e353c85 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 00:48:43 +0200 Subject: [PATCH 1/8] feat(web): consume managed write-only secret contracts --- clients/python/agenta_client/__init__.py | 45 +- .../python/agenta_client/secrets/client.py | 415 +++++++--- .../agenta_client/secrets/raw_client.py | 777 +++++++++++++----- .../python/agenta_client/types/__init__.py | 45 +- .../agenta_client/types/credential_result.py | 23 + .../agenta_client/types/credential_status.py | 7 + .../types/custom_provider_dto.py | 8 +- .../types/custom_secret_settings_dto.py | 9 +- .../agenta_client/types/discovery_result.py | 23 + .../agenta_client/types/discovery_status.py | 7 + .../types/probe_provider_response.py | 26 + .../types/provider_credentials.py | 32 + .../types/public_secret_management_dto.py | 22 + ...e_dto.py => public_secret_response_dto.py} | 24 +- .../types/public_secret_response_dto_data.py | 17 + .../python/agenta_client/types/secret_dto.py | 11 +- .../types/secret_management_policy.py | 5 + .../types/secret_value_status.py | 22 + .../types/sso_provider_settings_dto.py | 9 +- .../types/standard_provider_dto.py | 10 +- .../types/standard_provider_settings_dto.py | 9 +- .../types/update_secret_payload_dto.py | 28 + ...a.py => update_secret_payload_dto_data.py} | 8 +- .../types/webhook_provider_settings_dto.py | 9 +- docs/design/write-only-secrets/context.md | 30 + .../implementation-report.md | 126 +++ docs/design/write-only-secrets/plan.md | 62 ++ docs/design/write-only-secrets/qa.md | 116 +++ docs/design/write-only-secrets/research.md | 32 + docs/design/write-only-secrets/review.md | 430 ++++++++++ docs/design/write-only-secrets/status.md | 31 + .../components/AgentMessage.runError.test.tsx | 42 + .../components/AgentMessage.tsx | 46 +- .../components/ConnectModelBanner.tsx | 14 + .../hooks/useChatSlashCommands.tsx | 23 +- .../hooks/useOnboardingProviderSetup.ts | 4 +- .../Vault/ConfigureSecretModal/index.tsx | 30 +- .../api/resources/secrets/client/Client.ts | 97 ++- .../client/requests/CreateSecretDto.ts | 5 +- .../client/requests/ProbeProviderRequest.ts | 15 + .../client/requests/UpdateSecretDto.ts | 2 +- .../secrets/client/requests/index.ts | 1 + .../generated/api/types/CredentialResult.ts | 8 + .../generated/api/types/CredentialStatus.ts | 15 + .../generated/api/types/CustomProviderDto.ts | 1 + .../api/types/CustomSecretSettingsDto.ts | 2 +- .../generated/api/types/DiscoveryResult.ts | 8 + .../generated/api/types/DiscoveryStatus.ts | 15 + .../api/types/ProbeProviderResponse.ts | 9 + .../api/types/ProviderCredentials.ts | 15 + .../api/types/PublicSecretManagementDto.ts | 7 + ...ponseDto.ts => PublicSecretResponseDto.ts} | 14 +- .../src/generated/api/types/SecretDto.ts | 3 + .../api/types/SecretManagementPolicy.ts | 6 + .../generated/api/types/SecretValueStatus.ts | 6 + .../api/types/SsoProviderSettingsDto.ts | 2 +- .../api/types/StandardProviderDto.ts | 2 + .../api/types/StandardProviderSettingsDto.ts | 2 +- .../api/types/UpdateSecretPayloadDto.ts | 20 + .../api/types/WebhookProviderSettingsDto.ts | 2 +- .../src/generated/api/types/index.ts | 12 +- web/packages/agenta-chat/src/assets/trace.ts | 18 + .../src/assets/transcriptToMessages.ts | 15 +- .../src/hooks/useAgentModelKeyStatus.ts | 3 +- .../agenta-chat/src/model/turnStatus.ts | 19 +- .../agenta-chat/src/model/turnViewModel.ts | 3 +- .../tests/unit/assets/trace.test.ts | 58 +- .../unit/assets/transcriptToMessages.test.ts | 21 + .../tests/unit/model/turnStatus.test.ts | 39 + .../agenta-entities/src/secret/api/probe.ts | 62 +- .../src/secret/core/connections.ts | 153 +++- .../agenta-entities/src/secret/core/index.ts | 5 + .../src/secret/core/transforms.ts | 63 +- .../agenta-entities/src/secret/core/types.ts | 43 +- .../agenta-entities/src/secret/index.ts | 5 + .../agenta-entities/src/secret/state/atoms.ts | 5 + .../src/secret/state/connections.ts | 5 +- .../src/secret/state/persistence.ts | 13 +- .../src/workflow/state/agentCreationPrefs.ts | 78 ++ .../src/workflow/state/appUtils.ts | 44 +- .../tests/unit/agent-creation-prefs.test.ts | 85 ++ .../tests/unit/provider-connections.test.ts | 278 ++++++- .../tests/unit/secret-transforms.test.ts | 65 ++ .../SchemaControls/AgentTemplateControl.tsx | 5 +- .../ProviderCredentialsSectionView.tsx | 5 +- .../agentTemplate/ProviderKeyField.tsx | 20 +- .../agentTemplate/useModelHarness.tsx | 10 +- .../SchemaControls/connectionPicker.ts | 17 +- .../SchemaControls/connectionUtils.ts | 77 +- .../src/DrillInView/SchemaControls/index.ts | 2 + .../agenta-entity-ui/src/DrillInView/index.ts | 2 + .../secretProvider/ProviderConnectionCard.tsx | 45 +- .../src/secretProvider/ProviderDrawer.tsx | 29 +- .../tests/unit/connectionPicker.test.ts | 34 +- .../tests/unit/connectionUtils.test.ts | 143 +++- .../src/providers/AIProvidersPage.tsx | 13 +- web/packages/agenta-shared/src/state/index.ts | 1 + .../src/state/openProviderDrawer.ts | 8 + .../agenta-shared/src/types/llmProvider.ts | 8 + .../agenta-ui/src/LLMIcons/assets/Agenta.tsx | 29 + web/packages/agenta-ui/src/LLMIcons/index.ts | 3 + .../agenta-ui/src/SelectLLMProvider/utils.ts | 3 + 102 files changed, 3800 insertions(+), 580 deletions(-) create mode 100644 clients/python/agenta_client/types/credential_result.py create mode 100644 clients/python/agenta_client/types/credential_status.py create mode 100644 clients/python/agenta_client/types/discovery_result.py create mode 100644 clients/python/agenta_client/types/discovery_status.py create mode 100644 clients/python/agenta_client/types/probe_provider_response.py create mode 100644 clients/python/agenta_client/types/provider_credentials.py create mode 100644 clients/python/agenta_client/types/public_secret_management_dto.py rename clients/python/agenta_client/types/{secret_response_dto.py => public_secret_response_dto.py} (52%) create mode 100644 clients/python/agenta_client/types/public_secret_response_dto_data.py create mode 100644 clients/python/agenta_client/types/secret_management_policy.py create mode 100644 clients/python/agenta_client/types/secret_value_status.py create mode 100644 clients/python/agenta_client/types/update_secret_payload_dto.py rename clients/python/agenta_client/types/{secret_response_dto_data.py => update_secret_payload_dto_data.py} (68%) create mode 100644 docs/design/write-only-secrets/context.md create mode 100644 docs/design/write-only-secrets/implementation-report.md create mode 100644 docs/design/write-only-secrets/plan.md create mode 100644 docs/design/write-only-secrets/qa.md create mode 100644 docs/design/write-only-secrets/research.md create mode 100644 docs/design/write-only-secrets/review.md create mode 100644 docs/design/write-only-secrets/status.md create mode 100644 web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx create mode 100644 web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/ProbeProviderRequest.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/CredentialResult.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/CredentialStatus.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/DiscoveryResult.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/DiscoveryStatus.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/ProbeProviderResponse.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/ProviderCredentials.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/PublicSecretManagementDto.ts rename web/packages/agenta-api-client/src/generated/api/types/{SecretResponseDto.ts => PublicSecretResponseDto.ts} (59%) create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SecretManagementPolicy.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SecretValueStatus.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/UpdateSecretPayloadDto.ts create mode 100644 web/packages/agenta-shared/src/state/openProviderDrawer.ts create mode 100644 web/packages/agenta-ui/src/LLMIcons/assets/Agenta.tsx diff --git a/clients/python/agenta_client/__init__.py b/clients/python/agenta_client/__init__.py index b266e7204c..b590c181d5 100644 --- a/clients/python/agenta_client/__init__.py +++ b/clients/python/agenta_client/__init__.py @@ -136,6 +136,8 @@ ConfigResponseModel, ConnectAffordance, ConnectionRequirement, + CredentialResult, + CredentialStatus, CustomModelSettingsDto, CustomProviderDto, CustomProviderKind, @@ -154,6 +156,8 @@ DiscoveredTriggerAlternative, DiscoveredTriggerEvent, DiscoveredTriggerEventType, + DiscoveryResult, + DiscoveryStatus, EntityRef, Environment, EnvironmentCreate, @@ -374,8 +378,13 @@ OrganizationUpdate, Permission, PlaygroundBuildKitContext, + ProbeProviderResponse, ProjectsResponse, + ProviderCredentials, PublicMountCreate, + PublicSecretManagementDto, + PublicSecretResponseDto, + PublicSecretResponseDtoData, QueriesResponse, Query, QueryCreate, @@ -410,8 +419,8 @@ SecretDto, SecretDtoData, SecretKind, - SecretResponseDto, - SecretResponseDtoData, + SecretManagementPolicy, + SecretValueStatus, Selector, SessionAttachment, SessionAttachmentResponse, @@ -723,6 +732,8 @@ TriggerSubscriptionQuery, TriggerSubscriptionResponse, TriggerSubscriptionsResponse, + UpdateSecretPayloadDto, + UpdateSecretPayloadDtoData, UserIdsResponse, ValidationError, ValidationErrorLocItem, @@ -1002,6 +1013,8 @@ "ConfigResponseModel": ".types", "ConnectAffordance": ".types", "ConnectionRequirement": ".types", + "CredentialResult": ".types", + "CredentialStatus": ".types", "CreateSimpleTestsetFromFileRequestFileType": ".testsets", "CreateTestsetRevisionFromFileRequestFileType": ".testsets", "CustomModelSettingsDto": ".types", @@ -1022,6 +1035,8 @@ "DiscoveredTriggerAlternative": ".types", "DiscoveredTriggerEvent": ".types", "DiscoveredTriggerEventType": ".types", + "DiscoveryResult": ".types", + "DiscoveryStatus": ".types", "EditSimpleTestsetFromFileRequestFileType": ".testsets", "EntityRef": ".types", "Environment": ".types", @@ -1248,8 +1263,13 @@ "OrganizationUpdate": ".types", "Permission": ".types", "PlaygroundBuildKitContext": ".types", + "ProbeProviderResponse": ".types", "ProjectsResponse": ".types", + "ProviderCredentials": ".types", "PublicMountCreate": ".types", + "PublicSecretManagementDto": ".types", + "PublicSecretResponseDto": ".types", + "PublicSecretResponseDtoData": ".types", "QueriesResponse": ".types", "Query": ".types", "QueryApplicationVariantsRequestOrder": ".applications", @@ -1295,8 +1315,8 @@ "SecretDto": ".types", "SecretDtoData": ".types", "SecretKind": ".types", - "SecretResponseDto": ".types", - "SecretResponseDtoData": ".types", + "SecretManagementPolicy": ".types", + "SecretValueStatus": ".types", "Selector": ".types", "SessionAttachment": ".types", "SessionAttachmentResponse": ".types", @@ -1611,6 +1631,8 @@ "TriggerSubscriptionQuery": ".types", "TriggerSubscriptionResponse": ".types", "TriggerSubscriptionsResponse": ".types", + "UpdateSecretPayloadDto": ".types", + "UpdateSecretPayloadDtoData": ".types", "UnprocessableEntityError": ".errors", "UserIdsResponse": ".types", "ValidationError": ".types", @@ -1881,6 +1903,8 @@ def __dir__(): "ConfigResponseModel", "ConnectAffordance", "ConnectionRequirement", + "CredentialResult", + "CredentialStatus", "CreateSimpleTestsetFromFileRequestFileType", "CreateTestsetRevisionFromFileRequestFileType", "CustomModelSettingsDto", @@ -1901,6 +1925,8 @@ def __dir__(): "DiscoveredTriggerAlternative", "DiscoveredTriggerEvent", "DiscoveredTriggerEventType", + "DiscoveryResult", + "DiscoveryStatus", "EditSimpleTestsetFromFileRequestFileType", "EntityRef", "Environment", @@ -2127,8 +2153,13 @@ def __dir__(): "OrganizationUpdate", "Permission", "PlaygroundBuildKitContext", + "ProbeProviderResponse", "ProjectsResponse", + "ProviderCredentials", "PublicMountCreate", + "PublicSecretManagementDto", + "PublicSecretResponseDto", + "PublicSecretResponseDtoData", "QueriesResponse", "Query", "QueryApplicationVariantsRequestOrder", @@ -2174,8 +2205,8 @@ def __dir__(): "SecretDto", "SecretDtoData", "SecretKind", - "SecretResponseDto", - "SecretResponseDtoData", + "SecretManagementPolicy", + "SecretValueStatus", "Selector", "SessionAttachment", "SessionAttachmentResponse", @@ -2490,6 +2521,8 @@ def __dir__(): "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", + "UpdateSecretPayloadDto", + "UpdateSecretPayloadDtoData", "UnprocessableEntityError", "UserIdsResponse", "ValidationError", diff --git a/clients/python/agenta_client/secrets/client.py b/clients/python/agenta_client/secrets/client.py index f264a4d692..86e8c987ba 100644 --- a/clients/python/agenta_client/secrets/client.py +++ b/clients/python/agenta_client/secrets/client.py @@ -5,43 +5,50 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions from ..types.header import Header +from ..types.probe_provider_response import ProbeProviderResponse +from ..types.provider_credentials import ProviderCredentials +from ..types.public_secret_response_dto import PublicSecretResponseDto from ..types.secret_dto import SecretDto -from ..types.secret_response_dto import SecretResponseDto +from ..types.update_secret_payload_dto import UpdateSecretPayloadDto from .raw_client import AsyncRawSecretsClient, RawSecretsClient # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) + + class SecretsClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._raw_client = RawSecretsClient(client_wrapper=client_wrapper) - + @property def with_raw_response(self) -> RawSecretsClient: """ Retrieves a raw implementation of this client that returns raw responses. - + Returns ------- RawSecretsClient """ return self._raw_client - - def list_secrets(self, *, request_options: typing.Optional[RequestOptions] = None) -> typing.List[SecretResponseDto]: + + def list_secrets( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.List[PublicSecretResponseDto]: """ Parameters ---------- request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - typing.List[SecretResponseDto] + typing.List[PublicSecretResponseDto] Successful Response - + Examples -------- from agenta import AgentaApi - + client = AgentaApi( api_key="YOUR_API_KEY", ) @@ -49,25 +56,35 @@ def list_secrets(self, *, request_options: typing.Optional[RequestOptions] = Non """ _response = self._raw_client.list_secrets(request_options=request_options) return _response.data - - def create_secret(self, *, header: Header, secret: SecretDto, slug: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SecretResponseDto: + + def create_secret( + self, + *, + header: Header, + secret: SecretDto, + slug: typing.Optional[str] = OMIT, + write_only: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PublicSecretResponseDto: """ Parameters ---------- header : Header - + secret : SecretDto - + slug : typing.Optional[str] - + + write_only : typing.Optional[bool] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - SecretResponseDto + PublicSecretResponseDto Successful Response - + Examples -------- from agenta import ( @@ -77,7 +94,7 @@ def create_secret(self, *, header: Header, secret: SecretDto, slug: typing.Optio StandardProviderDto, StandardProviderSettingsDto, ) - + client = AgentaApi( api_key="YOUR_API_KEY", ) @@ -87,34 +104,43 @@ def create_secret(self, *, header: Header, secret: SecretDto, slug: typing.Optio kind="provider_key", data=StandardProviderDto( kind="openai", - provider=StandardProviderSettingsDto( - key="key", - ), + provider=StandardProviderSettingsDto(), ), ), ) """ - _response = self._raw_client.create_secret(header=header, secret=secret, slug=slug, request_options=request_options) + _response = self._raw_client.create_secret( + header=header, + secret=secret, + slug=slug, + write_only=write_only, + request_options=request_options, + ) return _response.data - - def read_secret(self, secret_id_or_slug: str, *, request_options: typing.Optional[RequestOptions] = None) -> SecretResponseDto: + + def read_secret( + self, + secret_id_or_slug: str, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> PublicSecretResponseDto: """ Parameters ---------- secret_id_or_slug : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - SecretResponseDto + PublicSecretResponseDto Successful Response - + Examples -------- from agenta import AgentaApi - + client = AgentaApi( api_key="YOUR_API_KEY", ) @@ -122,31 +148,40 @@ def read_secret(self, secret_id_or_slug: str, *, request_options: typing.Optiona secret_id_or_slug="secret_id_or_slug", ) """ - _response = self._raw_client.read_secret(secret_id_or_slug, request_options=request_options) + _response = self._raw_client.read_secret( + secret_id_or_slug, request_options=request_options + ) return _response.data - - def update_secret(self, secret_id: str, *, header: typing.Optional[Header] = OMIT, secret: typing.Optional[SecretDto] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SecretResponseDto: + + def update_secret( + self, + secret_id: str, + *, + header: typing.Optional[Header] = OMIT, + secret: typing.Optional[UpdateSecretPayloadDto] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PublicSecretResponseDto: """ Parameters ---------- secret_id : str - + header : typing.Optional[Header] - - secret : typing.Optional[SecretDto] - + + secret : typing.Optional[UpdateSecretPayloadDto] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - SecretResponseDto + PublicSecretResponseDto Successful Response - + Examples -------- from agenta import AgentaApi - + client = AgentaApi( api_key="YOUR_API_KEY", ) @@ -154,26 +189,30 @@ def update_secret(self, secret_id: str, *, header: typing.Optional[Header] = OMI secret_id="secret_id", ) """ - _response = self._raw_client.update_secret(secret_id, header=header, secret=secret, request_options=request_options) + _response = self._raw_client.update_secret( + secret_id, header=header, secret=secret, request_options=request_options + ) return _response.data - - def delete_secret(self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + + def delete_secret( + self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> None: """ Parameters ---------- secret_id : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- None - + Examples -------- from agenta import AgentaApi - + client = AgentaApi( api_key="YOUR_API_KEY", ) @@ -181,77 +220,137 @@ def delete_secret(self, secret_id: str, *, request_options: typing.Optional[Requ secret_id="secret_id", ) """ - _response = self._raw_client.delete_secret(secret_id, request_options=request_options) + _response = self._raw_client.delete_secret( + secret_id, request_options=request_options + ) + return _response.data + + def probe_provider( + self, + *, + kind: typing.Optional[str] = OMIT, + provider: typing.Optional[ProviderCredentials] = OMIT, + secret_id: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ProbeProviderResponse: + """ + Parameters + ---------- + kind : typing.Optional[str] + Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it. + + provider : typing.Optional[ProviderCredentials] + + secret_id : typing.Optional[str] + Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ProbeProviderResponse + Successful Response + + Examples + -------- + from agenta import AgentaApi + + client = AgentaApi( + api_key="YOUR_API_KEY", + ) + client.secrets.probe_provider() + """ + _response = self._raw_client.probe_provider( + kind=kind, + provider=provider, + secret_id=secret_id, + request_options=request_options, + ) return _response.data + + class AsyncSecretsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._raw_client = AsyncRawSecretsClient(client_wrapper=client_wrapper) - + @property def with_raw_response(self) -> AsyncRawSecretsClient: """ Retrieves a raw implementation of this client that returns raw responses. - + Returns ------- AsyncRawSecretsClient """ return self._raw_client - - async def list_secrets(self, *, request_options: typing.Optional[RequestOptions] = None) -> typing.List[SecretResponseDto]: + + async def list_secrets( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> typing.List[PublicSecretResponseDto]: """ Parameters ---------- request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - typing.List[SecretResponseDto] + typing.List[PublicSecretResponseDto] Successful Response - + Examples -------- import asyncio - + from agenta import AsyncAgentaApi - + client = AsyncAgentaApi( api_key="YOUR_API_KEY", ) - - + + async def main() -> None: await client.secrets.list_secrets() - - + + asyncio.run(main()) """ _response = await self._raw_client.list_secrets(request_options=request_options) return _response.data - - async def create_secret(self, *, header: Header, secret: SecretDto, slug: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SecretResponseDto: + + async def create_secret( + self, + *, + header: Header, + secret: SecretDto, + slug: typing.Optional[str] = OMIT, + write_only: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PublicSecretResponseDto: """ Parameters ---------- header : Header - + secret : SecretDto - + slug : typing.Optional[str] - + + write_only : typing.Optional[bool] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - SecretResponseDto + PublicSecretResponseDto Successful Response - + Examples -------- import asyncio - + from agenta import ( AsyncAgentaApi, Header, @@ -259,12 +358,12 @@ async def create_secret(self, *, header: Header, secret: SecretDto, slug: typing StandardProviderDto, StandardProviderSettingsDto, ) - + client = AsyncAgentaApi( api_key="YOUR_API_KEY", ) - - + + async def main() -> None: await client.secrets.create_secret( header=Header(), @@ -272,126 +371,202 @@ async def main() -> None: kind="provider_key", data=StandardProviderDto( kind="openai", - provider=StandardProviderSettingsDto( - key="key", - ), + provider=StandardProviderSettingsDto(), ), ), ) - - + + asyncio.run(main()) """ - _response = await self._raw_client.create_secret(header=header, secret=secret, slug=slug, request_options=request_options) + _response = await self._raw_client.create_secret( + header=header, + secret=secret, + slug=slug, + write_only=write_only, + request_options=request_options, + ) return _response.data - - async def read_secret(self, secret_id_or_slug: str, *, request_options: typing.Optional[RequestOptions] = None) -> SecretResponseDto: + + async def read_secret( + self, + secret_id_or_slug: str, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> PublicSecretResponseDto: """ Parameters ---------- secret_id_or_slug : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - SecretResponseDto + PublicSecretResponseDto Successful Response - + Examples -------- import asyncio - + from agenta import AsyncAgentaApi - + client = AsyncAgentaApi( api_key="YOUR_API_KEY", ) - - + + async def main() -> None: await client.secrets.read_secret( secret_id_or_slug="secret_id_or_slug", ) - - + + asyncio.run(main()) """ - _response = await self._raw_client.read_secret(secret_id_or_slug, request_options=request_options) + _response = await self._raw_client.read_secret( + secret_id_or_slug, request_options=request_options + ) return _response.data - - async def update_secret(self, secret_id: str, *, header: typing.Optional[Header] = OMIT, secret: typing.Optional[SecretDto] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> SecretResponseDto: + + async def update_secret( + self, + secret_id: str, + *, + header: typing.Optional[Header] = OMIT, + secret: typing.Optional[UpdateSecretPayloadDto] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> PublicSecretResponseDto: """ Parameters ---------- secret_id : str - + header : typing.Optional[Header] - - secret : typing.Optional[SecretDto] - + + secret : typing.Optional[UpdateSecretPayloadDto] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - SecretResponseDto + PublicSecretResponseDto Successful Response - + Examples -------- import asyncio - + from agenta import AsyncAgentaApi - + client = AsyncAgentaApi( api_key="YOUR_API_KEY", ) - - + + async def main() -> None: await client.secrets.update_secret( secret_id="secret_id", ) - - + + asyncio.run(main()) """ - _response = await self._raw_client.update_secret(secret_id, header=header, secret=secret, request_options=request_options) + _response = await self._raw_client.update_secret( + secret_id, header=header, secret=secret, request_options=request_options + ) return _response.data - - async def delete_secret(self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> None: + + async def delete_secret( + self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> None: """ Parameters ---------- secret_id : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- None - + Examples -------- import asyncio - + from agenta import AsyncAgentaApi - + client = AsyncAgentaApi( api_key="YOUR_API_KEY", ) - - + + async def main() -> None: await client.secrets.delete_secret( secret_id="secret_id", ) - - + + asyncio.run(main()) """ - _response = await self._raw_client.delete_secret(secret_id, request_options=request_options) + _response = await self._raw_client.delete_secret( + secret_id, request_options=request_options + ) + return _response.data + + async def probe_provider( + self, + *, + kind: typing.Optional[str] = OMIT, + provider: typing.Optional[ProviderCredentials] = OMIT, + secret_id: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> ProbeProviderResponse: + """ + Parameters + ---------- + kind : typing.Optional[str] + Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it. + + provider : typing.Optional[ProviderCredentials] + + secret_id : typing.Optional[str] + Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ProbeProviderResponse + Successful Response + + Examples + -------- + import asyncio + + from agenta import AsyncAgentaApi + + client = AsyncAgentaApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.secrets.probe_provider() + + + asyncio.run(main()) + """ + _response = await self._raw_client.probe_provider( + kind=kind, + provider=provider, + secret_id=secret_id, + request_options=request_options, + ) return _response.data diff --git a/clients/python/agenta_client/secrets/raw_client.py b/clients/python/agenta_client/secrets/raw_client.py index 05324e4632..deed18bbde 100644 --- a/clients/python/agenta_client/secrets/raw_client.py +++ b/clients/python/agenta_client/secrets/raw_client.py @@ -13,428 +13,791 @@ from ..errors.unprocessable_entity_error import UnprocessableEntityError from ..types.header import Header from ..types.http_validation_error import HttpValidationError +from ..types.probe_provider_response import ProbeProviderResponse +from ..types.provider_credentials import ProviderCredentials +from ..types.public_secret_response_dto import PublicSecretResponseDto from ..types.secret_dto import SecretDto -from ..types.secret_response_dto import SecretResponseDto +from ..types.update_secret_payload_dto import UpdateSecretPayloadDto # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) + + class RawSecretsClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper - - def list_secrets(self, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[typing.List[SecretResponseDto]]: + + def list_secrets( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[typing.List[PublicSecretResponseDto]]: """ Parameters ---------- request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - HttpResponse[typing.List[SecretResponseDto]] + HttpResponse[typing.List[PublicSecretResponseDto]] Successful Response """ _response = self._client_wrapper.httpx_client.request( - "secrets/",method="GET", - request_options=request_options,) + "secrets/", + method="GET", + request_options=request_options, + ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - typing.List[SecretResponseDto], + typing.List[PublicSecretResponseDto], parse_obj_as( - type_ =typing.List[SecretResponseDto], # type: ignore - object_ =_response.json() - ) + type_=typing.List[PublicSecretResponseDto], # type: ignore + object_=_response.json(), + ), ) return HttpResponse(response=_response, data=_data) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - def create_secret(self, *, header: Header, secret: SecretDto, slug: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SecretResponseDto]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def create_secret( + self, + *, + header: Header, + secret: SecretDto, + slug: typing.Optional[str] = OMIT, + write_only: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PublicSecretResponseDto]: """ Parameters ---------- header : Header - + secret : SecretDto - + slug : typing.Optional[str] - + + write_only : typing.Optional[bool] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - HttpResponse[SecretResponseDto] + HttpResponse[PublicSecretResponseDto] Successful Response """ _response = self._client_wrapper.httpx_client.request( - "secrets/",method="POST", + "secrets/", + method="POST", json={ "slug": slug, - "header": convert_and_respect_annotation_metadata(object_=header, annotation=Header, direction="write"), - "secret": convert_and_respect_annotation_metadata(object_=secret, annotation=SecretDto, direction="write"), - } - , - headers={"content-type": "application/json", } - , - request_options=request_options,omit=OMIT, + "header": convert_and_respect_annotation_metadata( + object_=header, annotation=Header, direction="write" + ), + "secret": convert_and_respect_annotation_metadata( + object_=secret, annotation=SecretDto, direction="write" + ), + "write_only": write_only, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SecretResponseDto, + PublicSecretResponseDto, parse_obj_as( - type_ =SecretResponseDto, # type: ignore - object_ =_response.json() - ) + type_=PublicSecretResponseDto, # type: ignore + object_=_response.json(), + ), ) return HttpResponse(response=_response, data=_data) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, - parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - def read_secret(self, secret_id_or_slug: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SecretResponseDto]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def read_secret( + self, + secret_id_or_slug: str, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PublicSecretResponseDto]: """ Parameters ---------- secret_id_or_slug : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - HttpResponse[SecretResponseDto] + HttpResponse[PublicSecretResponseDto] Successful Response """ _response = self._client_wrapper.httpx_client.request( - f"secrets/{jsonable_encoder(secret_id_or_slug)}",method="GET", - request_options=request_options,) + f"secrets/{jsonable_encoder(secret_id_or_slug)}", + method="GET", + request_options=request_options, + ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SecretResponseDto, + PublicSecretResponseDto, parse_obj_as( - type_ =SecretResponseDto, # type: ignore - object_ =_response.json() - ) + type_=PublicSecretResponseDto, # type: ignore + object_=_response.json(), + ), ) return HttpResponse(response=_response, data=_data) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, - parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - def update_secret(self, secret_id: str, *, header: typing.Optional[Header] = OMIT, secret: typing.Optional[SecretDto] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[SecretResponseDto]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def update_secret( + self, + secret_id: str, + *, + header: typing.Optional[Header] = OMIT, + secret: typing.Optional[UpdateSecretPayloadDto] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[PublicSecretResponseDto]: """ Parameters ---------- secret_id : str - + header : typing.Optional[Header] - - secret : typing.Optional[SecretDto] - + + secret : typing.Optional[UpdateSecretPayloadDto] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - HttpResponse[SecretResponseDto] + HttpResponse[PublicSecretResponseDto] Successful Response """ _response = self._client_wrapper.httpx_client.request( - f"secrets/{jsonable_encoder(secret_id)}",method="PUT", + f"secrets/{jsonable_encoder(secret_id)}", + method="PUT", json={ - "header": convert_and_respect_annotation_metadata(object_=header, annotation=typing.Optional[Header], direction="write"), - "secret": convert_and_respect_annotation_metadata(object_=secret, annotation=typing.Optional[SecretDto], direction="write"), - } - , - headers={"content-type": "application/json", } - , - request_options=request_options,omit=OMIT, + "header": convert_and_respect_annotation_metadata( + object_=header, + annotation=typing.Optional[Header], + direction="write", + ), + "secret": convert_and_respect_annotation_metadata( + object_=secret, + annotation=typing.Optional[UpdateSecretPayloadDto], + direction="write", + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SecretResponseDto, + PublicSecretResponseDto, parse_obj_as( - type_ =SecretResponseDto, # type: ignore - object_ =_response.json() - ) + type_=PublicSecretResponseDto, # type: ignore + object_=_response.json(), + ), ) return HttpResponse(response=_response, data=_data) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, - parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - def delete_secret(self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> HttpResponse[None]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def delete_secret( + self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[None]: """ Parameters ---------- secret_id : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- HttpResponse[None] """ _response = self._client_wrapper.httpx_client.request( - f"secrets/{jsonable_encoder(secret_id)}",method="DELETE", - request_options=request_options,) + f"secrets/{jsonable_encoder(secret_id)}", + method="DELETE", + request_options=request_options, + ) try: if 200 <= _response.status_code < 300: return HttpResponse(response=_response, data=None) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + def probe_provider( + self, + *, + kind: typing.Optional[str] = OMIT, + provider: typing.Optional[ProviderCredentials] = OMIT, + secret_id: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ProbeProviderResponse]: + """ + Parameters + ---------- + kind : typing.Optional[str] + Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it. + + provider : typing.Optional[ProviderCredentials] + + secret_id : typing.Optional[str] + Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ProbeProviderResponse] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "providers/probe", + method="POST", + json={ + "kind": kind, + "provider": convert_and_respect_annotation_metadata( + object_=provider, annotation=ProviderCredentials, direction="write" + ), + "secret_id": secret_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ProbeProviderResponse, parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + type_=ProbeProviderResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + class AsyncRawSecretsClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): self._client_wrapper = client_wrapper - - async def list_secrets(self, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[typing.List[SecretResponseDto]]: + + async def list_secrets( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[typing.List[PublicSecretResponseDto]]: """ Parameters ---------- request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - AsyncHttpResponse[typing.List[SecretResponseDto]] + AsyncHttpResponse[typing.List[PublicSecretResponseDto]] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "secrets/",method="GET", - request_options=request_options,) + "secrets/", + method="GET", + request_options=request_options, + ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - typing.List[SecretResponseDto], + typing.List[PublicSecretResponseDto], parse_obj_as( - type_ =typing.List[SecretResponseDto], # type: ignore - object_ =_response.json() - ) + type_=typing.List[PublicSecretResponseDto], # type: ignore + object_=_response.json(), + ), ) return AsyncHttpResponse(response=_response, data=_data) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - async def create_secret(self, *, header: Header, secret: SecretDto, slug: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SecretResponseDto]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def create_secret( + self, + *, + header: Header, + secret: SecretDto, + slug: typing.Optional[str] = OMIT, + write_only: typing.Optional[bool] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PublicSecretResponseDto]: """ Parameters ---------- header : Header - + secret : SecretDto - + slug : typing.Optional[str] - + + write_only : typing.Optional[bool] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - AsyncHttpResponse[SecretResponseDto] + AsyncHttpResponse[PublicSecretResponseDto] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - "secrets/",method="POST", + "secrets/", + method="POST", json={ "slug": slug, - "header": convert_and_respect_annotation_metadata(object_=header, annotation=Header, direction="write"), - "secret": convert_and_respect_annotation_metadata(object_=secret, annotation=SecretDto, direction="write"), - } - , - headers={"content-type": "application/json", } - , - request_options=request_options,omit=OMIT, + "header": convert_and_respect_annotation_metadata( + object_=header, annotation=Header, direction="write" + ), + "secret": convert_and_respect_annotation_metadata( + object_=secret, annotation=SecretDto, direction="write" + ), + "write_only": write_only, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SecretResponseDto, + PublicSecretResponseDto, parse_obj_as( - type_ =SecretResponseDto, # type: ignore - object_ =_response.json() - ) + type_=PublicSecretResponseDto, # type: ignore + object_=_response.json(), + ), ) return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, - parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - async def read_secret(self, secret_id_or_slug: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SecretResponseDto]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def read_secret( + self, + secret_id_or_slug: str, + *, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PublicSecretResponseDto]: """ Parameters ---------- secret_id_or_slug : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - AsyncHttpResponse[SecretResponseDto] + AsyncHttpResponse[PublicSecretResponseDto] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - f"secrets/{jsonable_encoder(secret_id_or_slug)}",method="GET", - request_options=request_options,) + f"secrets/{jsonable_encoder(secret_id_or_slug)}", + method="GET", + request_options=request_options, + ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SecretResponseDto, + PublicSecretResponseDto, parse_obj_as( - type_ =SecretResponseDto, # type: ignore - object_ =_response.json() - ) + type_=PublicSecretResponseDto, # type: ignore + object_=_response.json(), + ), ) return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, - parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - async def update_secret(self, secret_id: str, *, header: typing.Optional[Header] = OMIT, secret: typing.Optional[SecretDto] = OMIT, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[SecretResponseDto]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def update_secret( + self, + secret_id: str, + *, + header: typing.Optional[Header] = OMIT, + secret: typing.Optional[UpdateSecretPayloadDto] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[PublicSecretResponseDto]: """ Parameters ---------- secret_id : str - + header : typing.Optional[Header] - - secret : typing.Optional[SecretDto] - + + secret : typing.Optional[UpdateSecretPayloadDto] + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- - AsyncHttpResponse[SecretResponseDto] + AsyncHttpResponse[PublicSecretResponseDto] Successful Response """ _response = await self._client_wrapper.httpx_client.request( - f"secrets/{jsonable_encoder(secret_id)}",method="PUT", + f"secrets/{jsonable_encoder(secret_id)}", + method="PUT", json={ - "header": convert_and_respect_annotation_metadata(object_=header, annotation=typing.Optional[Header], direction="write"), - "secret": convert_and_respect_annotation_metadata(object_=secret, annotation=typing.Optional[SecretDto], direction="write"), - } - , - headers={"content-type": "application/json", } - , - request_options=request_options,omit=OMIT, + "header": convert_and_respect_annotation_metadata( + object_=header, + annotation=typing.Optional[Header], + direction="write", + ), + "secret": convert_and_respect_annotation_metadata( + object_=secret, + annotation=typing.Optional[UpdateSecretPayloadDto], + direction="write", + ), + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - SecretResponseDto, + PublicSecretResponseDto, parse_obj_as( - type_ =SecretResponseDto, # type: ignore - object_ =_response.json() - ) + type_=PublicSecretResponseDto, # type: ignore + object_=_response.json(), + ), ) return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, - parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - async def delete_secret(self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None) -> AsyncHttpResponse[None]: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def delete_secret( + self, secret_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: """ Parameters ---------- secret_id : str - + request_options : typing.Optional[RequestOptions] Request-specific configuration. - + Returns ------- AsyncHttpResponse[None] """ _response = await self._client_wrapper.httpx_client.request( - f"secrets/{jsonable_encoder(secret_id)}",method="DELETE", - request_options=request_options,) + f"secrets/{jsonable_encoder(secret_id)}", + method="DELETE", + request_options=request_options, + ) try: if 200 <= _response.status_code < 300: return AsyncHttpResponse(response=_response, data=None) if _response.status_code == 422: - raise UnprocessableEntityError(headers=dict(_response.headers), body=typing.cast( - HttpValidationError, + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) + + async def probe_provider( + self, + *, + kind: typing.Optional[str] = OMIT, + provider: typing.Optional[ProviderCredentials] = OMIT, + secret_id: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ProbeProviderResponse]: + """ + Parameters + ---------- + kind : typing.Optional[str] + Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it. + + provider : typing.Optional[ProviderCredentials] + + secret_id : typing.Optional[str] + Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ProbeProviderResponse] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "providers/probe", + method="POST", + json={ + "kind": kind, + "provider": convert_and_respect_annotation_metadata( + object_=provider, annotation=ProviderCredentials, direction="write" + ), + "secret_id": secret_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ProbeProviderResponse, parse_obj_as( - type_ =HttpValidationError, # type: ignore - object_ =_response.json() - ) - )) + type_=ProbeProviderResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + HttpValidationError, + parse_obj_as( + type_=HttpValidationError, # type: ignore + object_=_response.json(), + ), + ), + ) _response_json = _response.json() except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response.text, + ) + raise ApiError( + status_code=_response.status_code, + headers=dict(_response.headers), + body=_response_json, + ) diff --git a/clients/python/agenta_client/types/__init__.py b/clients/python/agenta_client/types/__init__.py index d200c0433a..6245a98679 100644 --- a/clients/python/agenta_client/types/__init__.py +++ b/clients/python/agenta_client/types/__init__.py @@ -177,6 +177,8 @@ from .config_response_model import ConfigResponseModel from .connect_affordance import ConnectAffordance from .connection_requirement import ConnectionRequirement + from .credential_result import CredentialResult + from .credential_status import CredentialStatus from .custom_model_settings_dto import CustomModelSettingsDto from .custom_provider_dto import CustomProviderDto from .custom_provider_kind import CustomProviderKind @@ -197,6 +199,8 @@ from .discovered_trigger_alternative import DiscoveredTriggerAlternative from .discovered_trigger_event import DiscoveredTriggerEvent from .discovered_trigger_event_type import DiscoveredTriggerEventType + from .discovery_result import DiscoveryResult + from .discovery_status import DiscoveryStatus from .entity_ref import EntityRef from .environment import Environment from .environment_create import EnvironmentCreate @@ -427,8 +431,13 @@ from .organization_update import OrganizationUpdate from .permission import Permission from .playground_build_kit_context import PlaygroundBuildKitContext + from .probe_provider_response import ProbeProviderResponse from .projects_response import ProjectsResponse + from .provider_credentials import ProviderCredentials from .public_mount_create import PublicMountCreate + from .public_secret_management_dto import PublicSecretManagementDto + from .public_secret_response_dto import PublicSecretResponseDto + from .public_secret_response_dto_data import PublicSecretResponseDtoData from .queries_response import QueriesResponse from .query import Query from .query_create import QueryCreate @@ -463,8 +472,8 @@ from .secret_dto import SecretDto from .secret_dto_data import SecretDtoData from .secret_kind import SecretKind - from .secret_response_dto import SecretResponseDto - from .secret_response_dto_data import SecretResponseDtoData + from .secret_management_policy import SecretManagementPolicy + from .secret_value_status import SecretValueStatus from .selector import Selector from .session_attachment import SessionAttachment from .session_attachment_response import SessionAttachmentResponse @@ -822,6 +831,8 @@ from .trigger_subscription_query import TriggerSubscriptionQuery from .trigger_subscription_response import TriggerSubscriptionResponse from .trigger_subscriptions_response import TriggerSubscriptionsResponse + from .update_secret_payload_dto import UpdateSecretPayloadDto + from .update_secret_payload_dto_data import UpdateSecretPayloadDtoData from .user_ids_response import UserIdsResponse from .validation_error import ValidationError from .validation_error_loc_item import ValidationErrorLocItem @@ -1037,6 +1048,8 @@ "ConfigResponseModel": ".config_response_model", "ConnectAffordance": ".connect_affordance", "ConnectionRequirement": ".connection_requirement", + "CredentialResult": ".credential_result", + "CredentialStatus": ".credential_status", "CustomModelSettingsDto": ".custom_model_settings_dto", "CustomProviderDto": ".custom_provider_dto", "CustomProviderKind": ".custom_provider_kind", @@ -1055,6 +1068,8 @@ "DiscoveredTriggerAlternative": ".discovered_trigger_alternative", "DiscoveredTriggerEvent": ".discovered_trigger_event", "DiscoveredTriggerEventType": ".discovered_trigger_event_type", + "DiscoveryResult": ".discovery_result", + "DiscoveryStatus": ".discovery_status", "EntityRef": ".entity_ref", "Environment": ".environment", "EnvironmentCreate": ".environment_create", @@ -1275,8 +1290,13 @@ "OrganizationUpdate": ".organization_update", "Permission": ".permission", "PlaygroundBuildKitContext": ".playground_build_kit_context", + "ProbeProviderResponse": ".probe_provider_response", "ProjectsResponse": ".projects_response", + "ProviderCredentials": ".provider_credentials", "PublicMountCreate": ".public_mount_create", + "PublicSecretManagementDto": ".public_secret_management_dto", + "PublicSecretResponseDto": ".public_secret_response_dto", + "PublicSecretResponseDtoData": ".public_secret_response_dto_data", "QueriesResponse": ".queries_response", "Query": ".query", "QueryCreate": ".query_create", @@ -1311,8 +1331,8 @@ "SecretDto": ".secret_dto", "SecretDtoData": ".secret_dto_data", "SecretKind": ".secret_kind", - "SecretResponseDto": ".secret_response_dto", - "SecretResponseDtoData": ".secret_response_dto_data", + "SecretManagementPolicy": ".secret_management_policy", + "SecretValueStatus": ".secret_value_status", "Selector": ".selector", "SessionAttachment": ".session_attachment", "SessionAttachmentResponse": ".session_attachment_response", @@ -1624,6 +1644,8 @@ "TriggerSubscriptionQuery": ".trigger_subscription_query", "TriggerSubscriptionResponse": ".trigger_subscription_response", "TriggerSubscriptionsResponse": ".trigger_subscriptions_response", + "UpdateSecretPayloadDto": ".update_secret_payload_dto", + "UpdateSecretPayloadDtoData": ".update_secret_payload_dto_data", "UserIdsResponse": ".user_ids_response", "ValidationError": ".validation_error", "ValidationErrorLocItem": ".validation_error_loc_item", @@ -1861,6 +1883,8 @@ def __dir__(): "ConfigResponseModel", "ConnectAffordance", "ConnectionRequirement", + "CredentialResult", + "CredentialStatus", "CustomModelSettingsDto", "CustomProviderDto", "CustomProviderKind", @@ -1879,6 +1903,8 @@ def __dir__(): "DiscoveredTriggerAlternative", "DiscoveredTriggerEvent", "DiscoveredTriggerEventType", + "DiscoveryResult", + "DiscoveryStatus", "EntityRef", "Environment", "EnvironmentCreate", @@ -2099,8 +2125,13 @@ def __dir__(): "OrganizationUpdate", "Permission", "PlaygroundBuildKitContext", + "ProbeProviderResponse", "ProjectsResponse", + "ProviderCredentials", "PublicMountCreate", + "PublicSecretManagementDto", + "PublicSecretResponseDto", + "PublicSecretResponseDtoData", "QueriesResponse", "Query", "QueryCreate", @@ -2135,8 +2166,8 @@ def __dir__(): "SecretDto", "SecretDtoData", "SecretKind", - "SecretResponseDto", - "SecretResponseDtoData", + "SecretManagementPolicy", + "SecretValueStatus", "Selector", "SessionAttachment", "SessionAttachmentResponse", @@ -2448,6 +2479,8 @@ def __dir__(): "TriggerSubscriptionQuery", "TriggerSubscriptionResponse", "TriggerSubscriptionsResponse", + "UpdateSecretPayloadDto", + "UpdateSecretPayloadDtoData", "UserIdsResponse", "ValidationError", "ValidationErrorLocItem", diff --git a/clients/python/agenta_client/types/credential_result.py b/clients/python/agenta_client/types/credential_result.py new file mode 100644 index 0000000000..06516baf3b --- /dev/null +++ b/clients/python/agenta_client/types/credential_result.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .credential_status import CredentialStatus + + +class CredentialResult(UniversalBaseModel): + status: CredentialStatus + message: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/credential_status.py b/clients/python/agenta_client/types/credential_status.py new file mode 100644 index 0000000000..ba4c5dd76b --- /dev/null +++ b/clients/python/agenta_client/types/credential_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +CredentialStatus = typing.Union[ + typing.Literal["valid", "invalid", "unknown"], typing.Any +] diff --git a/clients/python/agenta_client/types/custom_provider_dto.py b/clients/python/agenta_client/types/custom_provider_dto.py index 15782574ab..1c57993c4d 100644 --- a/clients/python/agenta_client/types/custom_provider_dto.py +++ b/clients/python/agenta_client/types/custom_provider_dto.py @@ -13,12 +13,16 @@ class CustomProviderDto(UniversalBaseModel): kind: CustomProviderKind provider: CustomProviderSettingsDto models: typing.List[CustomModelSettingsDto] + harnesses: typing.Optional[typing.List[str]] = None provider_slug: typing.Optional[str] = None model_keys: typing.Optional[typing.List[str]] = None - + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/custom_secret_settings_dto.py b/clients/python/agenta_client/types/custom_secret_settings_dto.py index 2288742120..89ff587e1a 100644 --- a/clients/python/agenta_client/types/custom_secret_settings_dto.py +++ b/clients/python/agenta_client/types/custom_secret_settings_dto.py @@ -10,11 +10,14 @@ class CustomSecretSettingsDto(UniversalBaseModel): format: CustomSecretFormat - content: CustomSecretSettingsDtoContent - + content: typing.Optional[CustomSecretSettingsDtoContent] = None + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/discovery_result.py b/clients/python/agenta_client/types/discovery_result.py new file mode 100644 index 0000000000..e5a0e95c85 --- /dev/null +++ b/clients/python/agenta_client/types/discovery_result.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .discovery_status import DiscoveryStatus + + +class DiscoveryResult(UniversalBaseModel): + status: DiscoveryStatus + models: typing.Optional[typing.List[str]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/discovery_status.py b/clients/python/agenta_client/types/discovery_status.py new file mode 100644 index 0000000000..72d83fb754 --- /dev/null +++ b/clients/python/agenta_client/types/discovery_status.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +DiscoveryStatus = typing.Union[ + typing.Literal["fetched", "unsupported", "failed"], typing.Any +] diff --git a/clients/python/agenta_client/types/probe_provider_response.py b/clients/python/agenta_client/types/probe_provider_response.py new file mode 100644 index 0000000000..40f99d8b6d --- /dev/null +++ b/clients/python/agenta_client/types/probe_provider_response.py @@ -0,0 +1,26 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .credential_result import CredentialResult +from .discovery_result import DiscoveryResult + + +class ProbeProviderResponse(UniversalBaseModel): + credential: CredentialResult + discovery: DiscoveryResult + fetched_at: dt.datetime + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/provider_credentials.py b/clients/python/agenta_client/types/provider_credentials.py new file mode 100644 index 0000000000..baab5a043a --- /dev/null +++ b/clients/python/agenta_client/types/provider_credentials.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class ProviderCredentials(UniversalBaseModel): + """ + Credentials in transit only. Never persisted here, never logged, never echoed. + + `key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line + or traceback that carries this object cannot print the credential. Unwrap the key with + `.get_secret_value()` at the point it is put on the wire, never earlier. + """ + + key: typing.Optional[str] = None + url: typing.Optional[str] = None + version: typing.Optional[str] = None + extras: typing.Optional[typing.Dict[str, typing.Any]] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/public_secret_management_dto.py b/clients/python/agenta_client/types/public_secret_management_dto.py new file mode 100644 index 0000000000..c50dad9b87 --- /dev/null +++ b/clients/python/agenta_client/types/public_secret_management_dto.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .secret_management_policy import SecretManagementPolicy + + +class PublicSecretManagementDto(UniversalBaseModel): + policy: SecretManagementPolicy + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/secret_response_dto.py b/clients/python/agenta_client/types/public_secret_response_dto.py similarity index 52% rename from clients/python/agenta_client/types/secret_response_dto.py rename to clients/python/agenta_client/types/public_secret_response_dto.py index 6e4e52c01f..891199076b 100644 --- a/clients/python/agenta_client/types/secret_response_dto.py +++ b/clients/python/agenta_client/types/public_secret_response_dto.py @@ -6,21 +6,33 @@ from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel from .header import Header from .legacy_lifecycle_dto import LegacyLifecycleDto +from .public_secret_management_dto import PublicSecretManagementDto +from .public_secret_response_dto_data import PublicSecretResponseDtoData from .secret_kind import SecretKind -from .secret_response_dto_data import SecretResponseDtoData +from .secret_value_status import SecretValueStatus -class SecretResponseDto(UniversalBaseModel): - kind: SecretKind - data: SecretResponseDtoData +class PublicSecretResponseDto(UniversalBaseModel): + """ + Caller-facing representation after grant-aware value projection. + """ + slug: typing.Optional[str] = None id: typing.Optional[str] = None + kind: SecretKind + data: PublicSecretResponseDtoData header: Header lifecycle: typing.Optional[LegacyLifecycleDto] = None - + write_only: typing.Optional[bool] = None + management: typing.Optional[PublicSecretManagementDto] = None + value_status: SecretValueStatus + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/public_secret_response_dto_data.py b/clients/python/agenta_client/types/public_secret_response_dto_data.py new file mode 100644 index 0000000000..b3790eea09 --- /dev/null +++ b/clients/python/agenta_client/types/public_secret_response_dto_data.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .custom_provider_dto import CustomProviderDto +from .custom_secret_dto import CustomSecretDto +from .sso_provider_dto import SsoProviderDto +from .standard_provider_dto import StandardProviderDto +from .webhook_provider_dto import WebhookProviderDto + +PublicSecretResponseDtoData = typing.Union[ + StandardProviderDto, + CustomProviderDto, + SsoProviderDto, + WebhookProviderDto, + CustomSecretDto, +] diff --git a/clients/python/agenta_client/types/secret_dto.py b/clients/python/agenta_client/types/secret_dto.py index 75c9db0e3d..bba094c131 100644 --- a/clients/python/agenta_client/types/secret_dto.py +++ b/clients/python/agenta_client/types/secret_dto.py @@ -9,12 +9,19 @@ class SecretDto(UniversalBaseModel): + """ + Create-time secret payload. Required credential fields must be present. + """ + kind: SecretKind data: SecretDtoData - + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/secret_management_policy.py b/clients/python/agenta_client/types/secret_management_policy.py new file mode 100644 index 0000000000..7926051e37 --- /dev/null +++ b/clients/python/agenta_client/types/secret_management_policy.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +SecretManagementPolicy = typing.Union[typing.Literal["manager_only"], typing.Any] diff --git a/clients/python/agenta_client/types/secret_value_status.py b/clients/python/agenta_client/types/secret_value_status.py new file mode 100644 index 0000000000..6f6ceace99 --- /dev/null +++ b/clients/python/agenta_client/types/secret_value_status.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class SecretValueStatus(UniversalBaseModel): + configured: bool + preview: typing.Optional[str] = None + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/sso_provider_settings_dto.py b/clients/python/agenta_client/types/sso_provider_settings_dto.py index ea04295906..26e988a319 100644 --- a/clients/python/agenta_client/types/sso_provider_settings_dto.py +++ b/clients/python/agenta_client/types/sso_provider_settings_dto.py @@ -8,14 +8,17 @@ class SsoProviderSettingsDto(UniversalBaseModel): client_id: str - client_secret: str + client_secret: typing.Optional[str] = None issuer_url: str scopes: typing.List[str] extra: typing.Optional[typing.Dict[str, typing.Any]] = None - + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/standard_provider_dto.py b/clients/python/agenta_client/types/standard_provider_dto.py index d7fb13f572..50d2e97eea 100644 --- a/clients/python/agenta_client/types/standard_provider_dto.py +++ b/clients/python/agenta_client/types/standard_provider_dto.py @@ -4,6 +4,7 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .custom_model_settings_dto import CustomModelSettingsDto from .standard_provider_kind import StandardProviderKind from .standard_provider_settings_dto import StandardProviderSettingsDto @@ -11,10 +12,15 @@ class StandardProviderDto(UniversalBaseModel): kind: StandardProviderKind provider: StandardProviderSettingsDto - + models: typing.Optional[typing.List[CustomModelSettingsDto]] = None + harnesses: typing.Optional[typing.List[str]] = None + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/standard_provider_settings_dto.py b/clients/python/agenta_client/types/standard_provider_settings_dto.py index f3db359d33..0160965780 100644 --- a/clients/python/agenta_client/types/standard_provider_settings_dto.py +++ b/clients/python/agenta_client/types/standard_provider_settings_dto.py @@ -7,11 +7,14 @@ class StandardProviderSettingsDto(UniversalBaseModel): - key: str - + key: typing.Optional[str] = None + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/clients/python/agenta_client/types/update_secret_payload_dto.py b/clients/python/agenta_client/types/update_secret_payload_dto.py new file mode 100644 index 0000000000..e674c40cca --- /dev/null +++ b/clients/python/agenta_client/types/update_secret_payload_dto.py @@ -0,0 +1,28 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .secret_kind import SecretKind +from .update_secret_payload_dto_data import UpdateSecretPayloadDtoData + + +class UpdateSecretPayloadDto(UniversalBaseModel): + """ + Update-time payload. Omitted credential fields keep their stored values. + """ + + kind: SecretKind + data: UpdateSecretPayloadDtoData + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/clients/python/agenta_client/types/secret_response_dto_data.py b/clients/python/agenta_client/types/update_secret_payload_dto_data.py similarity index 68% rename from clients/python/agenta_client/types/secret_response_dto_data.py rename to clients/python/agenta_client/types/update_secret_payload_dto_data.py index cb6f1de03b..c6e9acc243 100644 --- a/clients/python/agenta_client/types/secret_response_dto_data.py +++ b/clients/python/agenta_client/types/update_secret_payload_dto_data.py @@ -8,4 +8,10 @@ from .standard_provider_dto import StandardProviderDto from .webhook_provider_dto import WebhookProviderDto -SecretResponseDtoData = typing.Union[StandardProviderDto, CustomProviderDto, SsoProviderDto, WebhookProviderDto, CustomSecretDto] +UpdateSecretPayloadDtoData = typing.Union[ + StandardProviderDto, + CustomProviderDto, + SsoProviderDto, + WebhookProviderDto, + CustomSecretDto, +] diff --git a/clients/python/agenta_client/types/webhook_provider_settings_dto.py b/clients/python/agenta_client/types/webhook_provider_settings_dto.py index 90a2088ff3..00195723df 100644 --- a/clients/python/agenta_client/types/webhook_provider_settings_dto.py +++ b/clients/python/agenta_client/types/webhook_provider_settings_dto.py @@ -7,11 +7,14 @@ class WebhookProviderSettingsDto(UniversalBaseModel): - key: str - + key: typing.Optional[str] = None + if IS_PYDANTIC_V2: - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="allow", frozen=True + ) # type: ignore # Pydantic v2 else: + class Config: frozen = True smart_union = True diff --git a/docs/design/write-only-secrets/context.md b/docs/design/write-only-secrets/context.md new file mode 100644 index 0000000000..9ae7d411c3 --- /dev/null +++ b/docs/design/write-only-secrets/context.md @@ -0,0 +1,30 @@ +# Context + +This workspace coordinates the production hardening of write-only and platform-managed Vault secrets. The implementation spans five open pull requests: #6164 defines value visibility and runtime resolution, #6165 defines platform management, #6138 creates the starter-credit connection, #6174 consumes the public contract in the web app, and #6195 probes stored provider credentials. + +## Goal + +Ship the approved review decisions without a database migration or feature flag. Preserve existing readable SSO and webhook secrets, batch-resolution performance, standalone provider environment fallback, and the current cache-key packing. + +## System boundaries + +- The Vault service stores the trusted plaintext representation in encrypted JSON and exposes caller-specific projections. +- Trusted platform services may resolve write-only values through a short-lived signed grant and a dedicated internal-service key. +- Ordinary API and frontend consumers receive public status and management policy, never an internal component identity or a write-only value. +- The starter-credit bridge creates one platform-managed, write-only provider connection. +- The provider probe may spend a stored user-managed credential, but it must not spend a platform-managed credential on a caller-selected endpoint. + +## Constraints + +- No database migration. +- No new feature flag. +- Redis remains inside the trusted backend boundary and may cache canonical plaintext DTOs. +- `write_only` is immutable after creation. +- Management ownership and value visibility are independent policies. +- The existing 12-character cache-key UUID packing stays unchanged. +- Railway-dependent validation is recorded as deferred while Railway is unavailable. +- The active pi-traces work owns `services/runner/**`; this project does not touch those files. + +## Pull request order + +The dependency chain is `release/v0.114.0` to #6164 to #6165 to #6138 to #6195. #6174 is a separate frontend consumer based on `release/v0.114.0`. Each dependent PR keeps the branch below it as its immediate GitHub base so its diff remains reviewable. diff --git a/docs/design/write-only-secrets/implementation-report.md b/docs/design/write-only-secrets/implementation-report.md new file mode 100644 index 0000000000..30b1c3911e --- /dev/null +++ b/docs/design/write-only-secrets/implementation-report.md @@ -0,0 +1,126 @@ +# Write-only and managed secrets implementation report + +Date: 2026-08-22 + +## Outcome + +The five-PR feature stack now implements the decisions in `review.md` without a database +migration or feature flag. The backend stack is rooted in `release/v0.114.0` and uses immediate +dependency bases. The frontend PR is stacked on the provider-probe PR, so its diff contains only +the generated clients, frontend consumers, and the implementation/QA reports. + +The implementation deliberately did not touch the parallel pi-traces branches, runner code, or +generated session/trace contracts. + +Each PR was also rebuilt and validated as an independent layer. A late standalone #6164 CI run +caught managed-secret imports that had accidentally landed below #6165. The six shared Vault files +were split at the real ownership boundary: #6164 now has no managed-secret import, field, storage, +or guard, and #6165 introduces that complete contract. The final combined behavior is unchanged. + +## Changes by PR + +### #6164: write-only secret contract + +- Restored the Vault list cache with its existing namespace, TTL, invalidation, and shortened UUID + packing. +- Cached canonical trusted DTOs and applied grant-aware redaction after every cache read. +- Made `write_only` a creation-time policy. Updates cannot change it; omitted legacy storage + values remain readable. +- Replaced `has_key` and `key_preview` with the general `value_status.configured` and + `value_status.preview` response. +- Separated create, update, trusted internal response, and public response DTO roles. +- Kept update carry-over and policy resolution in a pure service resolver invoked against the + DAO's locked current row. +- Centralized and allowlisted the `secret-resolve` grant. +- Preserved the provider-specific standalone environment fallback. +- Kept SSO and webhook secrets explicitly readable with `write_only=False`. +- Removed the admin-key fallback. `AGENTA_SERVICES_INTERNAL_KEY` is the only accepted internal + proof, and the API now fails startup when it is missing or still `replace-me`. +- Updated Docker Compose, Helm, Railway templates, examples, and design documentation. The key is + provided only to the API and trusted services, never runners or sandboxes. + +### #6165: managed-secret model + +- Added typed internal `SecretManager`, public `SecretManagementPolicy`, and structured + `SecretManagementDTO`. +- Stored `management` in the existing encrypted JSON payload. Existing rows remain unmanaged; + no schema migration is needed. +- Exposed only `management.policy` publicly. The backend component identity does not cross the + API boundary. +- Removed management fields from public create/update DTOs. +- Added `create_managed_secret` as the typed internal creation boundary. +- Removed the universal `allow_managed` bypass. +- Enforced managed update/delete rejection against the current row under the DAO transaction lock. +- Kept management and write-only visibility independent. + +### #6138: starter-credit seeded secret + +- Creates the seeded connection through `create_managed_secret`. +- Chooses `manager=starter-credits-bridge`, `policy=manager_only`, and `write_only=True` + explicitly. +- Separates internal proxy-origin metadata from user-facing copy. +- Refuses to mint or seed when the dedicated internal key is unusable. +- Uses Vault service-level invalidation, so internal creation invalidates the same list cache as a + public mutation. + +### #6195: provider probe + +- Rejects any stored secret carrying management metadata before credentials are inspected, merged + with overrides, or sent outbound. +- Ordinary user-owned stored-secret probes continue to work. +- Added a regression test proving a managed credential cannot be redirected through a + caller-supplied URL. + +### #6174: generated frontend contract and UX + +- Regenerated Fern from the final EE OpenAPI contract for Python and TypeScript. +- Replaced handwritten response intersections with `PublicSecretResponseDto`. +- Reads credential presence and preview from `value_status`. +- Reads management behavior from the exact generated `manager_only` policy, never an internal + component-name string. +- Uses the Fern secrets client for `POST /providers/probe`, with independent Zod validation kept + at the frontend boundary. +- Hides manager-only connections from Settings and edit drawers, while retaining them in the + shared connection atom, agent defaults, key gating, and model picker. + +## Data and compatibility + +- No database migration is introduced. +- Existing rows without `write_only` resolve as readable. +- Existing rows without `management` resolve as unmanaged. +- SSO and webhook value behavior is unchanged. +- Cache key formatting, TTL, and invalidation namespace are unchanged. +- Redis may hold canonical decrypted DTOs inside the trusted backend boundary, as approved. + +## Verification + +Local automated verification passed: + +- 2,664 OSS API unit tests after the v0.114 rebase. +- 3,005 combined OSS and EE API unit tests after the v0.114 rebase. +- 233 focused API tests covering secrets, grants, middleware, provider probe, SSO/webhook + behavior, and starter-credit seeding/client behavior. +- Standalone boundary checks after the split: 55 write-only tests on #6164 alone, 65 + write-only plus managed tests on #6165, 77 starter-credit tests on #6138, and 93 + provider-probe tests on #6195. +- 4 services tests covering credential exchange and secret mapping. +- 89 SDK tests covering write-only resolution and provider-specific environment fallback. +- 292 focused frontend unit tests across entities, entity UI, chat projection, and the OSS exhaustion flow. +- TypeScript type checks for `@agenta/entities`, `@agenta/entity-ui`, and + `@agenta/settings-ui`. +- Generated TypeScript client build. +- Generated Python client imports. +- Required frontend `pnpm lint-fix`. +- Scoped Ruff formatting and linting. +- Static Compose, Helm, and Railway template checks completed during implementation. +- The CI-only unknown-grant regression is covered: malformed grant claims now flow through the + existing invalid-token handler and remain HTTP 401 instead of being wrapped as HTTP 500. + +Railway live checks were not run because Railway is unavailable. They remain a release-QA item. + +## Accepted boundary and follow-up + +A short-lived granted Secret token reaches the runner because the runner must resolve secrets for +the authorized workload. The dedicated internal key does not reach the runner or sandbox. The +current grant is project-wide; future per-secret permissions can add resource scope to the same +grant model without changing the first release. diff --git a/docs/design/write-only-secrets/plan.md b/docs/design/write-only-secrets/plan.md new file mode 100644 index 0000000000..c70dc95bcb --- /dev/null +++ b/docs/design/write-only-secrets/plan.md @@ -0,0 +1,62 @@ +# Implementation plan + +## Slice 1: write-only contract and runtime boundary (#6164) + +Implement separate trusted and public response roles, replace key-specific metadata with `value_status`, make `write_only` creation-time immutable, restore canonical list caching followed by per-caller redaction, move update policy into a pure resolver executed under the DAO lock, centralize the runtime grant, require only the dedicated internal-service key, preserve provider-specific environment fallback, and keep SSO plus webhook secrets explicitly readable. + +Acceptance checks: + +- Cached and uncached ordinary callers receive identical redacted responses. +- Cached and uncached granted runtimes receive identical plaintext trusted responses. +- Create supports `write_only`; update cannot change it. +- Omitted update credentials are carried from the locked row without crossing secret identity. +- SSO and webhook creation remain readable even when the default is write-only. +- Generated OpenAPI and Fern types include the final public contract. +- Focused API, SDK, services, configuration, and generated-client checks pass. + +## Slice 2: structured management (#6165) + +Replace free-form public `managed_by` with an internal structured owner and a public policy projection. Remove client-settable ownership, empty-string clearing, the generic `allow_managed` bypass, and the rule that management implies write-only. Keep management metadata in encrypted JSON with unmanaged defaults for existing rows. Enforce user update and delete policy atomically while leaving transaction mechanics in the DAO. + +Acceptance checks: + +- Public create and update contracts cannot express manager ownership. +- Public responses expose only `management.policy`. +- Existing rows remain unmanaged without migration. +- A managed row rejects user update and delete under the row lock. +- A normal row keeps existing update and delete behavior. +- Management and `write_only` can vary independently in domain tests. + +## Slice 3: starter-credit owner (#6138) + +Create the starter-credit connection through an internal managed-secret path with `manager=starter-credits-bridge`, `policy=manager_only`, and explicit `write_only=True`. Keep bridge identity separate from the public product name and centralize cache invalidation at the Vault mutation boundary. + +Acceptance checks: + +- A new starter-credit row stores both policies explicitly. +- The bridge does not use an ownership bypass for update or delete. +- Internal creation invalidates the Vault list cache. +- Existing seeding, failure cleanup, and spending limits remain covered. + +## Slice 4: frontend and probe consumers (#6174 and #6195) + +Regenerate Fern after the backend contract is final. Remove handwritten response intersections and casts. Drive UI behavior from `management.policy` and `value_status`. Reject stored-credential probing for platform-managed rows before merging a caller-selected endpoint. + +Acceptance checks: + +- Frontend packages compile against generated types without manual backend-field extensions. +- Managed connection visibility and editing behavior match the approved UX. +- A managed stored secret cannot be probed. +- User-managed stored-secret probe behavior and credential-free responses remain unchanged. + +## Slice 5: validation and release handoff + +Run formatters, focused tests, broader local suites, generated-client build, frontend unit tests and type checks, and the available local end-to-end checks. Review each immediate-base diff before pushing. Update PR descriptions and signed comments with exact changes, tests, risks, and QA steps. + +Acceptance checks: + +- Every available local check is green or has a documented unrelated failure. +- Railway-only checks are listed as deferred, not reported as passed. +- Each PR contains only its intended dependency delta. +- Local and remote branch SHAs match after push. +- `implementation-report.md` and `qa.md` let a reviewer continue without this chat. diff --git a/docs/design/write-only-secrets/qa.md b/docs/design/write-only-secrets/qa.md new file mode 100644 index 0000000000..5ec25ef647 --- /dev/null +++ b/docs/design/write-only-secrets/qa.md @@ -0,0 +1,116 @@ +# Write-only and managed secrets QA + +## Preconditions + +- Deploy the full stack rooted in `release/v0.114.0`. +- Set the same non-placeholder `AGENTA_SERVICES_INTERNAL_KEY` on the API and Services. +- Confirm the key is absent from web, runner, sandbox, worker, cron, and migration containers. +- Use a project with one ordinary provider key and starter credits enabled. +- Confirm each PR checks out and imports on its declared base; in particular, #6164 must not + import `oss.src.core.secrets.managed`, which is introduced by #6165. + +## Release-blocking flows + +### 1. Configuration fails closed + +1. Start the API with the internal key absent. +2. Repeat with `AGENTA_SERVICES_INTERNAL_KEY=replace-me`. +3. Set a real matching value on API and Services and start again. + +Expected: + +- The first two starts fail with an error naming `AGENTA_SERVICES_INTERNAL_KEY`. +- The configured deployment starts. +- No log prints the configured value. + +### 2. Ordinary write-only provider key + +1. Create an OpenAI provider connection. +2. Inspect the create and list responses. +3. Reload Settings and edit only its models or display name without re-entering the key. +4. Try to submit an update that changes `write_only`. +5. Delete and recreate it if a different visibility policy is required. + +Expected: + +- The value is never returned to the browser. +- `value_status.configured=true`; preview is optional and safe. +- The unrelated edit keeps the stored key. +- The API refuses a visibility-policy change. + +### 3. Cache and invalidation + +1. List secrets twice and confirm the second request uses the shared cache. +2. Compare an ordinary caller's list with a granted runtime list from the same cached entry. +3. Create, update, and delete a secret, listing after each mutation. +4. Run a batch evaluation or repeated completion/chat calls that resolve the same project secrets. + +Expected: + +- Ordinary callers always receive redacted values. +- Granted runtimes receive plaintext needed for execution. +- Cache hits and misses produce the same caller-visible response. +- Each mutation becomes visible immediately; no stale row lasts until TTL. +- Repeated runtime resolution does not cause one database list query per call. + +### 4. Runtime and standalone fallback + +1. Run an agent with a stored write-only provider connection through the platform Services path. +2. Verify Services sends the internal header only on the access exchange. +3. Verify the runner receives a short-lived granted token, not the internal key. +4. Run the standalone SDK with the Vault value redacted and the matching provider environment + credential set. +5. Repeat without the matching environment credential and with an unrelated provider credential. + +Expected: + +- The platform run succeeds. +- The internal key never reaches the runner or sandbox. +- The matching standalone fallback succeeds. +- Missing or unrelated credentials fail clearly and are never borrowed across providers. + +### 5. SSO and webhook regression + +1. Create and edit an SSO provider without changing its client secret. +2. Test the SSO provider and complete a login. +3. Create a webhook subscription and verify a signed delivery. +4. Read the SSO and webhook secret through their existing authorized flows. + +Expected: + +- Both are stored with `write_only=False`. +- Existing edit, test, login, and signature-verification flows continue unchanged. + +### 6. Managed starter-credit connection + +1. Trigger starter-credit seeding for a new eligible project. +2. Inspect the internal Vault row and public list response. +3. Open Settings and the provider drawer. +4. Open agent creation and the model picker, then run a seeded model. +5. Attempt public update, delete, and `/providers/probe` with the managed secret ID and an + overridden URL. + +Expected: + +- The stored row has `manager=starter-credits-bridge`, `policy=manager_only`, and + `write_only=True`. +- The public response exposes only `management.policy=manager_only`. +- The row is hidden from Settings/edit surfaces. +- It remains available to agent defaults, key gating, model selection, and execution. +- Update, delete, and probe all return HTTP 409. +- No outbound probe request is made. + +## Additional checks + +- Confirm Python and TypeScript clients contain `PublicSecretResponseDto`, + `SecretValueStatus`, `SecretManagementPolicy`, and provider-probe types. +- Confirm public create/update schemas contain no manager identity or management bypass. +- Confirm cache keys retain the existing shortened project/user UUID segments. +- Confirm an existing row without `write_only` remains readable and one without `management` + remains user-managed. + +## Deferred external checks + +Railway-dependent deployment and end-to-end checks are blocked while Railway is unavailable. +Run the same release-blocking flows on the Railway preview before merge or release, and record the +preview URL, build SHA, and result in the PR QA comment. diff --git a/docs/design/write-only-secrets/research.md b/docs/design/write-only-secrets/research.md new file mode 100644 index 0000000000..69874d37ce --- /dev/null +++ b/docs/design/write-only-secrets/research.md @@ -0,0 +1,32 @@ +# Research + +## Current implementation + +- #6164 currently removes the Vault list cache, supports changing `write_only` during update, returns `has_key` and `key_preview`, and uses one DTO inheritance tree for create, update, trusted reads, and public responses. +- The current update resolver mutates the caller's DTO. The Postgres DAO owns part of the `write_only` policy while it also owns the row lock. +- The runtime proof uses `X-Agenta-Runtime-Key`. The dedicated configuration exists, but documentation and failure behavior still need a complete source walk. +- SSO and webhook paths have dedicated readable-secret behavior. Every creation path still needs an explicit-policy audit. +- #6165 stores a free-form `managed_by` string in encrypted JSON. Public request DTOs structurally accept it, routes reject it, and `allow_managed=True` bypasses ownership checks. +- #6165 derives `write_only=True` from management, although the two policies have different owners and lifecycles. +- #6138 uses one string marker for bridge identity and creates the starter-credit row with both management and write-only behavior. +- #6174 hand-maintains backend response fields around the generated Fern type. +- #6195 can load a stored plaintext credential and merge it with caller-supplied provider configuration. It needs a managed-secret guard before outbound probing. + +## Storage compatibility + +`write_only` already lives inside encrypted JSON. Structured management can also live there under `management`. Rows without either field map to readable and unmanaged defaults. This keeps the production change compatible without a schema migration. + +## Interface classification + +- Secret value fields are credential data. +- `write_only` is value-visibility policy selected at resource creation. +- `management.manager` is internal lifecycle ownership metadata. +- `management.policy` is user-mutation policy. +- `value_status` is public response metadata derived from the trusted value. +- Runtime grants are authorization policy carried in signed protocol context. + +These roles remain separate in the final models. The frontend receives public policy and status, not the internal manager identifier. + +## Workspace state + +The secrets lanes were rebased locally by another agent after the last push. Their local tips differ from the remote PR heads and require force-with-lease updates after implementation. The shared workspace also contains unrelated pi-traces work, website work, hooks, and other lanes. Only secrets-owned changes may enter these PRs. diff --git a/docs/design/write-only-secrets/review.md b/docs/design/write-only-secrets/review.md new file mode 100644 index 0000000000..dae67697dc --- /dev/null +++ b/docs/design/write-only-secrets/review.md @@ -0,0 +1,430 @@ +# Review: write-only vault secrets + +This review covers the write-only and managed-secret stack: [#6164](https://github.com/Agenta-AI/agenta/pull/6164) defines write-only secrets, [#6165](https://github.com/Agenta-AI/agenta/pull/6165) defines managed secrets, [#6138](https://github.com/Agenta-AI/agenta/pull/6138) creates the first managed secret, [#6174](https://github.com/Agenta-AI/agenta/pull/6174) is the frontend consumer, and [#6195](https://github.com/Agenta-AI/agenta/pull/6195) is the provider-probe consumer. + +The backend stack is rooted in `release/v0.114.0`; managed secrets, seeded credits, and +provider probe use the preceding backend branch as their immediate GitHub base. The frontend PR +is based directly on `release/v0.114.0` and must merge after the backend stack. References to +`main`, removed PR #6135, a later gate flip, or another root base are stale and must be +removed from PR descriptions and design documentation. + +## Owner decisions + +These are settled decisions for this review: + +- Restore the Vault list cache. Batch evaluations, completion services, chat services, and agent resolution can list secrets repeatedly. Removing the shared cache adds latency and unnecessary database load. +- Keep the existing cache-key UUID packing unchanged. Do not add a generation to the key and do not change the 12-character project/user segments in this PR. +- Cache the canonical plaintext DTO in trusted Redis, then apply caller-specific redaction after reading it. Never cache a caller-specific redacted response. +- Redis is inside the trusted backend boundary, so storing the encrypted-at-rest secret's decrypted runtime representation in this cache is accepted. +- A secret's `write_only` policy is selected when the secret is created and is immutable afterward. An update must not turn an old readable secret into a write-only secret, or the reverse. +- Keep the standalone SDK environment fallback. A redacted Vault value may still be supplied locally through the matching provider-specific environment variable. +- Keep the signed, short-lived runtime grant as the first implementation. Do not add issuer, subject, audience, or per-secret resource claims now. +- Use only `AGENTA_SERVICES_INTERNAL_KEY` to prove the internal service hop. Remove the fallback to `AGENTA_AUTH_KEY` and update every deployment example and document that configures the services/API pair. +- SSO and webhook secrets remain readable. Set `write_only=False` explicitly at their creation call sites. +- Keep `write_only` in the existing encrypted JSON payload for now. This review requires no database migration. +- Replace key-specific response metadata with a general value-status model, and keep persistence mechanics out of the DAO. +- Keep the internal manager identity separate from the public management policy. Do not make frontend behavior depend on a backend component-name string. +- Do not use an `allow_managed` boolean as an ownership credential. The current bridge never updates, releases, or deletes its row, so no bypass is needed in this release. +- Keep management and value visibility independent. Starter credits explicitly chooses both `management.policy=manager_only` and `write_only=True`; one must not be derived from the other. + +## Required changes + +### 1. Finalize the API model and regenerate Fern + +The backend contract is currently represented manually in #6174. Its frontend type intersects the generated `SecretResponseDto` with handwritten `write_only`, `managed_by`, `has_key`, and `key_preview` fields, and separately makes provider keys optional. That proves the generated client does not yet contain the contract the frontend consumes. + +Requested change: + +1. Finalize the backend DTO names and shapes described below. +2. Regenerate the Fern client in #6164 and commit the generated files. +3. Update #6174 to consume those generated types directly. +4. Remove the handwritten response intersection, key-optional intersection, and related casts from #6174. +5. Update backend OpenAPI/contract tests so a later generation cannot silently lose these fields. + +The backend PR must define the wire contract. The frontend PR should consume it, not maintain a second copy of it. + +### 2. Restore the Vault list cache without changing its key scheme + +Restore `get_cache`/`set_cache` for Vault list results, using the existing namespace, TTL, mutation invalidation, and cache-key packing. + +The correct flow is: + +```text +list request + -> load canonical SecretResponseDTO list from Redis, or load it from the DAO and cache it + -> inspect the verified caller grant + -> return plaintext DTOs to an authorized runtime, or redact them for an ordinary caller +``` + +This order matters. If an ordinary caller's redacted list is cached, a runtime may be unable to resolve its secrets. If a runtime's plaintext list is returned directly from the cache without the response-boundary check, an ordinary caller may receive plaintext. The shared entry must therefore be canonical and the caller projection must always happen after the cache read. + +Continue invalidating the list namespace after create, update, and delete. Do not add a cache generation, a second redacted cache, or request coalescing in this PR. + +#### Why the cache currently shortens UUIDs + +The 12-character packing was introduced when the shared cache helper was generalized to support optional project/user scopes and wildcard invalidation. It creates short, fixed-width `p:...:u:...` key segments, which makes keys and scan patterns compact and predictable. The introducing change does not document why the UUID suffix was chosen instead of the full UUID, so there is no confirmed stronger rationale to cite. + +That uncertainty is not a reason to change a platform-wide cache convention inside this feature. Keep the current packing. Its theoretical collision concern is separate from write-only secrets and remains outside this PR. + +### 3. Make `write_only` a creation-time policy + +The current false-to-true update path should be removed. It is surprising for a normal secret update to change whether an existing value can ever be read again, and it creates a security-sensitive cache transition that would need stronger invalidation coordination. + +Requested behavior: + +- Create accepts or derives `write_only`. +- Update does not expose `write_only` as mutable state. If compatibility requires accepting the field temporarily, reject any value different from the stored value. +- Existing records without the stored field continue to resolve as `write_only=False`. +- To change the policy, the caller deletes and recreates the secret. + +This also means a late cache refill cannot convert a newly write-only record back into a readable cached view, because readable-to-write-only conversion no longer exists. Ordinary key replacement keeps the cache's existing TTL/invalidation semantics. + +### 4. Keep SSO and webhook behavior unchanged + +Webhook creation already explicitly sets `write_only=False`. Keep that behavior. + +SSO currently relies on the default rather than stating the policy. Set `write_only=False` explicitly on every SSO `CreateSecretDTO` call path, including create-on-edit paths if present. + +This is important because the current SSO settings form reads the stored `client_secret` to prefill and validate edits. If SSO became write-only, the outward response would omit `client_secret`, and editing unrelated SSO fields would require the administrator to re-enter it. That would be a regression introduced by applying write-only behavior to SSO, not an existing SSO bug. + +With explicit `write_only=False`, there is no SSO UX change in this feature: + +- the settings API still returns `client_secret` as it does today; +- the edit form can still prefill it; +- testing and login continue to receive the plaintext value; +- no SSO frontend workaround is required. + +Add a regression test proving that SSO remains readable even if the ordinary Vault-secret default is write-only. Keep the equivalent explicit-policy test for webhooks. + +### 5. Require a dedicated internal-service key + +`X-Agenta-Runtime-Key` is acceptable for the internal proof because the normal `Authorization` header is already carrying the end user's credential. The important boundary is the credential behind the header, not the spelling of the header. + +Requested change: + +- Read the proof only from `AGENTA_SERVICES_INTERNAL_KEY` through the shared API environment configuration. +- Remove the `AGENTA_AUTH_KEY` fallback. +- Keep constant-time comparison and reject known placeholder values. +- Make missing or placeholder configuration fail clearly before write-only runtime traffic is served. Since this feature is not gated, a warning that permits a predictably broken production deployment is insufficient. +- Give the same value only to the API and trusted services that perform the exchange. Do not give it to runners or sandboxes. +- Update Docker Compose variants, Helm values/templates, Railway configuration, example env files, deployment documentation, design documentation, validation messages, and tests. + +The service exchanges the user's already-authorized credential and adds the runtime grant. The internal key proves that this exchange was requested by an Agenta service; it is not user authentication and it must not be a reusable general admin credential. + +### 6. Keep the resolver grant simple, but name and validate it centrally + +The short-term model can remain one project-wide capability: a verified, short-lived Secret token carrying the Vault-resolution grant may resolve all secrets in that token's project. + +Requested cleanup: + +- Define the grant name once in the authentication/authorization owner module. +- Validate grants against an explicit allowlist when tokens are created and consumed. +- Check the exact grant at the Vault response boundary. +- Document that a grant is additive and project-scoped. It does not replace project authorization. +- Preserve the grant only when refreshing an already verified token that already carries it. + +Do not add `iss`, `sub`, `aud`, or a general capability framework now. Those claims are useful when tokens cross more trust boundaries, have several issuers, or target several services, but they do not solve an immediate problem in this first version. + +For future per-secret permissions, extend the same concept with an action and resource scope rather than creating a new header per permission. For example: + +```json +{ + "grants": ["secrets:resolve"], + "secret_scope": ["secret-id-1", "secret-id-2"] +} +``` + +The current implementation does not need `secret_scope`. This shape records the direction so the project-wide grant does not become an accidental permanent contract. + +### 7. Keep the standalone environment fallback + +Do not remove the resolver fallback from a redacted Agenta connection to a locally supplied provider credential. This is the intended standalone/self-hosted escape hatch: the control-plane record can still provide non-secret configuration while the process supplies the secret locally. + +Keep the fallback narrow: + +- OpenAI reads an OpenAI credential, Anthropic reads an Anthropic credential, and so on. +- AWS requires its complete credential combination. +- Azure and Vertex retain their provider-specific requirements. +- A missing matching environment credential fails with a clear message. +- Logs must not contain the secret name or value. + +Add or retain tests for each supported fallback family and document the behavior in SDK examples. Do not silently borrow another provider's environment variable. + +### 8. Generalize the public secret-status model + +`has_key` and `key_preview` expose one provider-key implementation through a model that also represents custom text, JSON credentials, SSO, webhooks, and future secret kinds. Replace them with value-oriented metadata: + +```python +class SecretValueStatus(BaseModel): + configured: bool + preview: str | None = None + + +class PublicSecretResponseDTO(BaseModel): + # identity, kind, timestamps, non-secret configuration, etc. + write_only: bool + value_status: SecretValueStatus +``` + +Semantics: + +- `configured` means credential material exists for that secret kind. +- `preview` is optional and is returned only when the kind and policy allow a safe preview. +- `preview=None` does not mean unconfigured. Callers use `configured` for that decision. +- The same structure works for provider keys, named custom secrets, compound credentials, and later secret kinds. + +Separate DTOs by role: + +- `CreateSecretDTO`: requires the value appropriate for the selected kind and accepts the creation-time `write_only` policy. +- `UpdateSecretDTO`: value is optional; omission means keep the stored value. It does not mutate `write_only`. +- `SecretResponseDTO` or `ResolvedSecretDTO`: trusted internal representation with plaintext credential fields. +- `PublicSecretResponseDTO`: caller-facing representation after grant-aware redaction, with `value_status` and no plaintext for a write-only secret. + +Avoid using `VALUE_REQUIRED: ClassVar` switches to make one inheritance tree serve incompatible create, update, internal-read, and public-response roles. + +For updates, preserve the distinction between omission and an explicitly supplied empty value: + +- omitted value: keep the stored value; +- `""` for a provider credential: supplied but invalid, so reject it; +- `{}` for JSON content: explicitly supplied empty object, subject to that secret kind's validation; +- empty custom text: accept or reject according to the custom-secret product rule, not because the transport confused it with omission. + +This is an API and in-memory model cleanup only. Continue storing `write_only` in the encrypted JSON data. Existing rows without it map to `False`; no database migration is required. + +### 9. Keep transaction mechanics in the DAO and policy in the service + +The DAO should keep the row lock because an update must resolve against the current stored record atomically. It should not know the meaning of `write_only`, parse policy fields from JSON, raise write-only domain exceptions, or mutate the request DTO through a callback. + +Replace a mutation callback such as: + +```python +resolve_update(current_secret) # mutates the caller's update DTO +``` + +with a pure resolver contract such as: + +```python +resolved_update = resolve_update(current_secret, requested_update) +``` + +Responsibilities should be: + +- DAO: validate tenant/project scope, lock and load the row, map it to the internal DTO, invoke the resolver, persist the returned update, and commit. +- Service/domain resolver: enforce immutable `write_only`, carry stored values on omission, require a new credential when identity/kind changes, prevent credential extras from crossing identities, and return a validated `UpdateSecretDTO`. +- Mapper/redaction layer: translate encrypted JSON to the internal model and translate that model to the caller-facing projection. + +This keeps the transaction safe without coupling persistence code to one secret policy. It also makes the update rules unit-testable without a database. + +### 10. Close the provider-probe managed-secret hole in #6195 + +#6195 allows `/providers/probe` to load plaintext by `secret_id` and combine it with caller-supplied provider configuration. #6165 prevents users from editing or deleting `managed_by` secrets, but the probe path does not currently apply that managed-secret guard. + +As a result, a caller could ask the backend to send a managed credential to a caller-selected endpoint. The credential is not returned in the HTTP response, but it leaves the intended provider boundary. That defeats the purpose of making the managed record immutable. + +Requested short fix: reject `secret_id` probing when the loaded internal secret has a `management` owner. Keep ordinary user-owned secret probing unchanged. Add a test proving a managed secret cannot be probed against an overridden URL. + +### 11. Split internal ownership from the public managed-secret contract + +#6165 currently puts `managed_by: str | None` on `CreateSecretDTO`, `UpdateSecretDTO`, and `SecretResponseDTO`. The public routes then accept the field structurally and reject it at runtime. #6174 copies the same internal component string into `LlmProvider.managedBy` and uses its truthiness to decide whether the row appears in Settings. + +This mixes three different concerns: + +- an internal identity: which Agenta component owns the row; +- a public capability: whether the user may edit or delete the row; +- frontend presentation: whether the row is shown, locked, or hidden. + +The manager needs a real data model, not only a string marker or a public boolean. Requested short-term internal and storage model: + +```python +class SecretManager(str, Enum): + STARTER_CREDITS_BRIDGE = "starter-credits-bridge" + + +class SecretManagementPolicy(str, Enum): + MANAGER_ONLY = "manager_only" + + +class SecretManagementDTO(BaseModel): + manager: SecretManager + policy: SecretManagementPolicy = SecretManagementPolicy.MANAGER_ONLY +``` + +Keep the existing encrypted JSON representation for storage: + +```json +{ + "management": { + "manager": "starter-credits-bridge", + "policy": "manager_only" + } +} +``` + +This requires no database migration because the object still lives in the existing encrypted JSON column. Existing rows have no `management` object and therefore remain unmanaged. + +The fields deliberately answer different questions: + +- `manager`: which trusted Agenta component owns the row's lifecycle; +- `policy`: what management means for user mutation; +- `write_only`: whether an ordinary caller can read the stored value; +- runtime grants: which authenticated runtime can resolve plaintext. + +Do not infer the latter three from the manager's string. A new manager can use the same policy, and a future policy can be introduced without teaching every consumer about every manager. + +The public Fern response should expose policy, but not the internal component identity: + +```python +class PublicSecretManagementDTO(BaseModel): + policy: SecretManagementPolicy + + +class PublicSecretResponseDTO(BaseModel): + management: PublicSecretManagementDTO | None = None +``` + +The frontend can now act on the supported policy without knowing who implements it: + +```typescript +if (secret.management?.policy === "manager_only") { + // render the chosen managed-row UX +} +``` + +If product needs to display an owner later, add a separate user-facing owner such as `owner: "agenta"`; do not expose `starter-credits-bridge`. It is an implementation identifier, not product copy. + +Because these PRs have not shipped, write the final `management` object directly. A temporary mapper fallback from the branch-only `managed_by` string is unnecessary unless a deployed preview contains durable rows that must be retained. + +### 12. Remove server-controlled fields from public create and update DTOs + +`managed_by` is not user input, so it should not be present in the DTO used by public Vault routes. Rejecting it in `_refuse_client_managed_by` is safe at runtime, but the OpenAPI and generated client still advertise a field clients are never permitted to use. + +Requested change: + +- Remove `managed_by` from the public `CreateSecretDTO` and `UpdateSecretDTO`. +- Remove the empty-string-means-clear convention from `UpdateSecretDTO` and delete `resolve_managed_by` from the general update mapper. +- Add an internal service command for creation, for example: + +```python +await vault_service.create_managed_secret( + project_id=project_id, + create_secret_dto=create_secret_dto, + management=SecretManagementDTO( + manager=SecretManager.STARTER_CREDITS_BRIDGE, + policy=SecretManagementPolicy.MANAGER_ONLY, + ), +) +``` + +`create_managed_secret` sets the trusted internal `management` object. It must not derive `write_only` from management. The caller chooses value visibility explicitly at creation, and the public create path has no way to claim management. + +There is no current consumer that needs to add, change, clear, update, or delete management after creation. Do not ship speculative set/clear behavior through the general update DTO. If a real owner lifecycle is added later, give it explicit operations such as `update_managed_secret`, `release_managed_secret`, or `delete_managed_secret`, with a typed manager identity and tests for that workflow. + +### 13. Remove the universal `allow_managed` bypass + +#6165 adds `allow_managed: bool = False` to update and delete. Passing `True` permits full access to every managed row, regardless of which component owns it. It therefore means "bypass all management," not "the owning component is acting." The name and documentation overstate the security property. + +The starter-credits bridge in #6138 creates its row once and never updates, releases, or deletes it. The bypass has no production consumer in this release. + +Requested change now: + +- Remove `allow_managed` from the general update and delete methods. +- Reject updates and deletes of a managed row for every caller of those general methods. +- Remove tests for setting/clearing the marker, owner re-credentialing, and owner deletion through `allow_managed=True`. +- Keep tests proving that a managed row is immutable through every public and general service path. + +When a real owner operation is needed later, take a typed `manager: SecretManager`, compare it with the stored manager on the locked row, and expose only the operation that owner needs. A boolean bypass should not return. + +### 14. Enforce the managed guard against the locked row + +The current update flow reads the row in `VaultService`, checks `managed_by`, and only afterward asks the DAO to acquire `SELECT ... FOR UPDATE`. The resolver executed under the DAO lock handles credential carry-over but does not repeat the managed check. A row can therefore change between the ownership check and the write. + +Delete has the same check-then-act shape: the service reads and checks, then the DAO opens a separate transaction and deletes without locking and rechecking the policy. + +Even if the first bridge never changes its marker, the implementation and tests claim a general ownership invariant. That invariant must be true at the persistence boundary. + +Requested change: + +- For update, use the pure locked-row resolver from section 9. It receives the current `SecretResponseDTO`, rejects a managed row, resolves carry-over and identity rules, and returns the complete `UpdateSecretDTO` that the DAO persists. +- For delete, select the row with `FOR UPDATE`, invoke a service-owned delete authorizer against that current DTO, then delete in the same transaction. +- The DAO remains unaware of what "managed" means. It provides the lock and invokes the supplied resolver/authorizer; domain code decides whether the operation is allowed. +- Add concurrency-oriented tests proving a user update or delete cannot pass a stale unmanaged check and then mutate a managed row. + +The same locked-row structure can later compare a typed owner for an explicit internal owner operation without adding a second race-prone path. + +### 15. Update #6138 to use the managed-secret boundary, not its storage DTO + +#6138 currently constructs the general `CreateSecretDTO` with both `managed_by=ORIGIN_MARKER` and `write_only=True`. It also uses the same `ORIGIN_MARKER` value for three semantic roles: proxy audit metadata, Vault manager identity, and the user-facing header description. + +Requested change: + +- Call `create_managed_secret(..., management=SecretManagementDTO(manager=SecretManager.STARTER_CREDITS_BRIDGE, policy=SecretManagementPolicy.MANAGER_ONLY))`. +- Keep `write_only=True` explicit in #6138 because the starter-credit credential must not be returned to users. +- Remove the generic “managed implies write-only” rule from #6165. Ownership/mutation policy and value visibility are separate contracts. +- Keep a separately named proxy-origin constant for LiteLLM metadata, even if its serialized value is currently the same. +- Use human-facing copy for `header.description`, not an internal component identifier. +- Type `_create_row` as `SecretResponseDTO` instead of `Any`. +- Keep `_platform_runtime_key_configured()` as a defense before minting, but make sure `env.agenta.services_internal_key` no longer inherits `AGENTA_AUTH_KEY` from the environment model. + +The Vault list cache also has an internal writer now. Cache invalidation should live at the Vault mutation boundary, not only in HTTP routers. Move list invalidation into the create/update/delete service operation, remove duplicate route invalidations, and verify that #6138's internal managed create invalidates the same namespace. Otherwise the newly seeded connection may remain invisible to a previously cached list until the TTL expires. + +### 16. Make the managed-secret UX decision explicit + +#6138's live PR description says the seeded connection remains visible in Settings, while #6174 currently filters every `managedBy` row out of the Settings table. The code still keeps it in the shared connection atom so model selection and run gating can use it. + +This is a documentation/UX inconsistency, not a reason to expose the internal manager string. Pick the intended presentation and make the PR description, design document, and frontend test agree: + +- if hidden, test that it is absent only from the Settings table but remains available to model resolution and key-status checks; +- if visible, render it with a general managed/locked affordance and no edit/delete actions. + +In both cases, branch on the generated public `management.policy`. Do not branch on `managed_by` or `"starter-credits-bridge"`. + +## Accepted security boundary + +The runtime grant, not `AGENTA_SERVICES_INTERNAL_KEY`, can reach the runner. The internal key exists only on the trusted API/services hop and must never be forwarded. A short-lived granted Secret JWT is forwarded because the runner must call the Vault to execute the user's workload. + +That means a runner trusted to execute a workload can resolve the project's secrets. This is accepted for the first version and matches the feature's stated trust model: write-only prevents casual API/UI reads; it does not claim to protect a secret from the workload authorized to use it. Per-secret runner scope is a future tightening, not a blocker for this PR. + +## Documentation and test updates required before merge + +Update `docs/design/write-only-secrets/README.md` and the PR descriptions so they describe the final production behavior: + +- base is `release/v0.114.0`; +- no reference to removed PR #6135; +- no feature flag or later gate flip; +- Vault list cache is restored and redaction happens after canonical cache retrieval; +- `write_only` is creation-time and immutable; +- SSO and webhook explicitly use `write_only=False`; +- dedicated `AGENTA_SERVICES_INTERNAL_KEY` is mandatory and has no admin-key fallback; +- standalone provider environment fallback is supported; +- final `value_status` response model replaces `has_key`/`key_preview`; +- public `management.policy` replaces the exposed internal `managed_by` identifier; +- public create/update DTOs do not advertise server-controlled management fields; +- managed creation uses an internal typed manager command and general mutations have no boolean bypass; +- managed update/delete checks run against the row locked in the mutation transaction; +- Fern is regenerated and the frontend consumes generated types; +- runtime trust and future per-secret scope are documented accurately. + +At minimum, tests must cover: + +- cached plaintext returned to a granted runtime and redacted to a normal caller from the same cache entry; +- cache miss and cache hit produce the same public response; +- create/update/delete invalidate the list cache; +- `write_only` cannot change after creation; +- existing records without the stored field remain readable; +- SSO and webhook remain readable regardless of the ordinary-secret default; +- missing/placeholder `AGENTA_SERVICES_INTERNAL_KEY` cannot mint a runtime grant; +- `AGENTA_AUTH_KEY` is not accepted as the runtime proof; +- grant preservation only from an already verified granted token; +- standalone provider-specific fallback behavior; +- managed secrets cannot be used through the provider probe; +- public callers cannot set, clear, update, or delete management state; +- a managed row cannot be changed through a stale pre-lock update/delete check; +- the starter-credits bridge creates a typed managed, write-only row and invalidates the Vault list cache; +- the chosen hidden-or-visible managed-row UX does not remove the connection from runtime/model resolution; +- generated frontend types compile without handwritten contract augmentation. + +## Merge assessment + +The overall direction is sound, but the stack should not merge until the required changes above are incorporated. The main blockers are the uncached high-frequency list path, the duplicated frontend contract, the mutable `write_only` policy, the generic-key fallback for internal-service proof, the key-specific public model/DAO coupling, the public/internal `managed_by` coupling, the universal `allow_managed` bypass, the pre-lock managed mutation checks, and the managed-secret probe escape in the companion PR. + +No database migration, cache-generation scheme, full-UUID cache-key rewrite, general token-claims framework, removal of standalone fallback, or SSO frontend rewrite is requested in this review. diff --git a/docs/design/write-only-secrets/status.md b/docs/design/write-only-secrets/status.md new file mode 100644 index 0000000000..7971dbccf8 --- /dev/null +++ b/docs/design/write-only-secrets/status.md @@ -0,0 +1,31 @@ +# Status + +Status: implementation complete; local verification green + +Date: 2026-08-22 + +## Current work + +- Refreshed all five live PR heads, bases, and the local GitButler stacks. +- Implemented the approved write-only contract, cache boundary, runtime grant, dedicated + internal-key validation, immutable creation policy, and explicit SSO/webhook readability. +- Replaced the free-form manager marker with typed internal ownership and public + `management.policy`, enforced under the DAO row lock without a boolean bypass. +- Updated seeded credits to create an explicitly managed, explicitly write-only secret. +- Rejected probing any managed stored credential before applying caller overrides. +- Regenerated the Python and TypeScript Fern clients from the final EE OpenAPI contract. +- Updated the frontend to use Fern's `value_status`, `management.policy`, and probe method. +- Kept managed rows hidden only from Settings/edit surfaces and available to agent runtime and + model selection. +- Kept all edits outside the active pi-traces lanes and generated session/trace contracts. + +## Known constraints + +- The local secrets lane tips were rebased after their last push, so final pushes require SHA verification. +- The workspace contains unrelated uncommitted work. No unrelated file may be staged, committed, reformatted, or discarded. +- Railway-dependent checks are unavailable and are listed as deferred in `qa.md`. + +## Next acceptance point + +Push each reviewed lane, verify its remote SHA and immediate PR base, then execute the manual +release QA in `qa.md`. diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx new file mode 100644 index 0000000000..8fe47387be --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx @@ -0,0 +1,42 @@ +/** + * The failed-run callout's one conditional affordance: the "Add your key" button. + * + * It appears only for the starter-credit failure classes the user can clear themselves. Every + * other failure (including a run that carried no code at all) shows the message and nothing more, + * so a plain crash never nags the user to go buy a provider key. + * + * Rendered with `renderToStaticMarkup` rather than a testing library: the repo has no + * `@testing-library/react`, and these are static presentational assertions that do not need one. + */ +import {renderToStaticMarkup} from "react-dom/server" +import {describe, expect, it} from "vitest" + +import {RunErrorBody} from "./AgentMessage" + +const text = (node: Parameters[0]): string => { + const host = document.createElement("div") + host.innerHTML = renderToStaticMarkup(node) + return (host.textContent ?? "").replace(/\s+/g, " ").trim() +} + +describe("RunErrorBody", () => { + it("offers the own-key escape hatch when the run ran out of starter credits", () => { + const rendered = text( + , + ) + + expect(rendered).toContain("Out of starter credits.") + expect(rendered).toContain("Add your key") + }) + + it("shows only the message when the failure carried no code", () => { + const rendered = text() + + expect(rendered).toContain("Something broke.") + expect(rendered).not.toContain("Add your key") + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 051c4f8119..e97b8bcee6 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -1,6 +1,11 @@ import {memo, useEffect, useMemo, useRef, useState} from "react" -import {getMessageRunError, getMessageTraceId, getMessageUsage} from "@agenta/chat/assets" +import { + getMessageRunError, + getMessageRunErrorCode, + getMessageTraceId, + getMessageUsage, +} from "@agenta/chat/assets" import {attachmentIdForPart, fileKind, filePartName} from "@agenta/chat/assets" import { ClientToolPart, @@ -20,6 +25,7 @@ import {chatPanelMaximizedAtom} from "@agenta/chat/state" import {traceDataSummaryAtomFamily} from "@agenta/entities/loadable" import {openTraceDrawerAtom} from "@agenta/observability/traceDrawer" import {buildRenderMap} from "@agenta/playground" +import {openProviderDrawerRequestAtom} from "@agenta/shared/state" import {hasPriorElicitationDegradation} from "@agenta/shared/utils" import { ChatActionIconButton, @@ -30,6 +36,7 @@ import { turnToolbarClass, turnToolbarRevealClass, } from "@agenta/ui/components/presentational" +import {Button} from "@agenta/ui/ui" import { ArrowUUpLeft, Brain, @@ -126,6 +133,12 @@ const ReasoningPart = ({ ) } +/** Failure classes the user can clear themselves by adding their own provider key. */ +const STARTER_CREDIT_CODES = new Set([ + "starter_credits_exhausted", + "starter_credits_program_paused", +]) + /** The ONE rule driving both the clamp and the toggle — they can't disagree and hide text (#5350). */ const isBigError = (text: string) => text.length > 240 || text.split("\n").length > 4 @@ -134,11 +147,22 @@ const isBigError = (text: string) => text.length > 240 || text.split("\n").lengt * full; a big one (stacktrace) clamps behind a "Show more" that opens a scrollable block, so it * can't drown the chat. */ -const RunErrorBody = ({text, stateKey}: {text: string; stateKey: string}) => { +export const RunErrorBody = ({ + text, + stateKey, + code, +}: { + text: string + stateKey: string + /** The runner's failure class, when the turn carried one (`data-agent-error`'s `code`). */ + code?: string +}) => { const stored = useAtomValue(expandedValueAtomFamily(stateKey)) const setExpanded = useSetAtom(setExpandedAtom) + const requestProviderDrawer = useSetAtom(openProviderDrawerRequestAtom) const expanded = stored ?? false const big = isBigError(text) + const offerOwnKey = code ? STARTER_CREDIT_CODES.has(code) : false return (
@@ -169,6 +193,17 @@ const RunErrorBody = ({text, stateKey}: {text: string; stateKey: string}) => { {expanded ? "Show less" : "Show more"} )} + {offerOwnKey && ( + + )}
) @@ -314,6 +349,7 @@ const AgentMessage = ({ // FE-side from the useChat stream error (AgentChatPanel). `errorText` is derived below, once // we know whether the turn produced an answer. const runError = getMessageRunError(message) + const runErrorCode = getMessageRunErrorCode(message) const fullText = message.parts .filter((p) => p.type === "text") .map((p) => (p as {text: string}).text) @@ -555,7 +591,11 @@ const AgentMessage = ({ // Failed run: the whole bubble reads as the error (red), message inline — no nested box. // RunErrorBody shows an everyday reason in full; only a big one collapses behind "Show more". const errorBody = ( - + ) // Partial output then failure: show the content AND the error. Answer-less failure: the diff --git a/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx b/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx index 575d057fd2..68c9de809d 100644 --- a/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ConnectModelBanner.tsx @@ -1,7 +1,11 @@ +import {useEffect} from "react" + import {RevealCollapse} from "@agenta/chat/components" import type {AgentModelKeyStatus} from "@agenta/chat/hooks" import {ProviderDrawer} from "@agenta/entity-ui/secretProvider" +import {openProviderDrawerRequestAtom} from "@agenta/shared/state" import {Button} from "@agenta/ui/ui" +import {useAtom} from "jotai" import {Lock} from "lucide-react" import {useOnboardingProviderSetup} from "../hooks/useOnboardingProviderSetup" @@ -27,9 +31,19 @@ const ConnectModelBanner = ({ suppressed = false, }: AgentModelKeyStatus & {entityId: string; suppressed?: boolean}) => { const setup = useOnboardingProviderSetup(entityId, {gateActive}) + const [drawerRequested, setDrawerRequested] = useAtom(openProviderDrawerRequestAtom) const open = !suppressed && gateActive + // A remote trigger (the failed-run callout) asks for the drawer. This component owns it, so it + // opens here — the banner above stays closed unless its own gate is active. + const {openDrawer} = setup + useEffect(() => { + if (!drawerRequested) return + setDrawerRequested(false) + openDrawer() + }, [drawerRequested, setDrawerRequested, openDrawer]) + return ( <> diff --git a/web/oss/src/components/AgentChatSlice/hooks/useChatSlashCommands.tsx b/web/oss/src/components/AgentChatSlice/hooks/useChatSlashCommands.tsx index f1e7758e66..05dcdc4212 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useChatSlashCommands.tsx +++ b/web/oss/src/components/AgentChatSlice/hooks/useChatSlashCommands.tsx @@ -21,7 +21,7 @@ import { describeSkill, describeTool, harnessMetaFor, - modelLabel, + modelDisplayName, permissionPolicyLabel, permissionPolicyOptionsForSchema, permissionPolicySchema, @@ -120,7 +120,7 @@ export function useChatSlashCommands({ const currentPermission = readRunnerPermission(config) const currentPermissionLabel = permissionPolicyLabel(currentPermission ?? DEFAULT_PERMISSION_POLICY) ?? "Allow reads" - const currentModelLabel = modelLabel(capabilities, currentHarness, currentModel) ?? currentModel + const currentModelLabel = modelDisplayName(capabilities, currentHarness, currentModel) /** * Policies this agent's schema permits, and whether it declares the field at all — the drawer @@ -219,13 +219,18 @@ export function useChatSlashCommands({ (modelId: string, option?: {metadata?: Record}) => { const selection = pickerSelectionFrom(modelId, option?.metadata) const harness = selection.harness ?? currentHarness - const provider = - selection.provider ?? - (selection.slug - ? (vaultPickedProviderFamily(modelId, null, capabilities, harness) ?? - providerForModel(capabilities, harness, modelId)) - : providerForModel(capabilities, harness, modelId)) - const label = modelLabel(capabilities, harness, modelId) ?? modelId + // A row naming a connection carries what to persist in its own metadata, but the two + // sources spell it differently: a connection row the already-resolved family, the + // fallback catalog menu the connection's raw KIND. Run a non-empty one through the + // drawer's resolver (a deployment kind is never a valid provider); an EMPTY one is the + // row saying the slug is the whole route (a custom OpenAI-compatible connection), so + // never re-derive a family behind its back. + const provider = selection.slug + ? selection.provider + ? vaultPickedProviderFamily(modelId, selection.provider, capabilities, harness) + : null + : (selection.provider ?? providerForModel(capabilities, harness, modelId)) + const label = modelDisplayName(capabilities, harness, modelId) const base = selection.harness && selection.harness !== currentHarness ? (withHarnessKind(config, selection.harness) ?? config) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useOnboardingProviderSetup.ts b/web/oss/src/components/AgentChatSlice/hooks/useOnboardingProviderSetup.ts index 383677a497..fd009d2034 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useOnboardingProviderSetup.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useOnboardingProviderSetup.ts @@ -24,7 +24,7 @@ import { import {harnessCapabilitiesAtomFamily, workflowMolecule} from "@agenta/entities/workflow" import { buildConnectionPickerRows, - modelLabel, + modelDisplayName, providerForModel, readHarnessKind, selectableHarnesses, @@ -138,7 +138,7 @@ export function useOnboardingProviderSetup( }) if (!next) return setConfiguration(entityId, next) - const label = modelLabel(capabilities, harness, selection.modelId) ?? selection.modelId + const label = modelDisplayName(capabilities, harness, selection.modelId) raiseDraftSignal({ revisionId: entityId, sectionKeys: ["model-harness"], diff --git a/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx index 4a163616c6..80ac13111a 100644 --- a/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx +++ b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx @@ -52,6 +52,9 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM const [saving, setSaving] = useState(false) const isEditing = !!selectedSecret?.id + // A write-only record returns no content. The form is then replace-only: nothing is prefilled, + // and leaving it untouched keeps whatever is stored. + const valueHidden = isEditing && selectedSecret?.writeOnly === true useEffect(() => { if (!open) return @@ -64,7 +67,11 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM setFormat(selectedSecret.format) if (selectedSecret.format === CustomSecretFormat.Json) { setTextValue("") - setKvRows(objectToRows(selectedSecret.content)) + setKvRows( + selectedSecret.content == null + ? [{key: "", value: ""}] + : objectToRows(selectedSecret.content), + ) } else { setTextValue( typeof selectedSecret.content === "string" ? selectedSecret.content : "", @@ -136,10 +143,15 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM return true } - const buildContent = (): CustomSecretContent | null => { + /** The content to send: `undefined` keeps the stored value, `null` means the form is invalid. */ + const buildContent = (): CustomSecretContent | null | undefined => { if (format === CustomSecretFormat.Text) { + if (valueHidden && !textValue) return undefined return textValue } + if (valueHidden && jsonView === "grid" && !kvRows.some((row) => row.key.trim())) { + return undefined + } if (jsonView === "json" && !syncJsonToRows()) { return null } @@ -269,7 +281,10 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM
- Content + + {/* TODO(copy: owner) */} + {valueHidden ? "Replace content" : "Content"} + {format === CustomSecretFormat.Json && jsonView === "grid" && hasDuplicateKeys && ( @@ -293,6 +308,15 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM )}
+ {valueHidden ? ( + + {/* TODO(copy: owner) */} + {selectedSecret?.keyPreview + ? `Value configured (${selectedSecret.keyPreview}). Leave blank to keep it.` + : "Value configured. Leave blank to keep it."} + + ) : null} + {format === CustomSecretFormat.Text ? ( { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__listSecrets(requestOptions)); } private async __listSecrets( requestOptions?: SecretsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -64,7 +64,7 @@ export class SecretsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SecretResponseDto[], rawResponse: _response.rawResponse }; + return { data: _response.body as AgentaApi.PublicSecretResponseDto[], rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -91,9 +91,7 @@ export class SecretsClient { * kind: "provider_key", * data: { * kind: "openai", - * provider: { - * key: "key" - * } + * provider: {} * } * } * }) @@ -101,14 +99,14 @@ export class SecretsClient { public createSecret( request: AgentaApi.CreateSecretDto, requestOptions?: SecretsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__createSecret(request, requestOptions)); } private async __createSecret( request: AgentaApi.CreateSecretDto, requestOptions?: SecretsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -136,7 +134,7 @@ export class SecretsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SecretResponseDto, rawResponse: _response.rawResponse }; + return { data: _response.body as AgentaApi.PublicSecretResponseDto, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -172,14 +170,14 @@ export class SecretsClient { public readSecret( request: AgentaApi.ReadSecretRequest, requestOptions?: SecretsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__readSecret(request, requestOptions)); } private async __readSecret( request: AgentaApi.ReadSecretRequest, requestOptions?: SecretsClient.RequestOptions, - ): Promise> { + ): Promise> { const { secret_id_or_slug: secretIdOrSlug } = request; const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( @@ -205,7 +203,7 @@ export class SecretsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SecretResponseDto, rawResponse: _response.rawResponse }; + return { data: _response.body as AgentaApi.PublicSecretResponseDto, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -241,14 +239,14 @@ export class SecretsClient { public updateSecret( request: AgentaApi.UpdateSecretDto, requestOptions?: SecretsClient.RequestOptions, - ): core.HttpResponsePromise { + ): core.HttpResponsePromise { return core.HttpResponsePromise.fromPromise(this.__updateSecret(request, requestOptions)); } private async __updateSecret( request: AgentaApi.UpdateSecretDto, requestOptions?: SecretsClient.RequestOptions, - ): Promise> { + ): Promise> { const { secret_id: secretId, ..._body } = request; const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( @@ -277,7 +275,7 @@ export class SecretsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: _response.body as AgentaApi.SecretResponseDto, rawResponse: _response.rawResponse }; + return { data: _response.body as AgentaApi.PublicSecretResponseDto, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -367,4 +365,73 @@ export class SecretsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "DELETE", "/secrets/{secret_id}"); } + + /** + * @param {AgentaApi.ProbeProviderRequest} request + * @param {SecretsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.secrets.probeProvider() + */ + public probeProvider( + request: AgentaApi.ProbeProviderRequest = {}, + requestOptions?: SecretsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__probeProvider(request, requestOptions)); + } + + private async __probeProvider( + request: AgentaApi.ProbeProviderRequest = {}, + requestOptions?: SecretsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + "providers/probe", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: request, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.ProbeProviderResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/providers/probe"); + } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/CreateSecretDto.ts b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/CreateSecretDto.ts index f94bf69e75..08e717dcf9 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/CreateSecretDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/CreateSecretDto.ts @@ -10,9 +10,7 @@ import type * as AgentaApi from "../../../../index.js"; * kind: "provider_key", * data: { * kind: "openai", - * provider: { - * key: "key" - * } + * provider: {} * } * } * } @@ -21,4 +19,5 @@ export interface CreateSecretDto { slug?: string | null; header: AgentaApi.Header; secret: AgentaApi.SecretDto; + write_only?: boolean; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/ProbeProviderRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/ProbeProviderRequest.ts new file mode 100644 index 0000000000..62127baf7c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/ProbeProviderRequest.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * {} + */ +export interface ProbeProviderRequest { + /** Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it. */ + kind?: string | null; + provider?: AgentaApi.ProviderCredentials; + /** Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones. */ + secret_id?: string | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/UpdateSecretDto.ts b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/UpdateSecretDto.ts index 0fc319f396..612f076814 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/UpdateSecretDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/UpdateSecretDto.ts @@ -11,5 +11,5 @@ import type * as AgentaApi from "../../../../index.js"; export interface UpdateSecretDto { secret_id: string; header?: AgentaApi.Header | null; - secret?: AgentaApi.SecretDto | null; + secret?: AgentaApi.UpdateSecretPayloadDto | null; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/index.ts index e3bf097bb9..0ad860f4c8 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/secrets/client/requests/index.ts @@ -1,4 +1,5 @@ export type { CreateSecretDto } from "./CreateSecretDto.js"; export type { DeleteSecretRequest } from "./DeleteSecretRequest.js"; +export type { ProbeProviderRequest } from "./ProbeProviderRequest.js"; export type { ReadSecretRequest } from "./ReadSecretRequest.js"; export type { UpdateSecretDto } from "./UpdateSecretDto.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/CredentialResult.ts b/web/packages/agenta-api-client/src/generated/api/types/CredentialResult.ts new file mode 100644 index 0000000000..2881700c1f --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/CredentialResult.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface CredentialResult { + status: AgentaApi.CredentialStatus; + message: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/CredentialStatus.ts b/web/packages/agenta-api-client/src/generated/api/types/CredentialStatus.ts new file mode 100644 index 0000000000..fa87ff8215 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/CredentialStatus.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * Did the provider accept this credential? + * + * `unknown` is an honest answer, not a failure: it means Agenta found no free, + * read-only endpoint that proves the credential works. A public catalog endpoint + * answering successfully never raises the status above `unknown`. + */ +export const CredentialStatus = { + Valid: "valid", + Invalid: "invalid", + Unknown: "unknown", +} as const; +export type CredentialStatus = (typeof CredentialStatus)[keyof typeof CredentialStatus]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/CustomProviderDto.ts b/web/packages/agenta-api-client/src/generated/api/types/CustomProviderDto.ts index 8795d3d249..d4c285a52c 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/CustomProviderDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/CustomProviderDto.ts @@ -6,6 +6,7 @@ export interface CustomProviderDto { kind: AgentaApi.CustomProviderKind; provider: AgentaApi.CustomProviderSettingsDto; models: AgentaApi.CustomModelSettingsDto[]; + harnesses?: (string[] | null) | undefined; provider_slug?: (string | null) | undefined; model_keys?: (string[] | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts b/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts index 379709fb54..5c8b02bb27 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts @@ -4,7 +4,7 @@ import type * as AgentaApi from "../index.js"; export interface CustomSecretSettingsDto { format: AgentaApi.CustomSecretFormat; - content: CustomSecretSettingsDto.Content; + content?: (CustomSecretSettingsDto.Content | null) | undefined; } export namespace CustomSecretSettingsDto { diff --git a/web/packages/agenta-api-client/src/generated/api/types/DiscoveryResult.ts b/web/packages/agenta-api-client/src/generated/api/types/DiscoveryResult.ts new file mode 100644 index 0000000000..69803e5884 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/DiscoveryResult.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface DiscoveryResult { + status: AgentaApi.DiscoveryStatus; + models?: string[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/DiscoveryStatus.ts b/web/packages/agenta-api-client/src/generated/api/types/DiscoveryStatus.ts new file mode 100644 index 0000000000..c1a5e48c0b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/DiscoveryStatus.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * Which model identifiers did the provider return? + * + * `unsupported` means the provider offers no model-list endpoint; `failed` means one + * exists but this attempt did not get an answer. Either way the caller keeps the + * shipped catalog rather than narrowing the user's model choice. + */ +export const DiscoveryStatus = { + Fetched: "fetched", + Unsupported: "unsupported", + Failed: "failed", +} as const; +export type DiscoveryStatus = (typeof DiscoveryStatus)[keyof typeof DiscoveryStatus]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/ProbeProviderResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/ProbeProviderResponse.ts new file mode 100644 index 0000000000..f9ea6b4704 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/ProbeProviderResponse.ts @@ -0,0 +1,9 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface ProbeProviderResponse { + credential: AgentaApi.CredentialResult; + discovery: AgentaApi.DiscoveryResult; + fetched_at: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/ProviderCredentials.ts b/web/packages/agenta-api-client/src/generated/api/types/ProviderCredentials.ts new file mode 100644 index 0000000000..f16b046c4b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/ProviderCredentials.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * Credentials in transit only. Never persisted here, never logged, never echoed. + * + * `key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line + * or traceback that carries this object cannot print the credential. Unwrap the key with + * `.get_secret_value()` at the point it is put on the wire, never earlier. + */ +export interface ProviderCredentials { + key?: (string | null) | undefined; + url?: (string | null) | undefined; + version?: (string | null) | undefined; + extras?: (Record | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PublicSecretManagementDto.ts b/web/packages/agenta-api-client/src/generated/api/types/PublicSecretManagementDto.ts new file mode 100644 index 0000000000..5334190b39 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PublicSecretManagementDto.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PublicSecretManagementDto { + policy: AgentaApi.SecretManagementPolicy; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SecretResponseDto.ts b/web/packages/agenta-api-client/src/generated/api/types/PublicSecretResponseDto.ts similarity index 59% rename from web/packages/agenta-api-client/src/generated/api/types/SecretResponseDto.ts rename to web/packages/agenta-api-client/src/generated/api/types/PublicSecretResponseDto.ts index c7bcc56018..56f6b0e9ee 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SecretResponseDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/PublicSecretResponseDto.ts @@ -2,16 +2,22 @@ import type * as AgentaApi from "../index.js"; -export interface SecretResponseDto { - kind: AgentaApi.SecretKind; - data: SecretResponseDto.Data; +/** + * Caller-facing representation after grant-aware value projection. + */ +export interface PublicSecretResponseDto { slug?: (string | null) | undefined; id?: (string | null) | undefined; + kind: AgentaApi.SecretKind; + data: PublicSecretResponseDto.Data; header: AgentaApi.Header; lifecycle?: (AgentaApi.LegacyLifecycleDto | null) | undefined; + write_only?: boolean | undefined; + management?: (AgentaApi.PublicSecretManagementDto | null) | undefined; + value_status: AgentaApi.SecretValueStatus; } -export namespace SecretResponseDto { +export namespace PublicSecretResponseDto { export type Data = | AgentaApi.StandardProviderDto | AgentaApi.CustomProviderDto diff --git a/web/packages/agenta-api-client/src/generated/api/types/SecretDto.ts b/web/packages/agenta-api-client/src/generated/api/types/SecretDto.ts index fb05624bde..ff0202a998 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SecretDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SecretDto.ts @@ -2,6 +2,9 @@ import type * as AgentaApi from "../index.js"; +/** + * Create-time secret payload. Required credential fields must be present. + */ export interface SecretDto { kind: AgentaApi.SecretKind; data: SecretDto.Data; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SecretManagementPolicy.ts b/web/packages/agenta-api-client/src/generated/api/types/SecretManagementPolicy.ts new file mode 100644 index 0000000000..ab8520a563 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SecretManagementPolicy.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export const SecretManagementPolicy = { + ManagerOnly: "manager_only", +} as const; +export type SecretManagementPolicy = (typeof SecretManagementPolicy)[keyof typeof SecretManagementPolicy]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SecretValueStatus.ts b/web/packages/agenta-api-client/src/generated/api/types/SecretValueStatus.ts new file mode 100644 index 0000000000..973b108846 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SecretValueStatus.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SecretValueStatus { + configured: boolean; + preview?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SsoProviderSettingsDto.ts b/web/packages/agenta-api-client/src/generated/api/types/SsoProviderSettingsDto.ts index 4dbc8711e0..b3abc8eb5c 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SsoProviderSettingsDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SsoProviderSettingsDto.ts @@ -2,7 +2,7 @@ export interface SsoProviderSettingsDto { client_id: string; - client_secret: string; + client_secret?: (string | null) | undefined; issuer_url: string; scopes: string[]; extra?: Record | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/StandardProviderDto.ts b/web/packages/agenta-api-client/src/generated/api/types/StandardProviderDto.ts index 17d32f1446..8d33a0c542 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/StandardProviderDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/StandardProviderDto.ts @@ -5,4 +5,6 @@ import type * as AgentaApi from "../index.js"; export interface StandardProviderDto { kind: AgentaApi.StandardProviderKind; provider: AgentaApi.StandardProviderSettingsDto; + models?: (AgentaApi.CustomModelSettingsDto[] | null) | undefined; + harnesses?: (string[] | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/StandardProviderSettingsDto.ts b/web/packages/agenta-api-client/src/generated/api/types/StandardProviderSettingsDto.ts index ad88a45c43..ba3707549a 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/StandardProviderSettingsDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/StandardProviderSettingsDto.ts @@ -1,5 +1,5 @@ // This file was auto-generated by Fern from our API Definition. export interface StandardProviderSettingsDto { - key: string; + key?: (string | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/UpdateSecretPayloadDto.ts b/web/packages/agenta-api-client/src/generated/api/types/UpdateSecretPayloadDto.ts new file mode 100644 index 0000000000..eb5b4390dc --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/UpdateSecretPayloadDto.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +/** + * Update-time payload. Omitted credential fields keep their stored values. + */ +export interface UpdateSecretPayloadDto { + kind: AgentaApi.SecretKind; + data: UpdateSecretPayloadDto.Data; +} + +export namespace UpdateSecretPayloadDto { + export type Data = + | AgentaApi.StandardProviderDto + | AgentaApi.CustomProviderDto + | AgentaApi.SsoProviderDto + | AgentaApi.WebhookProviderDto + | AgentaApi.CustomSecretDto; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/WebhookProviderSettingsDto.ts b/web/packages/agenta-api-client/src/generated/api/types/WebhookProviderSettingsDto.ts index 560d48ae5f..491806dbdf 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/WebhookProviderSettingsDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/WebhookProviderSettingsDto.ts @@ -1,5 +1,5 @@ // This file was auto-generated by Fern from our API Definition. export interface WebhookProviderSettingsDto { - key: string; + key?: (string | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index bdc2c2e0fc..d9b7cbf691 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -110,6 +110,8 @@ export * from "./Condition.js"; export * from "./ConfigResponseModel.js"; export * from "./ConnectAffordance.js"; export * from "./ConnectionRequirement.js"; +export * from "./CredentialResult.js"; +export * from "./CredentialStatus.js"; export * from "./CustomModelSettingsDto.js"; export * from "./CustomProviderDto.js"; export * from "./CustomProviderKind.js"; @@ -123,6 +125,8 @@ export * from "./DiscoveredTool.js"; export * from "./DiscoveredTriggerAlternative.js"; export * from "./DiscoveredTriggerEvent.js"; export * from "./DiscoverResponse.js"; +export * from "./DiscoveryResult.js"; +export * from "./DiscoveryStatus.js"; export * from "./EntityRef.js"; export * from "./Environment.js"; export * from "./EnvironmentCreate.js"; @@ -323,8 +327,12 @@ export * from "./OTelTracingRequest.js"; export * from "./OTelTracingResponse.js"; export * from "./Permission.js"; export * from "./PlaygroundBuildKitContext.js"; +export * from "./ProbeProviderResponse.js"; export * from "./ProjectsResponse.js"; +export * from "./ProviderCredentials.js"; export * from "./PublicMountCreate.js"; +export * from "./PublicSecretManagementDto.js"; +export * from "./PublicSecretResponseDto.js"; export * from "./QueriesResponse.js"; export * from "./Query.js"; export * from "./QueryCreate.js"; @@ -358,7 +366,8 @@ export * from "./ResolvedTool.js"; export * from "./RetrievalInfo.js"; export * from "./SecretDto.js"; export * from "./SecretKind.js"; -export * from "./SecretResponseDto.js"; +export * from "./SecretManagementPolicy.js"; +export * from "./SecretValueStatus.js"; export * from "./Selector.js"; export * from "./SessionAttachment.js"; export * from "./SessionAttachmentResponse.js"; @@ -621,6 +630,7 @@ export * from "./TriggerSubscriptionFlags.js"; export * from "./TriggerSubscriptionQuery.js"; export * from "./TriggerSubscriptionResponse.js"; export * from "./TriggerSubscriptionsResponse.js"; +export * from "./UpdateSecretPayloadDto.js"; export * from "./UserIdsResponse.js"; export * from "./ValidationError.js"; export * from "./WebhookDeliveriesResponse.js"; diff --git a/web/packages/agenta-chat/src/assets/trace.ts b/web/packages/agenta-chat/src/assets/trace.ts index 16387fb359..6fe9d6b11e 100644 --- a/web/packages/agenta-chat/src/assets/trace.ts +++ b/web/packages/agenta-chat/src/assets/trace.ts @@ -46,6 +46,24 @@ export const getMessageRunError = (message: UIMessage): string | undefined => { return typeof msg === "string" && msg.trim() ? msg : undefined } +/** + * The failure CLASS behind a run error — the runner's stable `code` (never a display string), so a + * callout can offer a purposeful action instead of parsing the message. Read from + * `metadata.runError.code` (replayed transcripts stamp it there) or the live stream's + * `data-agent-error` part. `ParsedRunError.code` is an HTTP-ish NUMBER on the same field, so only a + * string counts here. + */ +export const getMessageRunErrorCode = (message: UIMessage): string | undefined => { + const metaCode = (message.metadata as {runError?: {code?: unknown}} | undefined)?.runError?.code + if (typeof metaCode === "string" && metaCode.trim()) return metaCode + + const errorPart = message.parts.find((p) => p.type === "data-agent-error") as + | {type: "data-agent-error"; data?: {code?: unknown}} + | undefined + const partCode = errorPart?.data?.code + return typeof partCode === "string" && partCode.trim() ? partCode : undefined +} + /** Token/cost fields in `ExecutionMetricsDisplay`'s shape. */ export interface MessageUsageMetrics { promptTokens?: number diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index 9c6ed62305..d657628a6b 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -69,6 +69,8 @@ interface DraftMessage { /** The turn's persisted `error` event — replayed through the same `metadata.runError` channel * the live stream stamps, so a failure renders as the error bubble, not as body text. */ runError?: string + /** That error's stable failure class (`error.code`), so a reload keeps the callout's action. */ + runErrorCode?: string } interface TranscriptIndex { @@ -502,7 +504,12 @@ function applyEvent( // the same red bubble as a live one. First non-empty wins — a cascading later error // must not mask the root cause. const message = str(payload.message).trim() - if (message && !draft.runError) draft.runError = message + if (message && !draft.runError) { + draft.runError = message + // Code rides the same event; an older runner omits it (protocol.ts `error.code`). + if (typeof payload.code === "string" && payload.code.trim()) + draft.runErrorCode = payload.code + } return } case "usage": { @@ -612,7 +619,11 @@ export function transcriptToMessages( if (d.traceId) metadata.traceId = d.traceId if (d.usage) metadata.usage = d.usage if (d.paused) metadata.paused = true - if (d.runError) metadata.runError = {message: d.runError} + if (d.runError) + metadata.runError = { + message: d.runError, + ...(d.runErrorCode ? {code: d.runErrorCode} : {}), + } return { id: d.id, role: d.role, diff --git a/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts b/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts index dd1382dcd7..d3d4a83d55 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts @@ -2,6 +2,7 @@ import {useMemo} from "react" import { + hasStoredKey, providerConnectionsAtom, providerKeySetupDoneAtom, standardSecretsAtom, @@ -131,7 +132,7 @@ export function useAgentModelKeyStatus(entityId: string): AgentModelKeyStatus { provider, model, harness, - hasKey: !!providerEntry?.key, + hasKey: hasStoredKey(providerEntry), providerEntry, loading, gateActive, diff --git a/web/packages/agenta-chat/src/model/turnStatus.ts b/web/packages/agenta-chat/src/model/turnStatus.ts index 9de69fe67d..c5fb1374ce 100644 --- a/web/packages/agenta-chat/src/model/turnStatus.ts +++ b/web/packages/agenta-chat/src/model/turnStatus.ts @@ -7,6 +7,8 @@ export interface TurnStatusContext { isStreaming: boolean traceError?: string | null runError?: string | null + /** Stable failure class from the runner (`data-agent-error`'s `code`), never a display string. */ + errorCode?: string | null } export interface TurnStatus { @@ -15,6 +17,8 @@ export interface TurnStatus { hasContent: boolean noResponse: boolean errorText: string | null + /** The failure class, only while an error is actually shown. */ + errorCode: string | null showError: boolean isError: boolean } @@ -29,7 +33,7 @@ export interface TurnStatus { */ export const deriveTurnStatus = ( message: UIMessage, - {isUser, isStreaming, traceError, runError}: TurnStatusContext, + {isUser, isStreaming, traceError, runError, errorCode}: TurnStatusContext, ): TurnStatus => { // "Answer" = anything the user is meant to read as a reply (text / tool / file / source). // Reasoning alone is NOT an answer — a turn that only thought hasn't responded. @@ -64,6 +68,17 @@ export const deriveTurnStatus = ( // A settled no-answer turn whose trace recorded an error → render the bubble itself as a // failure (red), with the message inline — not a nested alert box. const isError = noResponse && showError + // A failure class with no surfaced failure is meaningless — a consumer keys UI off it. + const shownErrorCode = showError ? (errorCode ?? null) : null - return {hasAnswer, hasReasoning, hasContent, noResponse, errorText, showError, isError} + return { + hasAnswer, + hasReasoning, + hasContent, + noResponse, + errorText, + errorCode: shownErrorCode, + showError, + isError, + } } diff --git a/web/packages/agenta-chat/src/model/turnViewModel.ts b/web/packages/agenta-chat/src/model/turnViewModel.ts index 70f948da7a..eef3a3453b 100644 --- a/web/packages/agenta-chat/src/model/turnViewModel.ts +++ b/web/packages/agenta-chat/src/model/turnViewModel.ts @@ -5,7 +5,7 @@ // rows. Pure so the conversation hook can memoize the whole list per commit. import type {ToolUIPart, UIMessage} from "ai" -import {getMessageRunError, getMessageTraceId} from "../assets/trace" +import {getMessageRunError, getMessageRunErrorCode, getMessageTraceId} from "../assets/trace" import {getTurnGrouping} from "./grouping" import {isEmptyAssistantTurn, isToolPart} from "./parts" @@ -95,6 +95,7 @@ export const buildTurnViewModels = ( isUser, isStreaming: isStreamingTurn, runError: getMessageRunError(message) ?? null, + errorCode: getMessageRunErrorCode(message) ?? null, traceError: null, }) const precededByEmptyAssistant = index > 0 && isEmptyAssistantTurn(messages[index - 1]) diff --git a/web/packages/agenta-chat/tests/unit/assets/trace.test.ts b/web/packages/agenta-chat/tests/unit/assets/trace.test.ts index 95a8b7b762..1764c93e39 100644 --- a/web/packages/agenta-chat/tests/unit/assets/trace.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/trace.test.ts @@ -1,7 +1,12 @@ import type {UIMessage} from "ai" import {describe, expect, it} from "vitest" -import {getMessageRunError, getMessageTraceId, getMessageUsage} from "../../../src/assets/trace" +import { + getMessageRunError, + getMessageRunErrorCode, + getMessageTraceId, + getMessageUsage, +} from "../../../src/assets/trace" describe("getMessageTraceId", () => { it("prefers message.metadata.traceId", () => { @@ -65,6 +70,57 @@ describe("getMessageRunError", () => { }) }) +describe("getMessageRunErrorCode", () => { + it("reads the code off the run-error metadata", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {runError: {message: "boom", code: "starter_credits_exhausted"}}, + parts: [], + } as unknown as UIMessage + expect(getMessageRunErrorCode(message)).toBe("starter_credits_exhausted") + }) + + it("falls back to the live data-agent-error part", () => { + const message = { + id: "m1", + role: "assistant", + parts: [{type: "data-agent-error", data: {code: "rate_limited", errorText: "boom"}}], + } as unknown as UIMessage + expect(getMessageRunErrorCode(message)).toBe("rate_limited") + }) + + it("prefers the metadata code over the part's", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {runError: {message: "boom", code: "starter_credits_program_paused"}}, + parts: [{type: "data-agent-error", data: {code: "runner_error"}}], + } as unknown as UIMessage + expect(getMessageRunErrorCode(message)).toBe("starter_credits_program_paused") + }) + + it("ignores the numeric ParsedRunError code", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {runError: {message: "boom", code: 402}}, + parts: [], + } as unknown as UIMessage + expect(getMessageRunErrorCode(message)).toBeUndefined() + }) + + it("returns undefined when nothing carries a code", () => { + const message = { + id: "m1", + role: "assistant", + metadata: {runError: {message: "boom"}}, + parts: [{type: "text", text: "hi"}], + } as unknown as UIMessage + expect(getMessageRunErrorCode(message)).toBeUndefined() + }) +}) + describe("getMessageUsage", () => { it("maps the service's usage fields to the metrics-display names", () => { const message = { diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 06d2e372eb..c51a92befd 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -983,3 +983,24 @@ describe("transcriptToMessages MCP argument wrapper", () => { }) }) }) + +/** The durable `error` event carries the failure class (`protocol.ts` `error.code`); replay must + * keep it or a reload loses the callout's action. */ +describe("transcriptToMessages run-error code", () => { + const runErrorOf = (payload: Record): unknown => + ( + transcriptToMessages([record("r-error", payload)])?.[0].metadata as + | Record + | undefined + )?.runError + + it("replays the code next to the message", () => { + expect( + runErrorOf({type: "error", message: "boom", code: "starter_credits_exhausted"}), + ).toEqual({message: "boom", code: "starter_credits_exhausted"}) + }) + + it("omits the code when an older runner sends none", () => { + expect(runErrorOf({type: "error", message: "boom"})).toEqual({message: "boom"}) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts b/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts index db3add3ff2..f8a47c689c 100644 --- a/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/turnStatus.test.ts @@ -72,4 +72,43 @@ describe("deriveTurnStatus", () => { expect(status.showError).toBe(false) expect(status.isError).toBe(false) }) + + it("carries the failure class alongside a shown error", () => { + const message = {id: "a1", role: "assistant", parts: []} as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: false, + runError: "out of credits", + errorCode: "starter_credits_exhausted", + }) + expect(status.showError).toBe(true) + expect(status.errorCode).toBe("starter_credits_exhausted") + }) + + it("drops the failure class while the turn is still streaming", () => { + const message = {id: "a1", role: "assistant", parts: []} as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: true, + runError: "out of credits", + errorCode: "starter_credits_exhausted", + }) + expect(status.showError).toBe(false) + expect(status.errorCode).toBeNull() + }) + + it("drops the failure class when no error is surfaced", () => { + const message = { + id: "a1", + role: "assistant", + parts: [{type: "text", text: "all good"}], + } as unknown as UIMessage + const status = deriveTurnStatus(message, { + isUser: false, + isStreaming: false, + errorCode: "starter_credits_exhausted", + }) + expect(status.showError).toBe(false) + expect(status.errorCode).toBeNull() + }) }) diff --git a/web/packages/agenta-entities/src/secret/api/probe.ts b/web/packages/agenta-entities/src/secret/api/probe.ts index a3175e34e9..2c34e4d2e0 100644 --- a/web/packages/agenta-entities/src/secret/api/probe.ts +++ b/web/packages/agenta-entities/src/secret/api/probe.ts @@ -5,28 +5,37 @@ * They are separate statuses because a public catalog can answer without proving a key, and an * OpenAI-compatible server may implement generation but not `GET /models`. * - * Called through the shared axios instance rather than the Fern client: the endpoint is new and - * the generated client has not been regenerated for it yet. Move this to - * `getProvidersClient().probeProvider(...)` when it has. + * Called through the Fern-generated secrets client, so the request and response stay aligned with + * the backend OpenAPI contract. * * The credential is spent on one outbound read and never stored — this request is the only place * the card's typed key leaves the browser before Done. */ -import {axios, getAgentaApiUrl} from "@agenta/shared/api" +import {AgentaApi} from "@agentaai/api-client" import {z} from "zod" import {safeParseWithLogging} from "../../shared" +import {getSecretsClient, projectScopedRequest} from "./client" + /** Did the provider accept the credential? `unknown` means Agenta had no free way to find out. */ -export const CREDENTIAL_STATUSES = ["valid", "invalid", "unknown"] as const -export type CredentialStatus = (typeof CREDENTIAL_STATUSES)[number] +export const CREDENTIAL_STATUSES = [ + AgentaApi.CredentialStatus.Valid, + AgentaApi.CredentialStatus.Invalid, + AgentaApi.CredentialStatus.Unknown, +] as const +export type CredentialStatus = AgentaApi.CredentialStatus /** Which model ids came back. `unsupported` means the provider offers no list at all. */ -export const DISCOVERY_STATUSES = ["fetched", "unsupported", "failed"] as const -export type DiscoveryStatus = (typeof DISCOVERY_STATUSES)[number] +export const DISCOVERY_STATUSES = [ + AgentaApi.DiscoveryStatus.Fetched, + AgentaApi.DiscoveryStatus.Unsupported, + AgentaApi.DiscoveryStatus.Failed, +] as const +export type DiscoveryStatus = AgentaApi.DiscoveryStatus -const probeResponseSchema = z.object({ +const probeResponseSchema: z.ZodType = z.object({ credential: z.object({ status: z.enum(CREDENTIAL_STATUSES), message: z.string(), @@ -38,37 +47,42 @@ const probeResponseSchema = z.object({ fetched_at: z.string(), }) -export type ProbeProviderResponse = z.infer +export type ProbeProviderResponse = AgentaApi.ProbeProviderResponse /** The credential shape the probe adapters read — the same vocabulary the vault stores. */ -export interface ProbeProviderCredentials { - key?: string - url?: string - version?: string - extras?: Record -} +export type ProbeProviderCredentials = AgentaApi.ProviderCredentials /** * Test a credential and fetch the provider's model list. * * Probe outcomes come back as HTTP 200 with a status inside, so a rejected key is a normal - * answer, not an exception. Returns `null` only when the payload fails the boundary schema; a - * transport failure still rejects so the card can tell "provider said no" from "we never asked". + * answer, not an exception. Returns `null` only when the independent boundary schema detects a + * drifted payload; a transport failure still rejects. + * + * `secretId` names a stored vault row for the server to resolve credentials from, which is how a + * write-only connection is testable at all — see `probeRequestFor`, which decides when to send it. + * Anything the caller also puts in `provider` overrides what the server resolved. */ export const probeProvider = async ({ projectId, kind, provider, + secretId, }: { projectId: string - kind: string + /** Omitted when `secretId` is given: the stored row names its own kind. */ + kind?: string provider: ProbeProviderCredentials + secretId?: string }): Promise => { - const response = await axios.post( - `${getAgentaApiUrl()}/providers/probe`, - {kind, provider}, - {params: {project_id: projectId}}, + const response = await getSecretsClient().probeProvider( + { + ...(kind ? {kind} : {}), + provider, + ...(secretId ? {secret_id: secretId} : {}), + }, + projectScopedRequest(projectId), ) - return safeParseWithLogging(probeResponseSchema, response.data, "[probeProvider]") + return safeParseWithLogging(probeResponseSchema, response, "[probeProvider]") } diff --git a/web/packages/agenta-entities/src/secret/core/connections.ts b/web/packages/agenta-entities/src/secret/core/connections.ts index 9b5cc1272d..acb898712c 100644 --- a/web/packages/agenta-entities/src/secret/core/connections.ts +++ b/web/packages/agenta-entities/src/secret/core/connections.ts @@ -11,6 +11,7 @@ */ import type {LlmProvider} from "@agenta/shared/types" +import {extractApiErrorMessage} from "@agenta/shared/utils" import { carriedCredentialKeys, @@ -22,7 +23,14 @@ import { type CredentialValues, } from "./providerCatalog" import {PROVIDER_AUTH_REQUIREMENTS} from "./providerFields" -import {PROVIDER_KINDS, SecretKind, VAULT_PERSIST_REDACTED, type CreateSecretDto} from "./types" +import { + PROVIDER_KINDS, + SECRET_VALUE_FIELDS, + SecretKind, + VAULT_PERSIST_REDACTED, + type CreateSecretDto, + type SecretManagementPolicy, +} from "./types" // --------------------------------------------------------------------------- // The connection model @@ -45,6 +53,16 @@ export interface ProviderConnection { /** Saved harness policy. `undefined` means "any harness Agenta supports". */ harnesses?: string[] createdAt?: string + /** + * The vault holds a credential for this connection. On a write-only record this is the ONLY + * presence signal there is — the value never comes back — so every "is it configured" check + * reads this rather than the credential fields. + */ + hasStoredCredential: boolean + /** Masked credential (`sk-****9Qa`), when the record carries one. */ + keyPreview?: string + /** Server-enforced management policy. Manager-only rows may not be edited or deleted by users. */ + managementPolicy?: SecretManagementPolicy /** The row this was derived from — the mutations round-trip it. */ source: LlmProvider } @@ -89,6 +107,11 @@ export const toProviderConnections = (rows: LlmProvider[]): ProviderConnection[] models: row.models, harnesses: row.harnesses, createdAt: row.created_at, + // A readable record proves it by carrying the value; a write-only one only says so. + hasStoredCredential: + row.hasKey ?? SECRET_VALUE_FIELDS.some((field) => !!(row[field] ?? "").trim()), + keyPreview: row.keyPreview, + managementPolicy: row.managementPolicy as SecretManagementPolicy | undefined, source: row, }) return acc @@ -127,6 +150,111 @@ export const credentialValuesFor = (connection: ProviderConnection): CredentialV return values } +/** + * The credential fields a saved connection already satisfies without the user retyping them. + * + * A write-only record returns no values, so its secret fields arrive empty on every edit. Treating + * them as unfilled would lock the card: changing only the model list would demand the key again. + * Non-secret fields (endpoint, region) still come back and need no exemption. + */ +export const storedCredentialFields = ( + connection: ProviderConnection | null | undefined, +): string[] => { + if (!connection?.hasStoredCredential) return [] + const keys = [ + ...credentialFieldsForKind(connection.kind).map((field) => field.key), + ...carriedCredentialKeys(connection.kind), + ] + return keys.filter((key) => (SECRET_VALUE_FIELDS as readonly string[]).includes(key)) +} + +/** The probe request body: a credential to spend, or the vault row to spend one from. */ +export interface ProbeRequestBody { + /** Omitted alongside `secret_id`: the stored row names its own kind. */ + kind?: string + provider: ReturnType + /** Vault row whose stored credentials the server resolves for this probe. */ + secret_id?: string +} + +/** Drop every blank value, and any `extras` left empty by dropping them. */ +const withoutBlanks = ( + provider: ReturnType, +): ReturnType => { + const out: Record = {} + for (const [key, value] of Object.entries(provider)) { + if (key === "extras") { + const extras = Object.fromEntries( + Object.entries((value ?? {}) as Record).filter(([, entry]) => + (entry ?? "").trim(), + ), + ) + if (Object.keys(extras).length) out.extras = extras + continue + } + if (typeof value === "string" && !value.trim()) continue + out[key] = value + } + return out +} + +/** + * The probe request for a Test press. + * + * A write-only connection hands its secret back to nobody, so a card sitting on one has nothing to + * put in the credential — testing it used to mean retyping the key. When the user typed no secret + * material and the vault holds some, the request names the row (`secret_id`) and the server + * resolves the credential itself. + * + * Typed fields still ride along, and the server overrides the resolved value field by field, so an + * edited base URL can be tested against the saved key. Blank fields are dropped rather than sent + * empty: an empty `key` alongside a `secret_id` would read as "test with no credential", which is + * a different question and, for an OpenAI-compatible endpoint, a legitimate one. + * + * `kind` is omitted whenever the row is named. The stored kind is authoritative, and the server + * rejects (422) a `kind` that disagrees with it unless a key rides along — which is exactly the + * request this builds. Sending the card's own kind would put the FE's canonical spelling in a + * position to contradict the vault's over nothing. + */ +export const probeRequestFor = ( + kind: string, + credential: CredentialValues, + connection?: ProviderConnection | null, +): ProbeRequestBody => { + const provider = toProviderCredentials(kind, credential) + const typedSecret = SECRET_VALUE_FIELDS.some((field) => (credential[field] ?? "").trim()) + if (typedSecret || !connection?.hasStoredCredential || !connection.id) { + return {kind, provider} + } + return {provider: withoutBlanks(provider), secret_id: connection.id} +} + +/** The HTTP status of a failed request, when it carried one. */ +const statusOf = (error: unknown): number | null => { + const response = (error as {response?: {status?: unknown}})?.response + return typeof response?.status === "number" ? response.status : null +} + +/** + * Why a Test never produced a verdict. + * + * A probe OUTCOME is an HTTP 200 with a status inside, so anything that throws here is the request + * itself failing. The default reads as "we could not reach the provider", which is true of a + * transport failure and false of everything the API rejects on its own — a 404 means the stored + * connection is gone, not that the provider is down. So a 4xx speaks with the server's own words + * where it gave any, and only a 5xx or a dead connection falls back to the reach-the-provider line. + */ +// TODO(copy: owner) +export const probeFailureMessage = (error: unknown, title: string): string => { + const status = statusOf(error) + if (status === 404) return "This connection no longer exists. Reload and try again." + if (status && status >= 400 && status < 500) { + const message = extractApiErrorMessage(error) + if (message && message !== String(error)) return message + } + return `Agenta could not reach ${title} to test this credential.` +} + /** * Whether this kind has enough credential to be worth testing or saving. * @@ -134,9 +262,16 @@ export const credentialValuesFor = (connection: ProviderConnection): CredentialV * be filled, and a provider with alternative auth sets (Bedrock: a bearer token OR an access-key * pair) must satisfy one of them. A kind that declares neither still needs something typed — * otherwise Done would happily store an empty connection. + * + * `stored` names the fields the vault already holds (see `storedCredentialFields`); they count as + * filled even though the card shows them empty. */ -export const hasRequiredCredential = (kind: string, values: CredentialValues): boolean => { - const filled = (key: string) => !!(values[key] ?? "").trim() +export const hasRequiredCredential = ( + kind: string, + values: CredentialValues, + stored: readonly string[] = [], +): boolean => { + const filled = (key: string) => !!(values[key] ?? "").trim() || stored.includes(key) const fields = credentialFieldsForKind(kind) if (!fields.every((field) => !field.required || filled(field.key))) return false @@ -160,8 +295,12 @@ export const maskSecret = (value: string): string => */ export const credentialSummary = (connection: ProviderConnection): string => { const {source} = connection + // A write-only record masks its own key server-side; only a readable one is masked here. + if (connection.keyPreview) return connection.keyPreview const key = source.key || source.apiKey || source.bearerToken || source.accessKeyId if (key) return maskSecret(key) + // TODO(copy: owner) + if (connection.hasStoredCredential) return "Key configured" if (source.apiBaseUrl) { try { @@ -519,6 +658,7 @@ export const buildConnectionPayload = ( fallbackName: string, ): CreateSecretDto => { const name = draft.name.trim() + const key = (draft.credential.apiKey ?? "").trim() const policy = { ...(draft.models ? {models: draft.models.map((slug) => ({slug}))} : {}), ...(draft.harnesses ? {harnesses: draft.harnesses} : {}), @@ -531,7 +671,8 @@ export const buildConnectionPayload = ( kind: SecretKind.ProviderKey, data: { kind: draft.kind, - provider: {key: (draft.credential.apiKey ?? "").trim()}, + // An omitted key means "keep the stored value"; `""` would blank it. + provider: key ? {key} : {}, ...policy, }, }, @@ -553,9 +694,7 @@ export const buildConnectionPayload = ( // request, where each adapter reads it where its provider expects it. extras: { ...(provider.extras ?? {}), - ...(draft.credential.apiKey?.trim() - ? {api_key: draft.credential.apiKey.trim()} - : {}), + ...(key ? {api_key: key} : {}), }, }, // A `custom_provider` record always declares `models`; an untouched card sends the diff --git a/web/packages/agenta-entities/src/secret/core/index.ts b/web/packages/agenta-entities/src/secret/core/index.ts index 325152806d..25c89f45de 100644 --- a/web/packages/agenta-entities/src/secret/core/index.ts +++ b/web/packages/agenta-entities/src/secret/core/index.ts @@ -24,11 +24,13 @@ export { PROVIDER_LABELS, STANDARD_PROVIDER_KINDS, SecretKind, + SecretManagementPolicy, StandardProviderKind, VAULT_PERSIST_REDACTED, } from "./types" export { + hasStoredKey, transformSecret, transformCustomProviderPayloadData, transformCustomSecretPayloadData, @@ -83,6 +85,9 @@ export { doneState, harnessSupportsProviderKind, hasRequiredCredential, + probeFailureMessage, + probeRequestFor, + storedCredentialFields, maskSecret, nextConnectionName, providerModelCatalog, diff --git a/web/packages/agenta-entities/src/secret/core/transforms.ts b/web/packages/agenta-entities/src/secret/core/transforms.ts index 9a6eeb3f67..1805706f33 100644 --- a/web/packages/agenta-entities/src/secret/core/transforms.ts +++ b/web/packages/agenta-entities/src/secret/core/transforms.ts @@ -57,6 +57,15 @@ const STANDARD_PROVIDER_ENV_ALIASES: Record = { MISTRALAI_API_KEY: StandardProviderKind.Mistral, } +/** + * Whether the vault holds a key for this row — the ONE presence check. + * + * A readable row proves it by carrying the value. A write-only row never returns one, so it says + * so with `hasKey` instead; reading `!!row.key` on it would report every connection as keyless. + */ +export const hasStoredKey = (provider: LlmProvider | null | undefined): boolean => + provider?.hasKey ?? !!provider?.key + /** * Transform raw `/secrets/` response items into the `LlmProvider` shape * used throughout the app. Standard provider secrets and custom provider @@ -67,6 +76,19 @@ const STANDARD_PROVIDER_ENV_ALIASES: Record = { * are dropped (with a warning) — the app uses the env-var name as the * provider identity, so an unmapped kind would surface as a nameless row. */ +/** + * The fields every row carries about its own value, whichever kind it is. + * + * The public API reports value presence and an optional safe preview through `value_status`. + * The UI maps those general facts into its provider-specific connection model here. + */ +const storageFacts = (secret: SecretResponseDto) => ({ + writeOnly: secret.write_only ?? undefined, + hasKey: secret.value_status.configured, + keyPreview: secret.value_status.preview ?? undefined, + managementPolicy: secret.management?.policy, +}) + export const transformSecret = (secrets: SecretResponseDto[]): LlmProvider[] => { return secrets.reduce((acc, secret) => { if (secret.kind === SecretKind.ProviderKey) { @@ -80,8 +102,9 @@ export const transformSecret = (secrets: SecretResponseDto[]): LlmProvider[] => } acc.push({ + ...storageFacts(secret), title: provider, - key: data.provider.key, + key: data.provider.key ?? undefined, name: envName, id: secret.id ?? undefined, slug: secret.slug ?? undefined, @@ -100,6 +123,7 @@ export const transformSecret = (secrets: SecretResponseDto[]): LlmProvider[] => const extras = (data.provider.extras ?? {}) as Record acc.push({ + ...storageFacts(secret), name: secret.header.name ?? "", displayName: secret.header.name ?? undefined, id: secret.id ?? undefined, @@ -129,6 +153,7 @@ export const transformSecret = (secrets: SecretResponseDto[]): LlmProvider[] => const data = secret.data as unknown as CustomSecretDto const row: NamedSecretRow = { + ...storageFacts(secret), name: secret.header.name ?? "", slug: secret.slug ?? undefined, format: data.secret.format, @@ -154,22 +179,22 @@ export const transformSecret = (secrets: SecretResponseDto[]): LlmProvider[] => export const transformStandardProviderPayloadData = ( values: LlmProvider, providerKind: StandardProviderKind, -): CreateSecretDto => ({ - header: { - name: values.title, - }, - secret: { - kind: SecretKind.ProviderKey, - data: { - kind: providerKind, - provider: { - key: values.key ?? "", - }, - ...(values.models ? {models: values.models.map((slug) => ({slug}))} : {}), - ...(values.harnesses ? {harnesses: values.harnesses} : {}), - } as StandardProviderDto, - }, -}) +): CreateSecretDto => + ({ + header: { + name: values.title, + }, + secret: { + kind: SecretKind.ProviderKey, + data: { + kind: providerKind, + // An omitted key means "keep the stored value"; `""` would blank it. + provider: values.key ? {key: values.key} : {}, + ...(values.models ? {models: values.models.map((slug) => ({slug}))} : {}), + ...(values.harnesses ? {harnesses: values.harnesses} : {}), + } satisfies StandardProviderDto, + }, + }) as CreateSecretDto /** * Transform a form-shaped `LlmProvider` into a `CreateSecretDto` suitable @@ -231,7 +256,9 @@ export const transformCustomSecretPayloadData = (values: NamedSecretRow): Create data: { secret: { format: values.format, - content: values.content, + // An omitted content means "keep the stored value"; a write-only record is only + // ever replaced, never read back, so an untouched edit must send nothing. + ...(values.content === undefined ? {} : {content: values.content}), }, } as CustomSecretDto, }, diff --git a/web/packages/agenta-entities/src/secret/core/types.ts b/web/packages/agenta-entities/src/secret/core/types.ts index 02708160c9..2bce6ea829 100644 --- a/web/packages/agenta-entities/src/secret/core/types.ts +++ b/web/packages/agenta-entities/src/secret/core/types.ts @@ -27,7 +27,9 @@ export type Header = AgentaApi.Header export type LegacyLifecycleDto = AgentaApi.LegacyLifecycleDto export type SecretDto = AgentaApi.SecretDto -export type SecretResponseDto = AgentaApi.SecretResponseDto + +export type SecretResponseDto = AgentaApi.PublicSecretResponseDto + export type CreateSecretDto = AgentaApi.CreateSecretDto export type UpdateSecretDto = AgentaApi.UpdateSecretDto @@ -35,23 +37,8 @@ export type StandardProviderSettingsDto = AgentaApi.StandardProviderSettingsDto export type CustomProviderSettingsDto = AgentaApi.CustomProviderSettingsDto export type CustomModelSettingsDto = AgentaApi.CustomModelSettingsDto -/** - * The connection policy both stored record shapes carry: the models this connection offers - * and the harnesses it may drive. A missing `models` means "use Agenta's defaults" and an - * empty one means "no models from this connection"; a missing `harnesses` means "any harness - * Agenta supports". The custom-provider record already declares `models`, so it only gains - * `harnesses` here. - * - * Layered onto the Fern types until the client is regenerated from the OpenAPI spec; dropping - * the intersections once Fern declares the fields is a no-op for callers. - */ -export type StandardProviderDto = AgentaApi.StandardProviderDto & { - models?: CustomModelSettingsDto[] | null - harnesses?: string[] | null -} -export type CustomProviderDto = AgentaApi.CustomProviderDto & { - harnesses?: string[] | null -} +export type StandardProviderDto = AgentaApi.StandardProviderDto +export type CustomProviderDto = AgentaApi.CustomProviderDto export type CustomSecretDto = AgentaApi.CustomSecretDto export type CustomSecretSettingsDto = AgentaApi.CustomSecretSettingsDto @@ -59,6 +46,8 @@ export type CustomSecretSettingsDto = AgentaApi.CustomSecretSettingsDto export const CustomSecretFormat = AgentaApi.CustomSecretFormat export type CustomSecretFormat = AgentaApi.CustomSecretFormat +export const SecretManagementPolicy = AgentaApi.SecretManagementPolicy +export type SecretManagementPolicy = AgentaApi.SecretManagementPolicy /** * Flat json content for a `json`-format custom secret: a single-level map of * primitives. Mirrors the backend's flat-only validation (no nesting/arrays). @@ -74,7 +63,8 @@ export type CustomSecretContent = CustomSecretSettingsDto["content"] export interface NamedSecretRow extends LlmProvider { slug?: string format: CustomSecretFormat - content: CustomSecretContent + /** Absent on a write-only record (the value never comes back) and on an update that keeps it. */ + content?: CustomSecretContent } // `SecretKind` / `StandardProviderKind` / `CustomProviderKind` are Fern @@ -148,6 +138,21 @@ export const STANDARD_PROVIDER_KINDS: StandardProviderKind[] = ( */ export const VAULT_PERSIST_REDACTED = "[redacted]" +/** + * Every `LlmProvider` field that can carry actual secret material — the fields the vault strips + * from a write-only response, and the fields the IndexedDB persister replaces with a sentinel. + * One list so the two can never disagree about what counts as a secret. + */ +export const SECRET_VALUE_FIELDS = [ + "key", + "apiKey", + "accessKeyId", + "accessKey", + "sessionToken", + "bearerToken", + "vertexCredentials", +] as const + // --------------------------------------------------------------------------- // Migration status (UI state, not wire) // --------------------------------------------------------------------------- diff --git a/web/packages/agenta-entities/src/secret/index.ts b/web/packages/agenta-entities/src/secret/index.ts index 818248472b..d30845781c 100644 --- a/web/packages/agenta-entities/src/secret/index.ts +++ b/web/packages/agenta-entities/src/secret/index.ts @@ -68,8 +68,10 @@ export { PROVIDER_LABELS, STANDARD_PROVIDER_KINDS, SecretKind, + SecretManagementPolicy, StandardProviderKind, getEnvNameMap, + hasStoredKey, transformCustomProviderPayloadData, transformCustomSecretPayloadData, transformSecret, @@ -108,6 +110,9 @@ export { doneState, harnessSupportsProviderKind, hasRequiredCredential, + probeFailureMessage, + probeRequestFor, + storedCredentialFields, maskSecret, nextConnectionName, providerModelCatalog, diff --git a/web/packages/agenta-entities/src/secret/state/atoms.ts b/web/packages/agenta-entities/src/secret/state/atoms.ts index 8aae9501b2..ccfc14b536 100644 --- a/web/packages/agenta-entities/src/secret/state/atoms.ts +++ b/web/packages/agenta-entities/src/secret/state/atoms.ts @@ -142,6 +142,11 @@ export const standardSecretsAtom = atom((get) => { return { ...secret, key: match.key, + // A write-only record answers "is it configured" here; its value never arrives. + writeOnly: match.writeOnly, + hasKey: match.hasKey, + keyPreview: match.keyPreview, + managementPolicy: match.managementPolicy, id: match.id, // The connection's saved policy round-trips: a form seeded from this row // sends it back on update instead of dropping it. diff --git a/web/packages/agenta-entities/src/secret/state/connections.ts b/web/packages/agenta-entities/src/secret/state/connections.ts index 9e613e2430..32b60bfca9 100644 --- a/web/packages/agenta-entities/src/secret/state/connections.ts +++ b/web/packages/agenta-entities/src/secret/state/connections.ts @@ -42,9 +42,10 @@ export const providerConnectionsAtom = atom((get) => */ export const probeProviderMutationAtom = atomWithMutation< ProbeProviderResponse | null, - {projectId: string; kind: string; provider: ProbeProviderCredentials} + {projectId: string; kind?: string; provider: ProbeProviderCredentials; secretId?: string} >(() => ({ - mutationFn: ({projectId, kind, provider}) => probeProvider({projectId, kind, provider}), + mutationFn: ({projectId, kind, provider, secretId}) => + probeProvider({projectId, kind, provider, secretId}), })) /** diff --git a/web/packages/agenta-entities/src/secret/state/persistence.ts b/web/packages/agenta-entities/src/secret/state/persistence.ts index 488ab4066c..684ada72a3 100644 --- a/web/packages/agenta-entities/src/secret/state/persistence.ts +++ b/web/packages/agenta-entities/src/secret/state/persistence.ts @@ -19,23 +19,12 @@ import type {LlmProvider} from "@agenta/shared/types" import {experimental_createQueryPersister} from "@tanstack/query-persist-client-core" import type {PersistedQuery} from "@tanstack/query-persist-client-core" -import {VAULT_PERSIST_REDACTED, type NamedSecretRow} from "../core/types" +import {SECRET_VALUE_FIELDS, VAULT_PERSIST_REDACTED, type NamedSecretRow} from "../core/types" export {VAULT_PERSIST_REDACTED} const VAULT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 -/** Every `LlmProvider` field that can carry actual secret material. */ -const SECRET_VALUE_FIELDS = [ - "key", - "apiKey", - "accessKeyId", - "accessKey", - "sessionToken", - "bearerToken", - "vertexCredentials", -] as const - /** Redact one vault row: sentinel for non-empty secret values, metadata kept. */ export const redactVaultSecretRow = (row: LlmProvider): LlmProvider => { const next: LlmProvider = {...row} diff --git a/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts b/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts index 8d2e72fb24..d9508e606a 100644 --- a/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts +++ b/web/packages/agenta-entities/src/workflow/state/agentCreationPrefs.ts @@ -6,6 +6,13 @@ */ import {atomWithStorage} from "jotai/utils" +import { + SecretKind, + SecretManagementPolicy, + connectionSlugFor, + type ProviderConnection, +} from "../../secret/core" + export interface AgentCreationPrefs { version: 1 harness?: string @@ -66,6 +73,77 @@ export function applyAgentCreationPrefs( return next } +/** The object at `key`, or `{}` when the config carries nothing usable there. */ +const objectAt = (config: Record, key: string): Record => { + const value = config[key] + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {} +} + +/** + * A connection's own model ids. A credential-set (custom) connection publishes `model_keys`, whose + * spelling names the connection and which the resolver rewrites itself; everything else publishes + * plain saved models. + */ +const connectionModelIds = (connection: ProviderConnection): string[] => + (connection.secretKind === SecretKind.CustomProvider + ? (connection.source.modelKeys ?? connection.models) + : connection.models) ?? [] + +/** + * Default a new agent onto an Agenta-managed connection when its template provider has no key. + * + * The backend template hard-codes a provider/model pair, so a project whose only credentials are + * ones protected by the manager-only policy mints an agent pointing at a provider the user has + * never connected — it paints a "Connect key" warning and cannot run until they configure one. + * Repoint it at the managed connection's first model, addressed by slug. + * + * Only the untouched default is rewritten: a config already naming a connection slug, or running + * on self-managed credentials, is the user's own choice (or their saved prefs) and is left alone. + * Likewise when the template's provider IS connected with the user's own key. + * + * No `provider` is written. The slug is the whole routing identity of a managed connection, and a + * provider on a named custom connection becomes a model-id prefix the resolver cannot match — the + * same reason `vaultPickedProviderFamily` omits it for a picked custom model. + */ +export function applyManagedConnectionDefault( + agentConfig: Record, + connections: ProviderConnection[], +): Record { + const llm = objectAt(agentConfig, "llm") + const connection = objectAt(llm, "connection") + const mode = typeof connection.mode === "string" ? connection.mode : "agenta" + const slug = typeof connection.slug === "string" ? connection.slug.trim() : "" + if (mode !== "agenta" || slug) return agentConfig + + const provider = typeof llm.provider === "string" ? llm.provider.trim().toLowerCase() : "" + const userConnected = connections.some( + (candidate) => + candidate.managementPolicy !== SecretManagementPolicy.ManagerOnly && + candidate.hasStoredCredential && + !!provider && + candidate.kind.toLowerCase() === provider, + ) + if (userConnected) return agentConfig + + const managed = connections.find( + (candidate) => + candidate.managementPolicy === SecretManagementPolicy.ManagerOnly && + !!connectionSlugFor(candidate) && + connectionModelIds(candidate).length > 0, + ) + if (!managed) return agentConfig + + const nextLlm: Record = { + ...llm, + model: connectionModelIds(managed)[0], + connection: {...connection, mode: "agenta", slug: connectionSlugFor(managed)}, + } + delete nextLlm.provider + return {...agentConfig, llm: nextLlm} +} + /** * Ensure a new agent's `sandbox.kind` is one the deployment actually enables. The template default * is `local` (SDK `AgentTemplate.sandbox`), which is also the runtime default when no kind is set. diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts index c146764c45..aef21de03b 100644 --- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -12,14 +12,17 @@ * @packageDocumentation */ -import {getEnabledSandboxProviders} from "@agenta/shared/api" +import {getEnabledSandboxProviders, getHostQueryClient} from "@agenta/shared/api" import {catalogPersister} from "@agenta/shared/api/persist" -import {projectIdAtom, sessionAtom} from "@agenta/shared/state" +import {projectIdAtom, sessionAtom, userAtom} from "@agenta/shared/state" +import type {LlmProvider} from "@agenta/shared/types" import type {QueryKey} from "@tanstack/react-query" import {atom, getDefaultStore} from "jotai" import {atomWithQuery} from "jotai-tanstack-query" import {syncPromptInputKeysInParameters} from "../../runnable/utils" +import {fetchVaultSecret} from "../../secret/api" +import {toProviderConnections, type ProviderConnection} from "../../secret/core" import {generateLocalId} from "../../shared" import type {WorkflowCatalogTemplate, WorkflowCatalogTemplatesResponse} from "../api" import {fetchWorkflowCatalogTemplates, inspectWorkflow} from "../api" @@ -28,6 +31,7 @@ import {buildWorkflowUri, parseWorkflowKeyFromUri} from "../core" import { applyAgentCreationPrefs, + applyManagedConnectionDefault, agentCreationPrefsAtom, ensureEnabledSandbox, } from "./agentCreationPrefs" @@ -115,6 +119,31 @@ function matchTemplateForType( ) } +/** + * The project's vault connections, for shaping a new agent's default model. + * + * Resolved through the SAME query entry `vaultSecretsQueryAtom` uses, so a warm cache costs + * nothing and a cold one is fetched once and shared. Deliberately not `store.get(...)` on that + * atom: the mint runs on first landing and can beat the atom's own fetch, and reading it empty + * would commit the very template default the managed connection exists to replace. A failure + * yields no connections, which leaves the template default untouched. + */ +async function vaultConnectionsForNewAgent( + projectId: string, + userId?: string, +): Promise { + try { + const rows = await getHostQueryClient().ensureQueryData({ + queryKey: ["vault", "secrets", userId, projectId], + queryFn: () => fetchVaultSecret({projectId}), + staleTime: 5 * 60_000, + }) + return toProviderConnections(rows ?? []) + } catch { + return [] + } +} + /** * Create a local-only application workflow entity from a built-in catalog * template (chat or completion). Mirrors `createEvaluatorFromTemplate` — @@ -201,12 +230,19 @@ export async function createEphemeralAppFromTemplate({ !Array.isArray(parameters.agent) ? (parameters.agent as Record) : {} + const withPrefs = applyAgentCreationPrefs(agentConfig, agentPrefs) + // Repoint the template's hard-coded provider at an Agenta-managed connection when that is + // the only credential the project has, so a first agent runs without being configured. + const withManaged = applyManagedConnectionDefault( + withPrefs, + await vaultConnectionsForNewAgent(projectId, store.get(userAtom)?.id), + ) + if (signal?.aborted) return null // Seed a deployment-valid sandbox before commit so a daytona-only deployment doesn't // commit an unrunnable `local` default and then show a phantom Advanced draft on open. - const withPrefs = applyAgentCreationPrefs(agentConfig, agentPrefs) parameters = { ...parameters, - agent: ensureEnabledSandbox(withPrefs, getEnabledSandboxProviders()), + agent: ensureEnabledSandbox(withManaged, getEnabledSandboxProviders()), } } diff --git a/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts b/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts index 7dc716aec9..ebb0aa512a 100644 --- a/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts +++ b/web/packages/agenta-entities/tests/unit/agent-creation-prefs.test.ts @@ -1,7 +1,9 @@ import {describe, expect, it} from "vitest" +import {SecretKind, SecretManagementPolicy, type ProviderConnection} from "../../src/secret/core" import { applyAgentCreationPrefs, + applyManagedConnectionDefault, ensureEnabledSandbox, } from "../../src/workflow/state/agentCreationPrefs" @@ -92,3 +94,86 @@ describe("ensureEnabledSandbox", () => { expect(ensureEnabledSandbox(config, [])).toBe(config) }) }) + +describe("applyManagedConnectionDefault", () => { + const managed = (over: Partial = {}): ProviderConnection => + ({ + id: "sec-managed", + slug: "starter-credits", + name: "Starter credits", + kind: "custom", + title: "Custom", + secretKind: SecretKind.CustomProvider, + hasStoredCredential: true, + managementPolicy: SecretManagementPolicy.ManagerOnly, + source: { + modelKeys: ["Starter credits/custom/vertex_ai/gemini-3.6-flash"], + } as ProviderConnection["source"], + ...over, + }) as ProviderConnection + + const ownKey = (kind: string): ProviderConnection => + ({ + id: `sec-${kind}`, + name: kind, + kind, + title: kind, + secretKind: SecretKind.ProviderKey, + hasStoredCredential: true, + source: {} as ProviderConnection["source"], + }) as ProviderConnection + + const TEMPLATE = {llm: {provider: "openai", model: "gpt-5.6-luna"}, tools: []} + + it("repoints the template default at the managed connection's first model", () => { + const result = applyManagedConnectionDefault(TEMPLATE, [managed()]) + expect(result.llm).toEqual({ + model: "Starter credits/custom/vertex_ai/gemini-3.6-flash", + connection: {mode: "agenta", slug: "starter-credits"}, + }) + expect(result.tools).toBe(TEMPLATE.tools) + }) + + it("writes no provider — the slug is the whole routing identity", () => { + const result = applyManagedConnectionDefault(TEMPLATE, [managed()]) + expect(result.llm).not.toHaveProperty("provider") + }) + + it("leaves the template alone when the user has their own key for its provider", () => { + const connections = [ownKey("openai"), managed()] + expect(applyManagedConnectionDefault(TEMPLATE, connections)).toBe(TEMPLATE) + }) + + it("still repoints when the user's own key is for a different provider", () => { + const result = applyManagedConnectionDefault(TEMPLATE, [ownKey("anthropic"), managed()]) + expect((result.llm as Record).model).toBe( + "Starter credits/custom/vertex_ai/gemini-3.6-flash", + ) + }) + + it("never overrides a config that already names a connection slug", () => { + const chosen = {llm: {model: "gpt-4o", connection: {mode: "agenta", slug: "my-gateway"}}} + expect(applyManagedConnectionDefault(chosen, [managed()])).toBe(chosen) + }) + + it("never overrides self-managed credentials", () => { + const chosen = {llm: {model: "sonnet", connection: {mode: "self_managed"}}} + expect(applyManagedConnectionDefault(chosen, [managed()])).toBe(chosen) + }) + + it("is a no-op when the project has no managed connection", () => { + expect(applyManagedConnectionDefault(TEMPLATE, [ownKey("anthropic")])).toBe(TEMPLATE) + expect(applyManagedConnectionDefault(TEMPLATE, [])).toBe(TEMPLATE) + }) + + it("skips a managed connection that publishes no models", () => { + const empty = managed({source: {modelKeys: []} as ProviderConnection["source"]}) + expect(applyManagedConnectionDefault(TEMPLATE, [empty])).toBe(TEMPLATE) + }) + + it("keeps sibling llm keys (temperature, extras) while repointing", () => { + const config = {llm: {provider: "openai", model: "gpt-5.6-luna", temperature: 0.3}} + const result = applyManagedConnectionDefault(config, [managed()]) + expect((result.llm as Record).temperature).toBe(0.3) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/provider-connections.test.ts b/web/packages/agenta-entities/tests/unit/provider-connections.test.ts index 58d76cf698..2614b95905 100644 --- a/web/packages/agenta-entities/tests/unit/provider-connections.test.ts +++ b/web/packages/agenta-entities/tests/unit/provider-connections.test.ts @@ -13,7 +13,10 @@ import { hasRequiredCredential, modelDisplayOrder, nextConnectionName, + probeFailureMessage, + probeRequestFor, providerModelCatalog, + storedCredentialFields, toProviderConnections, type HarnessCapabilityMap, type ProviderConnection, @@ -25,13 +28,12 @@ import { credentialFieldsForKind, secretKindForProviderKind, } from "../../src/secret/core/providerCatalog" -import {SecretKind, VAULT_PERSIST_REDACTED} from "../../src/secret/core/types" +import {SecretKind, SecretManagementPolicy, VAULT_PERSIST_REDACTED} from "../../src/secret/core/types" -const axiosPost = vi.fn() +const fernProbeProvider = vi.fn() -vi.mock("@agenta/shared/api", () => ({ - axios: {post: (...args: unknown[]) => axiosPost(...args)}, - getAgentaApiUrl: () => "https://agenta.test/api", +vi.mock("@agenta/sdk/resources", () => ({ + getSecretsClient: () => ({probeProvider: (...args: unknown[]) => fernProbeProvider(...args)}), })) // Imported after the mock so the module picks it up. @@ -43,6 +45,7 @@ const connection = (overrides: Partial = {}): ProviderConnec kind: "openai", title: "OpenAI", secretKind: SecretKind.ProviderKey, + hasStoredCredential: false, source: {}, ...overrides, }) @@ -731,16 +734,14 @@ describe("buildConnectionPayload", () => { describe("probeProvider", () => { beforeEach(() => { - axiosPost.mockReset() + fernProbeProvider.mockReset() }) it("posts the credential and returns the two statuses", async () => { - axiosPost.mockResolvedValueOnce({ - data: { - credential: {status: "valid", message: "OpenAI accepted this key."}, - discovery: {status: "fetched", models: ["gpt-5.5"]}, - fetched_at: "2026-08-12T10:00:00Z", - }, + fernProbeProvider.mockResolvedValueOnce({ + credential: {status: "valid", message: "OpenAI accepted this key."}, + discovery: {status: "fetched", models: ["gpt-5.5"]}, + fetched_at: "2026-08-12T10:00:00Z", }) const result = await probeProvider({ @@ -749,22 +750,45 @@ describe("probeProvider", () => { provider: {key: "sk-one"}, }) - expect(axiosPost).toHaveBeenCalledWith( - "https://agenta.test/api/providers/probe", + expect(fernProbeProvider).toHaveBeenCalledWith( {kind: "openai", provider: {key: "sk-one"}}, - {params: {project_id: "proj-1"}}, + {queryParams: {project_id: "proj-1"}}, ) expect(result?.credential.status).toBe("valid") expect(result?.discovery.models).toEqual(["gpt-5.5"]) }) + it("puts the stored row on the wire as `secret_id`, and omits the field otherwise", async () => { + const answer = { + credential: {status: "valid", message: "ok"}, + discovery: {status: "fetched", models: ["m"]}, + fetched_at: "2026-08-12T10:00:00Z", + } + + fernProbeProvider.mockResolvedValueOnce(answer) + await probeProvider({ + projectId: "proj-1", + provider: {url: "https://llm.example.com/v1"}, + secretId: "sec-1", + }) + expect(fernProbeProvider).toHaveBeenLastCalledWith( + {provider: {url: "https://llm.example.com/v1"}, secret_id: "sec-1"}, + {queryParams: {project_id: "proj-1"}}, + ) + // Absent, not empty: the stored row names its own kind, and a disagreeing one is a 422. + expect(fernProbeProvider.mock.lastCall?.[0]).not.toHaveProperty("kind") + + // Absent, not null: the server reads a present `secret_id` as "resolve the stored row". + fernProbeProvider.mockResolvedValueOnce(answer) + await probeProvider({projectId: "proj-1", kind: "openai", provider: {key: "sk-one"}}) + expect(fernProbeProvider.mock.lastCall?.[0]).not.toHaveProperty("secret_id") + }) + it("defaults a fetched-but-model-less discovery to an empty list", async () => { - axiosPost.mockResolvedValueOnce({ - data: { - credential: {status: "unknown", message: "not tested"}, - discovery: {status: "unsupported"}, - fetched_at: "2026-08-12T10:00:00Z", - }, + fernProbeProvider.mockResolvedValueOnce({ + credential: {status: "unknown", message: "not tested"}, + discovery: {status: "unsupported"}, + fetched_at: "2026-08-12T10:00:00Z", }) const result = await probeProvider({projectId: "p", kind: "minimax", provider: {key: "k"}}) @@ -773,10 +797,220 @@ describe("probeProvider", () => { }) it("returns null rather than a half-read answer when the payload does not fit", async () => { - axiosPost.mockResolvedValueOnce({data: {credential: {status: "maybe"}}}) + fernProbeProvider.mockResolvedValueOnce({credential: {status: "maybe"}}) const result = await probeProvider({projectId: "p", kind: "openai", provider: {key: "k"}}) expect(result).toBeNull() }) }) + +describe("write-only records", () => { + const writeOnlyRow = { + id: "sec-1", + type: SecretKind.ProviderKey, + title: "openai", + name: "OPENAI_API_KEY", + writeOnly: true, + hasKey: true, + keyPreview: "sk-****9Qa", + } + + it("reports a stored credential from `hasKey`, with no value to read", () => { + const [connected] = toProviderConnections([writeOnlyRow]) + + expect(connected.hasStoredCredential).toBe(true) + expect(connected.keyPreview).toBe("sk-****9Qa") + expect(credentialValuesFor(connected).apiKey).toBe("") + }) + + it("falls back to the value itself for a readable record", () => { + const [readable] = toProviderConnections([ + {id: "sec-2", type: SecretKind.ProviderKey, title: "openai", key: "sk-live"}, + ]) + + expect(readable.hasStoredCredential).toBe(true) + }) + + it("calls a record with neither value nor `hasKey` unconfigured", () => { + const [empty] = toProviderConnections([ + {id: "sec-3", type: SecretKind.ProviderKey, title: "openai"}, + ]) + + expect(empty.hasStoredCredential).toBe(false) + }) + + it("carries the managed marker through, so a surface can choose not to list the row", () => { + const [managed] = toProviderConnections([ + {...writeOnlyRow, managementPolicy: SecretManagementPolicy.ManagerOnly}, + ]) + + expect(managed.managementPolicy).toBe(SecretManagementPolicy.ManagerOnly) + }) + + it("shows the server's preview as the credential summary", () => { + const [connected] = toProviderConnections([writeOnlyRow]) + + expect(credentialSummary(connected)).toBe("sk-****9Qa") + }) + + it("says a value exists when the record has one but no preview to show", () => { + const [connected] = toProviderConnections([{...writeOnlyRow, keyPreview: undefined}]) + + expect(credentialSummary(connected)).toBe("Key configured") + }) + + it("exempts only the SECRET fields of a record that holds a credential", () => { + const [connected] = toProviderConnections([writeOnlyRow]) + + expect(storedCredentialFields(connected)).toContain("apiKey") + expect(storedCredentialFields(connected)).not.toContain("apiBaseUrl") + expect(storedCredentialFields(connection())).toEqual([]) + }) + + it("lets an untouched card save: the stored key counts as filled", () => { + const [connected] = toProviderConnections([writeOnlyRow]) + + expect(hasRequiredCredential("openai", {apiKey: ""})).toBe(false) + expect( + hasRequiredCredential("openai", {apiKey: ""}, storedCredentialFields(connected)), + ).toBe(true) + }) + + it("omits the key entirely when nothing was typed, rather than blanking the stored one", () => { + const payload = buildConnectionPayload( + {kind: "openai", name: "", credential: {apiKey: " "}}, + "OpenAI", + ) + + expect((payload.secret.data as {provider: {key?: string}}).provider).toEqual({}) + }) + + it("still sends a key the user did type", () => { + const payload = buildConnectionPayload( + {kind: "openai", name: "", credential: {apiKey: " sk-new "}}, + "OpenAI", + ) + + expect((payload.secret.data as {provider: {key?: string}}).provider).toEqual({ + key: "sk-new", + }) + }) +}) + +describe("Test on a write-only connection: the enable rule and the request shape", () => { + // A write-only record hands its secret back to nobody, so the card's key box is empty on every + // edit. Test used to demand typed material and was therefore unreachable for exactly the + // connections most likely to need a model refresh. + const stored = (overrides: Partial = {}): ProviderConnection => + connection({id: "sec-1", hasStoredCredential: true, ...overrides}) + + describe("the enable rule", () => { + it("enables Test on a stored credential alone, with nothing typed", () => { + const fields = storedCredentialFields(stored()) + + expect(hasRequiredCredential("openai", {apiKey: ""}, fields)).toBe(true) + }) + + it("still refuses an empty form on a connection with nothing stored", () => { + expect( + hasRequiredCredential("openai", {apiKey: ""}, storedCredentialFields(connection())), + ).toBe(false) + }) + + it("keeps enabling a custom endpoint on its base URL alone", () => { + // Confirmed against the backend, not assumed: `OpenAICompatibleAdapter` adds the + // Authorization header only `if key`, answers `credential: unknown` + + // `discovery: fetched` for a keyless 200, and has a test pinning exactly that. An open + // OpenAI-compatible server is a real deployment, so its URL stays sufficient. + expect( + hasRequiredCredential("custom", {apiBaseUrl: "https://llm.example.com/v1"}), + ).toBe(true) + expect(hasRequiredCredential("custom", {apiBaseUrl: ""})).toBe(false) + }) + }) + + describe("why a Test produced no verdict", () => { + // A probe OUTCOME is a 200 with a status inside, so anything that throws is the request + // failing — and "could not reach the provider" is false for everything the API rejects + // on its own. + const httpError = (status: number, detail?: string) => ({ + response: {status, data: detail ? {detail} : undefined}, + }) + + it("says the connection is gone on a 404, not that the provider is unreachable", () => { + expect(probeFailureMessage(httpError(404), "OpenAI")).toContain("no longer exists") + }) + + it("speaks the server's own words for a 4xx that carried a message", () => { + expect(probeFailureMessage(httpError(422, "Stored key is for another provider."))).toBe( + "Stored key is for another provider.", + ) + }) + + it("falls back to the reach-the-provider line for a transport failure or a 5xx", () => { + expect(probeFailureMessage(new Error("network down"), "OpenAI")).toBe( + "Agenta could not reach OpenAI to test this credential.", + ) + expect(probeFailureMessage(httpError(500), "OpenAI")).toBe( + "Agenta could not reach OpenAI to test this credential.", + ) + }) + }) + + describe("the request shape", () => { + it("names the stored row instead of sending an empty credential", () => { + const request = probeRequestFor("openai", {apiKey: ""}, stored()) + + expect(request).toEqual({provider: {}, secret_id: "sec-1"}) + }) + + it("sends typed non-secret fields alongside the stored row, for the server to override", () => { + const request = probeRequestFor( + "custom", + {apiKey: "", apiBaseUrl: "https://edited.example.com/v1"}, + stored({kind: "custom"}), + ) + + expect(request).toEqual({ + provider: {url: "https://edited.example.com/v1"}, + secret_id: "sec-1", + }) + }) + + it("omits `kind` whenever it names a row, so it cannot contradict the stored one", () => { + // The server rejects (422) a kind that disagrees with the stored one unless a key + // rides along — and this request deliberately carries none. The stored kind is + // authoritative, so the card's own canonical spelling is simply not sent. + expect(probeRequestFor("openai", {apiKey: ""}, stored())).not.toHaveProperty("kind") + // Without a row to name, the kind is the only thing that says what to probe. + expect(probeRequestFor("openai", {apiKey: "sk-typed"}, stored()).kind).toBe("openai") + expect(probeRequestFor("openai", {apiKey: ""}, connection()).kind).toBe("openai") + }) + + it("spends the typed credential and names no row once the user types one", () => { + const request = probeRequestFor("openai", {apiKey: "sk-typed"}, stored()) + + expect(request).toEqual({kind: "openai", provider: {key: "sk-typed"}}) + expect(request.secret_id).toBeUndefined() + }) + + it("names no row when the connection holds nothing, or when there is no connection", () => { + expect(probeRequestFor("openai", {apiKey: ""}, connection()).secret_id).toBeUndefined() + expect(probeRequestFor("openai", {apiKey: ""}, null).secret_id).toBeUndefined() + }) + + it("drops blank extras rather than sending a stored-row probe with empty AWS fields", () => { + const request = probeRequestFor( + "bedrock", + {region: "eu-central-1", bearerToken: ""}, + stored({kind: "bedrock"}), + ) + + expect(request).toEqual({ + provider: {extras: {aws_region_name: "eu-central-1"}}, + secret_id: "sec-1", + }) + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/secret-transforms.test.ts b/web/packages/agenta-entities/tests/unit/secret-transforms.test.ts index 0a1f62fc6a..01f463cc1d 100644 --- a/web/packages/agenta-entities/tests/unit/secret-transforms.test.ts +++ b/web/packages/agenta-entities/tests/unit/secret-transforms.test.ts @@ -1,12 +1,14 @@ import {describe, expect, it} from "vitest" import { + hasStoredKey, transformCustomProviderPayloadData, transformSecret, transformStandardProviderPayloadData, } from "../../src/secret/core/transforms" import { SecretKind, + SecretManagementPolicy, StandardProviderKind, type SecretResponseDto, type StandardProviderDto, @@ -19,6 +21,7 @@ const standardSecret = (data: Partial): SecretResponseDto = kind: SecretKind.ProviderKey, header: {name: "OpenAI 2"}, data: {kind: StandardProviderKind.Openai, provider: {key: "sk-one"}, ...data}, + value_status: {configured: true, preview: null}, }) as unknown as SecretResponseDto describe("transformSecret", () => { @@ -62,6 +65,7 @@ describe("transformSecret", () => { models: [{slug: "gpt-4o-mini"}], harnesses: ["pi_core"], }, + value_status: {configured: true, preview: null}, } as unknown as SecretResponseDto, ]) @@ -155,3 +159,64 @@ describe("transformCustomProviderPayloadData", () => { ).toMatchObject({harnesses: ["claude"]}) }) }) + +describe("write-only records", () => { + const writeOnly = (over: Record = {}): SecretResponseDto => + ({ + id: "id-2", + kind: SecretKind.ProviderKey, + header: {name: "OpenAI"}, + data: {kind: StandardProviderKind.Openai, provider: {key: null}}, + write_only: true, + value_status: {configured: true, preview: "sk-****9Qa"}, + ...over, + }) as unknown as SecretResponseDto + + it("carries the presence answer a redacted record gives in place of its value", () => { + const [row] = transformSecret([writeOnly()]) + + expect(row.key).toBeUndefined() + expect(row.writeOnly).toBe(true) + expect(row.hasKey).toBe(true) + expect(row.keyPreview).toBe("sk-****9Qa") + }) + + it("carries the owner marker of a platform-provisioned record", () => { + const [row] = transformSecret([ + writeOnly({ + management: {policy: SecretManagementPolicy.ManagerOnly}, + }), + ]) + + expect(row.managementPolicy).toBe(SecretManagementPolicy.ManagerOnly) + }) + + it("leaves every write-only field undefined on a legacy readable record", () => { + const [row] = transformSecret([standardSecret({})]) + + expect(row.writeOnly).toBeUndefined() + expect(row.hasKey).toBe(true) + expect(row.managementPolicy).toBeUndefined() + }) +}) + +describe("hasStoredKey", () => { + it("trusts `hasKey` when the record has one, because the value never arrives", () => { + expect(hasStoredKey({hasKey: true})).toBe(true) + expect(hasStoredKey({hasKey: false, key: "sk-stale"})).toBe(false) + }) + + it("falls back to the value for a readable record", () => { + expect(hasStoredKey({key: "sk-live"})).toBe(true) + expect(hasStoredKey({})).toBe(false) + expect(hasStoredKey(null)).toBe(false) + }) +}) + +describe("update payloads", () => { + it("omits the key when the caller has none, so the stored value survives", () => { + const payload = transformStandardProviderPayloadData({}, StandardProviderKind.Openai) + + expect((payload.secret.data as {provider: {key?: string}}).provider).toEqual({}) + }) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index c7710ac6f3..5af0d70054 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -260,7 +260,10 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ version: 1, harness: typeof harnessKind === "string" ? harnessKind : prev.harness, model: modelId ?? prev.model, - provider: connection.provider ?? prev.provider, + // Not `?? prev.provider`: a custom-connection pick deliberately stores none, + // and inheriting the last family would seed the next agent with a provider its + // model contradicts. + provider: connection.provider ?? undefined, connectionMode: connection.mode ?? prev.connectionMode, })) } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSectionView.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSectionView.tsx index 8736dd97df..7d6c3701c6 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSectionView.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSectionView.tsx @@ -26,6 +26,7 @@ import {useEffect, useMemo, useState, type ReactNode} from "react" import { CUSTOM_PROVIDER_KIND_FAMILIES, CustomProviderKind, + hasStoredKey, PROVIDER_LABELS, } from "@agenta/entities/secret" import type {SubscriptionStatusDisplay, SubscriptionStatusTone} from "@agenta/entities/workflow" @@ -558,7 +559,7 @@ export function ProviderCredentialsSectionView({ } label={secret.title ?? secret.name ?? "Provider"} trailing={ - secret.key ? ( + hasStoredKey(secret) ? ( ) : undefined } @@ -617,7 +618,7 @@ export function ProviderCredentialsSectionView({ } label={secret.title ?? secret.name ?? "Provider"} trailing={ - secret.key ? ( + hasStoredKey(secret) ? ( ) : undefined } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx index 32ad32bd70..94511b3712 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderKeyField.tsx @@ -1,6 +1,6 @@ import {useEffect, useId, useRef, useState} from "react" -import {useVaultSecret} from "@agenta/entities/secret" +import {hasStoredKey, useVaultSecret} from "@agenta/entities/secret" import {providerKeyAddedSignalAtom} from "@agenta/shared/state" import type {LlmProvider} from "@agenta/shared/types" import {message} from "@agenta/ui/app-message" @@ -43,7 +43,7 @@ const ProviderKeyField = ({ // mount: the section body stays mounted while collapsed, so a mount-time `autoFocus` would fire // while hidden. Only when there's no key yet — an existing key isn't waiting to be typed. const sectionOpen = useAccordionSectionOpen() - const hasKey = !!provider.key + const hasKey = hasStoredKey(provider) useEffect(() => { if (!sectionOpen || hasKey || disabled) return const t = window.setTimeout(() => inputRef.current?.focus(), 0) @@ -53,7 +53,7 @@ const ProviderKeyField = ({ const save = async () => { const trimmed = key.trim() if (!trimmed || saving || disabled) return - const isFirstKey = !provider.key + const isFirstKey = !hasKey setSaving(true) try { await handleModifyVaultSecret({...provider, key: trimmed}) @@ -80,7 +80,10 @@ const ProviderKeyField = ({ hasKey ? ( - Key configured · enter a new value to replace it. + {/* TODO(copy: owner) */} + {provider.keyPreview + ? `Key configured (${provider.keyPreview}) · enter a new value to replace it.` + : "Key configured · enter a new value to replace it."} ) : null ) : ( @@ -92,14 +95,19 @@ const ProviderKeyField = ({ {hasKey ? ( - Key configured · enter a new value to replace it. + {/* TODO(copy: owner) */} + {provider.keyPreview + ? `Key configured (${provider.keyPreview}) · enter a new value to replace it.` + : "Key configured · enter a new value to replace it."} ) : null}
)}
//..."), which neither the catalog nor the schema knows it by, so + // both are also asked about its bare id — the summary must read as a model name either way. + const bareModel = modelId ? bareConnectionModelId(modelId) : null const modelSummary = [ enumLabel(harnessProps.kind, harness.kind), - modelLabel(capabilities, harnessValue, modelId) ?? enumLabel(props.llm, modelId), + modelLabel(capabilities, harnessValue, modelId) ?? + modelLabel(capabilities, harnessValue, bareModel) ?? + enumLabel(props.llm, bareModel), ] .filter(Boolean) .join(" · ") || undefined diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionPicker.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionPicker.ts index 1b12d9052a..1dbba1a67f 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionPicker.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionPicker.ts @@ -21,6 +21,7 @@ import { SecretKind, + SecretManagementPolicy, bareModelId, connectionSlugFor, harnessSupportsProviderKind, @@ -33,7 +34,7 @@ import { } from "@agenta/entities/secret" import { - modelLabel, + modelDisplayName, modelSelectionMode, vaultPickedProviderFamily, type ConnectionMode, @@ -41,6 +42,9 @@ import { } from "./connectionUtils" import {harnessMetaFor} from "./harnessMeta" +/** The `PROVIDER_ICON_MAP` key that resolves to Agenta's own brand mark. */ +const AGENTA_ICON_KEY = "agenta" + /** * The harnesses whose `self_managed` on-ramp is a consumer SUBSCRIPTION, and the provider family * the subscription covers. @@ -246,7 +250,7 @@ const modelRow = ({ connection: {key: string; name: string} }): PickerModelRow => ({ modelId, - label: modelLabel(capabilities, harness, modelId) ?? label ?? modelId, + label: label ?? modelDisplayName(capabilities, harness, modelId), harness, harnessLabel: harnessMetaFor(harness).label, mode, @@ -341,7 +345,14 @@ const connectionRows = ({ rows.push({ key: connection.id, name: connection.name, - iconKey: connection.kind, + // A connection Agenta provisioned carries Agenta's mark, not the vendor mark of the + // deployment behind it — that deployment is an implementation detail of the offer, and + // naming it here would credit a vendor the user never chose. The row's NAME still comes + // from the record, so what it is called stays the backend's to decide. + iconKey: + connection.managementPolicy === SecretManagementPolicy.ManagerOnly + ? AGENTA_ICON_KEY + : connection.kind, kind: "connection", models, }) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts index 6c42588105..c41be0e7d6 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/connectionUtils.ts @@ -359,6 +359,47 @@ export function modelLabel( return hit?.label ?? hit?.name ?? null } +/** + * The model's own id inside a connection model key. + * + * A credential-set connection stores its models as `model_keys`, spelled + * `//` — and the id can itself carry the deployment's own prefix + * ("Agenta/custom/vertex_ai/gemini-3.6-flash"). None of that namespace is the model's name, so it + * is dropped for display. Anything that is not a model key comes back untouched, which is why the + * second segment must name a deployment before this strips anything: a plain provider-prefixed id + * ("anthropic/claude-opus-4-7") is two segments and never matches. + * + * Display only. The stored config keeps the full key — that is what the resolver matches on. + */ +export function bareConnectionModelId(modelId: string): string { + const parts = modelId.split("/") + if (parts.length < 3 || !isDeploymentProviderKind(parts[1])) return modelId + return parts[parts.length - 1] +} + +/** + * What to CALL a model in the UI: the catalog's curated name when it knows the id, else the id + * itself. Never null — every surface that shows a model needs something to print. + * + * A connection model key is looked up twice, on the stored spelling and on its bare id, so a + * provisioned connection's model reads "Gemini 3.6 Flash" rather than the whole key. No + * prettifier: an id the catalog does not curate is shown exactly as it is stored, because a + * guessed capitalization is worse than the real string. + */ +export function modelDisplayName( + capabilities: HarnessCapabilitiesMap | null | undefined, + harness: string | null | undefined, + modelId: string | null | undefined, +): string { + if (!modelId) return "" + const bare = bareConnectionModelId(modelId) + return ( + modelLabel(capabilities, harness, modelId) ?? + modelLabel(capabilities, harness, bare) ?? + bare + ) +} + /** * The provider family that owns a picked model id, derived from the harness's published models * (the group the id sits in). Returns null when the id is not in any group (e.g. a stale id under @@ -415,7 +456,7 @@ export function harnessAllowsModel( if (vaultSourceSlug(secret) !== slug) continue namesCustomConnection = true const kind = secret.provider?.toLowerCase() || null - const secretModels = (secret.models ?? []).filter(Boolean) + const secretModels = reachableModelIds(secret) if (!secretModels.includes(modelId)) continue if (!kind || harnessReachesCustomProviderKind(capabilities, harness, kind)) return true @@ -433,7 +474,7 @@ export function harnessAllowsModel( if (customSecrets?.length) { for (const secret of customSecrets) { const kind = secret.provider?.toLowerCase() || null - const secretModels = (secret.models ?? []).filter(Boolean) + const secretModels = reachableModelIds(secret) if (!secretModels.includes(modelId)) continue if (!kind || harnessReachesCustomProviderKind(capabilities, harness, kind)) return true } @@ -461,12 +502,26 @@ export interface VaultModelSource { provider?: string /** The connection's own model ids (bare slugs). */ models?: string[] + /** + * The connection's `model_keys` — the fully qualified spelling ("//") the + * picker actually persists for a credential-set connection. Distinct from `models`, which + * holds the bare slugs, so reachability has to accept both or a saved key reads as unavailable. + */ + modelKeys?: string[] } /** The identity the resolver matches this connection on — its stored slug, else its name. */ const vaultSourceSlug = (secret: VaultModelSource): string | null => secret.slug?.trim() || secret.name?.trim() || null +/** + * Every model id a connection can be addressed by: its bare slugs AND its `model_keys`. The picker + * persists whichever the connection publishes (a credential-set connection publishes only keys), so + * a check against `models` alone reads a valid saved config back as unreachable. + */ +const reachableModelIds = (secret: VaultModelSource): string[] => + [...(secret.models ?? []), ...(secret.modelKeys ?? [])].filter(Boolean) + /** * The model FAMILY a hosted model id encodes, matched against the provider families the capability * map knows (union across harnesses — data-driven, no hardcoded vendor list). Deployment-hosted ids @@ -510,18 +565,25 @@ export function soleHarnessProviderFamily( * The provider FAMILY to persist for a vault-hosted model pick (a picker option carrying a * `connectionSlug`, per `vaultModelGroups`). Resolution order: * + * 0. NONE for an OpenAI-compatible (`custom`) connection — see below; * 1. the family the model id itself encodes (`familyFromModelId` — deployment-hosted ids like * "eu.anthropic.claude-haiku-4-5" carry it structurally); * 2. the connection's own kind, but ONLY when that IS already a plain family — a deployment kind * (bedrock/azure/...) is a hosting mechanism and never a valid `llm.provider`; * 3. the sole family the driving harness reaches (`soleHarnessProviderFamily`), which is what * resolves a deployment-hosted id that names only the model ("claude-3-sonnet-...-v1:0" on a - * Bedrock connection under Claude Code); - * 4. `openai` for the OpenAI-compatible (`custom`) deployment. + * Bedrock connection under Claude Code). * * Null when none of them resolves a family. The caller must then write NO provider: inheriting the * previously selected model's family would persist a connection whose provider contradicts it, and * the server validates the pair (`harness_allows_pair`) and fails the run. + * + * A `custom` connection resolves to null on purpose. Its models are stored as `model_keys`, whose + * spelling already names the connection (`/custom/`), and the resolver matches them + * against `ModelRef.to_model_string()` — which a written `provider` turns into `/`, + * missing every key and routing the raw id to the endpoint. The resolver then supplies the family + * itself (`_ConnectionCandidate.resolved_provider` normalizes a provider-less custom connection to + * `openai`), so omitting it is both required and lossless. */ export function vaultPickedProviderFamily( modelId: string | null | undefined, @@ -529,14 +591,11 @@ export function vaultPickedProviderFamily( capabilities: HarnessCapabilitiesMap | null | undefined, harness?: string | null, ): string | null { + if (metadataProvider?.toLowerCase() === OPENAI_COMPATIBLE_KIND) return null const family = familyFromModelId(modelId, capabilities) if (family) return family if (metadataProvider && !isDeploymentProviderKind(metadataProvider)) return metadataProvider - const sole = soleHarnessProviderFamily(capabilities, harness) - if (sole) return sole - if (metadataProvider?.toLowerCase() === OPENAI_COMPATIBLE_KIND) - return OPENAI_COMPATIBLE_DEFAULT_FAMILY - return null + return soleHarnessProviderFamily(capabilities, harness) } // A custom_provider secret's `kind` (its `provider` field) is one of two flavors: a DEPLOYMENT diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts index b5d7d754f0..c607e86cdf 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/index.ts @@ -113,6 +113,8 @@ export { allowedProviders, buildModelOptionGroups, harnessAllowsModel, + bareConnectionModelId, + modelDisplayName, modelLabel, providerForModel, vaultModelGroups, diff --git a/web/packages/agenta-entity-ui/src/DrillInView/index.ts b/web/packages/agenta-entity-ui/src/DrillInView/index.ts index 30c936580a..637185a80f 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/index.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/index.ts @@ -299,6 +299,8 @@ export { allowedProviders, buildModelOptionGroups, harnessAllowsModel, + bareConnectionModelId, + modelDisplayName, modelLabel, providerForModel, vaultModelGroups, diff --git a/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx b/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx index b6a2075087..bfeaa0012f 100644 --- a/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx +++ b/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx @@ -31,13 +31,15 @@ import { manualModelPlaceholderForKind, modelDisplayOrder, probeProviderMutationAtom, + probeFailureMessage, + probeRequestFor, providerModelCatalog, providerTitleForKind, saveProviderConnectionAtom, secretKindForProviderKind, secretNoteForKind, SecretKind, - toProviderCredentials, + storedCredentialFields, type CredentialValues, type ProbeProviderResponse, type ProviderConnection, @@ -162,7 +164,13 @@ const ProviderConnectionCard = ({ setHarnesses(connection?.harnesses ?? null) }, [connection, storedCredential]) - const credentialFilled = hasRequiredCredential(kind, credential) + // A saved write-only record returns no values, so its secret fields arrive empty every time. + // They still count as filled — otherwise editing only the model list would demand the key again. + const storedFields = useMemo(() => storedCredentialFields(connection), [connection]) + // Typed OR already in the vault. Test used to demand typed material, because an empty form had + // no credential to spend; the probe now takes a `secret_id` and resolves the stored one itself, + // so a write-only connection is testable without retyping a key it can never read back. + const credentialFilled = hasRequiredCredential(kind, credential, storedFields) const storedCredentialUnchanged = useMemo( () => !!connection && @@ -270,19 +278,21 @@ const ProviderConnectionCard = ({ if (!projectId) return setProbeFailure(null) setSaveError(null) + const request = probeRequestFor(kind, credential, connection) try { const result = await probeMutation.mutateAsync({ projectId, - kind, - provider: toProviderCredentials(kind, credential), + kind: request.kind, + provider: request.provider, + secretId: request.secret_id, }) setProbe(result) if (!result) setProbeFailure(`Agenta could not read ${title}'s answer.`) - } catch { + } catch (error) { setProbe(null) - setProbeFailure(`Agenta could not reach ${title} to test this credential.`) + setProbeFailure(probeFailureMessage(error, title)) } - }, [projectId, probeMutation, kind, credential, title]) + }, [projectId, probeMutation, kind, credential, connection, title]) const setField = (key: string, value: string) => { setCredential((previous) => ({...previous, [key]: value})) @@ -346,7 +356,7 @@ const ProviderConnectionCard = ({ const statusLine = credentialMessage ? credentialStatusLine( credentialMessage, - discovered ? (probe?.discovery.models.length ?? 0) : null, + discovered ? (probe?.discovery.models?.length ?? 0) : null, ) : null @@ -376,10 +386,27 @@ const ProviderConnectionCard = ({ // Test belongs beside the credential it spends. A JSON credential is a block, // not a line, so it gets the button underneath instead. const inlineTest = field.key === testedField && !block + // Stored but unreadable: the field is a replace box, never a prefilled value. + const replaceOnly = storedFields.includes(field.key) return (
- {field.label} + + {/* TODO(copy: owner) */} + {replaceOnly + ? field.key === "apiKey" + ? "Replace key" + : `Replace ${field.label}` + : field.label} + + {replaceOnly ? ( + + {/* TODO(copy: owner) */} + {connection?.keyPreview + ? `Key configured (${connection.keyPreview}). Leave blank to keep it.` + : "Key configured. Leave blank to keep it."} + + ) : null}
{block ? ( diff --git a/web/packages/agenta-entity-ui/src/secretProvider/ProviderDrawer.tsx b/web/packages/agenta-entity-ui/src/secretProvider/ProviderDrawer.tsx index 0e9354dd14..7709d72cf5 100644 --- a/web/packages/agenta-entity-ui/src/secretProvider/ProviderDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/secretProvider/ProviderDrawer.tsx @@ -16,12 +16,13 @@ * * Design: providers-drawer-final/README.md */ -import {useCallback, useEffect, useState} from "react" +import {useCallback, useEffect, useMemo, useState} from "react" -import type { - ProviderCatalogEntry, - ProviderConnection, - SubscriptionPair, +import { + SecretManagementPolicy, + type ProviderCatalogEntry, + type ProviderConnection, + type SubscriptionPair, } from "@agenta/entities/secret" import {providerTitleForKind} from "@agenta/entities/secret" import {EnhancedDrawer} from "@agenta/ui/drawer" @@ -130,6 +131,20 @@ const ProviderDrawer = ({ width = DRAWER_WIDTH, }: ProviderDrawerProps) => { const [view, setView] = useState({level: "catalog"}) + /** + * The connections the user actually connected. A manager-only one is not editable + * — saving it answers 409 — so it is neither counted nor listed, the same rule the Settings + * table applies. It stays in the `connections` prop the card reads, and in the callers' own + * lists, so the model picker and the "Connect key" gate keep counting it. + */ + const userConnections = useMemo( + () => + connections.filter( + (candidate) => candidate.managementPolicy !== SecretManagementPolicy.ManagerOnly, + ), + [connections], + ) + const visibleCount = userConnections.length const settingsHref = useSettingsHref() // The card owns the save; the footer that triggers it lives out here, so the card publishes // what it needs. Cleared on every level change — the next card publishes its own. @@ -280,7 +295,7 @@ const ProviderDrawer = ({ ) : (

{/* A count over an empty list says nothing; the link is the whole footer then. */} - {connections.length ? `${connections.length} connected` : ""} + {visibleCount ? `${visibleCount} connected` : ""} {settingsHref ? ( // In-app navigation, so `Link` rather than a bare anchor: it prefixes the // host's basePath and skips the full reload. The drawer closes behind it. @@ -317,7 +332,7 @@ const ProviderDrawer = ({ <> {showConnected ? ( showView({ level: "connection", diff --git a/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts b/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts index 15fe438b34..fadf41a79e 100644 --- a/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts @@ -6,7 +6,11 @@ * pair only exists when the harness can both drive the connection and spell the model, and a pick * persists the exact connection slug. Runs under @agenta/entity-ui's own vitest runner. */ -import {SecretKind, type ProviderConnection} from "@agenta/entities/secret" +import { + SecretKind, + SecretManagementPolicy, + type ProviderConnection, +} from "@agenta/entities/secret" import {describe, expect, it} from "vitest" import { @@ -667,3 +671,31 @@ describe("a deployment-hosted connection's provider family", () => { ).not.toHaveProperty("provider") }) }) + +describe("buildConnectionPickerRows: a provisioned connection wears Agenta's mark", () => { + // The deployment behind a provisioned connection is an implementation detail of the offer, so + // its vendor mark would credit a vendor the user never chose. The row's NAME is untouched: + // what the connection is called stays the record's to decide. + const args = (connection: ProviderConnection) => ({ + connections: [connection], + capabilities: CAPABILITIES, + harnessIds: HARNESS_IDS, + showSubscriptions: false, + }) + + it("keys the icon on the manager-only policy, not on the deployment kind", () => { + const managed = custom("m1", "bedrock", ["Agenta/custom/anthropic/claude-fable-5"], { + name: "Agenta", + managementPolicy: SecretManagementPolicy.ManagerOnly, + }) + const [row] = buildConnectionPickerRows(args(managed)) + expect(row.iconKey).toBe("agenta") + expect(row.name).toBe("Agenta") + }) + + it("leaves an ordinary custom connection on its own provider mark", () => { + const own = custom("c1", "bedrock", ["my-bedrock/custom/anthropic/claude-fable-5"]) + const [row] = buildConnectionPickerRows(args(own)) + expect(row.iconKey).toBe("bedrock") + }) +}) diff --git a/web/packages/agenta-entity-ui/tests/unit/connectionUtils.test.ts b/web/packages/agenta-entity-ui/tests/unit/connectionUtils.test.ts index ae2aa1c5da..ce2a35ade7 100644 --- a/web/packages/agenta-entity-ui/tests/unit/connectionUtils.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/connectionUtils.test.ts @@ -16,11 +16,13 @@ import { buildModelOptionGroups, composeModelValue, connectionFromConfig, + bareConnectionModelId, harnessAllowsModel, harnessAllowsProvider, harnessSupportsUserMcp, isDeploymentProviderKind, modelIdFromConfig, + modelDisplayName, modelLabel, modelSelectionMode, providerForModel, @@ -292,6 +294,41 @@ describe("connectionUtils: harness-filtered model picker", () => { ).toBe(false) }) + it("accepts a connection's model_keys, which is what the picker persists", () => { + // A credential-set (custom) connection publishes only `model_keys` — the fully qualified + // "//" spelling the picker saves — while `models` holds bare slugs. A + // check against `models` alone reads a valid saved config back as unavailable and paints + // the red "Unavailable" badge on a working agent. + const secrets = [ + { + name: "Starter credits", + slug: "starter-credits", + provider: "custom", + models: [], + modelKeys: ["Starter credits/custom/vertex_ai/gemini-3.6-flash"], + }, + ] + expect( + harnessAllowsModel( + CAPABILITIES, + "pi_openai_compat", + "Starter credits/custom/vertex_ai/gemini-3.6-flash", + secrets, + "starter-credits", + ), + ).toBe(true) + // A key that connection does not publish is still unreachable. + expect( + harnessAllowsModel( + CAPABILITIES, + "pi_openai_compat", + "someone-else/custom/x", + secrets, + "starter-credits", + ), + ).toBe(false) + }) + it("requires a specific vault connection to explicitly support a model when slug is provided, skipping generic catalog checks (name collision)", () => { const secrets = [{name: "my-custom-conn", provider: "bedrock", models: ["other-model"]}] // "opus" is in the claude catalog. @@ -397,8 +434,21 @@ describe("connectionUtils: model_catalog is preferred when published", () => { deployments: ["direct"], connection_modes: ["agenta", "self_managed"], model_selection: "provider/id", - models: {openai: ["gpt-5.5"], anthropic: ["anthropic/claude-opus-4-7"]}, + models: { + openai: ["gpt-5.5"], + anthropic: ["anthropic/claude-opus-4-7"], + gemini: ["gemini/gemini-3.6-flash"], + }, model_catalog: [ + // The shape the seeded connection's model needs to carry to read as a name. The + // backend publishes this catalog (SDK `capabilities.py`); the frontend only + // displays it, so this entry is what the display path is pinned against. + { + id: "gemini/gemini-3.6-flash", + provider: "gemini", + source: "pi_generated", + name: "Gemini 3.6 Flash", + }, { id: "openai/gpt-5.5", provider: "openai", @@ -456,6 +506,66 @@ describe("connectionUtils: model_catalog is preferred when published", () => { }) }) + describe("bareConnectionModelId / modelDisplayName", () => { + it("strips a connection model key down to the model's own id", () => { + // What a credential-set connection stores: "//", and the + // id can carry the deployment's own prefix too. + expect(bareConnectionModelId("Agenta/custom/vertex_ai/gemini-3.6-flash")).toBe( + "gemini-3.6-flash", + ) + expect(bareConnectionModelId("Starter credits/custom/gpt-oss")).toBe("gpt-oss") + }) + + it("leaves anything that is not a connection model key alone", () => { + // A family-prefixed id is two segments and must survive: stripping it would break + // every catalog lookup that matches on the family prefix. + expect(bareConnectionModelId("anthropic/claude-fable-5")).toBe( + "anthropic/claude-fable-5", + ) + expect(bareConnectionModelId("gpt-5.5")).toBe("gpt-5.5") + expect(bareConnectionModelId("eu.anthropic.claude-haiku-4-5")).toBe( + "eu.anthropic.claude-haiku-4-5", + ) + // Second segment is a provider family, not a deployment — not a key. + expect(bareConnectionModelId("some/openai/thing")).toBe("some/openai/thing") + }) + + it("names a connection model key by the catalog's curated name", () => { + // The user-visible fix: the Model row and the picker read "GPT-5.5", never the key. + expect(modelDisplayName(WITH_CATALOG, "pi_core", "Agenta/custom/openai/gpt-5.5")).toBe( + "GPT-5.5", + ) + }) + + it("names the seeded connection's model from the catalog, prefix and all", () => { + // The exact key a provisioned connection stores, with the deployment's own prefix on + // the tail. Once the backend catalog carries the model, the row reads its name with no + // frontend change — that is the whole contract this pins. + expect( + modelDisplayName( + WITH_CATALOG, + "pi_core", + "Agenta/custom/vertex_ai/gemini-3.6-flash", + ), + ).toBe("Gemini 3.6 Flash") + }) + + it("falls back to the bare id, never to a guessed prettification", () => { + // A model the catalog does not carry: shown exactly as stored, minus the namespace. + expect( + modelDisplayName(WITH_CATALOG, "pi_core", "Agenta/custom/vertex_ai/gemini-4-ultra"), + ).toBe("gemini-4-ultra") + }) + + it("still names an ordinary catalogued id, and returns the id for an unknown one", () => { + expect(modelDisplayName(WITH_CATALOG, "pi_core", "claude-fable-5")).toBe("Fable") + expect(modelDisplayName(WITH_CATALOG, "pi_core", "deepseek/deepseek-v4:nitro")).toBe( + "deepseek/deepseek-v4:nitro", + ) + expect(modelDisplayName(WITH_CATALOG, "pi_core", null)).toBe("") + }) + }) + it("fills the metadata seam: pricing as {input, output} plus description/name/ratings", () => { const groups = buildModelOptionGroups(WITH_CATALOG, "pi_core") const fable = groups @@ -746,27 +856,30 @@ describe("connectionUtils: vaultPickedProviderFamily (F1 — vault pick must per expect(vaultPickedProviderFamily(null, "openai", CAPABILITIES)).toBe("openai") }) - it("defaults an OpenAI-compatible (custom) connection with a bare model id to openai", () => { - // The `custom` kind is a deployment surface (not itself a family), but the OpenAI-compatible - // endpoint speaks the OpenAI dialect — so a provider-less pick resolves to openai instead of - // deferring to the caller's prior-provider fallback (design Decision 8). - expect(vaultPickedProviderFamily("gpt-oss", "custom", CAPABILITIES)).toBe("openai") - expect(vaultPickedProviderFamily("qwen2.5-coder:7b", "custom", CAPABILITIES)).toBe("openai") + it("writes NO provider for an OpenAI-compatible (custom) connection", () => { + // A named custom connection routes by slug alone. Its models are stored as `model_keys` + // ("/custom/") and the resolver matches them against + // `ModelRef.to_model_string()`, which a written provider turns into "/" — + // matching no key, so the raw id reaches the endpoint. The resolver supplies the family + // itself (`resolved_provider` normalizes a provider-less custom connection to openai). + expect(vaultPickedProviderFamily("gpt-oss", "custom", CAPABILITIES)).toBeNull() + expect(vaultPickedProviderFamily("qwen2.5-coder:7b", "custom", CAPABILITIES)).toBeNull() }) - it("still prefers an explicit id-encoded family over the custom openai default", () => { - // If the id itself encodes a known family, that wins even for a custom connection. + it("writes no provider for a custom connection even when the id encodes a family", () => { + // The prefix would break the model_keys match just the same, and "anthropic/" is not a + // route the OpenAI-compatible endpoint understands. expect( vaultPickedProviderFamily("eu.anthropic.claude-haiku-4-5", "custom", CAPABILITIES), - ).toBe("anthropic") + ).toBeNull() }) }) -describe("connectionUtils: custom pick persists openai family AND keeps the connection slug", () => { +describe("connectionUtils: a custom pick keeps the slug and omits the provider", () => { // Mirrors what `useModelHarness.writeModel` composes for a picked OpenAI-compatible option: the - // resolved family (openai, from vaultPickedProviderFamily) plus the option's own connection slug - // (threaded through `metadata.connectionSlug`). Neither may be dropped. - it("composes a ModelRef with provider openai and the preserved agenta slug", () => { + // option's own connection slug (threaded through `metadata.connectionSlug`) is the whole + // routing identity, and no provider rides along to prefix the model id. + it("composes a ModelRef with the agenta slug and no provider key", () => { const provider = vaultPickedProviderFamily("gpt-oss", "custom", CAPABILITIES) const ref = composeModelValue({ modelId: "gpt-oss", @@ -776,8 +889,8 @@ describe("connectionUtils: custom pick persists openai family AND keeps the conn }) expect(ref).toEqual({ model: "gpt-oss", - provider: "openai", connection: {mode: "agenta", slug: "my-gateway"}, }) + expect(ref).not.toHaveProperty("provider") }) }) diff --git a/web/packages/agenta-settings-ui/src/providers/AIProvidersPage.tsx b/web/packages/agenta-settings-ui/src/providers/AIProvidersPage.tsx index 089738d156..eeab2b09d2 100644 --- a/web/packages/agenta-settings-ui/src/providers/AIProvidersPage.tsx +++ b/web/packages/agenta-settings-ui/src/providers/AIProvidersPage.tsx @@ -4,6 +4,7 @@ import { activeModelsSummary, credentialSummary, deleteSecretAtom, + SecretManagementPolicy, providerConnectionsAtom, useVaultSecret, type ProviderConnection, @@ -72,8 +73,18 @@ export const AIProvidersPage = ({ const canRemove = Boolean(renderRemoveDialog) + // A connection Agenta provisioned is not the user's to edit or remove — the API answers 409 — + // so it is not listed here. It stays in `providerConnectionsAtom`, which is what the composer + // gate and the model pickers count: hiding the row must not make the project look keyless, + // which is also why the drawer below still receives the unfiltered list. const rows = useMemo( - () => connections.map((connection) => ({...connection, key: connection.id})), + () => + connections + .filter( + (connection) => + connection.managementPolicy !== SecretManagementPolicy.ManagerOnly, + ) + .map((connection) => ({...connection, key: connection.id})), [connections], ) diff --git a/web/packages/agenta-shared/src/state/index.ts b/web/packages/agenta-shared/src/state/index.ts index 4f6fb14949..22cf7cd4c4 100644 --- a/web/packages/agenta-shared/src/state/index.ts +++ b/web/packages/agenta-shared/src/state/index.ts @@ -14,6 +14,7 @@ export {simulatedAgentRunAtomFamily} from "./simulatedAgentRun" export type {SimulatedAgentRunRequest} from "./simulatedAgentRun" export {openAgentConfigSectionAtom} from "./openConfigSection" export type {AgentConfigSection} from "./openConfigSection" +export {openProviderDrawerRequestAtom} from "./openProviderDrawer" export {agentSelfCommitSignalAtom} from "./agentCommitSignal" export type {AgentSelfCommitSignal} from "./agentCommitSignal" export {draftConfigChangeSignalAtom} from "./draftConfigChangeSignal" diff --git a/web/packages/agenta-shared/src/state/openProviderDrawer.ts b/web/packages/agenta-shared/src/state/openProviderDrawer.ts new file mode 100644 index 0000000000..19d6db9906 --- /dev/null +++ b/web/packages/agenta-shared/src/state/openProviderDrawer.ts @@ -0,0 +1,8 @@ +import {atom} from "jotai" + +/** + * Cross-component request to open the chat's provider drawer (the add-your-own-key form). + * Set by a remote trigger (e.g. the failed-run callout in the transcript) and consumed by + * `ConnectModelBanner`, which owns that drawer and clears this back to `false`. + */ +export const openProviderDrawerRequestAtom = atom(false) diff --git a/web/packages/agenta-shared/src/types/llmProvider.ts b/web/packages/agenta-shared/src/types/llmProvider.ts index ced8fd7362..a8a90ee031 100644 --- a/web/packages/agenta-shared/src/types/llmProvider.ts +++ b/web/packages/agenta-shared/src/types/llmProvider.ts @@ -38,6 +38,14 @@ export interface LlmProvider { displayName?: string /** Harnesses this connection may drive; absent means any harness Agenta supports. */ harnesses?: string[] + /** The row is write-only: the vault stores its value but never returns it. */ + writeOnly?: boolean + /** Whether the vault holds a credential for this row. The only presence check a write-only row has. */ + hasKey?: boolean + /** Masked credential (`sk-****9Qa`) a write-only row carries in place of its value. */ + keyPreview?: string + /** Public management policy for the row; internal manager identity is never exposed. */ + managementPolicy?: string id?: string type?: string created_at?: string diff --git a/web/packages/agenta-ui/src/LLMIcons/assets/Agenta.tsx b/web/packages/agenta-ui/src/LLMIcons/assets/Agenta.tsx new file mode 100644 index 0000000000..47f7e5d83a --- /dev/null +++ b/web/packages/agenta-ui/src/LLMIcons/assets/Agenta.tsx @@ -0,0 +1,29 @@ +import {IconProps} from "./types" + +/** + * Agenta's own brand mark, for a connection Agenta provisioned — those are not any one vendor's, + * so no vendor mark is honest for them. + * + * Traced from the sidebar's mark (`SidebarLogo`) rather than imported: that lives in + * `@agenta/navigation-ui`, which sits above `@agenta/ui`. Same path, same theme accent, and the + * same 171x140 viewBox — so it letterboxes inside a square icon box exactly as the rail's does. + */ +const Agenta = ({...props}: IconProps) => { + return ( + + + + ) +} + +export default Agenta diff --git a/web/packages/agenta-ui/src/LLMIcons/index.ts b/web/packages/agenta-ui/src/LLMIcons/index.ts index 1c40a08f65..217527e2b9 100644 --- a/web/packages/agenta-ui/src/LLMIcons/index.ts +++ b/web/packages/agenta-ui/src/LLMIcons/index.ts @@ -12,6 +12,7 @@ * ``` */ +import Agenta from "./assets/Agenta" import AlephAlpha from "./assets/AlephAlpha" import Anthropic from "./assets/Anthropic" import AnyScale from "./assets/AnyScale" @@ -42,6 +43,7 @@ export type {IconProps} from "./assets/types" * Use this to look up icons by provider name. */ export const LLMIconMap: Record> = { + Agenta: Agenta, OpenAI: OpenAi, Cohere: Cerebus, Anyscale: AnyScale, @@ -67,6 +69,7 @@ export const LLMIconMap: Record> = { // Export individual icons for direct use export { + Agenta, AlephAlpha, Anthropic, AnyScale, diff --git a/web/packages/agenta-ui/src/SelectLLMProvider/utils.ts b/web/packages/agenta-ui/src/SelectLLMProvider/utils.ts index 2e075f0663..6e97c008dd 100644 --- a/web/packages/agenta-ui/src/SelectLLMProvider/utils.ts +++ b/web/packages/agenta-ui/src/SelectLLMProvider/utils.ts @@ -15,6 +15,9 @@ export function capitalize(str: string): string { * Map normalized (lowercase, `_`-separated) provider keys to LLMIcons display labels. */ export const PROVIDER_ICON_MAP: Record = { + // Not a vendor: the mark a connection Agenta provisioned carries, since no vendor mark is + // honest for one. + agenta: "Agenta", anthropic: "Anthropic", openai: "OpenAI", // OpenAI's ChatGPT/Codex subscription provider — reuses the OpenAI mark (no distinct icon From ba0f2f81735d024aa3542dbcb8d49214d63a0ffe Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 00:51:38 +0200 Subject: [PATCH 2/8] docs(secrets): record isolated stack verification --- docs/design/write-only-secrets/implementation-report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/write-only-secrets/implementation-report.md b/docs/design/write-only-secrets/implementation-report.md index 30b1c3911e..7f1bb3f98b 100644 --- a/docs/design/write-only-secrets/implementation-report.md +++ b/docs/design/write-only-secrets/implementation-report.md @@ -96,8 +96,8 @@ or guard, and #6165 introduces that complete contract. The final combined behavi Local automated verification passed: -- 2,664 OSS API unit tests after the v0.114 rebase. -- 3,005 combined OSS and EE API unit tests after the v0.114 rebase. +- 2,635 OSS API unit tests from an isolated #6164 checkout (73 Postgres/live-key tests skipped). +- 3,005 combined OSS and EE API unit tests from an isolated final-stack checkout (73 Postgres/live-key tests skipped). - 233 focused API tests covering secrets, grants, middleware, provider probe, SSO/webhook behavior, and starter-credit seeding/client behavior. - Standalone boundary checks after the split: 55 write-only tests on #6164 alone, 65 From f9026a771f2fe4ab35cd8ad79c477ddace87bcc0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 00:58:15 +0200 Subject: [PATCH 3/8] style(web): format managed-secret tests --- .../agenta-entities/tests/unit/provider-connections.test.ts | 6 +++++- .../agenta-entity-ui/tests/unit/connectionPicker.test.ts | 6 +----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-entities/tests/unit/provider-connections.test.ts b/web/packages/agenta-entities/tests/unit/provider-connections.test.ts index 2614b95905..8e1128a214 100644 --- a/web/packages/agenta-entities/tests/unit/provider-connections.test.ts +++ b/web/packages/agenta-entities/tests/unit/provider-connections.test.ts @@ -28,7 +28,11 @@ import { credentialFieldsForKind, secretKindForProviderKind, } from "../../src/secret/core/providerCatalog" -import {SecretKind, SecretManagementPolicy, VAULT_PERSIST_REDACTED} from "../../src/secret/core/types" +import { + SecretKind, + SecretManagementPolicy, + VAULT_PERSIST_REDACTED, +} from "../../src/secret/core/types" const fernProbeProvider = vi.fn() diff --git a/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts b/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts index fadf41a79e..e18911fe66 100644 --- a/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/connectionPicker.test.ts @@ -6,11 +6,7 @@ * pair only exists when the harness can both drive the connection and spell the model, and a pick * persists the exact connection slug. Runs under @agenta/entity-ui's own vitest runner. */ -import { - SecretKind, - SecretManagementPolicy, - type ProviderConnection, -} from "@agenta/entities/secret" +import {SecretKind, SecretManagementPolicy, type ProviderConnection} from "@agenta/entities/secret" import {describe, expect, it} from "vitest" import { From 549824933497315ae077435ed2f9fe6cbe68fcbc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 12:01:29 +0200 Subject: [PATCH 4/8] docs(secrets): align the final release contract --- docs/design/write-only-secrets/context.md | 2 +- .../implementation-report.md | 17 +++++- docs/design/write-only-secrets/plan.md | 3 + docs/design/write-only-secrets/qa.md | 12 +++- docs/design/write-only-secrets/research.md | 60 ++++++++++++++----- docs/design/write-only-secrets/review.md | 41 ++++++------- 6 files changed, 91 insertions(+), 44 deletions(-) diff --git a/docs/design/write-only-secrets/context.md b/docs/design/write-only-secrets/context.md index 9ae7d411c3..aad65fc76d 100644 --- a/docs/design/write-only-secrets/context.md +++ b/docs/design/write-only-secrets/context.md @@ -27,4 +27,4 @@ Ship the approved review decisions without a database migration or feature flag. ## Pull request order -The dependency chain is `release/v0.114.0` to #6164 to #6165 to #6138 to #6195. #6174 is a separate frontend consumer based on `release/v0.114.0`. Each dependent PR keeps the branch below it as its immediate GitHub base so its diff remains reviewable. +The dependency chain is `release/v0.114.0` to #6164 to #6165 to #6138 to #6195 to #6174. Each PR uses the preceding branch as its immediate GitHub base so every diff remains reviewable. The backend and frontend deploy together in one release. diff --git a/docs/design/write-only-secrets/implementation-report.md b/docs/design/write-only-secrets/implementation-report.md index 7f1bb3f98b..e1b6bf6353 100644 --- a/docs/design/write-only-secrets/implementation-report.md +++ b/docs/design/write-only-secrets/implementation-report.md @@ -1,6 +1,6 @@ # Write-only and managed secrets implementation report -Date: 2026-08-22 +Date: 2026-08-23 ## Outcome @@ -17,6 +17,12 @@ caught managed-secret imports that had accidentally landed below #6165. The six were split at the real ownership boundary: #6164 now has no managed-secret import, field, storage, or guard, and #6165 introduces that complete contract. The final combined behavior is unchanged. +A final consumer-contract audit found that three Python SDK paths still read the removed +`has_key` field. #6164 now centralizes `value_status.configured` parsing in the shared +credential module, uses it in connection resolution, named-secret resolution, and legacy Vault +middleware, and tests the SDK helper against a serialized public API DTO. No legacy field fallback +is included because the backend, SDK, generated clients, and frontend ship together. + ## Changes by PR ### #6164: write-only secret contract @@ -33,6 +39,9 @@ or guard, and #6165 introduces that complete contract. The final combined behavi DAO's locked current row. - Centralized and allowlisted the `secret-resolve` grant. - Preserved the provider-specific standalone environment fallback. +- Updated every Python SDK redaction consumer to use `value_status.configured` and removed all + production reads of `has_key`. +- Added a serialized public-DTO contract test so SDK fixtures cannot drift from the API response. - Kept SSO and webhook secrets explicitly readable with `write_only=False`. - Removed the admin-key fallback. `AGENTA_SERVICES_INTERNAL_KEY` is the only accepted internal proof, and the API now fails startup when it is missing or still `replace-me`. @@ -82,9 +91,15 @@ or guard, and #6165 introduces that complete contract. The final combined behavi at the frontend boundary. - Hides manager-only connections from Settings and edit drawers, while retaining them in the shared connection atom, agent defaults, key gating, and model picker. +- Omits untouched credentials on update. The backend keeps omitted values and rejects explicit + blank provider credentials. ## Data and compatibility +- The backend, Python SDK, generated clients, and frontend use one `value_status` contract. No + `has_key` compatibility path is retained. +- Backend and frontend deploy together, so strict omit-to-keep semantics do not create an + intermediate release state. - No database migration is introduced. - Existing rows without `write_only` resolve as readable. - Existing rows without `management` resolve as unmanaged. diff --git a/docs/design/write-only-secrets/plan.md b/docs/design/write-only-secrets/plan.md index c70dc95bcb..16fe9ba8f2 100644 --- a/docs/design/write-only-secrets/plan.md +++ b/docs/design/write-only-secrets/plan.md @@ -12,6 +12,7 @@ Acceptance checks: - Omitted update credentials are carried from the locked row without crossing secret identity. - SSO and webhook creation remain readable even when the default is write-only. - Generated OpenAPI and Fern types include the final public contract. +- Python SDK consumers read `value_status.configured`; no production consumer reads `has_key`. - Focused API, SDK, services, configuration, and generated-client checks pass. ## Slice 2: structured management (#6165) @@ -45,6 +46,8 @@ Regenerate Fern after the backend contract is final. Remove handwritten response Acceptance checks: - Frontend packages compile against generated types without manual backend-field extensions. +- Untouched frontend credentials are omitted on update; explicit blanks are never used as a + keep signal. - Managed connection visibility and editing behavior match the approved UX. - A managed stored secret cannot be probed. - User-managed stored-secret probe behavior and credential-free responses remain unchanged. diff --git a/docs/design/write-only-secrets/qa.md b/docs/design/write-only-secrets/qa.md index 5ec25ef647..efa41e6679 100644 --- a/docs/design/write-only-secrets/qa.md +++ b/docs/design/write-only-secrets/qa.md @@ -28,14 +28,17 @@ Expected: 1. Create an OpenAI provider connection. 2. Inspect the create and list responses. 3. Reload Settings and edit only its models or display name without re-entering the key. -4. Try to submit an update that changes `write_only`. -5. Delete and recreate it if a different visibility policy is required. +4. Send a direct update that explicitly supplies `key: ""`. +5. Try to submit an update that changes `write_only`. +6. Delete and recreate it if a different visibility policy is required. Expected: - The value is never returned to the browser. - `value_status.configured=true`; preview is optional and safe. - The unrelated edit keeps the stored key. +- The frontend omits the untouched key, and the stored value is retained. +- A direct explicit blank is rejected; it is never interpreted as the keep signal. - The API refuses a visibility-policy change. ### 3. Cache and invalidation @@ -60,13 +63,16 @@ Expected: 3. Verify the runner receives a short-lived granted token, not the internal key. 4. Run the standalone SDK with the Vault value redacted and the matching provider environment credential set. -5. Repeat without the matching environment credential and with an unrelated provider credential. +5. Inspect the redacted response and confirm it contains `value_status.configured=true` and no + `has_key`. +6. Repeat without the matching environment credential and with an unrelated provider credential. Expected: - The platform run succeeds. - The internal key never reaches the runner or sandbox. - The matching standalone fallback succeeds. +- The SDK recognizes redaction through `value_status` and contains no legacy-field fallback. - Missing or unrelated credentials fail clearly and are never borrowed across providers. ### 5. SSO and webhook regression diff --git a/docs/design/write-only-secrets/research.md b/docs/design/write-only-secrets/research.md index 69874d37ce..ab34ae83ff 100644 --- a/docs/design/write-only-secrets/research.md +++ b/docs/design/write-only-secrets/research.md @@ -1,32 +1,60 @@ # Research -## Current implementation - -- #6164 currently removes the Vault list cache, supports changing `write_only` during update, returns `has_key` and `key_preview`, and uses one DTO inheritance tree for create, update, trusted reads, and public responses. -- The current update resolver mutates the caller's DTO. The Postgres DAO owns part of the `write_only` policy while it also owns the row lock. -- The runtime proof uses `X-Agenta-Runtime-Key`. The dedicated configuration exists, but documentation and failure behavior still need a complete source walk. -- SSO and webhook paths have dedicated readable-secret behavior. Every creation path still needs an explicit-policy audit. -- #6165 stores a free-form `managed_by` string in encrypted JSON. Public request DTOs structurally accept it, routes reject it, and `allow_managed=True` bypasses ownership checks. -- #6165 derives `write_only=True` from management, although the two policies have different owners and lifecycles. -- #6138 uses one string marker for bridge identity and creates the starter-credit row with both management and write-only behavior. -- #6174 hand-maintains backend response fields around the generated Fern type. -- #6195 can load a stored plaintext credential and merge it with caller-supplied provider configuration. It needs a managed-secret guard before outbound probing. +## Review baseline + +The initial review found these problems across the five-PR stack: + +- #6164 removed the Vault list cache, allowed `write_only` changes during update, returned + key-specific `has_key` and `key_preview` fields, and reused one DTO hierarchy for incompatible + create, update, trusted-read, and public-response roles. +- The update resolver mutated its caller DTO, while the DAO mixed row-lock mechanics with + write-only policy. +- Runtime proof could inherit the administrator key and deployment failure behavior was incomplete. +- SSO depended on an implicit visibility default, and every readable SSO/webhook creation path + needed an explicit audit. +- #6165 stored a free-form public `managed_by` string and exposed a universal + `allow_managed=True` bypass. +- #6138 reused one marker for proxy metadata, Vault ownership, and user-facing copy. +- #6174 hand-maintained backend response fields around a generated Fern type. +- #6195 could spend a managed plaintext credential through a caller-configured provider probe. +- Python SDK consumers still read the removed `has_key` field after the API moved to + `value_status`. + +## Final implementation + +- Vault list reads cache canonical trusted DTOs and apply caller projection after retrieval. +- `write_only` is selected at creation and cannot change through update. +- Public responses use `value_status`; all Python SDK consumers read that same structure. +- Omitted update credentials keep the locked-row value. Explicit blank provider credentials are + invalid. The co-released frontend omits untouched credentials. +- `AGENTA_SERVICES_INTERNAL_KEY` is the only internal proof and is mandatory at API startup. +- SSO and webhooks explicitly remain readable. +- Managed storage uses typed internal manager identity and public mutation policy. General + mutations have no ownership bypass. +- The starter-credit bridge creates one explicitly managed and explicitly write-only row. +- Managed credentials cannot be spent through the provider-probe path. +- Fern Python and TypeScript clients are generated from the final combined OpenAPI contract in + #6174, which also contains the frontend consumers. ## Storage compatibility -`write_only` already lives inside encrypted JSON. Structured management can also live there under `management`. Rows without either field map to readable and unmanaged defaults. This keeps the production change compatible without a schema migration. +`write_only` and structured `management` live inside the existing encrypted JSON payload. Rows +without those fields map to readable and unmanaged defaults. No database migration is required. ## Interface classification - Secret value fields are credential data. - `write_only` is value-visibility policy selected at resource creation. - `management.manager` is internal lifecycle ownership metadata. -- `management.policy` is user-mutation policy. +- `management.policy` is public user-mutation policy. - `value_status` is public response metadata derived from the trusted value. - Runtime grants are authorization policy carried in signed protocol context. -These roles remain separate in the final models. The frontend receives public policy and status, not the internal manager identifier. +The final models keep these roles separate. The frontend receives public policy and status, not +the internal manager identifier. -## Workspace state +## Workspace boundary -The secrets lanes were rebased locally by another agent after the last push. Their local tips differ from the remote PR heads and require force-with-lease updates after implementation. The shared workspace also contains unrelated pi-traces work, website work, hooks, and other lanes. Only secrets-owned changes may enter these PRs. +The secrets stack shares a GitButler workspace with unrelated Pi-traces, website, hooks, and other +work. Only secrets-owned changes belong in these five PRs. The Pi branches and runner files remain +out of scope. diff --git a/docs/design/write-only-secrets/review.md b/docs/design/write-only-secrets/review.md index dae67697dc..3c30a288f5 100644 --- a/docs/design/write-only-secrets/review.md +++ b/docs/design/write-only-secrets/review.md @@ -2,11 +2,7 @@ This review covers the write-only and managed-secret stack: [#6164](https://github.com/Agenta-AI/agenta/pull/6164) defines write-only secrets, [#6165](https://github.com/Agenta-AI/agenta/pull/6165) defines managed secrets, [#6138](https://github.com/Agenta-AI/agenta/pull/6138) creates the first managed secret, [#6174](https://github.com/Agenta-AI/agenta/pull/6174) is the frontend consumer, and [#6195](https://github.com/Agenta-AI/agenta/pull/6195) is the provider-probe consumer. -The backend stack is rooted in `release/v0.114.0`; managed secrets, seeded credits, and -provider probe use the preceding backend branch as their immediate GitHub base. The frontend PR -is based directly on `release/v0.114.0` and must merge after the backend stack. References to -`main`, removed PR #6135, a later gate flip, or another root base are stale and must be -removed from PR descriptions and design documentation. +Status: resolved. Every required change in this review is implemented. The five PRs form one release chain rooted in `release/v0.114.0`: #6164, #6165, #6138, #6195, then #6174. Each PR uses the preceding branch as its immediate GitHub base, and backend plus frontend deploy together. ## Owner decisions @@ -31,17 +27,17 @@ These are settled decisions for this review: ### 1. Finalize the API model and regenerate Fern -The backend contract is currently represented manually in #6174. Its frontend type intersects the generated `SecretResponseDto` with handwritten `write_only`, `managed_by`, `has_key`, and `key_preview` fields, and separately makes provider keys optional. That proves the generated client does not yet contain the contract the frontend consumes. +At review time, the backend contract was represented manually in #6174. Its frontend type intersected the generated `SecretResponseDto` with handwritten `write_only`, `managed_by`, `has_key`, and `key_preview` fields, and separately made provider keys optional. That proved the generated client does not yet contain the contract the frontend consumes. Requested change: 1. Finalize the backend DTO names and shapes described below. -2. Regenerate the Fern client in #6164 and commit the generated files. -3. Update #6174 to consume those generated types directly. +2. Generate the final Fern clients from the combined OpenAPI contract in #6174. +3. Consume those generated types directly in #6174. 4. Remove the handwritten response intersection, key-optional intersection, and related casts from #6174. 5. Update backend OpenAPI/contract tests so a later generation cannot silently lose these fields. -The backend PR must define the wire contract. The frontend PR should consume it, not maintain a second copy of it. +The backend DTOs and OpenAPI define the wire contract. #6174 generates the final Python and TypeScript clients from the combined stack and consumes those generated types without a handwritten copy. ### 2. Restore the Vault list cache without changing its key scheme @@ -68,7 +64,7 @@ That uncertainty is not a reason to change a platform-wide cache convention insi ### 3. Make `write_only` a creation-time policy -The current false-to-true update path should be removed. It is surprising for a normal secret update to change whether an existing value can ever be read again, and it creates a security-sensitive cache transition that would need stronger invalidation coordination. +The reviewed false-to-true update path had to be removed. It is surprising for a normal secret update to change whether an existing value can ever be read again, and it creates a security-sensitive cache transition that would need stronger invalidation coordination. Requested behavior: @@ -83,7 +79,7 @@ This also means a late cache refill cannot convert a newly write-only record bac Webhook creation already explicitly sets `write_only=False`. Keep that behavior. -SSO currently relies on the default rather than stating the policy. Set `write_only=False` explicitly on every SSO `CreateSecretDTO` call path, including create-on-edit paths if present. +At review time, SSO relied on the default rather than stating the policy. Set `write_only=False` explicitly on every SSO `CreateSecretDTO` call path, including create-on-edit paths if present. This is important because the current SSO settings form reads the stored `client_secret` to prefill and validate edits. If SSO became write-only, the outward response would omit `client_secret`, and editing unrelated SSO fields would require the administrator to re-enter it. That would be a regression introduced by applying write-only behavior to SSO, not an existing SSO bug. @@ -134,7 +130,7 @@ For future per-secret permissions, extend the same concept with an action and re } ``` -The current implementation does not need `secret_scope`. This shape records the direction so the project-wide grant does not become an accidental permanent contract. +This release does not need `secret_scope`. This shape records the direction so the project-wide grant does not become an accidental permanent contract. ### 7. Keep the standalone environment fallback @@ -217,7 +213,7 @@ This keeps the transaction safe without coupling persistence code to one secret ### 10. Close the provider-probe managed-secret hole in #6195 -#6195 allows `/providers/probe` to load plaintext by `secret_id` and combine it with caller-supplied provider configuration. #6165 prevents users from editing or deleting `managed_by` secrets, but the probe path does not currently apply that managed-secret guard. +At review time, #6195 allowed `/providers/probe` to load plaintext by `secret_id` and combine it with caller-supplied provider configuration, while the probe path did not apply the managed-secret guard. As a result, a caller could ask the backend to send a managed credential to a caller-selected endpoint. The credential is not returned in the HTTP response, but it leaves the intended provider boundary. That defeats the purpose of making the managed record immutable. @@ -225,7 +221,7 @@ Requested short fix: reject `secret_id` probing when the loaded internal secret ### 11. Split internal ownership from the public managed-secret contract -#6165 currently puts `managed_by: str | None` on `CreateSecretDTO`, `UpdateSecretDTO`, and `SecretResponseDTO`. The public routes then accept the field structurally and reject it at runtime. #6174 copies the same internal component string into `LlmProvider.managedBy` and uses its truthiness to decide whether the row appears in Settings. +At review time, #6165 put `managed_by: str | None` on `CreateSecretDTO`, `UpdateSecretDTO`, and `SecretResponseDTO`. The public routes then accepted the field structurally and rejected it at runtime. #6174 copied the same internal component string into `LlmProvider.managedBy` and used its truthiness to decide whether the row appears in Settings. This mixes three different concerns: @@ -336,9 +332,9 @@ When a real owner operation is needed later, take a typed `manager: SecretManage ### 14. Enforce the managed guard against the locked row -The current update flow reads the row in `VaultService`, checks `managed_by`, and only afterward asks the DAO to acquire `SELECT ... FOR UPDATE`. The resolver executed under the DAO lock handles credential carry-over but does not repeat the managed check. A row can therefore change between the ownership check and the write. +At review time, the update flow read the row in `VaultService`, checked `managed_by`, and only afterward asked the DAO to acquire `SELECT ... FOR UPDATE`. The resolver executed under the DAO lock handled credential carry-over but did not repeat the managed check. A row could therefore change between the ownership check and the write. -Delete has the same check-then-act shape: the service reads and checks, then the DAO opens a separate transaction and deletes without locking and rechecking the policy. +Delete had the same check-then-act shape: the service read and checked, then the DAO opened a separate transaction and deleted without locking and rechecking the policy. Even if the first bridge never changes its marker, the implementation and tests claim a general ownership invariant. That invariant must be true at the persistence boundary. @@ -353,7 +349,7 @@ The same locked-row structure can later compare a typed owner for an explicit in ### 15. Update #6138 to use the managed-secret boundary, not its storage DTO -#6138 currently constructs the general `CreateSecretDTO` with both `managed_by=ORIGIN_MARKER` and `write_only=True`. It also uses the same `ORIGIN_MARKER` value for three semantic roles: proxy audit metadata, Vault manager identity, and the user-facing header description. +#6138 initially constructed the general `CreateSecretDTO` with both `managed_by=ORIGIN_MARKER` and `write_only=True`. It also used the same `ORIGIN_MARKER` value for three semantic roles: proxy audit metadata, Vault manager identity, and the user-facing header description. Requested change: @@ -369,7 +365,7 @@ The Vault list cache also has an internal writer now. Cache invalidation should ### 16. Make the managed-secret UX decision explicit -#6138's live PR description says the seeded connection remains visible in Settings, while #6174 currently filters every `managedBy` row out of the Settings table. The code still keeps it in the shared connection atom so model selection and run gating can use it. +At review time, #6138 described the seeded connection as visible in Settings while #6174 filtered every `managedBy` row out of the Settings table. The final code keeps it in the shared connection atom so model selection and run gating can use it. This is a documentation/UX inconsistency, not a reason to expose the internal manager string. Pick the intended presentation and make the PR description, design document, and frontend test agree: @@ -384,12 +380,11 @@ The runtime grant, not `AGENTA_SERVICES_INTERNAL_KEY`, can reach the runner. The That means a runner trusted to execute a workload can resolve the project's secrets. This is accepted for the first version and matches the feature's stated trust model: write-only prevents casual API/UI reads; it does not claim to protect a secret from the workload authorized to use it. Per-secret runner scope is a future tightening, not a blocker for this PR. -## Documentation and test updates required before merge +## Final documentation and test contract -Update `docs/design/write-only-secrets/README.md` and the PR descriptions so they describe the final production behavior: +The README and PR descriptions describe this final production behavior: - base is `release/v0.114.0`; -- no reference to removed PR #6135; - no feature flag or later gate flip; - Vault list cache is restored and redaction happens after canonical cache retrieval; - `write_only` is creation-time and immutable; @@ -404,7 +399,7 @@ Update `docs/design/write-only-secrets/README.md` and the PR descriptions so the - Fern is regenerated and the frontend consumes generated types; - runtime trust and future per-secret scope are documented accurately. -At minimum, tests must cover: +The final stack tests cover: - cached plaintext returned to a granted runtime and redacted to a normal caller from the same cache entry; - cache miss and cache hit produce the same public response; @@ -425,6 +420,6 @@ At minimum, tests must cover: ## Merge assessment -The overall direction is sound, but the stack should not merge until the required changes above are incorporated. The main blockers are the uncached high-frequency list path, the duplicated frontend contract, the mutable `write_only` policy, the generic-key fallback for internal-service proof, the key-specific public model/DAO coupling, the public/internal `managed_by` coupling, the universal `allow_managed` bypass, the pre-lock managed mutation checks, and the managed-secret probe escape in the companion PR. +The required architecture changes are incorporated. The later contract audit also found and fixed the Python SDK consumers that still read `has_key`. The SDK now reads `value_status` exclusively, and the co-released frontend omits untouched credential fields while the backend continues to reject explicit blanks. No database migration, cache-generation scheme, full-UUID cache-key rewrite, general token-claims framework, removal of standalone fallback, or SSO frontend rewrite is requested in this review. From 7bd05ee604676660597f7767fb8f4d40ef5a9f5c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 12:08:50 +0200 Subject: [PATCH 5/8] docs(secrets): record final local verification --- .../implementation-report.md | 12 ++++++++++++ docs/design/write-only-secrets/qa.md | 17 +++++++++++++++++ docs/design/write-only-secrets/status.md | 19 ++++++++++++++----- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/design/write-only-secrets/implementation-report.md b/docs/design/write-only-secrets/implementation-report.md index e1b6bf6353..d9432fff03 100644 --- a/docs/design/write-only-secrets/implementation-report.md +++ b/docs/design/write-only-secrets/implementation-report.md @@ -111,6 +111,14 @@ is included because the backend, SDK, generated clients, and frontend ship toget Local automated verification passed: +- Final working-tree gate after the SDK contract audit: 2,668 API unit tests passed (73 + Postgres/live-key tests skipped) and 2,241 SDK unit tests passed (4 skipped, 16 expected + failures). The SDK gate excluded one cross-package streaming assertion owned by the parallel + pi-traces work; it now observes that lane and its transient `environment_starting` event. +- Final focused contract gate: 56 API write-only/Vault route tests, 31 SDK write-only/HTTP/parity + tests, and 100 frontend entity connection/transform tests passed. +- The `@agenta/entities` package build and TypeScript type-check passed. + - 2,635 OSS API unit tests from an isolated #6164 checkout (73 Postgres/live-key tests skipped). - 3,005 combined OSS and EE API unit tests from an isolated final-stack checkout (73 Postgres/live-key tests skipped). - 233 focused API tests covering secrets, grants, middleware, provider probe, SSO/webhook @@ -133,6 +141,10 @@ Local automated verification passed: Railway live checks were not run because Railway is unavailable. They remain a release-QA item. +The complete acceptance runners were also invoked without a deployed local stack. Their deployed +tests stopped at setup because `AGENTA_API_URL` and `AGENTA_AUTH_KEY` were absent; the API run had +no assertion failures. This is an environment limit, not a green deployed-acceptance claim. + ## Accepted boundary and follow-up A short-lived granted Secret token reaches the runner because the runner must resolve secrets for diff --git a/docs/design/write-only-secrets/qa.md b/docs/design/write-only-secrets/qa.md index efa41e6679..b1a0320d47 100644 --- a/docs/design/write-only-secrets/qa.md +++ b/docs/design/write-only-secrets/qa.md @@ -120,3 +120,20 @@ Expected: Railway-dependent deployment and end-to-end checks are blocked while Railway is unavailable. Run the same release-blocking flows on the Railway preview before merge or release, and record the preview URL, build SHA, and result in the PR QA comment. + +## Local verification record + +Passed on 2026-08-23: + +- 2,668 API unit tests (73 Postgres/live-key tests skipped). +- 2,241 SDK unit tests after excluding the cross-package runner streaming assertion owned by + the parallel pi-traces lane (4 skipped, 16 expected failures). +- 56 focused API write-only and Vault-route tests. +- 31 focused SDK write-only, HTTP, and credential-parity tests. +- 100 frontend entity connection and secret-transform tests. +- `@agenta/entities` build and TypeScript type-check. +- Scoped Ruff formatting and linting, plus `git diff --check`. + +The full acceptance commands were attempted without a deployed local stack. Tests that require +`AGENTA_API_URL` and `AGENTA_AUTH_KEY` stopped during setup, so deployed acceptance remains part +of the release QA above. diff --git a/docs/design/write-only-secrets/status.md b/docs/design/write-only-secrets/status.md index 7971dbccf8..27ae15b3bf 100644 --- a/docs/design/write-only-secrets/status.md +++ b/docs/design/write-only-secrets/status.md @@ -2,7 +2,7 @@ Status: implementation complete; local verification green -Date: 2026-08-22 +Date: 2026-08-23 ## Current work @@ -15,17 +15,26 @@ Date: 2026-08-22 - Rejected probing any managed stored credential before applying caller overrides. - Regenerated the Python and TypeScript Fern clients from the final EE OpenAPI contract. - Updated the frontend to use Fern's `value_status`, `management.policy`, and probe method. +- Updated every Python SDK redaction consumer to use `value_status.configured`; no production + `has_key` compatibility path remains. +- Kept one strict co-release update contract: omitted credentials are preserved, explicit blank + credentials are rejected, and the frontend omits untouched credentials. - Kept managed rows hidden only from Settings/edit surfaces and available to agent runtime and model selection. - Kept all edits outside the active pi-traces lanes and generated session/trace contracts. ## Known constraints -- The local secrets lane tips were rebased after their last push, so final pushes require SHA verification. -- The workspace contains unrelated uncommitted work. No unrelated file may be staged, committed, reformatted, or discarded. +- The workspace contains unrelated uncommitted work. No unrelated file was staged, committed, + reformatted, or discarded. +- Deployed acceptance tests require `AGENTA_API_URL` and `AGENTA_AUTH_KEY`; no local deployment + was loaded for this pass. +- One SDK-to-runner streaming assertion reflects the parallel pi-traces lane and its new transient + `environment_starting` event. It is outside this stack and was excluded from the secrets unit + gate; no runner or pi-traces file was changed. - Railway-dependent checks are unavailable and are listed as deferred in `qa.md`. ## Next acceptance point -Push each reviewed lane, verify its remote SHA and immediate PR base, then execute the manual -release QA in `qa.md`. +After repository CI, execute the manual same-release QA in `qa.md`, including the deployed +backend/frontend edit flow and Railway-dependent checks when Railway is available. From 114210ea071f9c3818f50513c5d1cb07de264737 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 14:11:47 +0200 Subject: [PATCH 6/8] docs(secrets): refresh final verification counts --- docs/design/write-only-secrets/implementation-report.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design/write-only-secrets/implementation-report.md b/docs/design/write-only-secrets/implementation-report.md index d9432fff03..d4f141cf0a 100644 --- a/docs/design/write-only-secrets/implementation-report.md +++ b/docs/design/write-only-secrets/implementation-report.md @@ -121,8 +121,9 @@ Local automated verification passed: - 2,635 OSS API unit tests from an isolated #6164 checkout (73 Postgres/live-key tests skipped). - 3,005 combined OSS and EE API unit tests from an isolated final-stack checkout (73 Postgres/live-key tests skipped). -- 233 focused API tests covering secrets, grants, middleware, provider probe, SSO/webhook - behavior, and starter-credit seeding/client behavior. +- 229 focused API tests after the webhook contract cleanup, covering secrets, grants, + middleware, provider probe, SSO/webhook behavior, and starter-credit seeding/client behavior. +- 109 webhook and write-only-secret tests passed against the final webhook behavior. - Standalone boundary checks after the split: 55 write-only tests on #6164 alone, 65 write-only plus managed tests on #6165, 77 starter-credit tests on #6138, and 93 provider-probe tests on #6195. From 32ae3dcbe30fe181504abbc5218488c3667a7e2e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 16:22:44 +0200 Subject: [PATCH 7/8] fix(secrets): preserve hidden credentials on edit --- .../types/provider_credentials.py | 13 +-- .../test_provider_credentials_generation.py | 62 +++++++++++++ .../test_provider_credentials_security.py | 28 ++++++ clients/scripts/generate.sh | 7 ++ .../scripts/protect_provider_credentials.py | 63 ++++++++++++++ .../assets/content.test.ts | 42 +++++++++ .../ConfigureSecretModal/assets/content.ts | 73 ++++++++++++++++ .../Vault/ConfigureSecretModal/index.tsx | 87 +++++++++---------- .../src/secret/core/connections.ts | 12 ++- .../tests/unit/provider-connections.test.ts | 23 ++++- 10 files changed, 353 insertions(+), 57 deletions(-) create mode 100644 clients/python/tests/test_provider_credentials_generation.py create mode 100644 clients/python/tests/test_provider_credentials_security.py create mode 100644 clients/scripts/protect_provider_credentials.py create mode 100644 web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.test.ts create mode 100644 web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.ts diff --git a/clients/python/agenta_client/types/provider_credentials.py b/clients/python/agenta_client/types/provider_credentials.py index baab5a043a..39b0529751 100644 --- a/clients/python/agenta_client/types/provider_credentials.py +++ b/clients/python/agenta_client/types/provider_credentials.py @@ -8,17 +8,18 @@ class ProviderCredentials(UniversalBaseModel): """ - Credentials in transit only. Never persisted here, never logged, never echoed. + Credentials sent to provider probe endpoints. - `key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line - or traceback that carries this object cannot print the credential. Unwrap the key with - `.get_secret_value()` at the point it is put on the wire, never earlier. + ``key`` and ``extras`` remain plain wire values so Fern can serialize them. Both fields + are excluded from this model's display representation to reduce accidental disclosure. """ - key: typing.Optional[str] = None + key: typing.Optional[str] = pydantic.Field(default=None, repr=False) url: typing.Optional[str] = None version: typing.Optional[str] = None - extras: typing.Optional[typing.Dict[str, typing.Any]] = None + extras: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field( + default=None, repr=False + ) if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( diff --git a/clients/python/tests/test_provider_credentials_generation.py b/clients/python/tests/test_provider_credentials_generation.py new file mode 100644 index 0000000000..1b8e5de2b7 --- /dev/null +++ b/clients/python/tests/test_provider_credentials_generation.py @@ -0,0 +1,62 @@ +import subprocess +import sys +from pathlib import Path + + +GENERATED_MODEL = """from typing import Any, Dict, Optional + +import pydantic + + +class ProviderCredentials: + \"\"\" + Credentials in transit only. Never persisted here, never logged, never echoed. + + `key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line + or traceback that carries this object cannot print the credential. Unwrap the key with + `.get_secret_value()` at the point it is put on the wire, never earlier. + \"\"\" + + key: typing.Optional[str] = None + url: typing.Optional[str] = None + extras: typing.Optional[typing.Dict[str, typing.Any]] = None +""" + + +def test_generation_hook_hides_plain_credential_fields(tmp_path: Path): + generated_file = tmp_path / "provider_credentials.py" + generated_file.write_text(GENERATED_MODEL) + helper = Path(__file__).parents[2] / "scripts" / "protect_provider_credentials.py" + + subprocess.run([sys.executable, str(helper), str(generated_file)], check=True) + + protected = generated_file.read_text() + assert "SecretStr" not in protected + assert ".get_secret_value()" not in protected + assert ( + "key: typing.Optional[str] = pydantic.Field(default=None, repr=False)" + in protected + ) + assert ( + "extras: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(" + in protected + ) + + +def test_generation_hook_fails_loudly_when_fern_shape_changes(tmp_path: Path): + generated_file = tmp_path / "provider_credentials.py" + generated_file.write_text( + GENERATED_MODEL.replace("key: typing.Optional[str]", "key: str") + ) + helper = Path(__file__).parents[2] / "scripts" / "protect_provider_credentials.py" + + result = subprocess.run( + [sys.executable, str(helper), str(generated_file)], + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert ( + "Expected exactly one generated ProviderCredentials key field" in result.stderr + ) diff --git a/clients/python/tests/test_provider_credentials_security.py b/clients/python/tests/test_provider_credentials_security.py new file mode 100644 index 0000000000..15e6fcd26d --- /dev/null +++ b/clients/python/tests/test_provider_credentials_security.py @@ -0,0 +1,28 @@ +from agenta_client.core.jsonable_encoder import jsonable_encoder +from agenta_client.types.provider_credentials import ProviderCredentials + + +def test_provider_credentials_hide_secrets_from_display_but_keep_wire_values(): + key_canary = "key-canary-must-not-appear" + extras_canary = "extras-canary-must-not-appear" + credentials = ProviderCredentials( + key=key_canary, + url="https://provider.example/v1", + extras={"authorization": extras_canary}, + ) + + for displayed in (repr(credentials), str(credentials)): + assert key_canary not in displayed + assert extras_canary not in displayed + assert "https://provider.example/v1" in displayed + + expected = { + "key": key_canary, + "url": "https://provider.example/v1", + "version": None, + "extras": {"authorization": extras_canary}, + } + assert credentials.model_dump() == expected + assert jsonable_encoder(credentials) == { + key: value for key, value in expected.items() if value is not None + } diff --git a/clients/scripts/generate.sh b/clients/scripts/generate.sh index 2a83c03e9d..2175a69a51 100755 --- a/clients/scripts/generate.sh +++ b/clients/scripts/generate.sh @@ -458,7 +458,14 @@ generate_python() { find "${types_dir}" -name "*.py.bak" -delete } + protect_provider_credentials_repr() { + local credentials_file="$1" + log "excluding provider credentials from generated model display" + python3 "${SCRIPT_DIR}/protect_provider_credentials.py" "${credentials_file}" + } + fix_recursive_types_in_dir "${target_dir}" + protect_provider_credentials_repr "${target_dir}/types/provider_credentials.py" log "generated Python client in ${target_dir}" } diff --git a/clients/scripts/protect_provider_credentials.py b/clients/scripts/protect_provider_credentials.py new file mode 100644 index 0000000000..f7e8a877ce --- /dev/null +++ b/clients/scripts/protect_provider_credentials.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Keep generated provider credentials serializable without displaying their values.""" + +from pathlib import Path +import sys + + +OLD_DOC = """ Credentials in transit only. Never persisted here, never logged, never echoed. + + `key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line + or traceback that carries this object cannot print the credential. Unwrap the key with + `.get_secret_value()` at the point it is put on the wire, never earlier. +""" +NEW_DOC = """ Credentials sent to provider probe endpoints. + + ``key`` and ``extras`` remain plain wire values so Fern can serialize them. Both fields + are excluded from this model's display representation to reduce accidental disclosure. +""" + +REPLACEMENTS = ( + ("docstring", OLD_DOC, NEW_DOC), + ( + "key field", + " key: typing.Optional[str] = None", + " key: typing.Optional[str] = pydantic.Field(default=None, repr=False)", + ), + ( + "extras field", + " extras: typing.Optional[typing.Dict[str, typing.Any]] = None", + """ extras: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field( + default=None, repr=False + )""", + ), +) + + +def protect_provider_credentials(path: Path) -> None: + source = path.read_text() + + for label, old, new in REPLACEMENTS: + count = source.count(old) + if count != 1: + raise RuntimeError( + f"Expected exactly one generated ProviderCredentials {label}; found {count}" + ) + source = source.replace(old, new) + + path.write_text(source) + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit("Usage: protect_provider_credentials.py PATH") + + path = Path(sys.argv[1]) + if not path.is_file(): + raise SystemExit(f"Generated ProviderCredentials model not found: {path}") + + protect_provider_credentials(path) + + +if __name__ == "__main__": + main() diff --git a/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.test.ts b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.test.ts new file mode 100644 index 0000000000..104b333956 --- /dev/null +++ b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.test.ts @@ -0,0 +1,42 @@ +import {CustomSecretFormat} from "@agenta/entities/secret" +import {describe, expect, it} from "vitest" + +import {buildSecretContent} from "./content" + +const hiddenJsonInput = { + format: CustomSecretFormat.Json, + originalFormat: CustomSecretFormat.Json, + valueHidden: true, + textValue: "", + jsonView: "json" as const, + jsonText: "{}", + kvRows: [{key: "", value: ""}], +} + +describe("buildSecretContent", () => { + it("preserves untouched hidden JSON after switching to Editor", () => { + expect(buildSecretContent({...hiddenJsonInput, replacementSupplied: false})).toEqual({ + content: undefined, + }) + }) + + it("returns the parsed dirty Editor value instead of stale grid state", () => { + expect( + buildSecretContent({ + ...hiddenJsonInput, + replacementSupplied: true, + jsonText: '{"token":"new-value","enabled":true}', + }), + ).toEqual({content: {token: "new-value", enabled: true}}) + }) + + it("rejects a hidden format change without replacement content", () => { + expect( + buildSecretContent({ + ...hiddenJsonInput, + format: CustomSecretFormat.Text, + replacementSupplied: false, + }), + ).toEqual({error: "Enter replacement content before changing the secret format."}) + }) +}) diff --git a/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.ts b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.ts new file mode 100644 index 0000000000..fbe5fbbb22 --- /dev/null +++ b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/assets/content.ts @@ -0,0 +1,73 @@ +import { + CustomSecretFormat, + type CustomSecretContent, + type CustomSecretFormat as CustomSecretFormatType, +} from "@agenta/entities/secret" + +import {isFlatPrimitiveObject, rowsToObject, type KvRow} from "./primitives" + +interface BuildSecretContentParams { + format: CustomSecretFormatType + originalFormat: CustomSecretFormatType + valueHidden: boolean + replacementSupplied: boolean + textValue: string + jsonView: "grid" | "json" + jsonText: string + kvRows: KvRow[] +} + +export type SecretContentResult = {content: CustomSecretContent | undefined} | {error: string} + +export const parseFlatJson = ( + jsonText: string, +): {value: Record} | {error: string} => { + let parsed: unknown + try { + parsed = JSON.parse(jsonText || "{}") + } catch { + return {error: "Invalid JSON."} + } + + if (!isFlatPrimitiveObject(parsed)) { + return {error: "Must be a flat object of primitives; nesting and arrays are not allowed."} + } + + return {value: parsed} +} + +/** Decide whether a write-only secret is preserved or deliberately replaced. */ +export const buildSecretContent = ({ + format, + originalFormat, + valueHidden, + replacementSupplied, + textValue, + jsonView, + jsonText, + kvRows, +}: BuildSecretContentParams): SecretContentResult => { + if (valueHidden && !replacementSupplied) { + if (format !== originalFormat) { + return {error: "Enter replacement content before changing the secret format."} + } + return {content: undefined} + } + + if (format === CustomSecretFormat.Text) { + return {content: textValue} + } + + if (jsonView === "json") { + const parsed = parseFlatJson(jsonText) + return "error" in parsed ? parsed : {content: parsed.value} + } + + const namedRows = kvRows.filter((row) => row.key.trim()) + const keys = namedRows.map((row) => row.key.trim()) + if (new Set(keys).size !== keys.length) { + return {error: "Duplicate keys are not allowed."} + } + + return {content: rowsToObject(namedRows)} +} diff --git a/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx index 80ac13111a..c996ba9863 100644 --- a/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx +++ b/web/oss/src/components/pages/settings/Vault/ConfigureSecretModal/index.tsx @@ -4,7 +4,6 @@ import { useVaultSecret, CustomSecretFormat, type CustomSecretFormat as CustomSecretFormatType, - type CustomSecretContent, type NamedSecretRow, } from "@agenta/entities/secret" import {message} from "@agenta/ui/app-message" @@ -16,9 +15,9 @@ import {Button, Input, Segmented, Select, Typography} from "antd" import {slugifyBase} from "@/oss/lib/utils/slugify" +import {buildSecretContent, parseFlatJson} from "./assets/content" import { coerceToType, - isFlatPrimitiveObject, objectToRows, primitiveTypeOf, PRIMITIVE_TYPES, @@ -49,6 +48,7 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM const [jsonView, setJsonView] = useState("grid") const [jsonText, setJsonText] = useState("{}") const [jsonError, setJsonError] = useState(null) + const [replacementSupplied, setReplacementSupplied] = useState(false) const [saving, setSaving] = useState(false) const isEditing = !!selectedSecret?.id @@ -60,6 +60,8 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM if (!open) return setJsonView("grid") setJsonError(null) + setJsonText("{}") + setReplacementSupplied(false) setSlugTouched(false) if (selectedSecret) { setName(selectedSecret.name ?? "") @@ -107,9 +109,11 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM setKvRows([{key: "", value: ""}]) setJsonView("grid") setJsonError(null) + setReplacementSupplied(false) } const updateRow = (idx: number, patch: Partial) => { + setReplacementSupplied(true) setKvRows((rows) => rows.map((r, i) => (i === idx ? {...r, ...patch} : r))) } @@ -122,46 +126,14 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM // JSON -> Grid: parse, enforce flat-primitive shape, then hydrate the rows. const onSwitchToGrid = () => { - const ok = syncJsonToRows() - if (ok) setJsonView("grid") - } - - const syncJsonToRows = (): boolean => { - let parsed: unknown - try { - parsed = JSON.parse(jsonText || "{}") - } catch { - setJsonError("Invalid JSON.") - return false - } - if (!isFlatPrimitiveObject(parsed)) { - setJsonError("Must be a flat object of primitives — no nesting or arrays.") - return false + const parsed = parseFlatJson(jsonText) + if ("error" in parsed) { + setJsonError(parsed.error) + return } - setKvRows(objectToRows(parsed)) + setKvRows(objectToRows(parsed.value)) setJsonError(null) - return true - } - - /** The content to send: `undefined` keeps the stored value, `null` means the form is invalid. */ - const buildContent = (): CustomSecretContent | null | undefined => { - if (format === CustomSecretFormat.Text) { - if (valueHidden && !textValue) return undefined - return textValue - } - if (valueHidden && jsonView === "grid" && !kvRows.some((row) => row.key.trim())) { - return undefined - } - if (jsonView === "json" && !syncJsonToRows()) { - return null - } - const named = kvRows.filter((r) => r.key.trim()) - const keys = named.map((r) => r.key.trim()) - if (new Set(keys).size !== keys.length) { - message.error("Duplicate keys are not allowed.") - return null - } - return rowsToObject(named) + setJsonView("grid") } const onSubmit = async () => { @@ -169,8 +141,21 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM message.error("Name is required.") return } - const content = buildContent() - if (content === null) return + const result = buildSecretContent({ + format, + originalFormat: selectedSecret?.format ?? CustomSecretFormat.Text, + valueHidden, + replacementSupplied, + textValue, + jsonView, + jsonText, + kvRows, + }) + if ("error" in result) { + if (format === CustomSecretFormat.Json) setJsonError(result.error) + message.error(result.error) + return + } try { setSaving(true) @@ -179,7 +164,7 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM // Slug is immutable: only send it on create. slug: isEditing ? undefined : slug.trim() || undefined, format, - content, + content: result.content, id: selectedSecret?.id, }) mutate() @@ -322,7 +307,10 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM rows={4} className="font-mono" value={textValue} - onChange={(e) => setTextValue(e.target.value)} + onChange={(e) => { + setReplacementSupplied(true) + setTextValue(e.target.value) + }} /> ) : jsonView === "json" ? (

@@ -330,6 +318,7 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM initialValue={jsonText} value={jsonText} handleChange={(v) => { + if (v !== jsonText) setReplacementSupplied(true) setJsonText(v) setJsonError(null) }} @@ -421,9 +410,10 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM icon={} size="small" disabled={kvRows.length === 1} - onClick={() => + onClick={() => { + setReplacementSupplied(true) setKvRows(kvRows.filter((_, i) => i !== idx)) - } + }} />
) @@ -432,7 +422,10 @@ const ConfigureSecretModal = ({open, selectedSecret, onCancel}: ConfigureSecretM type="dashed" size="small" icon={} - onClick={() => setKvRows([...kvRows, {key: "", value: ""}])} + onClick={() => { + setReplacementSupplied(true) + setKvRows([...kvRows, {key: "", value: ""}]) + }} > Add field diff --git a/web/packages/agenta-entities/src/secret/core/connections.ts b/web/packages/agenta-entities/src/secret/core/connections.ts index acb898712c..f190615878 100644 --- a/web/packages/agenta-entities/src/secret/core/connections.ts +++ b/web/packages/agenta-entities/src/secret/core/connections.ts @@ -231,10 +231,20 @@ export const probeRequestFor = ( /** The HTTP status of a failed request, when it carried one. */ const statusOf = (error: unknown): number | null => { + const statusCode = (error as {statusCode?: unknown})?.statusCode + if (typeof statusCode === "number") return statusCode + const response = (error as {response?: {status?: unknown}})?.response return typeof response?.status === "number" ? response.status : null } +/** The structured error body from Fern or Axios, when either supplied one. */ +const bodyOf = (error: unknown): unknown => { + const body = (error as {body?: unknown})?.body + if (body !== undefined) return body + return (error as {response?: {data?: unknown}})?.response?.data +} + /** * Why a Test never produced a verdict. * @@ -249,7 +259,7 @@ export const probeFailureMessage = (error: unknown, title: string): string => { const status = statusOf(error) if (status === 404) return "This connection no longer exists. Reload and try again." if (status && status >= 400 && status < 500) { - const message = extractApiErrorMessage(error) + const message = extractApiErrorMessage(bodyOf(error) ?? error) if (message && message !== String(error)) return message } return `Agenta could not reach ${title} to test this credential.` diff --git a/web/packages/agenta-entities/tests/unit/provider-connections.test.ts b/web/packages/agenta-entities/tests/unit/provider-connections.test.ts index 8e1128a214..756d31f07f 100644 --- a/web/packages/agenta-entities/tests/unit/provider-connections.test.ts +++ b/web/packages/agenta-entities/tests/unit/provider-connections.test.ts @@ -941,15 +941,32 @@ describe("Test on a write-only connection: the enable rule and the request shape const httpError = (status: number, detail?: string) => ({ response: {status, data: detail ? {detail} : undefined}, }) + const fernError = (statusCode: number, detail?: string) => ({ + statusCode, + body: detail ? {detail} : undefined, + }) it("says the connection is gone on a 404, not that the provider is unreachable", () => { expect(probeFailureMessage(httpError(404), "OpenAI")).toContain("no longer exists") }) it("speaks the server's own words for a 4xx that carried a message", () => { - expect(probeFailureMessage(httpError(422, "Stored key is for another provider."))).toBe( - "Stored key is for another provider.", - ) + expect( + probeFailureMessage( + httpError(422, "Stored key is for another provider."), + "OpenAI", + ), + ).toBe("Stored key is for another provider.") + expect( + probeFailureMessage( + fernError(422, "Stored key is for another provider."), + "OpenAI", + ), + ).toBe("Stored key is for another provider.") + }) + + it("recognizes Fern 404 errors as missing stored connections", () => { + expect(probeFailureMessage(fernError(404), "OpenAI")).toContain("no longer exists") }) it("falls back to the reach-the-provider line for a transport failure or a 5xx", () => { From e9b789a29796892c5e4b350fd101cb6a8e8a0863 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 18:56:50 +0200 Subject: [PATCH 8/8] fix(secrets): close final review gaps --- api/oss/src/apis/fastapi/access/router.py | 2 +- api/oss/src/apis/fastapi/providers/router.py | 5 ++- .../pytest/unit/access/test_grant_exchange.py | 9 ++++ .../unit/providers/test_provider_probe.py | 32 +++++++++++--- docs/design/write-only-secrets/review.md | 6 +-- hosting/docker-compose/ee/env.ee.dev.example | 4 +- hosting/docker-compose/ee/env.ee.gh.example | 4 +- .../docker-compose/oss/env.oss.dev.example | 4 +- hosting/docker-compose/oss/env.oss.gh.example | 4 +- .../agenta/sdk/agents/platform/connections.py | 1 + .../agenta/sdk/middlewares/running/vault.py | 13 +++--- .../platform/test_write_only_secrets.py | 35 +++++++++++++++ .../components/AgentMessage.runError.test.tsx | 11 +---- .../src/workflow/state/appUtils.ts | 3 ++ ...create-ephemeral-app-from-template.test.ts | 43 +++++++++++++++++-- .../secretProvider/ProviderConnectionCard.tsx | 8 ++-- 16 files changed, 139 insertions(+), 45 deletions(-) diff --git a/api/oss/src/apis/fastapi/access/router.py b/api/oss/src/apis/fastapi/access/router.py index b74c7c84e2..a7199907f2 100644 --- a/api/oss/src/apis/fastapi/access/router.py +++ b/api/oss/src/apis/fastapi/access/router.py @@ -124,7 +124,7 @@ def _is_platform_runtime(request: Request) -> bool: if not expected or expected == _UNCONFIGURED_KEY: return False - return compare_digest(presented, expected) + return compare_digest(presented.encode("utf-8"), expected.encode("utf-8")) def _run_credential_grants(request: Request, *, action: Optional[str]) -> List[str]: diff --git a/api/oss/src/apis/fastapi/providers/router.py b/api/oss/src/apis/fastapi/providers/router.py index 532b8bdad8..948dcf37d2 100644 --- a/api/oss/src/apis/fastapi/providers/router.py +++ b/api/oss/src/apis/fastapi/providers/router.py @@ -123,7 +123,8 @@ async def _merge_stored_secret( stored_extras = getattr(settings, "extras", None) if settings else None stored_key = _stored_credential(secret, settings, stored_extras) - if kind is not None and kind != stored_kind and typed.key is None: + typed_key = _typed_or_stored(typed.key, None) + if kind is not None and kind != stored_kind and typed_key is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=( @@ -133,7 +134,7 @@ async def _merge_stored_secret( ) merged = ProviderCredentials( - key=_typed_or_stored(typed.key, stored_key), + key=_typed_or_stored(typed_key, stored_key), url=_typed_or_stored(typed.url, getattr(settings, "url", None)), version=_typed_or_stored(typed.version, getattr(settings, "version", None)), extras=_merged_extras(typed.extras, stored_extras), diff --git a/api/oss/tests/pytest/unit/access/test_grant_exchange.py b/api/oss/tests/pytest/unit/access/test_grant_exchange.py index 05e374acb4..5c5669e65b 100644 --- a/api/oss/tests/pytest/unit/access/test_grant_exchange.py +++ b/api/oss/tests/pytest/unit/access/test_grant_exchange.py @@ -169,6 +169,15 @@ async def test_a_wrong_runtime_key_is_not_the_runtime(exchange): assert "grants" not in _claims(body["credentials"]) +@pytest.mark.asyncio +async def test_a_non_ascii_runtime_key_is_not_the_runtime(exchange): + run, _ = exchange + + body = _body(await run("run_service", runtime_key="not-the-key-ÿ")) + + assert "grants" not in _claims(body["credentials"]) + + @pytest.mark.asyncio async def test_the_runtime_key_only_grants_a_run_exchange(exchange): run, _ = exchange diff --git a/api/oss/tests/pytest/unit/providers/test_provider_probe.py b/api/oss/tests/pytest/unit/providers/test_provider_probe.py index 5031ed0b67..5994d3b549 100644 --- a/api/oss/tests/pytest/unit/providers/test_provider_probe.py +++ b/api/oss/tests/pytest/unit/providers/test_provider_probe.py @@ -28,6 +28,8 @@ ) from oss.src.core.providers.service import ProviderProbeService from oss.src.core.secrets.managed import SecretManagementDTO, SecretManager +from oss.src.core.secrets.enums import SecretKind +from oss.src.core.secrets.redaction import PRIMARY_CREDENTIAL_FIELDS CANARY = "sk-CANARY-DO-NOT-LEAK-abc123" @@ -916,6 +918,27 @@ def test_the_stored_key_is_not_lent_to_another_provider(monkeypatch): assert STORED_KEY not in response.text +def test_a_blank_key_does_not_lend_the_stored_key_to_another_provider(monkeypatch): + vault = _StubVault() + secret_id = uuid4() + vault.store(secret_id, PROJECT_ID, _stored_provider_key(kind="openai")) + recorder = Recorder(json_response({"data": []})) + client = build_client(monkeypatch, recorder, vault=vault) + + response = client.post( + "/providers/probe", + json={ + "secret_id": str(secret_id), + "kind": "anthropic", + "provider": {"key": ""}, + }, + ) + + assert response.status_code == 422 + assert recorder.requests == [] + assert STORED_KEY not in response.text + + def test_a_kind_change_is_allowed_when_the_caller_brings_the_credential(monkeypatch): vault = _StubVault() secret_id = uuid4() @@ -1134,13 +1157,8 @@ def test_a_bedrock_connection_probes_with_its_stored_extras_credential( assert STORED_KEY not in response.text -def test_every_secret_kind_the_classifier_knows_has_a_credential_location(): - # The probe asks the classifier where a kind keeps its credential. If a kind is ever - # added to the vault without an entry there, this probe would silently send nothing. - from oss.src.core.secrets.redaction import PRIMARY_CREDENTIAL_FIELDS - - assert PRIMARY_CREDENTIAL_FIELDS["provider_key"] == ("provider", "key") - assert PRIMARY_CREDENTIAL_FIELDS["custom_provider"] == ("provider", "key") +def test_every_secret_kind_has_a_credential_location(): + assert set(PRIMARY_CREDENTIAL_FIELDS) == {kind.value for kind in SecretKind} @pytest.mark.parametrize("path", ["/providers/probe", "/vault/v1/providers/probe"]) diff --git a/docs/design/write-only-secrets/review.md b/docs/design/write-only-secrets/review.md index 3c30a288f5..6051ef93d0 100644 --- a/docs/design/write-only-secrets/review.md +++ b/docs/design/write-only-secrets/review.md @@ -282,7 +282,7 @@ The frontend can now act on the supported policy without knowing who implements ```typescript if (secret.management?.policy === "manager_only") { - // render the chosen managed-row UX + // render the chosen managed-row UX } ``` @@ -317,7 +317,7 @@ There is no current consumer that needs to add, change, clear, update, or delete ### 13. Remove the universal `allow_managed` bypass -#6165 adds `allow_managed: bool = False` to update and delete. Passing `True` permits full access to every managed row, regardless of which component owns it. It therefore means "bypass all management," not "the owning component is acting." The name and documentation overstate the security property. +PR #6165 adds `allow_managed: bool = False` to update and delete. Passing `True` permits full access to every managed row, regardless of which component owns it. It therefore means "bypass all management," not "the owning component is acting." The name and documentation overstate the security property. The starter-credits bridge in #6138 creates its row once and never updates, releases, or deletes it. The bypass has no production consumer in this release. @@ -349,7 +349,7 @@ The same locked-row structure can later compare a typed owner for an explicit in ### 15. Update #6138 to use the managed-secret boundary, not its storage DTO -#6138 initially constructed the general `CreateSecretDTO` with both `managed_by=ORIGIN_MARKER` and `write_only=True`. It also used the same `ORIGIN_MARKER` value for three semantic roles: proxy audit metadata, Vault manager identity, and the user-facing header description. +PR #6138 initially constructed the general `CreateSecretDTO` with both `managed_by=ORIGIN_MARKER` and `write_only=True`. It also used the same `ORIGIN_MARKER` value for three semantic roles: proxy audit metadata, Vault manager identity, and the user-facing header description. Requested change: diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 00d8ce27b3..411db1efdd 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -28,8 +28,8 @@ AGENTA_AUTH_KEY=replace-me # Proves to the API that a caller IS the platform runtime (the workflow service), so the # credential it receives may read write-only secret values. The API and the services # container must hold the SAME dedicated value, and a browser, runner, worker, cron, or -# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. Missing and -# placeholder values prevent write-only secret grants from being issued. +# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. The API refuses +# to start until this placeholder is replaced with a strong shared value. AGENTA_SERVICES_INTERNAL_KEY=replace-me AGENTA_CRYPT_KEY=replace-me diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 0654ce99e8..7fd4ed414b 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -32,8 +32,8 @@ AGENTA_AUTH_KEY=replace-me # Proves to the API that a caller IS the platform runtime (the workflow service), so the # credential it receives may read write-only secret values. The API and the services # container must hold the SAME dedicated value, and a browser, runner, worker, cron, or -# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. Missing and -# placeholder values prevent write-only secret grants from being issued. +# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. The API refuses +# to start until this placeholder is replaced with a strong shared value. AGENTA_SERVICES_INTERNAL_KEY=replace-me AGENTA_CRYPT_KEY=replace-me diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 487cab8bb2..2a3b2cfff7 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -28,8 +28,8 @@ AGENTA_AUTH_KEY=replace-me # Proves to the API that a caller IS the platform runtime (the workflow service), so the # credential it receives may read write-only secret values. The API and the services # container must hold the SAME dedicated value, and a browser, runner, worker, cron, or -# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. Missing and -# placeholder values prevent write-only secret grants from being issued. +# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. The API refuses +# to start until this placeholder is replaced with a strong shared value. AGENTA_SERVICES_INTERNAL_KEY=replace-me AGENTA_CRYPT_KEY=replace-me diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index ee6ac96f82..895374b698 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -32,8 +32,8 @@ AGENTA_AUTH_KEY=replace-me # Proves to the API that a caller IS the platform runtime (the workflow service), so the # credential it receives may read write-only secret values. The API and the services # container must hold the SAME dedicated value, and a browser, runner, worker, cron, or -# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. Missing and -# placeholder values prevent write-only secret grants from being issued. +# sandbox must never receive it. There is no AGENTA_AUTH_KEY fallback. The API refuses +# to start until this placeholder is replaced with a strong shared value. AGENTA_SERVICES_INTERNAL_KEY=replace-me AGENTA_CRYPT_KEY=replace-me diff --git a/sdks/python/agenta/sdk/agents/platform/connections.py b/sdks/python/agenta/sdk/agents/platform/connections.py index 1144f25dee..f81a0ff88d 100644 --- a/sdks/python/agenta/sdk/agents/platform/connections.py +++ b/sdks/python/agenta/sdk/agents/platform/connections.py @@ -196,6 +196,7 @@ def _credential_channels( if candidate.deployment == "bedrock": return [ ("AWS_BEARER_TOKEN_BEDROCK",), + ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"), ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"), ] if candidate.deployment in ("vertex_ai", "vertex"): diff --git a/sdks/python/agenta/sdk/middlewares/running/vault.py b/sdks/python/agenta/sdk/middlewares/running/vault.py index 0cfd5ee8f5..e494bafead 100644 --- a/sdks/python/agenta/sdk/middlewares/running/vault.py +++ b/sdks/python/agenta/sdk/middlewares/running/vault.py @@ -437,11 +437,7 @@ def _split_write_only_redacted( redacted_names: List[str] = [] for secret in vault_secrets or []: - if ( - isinstance(secret, dict) - and secret.get("write_only") - and secret_value_configured(secret) - ): + if isinstance(secret, dict) and secret.get("write_only"): data = secret.get("data") or {} kind = secret.get("kind") value = None @@ -454,8 +450,11 @@ def _split_write_only_redacted( value = (data.get("secret") or {}).get("content") if not value: - header = secret.get("header") or {} - redacted_names.append(header.get("name") or secret.get("slug") or kind) + if secret_value_configured(secret): + header = secret.get("header") or {} + redacted_names.append( + header.get("name") or secret.get("slug") or kind + ) continue usable.append(secret) diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_write_only_secrets.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_write_only_secrets.py index 92bac7ac6d..2c854e977b 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_write_only_secrets.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_write_only_secrets.py @@ -31,6 +31,7 @@ def _no_ambient_provider_keys(monkeypatch): "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", "AZURE_OPENAI_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", }: @@ -230,6 +231,22 @@ def test_a_bedrock_connection_accepts_an_aws_key_pair_from_the_environment(monke assert env["AWS_SECRET_ACCESS_KEY"] == "aws-secret-from-env" +def test_a_bedrock_connection_keeps_the_aws_session_token(monkeypatch): + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret-from-env") + monkeypatch.setenv("AWS_SESSION_TOKEN", "aws-session-token") + redacted = _redacted_custom("bedrock", "bedrock-conn") + + resolved = connections._resolve_from_secrets( + secrets=[redacted], model=_claude_model("bedrock-conn"), harness="claude_code" + ) + + env = {item.binding.name: item.value for item in resolved.credentials} + assert env["AWS_ACCESS_KEY_ID"] == "AKIAEXAMPLE" + assert env["AWS_SECRET_ACCESS_KEY"] == "aws-secret-from-env" + assert env["AWS_SESSION_TOKEN"] == "aws-session-token" + + def test_half_an_aws_key_pair_is_not_a_credential(monkeypatch): # An access key id with no secret authenticates nothing; failing here names the # problem, where passing it on would fail at the provider with an auth error. @@ -360,6 +377,24 @@ def test_partition_drops_redacted_entries_and_names_them(): assert [s["data"]["kind"] for s in usable] == ["anthropic", "mistral"] +def test_partition_drops_an_unconfigured_write_only_entry_without_naming_it(): + usable, redacted_names = _split_write_only_redacted( + [ + { + "kind": "provider_key", + "slug": "empty-openai", + "header": {"name": "Empty OpenAI"}, + "data": {"kind": "openai", "provider": {}}, + "write_only": True, + "value_status": {"configured": False}, + } + ] + ) + + assert usable == [] + assert redacted_names == [] + + def test_partition_keeps_write_only_entries_whose_value_came_through(): usable, redacted_names = _split_write_only_redacted([_plaintext_provider_key()]) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx index 8fe47387be..25f6046d88 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx @@ -1,13 +1,4 @@ -/** - * The failed-run callout's one conditional affordance: the "Add your key" button. - * - * It appears only for the starter-credit failure classes the user can clear themselves. Every - * other failure (including a run that carried no code at all) shows the message and nothing more, - * so a plain crash never nags the user to go buy a provider key. - * - * Rendered with `renderToStaticMarkup` rather than a testing library: the repo has no - * `@testing-library/react`, and these are static presentational assertions that do not need one. - */ +/** The Add your key action appears only for starter-credit failures the user can clear. */ import {renderToStaticMarkup} from "react-dom/server" import {describe, expect, it} from "vitest" diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts index aef21de03b..f5c7fef500 100644 --- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -132,11 +132,14 @@ async function vaultConnectionsForNewAgent( projectId: string, userId?: string, ): Promise { + if (!userId) return [] + try { const rows = await getHostQueryClient().ensureQueryData({ queryKey: ["vault", "secrets", userId, projectId], queryFn: () => fetchVaultSecret({projectId}), staleTime: 5 * 60_000, + retry: false, }) return toProviderConnections(rows ?? []) } catch { diff --git a/web/packages/agenta-entities/tests/unit/create-ephemeral-app-from-template.test.ts b/web/packages/agenta-entities/tests/unit/create-ephemeral-app-from-template.test.ts index fbe07640ed..4bb751bfaa 100644 --- a/web/packages/agenta-entities/tests/unit/create-ephemeral-app-from-template.test.ts +++ b/web/packages/agenta-entities/tests/unit/create-ephemeral-app-from-template.test.ts @@ -8,15 +8,22 @@ * alone. Nothing covered that before, and a drop here reproduces the "agent has no tools" symptom * with every Python test still green. */ -import {projectIdAtom} from "@agenta/shared/state" +import {projectIdAtom, userAtom} from "@agenta/shared/state" import {QueryClient} from "@tanstack/react-query" import {getDefaultStore} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" import {beforeEach, describe, expect, it, vi} from "vitest" -const {fetchWorkflowCatalogTemplatesMock, inspectWorkflowMock} = vi.hoisted(() => ({ - fetchWorkflowCatalogTemplatesMock: vi.fn(), - inspectWorkflowMock: vi.fn(), +const {fetchVaultSecretMock, fetchWorkflowCatalogTemplatesMock, inspectWorkflowMock} = vi.hoisted( + () => ({ + fetchVaultSecretMock: vi.fn(), + fetchWorkflowCatalogTemplatesMock: vi.fn(), + inspectWorkflowMock: vi.fn(), + }), +) + +vi.mock("../../src/secret/api", () => ({ + fetchVaultSecret: fetchVaultSecretMock, })) vi.mock("../../src/workflow/api", async (importOriginal) => { @@ -74,7 +81,10 @@ describe("createEphemeralAppFromTemplate (agent tools)", () => { const store = getDefaultStore() store.set(queryClientAtom, new QueryClient()) store.set(projectIdAtom, PROJECT_ID) + store.set(userAtom, null) store.set(agentCreationPrefsAtom, {version: 1}) + fetchVaultSecretMock.mockReset() + fetchVaultSecretMock.mockResolvedValue([]) fetchWorkflowCatalogTemplatesMock.mockReset() fetchWorkflowCatalogTemplatesMock.mockResolvedValue({ count: 1, @@ -121,4 +131,29 @@ describe("createEphemeralAppFromTemplate (agent tools)", () => { const localId = await createEphemeralAppFromTemplate({type: "agent", deferInspect: true}) expect(readAgentConfig(localId!).tools).toEqual(PI_DEFAULT_BUILTINS) }) + + it("does not query the vault before the user is hydrated", async () => { + await createEphemeralAppFromTemplate({type: "agent"}) + + expect(fetchVaultSecretMock).not.toHaveBeenCalled() + }) + + it("does not retry a failed vault lookup during agent creation", async () => { + const store = getDefaultStore() + store.set(userAtom, { + id: "user-1", + uid: "user-1", + username: "tester", + email: "tester@example.com", + }) + store.set( + queryClientAtom, + new QueryClient({defaultOptions: {queries: {retry: 3, retryDelay: 0}}}), + ) + fetchVaultSecretMock.mockRejectedValue(new Error("vault unavailable")) + + await createEphemeralAppFromTemplate({type: "agent"}) + + expect(fetchVaultSecretMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx b/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx index bfeaa0012f..fbd50cda43 100644 --- a/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx +++ b/web/packages/agenta-entity-ui/src/secretProvider/ProviderConnectionCard.tsx @@ -402,9 +402,11 @@ const ProviderConnectionCard = ({ {replaceOnly ? ( {/* TODO(copy: owner) */} - {connection?.keyPreview - ? `Key configured (${connection.keyPreview}). Leave blank to keep it.` - : "Key configured. Leave blank to keep it."} + {field.key === "apiKey" + ? connection?.keyPreview + ? `Key configured (${connection.keyPreview}). Leave blank to keep it.` + : "Key configured. Leave blank to keep it." + : "Saved value. Leave blank to keep it."} ) : null}