alysis-code 0.13.0__py3-none-any.whl

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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1504 @@
1
+ """Durable, protocol-safe OAuth lifecycle primitives for IDE-managed MCP login.
2
+
3
+ This module deliberately does not open a browser, listen on a callback socket,
4
+ perform HTTP requests, or serialize credentials. It owns the security-sensitive
5
+ state machine around those operations while callers supply the UI and token
6
+ exchange adapters.
7
+
8
+ Only short-lived flow material is stored in the SQLite registry. Access and
9
+ refresh tokens are handed directly to an injected :class:`OAuthTokenVault` and
10
+ are excluded from dataclass representations, public payloads, exceptions, and
11
+ the registry database.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ import secrets
24
+ import sqlite3
25
+ import threading
26
+ import time
27
+ from collections.abc import Callable, Iterable, Mapping
28
+ from contextlib import contextmanager
29
+ from dataclasses import dataclass, field
30
+ from enum import Enum
31
+ from pathlib import Path
32
+ from typing import Any, Protocol, TypeAlias
33
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
34
+
35
+ from ..branding import canonical_user_data_dir, env_get
36
+
37
+ __all__ = [
38
+ "AuthorizationCodeFlowRequest",
39
+ "DeviceCodeFlowRequest",
40
+ "McpOAuthFlowRegistry",
41
+ "OAuthCompletionClaim",
42
+ "OAuthCompletionRejectedError",
43
+ "OAuthExchangeMaterial",
44
+ "OAuthFlowConflictError",
45
+ "OAuthFlowKind",
46
+ "OAuthFlowNotFoundError",
47
+ "OAuthFlowState",
48
+ "OAuthFlowStateError",
49
+ "OAuthFlowStatus",
50
+ "OAuthLifecycleError",
51
+ "OAuthLogoutResult",
52
+ "OAuthTokenSet",
53
+ "OAuthTokenVault",
54
+ "OAuthValidationError",
55
+ "OAuthVaultError",
56
+ "default_oauth_flow_registry_path",
57
+ ]
58
+
59
+ JsonScalar: TypeAlias = None | bool | int | float | str
60
+
61
+ SCHEMA_VERSION = 1
62
+ DEFAULT_FLOW_TTL_SECONDS = 10 * 60.0
63
+ DEFAULT_COMPLETION_LEASE_SECONDS = 90.0
64
+ DEFAULT_TERMINAL_RETENTION_SECONDS = 24 * 60 * 60.0
65
+ MAX_FLOW_TTL_SECONDS = 30 * 60.0
66
+ MAX_URL_LENGTH = 4096
67
+ MAX_SCOPES = 64
68
+
69
+ _SERVER_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
70
+ _PARAM_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
71
+ _SCOPE_RE = re.compile(r"^[\x21\x23-\x5B\x5D-\x7E]{1,256}$")
72
+ _FLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
73
+ _ERROR_CODE_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
74
+ _RESERVED_AUTH_PARAMS = frozenset(
75
+ {
76
+ "client_id",
77
+ "code_challenge",
78
+ "code_challenge_method",
79
+ "nonce",
80
+ "redirect_uri",
81
+ "response_type",
82
+ "scope",
83
+ "state",
84
+ }
85
+ )
86
+ _SENSITIVE_URL_PARAM_NAMES = frozenset(
87
+ {
88
+ "access_token",
89
+ "authorization",
90
+ "client_secret",
91
+ "code",
92
+ "device_code",
93
+ "id_token",
94
+ "refresh_token",
95
+ "token",
96
+ }
97
+ )
98
+
99
+
100
+ class OAuthFlowKind(str, Enum):
101
+ AUTHORIZATION_CODE = "authorization_code"
102
+ DEVICE_CODE = "device_code"
103
+
104
+
105
+ class OAuthFlowState(str, Enum):
106
+ PENDING = "pending"
107
+ COMPLETING = "completing"
108
+ COMPLETED = "completed"
109
+ CANCELLED = "cancelled"
110
+ FAILED = "failed"
111
+ EXPIRED = "expired"
112
+
113
+
114
+ TERMINAL_STATES = frozenset(
115
+ {
116
+ OAuthFlowState.COMPLETED,
117
+ OAuthFlowState.CANCELLED,
118
+ OAuthFlowState.FAILED,
119
+ OAuthFlowState.EXPIRED,
120
+ }
121
+ )
122
+
123
+
124
+ class OAuthLifecycleError(RuntimeError):
125
+ """Base error whose message is safe for logs and protocol error objects."""
126
+
127
+
128
+ class OAuthValidationError(OAuthLifecycleError, ValueError):
129
+ pass
130
+
131
+
132
+ class OAuthFlowConflictError(OAuthLifecycleError):
133
+ pass
134
+
135
+
136
+ class OAuthFlowNotFoundError(OAuthLifecycleError):
137
+ pass
138
+
139
+
140
+ class OAuthFlowStateError(OAuthLifecycleError):
141
+ pass
142
+
143
+
144
+ class OAuthCompletionRejectedError(OAuthLifecycleError):
145
+ pass
146
+
147
+
148
+ class OAuthVaultError(OAuthLifecycleError):
149
+ pass
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class OAuthTokenSet:
154
+ """Credentials that must only move between an exchange adapter and a vault."""
155
+
156
+ access_token: str = field(repr=False)
157
+ refresh_token: str | None = field(default=None, repr=False)
158
+ token_type: str = field(default="Bearer", repr=False)
159
+ expires_at: float | None = None
160
+ scopes: tuple[str, ...] = field(default_factory=tuple, repr=False)
161
+
162
+ def __post_init__(self) -> None:
163
+ if not isinstance(self.access_token, str) or not self.access_token.strip():
164
+ raise OAuthValidationError("OAuth access token is missing.")
165
+ if self.refresh_token is not None and (
166
+ not isinstance(self.refresh_token, str) or not self.refresh_token.strip()
167
+ ):
168
+ raise OAuthValidationError("OAuth refresh token is invalid.")
169
+ if not isinstance(self.token_type, str) or not self.token_type.strip():
170
+ raise OAuthValidationError("OAuth token type is invalid.")
171
+ if self.expires_at is not None and (
172
+ isinstance(self.expires_at, bool)
173
+ or not isinstance(self.expires_at, (int, float))
174
+ or not math.isfinite(float(self.expires_at))
175
+ ):
176
+ raise OAuthValidationError("OAuth token expiry is invalid.")
177
+ object.__setattr__(self, "scopes", _normalize_scopes(self.scopes))
178
+
179
+
180
+ class OAuthTokenVault(Protocol):
181
+ """Secret-store boundary required by :class:`McpOAuthFlowRegistry`.
182
+
183
+ ``binding_id`` is the flow id. Implementations must save the credentials
184
+ atomically and preserve the binding so a restarted registry can determine
185
+ whether an interrupted completion reached the vault.
186
+ """
187
+
188
+ def store(self, server_id: str, tokens: OAuthTokenSet, *, binding_id: str) -> None: ...
189
+
190
+ def load(self, server_id: str) -> OAuthTokenSet | None: ...
191
+
192
+ def has_binding(self, server_id: str, *, binding_id: str) -> bool: ...
193
+
194
+ def delete(self, server_id: str) -> bool: ...
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class AuthorizationCodeFlowRequest:
199
+ server_id: str
200
+ authorization_endpoint: str
201
+ token_endpoint: str
202
+ client_id: str = field(repr=False)
203
+ redirect_uri: str
204
+ scopes: tuple[str, ...] = field(default_factory=tuple)
205
+ extra_authorization_params: Mapping[str, JsonScalar] = field(default_factory=dict, repr=False)
206
+ expires_in: float = DEFAULT_FLOW_TTL_SECONDS
207
+ require_nonce: bool = True
208
+
209
+
210
+ @dataclass(frozen=True, slots=True)
211
+ class DeviceCodeFlowRequest:
212
+ server_id: str
213
+ token_endpoint: str
214
+ client_id: str = field(repr=False)
215
+ device_code: str = field(repr=False)
216
+ user_code: str = field(repr=False)
217
+ verification_uri: str
218
+ verification_uri_complete: str | None = field(default=None, repr=False)
219
+ scopes: tuple[str, ...] = field(default_factory=tuple)
220
+ expires_in: float = DEFAULT_FLOW_TTL_SECONDS
221
+ polling_interval: float = 5.0
222
+
223
+
224
+ @dataclass(frozen=True, slots=True)
225
+ class OAuthFlowStatus:
226
+ flow_id: str
227
+ server_id: str
228
+ kind: OAuthFlowKind
229
+ state: OAuthFlowState
230
+ created_at: float
231
+ updated_at: float
232
+ expires_at: float
233
+ authorization_url: str | None = field(default=None, repr=False)
234
+ verification_uri: str | None = None
235
+ verification_uri_complete: str | None = field(default=None, repr=False)
236
+ user_code: str | None = field(default=None, repr=False)
237
+ polling_interval: float | None = None
238
+ terminal_at: float | None = None
239
+ error_code: str | None = None
240
+
241
+ def to_public_dict(self) -> dict[str, Any]:
242
+ """Return the allowlisted payload suitable for an IDE protocol response."""
243
+
244
+ payload: dict[str, Any] = {
245
+ "flow_id": self.flow_id,
246
+ "server_id": self.server_id,
247
+ "kind": self.kind.value,
248
+ "state": self.state.value,
249
+ "created_at": self.created_at,
250
+ "updated_at": self.updated_at,
251
+ "expires_at": self.expires_at,
252
+ "terminal_at": self.terminal_at,
253
+ "error_code": self.error_code,
254
+ }
255
+ if self.kind is OAuthFlowKind.AUTHORIZATION_CODE:
256
+ payload["authorization_url"] = self.authorization_url
257
+ else:
258
+ payload.update(
259
+ {
260
+ "verification_uri": self.verification_uri,
261
+ "verification_uri_complete": self.verification_uri_complete,
262
+ "user_code": self.user_code,
263
+ "polling_interval": self.polling_interval,
264
+ }
265
+ )
266
+ return payload
267
+
268
+
269
+ @dataclass(frozen=True, slots=True)
270
+ class OAuthExchangeMaterial:
271
+ flow_id: str
272
+ server_id: str
273
+ kind: OAuthFlowKind
274
+ token_endpoint: str
275
+ client_id: str = field(repr=False)
276
+ redirect_uri: str | None = None
277
+ pkce_verifier: str | None = field(default=None, repr=False)
278
+ expected_nonce: str | None = field(default=None, repr=False)
279
+ device_code: str | None = field(default=None, repr=False)
280
+ scopes: tuple[str, ...] = field(default_factory=tuple)
281
+
282
+
283
+ @dataclass(frozen=True, slots=True)
284
+ class OAuthCompletionClaim:
285
+ flow_id: str
286
+ claim_token: str = field(repr=False)
287
+ material: OAuthExchangeMaterial = field(repr=False)
288
+ lease_expires_at: float
289
+
290
+
291
+ @dataclass(frozen=True, slots=True)
292
+ class OAuthLogoutResult:
293
+ server_id: str
294
+ local_credentials_removed: bool
295
+ active_flows_cancelled: int
296
+ remote_revocation_attempted: bool
297
+ remote_revocation_succeeded: bool | None
298
+ error_code: str | None = None
299
+
300
+
301
+ def default_oauth_flow_registry_path() -> Path:
302
+ override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
303
+ data_dir = Path(override).expanduser() if override else canonical_user_data_dir()
304
+ return data_dir / "ide" / "mcp-oauth-flows.sqlite3"
305
+
306
+
307
+ class McpOAuthFlowRegistry:
308
+ """SQLite-backed OAuth flow registry with fenced single-use completion."""
309
+
310
+ def __init__(
311
+ self,
312
+ vault: OAuthTokenVault,
313
+ path: str | os.PathLike[str] | None = None,
314
+ *,
315
+ redirect_allowlist: Iterable[str] = (),
316
+ workspace_roots: Iterable[str | os.PathLike[str]] = (),
317
+ clock: Callable[[], float] = time.time,
318
+ id_factory: Callable[[], str] | None = None,
319
+ completion_lease_seconds: float = DEFAULT_COMPLETION_LEASE_SECONDS,
320
+ ) -> None:
321
+ if vault is None:
322
+ raise OAuthValidationError("A secure OAuth token vault is required.")
323
+ self.vault = vault
324
+ self.path = Path(path) if path is not None else default_oauth_flow_registry_path()
325
+ self._clock = clock
326
+ self._id_factory = id_factory or (lambda: secrets.token_urlsafe(32))
327
+ self._owner_id = secrets.token_urlsafe(24)
328
+ self._completion_lease_seconds = _bounded_duration(
329
+ completion_lease_seconds,
330
+ field_name="completion lease",
331
+ minimum=1.0,
332
+ maximum=10 * 60.0,
333
+ )
334
+ self._redirect_allowlist = frozenset(
335
+ _validate_redirect_uri(value) for value in redirect_allowlist
336
+ )
337
+ self._lock = threading.RLock()
338
+ self._validate_storage_location(workspace_roots)
339
+ self._prepare_storage()
340
+ self.recover()
341
+
342
+ def start_authorization_code(self, request: AuthorizationCodeFlowRequest) -> OAuthFlowStatus:
343
+ self.recover()
344
+ server_id = _validate_server_id(request.server_id)
345
+ authorization_endpoint = _validate_https_url(
346
+ request.authorization_endpoint, field_name="authorization endpoint"
347
+ )
348
+ token_endpoint = _validate_https_url(request.token_endpoint, field_name="token endpoint")
349
+ redirect_uri = _validate_redirect_uri(request.redirect_uri)
350
+ if not self._redirect_allowlist or redirect_uri not in self._redirect_allowlist:
351
+ raise OAuthValidationError("OAuth redirect URI is not allowlisted.")
352
+ client_id = _validate_client_id(request.client_id)
353
+ scopes = _normalize_scopes(request.scopes)
354
+ expires_in = _bounded_duration(
355
+ request.expires_in,
356
+ field_name="flow expiry",
357
+ minimum=30.0,
358
+ maximum=MAX_FLOW_TTL_SECONDS,
359
+ )
360
+ extras = _normalize_extra_params(request.extra_authorization_params)
361
+ if not isinstance(request.require_nonce, bool):
362
+ raise OAuthValidationError("OAuth nonce policy is invalid.")
363
+ flow_id = self._new_flow_id()
364
+ state = secrets.token_urlsafe(32)
365
+ nonce = secrets.token_urlsafe(32)
366
+ pkce_verifier = secrets.token_urlsafe(64)[:96]
367
+ code_challenge = _pkce_challenge(pkce_verifier)
368
+ authorization_url = _build_authorization_url(
369
+ authorization_endpoint,
370
+ client_id=client_id,
371
+ redirect_uri=redirect_uri,
372
+ scopes=scopes,
373
+ state=state,
374
+ nonce=nonce,
375
+ code_challenge=code_challenge,
376
+ extras=extras,
377
+ )
378
+ now = self._now()
379
+ expires_at = now + expires_in
380
+ row = {
381
+ "flow_id": flow_id,
382
+ "server_id": server_id,
383
+ "kind": OAuthFlowKind.AUTHORIZATION_CODE.value,
384
+ "state": OAuthFlowState.PENDING.value,
385
+ "authorization_url": authorization_url,
386
+ "token_endpoint": token_endpoint,
387
+ "client_id": client_id,
388
+ "redirect_uri": redirect_uri,
389
+ "scopes_json": _encode_scopes(scopes),
390
+ "state_hash": _secret_digest(state),
391
+ "nonce_hash": _secret_digest(nonce),
392
+ "nonce_value": nonce,
393
+ "nonce_required": int(bool(request.require_nonce)),
394
+ "pkce_verifier": pkce_verifier,
395
+ "device_code": None,
396
+ "verification_uri": None,
397
+ "verification_uri_complete": None,
398
+ "user_code": None,
399
+ "polling_interval": None,
400
+ "created_at": now,
401
+ "updated_at": now,
402
+ "expires_at": expires_at,
403
+ }
404
+ self._insert_flow(row)
405
+ return self.status(flow_id)
406
+
407
+ def start_device_code(self, request: DeviceCodeFlowRequest) -> OAuthFlowStatus:
408
+ self.recover()
409
+ server_id = _validate_server_id(request.server_id)
410
+ token_endpoint = _validate_https_url(request.token_endpoint, field_name="token endpoint")
411
+ client_id = _validate_client_id(request.client_id)
412
+ device_code = _required_secret(request.device_code, field_name="device code")
413
+ user_code = _required_public_string(
414
+ request.user_code, field_name="device user code", maximum=256
415
+ )
416
+ verification_uri = _validate_https_url(
417
+ request.verification_uri, field_name="device verification URI"
418
+ )
419
+ verification_uri_complete = None
420
+ if request.verification_uri_complete is not None:
421
+ verification_uri_complete = _validate_https_url(
422
+ request.verification_uri_complete,
423
+ field_name="complete device verification URI",
424
+ )
425
+ if _url_origin(verification_uri_complete) != _url_origin(verification_uri):
426
+ raise OAuthValidationError(
427
+ "Complete device verification URI must use the verification origin."
428
+ )
429
+ scopes = _normalize_scopes(request.scopes)
430
+ expires_in = _bounded_duration(
431
+ request.expires_in,
432
+ field_name="flow expiry",
433
+ minimum=30.0,
434
+ maximum=MAX_FLOW_TTL_SECONDS,
435
+ )
436
+ polling_interval = _bounded_duration(
437
+ request.polling_interval,
438
+ field_name="device polling interval",
439
+ minimum=1.0,
440
+ maximum=60.0,
441
+ )
442
+ flow_id = self._new_flow_id()
443
+ now = self._now()
444
+ row = {
445
+ "flow_id": flow_id,
446
+ "server_id": server_id,
447
+ "kind": OAuthFlowKind.DEVICE_CODE.value,
448
+ "state": OAuthFlowState.PENDING.value,
449
+ "authorization_url": None,
450
+ "token_endpoint": token_endpoint,
451
+ "client_id": client_id,
452
+ "redirect_uri": None,
453
+ "scopes_json": _encode_scopes(scopes),
454
+ "state_hash": None,
455
+ "nonce_hash": None,
456
+ "nonce_value": None,
457
+ "nonce_required": 0,
458
+ "pkce_verifier": None,
459
+ "device_code": device_code,
460
+ "verification_uri": verification_uri,
461
+ "verification_uri_complete": verification_uri_complete,
462
+ "user_code": user_code,
463
+ "polling_interval": polling_interval,
464
+ "created_at": now,
465
+ "updated_at": now,
466
+ "expires_at": now + expires_in,
467
+ }
468
+ self._insert_flow(row)
469
+ return self.status(flow_id)
470
+
471
+ def status(self, flow_id: str) -> OAuthFlowStatus:
472
+ canonical_flow_id = _validate_flow_id(flow_id)
473
+ self.recover(flow_id=canonical_flow_id)
474
+ with self._connect() as connection:
475
+ row = connection.execute(
476
+ "SELECT * FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
477
+ ).fetchone()
478
+ if row is None:
479
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
480
+ return _status_from_row(row)
481
+
482
+ def list(
483
+ self, *, server_id: str | None = None, limit: int = 100
484
+ ) -> tuple[OAuthFlowStatus, ...]:
485
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1 or limit > 500:
486
+ raise OAuthValidationError("OAuth flow list limit is outside the allowed range.")
487
+ canonical_server_id = _validate_server_id(server_id) if server_id is not None else None
488
+ self.recover()
489
+ query = "SELECT * FROM oauth_flows"
490
+ params: tuple[Any, ...]
491
+ if canonical_server_id is None:
492
+ params = (limit,)
493
+ else:
494
+ query += " WHERE server_id = ?"
495
+ params = (canonical_server_id, limit)
496
+ query += " ORDER BY created_at DESC, flow_id DESC LIMIT ?"
497
+ with self._connect() as connection:
498
+ rows = connection.execute(query, params).fetchall()
499
+ return tuple(_status_from_row(row) for row in rows)
500
+
501
+ def begin_completion(
502
+ self,
503
+ flow_id: str,
504
+ *,
505
+ state: str | None = None,
506
+ nonce: str | None = None,
507
+ ) -> OAuthCompletionClaim:
508
+ canonical_flow_id = _validate_flow_id(flow_id)
509
+ self.recover(flow_id=canonical_flow_id)
510
+ now = self._now()
511
+ claim_token = secrets.token_urlsafe(32)
512
+ claim_hash = _secret_digest(claim_token)
513
+ with self._transaction() as connection:
514
+ row = connection.execute(
515
+ "SELECT * FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
516
+ ).fetchone()
517
+ if row is None:
518
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
519
+ current = OAuthFlowState(str(row["state"]))
520
+ if current is not OAuthFlowState.PENDING:
521
+ raise OAuthFlowStateError("OAuth flow is not pending completion.")
522
+ if now >= float(row["expires_at"]):
523
+ self._set_terminal(
524
+ connection,
525
+ canonical_flow_id,
526
+ OAuthFlowState.EXPIRED,
527
+ now=now,
528
+ error_code="flow_expired",
529
+ )
530
+ # Preserve the expiry transition even though the transaction
531
+ # context will see the state error raised below.
532
+ connection.commit()
533
+ raise OAuthFlowStateError("OAuth flow has expired.")
534
+ kind = OAuthFlowKind(str(row["kind"]))
535
+ if kind is OAuthFlowKind.AUTHORIZATION_CODE:
536
+ self._validate_callback_proof(row, state=state, nonce=nonce)
537
+ elif state is not None or nonce is not None:
538
+ raise OAuthCompletionRejectedError(
539
+ "Device-code completion does not accept callback proof."
540
+ )
541
+ lease_expires_at = (
542
+ float(row["expires_at"])
543
+ if kind is OAuthFlowKind.DEVICE_CODE
544
+ else min(float(row["expires_at"]), now + self._completion_lease_seconds)
545
+ )
546
+ connection.execute(
547
+ """
548
+ UPDATE oauth_flows
549
+ SET state = ?, updated_at = ?, completion_owner = ?,
550
+ completion_claim_hash = ?, completion_lease_expires_at = ?,
551
+ error_code = NULL
552
+ WHERE flow_id = ? AND state = ?
553
+ """,
554
+ (
555
+ OAuthFlowState.COMPLETING.value,
556
+ now,
557
+ self._owner_id,
558
+ claim_hash,
559
+ lease_expires_at,
560
+ canonical_flow_id,
561
+ OAuthFlowState.PENDING.value,
562
+ ),
563
+ )
564
+ material = _exchange_material_from_row(row)
565
+ return OAuthCompletionClaim(
566
+ flow_id=canonical_flow_id,
567
+ claim_token=claim_token,
568
+ material=material,
569
+ lease_expires_at=lease_expires_at,
570
+ )
571
+
572
+ def device_poll_material(self, flow_id: str) -> OAuthExchangeMaterial:
573
+ """Return device polling material without consuming the pending flow.
574
+
575
+ This method is for the trusted backend exchange adapter only. Keeping
576
+ the flow pending while a user authorizes the device means ``cancel``
577
+ and expiry remain effective during a potentially long poll loop.
578
+ """
579
+
580
+ canonical_flow_id = _validate_flow_id(flow_id)
581
+ self.recover(flow_id=canonical_flow_id)
582
+ with self._connect() as connection:
583
+ row = connection.execute(
584
+ "SELECT * FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
585
+ ).fetchone()
586
+ if row is None:
587
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
588
+ if OAuthFlowState(str(row["state"])) is not OAuthFlowState.PENDING:
589
+ raise OAuthFlowStateError("OAuth device flow is not pending authorization.")
590
+ if OAuthFlowKind(str(row["kind"])) is not OAuthFlowKind.DEVICE_CODE:
591
+ raise OAuthFlowStateError("OAuth flow does not use device authorization.")
592
+ return _exchange_material_from_row(row)
593
+
594
+ def finish_completion(
595
+ self,
596
+ claim: OAuthCompletionClaim,
597
+ tokens: OAuthTokenSet,
598
+ *,
599
+ nonce: str | None = None,
600
+ ) -> OAuthFlowStatus:
601
+ canonical_flow_id = _validate_flow_id(claim.flow_id)
602
+ if not isinstance(tokens, OAuthTokenSet):
603
+ raise OAuthValidationError("OAuth token result is invalid.")
604
+ row = self._consume_live_claim(canonical_flow_id, claim.claim_token, returned_nonce=nonce)
605
+ server_id = str(row["server_id"])
606
+ try:
607
+ self._vault_store(server_id, tokens, binding_id=canonical_flow_id)
608
+ except OAuthVaultError:
609
+ self._fail_consumed_claim(canonical_flow_id, error_code="vault_store_failed")
610
+ raise
611
+
612
+ now = self._now()
613
+ with self._transaction() as connection:
614
+ live = connection.execute(
615
+ "SELECT state, error_code, completion_owner, completion_claim_hash FROM oauth_flows "
616
+ "WHERE flow_id = ?",
617
+ (canonical_flow_id,),
618
+ ).fetchone()
619
+ interrupted = bool(
620
+ live is not None
621
+ and OAuthFlowState(str(live["state"])) is OAuthFlowState.FAILED
622
+ and str(live["error_code"] or "") == "completion_interrupted"
623
+ )
624
+ if not interrupted and (
625
+ live is None
626
+ or OAuthFlowState(str(live["state"])) is not OAuthFlowState.COMPLETING
627
+ or str(live["completion_owner"] or "") != self._owner_id
628
+ or live["completion_claim_hash"] is not None
629
+ ):
630
+ # A successful deterministic binding is recoverable, but this
631
+ # claim is still single-use and cannot be finalized twice.
632
+ raise OAuthFlowStateError("OAuth completion claim is no longer active.")
633
+ self._set_terminal(
634
+ connection,
635
+ canonical_flow_id,
636
+ OAuthFlowState.COMPLETED,
637
+ now=now,
638
+ error_code=None,
639
+ )
640
+ return self.status(canonical_flow_id)
641
+
642
+ def complete(
643
+ self,
644
+ flow_id: str,
645
+ tokens: OAuthTokenSet,
646
+ *,
647
+ state: str | None = None,
648
+ nonce: str | None = None,
649
+ ) -> OAuthFlowStatus:
650
+ """Claim and finish a flow when token exchange has already completed."""
651
+
652
+ self._validate_token_nonce_preflight(flow_id, nonce=nonce)
653
+ claim = self.begin_completion(flow_id, state=state, nonce=nonce)
654
+ return self.finish_completion(claim, tokens, nonce=nonce)
655
+
656
+ def fail_completion(
657
+ self, claim: OAuthCompletionClaim, *, error_code: str = "token_exchange_failed"
658
+ ) -> OAuthFlowStatus:
659
+ safe_error_code = _validate_error_code(error_code)
660
+ self._fail_live_claim(claim.flow_id, claim.claim_token, error_code=safe_error_code)
661
+ return self.status(claim.flow_id)
662
+
663
+ def fail_pending(
664
+ self, flow_id: str, *, error_code: str = "authorization_failed"
665
+ ) -> OAuthFlowStatus:
666
+ """Fail a pending flow without accepting provider-controlled error text."""
667
+
668
+ canonical_flow_id = _validate_flow_id(flow_id)
669
+ safe_error_code = _validate_error_code(error_code)
670
+ self.recover(flow_id=canonical_flow_id)
671
+ now = self._now()
672
+ with self._transaction() as connection:
673
+ row = connection.execute(
674
+ "SELECT state FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
675
+ ).fetchone()
676
+ if row is None:
677
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
678
+ if OAuthFlowState(str(row["state"])) is not OAuthFlowState.PENDING:
679
+ raise OAuthFlowStateError("OAuth flow is not pending authorization.")
680
+ self._set_terminal(
681
+ connection,
682
+ canonical_flow_id,
683
+ OAuthFlowState.FAILED,
684
+ now=now,
685
+ error_code=safe_error_code,
686
+ )
687
+ return self._status_in_transaction(connection, canonical_flow_id)
688
+
689
+ def cancel(self, flow_id: str) -> OAuthFlowStatus:
690
+ canonical_flow_id = _validate_flow_id(flow_id)
691
+ self.recover(flow_id=canonical_flow_id)
692
+ now = self._now()
693
+ with self._transaction() as connection:
694
+ row = connection.execute(
695
+ "SELECT state FROM oauth_flows WHERE flow_id = ?", (canonical_flow_id,)
696
+ ).fetchone()
697
+ if row is None:
698
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
699
+ current = OAuthFlowState(str(row["state"]))
700
+ if current is OAuthFlowState.CANCELLED:
701
+ return self._status_in_transaction(connection, canonical_flow_id)
702
+ if current is not OAuthFlowState.PENDING:
703
+ raise OAuthFlowStateError("OAuth flow can no longer be cancelled.")
704
+ self._set_terminal(
705
+ connection,
706
+ canonical_flow_id,
707
+ OAuthFlowState.CANCELLED,
708
+ now=now,
709
+ error_code="cancelled_by_user",
710
+ )
711
+ return self._status_in_transaction(connection, canonical_flow_id)
712
+
713
+ def logout(
714
+ self,
715
+ server_id: str,
716
+ *,
717
+ revoker: Callable[[OAuthTokenSet], None] | None = None,
718
+ ) -> OAuthLogoutResult:
719
+ canonical_server_id = _validate_server_id(server_id)
720
+ self.recover()
721
+ tokens = self._vault_load(canonical_server_id)
722
+
723
+ revocation_attempted = revoker is not None and tokens is not None
724
+ revocation_succeeded: bool | None = None
725
+ error_code: str | None = None
726
+ if revocation_attempted:
727
+ try:
728
+ assert revoker is not None and tokens is not None
729
+ revoker(tokens)
730
+ revocation_succeeded = True
731
+ except Exception: # noqa: BLE001 - never surface provider/token details
732
+ revocation_succeeded = False
733
+ error_code = "remote_revocation_failed"
734
+
735
+ removed = self._vault_delete(canonical_server_id)
736
+
737
+ now = self._now()
738
+ with self._transaction() as connection:
739
+ active = connection.execute(
740
+ """
741
+ SELECT COUNT(*) AS count
742
+ FROM oauth_flows
743
+ WHERE server_id = ? AND state IN (?, ?)
744
+ """,
745
+ (
746
+ canonical_server_id,
747
+ OAuthFlowState.PENDING.value,
748
+ OAuthFlowState.COMPLETING.value,
749
+ ),
750
+ ).fetchone()
751
+ cancelled = int(active["count"] if active is not None else 0)
752
+ connection.execute(
753
+ """
754
+ UPDATE oauth_flows
755
+ SET state = ?, updated_at = ?, terminal_at = ?, error_code = ?,
756
+ state_hash = NULL, nonce_hash = NULL, nonce_value = NULL,
757
+ pkce_verifier = NULL,
758
+ device_code = NULL, completion_owner = NULL,
759
+ completion_claim_hash = NULL, completion_lease_expires_at = NULL,
760
+ authorization_url = NULL, user_code = NULL,
761
+ verification_uri_complete = NULL
762
+ WHERE server_id = ? AND state IN (?, ?)
763
+ """,
764
+ (
765
+ OAuthFlowState.CANCELLED.value,
766
+ now,
767
+ now,
768
+ "logout",
769
+ canonical_server_id,
770
+ OAuthFlowState.PENDING.value,
771
+ OAuthFlowState.COMPLETING.value,
772
+ ),
773
+ )
774
+ return OAuthLogoutResult(
775
+ server_id=canonical_server_id,
776
+ local_credentials_removed=removed,
777
+ active_flows_cancelled=cancelled,
778
+ remote_revocation_attempted=revocation_attempted,
779
+ remote_revocation_succeeded=revocation_succeeded,
780
+ error_code=error_code,
781
+ )
782
+
783
+ def recover(self, *, flow_id: str | None = None) -> int:
784
+ """Recover interrupted completion and expire overdue flows.
785
+
786
+ A vault binding proves that credentials were atomically stored before a
787
+ crash. Otherwise a completing flow remains leased until its fencing
788
+ deadline and then fails closed rather than retrying a possibly consumed
789
+ authorization code.
790
+ """
791
+
792
+ canonical_flow_id = _validate_flow_id(flow_id) if flow_id is not None else None
793
+ now = self._now()
794
+ with self._connect() as connection:
795
+ if canonical_flow_id is None:
796
+ rows = connection.execute(
797
+ "SELECT flow_id, server_id, state, expires_at, completion_lease_expires_at "
798
+ "FROM oauth_flows WHERE state IN (?, ?) "
799
+ "OR (state = ? AND error_code = ?)",
800
+ (
801
+ OAuthFlowState.PENDING.value,
802
+ OAuthFlowState.COMPLETING.value,
803
+ OAuthFlowState.FAILED.value,
804
+ "completion_interrupted",
805
+ ),
806
+ ).fetchall()
807
+ else:
808
+ rows = connection.execute(
809
+ "SELECT flow_id, server_id, state, expires_at, completion_lease_expires_at "
810
+ "FROM oauth_flows WHERE flow_id = ? AND "
811
+ "(state IN (?, ?) OR (state = ? AND error_code = ?))",
812
+ (
813
+ canonical_flow_id,
814
+ OAuthFlowState.PENDING.value,
815
+ OAuthFlowState.COMPLETING.value,
816
+ OAuthFlowState.FAILED.value,
817
+ "completion_interrupted",
818
+ ),
819
+ ).fetchall()
820
+
821
+ recovered = 0
822
+ for row in rows:
823
+ current = OAuthFlowState(str(row["state"]))
824
+ target: OAuthFlowState | None = None
825
+ error_code: str | None = None
826
+ if current is OAuthFlowState.PENDING and now >= float(row["expires_at"]):
827
+ target = OAuthFlowState.EXPIRED
828
+ error_code = "flow_expired"
829
+ elif current is OAuthFlowState.COMPLETING:
830
+ binding_exists = self._vault_has_binding(
831
+ str(row["server_id"]), binding_id=str(row["flow_id"])
832
+ )
833
+ if binding_exists:
834
+ target = OAuthFlowState.COMPLETED
835
+ else:
836
+ lease_expiry = float(row["completion_lease_expires_at"] or 0.0)
837
+ if now >= min(float(row["expires_at"]), lease_expiry):
838
+ target = OAuthFlowState.FAILED
839
+ error_code = "completion_interrupted"
840
+ elif current is OAuthFlowState.FAILED:
841
+ binding_exists = self._vault_has_binding(
842
+ str(row["server_id"]), binding_id=str(row["flow_id"])
843
+ )
844
+ if binding_exists:
845
+ target = OAuthFlowState.COMPLETED
846
+ error_code = None
847
+ if target is None:
848
+ continue
849
+ with self._transaction() as connection:
850
+ live = connection.execute(
851
+ "SELECT state FROM oauth_flows WHERE flow_id = ?", (str(row["flow_id"]),)
852
+ ).fetchone()
853
+ if live is None or OAuthFlowState(str(live["state"])) is not current:
854
+ continue
855
+ self._set_terminal(
856
+ connection,
857
+ str(row["flow_id"]),
858
+ target,
859
+ now=now,
860
+ error_code=error_code,
861
+ )
862
+ recovered += 1
863
+ return recovered
864
+
865
+ def cleanup(
866
+ self, *, terminal_retention_seconds: float = DEFAULT_TERMINAL_RETENTION_SECONDS
867
+ ) -> int:
868
+ retention = _bounded_duration(
869
+ terminal_retention_seconds,
870
+ field_name="terminal retention",
871
+ minimum=0.0,
872
+ maximum=365 * 24 * 60 * 60.0,
873
+ )
874
+ self.recover()
875
+ cutoff = self._now() - retention
876
+ with self._transaction() as connection:
877
+ cursor = connection.execute(
878
+ """
879
+ DELETE FROM oauth_flows
880
+ WHERE state IN (?, ?, ?, ?) AND terminal_at IS NOT NULL AND terminal_at <= ?
881
+ """,
882
+ (
883
+ OAuthFlowState.COMPLETED.value,
884
+ OAuthFlowState.CANCELLED.value,
885
+ OAuthFlowState.FAILED.value,
886
+ OAuthFlowState.EXPIRED.value,
887
+ cutoff,
888
+ ),
889
+ )
890
+ return max(0, int(cursor.rowcount))
891
+
892
+ def _prepare_storage(self) -> None:
893
+ try:
894
+ self.path.parent.mkdir(parents=True, exist_ok=True)
895
+ _restrict_permissions(self.path.parent, directory=True)
896
+ _secure_precreate_database(self.path)
897
+ with self._connect() as connection:
898
+ connection.executescript(
899
+ """
900
+ CREATE TABLE IF NOT EXISTS schema_info (
901
+ version INTEGER NOT NULL
902
+ );
903
+ CREATE TABLE IF NOT EXISTS oauth_flows (
904
+ flow_id TEXT PRIMARY KEY,
905
+ server_id TEXT NOT NULL,
906
+ kind TEXT NOT NULL,
907
+ state TEXT NOT NULL,
908
+ authorization_url TEXT,
909
+ token_endpoint TEXT NOT NULL,
910
+ client_id TEXT NOT NULL,
911
+ redirect_uri TEXT,
912
+ scopes_json TEXT NOT NULL,
913
+ state_hash TEXT,
914
+ nonce_hash TEXT,
915
+ nonce_value TEXT,
916
+ nonce_required INTEGER NOT NULL DEFAULT 0,
917
+ pkce_verifier TEXT,
918
+ device_code TEXT,
919
+ verification_uri TEXT,
920
+ verification_uri_complete TEXT,
921
+ user_code TEXT,
922
+ polling_interval REAL,
923
+ created_at REAL NOT NULL,
924
+ updated_at REAL NOT NULL,
925
+ expires_at REAL NOT NULL,
926
+ terminal_at REAL,
927
+ error_code TEXT,
928
+ completion_owner TEXT,
929
+ completion_claim_hash TEXT,
930
+ completion_lease_expires_at REAL
931
+ );
932
+ CREATE UNIQUE INDEX IF NOT EXISTS oauth_flows_one_active_server
933
+ ON oauth_flows(server_id)
934
+ WHERE state IN ('pending', 'completing');
935
+ CREATE INDEX IF NOT EXISTS oauth_flows_terminal_cleanup
936
+ ON oauth_flows(state, terminal_at);
937
+ """
938
+ )
939
+ row = connection.execute("SELECT version FROM schema_info LIMIT 1").fetchone()
940
+ if row is None:
941
+ connection.execute(
942
+ "INSERT INTO schema_info(version) VALUES (?)", (SCHEMA_VERSION,)
943
+ )
944
+ elif int(row["version"]) != SCHEMA_VERSION:
945
+ raise OAuthLifecycleError("OAuth flow registry schema is unsupported.")
946
+ _restrict_permissions(self.path, directory=False)
947
+ except OAuthLifecycleError:
948
+ raise
949
+ except Exception as exc: # noqa: BLE001
950
+ raise OAuthLifecycleError("OAuth flow registry could not be initialized.") from exc
951
+
952
+ def _vault_store(self, server_id: str, tokens: OAuthTokenSet, *, binding_id: str) -> None:
953
+ failed = False
954
+ try:
955
+ self.vault.store(server_id, tokens, binding_id=binding_id)
956
+ except Exception: # noqa: BLE001 - erase secret-bearing exception context
957
+ failed = True
958
+ if failed:
959
+ raise OAuthVaultError("OAuth credentials could not be stored securely.")
960
+
961
+ def _vault_load(self, server_id: str) -> OAuthTokenSet | None:
962
+ failed = False
963
+ tokens: OAuthTokenSet | None = None
964
+ try:
965
+ tokens = self.vault.load(server_id)
966
+ except Exception: # noqa: BLE001 - erase secret-bearing exception context
967
+ failed = True
968
+ if failed:
969
+ raise OAuthVaultError("OAuth credentials could not be read securely.")
970
+ return tokens
971
+
972
+ def _vault_has_binding(self, server_id: str, *, binding_id: str) -> bool:
973
+ failed = False
974
+ result = False
975
+ try:
976
+ result = bool(self.vault.has_binding(server_id, binding_id=binding_id))
977
+ except Exception: # noqa: BLE001 - erase secret-bearing exception context
978
+ failed = True
979
+ if failed:
980
+ raise OAuthVaultError("OAuth credential recovery check failed.")
981
+ return result
982
+
983
+ def _vault_delete(self, server_id: str) -> bool:
984
+ failed = False
985
+ removed = False
986
+ try:
987
+ removed = bool(self.vault.delete(server_id))
988
+ except Exception: # noqa: BLE001 - erase secret-bearing exception context
989
+ failed = True
990
+ if failed:
991
+ raise OAuthVaultError("OAuth credentials could not be removed securely.")
992
+ return removed
993
+
994
+ def _insert_flow(self, row: Mapping[str, Any]) -> None:
995
+ columns = tuple(row)
996
+ placeholders = ", ".join("?" for _ in columns)
997
+ try:
998
+ with self._transaction() as connection:
999
+ connection.execute(
1000
+ f"INSERT INTO oauth_flows ({', '.join(columns)}) VALUES ({placeholders})", # noqa: S608
1001
+ tuple(row[column] for column in columns),
1002
+ )
1003
+ except sqlite3.IntegrityError as exc:
1004
+ raise OAuthFlowConflictError(
1005
+ "An OAuth flow is already active for this server."
1006
+ ) from exc
1007
+ except OAuthLifecycleError:
1008
+ raise
1009
+ except Exception as exc: # noqa: BLE001
1010
+ raise OAuthLifecycleError("OAuth flow could not be persisted.") from exc
1011
+
1012
+ def _consume_live_claim(
1013
+ self, flow_id: str, claim_token: str, *, returned_nonce: str | None
1014
+ ) -> sqlite3.Row:
1015
+ now = self._now()
1016
+ with self._transaction() as connection:
1017
+ row = self._select_claim_for_update(connection, flow_id, claim_token, now=now)
1018
+ if row is None:
1019
+ raise OAuthFlowStateError("OAuth completion claim is no longer active.")
1020
+ kind = OAuthFlowKind(str(row["kind"]))
1021
+ if kind is OAuthFlowKind.AUTHORIZATION_CODE:
1022
+ nonce_required = bool(int(row["nonce_required"] or 0))
1023
+ expected_nonce_hash = str(row["nonce_hash"] or "")
1024
+ if returned_nonce is None:
1025
+ if nonce_required:
1026
+ raise OAuthCompletionRejectedError("OAuth token nonce proof is incomplete.")
1027
+ elif not hmac.compare_digest(expected_nonce_hash, _secret_digest(returned_nonce)):
1028
+ raise OAuthCompletionRejectedError("OAuth token nonce proof was rejected.")
1029
+ elif returned_nonce is not None:
1030
+ raise OAuthCompletionRejectedError(
1031
+ "Device-code completion does not accept a token nonce."
1032
+ )
1033
+ connection.execute(
1034
+ "UPDATE oauth_flows SET completion_claim_hash = NULL, updated_at = ? "
1035
+ "WHERE flow_id = ?",
1036
+ (now, flow_id),
1037
+ )
1038
+ return row
1039
+
1040
+ def _select_claim_for_update(
1041
+ self,
1042
+ connection: sqlite3.Connection,
1043
+ flow_id: str,
1044
+ claim_token: str,
1045
+ *,
1046
+ now: float,
1047
+ ) -> sqlite3.Row | None:
1048
+ if not isinstance(claim_token, str) or not claim_token:
1049
+ return None
1050
+ row = connection.execute(
1051
+ "SELECT * FROM oauth_flows WHERE flow_id = ?", (flow_id,)
1052
+ ).fetchone()
1053
+ if row is None or OAuthFlowState(str(row["state"])) is not OAuthFlowState.COMPLETING:
1054
+ return None
1055
+ if str(row["completion_owner"] or "") != self._owner_id:
1056
+ return None
1057
+ expected = str(row["completion_claim_hash"] or "")
1058
+ if not hmac.compare_digest(expected, _secret_digest(claim_token)):
1059
+ return None
1060
+ if now >= float(row["completion_lease_expires_at"] or 0.0):
1061
+ return None
1062
+ return row
1063
+
1064
+ def _fail_live_claim(self, flow_id: str, claim_token: str, *, error_code: str) -> None:
1065
+ canonical_flow_id = _validate_flow_id(flow_id)
1066
+ now = self._now()
1067
+ with self._transaction() as connection:
1068
+ row = self._select_claim_for_update(connection, canonical_flow_id, claim_token, now=now)
1069
+ if row is None:
1070
+ raise OAuthFlowStateError("OAuth completion claim is no longer active.")
1071
+ self._set_terminal(
1072
+ connection,
1073
+ canonical_flow_id,
1074
+ OAuthFlowState.FAILED,
1075
+ now=now,
1076
+ error_code=error_code,
1077
+ )
1078
+
1079
+ def _fail_consumed_claim(self, flow_id: str, *, error_code: str) -> None:
1080
+ now = self._now()
1081
+ with self._transaction() as connection:
1082
+ row = connection.execute(
1083
+ "SELECT state, completion_owner, completion_claim_hash FROM oauth_flows "
1084
+ "WHERE flow_id = ?",
1085
+ (flow_id,),
1086
+ ).fetchone()
1087
+ if (
1088
+ row is None
1089
+ or OAuthFlowState(str(row["state"])) is not OAuthFlowState.COMPLETING
1090
+ or str(row["completion_owner"] or "") != self._owner_id
1091
+ or row["completion_claim_hash"] is not None
1092
+ ):
1093
+ raise OAuthFlowStateError("OAuth completion claim is no longer active.")
1094
+ self._set_terminal(
1095
+ connection,
1096
+ flow_id,
1097
+ OAuthFlowState.FAILED,
1098
+ now=now,
1099
+ error_code=error_code,
1100
+ )
1101
+
1102
+ def _validate_callback_proof(
1103
+ self, row: sqlite3.Row, *, state: str | None, nonce: str | None
1104
+ ) -> None:
1105
+ if not isinstance(state, str):
1106
+ raise OAuthCompletionRejectedError("OAuth callback proof is incomplete.")
1107
+ expected_state = str(row["state_hash"] or "")
1108
+ expected_nonce = str(row["nonce_hash"] or "")
1109
+ state_valid = hmac.compare_digest(expected_state, _secret_digest(state))
1110
+ nonce_valid = nonce is None or hmac.compare_digest(expected_nonce, _secret_digest(nonce))
1111
+ if not state_valid or not nonce_valid:
1112
+ raise OAuthCompletionRejectedError("OAuth callback proof was rejected.")
1113
+
1114
+ def _validate_token_nonce_preflight(self, flow_id: str, *, nonce: str | None) -> None:
1115
+ canonical_flow_id = _validate_flow_id(flow_id)
1116
+ with self._connect() as connection:
1117
+ row = connection.execute(
1118
+ "SELECT kind, nonce_hash, nonce_required FROM oauth_flows WHERE flow_id = ?",
1119
+ (canonical_flow_id,),
1120
+ ).fetchone()
1121
+ if row is None:
1122
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
1123
+ if OAuthFlowKind(str(row["kind"])) is OAuthFlowKind.DEVICE_CODE:
1124
+ if nonce is not None:
1125
+ raise OAuthCompletionRejectedError(
1126
+ "Device-code completion does not accept a token nonce."
1127
+ )
1128
+ return
1129
+ nonce_required = bool(int(row["nonce_required"] or 0))
1130
+ if nonce is None:
1131
+ if nonce_required:
1132
+ raise OAuthCompletionRejectedError("OAuth token nonce proof is incomplete.")
1133
+ return
1134
+ if not hmac.compare_digest(str(row["nonce_hash"] or ""), _secret_digest(nonce)):
1135
+ raise OAuthCompletionRejectedError("OAuth token nonce proof was rejected.")
1136
+
1137
+ def _set_terminal(
1138
+ self,
1139
+ connection: sqlite3.Connection,
1140
+ flow_id: str,
1141
+ state: OAuthFlowState,
1142
+ *,
1143
+ now: float,
1144
+ error_code: str | None,
1145
+ ) -> None:
1146
+ if state not in TERMINAL_STATES:
1147
+ raise OAuthLifecycleError("Internal OAuth terminal transition is invalid.")
1148
+ connection.execute(
1149
+ """
1150
+ UPDATE oauth_flows
1151
+ SET state = ?, updated_at = ?, terminal_at = ?, error_code = ?,
1152
+ state_hash = NULL, nonce_hash = NULL, nonce_value = NULL,
1153
+ pkce_verifier = NULL,
1154
+ device_code = NULL, completion_owner = NULL,
1155
+ completion_claim_hash = NULL, completion_lease_expires_at = NULL,
1156
+ authorization_url = NULL, user_code = NULL,
1157
+ verification_uri_complete = NULL
1158
+ WHERE flow_id = ?
1159
+ """,
1160
+ (state.value, now, now, error_code, flow_id),
1161
+ )
1162
+
1163
+ def _status_in_transaction(
1164
+ self, connection: sqlite3.Connection, flow_id: str
1165
+ ) -> OAuthFlowStatus:
1166
+ row = connection.execute(
1167
+ "SELECT * FROM oauth_flows WHERE flow_id = ?", (flow_id,)
1168
+ ).fetchone()
1169
+ if row is None:
1170
+ raise OAuthFlowNotFoundError("OAuth flow was not found.")
1171
+ return _status_from_row(row)
1172
+
1173
+ def _new_flow_id(self) -> str:
1174
+ for _ in range(8):
1175
+ candidate = str(self._id_factory())
1176
+ if _FLOW_ID_RE.fullmatch(candidate):
1177
+ return candidate
1178
+ raise OAuthLifecycleError("Could not allocate an opaque OAuth flow id.")
1179
+
1180
+ def _now(self) -> float:
1181
+ value = self._clock()
1182
+ if (
1183
+ isinstance(value, bool)
1184
+ or not isinstance(value, (int, float))
1185
+ or not math.isfinite(value)
1186
+ ):
1187
+ raise OAuthLifecycleError("OAuth lifecycle clock returned an invalid value.")
1188
+ return float(value)
1189
+
1190
+ def _validate_storage_location(self, workspace_roots: Iterable[str | os.PathLike[str]]) -> None:
1191
+ candidate = self.path.expanduser().resolve(strict=False)
1192
+ for raw_root in workspace_roots:
1193
+ root = Path(raw_root).expanduser().resolve(strict=False)
1194
+ if candidate == root or root in candidate.parents:
1195
+ raise OAuthValidationError("OAuth flow registry must be outside the workspace.")
1196
+
1197
+ @contextmanager
1198
+ def _connect(self) -> Any:
1199
+ connection = sqlite3.connect(
1200
+ self.path,
1201
+ timeout=10.0,
1202
+ isolation_level=None,
1203
+ check_same_thread=False,
1204
+ )
1205
+ connection.row_factory = sqlite3.Row
1206
+ try:
1207
+ connection.execute("PRAGMA foreign_keys = ON")
1208
+ connection.execute("PRAGMA journal_mode = WAL")
1209
+ connection.execute("PRAGMA synchronous = FULL")
1210
+ connection.execute("PRAGMA busy_timeout = 10000")
1211
+ yield connection
1212
+ finally:
1213
+ connection.close()
1214
+
1215
+ @contextmanager
1216
+ def _transaction(self) -> Any:
1217
+ with self._lock, self._connect() as connection:
1218
+ connection.execute("BEGIN IMMEDIATE")
1219
+ try:
1220
+ yield connection
1221
+ except BaseException:
1222
+ connection.rollback()
1223
+ raise
1224
+ else:
1225
+ connection.commit()
1226
+
1227
+
1228
+ def _status_from_row(row: sqlite3.Row) -> OAuthFlowStatus:
1229
+ return OAuthFlowStatus(
1230
+ flow_id=str(row["flow_id"]),
1231
+ server_id=str(row["server_id"]),
1232
+ kind=OAuthFlowKind(str(row["kind"])),
1233
+ state=OAuthFlowState(str(row["state"])),
1234
+ created_at=float(row["created_at"]),
1235
+ updated_at=float(row["updated_at"]),
1236
+ expires_at=float(row["expires_at"]),
1237
+ authorization_url=_optional_row_string(row, "authorization_url"),
1238
+ verification_uri=_optional_row_string(row, "verification_uri"),
1239
+ verification_uri_complete=_optional_row_string(row, "verification_uri_complete"),
1240
+ user_code=_optional_row_string(row, "user_code"),
1241
+ polling_interval=(
1242
+ float(row["polling_interval"]) if row["polling_interval"] is not None else None
1243
+ ),
1244
+ terminal_at=float(row["terminal_at"]) if row["terminal_at"] is not None else None,
1245
+ error_code=_optional_row_string(row, "error_code"),
1246
+ )
1247
+
1248
+
1249
+ def _exchange_material_from_row(row: sqlite3.Row) -> OAuthExchangeMaterial:
1250
+ return OAuthExchangeMaterial(
1251
+ flow_id=str(row["flow_id"]),
1252
+ server_id=str(row["server_id"]),
1253
+ kind=OAuthFlowKind(str(row["kind"])),
1254
+ token_endpoint=str(row["token_endpoint"]),
1255
+ client_id=str(row["client_id"]),
1256
+ redirect_uri=_optional_row_string(row, "redirect_uri"),
1257
+ pkce_verifier=_optional_row_string(row, "pkce_verifier"),
1258
+ expected_nonce=_optional_row_string(row, "nonce_value"),
1259
+ device_code=_optional_row_string(row, "device_code"),
1260
+ scopes=_decode_scopes(str(row["scopes_json"])),
1261
+ )
1262
+
1263
+
1264
+ def _optional_row_string(row: sqlite3.Row, key: str) -> str | None:
1265
+ value = row[key]
1266
+ return str(value) if value is not None else None
1267
+
1268
+
1269
+ def _validate_server_id(value: object) -> str:
1270
+ if not isinstance(value, str) or not _SERVER_ID_RE.fullmatch(value.strip()):
1271
+ raise OAuthValidationError("OAuth server id is invalid.")
1272
+ return value.strip()
1273
+
1274
+
1275
+ def _validate_flow_id(value: object) -> str:
1276
+ if not isinstance(value, str) or not _FLOW_ID_RE.fullmatch(value):
1277
+ raise OAuthValidationError("OAuth flow id is invalid.")
1278
+ return value
1279
+
1280
+
1281
+ def _validate_client_id(value: object) -> str:
1282
+ return _required_public_string(value, field_name="OAuth client id", maximum=1024)
1283
+
1284
+
1285
+ def _required_secret(value: object, *, field_name: str) -> str:
1286
+ if not isinstance(value, str) or not value or len(value.encode("utf-8")) > 8192:
1287
+ raise OAuthValidationError(f"OAuth {field_name} is invalid.")
1288
+ return value
1289
+
1290
+
1291
+ def _required_public_string(value: object, *, field_name: str, maximum: int) -> str:
1292
+ if not isinstance(value, str):
1293
+ raise OAuthValidationError(f"{field_name} is invalid.")
1294
+ cleaned = value.strip()
1295
+ if not cleaned or len(cleaned) > maximum or any(ord(char) < 0x20 for char in cleaned):
1296
+ raise OAuthValidationError(f"{field_name} is invalid.")
1297
+ return cleaned
1298
+
1299
+
1300
+ def _normalize_scopes(values: Iterable[str]) -> tuple[str, ...]:
1301
+ if isinstance(values, (str, bytes)):
1302
+ raise OAuthValidationError("OAuth scopes must be a collection.")
1303
+ try:
1304
+ raw_values = tuple(values)
1305
+ except TypeError as exc:
1306
+ raise OAuthValidationError("OAuth scopes must be a collection.") from exc
1307
+ if len(raw_values) > MAX_SCOPES:
1308
+ raise OAuthValidationError("OAuth scope count exceeds the allowed limit.")
1309
+ normalized: list[str] = []
1310
+ seen: set[str] = set()
1311
+ for value in raw_values:
1312
+ if not isinstance(value, str) or not _SCOPE_RE.fullmatch(value):
1313
+ raise OAuthValidationError("OAuth scope is invalid.")
1314
+ if value not in seen:
1315
+ seen.add(value)
1316
+ normalized.append(value)
1317
+ return tuple(normalized)
1318
+
1319
+
1320
+ def _normalize_extra_params(values: Mapping[str, JsonScalar]) -> dict[str, str]:
1321
+ if not isinstance(values, Mapping):
1322
+ raise OAuthValidationError("OAuth authorization parameters must be an object.")
1323
+ if len(values) > 32:
1324
+ raise OAuthValidationError("OAuth authorization parameter count exceeds the limit.")
1325
+ normalized: dict[str, str] = {}
1326
+ for raw_name, raw_value in values.items():
1327
+ if not isinstance(raw_name, str) or not _PARAM_NAME_RE.fullmatch(raw_name):
1328
+ raise OAuthValidationError("OAuth authorization parameter name is invalid.")
1329
+ name = raw_name.lower()
1330
+ if name in _RESERVED_AUTH_PARAMS:
1331
+ raise OAuthValidationError("OAuth authorization parameter overrides a reserved field.")
1332
+ if name in _SENSITIVE_URL_PARAM_NAMES:
1333
+ raise OAuthValidationError("OAuth authorization parameter may expose credentials.")
1334
+ if raw_value is None:
1335
+ continue
1336
+ if isinstance(raw_value, bool):
1337
+ value = "true" if raw_value else "false"
1338
+ elif isinstance(raw_value, (str, int, float)):
1339
+ if isinstance(raw_value, float) and not math.isfinite(raw_value):
1340
+ raise OAuthValidationError("OAuth authorization parameter value is invalid.")
1341
+ value = str(raw_value)
1342
+ else:
1343
+ raise OAuthValidationError("OAuth authorization parameter value is invalid.")
1344
+ if len(value) > 1024 or any(ord(char) < 0x20 for char in value):
1345
+ raise OAuthValidationError("OAuth authorization parameter value is invalid.")
1346
+ normalized[raw_name] = value
1347
+ return normalized
1348
+
1349
+
1350
+ def _validate_https_url(value: object, *, field_name: str) -> str:
1351
+ if not isinstance(value, str) or not value or len(value) > MAX_URL_LENGTH:
1352
+ raise OAuthValidationError(f"OAuth {field_name} is invalid.")
1353
+ try:
1354
+ split = urlsplit(value)
1355
+ port = split.port
1356
+ except ValueError as exc:
1357
+ raise OAuthValidationError(f"OAuth {field_name} is invalid.") from exc
1358
+ if (
1359
+ split.scheme.lower() != "https"
1360
+ or not split.hostname
1361
+ or split.username is not None
1362
+ or split.password is not None
1363
+ or split.fragment
1364
+ or port is not None
1365
+ and (port < 1 or port > 65535)
1366
+ ):
1367
+ raise OAuthValidationError(f"OAuth {field_name} must be a secure HTTPS URL.")
1368
+ if any(
1369
+ name.lower() in _SENSITIVE_URL_PARAM_NAMES
1370
+ for name, _ in parse_qsl(split.query, keep_blank_values=True)
1371
+ ):
1372
+ raise OAuthValidationError(f"OAuth {field_name} may expose credentials.")
1373
+ return urlunsplit(("https", split.netloc.lower(), split.path or "/", split.query, ""))
1374
+
1375
+
1376
+ def _validate_redirect_uri(value: object) -> str:
1377
+ if not isinstance(value, str) or not value or len(value) > MAX_URL_LENGTH:
1378
+ raise OAuthValidationError("OAuth redirect URI is invalid.")
1379
+ try:
1380
+ split = urlsplit(value)
1381
+ port = split.port
1382
+ except ValueError as exc:
1383
+ raise OAuthValidationError("OAuth redirect URI is invalid.") from exc
1384
+ host = str(split.hostname or "").lower()
1385
+ if (
1386
+ split.scheme.lower() != "http"
1387
+ or host not in {"127.0.0.1", "::1"}
1388
+ or split.username is not None
1389
+ or split.password is not None
1390
+ or split.fragment
1391
+ or split.query
1392
+ or port is None
1393
+ or port < 1024
1394
+ or port > 65535
1395
+ or not split.path.startswith("/")
1396
+ ):
1397
+ raise OAuthValidationError("OAuth redirect URI must be an allowlisted loopback URL.")
1398
+ normalized_host = f"[{host}]" if ":" in host else host
1399
+ return urlunsplit(("http", f"{normalized_host}:{port}", split.path or "/", "", ""))
1400
+
1401
+
1402
+ def _url_origin(value: str) -> tuple[str, str, int | None]:
1403
+ split = urlsplit(value)
1404
+ return split.scheme.lower(), str(split.hostname or "").lower(), split.port
1405
+
1406
+
1407
+ def _build_authorization_url(
1408
+ endpoint: str,
1409
+ *,
1410
+ client_id: str,
1411
+ redirect_uri: str,
1412
+ scopes: tuple[str, ...],
1413
+ state: str,
1414
+ nonce: str,
1415
+ code_challenge: str,
1416
+ extras: Mapping[str, str],
1417
+ ) -> str:
1418
+ split = urlsplit(endpoint)
1419
+ existing = parse_qsl(split.query, keep_blank_values=True)
1420
+ if any(name.lower() in _RESERVED_AUTH_PARAMS for name, _ in existing):
1421
+ raise OAuthValidationError("OAuth authorization endpoint contains reserved parameters.")
1422
+ params = [*existing, *extras.items()]
1423
+ params.extend(
1424
+ [
1425
+ ("response_type", "code"),
1426
+ ("client_id", client_id),
1427
+ ("redirect_uri", redirect_uri),
1428
+ ("scope", " ".join(scopes)),
1429
+ ("state", state),
1430
+ ("nonce", nonce),
1431
+ ("code_challenge", code_challenge),
1432
+ ("code_challenge_method", "S256"),
1433
+ ]
1434
+ )
1435
+ result = urlunsplit((split.scheme, split.netloc, split.path, urlencode(params), ""))
1436
+ if len(result) > MAX_URL_LENGTH:
1437
+ raise OAuthValidationError("OAuth authorization URL exceeds the allowed length.")
1438
+ return result
1439
+
1440
+
1441
+ def _pkce_challenge(verifier: str) -> str:
1442
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
1443
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
1444
+
1445
+
1446
+ def _secret_digest(value: str) -> str:
1447
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
1448
+
1449
+
1450
+ def _encode_scopes(scopes: tuple[str, ...]) -> str:
1451
+ return json.dumps(list(scopes), ensure_ascii=True, separators=(",", ":"))
1452
+
1453
+
1454
+ def _decode_scopes(value: str) -> tuple[str, ...]:
1455
+ try:
1456
+ payload = json.loads(value)
1457
+ except (TypeError, ValueError) as exc:
1458
+ raise OAuthLifecycleError("OAuth flow registry contains invalid scope metadata.") from exc
1459
+ return _normalize_scopes(payload)
1460
+
1461
+
1462
+ def _validate_error_code(value: object) -> str:
1463
+ if not isinstance(value, str) or not _ERROR_CODE_RE.fullmatch(value):
1464
+ raise OAuthValidationError("OAuth error code is invalid.")
1465
+ return value
1466
+
1467
+
1468
+ def _bounded_duration(value: object, *, field_name: str, minimum: float, maximum: float) -> float:
1469
+ if (
1470
+ isinstance(value, bool)
1471
+ or not isinstance(value, (int, float))
1472
+ or not math.isfinite(float(value))
1473
+ or float(value) < minimum
1474
+ or float(value) > maximum
1475
+ ):
1476
+ raise OAuthValidationError(f"OAuth {field_name} is outside the allowed range.")
1477
+ return float(value)
1478
+
1479
+
1480
+ def _restrict_permissions(path: Path, *, directory: bool) -> None:
1481
+ if os.name == "nt":
1482
+ return
1483
+ try:
1484
+ path.chmod(0o700 if directory else 0o600)
1485
+ except OSError as exc:
1486
+ raise OAuthLifecycleError("OAuth flow registry permissions could not be secured.") from exc
1487
+
1488
+
1489
+ def _secure_precreate_database(path: Path) -> None:
1490
+ if path.is_symlink():
1491
+ raise OAuthLifecycleError("OAuth flow registry cannot use a symbolic link.")
1492
+ flags = os.O_CREAT | os.O_EXCL | os.O_RDWR
1493
+ if hasattr(os, "O_NOFOLLOW"):
1494
+ flags |= os.O_NOFOLLOW
1495
+ try:
1496
+ descriptor = os.open(path, flags, 0o600)
1497
+ except FileExistsError:
1498
+ if path.is_symlink():
1499
+ raise OAuthLifecycleError("OAuth flow registry cannot use a symbolic link.") from None
1500
+ return
1501
+ except OSError as exc:
1502
+ raise OAuthLifecycleError("OAuth flow registry could not be created securely.") from exc
1503
+ else:
1504
+ os.close(descriptor)