imperal-sdk 4.2.1__tar.gz → 4.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/CHANGELOG.md +55 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/PKG-INFO +1 -1
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/__init__.py +7 -1
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/extension.py +65 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/manifest.py +7 -0
- imperal_sdk-4.2.2/src/imperal_sdk/secrets/__init__.py +29 -0
- imperal_sdk-4.2.2/src/imperal_sdk/secrets/client.py +238 -0
- imperal_sdk-4.2.2/src/imperal_sdk/secrets/exceptions.py +34 -0
- imperal_sdk-4.2.2/src/imperal_sdk/secrets/spec.py +66 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/testing/__init__.py +2 -0
- imperal_sdk-4.2.2/src/imperal_sdk/testing/mock_secrets.py +76 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/.github/workflows/identity-contract.yml +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/.github/workflows/publish.yml +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/.github/workflows/test.yml +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/.gitignore +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/LICENSE +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/README.md +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/api_surface.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/pyproject.toml +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/.codebase-index-cache.pkl +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ai/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ai/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/auth/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/auth/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/auth/middleware.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/billing/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/billing/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/cache/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/cache/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/cache/protocol.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/action_result.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/error_codes.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/extension.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/filters.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/guards.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/handler.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/kernel_primitives.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/narration.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/narration_guard.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/prompt.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/refusal.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/retry.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/cli/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/cli/main.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/config/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/config/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/context.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/db/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/db/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/errors.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/extensions/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/extensions/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/http/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/http/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/manifest_schema.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/notify/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/notify/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/icnli_integrity_rules.txt +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/kernel_formatting_rule.txt +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/kernel_proactivity_rule.txt +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/protocols.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/rpc/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/rpc/codec.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/rpc/contract.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/executor.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/llm_provider.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/message_adapter.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/action_result.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/balance_info.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/chat_result.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/completion_result.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/document.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/event.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/file_info.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/function_call.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/http_response.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/imperal.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/limits_result.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/subscription_info.schema.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/security/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/security/call_token.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/skeleton/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/skeleton/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/storage/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/storage/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/store/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/store/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/store/exceptions.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/testing/mock_context.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/generate_api_surface.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/validate_identity_contract.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/action_result.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/chat_result.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/client_contracts.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/contracts.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/contributions.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/events.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/health.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/identity.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/models.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/pagination.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/types/store_contracts.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/actions.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/base.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/data.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/display.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/feedback.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/graph.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/input_components.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/interactive.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/layout.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/theme.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/validator.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/src/imperal_sdk/validator_v1_6_0.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/conftest.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/contracts/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/contracts/test_store_contracts.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/fixtures/openapi/auth-gateway.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/fixtures/openapi/registry.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/fixtures/openapi/sharelock-cases.json +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/rpc/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/rpc/test_codec.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/rpc/test_contract.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/runtime/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/runtime/test_llm_provider_config_store.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/runtime/test_llm_provider_ctx_injection.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/store/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/store/test_list_users_client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/store/test_query_all_client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_action_result_typed.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_as_user.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_auth.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_billing.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_cache_client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_cache_model.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_call_token.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_extension_deprecation.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_filters.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_guards.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_guards_bleed.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_prompt.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_pydantic_retry.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_chat_result.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_cli.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_client_contracts.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_config_client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_context.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_context_guards.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_contracts.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_contracts_live.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_contributions.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_document_contract.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_emits_decorator.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_error_codes.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_errors.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_event_schema_v2.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_events_health.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_extension.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_extension_v2.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_extensions_emit.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_handler_p2.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_id_shape_guard.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_identity_contract.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_imperal_schema_v2.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_kernel_primitives.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest_roundtrip_gate.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest_schema.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest_v2_events.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest_v2_other_sections.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest_v2_webhooks.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_manifest_validator_v2.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_mock_context.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_models.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_narration_emission.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_narration_guard.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_openai_max_completion_tokens.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_pagination.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_panel_rendering_contract.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_panels.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_skeleton_decorator.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_spec_validation.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_tools_client.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_ui.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_ui_fileupload_enhanced.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_ui_html.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_ui_image_enhanced.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_ui_open.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_ui_theme.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_user.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_v7_emit_refusal.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_validator.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_validator_drift.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_validator_pep563.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_validator_v1_6_0_rules.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/test_write_arg_bleed.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/tools/__init__.py +0 -0
- {imperal_sdk-4.2.1 → imperal_sdk-4.2.2}/tests/tools/test_generate_api_surface.py +0 -0
|
@@ -2,6 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `imperal-sdk` are documented here.
|
|
4
4
|
|
|
5
|
+
## 4.2.2 — 2026-05-13
|
|
6
|
+
|
|
7
|
+
### Added — EXT-SECRETS-V1 (closes ARCH-D1 in compliance-posture.md)
|
|
8
|
+
|
|
9
|
+
- **`@ext.secret(name, description, ...)`** declarative decorator. Extensions
|
|
10
|
+
declare what user-supplied credentials they need (API keys, OAuth tokens,
|
|
11
|
+
webhook signing secrets). Each declaration carries `required`,
|
|
12
|
+
`write_mode` (`user` / `extension` / `both`), `max_bytes`, optional
|
|
13
|
+
`rotation_hint_days`. Manifest emits `secrets[]` as an additive optional
|
|
14
|
+
field (manifest schema v3 stays — back-compat).
|
|
15
|
+
|
|
16
|
+
- **`ctx.secrets`** accessor on `KernelContext` (resolved kernel-side; SDK
|
|
17
|
+
ships `SecretClient` HTTP proxy to auth-gw `/v1/secrets/*`). Methods:
|
|
18
|
+
`get(name)` → plaintext or None; `set(name, value)` → raises
|
|
19
|
+
`SecretWriteForbidden` for `write_mode='user'`; `delete(name)`;
|
|
20
|
+
`is_set(name)` (cheap metadata, no audit); `list()` (descriptions +
|
|
21
|
+
is_set, never values).
|
|
22
|
+
|
|
23
|
+
- **Dev mode** (`IMPERAL_DEV_MODE=true`): `get(name)` reads
|
|
24
|
+
`IMPERAL_SECRET_<UPPER_NAME>` env var; set/delete are no-ops with WARN
|
|
25
|
+
log. Manifest contract still enforced (I-SECRETS-CONTRACT-DECLARED —
|
|
26
|
+
undeclared names raise even in dev).
|
|
27
|
+
|
|
28
|
+
- **`imperal_sdk.testing.MockSecretStore`** for pytest fixtures. Optional
|
|
29
|
+
`declared` set to mirror SecretNotDeclaredError semantics.
|
|
30
|
+
|
|
31
|
+
- **Federal invariants enforced SDK-side**:
|
|
32
|
+
- `I-SECRETS-HANDLER-SCOPE-MEMORY` — no module/class-level plaintext
|
|
33
|
+
cache in `SecretClient`; source-inspection-friendly
|
|
34
|
+
- `I-SECRETS-CONTRACT-DECLARED` — runtime read/write of undeclared
|
|
35
|
+
name raises `SecretNotDeclaredError`; manifest is single source of truth
|
|
36
|
+
- `I-SECRETS-VAULT-DEPENDENCY` — auth-gw 503 → `SecretVaultUnavailable`
|
|
37
|
+
|
|
38
|
+
- **Federal invariants enforced auth-gw-side** (live in production
|
|
39
|
+
whm-gateway since 2026-05-13):
|
|
40
|
+
- `I-SECRETS-USER-SCOPED` — cross-user 403
|
|
41
|
+
- `I-SECRETS-NEVER-LOGGED` — `action_ledger` row stores length +
|
|
42
|
+
sha256-prefix-8 only, never the value
|
|
43
|
+
- `I-SECRETS-EXT-SCOPED` — extension token's `ext_id` claim must
|
|
44
|
+
match URL `{ext_id}`
|
|
45
|
+
- `I-SECRETS-AUDIT-FOREVER` — every op writes
|
|
46
|
+
`retention_class='security_forever'`
|
|
47
|
+
|
|
48
|
+
- **New JWT claims**: `actor_kind` (`'user'` or `'extension'`) and `ext_id`
|
|
49
|
+
(extension tokens only). `build_session_claims` and
|
|
50
|
+
`build_extension_claims` helpers in `app.auth.claims` on the auth-gw side.
|
|
51
|
+
|
|
52
|
+
### Notes
|
|
53
|
+
|
|
54
|
+
- Migration of existing plaintext-stored credentials (BYOLLM keys, OAuth
|
|
55
|
+
refresh tokens, etc.) is at extension-author pace; no automated migration.
|
|
56
|
+
The V32 publish-time validator blocks *new* extensions that read
|
|
57
|
+
credential-like fields without an `@ext.secret` declaration
|
|
58
|
+
(validator implementation deferred).
|
|
59
|
+
|
|
5
60
|
## 4.2.1 — 2026-05-11
|
|
6
61
|
|
|
7
62
|
### Fixed
|
|
@@ -33,7 +33,13 @@ from imperal_sdk.validator_v1_6_0 import (
|
|
|
33
33
|
validate_manifest_v1_6_0,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
from imperal_sdk.secrets import (
|
|
37
|
+
SecretSpec, SecretClient, SecretStatus,
|
|
38
|
+
SecretNotDeclaredError, SecretWriteForbidden, SecretVaultUnavailable,
|
|
39
|
+
SecretValueTooLarge, SecretDeclarationConflict,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "4.2.2"
|
|
37
43
|
|
|
38
44
|
__all__ = [
|
|
39
45
|
# Core
|
|
@@ -139,6 +139,71 @@ class Extension:
|
|
|
139
139
|
self._panels: dict[str, dict] = {}
|
|
140
140
|
self._tray: dict[str, "TrayDef"] = {}
|
|
141
141
|
self._cache_models: dict[str, type] = {}
|
|
142
|
+
# EXT-SECRETS-V1 (v4.2.2) — declared secrets emitted into manifest.secrets[]
|
|
143
|
+
self._secrets: dict[str, "SecretSpec"] = {}
|
|
144
|
+
|
|
145
|
+
def secret(
|
|
146
|
+
self,
|
|
147
|
+
name: str,
|
|
148
|
+
description: str,
|
|
149
|
+
*,
|
|
150
|
+
required: bool = False,
|
|
151
|
+
write_mode: str = "user",
|
|
152
|
+
max_bytes: int = 4096,
|
|
153
|
+
rotation_hint_days: int | None = None,
|
|
154
|
+
):
|
|
155
|
+
"""Declare a secret the extension needs.
|
|
156
|
+
|
|
157
|
+
Federal EXT-SECRETS-V1 contract — manifest.secrets[] is the single
|
|
158
|
+
source of truth for what an extension may touch via ``ctx.secrets``.
|
|
159
|
+
|
|
160
|
+
Usage::
|
|
161
|
+
|
|
162
|
+
ext.secret(
|
|
163
|
+
name="spotify_api_key",
|
|
164
|
+
description="Your Spotify API key (from developer.spotify.com)",
|
|
165
|
+
required=True,
|
|
166
|
+
write_mode="user", # user pastes in Panel UI
|
|
167
|
+
max_bytes=200,
|
|
168
|
+
)(lambda: None)
|
|
169
|
+
|
|
170
|
+
# OAuth refresh tokens are written by the ext after authorize
|
|
171
|
+
ext.secret(
|
|
172
|
+
name="spotify_refresh_token",
|
|
173
|
+
description="OAuth refresh token written by ext after authorize",
|
|
174
|
+
write_mode="extension",
|
|
175
|
+
rotation_hint_days=30,
|
|
176
|
+
)(lambda: None)
|
|
177
|
+
|
|
178
|
+
Returns an identity decorator — the wrapped target is unchanged.
|
|
179
|
+
The call itself registers the SecretSpec on the Extension.
|
|
180
|
+
"""
|
|
181
|
+
from imperal_sdk.secrets.spec import SecretSpec
|
|
182
|
+
from imperal_sdk.secrets.exceptions import SecretDeclarationConflict
|
|
183
|
+
|
|
184
|
+
spec = SecretSpec(
|
|
185
|
+
name=name,
|
|
186
|
+
description=description,
|
|
187
|
+
required=required,
|
|
188
|
+
write_mode=write_mode,
|
|
189
|
+
max_bytes=max_bytes,
|
|
190
|
+
rotation_hint_days=rotation_hint_days,
|
|
191
|
+
)
|
|
192
|
+
if name in self._secrets:
|
|
193
|
+
raise SecretDeclarationConflict(
|
|
194
|
+
f"@ext.secret name={name!r} declared twice on app_id={self.app_id!r}"
|
|
195
|
+
)
|
|
196
|
+
self._secrets[name] = spec
|
|
197
|
+
|
|
198
|
+
def _decorator(target):
|
|
199
|
+
return target # syntactic anchor only — decorator is a no-op wrapper
|
|
200
|
+
|
|
201
|
+
return _decorator
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def secrets(self) -> dict[str, "SecretSpec"]:
|
|
205
|
+
"""Read-only view of declared secrets keyed by name."""
|
|
206
|
+
return dict(self._secrets)
|
|
142
207
|
|
|
143
208
|
def tool(self, name: str, scopes: list[str] | None = None, description: str = ""):
|
|
144
209
|
"""Register a tool that the AI assistant can call."""
|
|
@@ -176,6 +176,13 @@ def generate_manifest(ext: Extension) -> dict:
|
|
|
176
176
|
if ext.config_defaults:
|
|
177
177
|
manifest["config_defaults"] = ext.config_defaults
|
|
178
178
|
|
|
179
|
+
# EXT-SECRETS-V1 (v4.2.2) — emit declared secrets[]. Optional field;
|
|
180
|
+
# backwards-compatible (extensions without @ext.secret omit it).
|
|
181
|
+
if getattr(ext, "_secrets", None):
|
|
182
|
+
manifest["secrets"] = [
|
|
183
|
+
s.to_manifest_dict() for s in ext._secrets.values()
|
|
184
|
+
]
|
|
185
|
+
|
|
179
186
|
return manifest
|
|
180
187
|
|
|
181
188
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""EXT-SECRETS-V1 — federal per-user per-extension encrypted secrets.
|
|
2
|
+
|
|
3
|
+
User-facing surfaces:
|
|
4
|
+
- ``@ext.secret(name, description, ...)`` declares a secret in the manifest.
|
|
5
|
+
- ``ctx.secrets.get(name)`` reads plaintext (only inside handler scope).
|
|
6
|
+
- ``ctx.secrets.set(name, value)`` writes (only when write_mode allows).
|
|
7
|
+
|
|
8
|
+
See: superpowers/specs/2026-05-12-ext-secrets-v1-design.md
|
|
9
|
+
"""
|
|
10
|
+
from imperal_sdk.secrets.spec import SecretSpec
|
|
11
|
+
from imperal_sdk.secrets.client import SecretClient, SecretStatus
|
|
12
|
+
from imperal_sdk.secrets.exceptions import (
|
|
13
|
+
SecretNotDeclaredError,
|
|
14
|
+
SecretWriteForbidden,
|
|
15
|
+
SecretVaultUnavailable,
|
|
16
|
+
SecretValueTooLarge,
|
|
17
|
+
SecretDeclarationConflict,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SecretSpec",
|
|
22
|
+
"SecretClient",
|
|
23
|
+
"SecretStatus",
|
|
24
|
+
"SecretNotDeclaredError",
|
|
25
|
+
"SecretWriteForbidden",
|
|
26
|
+
"SecretVaultUnavailable",
|
|
27
|
+
"SecretValueTooLarge",
|
|
28
|
+
"SecretDeclarationConflict",
|
|
29
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""SecretClient — thin HTTP proxy from SDK to auth-gw /v1/secrets/*.
|
|
2
|
+
|
|
3
|
+
Federal contract:
|
|
4
|
+
- NEVER caches plaintext between calls (I-SECRETS-HANDLER-SCOPE-MEMORY).
|
|
5
|
+
- Validates name against manifest declarations (I-SECRETS-CONTRACT-DECLARED).
|
|
6
|
+
- Validates write_mode before PUT/DELETE/rotate.
|
|
7
|
+
- Translates auth-gw 503 → SecretVaultUnavailable.
|
|
8
|
+
- Returns None for 404 SECRET_NOT_SET (declared but no value yet).
|
|
9
|
+
|
|
10
|
+
Dev mode (when ``IMPERAL_DEV_MODE=true``):
|
|
11
|
+
- get(name) reads ``IMPERAL_SECRET_<UPPER_NAME>`` env var
|
|
12
|
+
- set/delete/rotate are no-ops with a WARN log (manifest contract still enforced)
|
|
13
|
+
- list() reflects env-var presence
|
|
14
|
+
|
|
15
|
+
Pytest: inject a MockSecretStore via fixture; see imperal_sdk.testing.MockSecretStore.
|
|
16
|
+
"""
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from imperal_sdk.secrets.exceptions import (
|
|
25
|
+
SecretNotDeclaredError,
|
|
26
|
+
SecretWriteForbidden,
|
|
27
|
+
SecretVaultUnavailable,
|
|
28
|
+
SecretValueTooLarge,
|
|
29
|
+
)
|
|
30
|
+
from imperal_sdk.secrets.spec import SecretSpec
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
SDK_HTTP_TIMEOUT_S = 5.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SecretStatus:
|
|
39
|
+
"""Returned by ``ctx.secrets.list()``. NEVER carries the value itself."""
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
is_set: bool
|
|
43
|
+
last_accessed_at: Optional[int]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dev_mode_active() -> bool:
|
|
47
|
+
return os.getenv("IMPERAL_DEV_MODE", "").lower() in {"1", "true", "yes", "on"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _dev_env_key(name: str) -> str:
|
|
51
|
+
return f"IMPERAL_SECRET_{name.upper()}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SecretClient:
|
|
55
|
+
"""Source-inspection contract: NO module-level cache, NO @lru_cache,
|
|
56
|
+
NO instance attribute holding plaintext between calls. Plaintext is
|
|
57
|
+
only ever a local variable in get()'s return path."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
ext_id: str,
|
|
63
|
+
imperal_id: str,
|
|
64
|
+
auth_gw_base: str,
|
|
65
|
+
session_token: str,
|
|
66
|
+
declared: dict[str, SecretSpec],
|
|
67
|
+
):
|
|
68
|
+
self._ext_id = ext_id
|
|
69
|
+
self._imperal_id = imperal_id
|
|
70
|
+
self._base = auth_gw_base.rstrip("/")
|
|
71
|
+
self._token = session_token
|
|
72
|
+
self._declared = declared
|
|
73
|
+
|
|
74
|
+
def _headers(self, *, json: bool = False) -> dict:
|
|
75
|
+
h = {
|
|
76
|
+
"Authorization": f"Bearer {self._token}",
|
|
77
|
+
"X-Acting-User": self._imperal_id,
|
|
78
|
+
"X-Ext-Id": self._ext_id,
|
|
79
|
+
}
|
|
80
|
+
if json:
|
|
81
|
+
h["Content-Type"] = "application/json"
|
|
82
|
+
return h
|
|
83
|
+
|
|
84
|
+
def _ensure_declared(self, name: str) -> SecretSpec:
|
|
85
|
+
if name not in self._declared:
|
|
86
|
+
raise SecretNotDeclaredError(
|
|
87
|
+
f"secret name={name!r} not in manifest for ext_id={self._ext_id!r}"
|
|
88
|
+
)
|
|
89
|
+
return self._declared[name]
|
|
90
|
+
|
|
91
|
+
async def get(self, name: str) -> Optional[str]:
|
|
92
|
+
self._ensure_declared(name)
|
|
93
|
+
|
|
94
|
+
if _dev_mode_active():
|
|
95
|
+
return os.getenv(_dev_env_key(name))
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
async with httpx.AsyncClient(timeout=SDK_HTTP_TIMEOUT_S) as c:
|
|
99
|
+
r = await c.get(
|
|
100
|
+
f"{self._base}/v1/secrets/{self._ext_id}/{name}",
|
|
101
|
+
headers=self._headers(),
|
|
102
|
+
)
|
|
103
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError) as e:
|
|
104
|
+
raise SecretVaultUnavailable(
|
|
105
|
+
f"auth-gw unreachable on get(name={name!r}): {type(e).__name__}"
|
|
106
|
+
) from None
|
|
107
|
+
if r.status_code == 200:
|
|
108
|
+
return r.json().get("value")
|
|
109
|
+
if r.status_code == 404:
|
|
110
|
+
return None
|
|
111
|
+
if r.status_code == 503:
|
|
112
|
+
raise SecretVaultUnavailable(
|
|
113
|
+
f"auth-gw 503 on get(name={name!r})"
|
|
114
|
+
)
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"unexpected auth-gw status {r.status_code} on get(name={name!r})"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def set(self, name: str, value: str) -> None:
|
|
120
|
+
spec = self._ensure_declared(name)
|
|
121
|
+
if spec.write_mode == "user":
|
|
122
|
+
raise SecretWriteForbidden(
|
|
123
|
+
f"secret name={name!r} has write_mode='user'; only Panel UI can "
|
|
124
|
+
f"write. Declare write_mode='extension' or 'both' to allow."
|
|
125
|
+
)
|
|
126
|
+
if len(value.encode("utf-8")) > spec.max_bytes:
|
|
127
|
+
raise SecretValueTooLarge(
|
|
128
|
+
f"value ({len(value.encode())} bytes) exceeds "
|
|
129
|
+
f"max_bytes={spec.max_bytes} for name={name!r}"
|
|
130
|
+
)
|
|
131
|
+
if _dev_mode_active():
|
|
132
|
+
log.warning(
|
|
133
|
+
"secret writes ignored in dev mode (name=%s, ext_id=%s)",
|
|
134
|
+
name, self._ext_id,
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
async with httpx.AsyncClient(timeout=SDK_HTTP_TIMEOUT_S) as c:
|
|
140
|
+
r = await c.put(
|
|
141
|
+
f"{self._base}/v1/secrets/{self._ext_id}/{name}",
|
|
142
|
+
headers=self._headers(json=True),
|
|
143
|
+
json={"value": value},
|
|
144
|
+
)
|
|
145
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError) as e:
|
|
146
|
+
raise SecretVaultUnavailable(
|
|
147
|
+
f"auth-gw unreachable on set(name={name!r}): {type(e).__name__}"
|
|
148
|
+
) from None
|
|
149
|
+
if r.status_code == 200:
|
|
150
|
+
return
|
|
151
|
+
if r.status_code == 503:
|
|
152
|
+
raise SecretVaultUnavailable(f"auth-gw 503 on set(name={name!r})")
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
f"unexpected auth-gw status {r.status_code} on set(name={name!r})"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def delete(self, name: str) -> bool:
|
|
158
|
+
spec = self._ensure_declared(name)
|
|
159
|
+
if spec.write_mode == "user":
|
|
160
|
+
raise SecretWriteForbidden(
|
|
161
|
+
f"secret name={name!r} write_mode='user' — only Panel can delete"
|
|
162
|
+
)
|
|
163
|
+
if _dev_mode_active():
|
|
164
|
+
log.warning("secret deletes ignored in dev mode (name=%s)", name)
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
async with httpx.AsyncClient(timeout=SDK_HTTP_TIMEOUT_S) as c:
|
|
169
|
+
r = await c.delete(
|
|
170
|
+
f"{self._base}/v1/secrets/{self._ext_id}/{name}",
|
|
171
|
+
headers=self._headers(),
|
|
172
|
+
)
|
|
173
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError) as e:
|
|
174
|
+
raise SecretVaultUnavailable(
|
|
175
|
+
f"auth-gw unreachable on delete(name={name!r}): {type(e).__name__}"
|
|
176
|
+
) from None
|
|
177
|
+
if r.status_code == 200:
|
|
178
|
+
return bool(r.json().get("was_set", False))
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"unexpected auth-gw status {r.status_code} on delete(name={name!r})"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def is_set(self, name: str) -> bool:
|
|
184
|
+
self._ensure_declared(name)
|
|
185
|
+
if _dev_mode_active():
|
|
186
|
+
return os.getenv(_dev_env_key(name)) is not None
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
async with httpx.AsyncClient(timeout=SDK_HTTP_TIMEOUT_S) as c:
|
|
190
|
+
r = await c.get(
|
|
191
|
+
f"{self._base}/v1/secrets/{self._ext_id}/{name}/meta",
|
|
192
|
+
headers=self._headers(),
|
|
193
|
+
)
|
|
194
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError) as e:
|
|
195
|
+
raise SecretVaultUnavailable(
|
|
196
|
+
f"auth-gw unreachable on is_set(name={name!r}): {type(e).__name__}"
|
|
197
|
+
) from None
|
|
198
|
+
if r.status_code == 200:
|
|
199
|
+
return bool(r.json().get("is_set", False))
|
|
200
|
+
if r.status_code == 404:
|
|
201
|
+
return False
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
f"unexpected auth-gw status {r.status_code} on is_set(name={name!r})"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def list(self) -> list[SecretStatus]:
|
|
207
|
+
if _dev_mode_active():
|
|
208
|
+
return [
|
|
209
|
+
SecretStatus(
|
|
210
|
+
name=n,
|
|
211
|
+
description=s.description,
|
|
212
|
+
is_set=os.getenv(_dev_env_key(n)) is not None,
|
|
213
|
+
last_accessed_at=None,
|
|
214
|
+
)
|
|
215
|
+
for n, s in self._declared.items()
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
async with httpx.AsyncClient(timeout=SDK_HTTP_TIMEOUT_S) as c:
|
|
220
|
+
r = await c.get(
|
|
221
|
+
f"{self._base}/v1/secrets/{self._ext_id}",
|
|
222
|
+
headers=self._headers(),
|
|
223
|
+
)
|
|
224
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError) as e:
|
|
225
|
+
raise SecretVaultUnavailable(
|
|
226
|
+
f"auth-gw unreachable on list(): {type(e).__name__}"
|
|
227
|
+
) from None
|
|
228
|
+
if r.status_code == 200:
|
|
229
|
+
return [
|
|
230
|
+
SecretStatus(
|
|
231
|
+
name=item.get("name", ""),
|
|
232
|
+
description=item.get("description", ""),
|
|
233
|
+
is_set=bool(item.get("is_set", False)),
|
|
234
|
+
last_accessed_at=item.get("last_accessed_at"),
|
|
235
|
+
)
|
|
236
|
+
for item in r.json()
|
|
237
|
+
]
|
|
238
|
+
raise RuntimeError(f"unexpected auth-gw status {r.status_code} on list()")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Secret-related SDK exceptions. Federal rule: NEVER embed plaintext in messages."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SecretNotDeclaredError(Exception):
|
|
5
|
+
"""ctx.secrets.* called with a name not in the manifest's secrets[].
|
|
6
|
+
|
|
7
|
+
Manifest is the single source of truth for what an extension may touch
|
|
8
|
+
(I-SECRETS-CONTRACT-DECLARED).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SecretWriteForbidden(Exception):
|
|
13
|
+
"""ctx.secrets.set() called for a secret with manifest write_mode='user'.
|
|
14
|
+
|
|
15
|
+
Only the Panel UI (user-attributable session) can write 'user'-mode
|
|
16
|
+
secrets. Extension code can write secrets declared with
|
|
17
|
+
write_mode='extension' or write_mode='both'.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecretVaultUnavailable(Exception):
|
|
22
|
+
"""auth-gw returned 503; Vault transit endpoint is down.
|
|
23
|
+
|
|
24
|
+
Per I-SECRETS-VAULT-DEPENDENCY, the SDK fails closed — no fallback
|
|
25
|
+
decryption, no cached plaintext.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecretValueTooLarge(Exception):
|
|
30
|
+
"""Written value exceeds the manifest's max_bytes for this secret."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SecretDeclarationConflict(Exception):
|
|
34
|
+
"""@ext.secret declared the same name twice for one Extension."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""SecretSpec — declarative shape of one @ext.secret declaration."""
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
SECRET_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
7
|
+
ALLOWED_WRITE_MODES = frozenset({"user", "extension", "both"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SecretSpec:
|
|
12
|
+
"""One secret an extension declares it needs.
|
|
13
|
+
|
|
14
|
+
Federal contract:
|
|
15
|
+
- ``name`` is snake_case; auth-gw stores it scoped under (user_id, ext_id, name)
|
|
16
|
+
- ``write_mode`` determines who can write the value: 'user' (Panel UI only),
|
|
17
|
+
'extension' (ctx.secrets.set only), or 'both'
|
|
18
|
+
- ``required=True`` triggers a dispatch-time gate — kernel blocks handler
|
|
19
|
+
and emits secret_missing_card chat message if value not set
|
|
20
|
+
- ``max_bytes`` caps both write payload and storage; hard ceiling 65536
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
description: str
|
|
25
|
+
required: bool = False
|
|
26
|
+
write_mode: Literal["user", "extension", "both"] = "user"
|
|
27
|
+
max_bytes: int = 4096
|
|
28
|
+
rotation_hint_days: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not SECRET_NAME_RE.match(self.name):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"SecretSpec.name {self.name!r} fails regex "
|
|
34
|
+
f"{SECRET_NAME_RE.pattern!r} (snake_case, start-letter, ≤63 chars)"
|
|
35
|
+
)
|
|
36
|
+
if self.write_mode not in ALLOWED_WRITE_MODES:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"SecretSpec.write_mode {self.write_mode!r} must be one of "
|
|
39
|
+
f"{sorted(ALLOWED_WRITE_MODES)}"
|
|
40
|
+
)
|
|
41
|
+
if not (1 <= self.max_bytes <= 65536):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"SecretSpec.max_bytes {self.max_bytes!r} must be in [1, 65536]"
|
|
44
|
+
)
|
|
45
|
+
if self.rotation_hint_days is not None and self.rotation_hint_days < 1:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"SecretSpec.rotation_hint_days {self.rotation_hint_days!r} "
|
|
48
|
+
f"must be a positive integer or None"
|
|
49
|
+
)
|
|
50
|
+
if not self.description.strip():
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"SecretSpec.description must be non-empty — Panel UI shows it "
|
|
53
|
+
"to the user when they're entering the value"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def to_manifest_dict(self) -> dict:
|
|
57
|
+
d = {
|
|
58
|
+
"name": self.name,
|
|
59
|
+
"description": self.description,
|
|
60
|
+
"required": self.required,
|
|
61
|
+
"write_mode": self.write_mode,
|
|
62
|
+
"max_bytes": self.max_bytes,
|
|
63
|
+
}
|
|
64
|
+
if self.rotation_hint_days is not None:
|
|
65
|
+
d["rotation_hint_days"] = self.rotation_hint_days
|
|
66
|
+
return d
|
|
@@ -13,6 +13,7 @@ from imperal_sdk.testing.mock_context import (
|
|
|
13
13
|
MockStorage,
|
|
14
14
|
MockStore,
|
|
15
15
|
)
|
|
16
|
+
from imperal_sdk.testing.mock_secrets import MockSecretStore
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
18
19
|
"MockContext",
|
|
@@ -25,4 +26,5 @@ __all__ = [
|
|
|
25
26
|
"MockHTTP",
|
|
26
27
|
"MockConfig",
|
|
27
28
|
"MockExtensions",
|
|
29
|
+
"MockSecretStore",
|
|
28
30
|
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
"""MockSecretStore — pytest-friendly in-memory backend for ctx.secrets.
|
|
4
|
+
|
|
5
|
+
Canonical pattern::
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def secrets():
|
|
9
|
+
return MockSecretStore({"openai_api_key": "sk-test"})
|
|
10
|
+
|
|
11
|
+
async def test_my_handler(ctx_factory, secrets):
|
|
12
|
+
ctx = ctx_factory(secrets=secrets)
|
|
13
|
+
...
|
|
14
|
+
"""
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _Status:
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
is_set: bool
|
|
24
|
+
last_accessed_at: Optional[int]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MockSecretStore:
|
|
28
|
+
"""Drop-in replacement for SecretClient in pytest. No HTTP, no Vault.
|
|
29
|
+
|
|
30
|
+
Validates name-not-declared semantics if ``declared`` set is passed
|
|
31
|
+
(raises ImportError-style ValueError to mirror SecretNotDeclaredError).
|
|
32
|
+
Otherwise accepts any name (looser default for fixture ergonomics).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
initial: dict[str, str] | None = None,
|
|
38
|
+
*,
|
|
39
|
+
declared: set[str] | None = None,
|
|
40
|
+
):
|
|
41
|
+
self._store: dict[str, str] = dict(initial or {})
|
|
42
|
+
self._declared = declared # if None, all names allowed
|
|
43
|
+
|
|
44
|
+
def _check_declared(self, name: str) -> None:
|
|
45
|
+
if self._declared is not None and name not in self._declared:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"MockSecretStore: name {name!r} not in declared set "
|
|
48
|
+
f"(declared={sorted(self._declared)})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def get(self, name: str) -> Optional[str]:
|
|
52
|
+
self._check_declared(name)
|
|
53
|
+
return self._store.get(name)
|
|
54
|
+
|
|
55
|
+
async def set(self, name: str, value: str) -> None:
|
|
56
|
+
self._check_declared(name)
|
|
57
|
+
self._store[name] = value
|
|
58
|
+
|
|
59
|
+
async def delete(self, name: str) -> bool:
|
|
60
|
+
self._check_declared(name)
|
|
61
|
+
return self._store.pop(name, None) is not None
|
|
62
|
+
|
|
63
|
+
async def is_set(self, name: str) -> bool:
|
|
64
|
+
self._check_declared(name)
|
|
65
|
+
return name in self._store
|
|
66
|
+
|
|
67
|
+
async def list(self) -> list[_Status]:
|
|
68
|
+
return [
|
|
69
|
+
_Status(
|
|
70
|
+
name=n,
|
|
71
|
+
description="(mock)",
|
|
72
|
+
is_set=True,
|
|
73
|
+
last_accessed_at=None,
|
|
74
|
+
)
|
|
75
|
+
for n in self._store
|
|
76
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|