imperal-sdk 4.2.0__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.
Files changed (205) hide show
  1. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/CHANGELOG.md +78 -0
  2. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/PKG-INFO +1 -1
  3. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/__init__.py +7 -1
  4. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/extension.py +65 -0
  5. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/manifest.py +7 -0
  6. imperal_sdk-4.2.2/src/imperal_sdk/secrets/__init__.py +29 -0
  7. imperal_sdk-4.2.2/src/imperal_sdk/secrets/client.py +238 -0
  8. imperal_sdk-4.2.2/src/imperal_sdk/secrets/exceptions.py +34 -0
  9. imperal_sdk-4.2.2/src/imperal_sdk/secrets/spec.py +66 -0
  10. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/testing/__init__.py +2 -0
  11. imperal_sdk-4.2.2/src/imperal_sdk/testing/mock_secrets.py +76 -0
  12. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/validator_v1_6_0.py +20 -8
  13. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_validator_v1_6_0_rules.py +37 -4
  14. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/.github/workflows/identity-contract.yml +0 -0
  15. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/.github/workflows/publish.yml +0 -0
  16. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/.github/workflows/test.yml +0 -0
  17. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/.gitignore +0 -0
  18. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/LICENSE +0 -0
  19. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/README.md +0 -0
  20. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/api_surface.json +0 -0
  21. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/pyproject.toml +0 -0
  22. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/.codebase-index-cache.pkl +0 -0
  23. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ai/__init__.py +0 -0
  24. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ai/client.py +0 -0
  25. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/auth/__init__.py +0 -0
  26. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/auth/client.py +0 -0
  27. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/auth/middleware.py +0 -0
  28. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/billing/__init__.py +0 -0
  29. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/billing/client.py +0 -0
  30. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/cache/__init__.py +0 -0
  31. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/cache/client.py +0 -0
  32. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/cache/protocol.py +0 -0
  33. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/__init__.py +0 -0
  34. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/action_result.py +0 -0
  35. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/error_codes.py +0 -0
  36. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/extension.py +0 -0
  37. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/filters.py +0 -0
  38. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/guards.py +0 -0
  39. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/handler.py +0 -0
  40. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/kernel_primitives.py +0 -0
  41. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/narration.py +0 -0
  42. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/narration_guard.py +0 -0
  43. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/prompt.py +0 -0
  44. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/refusal.py +0 -0
  45. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/chat/retry.py +0 -0
  46. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/cli/__init__.py +0 -0
  47. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/cli/main.py +0 -0
  48. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/config/__init__.py +0 -0
  49. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/config/client.py +0 -0
  50. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/context.py +0 -0
  51. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/db/__init__.py +0 -0
  52. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/db/client.py +0 -0
  53. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/errors.py +0 -0
  54. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/extensions/__init__.py +0 -0
  55. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/extensions/client.py +0 -0
  56. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/http/__init__.py +0 -0
  57. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/http/client.py +0 -0
  58. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/manifest_schema.py +0 -0
  59. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/notify/__init__.py +0 -0
  60. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/notify/client.py +0 -0
  61. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/__init__.py +0 -0
  62. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/icnli_integrity_rules.txt +0 -0
  63. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/kernel_formatting_rule.txt +0 -0
  64. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/prompts/kernel_proactivity_rule.txt +0 -0
  65. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/protocols.py +0 -0
  66. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/rpc/__init__.py +0 -0
  67. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/rpc/codec.py +0 -0
  68. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/rpc/contract.py +0 -0
  69. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/__init__.py +0 -0
  70. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/executor.py +0 -0
  71. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/llm_provider.py +0 -0
  72. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/runtime/message_adapter.py +0 -0
  73. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/action_result.schema.json +0 -0
  74. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/balance_info.schema.json +0 -0
  75. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/chat_result.schema.json +0 -0
  76. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/completion_result.schema.json +0 -0
  77. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/document.schema.json +0 -0
  78. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/event.schema.json +0 -0
  79. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/file_info.schema.json +0 -0
  80. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/function_call.schema.json +0 -0
  81. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/http_response.schema.json +0 -0
  82. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/imperal.schema.json +0 -0
  83. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/limits_result.schema.json +0 -0
  84. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/schemas/subscription_info.schema.json +0 -0
  85. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/security/__init__.py +0 -0
  86. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/security/call_token.py +0 -0
  87. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/skeleton/__init__.py +0 -0
  88. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/skeleton/client.py +0 -0
  89. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/storage/__init__.py +0 -0
  90. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/storage/client.py +0 -0
  91. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/store/__init__.py +0 -0
  92. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/store/client.py +0 -0
  93. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/store/exceptions.py +0 -0
  94. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/testing/mock_context.py +0 -0
  95. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/__init__.py +0 -0
  96. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/client.py +0 -0
  97. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/generate_api_surface.py +0 -0
  98. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/tools/validate_identity_contract.py +0 -0
  99. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/__init__.py +0 -0
  100. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/action_result.py +0 -0
  101. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/chat_result.py +0 -0
  102. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/client_contracts.py +0 -0
  103. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/contracts.py +0 -0
  104. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/contributions.py +0 -0
  105. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/events.py +0 -0
  106. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/health.py +0 -0
  107. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/identity.py +0 -0
  108. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/models.py +0 -0
  109. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/pagination.py +0 -0
  110. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/types/store_contracts.py +0 -0
  111. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/__init__.py +0 -0
  112. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/actions.py +0 -0
  113. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/base.py +0 -0
  114. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/data.py +0 -0
  115. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/display.py +0 -0
  116. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/feedback.py +0 -0
  117. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/graph.py +0 -0
  118. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/input_components.py +0 -0
  119. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/interactive.py +0 -0
  120. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/layout.py +0 -0
  121. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/ui/theme.py +0 -0
  122. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/src/imperal_sdk/validator.py +0 -0
  123. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/conftest.py +0 -0
  124. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/contracts/__init__.py +0 -0
  125. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/contracts/test_store_contracts.py +0 -0
  126. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/fixtures/openapi/auth-gateway.json +0 -0
  127. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/fixtures/openapi/registry.json +0 -0
  128. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/fixtures/openapi/sharelock-cases.json +0 -0
  129. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/rpc/__init__.py +0 -0
  130. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/rpc/test_codec.py +0 -0
  131. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/rpc/test_contract.py +0 -0
  132. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/runtime/__init__.py +0 -0
  133. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/runtime/test_llm_provider_config_store.py +0 -0
  134. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/runtime/test_llm_provider_ctx_injection.py +0 -0
  135. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/store/__init__.py +0 -0
  136. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/store/test_list_users_client.py +0 -0
  137. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/store/test_query_all_client.py +0 -0
  138. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_action_result_typed.py +0 -0
  139. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_as_user.py +0 -0
  140. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_auth.py +0 -0
  141. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_billing.py +0 -0
  142. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_cache_client.py +0 -0
  143. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_cache_model.py +0 -0
  144. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_call_token.py +0 -0
  145. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_extension_deprecation.py +0 -0
  146. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_filters.py +0 -0
  147. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_guards.py +0 -0
  148. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_guards_bleed.py +0 -0
  149. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_prompt.py +0 -0
  150. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_pydantic_retry.py +0 -0
  151. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_chat_result.py +0 -0
  152. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_cli.py +0 -0
  153. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_client_contracts.py +0 -0
  154. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_config_client.py +0 -0
  155. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_context.py +0 -0
  156. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_context_guards.py +0 -0
  157. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_contracts.py +0 -0
  158. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_contracts_live.py +0 -0
  159. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_contributions.py +0 -0
  160. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_document_contract.py +0 -0
  161. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_emits_decorator.py +0 -0
  162. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_error_codes.py +0 -0
  163. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_errors.py +0 -0
  164. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_event_schema_v2.py +0 -0
  165. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_events_health.py +0 -0
  166. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_extension.py +0 -0
  167. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_extension_v2.py +0 -0
  168. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_extensions_emit.py +0 -0
  169. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_handler_p2.py +0 -0
  170. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_id_shape_guard.py +0 -0
  171. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_identity_contract.py +0 -0
  172. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_imperal_schema_v2.py +0 -0
  173. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_kernel_primitives.py +0 -0
  174. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest.py +0 -0
  175. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest_roundtrip_gate.py +0 -0
  176. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest_schema.py +0 -0
  177. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest_v2_events.py +0 -0
  178. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest_v2_other_sections.py +0 -0
  179. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest_v2_webhooks.py +0 -0
  180. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_manifest_validator_v2.py +0 -0
  181. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_mock_context.py +0 -0
  182. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_models.py +0 -0
  183. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_narration_emission.py +0 -0
  184. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_narration_guard.py +0 -0
  185. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_openai_max_completion_tokens.py +0 -0
  186. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_pagination.py +0 -0
  187. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_panel_rendering_contract.py +0 -0
  188. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_panels.py +0 -0
  189. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_skeleton_decorator.py +0 -0
  190. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_spec_validation.py +0 -0
  191. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_tools_client.py +0 -0
  192. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_ui.py +0 -0
  193. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_ui_fileupload_enhanced.py +0 -0
  194. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_ui_html.py +0 -0
  195. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_ui_image_enhanced.py +0 -0
  196. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_ui_open.py +0 -0
  197. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_ui_theme.py +0 -0
  198. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_user.py +0 -0
  199. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_v7_emit_refusal.py +0 -0
  200. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_validator.py +0 -0
  201. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_validator_drift.py +0 -0
  202. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_validator_pep563.py +0 -0
  203. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/test_write_arg_bleed.py +0 -0
  204. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/tools/__init__.py +0 -0
  205. {imperal_sdk-4.2.0 → imperal_sdk-4.2.2}/tests/tools/test_generate_api_surface.py +0 -0
