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,1192 @@
|
|
|
1
|
+
"""Bounded, concurrent WebSocket transport for Chromium DevTools Protocol.
|
|
2
|
+
|
|
3
|
+
The managed browser launches Chromium's DevTools endpoint on loopback. This
|
|
4
|
+
module is the production transport for that endpoint. It deliberately owns a
|
|
5
|
+
single reader thread because CDP replies and events share one WebSocket and may
|
|
6
|
+
arrive in any order.
|
|
7
|
+
|
|
8
|
+
Navigation installs a persistent Fetch-domain guard at both request and
|
|
9
|
+
response stages. No HTTP(S) request is continued until the caller's URL policy
|
|
10
|
+
accepts its current URL, including requests started after page load by script
|
|
11
|
+
or an input action. Malformed protocol traffic, an unknown response id, queue
|
|
12
|
+
overflow during interception, timeout, and cancellation all fail closed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ipaddress
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import queue
|
|
21
|
+
import re
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections import deque
|
|
25
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Protocol, TypeAlias
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
from .managed_browser import (
|
|
31
|
+
BrowserCancelledError,
|
|
32
|
+
BrowserDependencyError,
|
|
33
|
+
BrowserError,
|
|
34
|
+
BrowserLaunchError,
|
|
35
|
+
BrowserLimitError,
|
|
36
|
+
BrowserSecurityError,
|
|
37
|
+
BrowserTimeoutError,
|
|
38
|
+
BrowserValidationError,
|
|
39
|
+
CancelCheck,
|
|
40
|
+
CdpTransport,
|
|
41
|
+
CdpTransportFactory,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"CdpCommandError",
|
|
46
|
+
"WebSocketCdpTransport",
|
|
47
|
+
"WebSocketCdpTransportFactory",
|
|
48
|
+
"validate_cdp_websocket_url",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
52
|
+
_MAX_EVENT_BYTES = 16 * 1024 * 1024
|
|
53
|
+
_MAX_EVENT_COUNT = 512
|
|
54
|
+
_MAX_EVENT_DRAIN = 2_000
|
|
55
|
+
_MAX_METHOD_CHARS = 256
|
|
56
|
+
_MAX_SESSION_ID_CHARS = 256
|
|
57
|
+
_MAX_RESPONSE_ID = (1 << 53) - 1
|
|
58
|
+
_MAX_PENDING_CALLS = 256
|
|
59
|
+
_READER_POLL_SECONDS = 0.2
|
|
60
|
+
_WAIT_POLL_SECONDS = 0.05
|
|
61
|
+
_DEVTOOLS_PATH_RE = re.compile(r"^/devtools/browser/[A-Za-z0-9._-]{1,160}$")
|
|
62
|
+
_METHOD_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.]{0,255}$")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CdpCommandError(BrowserError):
|
|
66
|
+
"""A CDP command returned an error object.
|
|
67
|
+
|
|
68
|
+
Chromium's error text is intentionally not reflected because it may contain
|
|
69
|
+
page-controlled data. ``code`` is retained when it is a bounded integer.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, method: str, code: int | None = None) -> None:
|
|
73
|
+
self.method = method
|
|
74
|
+
self.code = code
|
|
75
|
+
suffix = f" (code {code})" if code is not None else ""
|
|
76
|
+
super().__init__(f"Browser CDP command failed: {method}{suffix}.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _WebSocketConnection(Protocol):
|
|
80
|
+
def send(self, message: str | bytes) -> None: ...
|
|
81
|
+
|
|
82
|
+
def recv(self, timeout: float | None = None, *, decode: bool | None = None) -> str | bytes: ...
|
|
83
|
+
|
|
84
|
+
def close(self) -> None: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_Connector: TypeAlias = Callable[..., _WebSocketConnection]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class _PendingCall:
|
|
92
|
+
event: threading.Event
|
|
93
|
+
response: Mapping[str, Any] | None = None
|
|
94
|
+
error: BrowserError | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class _OutboundMessage:
|
|
99
|
+
encoded: str
|
|
100
|
+
sent: threading.Event
|
|
101
|
+
error: BrowserError | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class _QueuedEvent:
|
|
106
|
+
payload: Mapping[str, Any]
|
|
107
|
+
size_bytes: int
|
|
108
|
+
session_id: str | None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class _InterceptionPolicy:
|
|
113
|
+
authorize_url: Callable[[str], str]
|
|
114
|
+
timeout: float
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_cdp_websocket_url(websocket_url: str) -> str:
|
|
118
|
+
"""Accept only an uncredentialed ``ws://`` URL on a literal loopback IP."""
|
|
119
|
+
|
|
120
|
+
if not isinstance(websocket_url, str) or not websocket_url:
|
|
121
|
+
raise BrowserValidationError("Browser DevTools WebSocket URL is invalid.")
|
|
122
|
+
if len(websocket_url) > 512 or any(char.isspace() for char in websocket_url):
|
|
123
|
+
raise BrowserValidationError("Browser DevTools WebSocket URL is invalid.")
|
|
124
|
+
try:
|
|
125
|
+
parsed = urlsplit(websocket_url)
|
|
126
|
+
host = parsed.hostname
|
|
127
|
+
port = parsed.port
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise BrowserValidationError("Browser DevTools WebSocket URL is invalid.") from exc
|
|
130
|
+
if (
|
|
131
|
+
parsed.scheme != "ws"
|
|
132
|
+
or parsed.username is not None
|
|
133
|
+
or parsed.password is not None
|
|
134
|
+
or parsed.query
|
|
135
|
+
or parsed.fragment
|
|
136
|
+
or host is None
|
|
137
|
+
or port is None
|
|
138
|
+
or not 1 <= port <= 65_535
|
|
139
|
+
or not _DEVTOOLS_PATH_RE.fullmatch(parsed.path)
|
|
140
|
+
):
|
|
141
|
+
raise BrowserSecurityError("Browser DevTools WebSocket endpoint is not trusted.")
|
|
142
|
+
try:
|
|
143
|
+
address = ipaddress.ip_address(host)
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise BrowserSecurityError(
|
|
146
|
+
"Browser DevTools WebSocket endpoint must use a literal loopback address."
|
|
147
|
+
) from exc
|
|
148
|
+
if not address.is_loopback:
|
|
149
|
+
raise BrowserSecurityError("Browser DevTools WebSocket endpoint must be loopback-only.")
|
|
150
|
+
return websocket_url
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class WebSocketCdpTransportFactory(CdpTransportFactory):
|
|
154
|
+
"""Create :class:`WebSocketCdpTransport` instances with ``websockets``.
|
|
155
|
+
|
|
156
|
+
``connector`` is injectable for deterministic tests. The default is the
|
|
157
|
+
maintained synchronous client from the ``websockets`` package. Proxying and
|
|
158
|
+
compression are disabled for the private loopback connection.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
connector: _Connector | None = None,
|
|
165
|
+
max_event_count: int = _MAX_EVENT_COUNT,
|
|
166
|
+
max_event_bytes: int = _MAX_EVENT_BYTES,
|
|
167
|
+
) -> None:
|
|
168
|
+
if not 1 <= max_event_count <= _MAX_EVENT_COUNT:
|
|
169
|
+
raise BrowserValidationError("Browser CDP event count limit is invalid.")
|
|
170
|
+
if not _MAX_MESSAGE_BYTES <= max_event_bytes <= _MAX_EVENT_BYTES:
|
|
171
|
+
raise BrowserValidationError("Browser CDP event byte limit is invalid.")
|
|
172
|
+
self._connector = connector
|
|
173
|
+
self._max_event_count = max_event_count
|
|
174
|
+
self._max_event_bytes = max_event_bytes
|
|
175
|
+
|
|
176
|
+
def connect(
|
|
177
|
+
self,
|
|
178
|
+
websocket_url: str,
|
|
179
|
+
*,
|
|
180
|
+
timeout: float,
|
|
181
|
+
cancel: CancelCheck = None,
|
|
182
|
+
) -> CdpTransport:
|
|
183
|
+
endpoint = validate_cdp_websocket_url(websocket_url)
|
|
184
|
+
call_timeout = _validated_timeout(timeout)
|
|
185
|
+
_check_cancel(cancel)
|
|
186
|
+
connector = self._connector or _default_connector()
|
|
187
|
+
finished = threading.Event()
|
|
188
|
+
abandoned = threading.Event()
|
|
189
|
+
outcome_lock = threading.Lock()
|
|
190
|
+
outcome: dict[str, Any] = {}
|
|
191
|
+
|
|
192
|
+
def open_connection() -> None:
|
|
193
|
+
connection: _WebSocketConnection | None = None
|
|
194
|
+
try:
|
|
195
|
+
connection = connector(
|
|
196
|
+
endpoint,
|
|
197
|
+
open_timeout=call_timeout,
|
|
198
|
+
close_timeout=min(call_timeout, 2.0),
|
|
199
|
+
ping_interval=20.0,
|
|
200
|
+
ping_timeout=min(call_timeout, 10.0),
|
|
201
|
+
max_size=_MAX_MESSAGE_BYTES,
|
|
202
|
+
max_queue=(32, 8),
|
|
203
|
+
compression=None,
|
|
204
|
+
proxy=None,
|
|
205
|
+
)
|
|
206
|
+
with outcome_lock:
|
|
207
|
+
if abandoned.is_set():
|
|
208
|
+
_close_connection_async(connection)
|
|
209
|
+
else:
|
|
210
|
+
outcome["connection"] = connection
|
|
211
|
+
except BaseException as exc: # noqa: BLE001 - normalized below
|
|
212
|
+
with outcome_lock:
|
|
213
|
+
outcome["error"] = exc
|
|
214
|
+
finally:
|
|
215
|
+
finished.set()
|
|
216
|
+
|
|
217
|
+
worker = threading.Thread(
|
|
218
|
+
target=open_connection,
|
|
219
|
+
name="alysis-cdp-connect",
|
|
220
|
+
daemon=True,
|
|
221
|
+
)
|
|
222
|
+
worker.start()
|
|
223
|
+
deadline = time.monotonic() + call_timeout
|
|
224
|
+
while not finished.wait(
|
|
225
|
+
timeout=min(_WAIT_POLL_SECONDS, max(0.0, deadline - time.monotonic()))
|
|
226
|
+
):
|
|
227
|
+
if cancel is not None and cancel():
|
|
228
|
+
with outcome_lock:
|
|
229
|
+
abandoned.set()
|
|
230
|
+
late_connection = outcome.pop("connection", None)
|
|
231
|
+
if late_connection is not None:
|
|
232
|
+
_close_connection_async(late_connection)
|
|
233
|
+
raise BrowserCancelledError("Browser WebSocket connection was cancelled.")
|
|
234
|
+
if time.monotonic() >= deadline:
|
|
235
|
+
with outcome_lock:
|
|
236
|
+
abandoned.set()
|
|
237
|
+
late_connection = outcome.pop("connection", None)
|
|
238
|
+
if late_connection is not None:
|
|
239
|
+
_close_connection_async(late_connection)
|
|
240
|
+
raise BrowserTimeoutError("Browser WebSocket connection timed out.")
|
|
241
|
+
with outcome_lock:
|
|
242
|
+
connection = outcome.get("connection")
|
|
243
|
+
if cancel is not None and cancel():
|
|
244
|
+
with outcome_lock:
|
|
245
|
+
abandoned.set()
|
|
246
|
+
if connection is not None:
|
|
247
|
+
_close_connection_async(connection)
|
|
248
|
+
raise BrowserCancelledError("Browser WebSocket connection was cancelled.")
|
|
249
|
+
if connection is None:
|
|
250
|
+
raise BrowserLaunchError(
|
|
251
|
+
"Browser DevTools WebSocket connection failed."
|
|
252
|
+
) from outcome.get("error")
|
|
253
|
+
return WebSocketCdpTransport(
|
|
254
|
+
connection,
|
|
255
|
+
max_event_count=self._max_event_count,
|
|
256
|
+
max_event_bytes=self._max_event_bytes,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class WebSocketCdpTransport(CdpTransport):
|
|
261
|
+
"""Thread-safe, bounded CDP request/reply and event multiplexer."""
|
|
262
|
+
|
|
263
|
+
def __init__(
|
|
264
|
+
self,
|
|
265
|
+
connection: _WebSocketConnection,
|
|
266
|
+
*,
|
|
267
|
+
max_event_count: int = _MAX_EVENT_COUNT,
|
|
268
|
+
max_event_bytes: int = _MAX_EVENT_BYTES,
|
|
269
|
+
) -> None:
|
|
270
|
+
if not 1 <= max_event_count <= _MAX_EVENT_COUNT:
|
|
271
|
+
raise BrowserValidationError("Browser CDP event count limit is invalid.")
|
|
272
|
+
if not _MAX_MESSAGE_BYTES <= max_event_bytes <= _MAX_EVENT_BYTES:
|
|
273
|
+
raise BrowserValidationError("Browser CDP event byte limit is invalid.")
|
|
274
|
+
self._connection = connection
|
|
275
|
+
self._max_event_count = max_event_count
|
|
276
|
+
self._max_event_bytes = max_event_bytes
|
|
277
|
+
self._lock = threading.RLock()
|
|
278
|
+
self._event_changed = threading.Condition(self._lock)
|
|
279
|
+
self._navigation_lock = threading.Lock()
|
|
280
|
+
self._pending: dict[int, _PendingCall] = {}
|
|
281
|
+
self._outbound: queue.Queue[_OutboundMessage] = queue.Queue(maxsize=_MAX_PENDING_CALLS)
|
|
282
|
+
self._interception_events: queue.Queue[_QueuedEvent] = queue.Queue(maxsize=max_event_count)
|
|
283
|
+
self._interception_event_bytes = 0
|
|
284
|
+
self._interception_policies: dict[str, _InterceptionPolicy] = {}
|
|
285
|
+
self._events: deque[_QueuedEvent] = deque()
|
|
286
|
+
self._event_bytes = 0
|
|
287
|
+
self._dropped_events = 0
|
|
288
|
+
self._drop_generation = 0
|
|
289
|
+
self._next_id = 1
|
|
290
|
+
self._terminal_error: BrowserError | None = None
|
|
291
|
+
self._closed = False
|
|
292
|
+
self._reader = threading.Thread(
|
|
293
|
+
target=self._reader_loop,
|
|
294
|
+
name="alysis-cdp-reader",
|
|
295
|
+
daemon=True,
|
|
296
|
+
)
|
|
297
|
+
self._writer = threading.Thread(
|
|
298
|
+
target=self._writer_loop,
|
|
299
|
+
name="alysis-cdp-writer",
|
|
300
|
+
daemon=True,
|
|
301
|
+
)
|
|
302
|
+
self._interceptor = threading.Thread(
|
|
303
|
+
target=self._interceptor_loop,
|
|
304
|
+
name="alysis-cdp-interceptor",
|
|
305
|
+
daemon=True,
|
|
306
|
+
)
|
|
307
|
+
self._reader.start()
|
|
308
|
+
self._writer.start()
|
|
309
|
+
self._interceptor.start()
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def dropped_event_count(self) -> int:
|
|
313
|
+
with self._lock:
|
|
314
|
+
return self._dropped_events
|
|
315
|
+
|
|
316
|
+
def call(
|
|
317
|
+
self,
|
|
318
|
+
method: str,
|
|
319
|
+
params: Mapping[str, Any] | None = None,
|
|
320
|
+
*,
|
|
321
|
+
session_id: str | None = None,
|
|
322
|
+
timeout: float,
|
|
323
|
+
cancel: CancelCheck = None,
|
|
324
|
+
) -> Mapping[str, Any]:
|
|
325
|
+
normalized_method = _validated_method(method)
|
|
326
|
+
normalized_session = _validated_session_id(session_id)
|
|
327
|
+
call_timeout = _validated_timeout(timeout)
|
|
328
|
+
_check_cancel(cancel)
|
|
329
|
+
deadline = time.monotonic() + call_timeout
|
|
330
|
+
with self._lock:
|
|
331
|
+
self._raise_if_unavailable_locked()
|
|
332
|
+
if len(self._pending) >= _MAX_PENDING_CALLS:
|
|
333
|
+
raise BrowserLimitError("Browser CDP pending command capacity was exhausted.")
|
|
334
|
+
request_id = self._next_request_id_locked()
|
|
335
|
+
pending = _PendingCall(event=threading.Event())
|
|
336
|
+
self._pending[request_id] = pending
|
|
337
|
+
message: dict[str, Any] = {
|
|
338
|
+
"id": request_id,
|
|
339
|
+
"method": normalized_method,
|
|
340
|
+
"params": dict(params or {}),
|
|
341
|
+
}
|
|
342
|
+
if normalized_session is not None:
|
|
343
|
+
message["sessionId"] = normalized_session
|
|
344
|
+
try:
|
|
345
|
+
encoded = _encode_message(message)
|
|
346
|
+
except BaseException:
|
|
347
|
+
with self._lock:
|
|
348
|
+
self._pending.pop(request_id, None)
|
|
349
|
+
raise
|
|
350
|
+
outbound = _OutboundMessage(encoded=encoded, sent=threading.Event())
|
|
351
|
+
try:
|
|
352
|
+
self._outbound.put_nowait(outbound)
|
|
353
|
+
except queue.Full as exc:
|
|
354
|
+
with self._lock:
|
|
355
|
+
self._pending.pop(request_id, None)
|
|
356
|
+
raise BrowserLimitError("Browser CDP outbound command capacity was exhausted.") from exc
|
|
357
|
+
|
|
358
|
+
while not outbound.sent.wait(
|
|
359
|
+
timeout=min(_WAIT_POLL_SECONDS, max(0.0, deadline - time.monotonic()))
|
|
360
|
+
):
|
|
361
|
+
if pending.event.is_set():
|
|
362
|
+
break
|
|
363
|
+
if cancel is not None and cancel():
|
|
364
|
+
error = BrowserCancelledError("Browser CDP command was cancelled.")
|
|
365
|
+
self._fail_transport(error)
|
|
366
|
+
raise error
|
|
367
|
+
if time.monotonic() >= deadline:
|
|
368
|
+
error = BrowserTimeoutError("Browser CDP command timed out.")
|
|
369
|
+
self._fail_transport(error)
|
|
370
|
+
raise error
|
|
371
|
+
if outbound.error is not None:
|
|
372
|
+
raise outbound.error
|
|
373
|
+
while not pending.event.wait(
|
|
374
|
+
timeout=min(_WAIT_POLL_SECONDS, max(0.0, deadline - time.monotonic()))
|
|
375
|
+
):
|
|
376
|
+
if cancel is not None and cancel():
|
|
377
|
+
error = BrowserCancelledError("Browser CDP command was cancelled.")
|
|
378
|
+
self._fail_transport(error)
|
|
379
|
+
raise error
|
|
380
|
+
if time.monotonic() >= deadline:
|
|
381
|
+
error = BrowserTimeoutError("Browser CDP command timed out.")
|
|
382
|
+
self._fail_transport(error)
|
|
383
|
+
raise error
|
|
384
|
+
if pending.error is not None:
|
|
385
|
+
raise pending.error
|
|
386
|
+
response = pending.response
|
|
387
|
+
if response is None:
|
|
388
|
+
error = BrowserSecurityError("Browser CDP response was missing.")
|
|
389
|
+
self._fail_transport(error)
|
|
390
|
+
raise error
|
|
391
|
+
error_payload = response.get("error")
|
|
392
|
+
if error_payload is not None:
|
|
393
|
+
code: int | None = None
|
|
394
|
+
if isinstance(error_payload, Mapping):
|
|
395
|
+
candidate = error_payload.get("code")
|
|
396
|
+
if (
|
|
397
|
+
isinstance(candidate, int)
|
|
398
|
+
and not isinstance(candidate, bool)
|
|
399
|
+
and abs(candidate) <= 1_000_000
|
|
400
|
+
):
|
|
401
|
+
code = candidate
|
|
402
|
+
raise CdpCommandError(normalized_method, code)
|
|
403
|
+
result = response.get("result", {})
|
|
404
|
+
if not isinstance(result, Mapping):
|
|
405
|
+
error = BrowserSecurityError("Browser CDP result was invalid.")
|
|
406
|
+
self._fail_transport(error)
|
|
407
|
+
raise error
|
|
408
|
+
return dict(result)
|
|
409
|
+
|
|
410
|
+
def guarded_navigate(
|
|
411
|
+
self,
|
|
412
|
+
url: str,
|
|
413
|
+
*,
|
|
414
|
+
session_id: str,
|
|
415
|
+
authorize_url: Callable[[str], str],
|
|
416
|
+
timeout: float,
|
|
417
|
+
cancel: CancelCheck = None,
|
|
418
|
+
) -> Mapping[str, Any]:
|
|
419
|
+
normalized_session = _validated_session_id(session_id)
|
|
420
|
+
if normalized_session is None:
|
|
421
|
+
raise BrowserValidationError("Browser CDP session id is required for navigation.")
|
|
422
|
+
if not isinstance(url, str) or not url:
|
|
423
|
+
raise BrowserValidationError("Browser navigation URL is invalid.")
|
|
424
|
+
if not callable(authorize_url):
|
|
425
|
+
raise BrowserValidationError("Browser navigation URL policy is invalid.")
|
|
426
|
+
call_timeout = _validated_timeout(timeout)
|
|
427
|
+
_check_cancel(cancel)
|
|
428
|
+
if not self._navigation_lock.acquire(blocking=False):
|
|
429
|
+
raise BrowserSecurityError("Another guarded browser navigation is already running.")
|
|
430
|
+
deadline = time.monotonic() + call_timeout
|
|
431
|
+
navigation_done = threading.Event()
|
|
432
|
+
navigation_outcome: dict[str, Any] = {}
|
|
433
|
+
navigation_started = False
|
|
434
|
+
try:
|
|
435
|
+
initial_url = _authorize(authorize_url, url)
|
|
436
|
+
policy = _InterceptionPolicy(
|
|
437
|
+
authorize_url=authorize_url,
|
|
438
|
+
timeout=min(call_timeout, 30.0),
|
|
439
|
+
)
|
|
440
|
+
with self._lock:
|
|
441
|
+
self._raise_if_unavailable_locked()
|
|
442
|
+
self._interception_policies[normalized_session] = policy
|
|
443
|
+
self.call(
|
|
444
|
+
"Target.setAutoAttach",
|
|
445
|
+
{
|
|
446
|
+
"autoAttach": True,
|
|
447
|
+
"waitForDebuggerOnStart": True,
|
|
448
|
+
"flatten": True,
|
|
449
|
+
},
|
|
450
|
+
session_id=normalized_session,
|
|
451
|
+
timeout=_remaining(deadline),
|
|
452
|
+
cancel=cancel,
|
|
453
|
+
)
|
|
454
|
+
self.call(
|
|
455
|
+
"Page.setLifecycleEventsEnabled",
|
|
456
|
+
{"enabled": True},
|
|
457
|
+
session_id=normalized_session,
|
|
458
|
+
timeout=_remaining(deadline),
|
|
459
|
+
cancel=cancel,
|
|
460
|
+
)
|
|
461
|
+
self.call(
|
|
462
|
+
"Fetch.enable",
|
|
463
|
+
{
|
|
464
|
+
"patterns": [
|
|
465
|
+
{"urlPattern": "*", "requestStage": "Request"},
|
|
466
|
+
{"urlPattern": "*", "requestStage": "Response"},
|
|
467
|
+
],
|
|
468
|
+
"handleAuthRequests": False,
|
|
469
|
+
},
|
|
470
|
+
session_id=normalized_session,
|
|
471
|
+
timeout=_remaining(deadline),
|
|
472
|
+
cancel=cancel,
|
|
473
|
+
)
|
|
474
|
+
with self._lock:
|
|
475
|
+
drop_generation = self._drop_generation
|
|
476
|
+
|
|
477
|
+
def navigate() -> None:
|
|
478
|
+
try:
|
|
479
|
+
navigation_outcome["result"] = self.call(
|
|
480
|
+
"Page.navigate",
|
|
481
|
+
{"url": initial_url},
|
|
482
|
+
session_id=normalized_session,
|
|
483
|
+
timeout=_remaining(deadline),
|
|
484
|
+
cancel=cancel,
|
|
485
|
+
)
|
|
486
|
+
except BaseException as exc: # noqa: BLE001 - delivered to caller below
|
|
487
|
+
navigation_outcome["error"] = exc
|
|
488
|
+
finally:
|
|
489
|
+
navigation_done.set()
|
|
490
|
+
|
|
491
|
+
threading.Thread(
|
|
492
|
+
target=navigate,
|
|
493
|
+
name="alysis-cdp-navigate",
|
|
494
|
+
daemon=True,
|
|
495
|
+
).start()
|
|
496
|
+
navigation_started = True
|
|
497
|
+
loaded_loader_ids: set[str] = set()
|
|
498
|
+
while True:
|
|
499
|
+
_check_cancel_and_deadline(cancel, deadline, self)
|
|
500
|
+
with self._lock:
|
|
501
|
+
if self._drop_generation != drop_generation:
|
|
502
|
+
error = BrowserSecurityError(
|
|
503
|
+
"Browser CDP events overflowed during guarded navigation."
|
|
504
|
+
)
|
|
505
|
+
self._fail_transport(error)
|
|
506
|
+
raise error
|
|
507
|
+
event = self._take_navigation_event(
|
|
508
|
+
normalized_session,
|
|
509
|
+
timeout=min(_WAIT_POLL_SECONDS, _remaining(deadline)),
|
|
510
|
+
)
|
|
511
|
+
if event is not None:
|
|
512
|
+
method = event.get("method")
|
|
513
|
+
if method == "Page.lifecycleEvent":
|
|
514
|
+
params = event.get("params")
|
|
515
|
+
if isinstance(params, Mapping) and params.get("name") == "load":
|
|
516
|
+
loader_id = params.get("loaderId")
|
|
517
|
+
if isinstance(loader_id, str) and loader_id:
|
|
518
|
+
loaded_loader_ids.add(loader_id)
|
|
519
|
+
if navigation_done.is_set():
|
|
520
|
+
navigation_error = navigation_outcome.get("error")
|
|
521
|
+
if navigation_error is not None:
|
|
522
|
+
if isinstance(navigation_error, BaseException):
|
|
523
|
+
raise navigation_error
|
|
524
|
+
raise BrowserLaunchError("Browser navigation failed.")
|
|
525
|
+
result = navigation_outcome.get("result")
|
|
526
|
+
if not isinstance(result, Mapping):
|
|
527
|
+
error = BrowserSecurityError("Browser navigation result was invalid.")
|
|
528
|
+
self._fail_transport(error)
|
|
529
|
+
raise error
|
|
530
|
+
error_text = result.get("errorText")
|
|
531
|
+
if error_text:
|
|
532
|
+
raise BrowserLaunchError(
|
|
533
|
+
"Browser navigation did not complete successfully."
|
|
534
|
+
)
|
|
535
|
+
loader_id = result.get("loaderId")
|
|
536
|
+
if not isinstance(loader_id, str) or not loader_id:
|
|
537
|
+
error = BrowserSecurityError(
|
|
538
|
+
"Browser navigation did not provide a trusted document loader id."
|
|
539
|
+
)
|
|
540
|
+
self._fail_transport(error)
|
|
541
|
+
raise error
|
|
542
|
+
if loader_id in loaded_loader_ids:
|
|
543
|
+
return dict(result)
|
|
544
|
+
except BrowserCancelledError as exc:
|
|
545
|
+
self._fail_transport(exc)
|
|
546
|
+
raise
|
|
547
|
+
except BrowserTimeoutError as exc:
|
|
548
|
+
self._fail_transport(exc)
|
|
549
|
+
raise
|
|
550
|
+
except BrowserError as exc:
|
|
551
|
+
# A setup or navigation failure can leave lifecycle/interception
|
|
552
|
+
# state ambiguous. Never allow the transport to continue without
|
|
553
|
+
# a proven persistent guard.
|
|
554
|
+
self._fail_transport(exc)
|
|
555
|
+
raise
|
|
556
|
+
finally:
|
|
557
|
+
if navigation_started and not navigation_done.is_set():
|
|
558
|
+
# Never leave a Page.navigate command running after its guarded
|
|
559
|
+
# operation returned. Its late reply would otherwise race a
|
|
560
|
+
# future operation and leave interception state ambiguous.
|
|
561
|
+
self._fail_transport(BrowserLaunchError("Browser navigation did not stop cleanly."))
|
|
562
|
+
self._navigation_lock.release()
|
|
563
|
+
|
|
564
|
+
def drain_events(
|
|
565
|
+
self,
|
|
566
|
+
*,
|
|
567
|
+
session_id: str,
|
|
568
|
+
max_events: int,
|
|
569
|
+
timeout: float,
|
|
570
|
+
cancel: CancelCheck = None,
|
|
571
|
+
) -> Sequence[Mapping[str, Any]]:
|
|
572
|
+
normalized_session = _validated_session_id(session_id)
|
|
573
|
+
if normalized_session is None:
|
|
574
|
+
raise BrowserValidationError("Browser CDP session id is required.")
|
|
575
|
+
if (
|
|
576
|
+
isinstance(max_events, bool)
|
|
577
|
+
or not isinstance(max_events, int)
|
|
578
|
+
or not 1 <= max_events <= _MAX_EVENT_DRAIN
|
|
579
|
+
):
|
|
580
|
+
raise BrowserValidationError("Browser CDP event limit is invalid.")
|
|
581
|
+
call_timeout = _validated_timeout(timeout)
|
|
582
|
+
deadline = time.monotonic() + call_timeout
|
|
583
|
+
output: list[Mapping[str, Any]] = []
|
|
584
|
+
with self._event_changed:
|
|
585
|
+
while True:
|
|
586
|
+
self._raise_if_unavailable_locked()
|
|
587
|
+
if self._dropped_events and len(output) < max_events:
|
|
588
|
+
output.append(
|
|
589
|
+
{
|
|
590
|
+
"method": "Alysis.eventsDropped",
|
|
591
|
+
"params": {"count": self._dropped_events},
|
|
592
|
+
"sessionId": normalized_session,
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
self._dropped_events = 0
|
|
596
|
+
if len(output) < max_events:
|
|
597
|
+
retained: deque[_QueuedEvent] = deque()
|
|
598
|
+
while self._events:
|
|
599
|
+
item = self._events.popleft()
|
|
600
|
+
if item.session_id == normalized_session and len(output) < max_events:
|
|
601
|
+
output.append(item.payload)
|
|
602
|
+
self._event_bytes -= item.size_bytes
|
|
603
|
+
else:
|
|
604
|
+
retained.append(item)
|
|
605
|
+
self._events = retained
|
|
606
|
+
if output or time.monotonic() >= deadline:
|
|
607
|
+
return output
|
|
608
|
+
if cancel is not None and cancel():
|
|
609
|
+
raise BrowserCancelledError("Browser event read was cancelled.")
|
|
610
|
+
self._event_changed.wait(
|
|
611
|
+
timeout=min(_WAIT_POLL_SECONDS, max(0.0, deadline - time.monotonic()))
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def close(self, *, timeout: float) -> None:
|
|
615
|
+
close_timeout = _validated_timeout(timeout)
|
|
616
|
+
self._fail_transport(BrowserLaunchError("Browser DevTools WebSocket was closed."))
|
|
617
|
+
_close_connection_bounded(self._connection, close_timeout)
|
|
618
|
+
if threading.current_thread() is not self._reader:
|
|
619
|
+
self._reader.join(timeout=close_timeout)
|
|
620
|
+
if threading.current_thread() is not self._writer:
|
|
621
|
+
self._writer.join(timeout=close_timeout)
|
|
622
|
+
if threading.current_thread() is not self._interceptor:
|
|
623
|
+
self._interceptor.join(timeout=close_timeout)
|
|
624
|
+
|
|
625
|
+
def _writer_loop(self) -> None:
|
|
626
|
+
while True:
|
|
627
|
+
with self._lock:
|
|
628
|
+
if self._closed:
|
|
629
|
+
return
|
|
630
|
+
try:
|
|
631
|
+
outbound = self._outbound.get(timeout=_READER_POLL_SECONDS)
|
|
632
|
+
except queue.Empty:
|
|
633
|
+
continue
|
|
634
|
+
with self._lock:
|
|
635
|
+
if self._closed:
|
|
636
|
+
outbound.error = self._terminal_error or BrowserLaunchError(
|
|
637
|
+
"Browser DevTools WebSocket is closed."
|
|
638
|
+
)
|
|
639
|
+
outbound.sent.set()
|
|
640
|
+
continue
|
|
641
|
+
try:
|
|
642
|
+
self._connection.send(outbound.encoded)
|
|
643
|
+
except BaseException: # noqa: BLE001 - normalized below
|
|
644
|
+
error = BrowserLaunchError("Browser DevTools WebSocket send failed.")
|
|
645
|
+
outbound.error = error
|
|
646
|
+
outbound.sent.set()
|
|
647
|
+
self._fail_transport(error)
|
|
648
|
+
return
|
|
649
|
+
outbound.sent.set()
|
|
650
|
+
|
|
651
|
+
def _reader_loop(self) -> None:
|
|
652
|
+
while True:
|
|
653
|
+
with self._lock:
|
|
654
|
+
if self._closed:
|
|
655
|
+
return
|
|
656
|
+
try:
|
|
657
|
+
raw = self._connection.recv(timeout=_READER_POLL_SECONDS, decode=False)
|
|
658
|
+
except TimeoutError:
|
|
659
|
+
continue
|
|
660
|
+
except BaseException: # noqa: BLE001 - normalized below
|
|
661
|
+
with self._lock:
|
|
662
|
+
if self._closed:
|
|
663
|
+
return
|
|
664
|
+
error = BrowserLaunchError("Browser DevTools WebSocket receive failed.")
|
|
665
|
+
self._fail_transport(error)
|
|
666
|
+
_close_connection_async(self._connection)
|
|
667
|
+
return
|
|
668
|
+
try:
|
|
669
|
+
payload, size_bytes = _decode_message(raw)
|
|
670
|
+
self._route_message(payload, size_bytes=size_bytes)
|
|
671
|
+
except BrowserError as exc:
|
|
672
|
+
self._fail_transport(exc)
|
|
673
|
+
_close_connection_async(self._connection)
|
|
674
|
+
return
|
|
675
|
+
except BaseException: # noqa: BLE001 - decoder bugs fail closed
|
|
676
|
+
error = BrowserSecurityError("Browser CDP message was invalid.")
|
|
677
|
+
self._fail_transport(error)
|
|
678
|
+
_close_connection_async(self._connection)
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
def _interceptor_loop(self) -> None:
|
|
682
|
+
"""Continue every policy-approved request for the transport lifetime."""
|
|
683
|
+
|
|
684
|
+
while True:
|
|
685
|
+
with self._lock:
|
|
686
|
+
if self._closed:
|
|
687
|
+
return
|
|
688
|
+
try:
|
|
689
|
+
item = self._interception_events.get(timeout=_READER_POLL_SECONDS)
|
|
690
|
+
except queue.Empty:
|
|
691
|
+
continue
|
|
692
|
+
with self._lock:
|
|
693
|
+
self._interception_event_bytes -= item.size_bytes
|
|
694
|
+
if self._interception_event_bytes < 0:
|
|
695
|
+
error = BrowserSecurityError(
|
|
696
|
+
"Browser request interception accounting failed closed."
|
|
697
|
+
)
|
|
698
|
+
self._interception_event_bytes = 0
|
|
699
|
+
self._fail_transport(error)
|
|
700
|
+
return
|
|
701
|
+
session_id = item.session_id
|
|
702
|
+
if session_id is None:
|
|
703
|
+
error = BrowserSecurityError("Browser intercepted request had no session id.")
|
|
704
|
+
self._fail_transport(error)
|
|
705
|
+
return
|
|
706
|
+
with self._lock:
|
|
707
|
+
policy = self._interception_policies.get(session_id)
|
|
708
|
+
if policy is None:
|
|
709
|
+
error = BrowserSecurityError("Browser request interception policy was unavailable.")
|
|
710
|
+
self._fail_transport(error)
|
|
711
|
+
return
|
|
712
|
+
try:
|
|
713
|
+
method = item.payload.get("method")
|
|
714
|
+
if method == "Target.attachedToTarget":
|
|
715
|
+
self._guard_attached_target(
|
|
716
|
+
item.payload,
|
|
717
|
+
parent_session_id=session_id,
|
|
718
|
+
policy=policy,
|
|
719
|
+
)
|
|
720
|
+
continue
|
|
721
|
+
if method == "Target.detachedFromTarget":
|
|
722
|
+
self._forget_detached_target(item.payload)
|
|
723
|
+
continue
|
|
724
|
+
if method == "Fetch.authRequired":
|
|
725
|
+
raise BrowserSecurityError(
|
|
726
|
+
"Browser authentication challenge was blocked during navigation."
|
|
727
|
+
)
|
|
728
|
+
if method != "Fetch.requestPaused":
|
|
729
|
+
raise BrowserSecurityError("Browser interception event was invalid.")
|
|
730
|
+
_continue_paused_request(
|
|
731
|
+
self,
|
|
732
|
+
item.payload,
|
|
733
|
+
session_id=session_id,
|
|
734
|
+
authorize_url=policy.authorize_url,
|
|
735
|
+
deadline=time.monotonic() + policy.timeout,
|
|
736
|
+
cancel=None,
|
|
737
|
+
)
|
|
738
|
+
except BrowserError as exc:
|
|
739
|
+
self._fail_transport(exc)
|
|
740
|
+
return
|
|
741
|
+
except BaseException as exc: # noqa: BLE001 - policy broker fails closed
|
|
742
|
+
error = BrowserSecurityError("Browser request interception failed closed.")
|
|
743
|
+
self._fail_transport(error)
|
|
744
|
+
_close_connection_async(self._connection)
|
|
745
|
+
_ = exc
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
def _guard_attached_target(
|
|
749
|
+
self,
|
|
750
|
+
event: Mapping[str, Any],
|
|
751
|
+
*,
|
|
752
|
+
parent_session_id: str,
|
|
753
|
+
policy: _InterceptionPolicy,
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Install the same guard before a popup/worker child is resumed."""
|
|
756
|
+
|
|
757
|
+
params = event.get("params")
|
|
758
|
+
if not isinstance(params, Mapping):
|
|
759
|
+
raise BrowserSecurityError("Browser attached-target event was invalid.")
|
|
760
|
+
child_session_id = params.get("sessionId")
|
|
761
|
+
waiting = params.get("waitingForDebugger")
|
|
762
|
+
if (
|
|
763
|
+
not isinstance(child_session_id, str)
|
|
764
|
+
or not child_session_id
|
|
765
|
+
or len(child_session_id) > _MAX_SESSION_ID_CHARS
|
|
766
|
+
or waiting is not True
|
|
767
|
+
or child_session_id == parent_session_id
|
|
768
|
+
):
|
|
769
|
+
raise BrowserSecurityError("Browser attached target was not safely paused.")
|
|
770
|
+
with self._lock:
|
|
771
|
+
self._raise_if_unavailable_locked()
|
|
772
|
+
self._interception_policies[child_session_id] = policy
|
|
773
|
+
deadline = time.monotonic() + policy.timeout
|
|
774
|
+
self.call(
|
|
775
|
+
"Target.setAutoAttach",
|
|
776
|
+
{
|
|
777
|
+
"autoAttach": True,
|
|
778
|
+
"waitForDebuggerOnStart": True,
|
|
779
|
+
"flatten": True,
|
|
780
|
+
},
|
|
781
|
+
session_id=child_session_id,
|
|
782
|
+
timeout=_remaining(deadline),
|
|
783
|
+
)
|
|
784
|
+
self.call(
|
|
785
|
+
"Fetch.enable",
|
|
786
|
+
{
|
|
787
|
+
"patterns": [
|
|
788
|
+
{"urlPattern": "*", "requestStage": "Request"},
|
|
789
|
+
{"urlPattern": "*", "requestStage": "Response"},
|
|
790
|
+
],
|
|
791
|
+
"handleAuthRequests": False,
|
|
792
|
+
},
|
|
793
|
+
session_id=child_session_id,
|
|
794
|
+
timeout=_remaining(deadline),
|
|
795
|
+
)
|
|
796
|
+
self.call(
|
|
797
|
+
"Runtime.runIfWaitingForDebugger",
|
|
798
|
+
{},
|
|
799
|
+
session_id=child_session_id,
|
|
800
|
+
timeout=_remaining(deadline),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def _forget_detached_target(self, event: Mapping[str, Any]) -> None:
|
|
804
|
+
params = event.get("params")
|
|
805
|
+
if not isinstance(params, Mapping):
|
|
806
|
+
raise BrowserSecurityError("Browser detached-target event was invalid.")
|
|
807
|
+
child_session_id = params.get("sessionId")
|
|
808
|
+
if not isinstance(child_session_id, str) or not child_session_id:
|
|
809
|
+
raise BrowserSecurityError("Browser detached-target event was invalid.")
|
|
810
|
+
with self._lock:
|
|
811
|
+
self._interception_policies.pop(child_session_id, None)
|
|
812
|
+
|
|
813
|
+
def _route_message(self, payload: Mapping[str, Any], *, size_bytes: int) -> None:
|
|
814
|
+
if "id" in payload:
|
|
815
|
+
if "method" in payload:
|
|
816
|
+
raise BrowserSecurityError("Browser CDP response shape was invalid.")
|
|
817
|
+
request_id = payload.get("id")
|
|
818
|
+
if (
|
|
819
|
+
isinstance(request_id, bool)
|
|
820
|
+
or not isinstance(request_id, int)
|
|
821
|
+
or not 1 <= request_id <= _MAX_RESPONSE_ID
|
|
822
|
+
):
|
|
823
|
+
raise BrowserSecurityError("Browser CDP response id was invalid.")
|
|
824
|
+
with self._lock:
|
|
825
|
+
pending = self._pending.pop(request_id, None)
|
|
826
|
+
if pending is None:
|
|
827
|
+
raise BrowserSecurityError("Browser CDP response id was unknown or duplicated.")
|
|
828
|
+
pending.response = payload
|
|
829
|
+
pending.event.set()
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
method = payload.get("method")
|
|
833
|
+
params = payload.get("params", {})
|
|
834
|
+
session_id = payload.get("sessionId")
|
|
835
|
+
if not isinstance(method, str) or not _METHOD_RE.fullmatch(method):
|
|
836
|
+
raise BrowserSecurityError("Browser CDP event method was invalid.")
|
|
837
|
+
if not isinstance(params, Mapping):
|
|
838
|
+
raise BrowserSecurityError("Browser CDP event params were invalid.")
|
|
839
|
+
if session_id is not None and (
|
|
840
|
+
not isinstance(session_id, str)
|
|
841
|
+
or not session_id
|
|
842
|
+
or len(session_id) > _MAX_SESSION_ID_CHARS
|
|
843
|
+
or any(ord(char) < 0x20 for char in session_id)
|
|
844
|
+
):
|
|
845
|
+
raise BrowserSecurityError("Browser CDP event session id was invalid.")
|
|
846
|
+
queued = _QueuedEvent(payload=payload, size_bytes=size_bytes, session_id=session_id)
|
|
847
|
+
if method in {
|
|
848
|
+
"Fetch.authRequired",
|
|
849
|
+
"Fetch.requestPaused",
|
|
850
|
+
"Target.attachedToTarget",
|
|
851
|
+
"Target.detachedFromTarget",
|
|
852
|
+
}:
|
|
853
|
+
with self._lock:
|
|
854
|
+
guarded = session_id is not None and session_id in self._interception_policies
|
|
855
|
+
if method == "Target.detachedFromTarget" and isinstance(params, Mapping):
|
|
856
|
+
detached = params.get("sessionId")
|
|
857
|
+
guarded = guarded or (
|
|
858
|
+
isinstance(detached, str) and detached in self._interception_policies
|
|
859
|
+
)
|
|
860
|
+
if guarded:
|
|
861
|
+
with self._lock:
|
|
862
|
+
if (
|
|
863
|
+
size_bytes > self._max_event_bytes
|
|
864
|
+
or self._interception_event_bytes + size_bytes > self._max_event_bytes
|
|
865
|
+
):
|
|
866
|
+
raise BrowserSecurityError(
|
|
867
|
+
"Browser request interception byte budget overflowed."
|
|
868
|
+
)
|
|
869
|
+
try:
|
|
870
|
+
self._interception_events.put_nowait(queued)
|
|
871
|
+
except queue.Full as exc:
|
|
872
|
+
raise BrowserSecurityError(
|
|
873
|
+
"Browser request interception queue overflowed."
|
|
874
|
+
) from exc
|
|
875
|
+
self._interception_event_bytes += size_bytes
|
|
876
|
+
return
|
|
877
|
+
with self._event_changed:
|
|
878
|
+
while self._events and (
|
|
879
|
+
len(self._events) >= self._max_event_count
|
|
880
|
+
or self._event_bytes + size_bytes > self._max_event_bytes
|
|
881
|
+
):
|
|
882
|
+
removed = self._events.popleft()
|
|
883
|
+
self._event_bytes -= removed.size_bytes
|
|
884
|
+
self._dropped_events += 1
|
|
885
|
+
self._drop_generation += 1
|
|
886
|
+
if size_bytes > self._max_event_bytes:
|
|
887
|
+
self._dropped_events += 1
|
|
888
|
+
self._drop_generation += 1
|
|
889
|
+
else:
|
|
890
|
+
self._events.append(queued)
|
|
891
|
+
self._event_bytes += size_bytes
|
|
892
|
+
self._event_changed.notify_all()
|
|
893
|
+
|
|
894
|
+
def _take_navigation_event(
|
|
895
|
+
self,
|
|
896
|
+
session_id: str,
|
|
897
|
+
*,
|
|
898
|
+
timeout: float,
|
|
899
|
+
) -> Mapping[str, Any] | None:
|
|
900
|
+
wanted = {"Page.lifecycleEvent"}
|
|
901
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
902
|
+
with self._event_changed:
|
|
903
|
+
while True:
|
|
904
|
+
self._raise_if_unavailable_locked()
|
|
905
|
+
for index, item in enumerate(self._events):
|
|
906
|
+
if item.session_id == session_id and item.payload.get("method") in wanted:
|
|
907
|
+
del self._events[index]
|
|
908
|
+
self._event_bytes -= item.size_bytes
|
|
909
|
+
return item.payload
|
|
910
|
+
remaining = deadline - time.monotonic()
|
|
911
|
+
if remaining <= 0:
|
|
912
|
+
return None
|
|
913
|
+
self._event_changed.wait(timeout=min(_WAIT_POLL_SECONDS, remaining))
|
|
914
|
+
|
|
915
|
+
def _next_request_id_locked(self) -> int:
|
|
916
|
+
if self._next_id > _MAX_RESPONSE_ID:
|
|
917
|
+
raise BrowserSecurityError("Browser CDP request id space was exhausted.")
|
|
918
|
+
request_id = self._next_id
|
|
919
|
+
self._next_id += 1
|
|
920
|
+
return request_id
|
|
921
|
+
|
|
922
|
+
def _raise_if_unavailable_locked(self) -> None:
|
|
923
|
+
if self._terminal_error is not None:
|
|
924
|
+
raise self._terminal_error
|
|
925
|
+
if self._closed:
|
|
926
|
+
raise BrowserLaunchError("Browser DevTools WebSocket is closed.")
|
|
927
|
+
|
|
928
|
+
def _fail_transport(self, error: BrowserError) -> None:
|
|
929
|
+
should_close = False
|
|
930
|
+
with self._event_changed:
|
|
931
|
+
if self._terminal_error is None:
|
|
932
|
+
self._terminal_error = error
|
|
933
|
+
should_close = True
|
|
934
|
+
self._closed = True
|
|
935
|
+
pending = tuple(self._pending.values())
|
|
936
|
+
self._pending.clear()
|
|
937
|
+
for item in pending:
|
|
938
|
+
item.error = self._terminal_error
|
|
939
|
+
item.event.set()
|
|
940
|
+
while True:
|
|
941
|
+
try:
|
|
942
|
+
outbound = self._outbound.get_nowait()
|
|
943
|
+
except queue.Empty:
|
|
944
|
+
break
|
|
945
|
+
outbound.error = self._terminal_error
|
|
946
|
+
outbound.sent.set()
|
|
947
|
+
self._event_changed.notify_all()
|
|
948
|
+
if should_close:
|
|
949
|
+
_close_connection_async(self._connection)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _continue_paused_request(
|
|
953
|
+
transport: WebSocketCdpTransport,
|
|
954
|
+
event: Mapping[str, Any],
|
|
955
|
+
*,
|
|
956
|
+
session_id: str,
|
|
957
|
+
authorize_url: Callable[[str], str],
|
|
958
|
+
deadline: float,
|
|
959
|
+
cancel: CancelCheck,
|
|
960
|
+
) -> str:
|
|
961
|
+
params = event.get("params")
|
|
962
|
+
if not isinstance(params, Mapping):
|
|
963
|
+
error = BrowserSecurityError("Browser paused request was invalid.")
|
|
964
|
+
transport._fail_transport(error)
|
|
965
|
+
raise error
|
|
966
|
+
request_id = params.get("requestId")
|
|
967
|
+
request = params.get("request")
|
|
968
|
+
if (
|
|
969
|
+
not isinstance(request_id, str)
|
|
970
|
+
or not request_id
|
|
971
|
+
or len(request_id) > 256
|
|
972
|
+
or not isinstance(request, Mapping)
|
|
973
|
+
):
|
|
974
|
+
error = BrowserSecurityError("Browser paused request was invalid.")
|
|
975
|
+
transport._fail_transport(error)
|
|
976
|
+
raise error
|
|
977
|
+
candidate = request.get("url")
|
|
978
|
+
if not isinstance(candidate, str) or not candidate:
|
|
979
|
+
error = BrowserSecurityError("Browser paused request URL was invalid.")
|
|
980
|
+
transport._fail_transport(error)
|
|
981
|
+
raise error
|
|
982
|
+
response_stage = "responseStatusCode" in params or "responseErrorReason" in params
|
|
983
|
+
try:
|
|
984
|
+
authorized = _authorize(authorize_url, candidate)
|
|
985
|
+
except BaseException as exc:
|
|
986
|
+
try:
|
|
987
|
+
transport.call(
|
|
988
|
+
"Fetch.failRequest",
|
|
989
|
+
{"requestId": request_id, "errorReason": "BlockedByClient"},
|
|
990
|
+
session_id=session_id,
|
|
991
|
+
timeout=_remaining(deadline),
|
|
992
|
+
cancel=cancel,
|
|
993
|
+
)
|
|
994
|
+
except BrowserError:
|
|
995
|
+
transport._fail_transport(
|
|
996
|
+
BrowserSecurityError("Browser rejected request could not be failed safely.")
|
|
997
|
+
)
|
|
998
|
+
if isinstance(exc, BrowserError):
|
|
999
|
+
raise exc
|
|
1000
|
+
raise BrowserSecurityError("Browser request URL policy failed closed.") from exc
|
|
1001
|
+
if response_stage:
|
|
1002
|
+
transport.call(
|
|
1003
|
+
"Fetch.continueResponse",
|
|
1004
|
+
{"requestId": request_id},
|
|
1005
|
+
session_id=session_id,
|
|
1006
|
+
timeout=_remaining(deadline),
|
|
1007
|
+
cancel=cancel,
|
|
1008
|
+
)
|
|
1009
|
+
return "Response"
|
|
1010
|
+
continue_params: dict[str, Any] = {"requestId": request_id}
|
|
1011
|
+
if authorized != candidate:
|
|
1012
|
+
continue_params["url"] = authorized
|
|
1013
|
+
transport.call(
|
|
1014
|
+
"Fetch.continueRequest",
|
|
1015
|
+
continue_params,
|
|
1016
|
+
session_id=session_id,
|
|
1017
|
+
timeout=_remaining(deadline),
|
|
1018
|
+
cancel=cancel,
|
|
1019
|
+
)
|
|
1020
|
+
return "Request"
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _default_connector() -> _Connector:
|
|
1024
|
+
try:
|
|
1025
|
+
from websockets.sync.client import connect
|
|
1026
|
+
except ImportError as exc: # pragma: no cover - installation contract
|
|
1027
|
+
raise BrowserDependencyError(
|
|
1028
|
+
"Managed browser automation requires the websockets package."
|
|
1029
|
+
) from exc
|
|
1030
|
+
return connect
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _encode_message(payload: Mapping[str, Any]) -> str:
|
|
1034
|
+
try:
|
|
1035
|
+
encoded = json.dumps(
|
|
1036
|
+
payload,
|
|
1037
|
+
ensure_ascii=False,
|
|
1038
|
+
allow_nan=False,
|
|
1039
|
+
separators=(",", ":"),
|
|
1040
|
+
).encode("utf-8")
|
|
1041
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
1042
|
+
raise BrowserValidationError("Browser CDP command was not valid bounded JSON.") from exc
|
|
1043
|
+
if len(encoded) > _MAX_MESSAGE_BYTES:
|
|
1044
|
+
raise BrowserValidationError("Browser CDP command exceeded the 8 MiB limit.")
|
|
1045
|
+
return encoded.decode("utf-8")
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _decode_message(raw: str | bytes) -> tuple[Mapping[str, Any], int]:
|
|
1049
|
+
if isinstance(raw, str):
|
|
1050
|
+
try:
|
|
1051
|
+
encoded = raw.encode("utf-8")
|
|
1052
|
+
except UnicodeError as exc:
|
|
1053
|
+
raise BrowserSecurityError("Browser CDP frame was not valid UTF-8.") from exc
|
|
1054
|
+
elif isinstance(raw, bytes):
|
|
1055
|
+
encoded = raw
|
|
1056
|
+
else:
|
|
1057
|
+
raise BrowserSecurityError("Browser CDP frame type was invalid.")
|
|
1058
|
+
if not encoded or len(encoded) > _MAX_MESSAGE_BYTES:
|
|
1059
|
+
raise BrowserSecurityError("Browser CDP frame exceeded the 8 MiB limit.")
|
|
1060
|
+
|
|
1061
|
+
def reject_constant(value: str) -> None:
|
|
1062
|
+
raise ValueError(f"unsupported JSON constant: {value}")
|
|
1063
|
+
|
|
1064
|
+
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
1065
|
+
result: dict[str, Any] = {}
|
|
1066
|
+
for key, value in pairs:
|
|
1067
|
+
if key in result:
|
|
1068
|
+
raise ValueError("duplicate JSON object key")
|
|
1069
|
+
result[key] = value
|
|
1070
|
+
return result
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
payload = json.loads(
|
|
1074
|
+
encoded,
|
|
1075
|
+
parse_constant=reject_constant,
|
|
1076
|
+
object_pairs_hook=unique_object,
|
|
1077
|
+
)
|
|
1078
|
+
except (UnicodeError, ValueError, RecursionError) as exc:
|
|
1079
|
+
raise BrowserSecurityError("Browser CDP frame was not valid JSON.") from exc
|
|
1080
|
+
if not isinstance(payload, Mapping):
|
|
1081
|
+
raise BrowserSecurityError("Browser CDP message was not an object.")
|
|
1082
|
+
try:
|
|
1083
|
+
decoded_size = len(
|
|
1084
|
+
json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode(
|
|
1085
|
+
"utf-8"
|
|
1086
|
+
)
|
|
1087
|
+
)
|
|
1088
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
1089
|
+
raise BrowserSecurityError("Browser CDP decoded message was invalid.") from exc
|
|
1090
|
+
if decoded_size > _MAX_MESSAGE_BYTES:
|
|
1091
|
+
raise BrowserSecurityError("Browser CDP decoded message exceeded the 8 MiB limit.")
|
|
1092
|
+
return payload, decoded_size
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _validated_method(method: str) -> str:
|
|
1096
|
+
if (
|
|
1097
|
+
not isinstance(method, str)
|
|
1098
|
+
or len(method) > _MAX_METHOD_CHARS
|
|
1099
|
+
or not _METHOD_RE.fullmatch(method)
|
|
1100
|
+
):
|
|
1101
|
+
raise BrowserValidationError("Browser CDP method is invalid.")
|
|
1102
|
+
return method
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _validated_session_id(session_id: str | None) -> str | None:
|
|
1106
|
+
if session_id is None:
|
|
1107
|
+
return None
|
|
1108
|
+
if (
|
|
1109
|
+
not isinstance(session_id, str)
|
|
1110
|
+
or not session_id
|
|
1111
|
+
or len(session_id) > _MAX_SESSION_ID_CHARS
|
|
1112
|
+
or any(ord(char) < 0x20 for char in session_id)
|
|
1113
|
+
):
|
|
1114
|
+
raise BrowserValidationError("Browser CDP session id is invalid.")
|
|
1115
|
+
return session_id
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _validated_timeout(timeout: float) -> float:
|
|
1119
|
+
if (
|
|
1120
|
+
isinstance(timeout, bool)
|
|
1121
|
+
or not isinstance(timeout, int | float)
|
|
1122
|
+
or not math.isfinite(float(timeout))
|
|
1123
|
+
or not 0.05 <= float(timeout) <= 300.0
|
|
1124
|
+
):
|
|
1125
|
+
raise BrowserValidationError("Browser CDP timeout is invalid.")
|
|
1126
|
+
return float(timeout)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _authorize(authorize_url: Callable[[str], str], candidate: str) -> str:
|
|
1130
|
+
try:
|
|
1131
|
+
authorized = authorize_url(candidate)
|
|
1132
|
+
except BrowserError:
|
|
1133
|
+
raise
|
|
1134
|
+
except BaseException as exc: # noqa: BLE001 - policy failure must fail closed
|
|
1135
|
+
raise BrowserSecurityError("Browser request URL policy failed closed.") from exc
|
|
1136
|
+
if not isinstance(authorized, str) or not authorized:
|
|
1137
|
+
raise BrowserSecurityError("Browser request URL policy returned an invalid URL.")
|
|
1138
|
+
return authorized
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _remaining(deadline: float) -> float:
|
|
1142
|
+
remaining = deadline - time.monotonic()
|
|
1143
|
+
if remaining < 0.05:
|
|
1144
|
+
raise BrowserTimeoutError("Browser CDP operation timed out.")
|
|
1145
|
+
return remaining
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _check_cancel(cancel: CancelCheck) -> None:
|
|
1149
|
+
if cancel is not None and cancel():
|
|
1150
|
+
raise BrowserCancelledError("Browser operation was cancelled.")
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _check_cancel_and_deadline(
|
|
1154
|
+
cancel: CancelCheck,
|
|
1155
|
+
deadline: float,
|
|
1156
|
+
transport: WebSocketCdpTransport,
|
|
1157
|
+
) -> None:
|
|
1158
|
+
if cancel is not None and cancel():
|
|
1159
|
+
error = BrowserCancelledError("Browser navigation was cancelled.")
|
|
1160
|
+
transport._fail_transport(error)
|
|
1161
|
+
raise error
|
|
1162
|
+
if time.monotonic() >= deadline:
|
|
1163
|
+
error = BrowserTimeoutError("Browser navigation timed out.")
|
|
1164
|
+
transport._fail_transport(error)
|
|
1165
|
+
raise error
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _close_connection_async(connection: _WebSocketConnection) -> None:
|
|
1169
|
+
threading.Thread(
|
|
1170
|
+
target=_close_connection_safely,
|
|
1171
|
+
args=(connection,),
|
|
1172
|
+
name="alysis-cdp-close",
|
|
1173
|
+
daemon=True,
|
|
1174
|
+
).start()
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _close_connection_bounded(connection: _WebSocketConnection, timeout: float) -> None:
|
|
1178
|
+
worker = threading.Thread(
|
|
1179
|
+
target=_close_connection_safely,
|
|
1180
|
+
args=(connection,),
|
|
1181
|
+
name="alysis-cdp-close",
|
|
1182
|
+
daemon=True,
|
|
1183
|
+
)
|
|
1184
|
+
worker.start()
|
|
1185
|
+
worker.join(timeout=timeout)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _close_connection_safely(connection: _WebSocketConnection) -> None:
|
|
1189
|
+
try:
|
|
1190
|
+
connection.close()
|
|
1191
|
+
except BaseException: # noqa: BLE001 - cleanup must not mask the original failure
|
|
1192
|
+
return
|