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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""Production coordinator for IDE-owned MCP OAuth authorization-code login.
|
|
2
|
+
|
|
3
|
+
The coordinator is intentionally protocol-agnostic. It starts a loopback
|
|
4
|
+
listener, returns public flow metadata immediately, and completes discovery,
|
|
5
|
+
callback validation, code exchange, and encrypted credential persistence
|
|
6
|
+
without placing authorization codes or tokens in a caller-visible object.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hmac
|
|
12
|
+
import html
|
|
13
|
+
import ipaddress
|
|
14
|
+
import math
|
|
15
|
+
import secrets
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Iterable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from http import HTTPStatus
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Protocol
|
|
25
|
+
from urllib.parse import parse_qs, urlsplit
|
|
26
|
+
|
|
27
|
+
from ..mcp.oauth import (
|
|
28
|
+
McpAuthorizationServerMetadata,
|
|
29
|
+
canonical_mcp_resource_uri,
|
|
30
|
+
discover_authorization_server_metadata,
|
|
31
|
+
exchange_authorization_code,
|
|
32
|
+
)
|
|
33
|
+
from ..mcp.oauth_store import (
|
|
34
|
+
McpOAuthTokenRecord,
|
|
35
|
+
delete_oauth_token_record,
|
|
36
|
+
load_oauth_token_record,
|
|
37
|
+
save_oauth_token_record,
|
|
38
|
+
)
|
|
39
|
+
from .mcp_oauth_lifecycle import (
|
|
40
|
+
AuthorizationCodeFlowRequest,
|
|
41
|
+
McpOAuthFlowRegistry,
|
|
42
|
+
OAuthCompletionClaim,
|
|
43
|
+
OAuthCompletionRejectedError,
|
|
44
|
+
OAuthFlowState,
|
|
45
|
+
OAuthFlowStateError,
|
|
46
|
+
OAuthFlowStatus,
|
|
47
|
+
OAuthLogoutResult,
|
|
48
|
+
OAuthTokenSet,
|
|
49
|
+
OAuthTokenVault,
|
|
50
|
+
OAuthValidationError,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"EncryptedMcpOAuthTokenVault",
|
|
55
|
+
"McpOAuthCoordinatorConfig",
|
|
56
|
+
"McpOAuthCoordinatorError",
|
|
57
|
+
"McpOAuthIdeCoordinator",
|
|
58
|
+
"McpOAuthLoginRequest",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
_CALLBACK_PREFIX = "/oauth/callback/"
|
|
62
|
+
_MAX_CALLBACK_TARGET_BYTES = 16 * 1024
|
|
63
|
+
_MAX_AUTHORIZATION_CODE_BYTES = 8 * 1024
|
|
64
|
+
_MAX_STATE_BYTES = 2 * 1024
|
|
65
|
+
_TOKEN_VAULT_LOCK = threading.RLock()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class McpOAuthCoordinatorError(RuntimeError):
|
|
69
|
+
"""Coordinator error safe for logs and IDE protocol error messages."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _DiscoveryCallable(Protocol):
|
|
73
|
+
def __call__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
server_id: str,
|
|
77
|
+
resource_server_url: str,
|
|
78
|
+
authorization_server_url: str | None = None,
|
|
79
|
+
timeout_s: float = 10.0,
|
|
80
|
+
) -> McpAuthorizationServerMetadata: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _ExchangeCallable(Protocol):
|
|
84
|
+
def __call__(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
server_id: str,
|
|
88
|
+
authorization_server_metadata: McpAuthorizationServerMetadata,
|
|
89
|
+
resource_server_url: str,
|
|
90
|
+
client_id: str,
|
|
91
|
+
code: str,
|
|
92
|
+
redirect_uri: str,
|
|
93
|
+
code_verifier: str,
|
|
94
|
+
requested_scopes: tuple[str, ...],
|
|
95
|
+
timeout_s: float,
|
|
96
|
+
) -> McpOAuthTokenRecord: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class McpOAuthLoginRequest:
|
|
101
|
+
server_id: str
|
|
102
|
+
resource_server_url: str
|
|
103
|
+
client_id: str = field(repr=False)
|
|
104
|
+
scopes: tuple[str, ...] = field(default_factory=tuple)
|
|
105
|
+
authorization_server_url: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class McpOAuthCoordinatorConfig:
|
|
110
|
+
callback_host: str = "127.0.0.1"
|
|
111
|
+
callback_port: int = 0
|
|
112
|
+
callback_timeout_seconds: float = 10 * 60.0
|
|
113
|
+
discovery_timeout_seconds: float = 10.0
|
|
114
|
+
token_timeout_seconds: float = 15.0
|
|
115
|
+
shutdown_wait_seconds: float = 20.0
|
|
116
|
+
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
if self.callback_host != "127.0.0.1":
|
|
119
|
+
raise OAuthValidationError("MCP OAuth callback host must be 127.0.0.1.")
|
|
120
|
+
if (
|
|
121
|
+
isinstance(self.callback_port, bool)
|
|
122
|
+
or not isinstance(self.callback_port, int)
|
|
123
|
+
or self.callback_port < 0
|
|
124
|
+
or self.callback_port > 65535
|
|
125
|
+
or 0 < self.callback_port < 1024
|
|
126
|
+
):
|
|
127
|
+
raise OAuthValidationError("MCP OAuth callback port is invalid.")
|
|
128
|
+
_bounded_seconds(
|
|
129
|
+
self.callback_timeout_seconds,
|
|
130
|
+
field_name="callback timeout",
|
|
131
|
+
minimum=30.0,
|
|
132
|
+
maximum=25 * 60.0,
|
|
133
|
+
)
|
|
134
|
+
_bounded_seconds(
|
|
135
|
+
self.discovery_timeout_seconds,
|
|
136
|
+
field_name="discovery timeout",
|
|
137
|
+
minimum=1.0,
|
|
138
|
+
maximum=60.0,
|
|
139
|
+
)
|
|
140
|
+
_bounded_seconds(
|
|
141
|
+
self.token_timeout_seconds,
|
|
142
|
+
field_name="token timeout",
|
|
143
|
+
minimum=1.0,
|
|
144
|
+
maximum=120.0,
|
|
145
|
+
)
|
|
146
|
+
_bounded_seconds(
|
|
147
|
+
self.shutdown_wait_seconds,
|
|
148
|
+
field_name="shutdown wait",
|
|
149
|
+
minimum=0.0,
|
|
150
|
+
maximum=120.0,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class EncryptedMcpOAuthTokenVault(OAuthTokenVault):
|
|
155
|
+
"""Adapter from lifecycle credentials to Alysis Code's encrypted OAuth store."""
|
|
156
|
+
|
|
157
|
+
def store(self, server_id: str, tokens: OAuthTokenSet, *, binding_id: str) -> None:
|
|
158
|
+
if tokens.expires_at is None:
|
|
159
|
+
raise OAuthValidationError("MCP OAuth token expiry is missing.")
|
|
160
|
+
expires_at = datetime.fromtimestamp(float(tokens.expires_at), tz=UTC).replace(microsecond=0)
|
|
161
|
+
obtained_at = datetime.now(UTC).replace(microsecond=0)
|
|
162
|
+
record = McpOAuthTokenRecord(
|
|
163
|
+
access_token=tokens.access_token,
|
|
164
|
+
refresh_token=tokens.refresh_token,
|
|
165
|
+
token_type=tokens.token_type,
|
|
166
|
+
expires_at=expires_at,
|
|
167
|
+
granted_scopes=tokens.scopes,
|
|
168
|
+
obtained_at=obtained_at,
|
|
169
|
+
binding_id=binding_id,
|
|
170
|
+
)
|
|
171
|
+
with _TOKEN_VAULT_LOCK:
|
|
172
|
+
save_oauth_token_record(server_id, record)
|
|
173
|
+
|
|
174
|
+
def load(self, server_id: str) -> OAuthTokenSet | None:
|
|
175
|
+
with _TOKEN_VAULT_LOCK:
|
|
176
|
+
record = load_oauth_token_record(server_id)
|
|
177
|
+
if record is None:
|
|
178
|
+
return None
|
|
179
|
+
return _tokens_from_record(record)
|
|
180
|
+
|
|
181
|
+
def has_binding(self, server_id: str, *, binding_id: str) -> bool:
|
|
182
|
+
with _TOKEN_VAULT_LOCK:
|
|
183
|
+
record = load_oauth_token_record(server_id)
|
|
184
|
+
if record is None or record.binding_id is None:
|
|
185
|
+
return False
|
|
186
|
+
return hmac.compare_digest(record.binding_id, binding_id)
|
|
187
|
+
|
|
188
|
+
def delete(self, server_id: str) -> bool:
|
|
189
|
+
with _TOKEN_VAULT_LOCK:
|
|
190
|
+
return delete_oauth_token_record(server_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class _CallbackPayload:
|
|
195
|
+
code: str | None = field(repr=False)
|
|
196
|
+
state: str | None = field(repr=False)
|
|
197
|
+
provider_error: bool = False
|
|
198
|
+
malformed: bool = False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _CallbackInbox:
|
|
202
|
+
def __init__(self) -> None:
|
|
203
|
+
self.event = threading.Event()
|
|
204
|
+
self._lock = threading.Lock()
|
|
205
|
+
self._payload: _CallbackPayload | None = None
|
|
206
|
+
self._expected_state: str | None = None
|
|
207
|
+
|
|
208
|
+
def expect_state(self, state: str) -> None:
|
|
209
|
+
with self._lock:
|
|
210
|
+
if self._expected_state is not None:
|
|
211
|
+
raise McpOAuthCoordinatorError("MCP OAuth callback state is already configured.")
|
|
212
|
+
self._expected_state = state
|
|
213
|
+
|
|
214
|
+
def deliver(self, payload: _CallbackPayload) -> bool:
|
|
215
|
+
with self._lock:
|
|
216
|
+
if (
|
|
217
|
+
self._expected_state is None
|
|
218
|
+
or payload.state is None
|
|
219
|
+
or not hmac.compare_digest(self._expected_state, payload.state)
|
|
220
|
+
):
|
|
221
|
+
return False
|
|
222
|
+
if self._payload is not None:
|
|
223
|
+
return False
|
|
224
|
+
self._payload = payload
|
|
225
|
+
self.event.set()
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
def read(self) -> _CallbackPayload | None:
|
|
229
|
+
with self._lock:
|
|
230
|
+
return self._payload
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _LoopbackHttpServer(ThreadingHTTPServer):
|
|
234
|
+
daemon_threads = True
|
|
235
|
+
allow_reuse_address = False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _LoopbackCallbackHandler(BaseHTTPRequestHandler):
|
|
239
|
+
server_version = "AlysisOAuthCallback/2.0"
|
|
240
|
+
sys_version = ""
|
|
241
|
+
|
|
242
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
|
243
|
+
del format, args
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def callback_path(self) -> str:
|
|
247
|
+
return str(self.server.callback_path) # type: ignore[attr-defined]
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def inbox(self) -> _CallbackInbox:
|
|
251
|
+
return self.server.callback_inbox # type: ignore[attr-defined,no-any-return]
|
|
252
|
+
|
|
253
|
+
def do_GET(self) -> None: # noqa: N802
|
|
254
|
+
if self.headers.get("Host", "") != str(
|
|
255
|
+
self.server.expected_host_header # type: ignore[attr-defined]
|
|
256
|
+
):
|
|
257
|
+
self._send_page(HTTPStatus.BAD_REQUEST, succeeded=False)
|
|
258
|
+
return
|
|
259
|
+
if len(self.path.encode("utf-8", errors="ignore")) > _MAX_CALLBACK_TARGET_BYTES:
|
|
260
|
+
self._send_page(HTTPStatus.REQUEST_URI_TOO_LONG, succeeded=False)
|
|
261
|
+
return
|
|
262
|
+
parsed = urlsplit(self.path)
|
|
263
|
+
if parsed.path != self.callback_path:
|
|
264
|
+
self._send_page(HTTPStatus.NOT_FOUND, succeeded=False)
|
|
265
|
+
return
|
|
266
|
+
try:
|
|
267
|
+
query = parse_qs(
|
|
268
|
+
parsed.query,
|
|
269
|
+
keep_blank_values=True,
|
|
270
|
+
strict_parsing=True,
|
|
271
|
+
max_num_fields=8,
|
|
272
|
+
)
|
|
273
|
+
except ValueError:
|
|
274
|
+
self.inbox.deliver(_CallbackPayload(code=None, state=None, malformed=True))
|
|
275
|
+
self._send_page(HTTPStatus.BAD_REQUEST, succeeded=False)
|
|
276
|
+
return
|
|
277
|
+
code = _single_callback_value(query, "code", maximum=_MAX_AUTHORIZATION_CODE_BYTES)
|
|
278
|
+
state = _single_callback_value(query, "state", maximum=_MAX_STATE_BYTES)
|
|
279
|
+
provider_error = "error" in query
|
|
280
|
+
malformed = (not provider_error and code is None) or state is None
|
|
281
|
+
accepted = self.inbox.deliver(
|
|
282
|
+
_CallbackPayload(
|
|
283
|
+
code=code,
|
|
284
|
+
state=state,
|
|
285
|
+
provider_error=provider_error,
|
|
286
|
+
malformed=malformed,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
self._send_page(
|
|
290
|
+
HTTPStatus.OK if accepted else HTTPStatus.CONFLICT,
|
|
291
|
+
succeeded=accepted and not provider_error and not malformed,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _send_page(self, status: HTTPStatus, *, succeeded: bool) -> None:
|
|
295
|
+
message = (
|
|
296
|
+
"Authorization received. Return to Visual Studio Code."
|
|
297
|
+
if succeeded
|
|
298
|
+
else "Authorization could not be accepted. Return to Visual Studio Code."
|
|
299
|
+
)
|
|
300
|
+
body = (
|
|
301
|
+
'<!doctype html><html><head><meta charset="utf-8">'
|
|
302
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
303
|
+
f"<title>Alysis Code OAuth</title></head><body><p>{html.escape(message)}</p>"
|
|
304
|
+
"<script>window.history.replaceState({},document.title,window.location.pathname);"
|
|
305
|
+
"</script></body></html>"
|
|
306
|
+
).encode()
|
|
307
|
+
self.send_response(int(status))
|
|
308
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
309
|
+
self.send_header("Content-Length", str(len(body)))
|
|
310
|
+
self.send_header("Cache-Control", "no-store")
|
|
311
|
+
self.send_header("Pragma", "no-cache")
|
|
312
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
313
|
+
self.send_header(
|
|
314
|
+
"Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'"
|
|
315
|
+
)
|
|
316
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
317
|
+
self.end_headers()
|
|
318
|
+
try:
|
|
319
|
+
self.wfile.write(body)
|
|
320
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class _LoopbackCallbackListener:
|
|
325
|
+
def __init__(self, *, host: str, port: int) -> None:
|
|
326
|
+
self.callback_path = f"{_CALLBACK_PREFIX}{secrets.token_urlsafe(24)}"
|
|
327
|
+
self.inbox = _CallbackInbox()
|
|
328
|
+
try:
|
|
329
|
+
self._server = _LoopbackHttpServer((host, port), _LoopbackCallbackHandler)
|
|
330
|
+
except OSError as exc:
|
|
331
|
+
raise McpOAuthCoordinatorError("MCP OAuth callback listener could not start.") from exc
|
|
332
|
+
self._server.callback_path = self.callback_path # type: ignore[attr-defined]
|
|
333
|
+
self._server.callback_inbox = self.inbox # type: ignore[attr-defined]
|
|
334
|
+
bound_host, bound_port = self._server.server_address[:2]
|
|
335
|
+
self._server.expected_host_header = f"{bound_host}:{bound_port}" # type: ignore[attr-defined]
|
|
336
|
+
self._closed = False
|
|
337
|
+
self._close_lock = threading.Lock()
|
|
338
|
+
self._thread = threading.Thread(
|
|
339
|
+
target=self._serve,
|
|
340
|
+
name="alysis-mcp-oauth-callback",
|
|
341
|
+
daemon=True,
|
|
342
|
+
)
|
|
343
|
+
self._thread.start()
|
|
344
|
+
|
|
345
|
+
def _serve(self) -> None:
|
|
346
|
+
self._server.serve_forever(poll_interval=0.05)
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def redirect_uri(self) -> str:
|
|
350
|
+
host, port = self._server.server_address[:2]
|
|
351
|
+
return f"http://{host}:{port}{self.callback_path}"
|
|
352
|
+
|
|
353
|
+
def expect_state(self, state: str) -> None:
|
|
354
|
+
self.inbox.expect_state(state)
|
|
355
|
+
|
|
356
|
+
def wait(
|
|
357
|
+
self, *, timeout_seconds: float, cancel_event: threading.Event
|
|
358
|
+
) -> _CallbackPayload | None:
|
|
359
|
+
deadline = time.monotonic() + timeout_seconds
|
|
360
|
+
while not cancel_event.is_set():
|
|
361
|
+
remaining = deadline - time.monotonic()
|
|
362
|
+
if remaining <= 0:
|
|
363
|
+
return None
|
|
364
|
+
if self.inbox.event.wait(min(0.05, remaining)):
|
|
365
|
+
return self.inbox.read()
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
def close(self) -> None:
|
|
369
|
+
with self._close_lock:
|
|
370
|
+
if self._closed:
|
|
371
|
+
return
|
|
372
|
+
self._closed = True
|
|
373
|
+
self._server.shutdown()
|
|
374
|
+
self._server.server_close()
|
|
375
|
+
if threading.current_thread() is not self._thread:
|
|
376
|
+
self._thread.join(timeout=2.0)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(slots=True)
|
|
380
|
+
class _ActiveFlow:
|
|
381
|
+
server_id: str
|
|
382
|
+
registry: McpOAuthFlowRegistry
|
|
383
|
+
listener: _LoopbackCallbackListener
|
|
384
|
+
cancel_event: threading.Event
|
|
385
|
+
thread: threading.Thread | None = None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class McpOAuthIdeCoordinator:
|
|
389
|
+
"""Own asynchronous loopback authorization for one IDE bridge process."""
|
|
390
|
+
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
*,
|
|
394
|
+
registry_path: str | Path | None = None,
|
|
395
|
+
workspace_roots: Iterable[str | Path] = (),
|
|
396
|
+
vault: OAuthTokenVault | None = None,
|
|
397
|
+
config: McpOAuthCoordinatorConfig | None = None,
|
|
398
|
+
discovery: _DiscoveryCallable = discover_authorization_server_metadata,
|
|
399
|
+
exchanger: _ExchangeCallable = exchange_authorization_code,
|
|
400
|
+
revoker: Callable[[OAuthTokenSet], None] | None = None,
|
|
401
|
+
) -> None:
|
|
402
|
+
self.config = config or McpOAuthCoordinatorConfig()
|
|
403
|
+
self.vault = vault or EncryptedMcpOAuthTokenVault()
|
|
404
|
+
self._workspace_roots = tuple(workspace_roots)
|
|
405
|
+
self._registry = McpOAuthFlowRegistry(
|
|
406
|
+
self.vault,
|
|
407
|
+
registry_path,
|
|
408
|
+
workspace_roots=self._workspace_roots,
|
|
409
|
+
completion_lease_seconds=min(
|
|
410
|
+
10 * 60.0,
|
|
411
|
+
max(30.0, self.config.token_timeout_seconds + 15.0),
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
self._discovery = discovery
|
|
415
|
+
self._exchanger = exchanger
|
|
416
|
+
self._revoker = revoker
|
|
417
|
+
self._lock = threading.RLock()
|
|
418
|
+
self._completion_lock = threading.RLock()
|
|
419
|
+
self._active: dict[str, _ActiveFlow] = {}
|
|
420
|
+
self._blocked_servers: set[str] = set()
|
|
421
|
+
self._closed = False
|
|
422
|
+
|
|
423
|
+
def start_authorization_code(self, request: McpOAuthLoginRequest) -> OAuthFlowStatus:
|
|
424
|
+
with self._lock:
|
|
425
|
+
if self._closed:
|
|
426
|
+
raise McpOAuthCoordinatorError("MCP OAuth coordinator is closed.")
|
|
427
|
+
|
|
428
|
+
resource_server_url = _secure_resource_url(request.resource_server_url)
|
|
429
|
+
try:
|
|
430
|
+
metadata = self._discovery(
|
|
431
|
+
server_id=request.server_id,
|
|
432
|
+
resource_server_url=resource_server_url,
|
|
433
|
+
authorization_server_url=request.authorization_server_url,
|
|
434
|
+
timeout_s=self.config.discovery_timeout_seconds,
|
|
435
|
+
)
|
|
436
|
+
except Exception: # noqa: BLE001 - discovery providers may include sensitive details
|
|
437
|
+
raise McpOAuthCoordinatorError("MCP OAuth metadata discovery failed.") from None
|
|
438
|
+
|
|
439
|
+
listener = _LoopbackCallbackListener(
|
|
440
|
+
host=self.config.callback_host,
|
|
441
|
+
port=self.config.callback_port,
|
|
442
|
+
)
|
|
443
|
+
flow_registry = McpOAuthFlowRegistry(
|
|
444
|
+
self.vault,
|
|
445
|
+
self._registry.path,
|
|
446
|
+
redirect_allowlist=(listener.redirect_uri,),
|
|
447
|
+
workspace_roots=self._workspace_roots,
|
|
448
|
+
completion_lease_seconds=min(
|
|
449
|
+
10 * 60.0,
|
|
450
|
+
max(30.0, self.config.token_timeout_seconds + 15.0),
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
try:
|
|
454
|
+
status = flow_registry.start_authorization_code(
|
|
455
|
+
AuthorizationCodeFlowRequest(
|
|
456
|
+
server_id=request.server_id,
|
|
457
|
+
authorization_endpoint=metadata.authorization_endpoint,
|
|
458
|
+
token_endpoint=metadata.token_endpoint,
|
|
459
|
+
client_id=request.client_id,
|
|
460
|
+
redirect_uri=listener.redirect_uri,
|
|
461
|
+
scopes=request.scopes,
|
|
462
|
+
extra_authorization_params={"resource": resource_server_url},
|
|
463
|
+
expires_in=(
|
|
464
|
+
self.config.callback_timeout_seconds
|
|
465
|
+
+ self.config.token_timeout_seconds
|
|
466
|
+
+ 30.0
|
|
467
|
+
),
|
|
468
|
+
# MCP OAuth does not require an OpenID id_token. State and
|
|
469
|
+
# PKCE remain mandatory; nonce is still sent when accepted.
|
|
470
|
+
require_nonce=False,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
listener.expect_state(_state_from_authorization_url(status.authorization_url))
|
|
474
|
+
except Exception:
|
|
475
|
+
listener.close()
|
|
476
|
+
raise
|
|
477
|
+
|
|
478
|
+
active = _ActiveFlow(
|
|
479
|
+
server_id=status.server_id,
|
|
480
|
+
registry=flow_registry,
|
|
481
|
+
listener=listener,
|
|
482
|
+
cancel_event=threading.Event(),
|
|
483
|
+
)
|
|
484
|
+
worker = threading.Thread(
|
|
485
|
+
target=self._run_flow,
|
|
486
|
+
args=(active, status.flow_id, metadata, resource_server_url),
|
|
487
|
+
name=f"alysis-mcp-oauth-{status.flow_id[:8]}",
|
|
488
|
+
daemon=True,
|
|
489
|
+
)
|
|
490
|
+
active.thread = worker
|
|
491
|
+
with self._completion_lock:
|
|
492
|
+
self._blocked_servers.discard(status.server_id)
|
|
493
|
+
with self._lock:
|
|
494
|
+
if self._closed:
|
|
495
|
+
listener.close()
|
|
496
|
+
flow_registry.cancel(status.flow_id)
|
|
497
|
+
raise McpOAuthCoordinatorError("MCP OAuth coordinator is closed.")
|
|
498
|
+
self._active[status.flow_id] = active
|
|
499
|
+
try:
|
|
500
|
+
worker.start()
|
|
501
|
+
except Exception:
|
|
502
|
+
with self._lock:
|
|
503
|
+
self._active.pop(status.flow_id, None)
|
|
504
|
+
listener.close()
|
|
505
|
+
flow_registry.fail_pending(status.flow_id, error_code="worker_start_failed")
|
|
506
|
+
raise McpOAuthCoordinatorError("MCP OAuth worker could not start.") from None
|
|
507
|
+
return status
|
|
508
|
+
|
|
509
|
+
def status(self, flow_id: str) -> OAuthFlowStatus:
|
|
510
|
+
status = self._registry.status(flow_id)
|
|
511
|
+
if status.state in {
|
|
512
|
+
OAuthFlowState.COMPLETED,
|
|
513
|
+
OAuthFlowState.CANCELLED,
|
|
514
|
+
OAuthFlowState.FAILED,
|
|
515
|
+
OAuthFlowState.EXPIRED,
|
|
516
|
+
}:
|
|
517
|
+
self._close_terminal_handle(flow_id)
|
|
518
|
+
return status
|
|
519
|
+
|
|
520
|
+
def cancel(self, flow_id: str) -> OAuthFlowStatus:
|
|
521
|
+
with self._lock:
|
|
522
|
+
active = self._active.get(flow_id)
|
|
523
|
+
if active is not None:
|
|
524
|
+
active.cancel_event.set()
|
|
525
|
+
active.listener.close()
|
|
526
|
+
status = self._registry.status(flow_id)
|
|
527
|
+
if status.state is OAuthFlowState.PENDING:
|
|
528
|
+
registry = active.registry if active is not None else self._registry
|
|
529
|
+
return registry.cancel(flow_id)
|
|
530
|
+
return status
|
|
531
|
+
|
|
532
|
+
def logout(self, server_id: str) -> OAuthLogoutResult:
|
|
533
|
+
with self._completion_lock:
|
|
534
|
+
self._blocked_servers.add(server_id)
|
|
535
|
+
with self._lock:
|
|
536
|
+
active_items = [
|
|
537
|
+
(flow_id, active)
|
|
538
|
+
for flow_id, active in self._active.items()
|
|
539
|
+
if active.server_id == server_id
|
|
540
|
+
]
|
|
541
|
+
for flow_id, active in active_items:
|
|
542
|
+
active.cancel_event.set()
|
|
543
|
+
active.listener.close()
|
|
544
|
+
try:
|
|
545
|
+
status = active.registry.status(flow_id)
|
|
546
|
+
if status.state is OAuthFlowState.PENDING:
|
|
547
|
+
active.registry.cancel(flow_id)
|
|
548
|
+
except Exception: # noqa: BLE001 - logout continues local cleanup
|
|
549
|
+
pass
|
|
550
|
+
return self._registry.logout(server_id, revoker=self._revoker)
|
|
551
|
+
|
|
552
|
+
def close(self) -> None:
|
|
553
|
+
with self._lock:
|
|
554
|
+
if self._closed:
|
|
555
|
+
return
|
|
556
|
+
self._closed = True
|
|
557
|
+
active_items = list(self._active.items())
|
|
558
|
+
with self._completion_lock:
|
|
559
|
+
self._blocked_servers.update(active.server_id for _, active in active_items)
|
|
560
|
+
for flow_id, active in active_items:
|
|
561
|
+
active.cancel_event.set()
|
|
562
|
+
active.listener.close()
|
|
563
|
+
try:
|
|
564
|
+
status = active.registry.status(flow_id)
|
|
565
|
+
if status.state is OAuthFlowState.PENDING:
|
|
566
|
+
active.registry.cancel(flow_id)
|
|
567
|
+
except Exception: # noqa: BLE001 - shutdown is best effort after listener close
|
|
568
|
+
pass
|
|
569
|
+
deadline = time.monotonic() + self.config.shutdown_wait_seconds
|
|
570
|
+
for _, active in active_items:
|
|
571
|
+
thread = active.thread
|
|
572
|
+
if thread is None or thread is threading.current_thread():
|
|
573
|
+
continue
|
|
574
|
+
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
|
575
|
+
with self._lock:
|
|
576
|
+
self._active.clear()
|
|
577
|
+
|
|
578
|
+
def __enter__(self) -> McpOAuthIdeCoordinator:
|
|
579
|
+
return self
|
|
580
|
+
|
|
581
|
+
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
582
|
+
del exc_type, exc, traceback
|
|
583
|
+
self.close()
|
|
584
|
+
|
|
585
|
+
def _run_flow(
|
|
586
|
+
self,
|
|
587
|
+
active: _ActiveFlow,
|
|
588
|
+
flow_id: str,
|
|
589
|
+
metadata: McpAuthorizationServerMetadata,
|
|
590
|
+
resource_server_url: str,
|
|
591
|
+
) -> None:
|
|
592
|
+
claim: OAuthCompletionClaim | None = None
|
|
593
|
+
try:
|
|
594
|
+
payload = active.listener.wait(
|
|
595
|
+
timeout_seconds=self.config.callback_timeout_seconds,
|
|
596
|
+
cancel_event=active.cancel_event,
|
|
597
|
+
)
|
|
598
|
+
if active.cancel_event.is_set():
|
|
599
|
+
self._cancel_pending_if_possible(active.registry, flow_id)
|
|
600
|
+
return
|
|
601
|
+
if payload is None:
|
|
602
|
+
self._fail_pending_if_possible(active.registry, flow_id, "callback_timeout")
|
|
603
|
+
return
|
|
604
|
+
if payload.provider_error:
|
|
605
|
+
self._fail_pending_if_possible(active.registry, flow_id, "provider_denied")
|
|
606
|
+
return
|
|
607
|
+
if payload.malformed or payload.code is None or payload.state is None:
|
|
608
|
+
self._fail_pending_if_possible(active.registry, flow_id, "invalid_callback")
|
|
609
|
+
return
|
|
610
|
+
try:
|
|
611
|
+
claim = active.registry.begin_completion(flow_id, state=payload.state)
|
|
612
|
+
except OAuthCompletionRejectedError:
|
|
613
|
+
self._fail_pending_if_possible(active.registry, flow_id, "callback_rejected")
|
|
614
|
+
return
|
|
615
|
+
if active.cancel_event.is_set():
|
|
616
|
+
active.registry.fail_completion(claim, error_code="cancelled_by_user")
|
|
617
|
+
return
|
|
618
|
+
material = claim.material
|
|
619
|
+
if material.pkce_verifier is None or material.redirect_uri is None:
|
|
620
|
+
active.registry.fail_completion(claim, error_code="exchange_material_missing")
|
|
621
|
+
return
|
|
622
|
+
try:
|
|
623
|
+
record = self._exchanger(
|
|
624
|
+
server_id=material.server_id,
|
|
625
|
+
authorization_server_metadata=metadata,
|
|
626
|
+
resource_server_url=resource_server_url,
|
|
627
|
+
client_id=material.client_id,
|
|
628
|
+
code=payload.code,
|
|
629
|
+
redirect_uri=material.redirect_uri,
|
|
630
|
+
code_verifier=material.pkce_verifier,
|
|
631
|
+
requested_scopes=material.scopes,
|
|
632
|
+
timeout_s=self.config.token_timeout_seconds,
|
|
633
|
+
)
|
|
634
|
+
except Exception: # noqa: BLE001 - token/code details must not escape the worker
|
|
635
|
+
active.registry.fail_completion(claim, error_code="token_exchange_failed")
|
|
636
|
+
return
|
|
637
|
+
with self._completion_lock:
|
|
638
|
+
if active.cancel_event.is_set() or active.server_id in self._blocked_servers:
|
|
639
|
+
active.registry.fail_completion(claim, error_code="cancelled_by_user")
|
|
640
|
+
return
|
|
641
|
+
active.registry.finish_completion(claim, _tokens_from_record(record))
|
|
642
|
+
except OAuthFlowStateError:
|
|
643
|
+
return
|
|
644
|
+
except Exception: # noqa: BLE001 - convert every worker failure to an allowlisted code
|
|
645
|
+
if claim is not None:
|
|
646
|
+
try:
|
|
647
|
+
active.registry.fail_completion(claim, error_code="authorization_worker_failed")
|
|
648
|
+
except Exception: # noqa: BLE001
|
|
649
|
+
pass
|
|
650
|
+
else:
|
|
651
|
+
self._fail_pending_if_possible(
|
|
652
|
+
active.registry, flow_id, "authorization_worker_failed"
|
|
653
|
+
)
|
|
654
|
+
finally:
|
|
655
|
+
active.listener.close()
|
|
656
|
+
with self._lock:
|
|
657
|
+
if self._active.get(flow_id) is active:
|
|
658
|
+
self._active.pop(flow_id, None)
|
|
659
|
+
|
|
660
|
+
def _close_terminal_handle(self, flow_id: str) -> None:
|
|
661
|
+
with self._lock:
|
|
662
|
+
active = self._active.get(flow_id)
|
|
663
|
+
if active is not None:
|
|
664
|
+
active.listener.close()
|
|
665
|
+
|
|
666
|
+
@staticmethod
|
|
667
|
+
def _fail_pending_if_possible(
|
|
668
|
+
registry: McpOAuthFlowRegistry, flow_id: str, error_code: str
|
|
669
|
+
) -> None:
|
|
670
|
+
try:
|
|
671
|
+
if registry.status(flow_id).state is OAuthFlowState.PENDING:
|
|
672
|
+
registry.fail_pending(flow_id, error_code=error_code)
|
|
673
|
+
except Exception: # noqa: BLE001
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _cancel_pending_if_possible(registry: McpOAuthFlowRegistry, flow_id: str) -> None:
|
|
678
|
+
try:
|
|
679
|
+
if registry.status(flow_id).state is OAuthFlowState.PENDING:
|
|
680
|
+
registry.cancel(flow_id)
|
|
681
|
+
except Exception: # noqa: BLE001
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _tokens_from_record(record: McpOAuthTokenRecord) -> OAuthTokenSet:
|
|
686
|
+
return OAuthTokenSet(
|
|
687
|
+
access_token=record.access_token,
|
|
688
|
+
refresh_token=record.refresh_token,
|
|
689
|
+
token_type=record.token_type,
|
|
690
|
+
expires_at=record.expires_at.timestamp(),
|
|
691
|
+
scopes=record.granted_scopes,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _single_callback_value(query: dict[str, list[str]], key: str, *, maximum: int) -> str | None:
|
|
696
|
+
values = query.get(key)
|
|
697
|
+
if values is None or len(values) != 1:
|
|
698
|
+
return None
|
|
699
|
+
value = values[0]
|
|
700
|
+
if not value or len(value.encode("utf-8")) > maximum:
|
|
701
|
+
return None
|
|
702
|
+
return value
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _state_from_authorization_url(authorization_url: str | None) -> str:
|
|
706
|
+
if authorization_url is None:
|
|
707
|
+
raise McpOAuthCoordinatorError("MCP OAuth authorization URL is missing.")
|
|
708
|
+
try:
|
|
709
|
+
values = parse_qs(urlsplit(authorization_url).query, max_num_fields=32)["state"]
|
|
710
|
+
except (KeyError, ValueError):
|
|
711
|
+
raise McpOAuthCoordinatorError("MCP OAuth authorization state is missing.") from None
|
|
712
|
+
if len(values) != 1 or not values[0]:
|
|
713
|
+
raise McpOAuthCoordinatorError("MCP OAuth authorization state is invalid.")
|
|
714
|
+
return values[0]
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _secure_resource_url(value: str) -> str:
|
|
718
|
+
try:
|
|
719
|
+
split = urlsplit(value)
|
|
720
|
+
host = str(split.hostname or "")
|
|
721
|
+
if not host or split.username is not None or split.password is not None or split.fragment:
|
|
722
|
+
raise ValueError
|
|
723
|
+
if split.scheme.lower() == "http":
|
|
724
|
+
if not ipaddress.ip_address(host).is_loopback:
|
|
725
|
+
raise ValueError
|
|
726
|
+
elif split.scheme.lower() != "https":
|
|
727
|
+
raise ValueError
|
|
728
|
+
return canonical_mcp_resource_uri(value)
|
|
729
|
+
except (TypeError, ValueError):
|
|
730
|
+
raise OAuthValidationError(
|
|
731
|
+
"MCP OAuth resource server must use HTTPS or loopback HTTP."
|
|
732
|
+
) from None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _bounded_seconds(value: object, *, field_name: str, minimum: float, maximum: float) -> float:
|
|
736
|
+
if (
|
|
737
|
+
isinstance(value, bool)
|
|
738
|
+
or not isinstance(value, (int, float))
|
|
739
|
+
or not math.isfinite(float(value))
|
|
740
|
+
or float(value) < minimum
|
|
741
|
+
or float(value) > maximum
|
|
742
|
+
):
|
|
743
|
+
raise OAuthValidationError(f"MCP OAuth {field_name} is outside the allowed range.")
|
|
744
|
+
return float(value)
|