@@ -2,6 +2,84 @@
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
+
60
+ ## 4.2.1 — 2026-05-11
61
+
62
+ ### Fixed
63
+
64
+ - **`MANIFEST-SKELETON-1` false positive on `@ext.tool("skeleton_alert_*")`**.
65
+ The local AST validator (`validator_v1_6_0.py`) was flagging the
66
+ canonical paired-alert pattern as a rule violation with the fix
67
+ suggestion *"Replace with `@ext.skeleton(<section>)`"* — but that
68
+ suggestion is wrong. `@ext.skeleton(section, alert=True)` registers
69
+ **only** `skeleton_refresh_<section>`; the paired
70
+ `skeleton_alert_<section>` handler **must** be registered separately
71
+ with `@ext.tool` (the kernel discovers alerts by tool-name presence
72
+ in `tools[]`, not via any `@ext.skeleton` metadata). The validator
73
+ now flags only `skeleton_refresh_*` tools, leaving
74
+ `skeleton_alert_*` as the documented, kernel-supported pattern.
75
+ See `Extension.skeleton` docstring and
76
+ `docs.imperal.io/en/sdk/decorator-skeleton-reference`.
77
+
78
+ Test updates: `test_manifest_skeleton_1_triggers_on_wrong_decorator`
79
+ expects exactly 1 hit (refresh only); new
80
+ `test_manifest_skeleton_1_silent_on_paired_alert_via_ext_tool` locks
81
+ the canonical pattern as silent.
82
+
5
83
  ## 4.2.0 — 2026-05-11
6
84
 
7
85
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imperal-sdk
3
- Version: 4.2.0
3
+ Version: 4.2.2
4
4
  Summary: SDK for building Imperal Cloud extensions
5
5
  Author: Valentin Scerbacov, Imperal, Inc.
6
6
  License-Expression: AGPL-3.0-or-later
@@ -33,7 +33,13 @@ from imperal_sdk.validator_v1_6_0 import (
33
33
  validate_manifest_v1_6_0,
34
34
  )
35
35
 
36
- __version__ = "4.2.0"
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
+ ]