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,2251 @@
|
|
|
1
|
+
"""Owned Chromium DevTools sessions for IDE browser automation.
|
|
2
|
+
|
|
3
|
+
The module intentionally keeps the browser/CDP boundary injectable. Chromium
|
|
4
|
+
can be launched without another Python package, but speaking WebSocket CDP
|
|
5
|
+
correctly requires a maintained transport (including concurrent event handling
|
|
6
|
+
for request interception). Integrators must supply :class:`CdpTransportFactory`;
|
|
7
|
+
the core fails closed when none is configured.
|
|
8
|
+
|
|
9
|
+
Security invariants:
|
|
10
|
+
|
|
11
|
+
* only an explicitly selected or known installed Chromium-family executable is
|
|
12
|
+
launched, never a shell command or an inherited ``PATH`` lookup;
|
|
13
|
+
* every process is put in a process group owned by this service and only that
|
|
14
|
+
exact group is terminated;
|
|
15
|
+
* the DevTools endpoint is loopback-only and uses a private, per-session profile
|
|
16
|
+
outside all declared workspaces;
|
|
17
|
+
* the child gets a minimal environment and a fixed, owned validating egress
|
|
18
|
+
proxy with no direct-network fallback;
|
|
19
|
+
* top-level and redirected/subresource URLs pass the same DNS-aware policy
|
|
20
|
+
before the transport permits a request;
|
|
21
|
+
* sessions and artifacts are owner-scoped, bounded, and never accept a caller
|
|
22
|
+
supplied filesystem path.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import binascii
|
|
29
|
+
import hashlib
|
|
30
|
+
import ipaddress
|
|
31
|
+
import json
|
|
32
|
+
import math
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import secrets
|
|
36
|
+
import shutil
|
|
37
|
+
import signal
|
|
38
|
+
import socket
|
|
39
|
+
import stat
|
|
40
|
+
import subprocess
|
|
41
|
+
import tempfile
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
45
|
+
from contextlib import suppress
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any, Protocol, TypeAlias
|
|
49
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
50
|
+
|
|
51
|
+
from ..branding import canonical_user_data_dir, env_get
|
|
52
|
+
from .browser_egress_proxy import BrowserEgressProxy
|
|
53
|
+
from .protocol import redact_secrets
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"BrowserArtifact",
|
|
57
|
+
"BrowserCancelledError",
|
|
58
|
+
"BrowserDependencyError",
|
|
59
|
+
"BrowserExecutable",
|
|
60
|
+
"BrowserLaunchError",
|
|
61
|
+
"BrowserLaunchSpec",
|
|
62
|
+
"BrowserLimitError",
|
|
63
|
+
"BrowserNotFoundError",
|
|
64
|
+
"BrowserOwnershipError",
|
|
65
|
+
"BrowserSecurityError",
|
|
66
|
+
"BrowserSessionStatus",
|
|
67
|
+
"BrowserTimeoutError",
|
|
68
|
+
"BrowserValidationError",
|
|
69
|
+
"CdpTransport",
|
|
70
|
+
"CdpTransportFactory",
|
|
71
|
+
"DefaultBrowserProcessLauncher",
|
|
72
|
+
"DefaultProcessTreeTerminator",
|
|
73
|
+
"ManagedBrowserConfig",
|
|
74
|
+
"ManagedBrowserService",
|
|
75
|
+
"ProcessHandle",
|
|
76
|
+
"discover_browser_executable",
|
|
77
|
+
"validate_browser_url",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
CancelCheck: TypeAlias = Callable[[], bool] | None
|
|
81
|
+
AddressResolver: TypeAlias = Callable[[str, int], Iterable[str]]
|
|
82
|
+
EgressProxyFactory: TypeAlias = Callable[..., BrowserEgressProxy]
|
|
83
|
+
PreviewUrlProvider: TypeAlias = Callable[[], Iterable[str]]
|
|
84
|
+
OwnedPreviewOriginAllowed: TypeAlias = Callable[[str, str, int], bool]
|
|
85
|
+
|
|
86
|
+
_OWNER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$")
|
|
87
|
+
_SESSION_RE = re.compile(r"^[A-Za-z0-9_-]{20,64}$")
|
|
88
|
+
_DEVTOOLS_PATH_RE = re.compile(r"^/devtools/browser/[A-Za-z0-9._-]{1,160}$")
|
|
89
|
+
_SELECTOR_MAX_CHARS = 2_000
|
|
90
|
+
_TYPE_MAX_CHARS = 100_000
|
|
91
|
+
_URL_MAX_CHARS = 8_192
|
|
92
|
+
_ACTIVE_PORT_MAX_BYTES = 1_024
|
|
93
|
+
_MAX_JSON_BYTES_HARD = 8 * 1024 * 1024
|
|
94
|
+
_MAX_SCREENSHOT_BYTES_HARD = 20 * 1024 * 1024
|
|
95
|
+
_MAX_DIAGNOSTIC_EVENTS_HARD = 2_000
|
|
96
|
+
_MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
|
|
97
|
+
_SCREENSHOT_NAME_RE = re.compile(r"^screenshot-[0-9]{4}-[0-9a-f]{8}\.png$")
|
|
98
|
+
_DNS_RESOLUTION_SLOTS = threading.BoundedSemaphore(4)
|
|
99
|
+
_OWNERSHIP_MARKER_VERSION = 3
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BrowserError(RuntimeError):
|
|
103
|
+
"""Base class whose messages are safe for logs and protocol responses."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BrowserValidationError(BrowserError, ValueError):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class BrowserSecurityError(BrowserError):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class BrowserDependencyError(BrowserError):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BrowserNotFoundError(BrowserError):
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class BrowserLaunchError(BrowserError):
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class BrowserTimeoutError(BrowserError, TimeoutError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class BrowserCancelledError(BrowserError):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BrowserLimitError(BrowserError):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class BrowserOwnershipError(BrowserError):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ProcessHandle(Protocol):
|
|
143
|
+
pid: int
|
|
144
|
+
|
|
145
|
+
def poll(self) -> int | None: ...
|
|
146
|
+
|
|
147
|
+
def wait(self, timeout: float | None = None) -> int: ...
|
|
148
|
+
|
|
149
|
+
def terminate(self) -> None: ...
|
|
150
|
+
|
|
151
|
+
def kill(self) -> None: ...
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class BrowserProcessLauncher(Protocol):
|
|
155
|
+
def launch(self, spec: BrowserLaunchSpec) -> ProcessHandle: ...
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ProcessTreeTerminator(Protocol):
|
|
159
|
+
def terminate(self, process: ProcessHandle, *, grace_seconds: float) -> None: ...
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CdpTransport(Protocol):
|
|
163
|
+
"""Bounded transport contract expected from the integration layer.
|
|
164
|
+
|
|
165
|
+
``guarded_navigate`` must use CDP Fetch request interception. It must call
|
|
166
|
+
``authorize_url`` before continuing every top-level, redirect, and
|
|
167
|
+
subresource request. A rejected callback must fail that request and the
|
|
168
|
+
navigation. This is stronger than validating only the initial URL and
|
|
169
|
+
prevents redirect-based access to loopback/private services.
|
|
170
|
+
|
|
171
|
+
Implementations must also cap every inbound WebSocket frame and decoded
|
|
172
|
+
command/event payload at 8 MiB, reject duplicate/unknown response ids, and
|
|
173
|
+
interrupt pending calls on cancellation or timeout. The service applies
|
|
174
|
+
tighter output-specific limits after that transport boundary.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def call(
|
|
178
|
+
self,
|
|
179
|
+
method: str,
|
|
180
|
+
params: Mapping[str, Any] | None = None,
|
|
181
|
+
*,
|
|
182
|
+
session_id: str | None = None,
|
|
183
|
+
timeout: float,
|
|
184
|
+
cancel: CancelCheck = None,
|
|
185
|
+
) -> Mapping[str, Any]: ...
|
|
186
|
+
|
|
187
|
+
def guarded_navigate(
|
|
188
|
+
self,
|
|
189
|
+
url: str,
|
|
190
|
+
*,
|
|
191
|
+
session_id: str,
|
|
192
|
+
authorize_url: Callable[[str], str],
|
|
193
|
+
timeout: float,
|
|
194
|
+
cancel: CancelCheck = None,
|
|
195
|
+
) -> Mapping[str, Any]: ...
|
|
196
|
+
|
|
197
|
+
def drain_events(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
session_id: str,
|
|
201
|
+
max_events: int,
|
|
202
|
+
timeout: float,
|
|
203
|
+
cancel: CancelCheck = None,
|
|
204
|
+
) -> Sequence[Mapping[str, Any]]: ...
|
|
205
|
+
|
|
206
|
+
def close(self, *, timeout: float) -> None: ...
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class CdpTransportFactory(Protocol):
|
|
210
|
+
def connect(
|
|
211
|
+
self,
|
|
212
|
+
websocket_url: str,
|
|
213
|
+
*,
|
|
214
|
+
timeout: float,
|
|
215
|
+
cancel: CancelCheck = None,
|
|
216
|
+
) -> CdpTransport: ...
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True, slots=True)
|
|
220
|
+
class BrowserExecutable:
|
|
221
|
+
path: Path
|
|
222
|
+
product: str
|
|
223
|
+
source: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True, slots=True)
|
|
227
|
+
class BrowserLaunchSpec:
|
|
228
|
+
executable: Path
|
|
229
|
+
arguments: tuple[str, ...]
|
|
230
|
+
environment: Mapping[str, str] = field(repr=False)
|
|
231
|
+
profile_dir: Path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True, slots=True)
|
|
235
|
+
class BrowserArtifact:
|
|
236
|
+
artifact_id: str
|
|
237
|
+
relative_path: str
|
|
238
|
+
media_type: str
|
|
239
|
+
size_bytes: int
|
|
240
|
+
sha256: str
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True, slots=True)
|
|
244
|
+
class BrowserSessionStatus:
|
|
245
|
+
session_id: str
|
|
246
|
+
owner_id: str
|
|
247
|
+
product: str
|
|
248
|
+
state: str
|
|
249
|
+
created_at: float
|
|
250
|
+
allow_local_destinations: bool
|
|
251
|
+
local_destinations_loopback_only: bool
|
|
252
|
+
active_url: str | None
|
|
253
|
+
artifact_count: int
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _default_managed_browser_data_root() -> Path:
|
|
257
|
+
override = str(env_get("ALYSIS_DATA_DIR") or "").strip()
|
|
258
|
+
base = Path(override).expanduser() if override else canonical_user_data_dir()
|
|
259
|
+
return base / "ide-browser"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True, slots=True)
|
|
263
|
+
class ManagedBrowserConfig:
|
|
264
|
+
data_root: Path = field(default_factory=_default_managed_browser_data_root)
|
|
265
|
+
workspace_roots: tuple[Path, ...] = ()
|
|
266
|
+
startup_timeout_seconds: float = 12.0
|
|
267
|
+
operation_timeout_seconds: float = 20.0
|
|
268
|
+
shutdown_grace_seconds: float = 2.0
|
|
269
|
+
dns_timeout_seconds: float = 2.0
|
|
270
|
+
max_sessions_total: int = 3
|
|
271
|
+
max_sessions_per_owner: int = 2
|
|
272
|
+
max_snapshot_bytes: int = 2 * 1024 * 1024
|
|
273
|
+
max_screenshot_bytes: int = 10 * 1024 * 1024
|
|
274
|
+
max_diagnostic_events: int = 500
|
|
275
|
+
|
|
276
|
+
def __post_init__(self) -> None:
|
|
277
|
+
for value, name, upper in (
|
|
278
|
+
(self.startup_timeout_seconds, "startup timeout", 120.0),
|
|
279
|
+
(self.operation_timeout_seconds, "operation timeout", 300.0),
|
|
280
|
+
(self.shutdown_grace_seconds, "shutdown grace", 30.0),
|
|
281
|
+
(self.dns_timeout_seconds, "DNS timeout", 30.0),
|
|
282
|
+
):
|
|
283
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
284
|
+
raise BrowserValidationError(f"Browser {name} must be numeric.")
|
|
285
|
+
if not 0.05 <= float(value) <= upper:
|
|
286
|
+
raise BrowserValidationError(f"Browser {name} is outside its safe range.")
|
|
287
|
+
if not 1 <= self.max_sessions_total <= 16:
|
|
288
|
+
raise BrowserValidationError("Browser total session limit is outside its safe range.")
|
|
289
|
+
if not 1 <= self.max_sessions_per_owner <= self.max_sessions_total:
|
|
290
|
+
raise BrowserValidationError("Browser owner session limit is outside its safe range.")
|
|
291
|
+
if not 1 <= self.max_snapshot_bytes <= _MAX_JSON_BYTES_HARD:
|
|
292
|
+
raise BrowserValidationError("Browser snapshot limit is outside its safe range.")
|
|
293
|
+
if not 1 <= self.max_screenshot_bytes <= _MAX_SCREENSHOT_BYTES_HARD:
|
|
294
|
+
raise BrowserValidationError("Browser screenshot limit is outside its safe range.")
|
|
295
|
+
if not 1 <= self.max_diagnostic_events <= _MAX_DIAGNOSTIC_EVENTS_HARD:
|
|
296
|
+
raise BrowserValidationError("Browser diagnostic limit is outside its safe range.")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass(slots=True)
|
|
300
|
+
class _BrowserSession:
|
|
301
|
+
session_id: str
|
|
302
|
+
owner_id: str
|
|
303
|
+
executable: BrowserExecutable
|
|
304
|
+
process: ProcessHandle
|
|
305
|
+
egress_proxy: BrowserEgressProxy
|
|
306
|
+
transport: CdpTransport
|
|
307
|
+
cdp_session_id: str
|
|
308
|
+
profile_dir: Path
|
|
309
|
+
artifact_dir: Path
|
|
310
|
+
created_at: float
|
|
311
|
+
allow_local_destinations: bool
|
|
312
|
+
local_destinations_loopback_only: bool
|
|
313
|
+
owned_preview_origin_allowed: OwnedPreviewOriginAllowed
|
|
314
|
+
active_url: str | None = None
|
|
315
|
+
artifact_count: int = 0
|
|
316
|
+
operation_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True, slots=True)
|
|
320
|
+
class _OwnershipMarker:
|
|
321
|
+
session_id: str
|
|
322
|
+
owner_pid: int | None
|
|
323
|
+
owner_identity: str | None
|
|
324
|
+
browser_pid: int | None
|
|
325
|
+
browser_identity: str | None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class DefaultBrowserProcessLauncher:
|
|
329
|
+
def launch(self, spec: BrowserLaunchSpec) -> ProcessHandle:
|
|
330
|
+
kwargs: dict[str, Any] = {
|
|
331
|
+
"args": [str(spec.executable), *spec.arguments],
|
|
332
|
+
"cwd": str(spec.profile_dir),
|
|
333
|
+
"env": dict(spec.environment),
|
|
334
|
+
"stdin": subprocess.DEVNULL,
|
|
335
|
+
"stdout": subprocess.DEVNULL,
|
|
336
|
+
"stderr": subprocess.DEVNULL,
|
|
337
|
+
"shell": False,
|
|
338
|
+
"close_fds": True,
|
|
339
|
+
}
|
|
340
|
+
if os.name == "nt":
|
|
341
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
|
|
342
|
+
subprocess, "CREATE_NO_WINDOW", 0
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
kwargs["start_new_session"] = True
|
|
346
|
+
try:
|
|
347
|
+
return subprocess.Popen(**kwargs) # type: ignore[arg-type,return-value]
|
|
348
|
+
except OSError as exc:
|
|
349
|
+
raise BrowserLaunchError("The managed browser process could not be started.") from exc
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class DefaultProcessTreeTerminator:
|
|
353
|
+
"""Terminate only the process group created by our launcher."""
|
|
354
|
+
|
|
355
|
+
def terminate(self, process: ProcessHandle, *, grace_seconds: float) -> None:
|
|
356
|
+
pid = process.pid
|
|
357
|
+
if (
|
|
358
|
+
isinstance(pid, bool)
|
|
359
|
+
or not isinstance(pid, int)
|
|
360
|
+
or pid <= 0
|
|
361
|
+
or pid == os.getpid()
|
|
362
|
+
or process.poll() is not None
|
|
363
|
+
):
|
|
364
|
+
return
|
|
365
|
+
if os.name == "nt":
|
|
366
|
+
taskkill = _windows_taskkill_executable()
|
|
367
|
+
if taskkill is not None:
|
|
368
|
+
with suppress(OSError, subprocess.TimeoutExpired):
|
|
369
|
+
subprocess.run(
|
|
370
|
+
[str(taskkill), "/PID", str(pid), "/T"],
|
|
371
|
+
check=False,
|
|
372
|
+
stdin=subprocess.DEVNULL,
|
|
373
|
+
stdout=subprocess.DEVNULL,
|
|
374
|
+
stderr=subprocess.DEVNULL,
|
|
375
|
+
timeout=grace_seconds,
|
|
376
|
+
shell=False,
|
|
377
|
+
)
|
|
378
|
+
with suppress(OSError, subprocess.TimeoutExpired):
|
|
379
|
+
process.wait(timeout=grace_seconds)
|
|
380
|
+
if process.poll() is None and taskkill is not None:
|
|
381
|
+
with suppress(OSError, subprocess.TimeoutExpired):
|
|
382
|
+
subprocess.run(
|
|
383
|
+
[str(taskkill), "/PID", str(pid), "/T", "/F"],
|
|
384
|
+
check=False,
|
|
385
|
+
stdin=subprocess.DEVNULL,
|
|
386
|
+
stdout=subprocess.DEVNULL,
|
|
387
|
+
stderr=subprocess.DEVNULL,
|
|
388
|
+
timeout=grace_seconds,
|
|
389
|
+
shell=False,
|
|
390
|
+
)
|
|
391
|
+
if process.poll() is None:
|
|
392
|
+
with suppress(OSError):
|
|
393
|
+
process.kill()
|
|
394
|
+
with suppress(OSError, subprocess.TimeoutExpired):
|
|
395
|
+
process.wait(timeout=grace_seconds)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
# ``pid`` is also the pgid because the process was started in a new
|
|
399
|
+
# session. Refuse group signalling if the OS does not confirm that.
|
|
400
|
+
try:
|
|
401
|
+
if os.getpgid(pid) != pid or os.getpgrp() == pid:
|
|
402
|
+
with suppress(OSError):
|
|
403
|
+
process.kill()
|
|
404
|
+
return
|
|
405
|
+
except (OSError, AttributeError):
|
|
406
|
+
with suppress(OSError):
|
|
407
|
+
process.kill()
|
|
408
|
+
return
|
|
409
|
+
try:
|
|
410
|
+
os.killpg(pid, signal.SIGTERM)
|
|
411
|
+
except ProcessLookupError:
|
|
412
|
+
return
|
|
413
|
+
except OSError:
|
|
414
|
+
with suppress(OSError):
|
|
415
|
+
process.terminate()
|
|
416
|
+
with suppress(OSError, subprocess.TimeoutExpired):
|
|
417
|
+
process.wait(timeout=grace_seconds)
|
|
418
|
+
# Descendants can remain after their group leader exits, so always make
|
|
419
|
+
# one final signal to the exact owned pgid.
|
|
420
|
+
with suppress(ProcessLookupError, OSError):
|
|
421
|
+
os.killpg(pid, signal.SIGKILL)
|
|
422
|
+
with suppress(OSError, subprocess.TimeoutExpired):
|
|
423
|
+
process.wait(timeout=grace_seconds)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def discover_browser_executable(
|
|
427
|
+
explicit_path: str | os.PathLike[str] | None = None,
|
|
428
|
+
*,
|
|
429
|
+
platform: str | None = None,
|
|
430
|
+
environment: Mapping[str, str] | None = None,
|
|
431
|
+
) -> BrowserExecutable:
|
|
432
|
+
"""Find Chrome, Edge, or Chromium without executing a PATH-derived name."""
|
|
433
|
+
|
|
434
|
+
if explicit_path is not None:
|
|
435
|
+
raw = Path(explicit_path).expanduser()
|
|
436
|
+
if not raw.is_absolute():
|
|
437
|
+
raise BrowserValidationError("An explicit browser path must be absolute.")
|
|
438
|
+
path = _validated_executable(raw)
|
|
439
|
+
return BrowserExecutable(path=path, product=_product_for_path(path), source="explicit")
|
|
440
|
+
|
|
441
|
+
system = (platform or os.sys.platform).lower()
|
|
442
|
+
env = environment or os.environ
|
|
443
|
+
candidates: list[tuple[str, Path]] = []
|
|
444
|
+
if system.startswith("win"):
|
|
445
|
+
for key in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
|
446
|
+
root_value = _case_insensitive_get(env, key)
|
|
447
|
+
if not root_value:
|
|
448
|
+
continue
|
|
449
|
+
root = Path(root_value)
|
|
450
|
+
candidates.extend(
|
|
451
|
+
(
|
|
452
|
+
("chrome", root / "Google/Chrome/Application/chrome.exe"),
|
|
453
|
+
("edge", root / "Microsoft/Edge/Application/msedge.exe"),
|
|
454
|
+
("chromium", root / "Chromium/Application/chrome.exe"),
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
elif system == "darwin":
|
|
458
|
+
candidates.extend(
|
|
459
|
+
(
|
|
460
|
+
("chrome", Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")),
|
|
461
|
+
("edge", Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge")),
|
|
462
|
+
("chromium", Path("/Applications/Chromium.app/Contents/MacOS/Chromium")),
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
candidates.extend(
|
|
467
|
+
(product, Path(path))
|
|
468
|
+
for product, path in (
|
|
469
|
+
("chrome", "/usr/bin/google-chrome-stable"),
|
|
470
|
+
("chrome", "/usr/bin/google-chrome"),
|
|
471
|
+
("chromium", "/usr/bin/chromium"),
|
|
472
|
+
("chromium", "/usr/bin/chromium-browser"),
|
|
473
|
+
("edge", "/usr/bin/microsoft-edge-stable"),
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
for product, candidate in candidates:
|
|
478
|
+
try:
|
|
479
|
+
path = _validated_executable(candidate)
|
|
480
|
+
except BrowserValidationError:
|
|
481
|
+
continue
|
|
482
|
+
return BrowserExecutable(path=path, product=product, source="known_installation")
|
|
483
|
+
raise BrowserNotFoundError(
|
|
484
|
+
"No supported Chrome, Edge, or Chromium installation was found; configure an absolute path."
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def validate_browser_url(
|
|
489
|
+
value: str,
|
|
490
|
+
*,
|
|
491
|
+
allow_local_destinations: bool = False,
|
|
492
|
+
local_destinations_loopback_only: bool = False,
|
|
493
|
+
resolver: AddressResolver | None = None,
|
|
494
|
+
resolution_timeout: float = 2.0,
|
|
495
|
+
owned_preview_origin_allowed: OwnedPreviewOriginAllowed | None = None,
|
|
496
|
+
) -> str:
|
|
497
|
+
"""Normalize a URL and reject unsafe schemes, userinfo, and destinations."""
|
|
498
|
+
|
|
499
|
+
if not isinstance(value, str) or not value.strip():
|
|
500
|
+
raise BrowserValidationError("Browser URL is required.")
|
|
501
|
+
if (
|
|
502
|
+
isinstance(resolution_timeout, bool)
|
|
503
|
+
or not isinstance(resolution_timeout, int | float)
|
|
504
|
+
or not 0.05 <= resolution_timeout <= 30.0
|
|
505
|
+
):
|
|
506
|
+
raise BrowserValidationError("Browser DNS timeout is invalid.")
|
|
507
|
+
if not isinstance(local_destinations_loopback_only, bool):
|
|
508
|
+
raise BrowserValidationError("Browser loopback-only policy must be a boolean.")
|
|
509
|
+
if local_destinations_loopback_only and not allow_local_destinations:
|
|
510
|
+
raise BrowserValidationError(
|
|
511
|
+
"Browser loopback-only policy requires explicit local-destination access."
|
|
512
|
+
)
|
|
513
|
+
text = value.strip()
|
|
514
|
+
if len(text) > _URL_MAX_CHARS or any(ord(char) < 0x20 for char in text):
|
|
515
|
+
raise BrowserValidationError("Browser URL is invalid or too long.")
|
|
516
|
+
try:
|
|
517
|
+
parsed = urlsplit(text)
|
|
518
|
+
port = parsed.port
|
|
519
|
+
except ValueError as exc:
|
|
520
|
+
raise BrowserValidationError("Browser URL has an invalid host or port.") from exc
|
|
521
|
+
scheme = parsed.scheme.lower()
|
|
522
|
+
if scheme not in {"http", "https"}:
|
|
523
|
+
raise BrowserSecurityError("Only HTTP and HTTPS browser URLs are allowed.")
|
|
524
|
+
if parsed.username is not None or parsed.password is not None:
|
|
525
|
+
raise BrowserSecurityError("Browser URLs containing credentials are not allowed.")
|
|
526
|
+
if not parsed.hostname:
|
|
527
|
+
raise BrowserValidationError("Browser URL host is required.")
|
|
528
|
+
if parsed.fragment and len(parsed.fragment) > 2_000:
|
|
529
|
+
raise BrowserValidationError("Browser URL fragment is too long.")
|
|
530
|
+
host = parsed.hostname.rstrip(".").lower()
|
|
531
|
+
try:
|
|
532
|
+
ascii_host = host.encode("idna").decode("ascii")
|
|
533
|
+
except UnicodeError as exc:
|
|
534
|
+
raise BrowserValidationError("Browser URL host is invalid.") from exc
|
|
535
|
+
effective_port = port or (443 if scheme == "https" else 80)
|
|
536
|
+
owned_preview_allowed = False
|
|
537
|
+
if owned_preview_origin_allowed is not None:
|
|
538
|
+
try:
|
|
539
|
+
owned_preview_allowed = bool(
|
|
540
|
+
owned_preview_origin_allowed(scheme, ascii_host, effective_port)
|
|
541
|
+
)
|
|
542
|
+
except Exception:
|
|
543
|
+
owned_preview_allowed = False
|
|
544
|
+
if not allow_local_destinations and not owned_preview_allowed:
|
|
545
|
+
if ascii_host in {"localhost", "localhost.localdomain"} or ascii_host.endswith(".local"):
|
|
546
|
+
raise BrowserSecurityError("Local and private browser destinations are disabled.")
|
|
547
|
+
addresses = _resolve_addresses(
|
|
548
|
+
ascii_host,
|
|
549
|
+
effective_port,
|
|
550
|
+
resolver=resolver,
|
|
551
|
+
timeout=float(resolution_timeout),
|
|
552
|
+
)
|
|
553
|
+
if not addresses:
|
|
554
|
+
raise BrowserSecurityError("Browser destination could not be resolved safely.")
|
|
555
|
+
if any(not _is_public_address(address) for address in addresses):
|
|
556
|
+
raise BrowserSecurityError("Local and private browser destinations are disabled.")
|
|
557
|
+
elif owned_preview_allowed and not allow_local_destinations:
|
|
558
|
+
addresses = _resolve_addresses(
|
|
559
|
+
ascii_host,
|
|
560
|
+
effective_port,
|
|
561
|
+
resolver=resolver,
|
|
562
|
+
timeout=float(resolution_timeout),
|
|
563
|
+
)
|
|
564
|
+
if not addresses or any(
|
|
565
|
+
not _is_safe_owned_preview_address(address) for address in addresses
|
|
566
|
+
):
|
|
567
|
+
raise BrowserSecurityError("Local and private browser destinations are disabled.")
|
|
568
|
+
elif local_destinations_loopback_only:
|
|
569
|
+
addresses = _resolve_addresses(
|
|
570
|
+
ascii_host,
|
|
571
|
+
effective_port,
|
|
572
|
+
resolver=resolver,
|
|
573
|
+
timeout=float(resolution_timeout),
|
|
574
|
+
)
|
|
575
|
+
if not addresses or any(
|
|
576
|
+
not (_is_public_address(address) or ipaddress.ip_address(address).is_loopback)
|
|
577
|
+
for address in addresses
|
|
578
|
+
):
|
|
579
|
+
raise BrowserSecurityError("Only public and loopback browser destinations are enabled.")
|
|
580
|
+
host_for_url = f"[{ascii_host}]" if ":" in ascii_host else ascii_host
|
|
581
|
+
if port is not None:
|
|
582
|
+
host_for_url = f"{host_for_url}:{port}"
|
|
583
|
+
return urlunsplit((scheme, host_for_url, parsed.path or "/", parsed.query, parsed.fragment))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class ManagedBrowserService:
|
|
587
|
+
def __init__(
|
|
588
|
+
self,
|
|
589
|
+
*,
|
|
590
|
+
config: ManagedBrowserConfig | None = None,
|
|
591
|
+
launcher: BrowserProcessLauncher | None = None,
|
|
592
|
+
terminator: ProcessTreeTerminator | None = None,
|
|
593
|
+
transport_factory: CdpTransportFactory | None = None,
|
|
594
|
+
resolver: AddressResolver | None = None,
|
|
595
|
+
egress_proxy_factory: EgressProxyFactory | None = None,
|
|
596
|
+
clock: Callable[[], float] = time.time,
|
|
597
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
598
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
599
|
+
) -> None:
|
|
600
|
+
self._config = config or ManagedBrowserConfig()
|
|
601
|
+
self._data_root = _prepare_data_root(
|
|
602
|
+
self._config.data_root, workspace_roots=self._config.workspace_roots
|
|
603
|
+
)
|
|
604
|
+
self._profiles_root = _private_dir(self._data_root / "profiles")
|
|
605
|
+
self._artifacts_root = _private_dir(self._data_root / "artifacts")
|
|
606
|
+
self._launcher = launcher or DefaultBrowserProcessLauncher()
|
|
607
|
+
self._terminator = terminator or DefaultProcessTreeTerminator()
|
|
608
|
+
_scavenge_stale_owned_dirs(
|
|
609
|
+
(self._profiles_root, self._artifacts_root),
|
|
610
|
+
grace_seconds=self._config.shutdown_grace_seconds,
|
|
611
|
+
)
|
|
612
|
+
self._transport_factory = transport_factory
|
|
613
|
+
self._resolver = resolver
|
|
614
|
+
self._egress_proxy_factory = egress_proxy_factory or BrowserEgressProxy
|
|
615
|
+
self._clock = clock
|
|
616
|
+
self._monotonic = monotonic
|
|
617
|
+
self._sleep = sleeper
|
|
618
|
+
self._sessions: dict[str, _BrowserSession] = {}
|
|
619
|
+
self._starting_by_owner: dict[str, int] = {}
|
|
620
|
+
self._starting_total = 0
|
|
621
|
+
self._lock = threading.RLock()
|
|
622
|
+
|
|
623
|
+
def start(
|
|
624
|
+
self,
|
|
625
|
+
owner_id: str,
|
|
626
|
+
*,
|
|
627
|
+
executable_path: str | os.PathLike[str] | None = None,
|
|
628
|
+
allow_local_destinations: bool = False,
|
|
629
|
+
local_destinations_loopback_only: bool = False,
|
|
630
|
+
allowed_preview_urls_provider: PreviewUrlProvider | None = None,
|
|
631
|
+
cancel: CancelCheck = None,
|
|
632
|
+
) -> BrowserSessionStatus:
|
|
633
|
+
owner = _validated_owner(owner_id)
|
|
634
|
+
if not isinstance(allow_local_destinations, bool):
|
|
635
|
+
raise BrowserValidationError("Browser local-destination policy must be a boolean.")
|
|
636
|
+
if not isinstance(local_destinations_loopback_only, bool):
|
|
637
|
+
raise BrowserValidationError("Browser loopback-only policy must be a boolean.")
|
|
638
|
+
if local_destinations_loopback_only and not allow_local_destinations:
|
|
639
|
+
raise BrowserValidationError(
|
|
640
|
+
"Browser loopback-only policy requires explicit local-destination access."
|
|
641
|
+
)
|
|
642
|
+
if allowed_preview_urls_provider is not None and not callable(
|
|
643
|
+
allowed_preview_urls_provider
|
|
644
|
+
):
|
|
645
|
+
raise BrowserValidationError("Browser preview URL provider is invalid.")
|
|
646
|
+
if self._transport_factory is None:
|
|
647
|
+
raise BrowserDependencyError(
|
|
648
|
+
"Managed browser automation needs a CDP WebSocket transport with guarded navigation."
|
|
649
|
+
)
|
|
650
|
+
_check_cancel(cancel)
|
|
651
|
+
executable = discover_browser_executable(executable_path)
|
|
652
|
+
self._reserve_start(owner)
|
|
653
|
+
session_id = secrets.token_urlsafe(24).replace("-", "A").replace("_", "B")
|
|
654
|
+
profile_dir: Path | None = None
|
|
655
|
+
artifact_dir: Path | None = None
|
|
656
|
+
process: ProcessHandle | None = None
|
|
657
|
+
egress_proxy: BrowserEgressProxy | None = None
|
|
658
|
+
transport: CdpTransport | None = None
|
|
659
|
+
try:
|
|
660
|
+
owned_preview_origin_allowed = _owned_preview_origin_check(
|
|
661
|
+
allowed_preview_urls_provider
|
|
662
|
+
)
|
|
663
|
+
profile_dir = _make_owned_session_dir(self._profiles_root, session_id)
|
|
664
|
+
artifact_dir = _make_owned_session_dir(self._artifacts_root, session_id)
|
|
665
|
+
egress_proxy = self._egress_proxy_factory(
|
|
666
|
+
resolver=self._resolver,
|
|
667
|
+
allow_local_destinations=bool(allow_local_destinations),
|
|
668
|
+
local_destinations_loopback_only=bool(local_destinations_loopback_only),
|
|
669
|
+
owned_preview_origin_allowed=owned_preview_origin_allowed,
|
|
670
|
+
)
|
|
671
|
+
egress_proxy.start()
|
|
672
|
+
proxy_host, proxy_port = _validated_egress_proxy_endpoint(egress_proxy)
|
|
673
|
+
egress_proxy.deny_endpoint(proxy_host, proxy_port)
|
|
674
|
+
spec = BrowserLaunchSpec(
|
|
675
|
+
executable=executable.path,
|
|
676
|
+
arguments=_browser_arguments(profile_dir, proxy_host, proxy_port),
|
|
677
|
+
environment=_sanitized_browser_environment(),
|
|
678
|
+
profile_dir=profile_dir,
|
|
679
|
+
)
|
|
680
|
+
process = self._launcher.launch(spec)
|
|
681
|
+
_record_owned_browser_process(
|
|
682
|
+
(profile_dir, artifact_dir), session_id=session_id, process=process
|
|
683
|
+
)
|
|
684
|
+
websocket_url = self._wait_for_devtools_endpoint(profile_dir, process, cancel=cancel)
|
|
685
|
+
egress_proxy.deny_endpoint("127.0.0.1", _trusted_devtools_endpoint_port(websocket_url))
|
|
686
|
+
transport = self._transport_factory.connect(
|
|
687
|
+
websocket_url,
|
|
688
|
+
timeout=self._config.operation_timeout_seconds,
|
|
689
|
+
cancel=cancel,
|
|
690
|
+
)
|
|
691
|
+
target = transport.call(
|
|
692
|
+
"Target.createTarget",
|
|
693
|
+
{"url": "about:blank", "newWindow": False, "background": True},
|
|
694
|
+
timeout=self._config.operation_timeout_seconds,
|
|
695
|
+
cancel=cancel,
|
|
696
|
+
)
|
|
697
|
+
target_id = _required_cdp_string(target, "targetId")
|
|
698
|
+
attached = transport.call(
|
|
699
|
+
"Target.attachToTarget",
|
|
700
|
+
{"targetId": target_id, "flatten": True},
|
|
701
|
+
timeout=self._config.operation_timeout_seconds,
|
|
702
|
+
cancel=cancel,
|
|
703
|
+
)
|
|
704
|
+
cdp_session_id = _required_cdp_string(attached, "sessionId")
|
|
705
|
+
for method in ("Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"):
|
|
706
|
+
transport.call(
|
|
707
|
+
method,
|
|
708
|
+
{},
|
|
709
|
+
session_id=cdp_session_id,
|
|
710
|
+
timeout=self._config.operation_timeout_seconds,
|
|
711
|
+
cancel=cancel,
|
|
712
|
+
)
|
|
713
|
+
if not egress_proxy.healthy:
|
|
714
|
+
raise BrowserLaunchError(
|
|
715
|
+
"The managed browser egress proxy stopped during session startup."
|
|
716
|
+
)
|
|
717
|
+
session = _BrowserSession(
|
|
718
|
+
session_id=session_id,
|
|
719
|
+
owner_id=owner,
|
|
720
|
+
executable=executable,
|
|
721
|
+
process=process,
|
|
722
|
+
egress_proxy=egress_proxy,
|
|
723
|
+
transport=transport,
|
|
724
|
+
cdp_session_id=cdp_session_id,
|
|
725
|
+
profile_dir=profile_dir,
|
|
726
|
+
artifact_dir=artifact_dir,
|
|
727
|
+
created_at=self._clock(),
|
|
728
|
+
allow_local_destinations=bool(allow_local_destinations),
|
|
729
|
+
local_destinations_loopback_only=bool(local_destinations_loopback_only),
|
|
730
|
+
owned_preview_origin_allowed=owned_preview_origin_allowed,
|
|
731
|
+
)
|
|
732
|
+
with self._lock:
|
|
733
|
+
self._sessions[session_id] = session
|
|
734
|
+
return self._status(session)
|
|
735
|
+
except BrowserError:
|
|
736
|
+
raise
|
|
737
|
+
except Exception as exc:
|
|
738
|
+
raise BrowserLaunchError(
|
|
739
|
+
"The managed browser session could not be initialized."
|
|
740
|
+
) from exc
|
|
741
|
+
finally:
|
|
742
|
+
succeeded = session_id in self._sessions
|
|
743
|
+
self._release_start(owner)
|
|
744
|
+
if not succeeded:
|
|
745
|
+
cleanup_failed = False
|
|
746
|
+
if egress_proxy is not None:
|
|
747
|
+
try:
|
|
748
|
+
egress_proxy.close(timeout=self._config.shutdown_grace_seconds)
|
|
749
|
+
except Exception:
|
|
750
|
+
cleanup_failed = True
|
|
751
|
+
if transport is not None:
|
|
752
|
+
try:
|
|
753
|
+
transport.close(timeout=self._config.shutdown_grace_seconds)
|
|
754
|
+
except Exception:
|
|
755
|
+
cleanup_failed = True
|
|
756
|
+
if process is not None:
|
|
757
|
+
try:
|
|
758
|
+
self._terminator.terminate(
|
|
759
|
+
process, grace_seconds=self._config.shutdown_grace_seconds
|
|
760
|
+
)
|
|
761
|
+
except Exception:
|
|
762
|
+
cleanup_failed = True
|
|
763
|
+
if profile_dir is not None:
|
|
764
|
+
cleanup_failed = (
|
|
765
|
+
not _remove_owned_session_dir(self._profiles_root, profile_dir, session_id)
|
|
766
|
+
or cleanup_failed
|
|
767
|
+
)
|
|
768
|
+
if artifact_dir is not None:
|
|
769
|
+
cleanup_failed = (
|
|
770
|
+
not _remove_owned_session_dir(
|
|
771
|
+
self._artifacts_root, artifact_dir, session_id
|
|
772
|
+
)
|
|
773
|
+
or cleanup_failed
|
|
774
|
+
)
|
|
775
|
+
if cleanup_failed:
|
|
776
|
+
raise BrowserLaunchError(
|
|
777
|
+
"Browser startup failed and owned resource cleanup is incomplete."
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def navigate(
|
|
781
|
+
self,
|
|
782
|
+
owner_id: str,
|
|
783
|
+
session_id: str,
|
|
784
|
+
url: str,
|
|
785
|
+
*,
|
|
786
|
+
timeout: float | None = None,
|
|
787
|
+
cancel: CancelCheck = None,
|
|
788
|
+
) -> dict[str, Any]:
|
|
789
|
+
session = self._owned_session(owner_id, session_id)
|
|
790
|
+
with self._operation(session, cancel=cancel):
|
|
791
|
+
deadline = _bounded_timeout(timeout, self._config.operation_timeout_seconds)
|
|
792
|
+
|
|
793
|
+
def authorize(candidate: str) -> str:
|
|
794
|
+
return validate_browser_url(
|
|
795
|
+
candidate,
|
|
796
|
+
allow_local_destinations=session.allow_local_destinations,
|
|
797
|
+
local_destinations_loopback_only=(session.local_destinations_loopback_only),
|
|
798
|
+
resolver=self._resolver,
|
|
799
|
+
resolution_timeout=self._config.dns_timeout_seconds,
|
|
800
|
+
owned_preview_origin_allowed=session.owned_preview_origin_allowed,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
normalized = authorize(url)
|
|
804
|
+
result = session.transport.guarded_navigate(
|
|
805
|
+
normalized,
|
|
806
|
+
session_id=session.cdp_session_id,
|
|
807
|
+
authorize_url=authorize,
|
|
808
|
+
timeout=deadline,
|
|
809
|
+
cancel=cancel,
|
|
810
|
+
)
|
|
811
|
+
session.active_url = normalized
|
|
812
|
+
return {
|
|
813
|
+
"session_id": session.session_id,
|
|
814
|
+
"url": normalized,
|
|
815
|
+
"result": _bounded_payload(result, self._config.max_snapshot_bytes),
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
def snapshot(
|
|
819
|
+
self,
|
|
820
|
+
owner_id: str,
|
|
821
|
+
session_id: str,
|
|
822
|
+
*,
|
|
823
|
+
kind: str = "semantic",
|
|
824
|
+
timeout: float | None = None,
|
|
825
|
+
cancel: CancelCheck = None,
|
|
826
|
+
) -> dict[str, Any]:
|
|
827
|
+
session = self._owned_session(owner_id, session_id)
|
|
828
|
+
normalized_kind = str(kind).strip().lower()
|
|
829
|
+
if normalized_kind not in {"semantic", "accessibility", "dom", "text"}:
|
|
830
|
+
raise BrowserValidationError("Browser snapshot kind is invalid.")
|
|
831
|
+
with self._operation(session, cancel=cancel):
|
|
832
|
+
call_timeout = _bounded_timeout(timeout, self._config.operation_timeout_seconds)
|
|
833
|
+
if normalized_kind == "text":
|
|
834
|
+
raw = session.transport.call(
|
|
835
|
+
"Runtime.evaluate",
|
|
836
|
+
{
|
|
837
|
+
"expression": "document.body ? document.body.innerText : ''",
|
|
838
|
+
"returnByValue": True,
|
|
839
|
+
"awaitPromise": False,
|
|
840
|
+
},
|
|
841
|
+
session_id=session.cdp_session_id,
|
|
842
|
+
timeout=call_timeout,
|
|
843
|
+
cancel=cancel,
|
|
844
|
+
)
|
|
845
|
+
value = raw.get("result")
|
|
846
|
+
if isinstance(value, Mapping):
|
|
847
|
+
value = value.get("value", "")
|
|
848
|
+
return {
|
|
849
|
+
"session_id": session.session_id,
|
|
850
|
+
"kind": "text",
|
|
851
|
+
**_bounded_text(str(value or ""), self._config.max_snapshot_bytes),
|
|
852
|
+
}
|
|
853
|
+
if normalized_kind == "dom":
|
|
854
|
+
raw = session.transport.call(
|
|
855
|
+
"DOMSnapshot.captureSnapshot",
|
|
856
|
+
{
|
|
857
|
+
"computedStyles": [],
|
|
858
|
+
"includePaintOrder": False,
|
|
859
|
+
"includeDOMRects": False,
|
|
860
|
+
"includeBlendedBackgroundColors": False,
|
|
861
|
+
"includeTextColorOpacities": False,
|
|
862
|
+
},
|
|
863
|
+
session_id=session.cdp_session_id,
|
|
864
|
+
timeout=call_timeout,
|
|
865
|
+
cancel=cancel,
|
|
866
|
+
)
|
|
867
|
+
else:
|
|
868
|
+
raw = session.transport.call(
|
|
869
|
+
"Accessibility.getFullAXTree",
|
|
870
|
+
{"depth": 32},
|
|
871
|
+
session_id=session.cdp_session_id,
|
|
872
|
+
timeout=call_timeout,
|
|
873
|
+
cancel=cancel,
|
|
874
|
+
)
|
|
875
|
+
return {
|
|
876
|
+
"session_id": session.session_id,
|
|
877
|
+
"kind": normalized_kind,
|
|
878
|
+
**_bounded_payload(raw, self._config.max_snapshot_bytes),
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
def screenshot(
|
|
882
|
+
self,
|
|
883
|
+
owner_id: str,
|
|
884
|
+
session_id: str,
|
|
885
|
+
*,
|
|
886
|
+
full_page: bool = False,
|
|
887
|
+
timeout: float | None = None,
|
|
888
|
+
cancel: CancelCheck = None,
|
|
889
|
+
) -> BrowserArtifact:
|
|
890
|
+
session = self._owned_session(owner_id, session_id)
|
|
891
|
+
with self._operation(session, cancel=cancel):
|
|
892
|
+
params: dict[str, Any] = {
|
|
893
|
+
"format": "png",
|
|
894
|
+
"fromSurface": True,
|
|
895
|
+
"captureBeyondViewport": bool(full_page),
|
|
896
|
+
}
|
|
897
|
+
raw = session.transport.call(
|
|
898
|
+
"Page.captureScreenshot",
|
|
899
|
+
params,
|
|
900
|
+
session_id=session.cdp_session_id,
|
|
901
|
+
timeout=_bounded_timeout(timeout, self._config.operation_timeout_seconds),
|
|
902
|
+
cancel=cancel,
|
|
903
|
+
)
|
|
904
|
+
encoded = raw.get("data")
|
|
905
|
+
if not isinstance(encoded, str):
|
|
906
|
+
raise BrowserSecurityError("Browser screenshot response was invalid.")
|
|
907
|
+
estimated = (len(encoded) * 3) // 4
|
|
908
|
+
if estimated > self._config.max_screenshot_bytes + 4:
|
|
909
|
+
raise BrowserSecurityError("Browser screenshot exceeded the configured limit.")
|
|
910
|
+
try:
|
|
911
|
+
payload = base64.b64decode(encoded, validate=True)
|
|
912
|
+
except (ValueError, binascii.Error) as exc:
|
|
913
|
+
raise BrowserSecurityError("Browser screenshot response was invalid.") from exc
|
|
914
|
+
if not payload.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
915
|
+
raise BrowserSecurityError("Browser screenshot was not a PNG image.")
|
|
916
|
+
if len(payload) > self._config.max_screenshot_bytes:
|
|
917
|
+
raise BrowserSecurityError("Browser screenshot exceeded the configured limit.")
|
|
918
|
+
filename = f"screenshot-{session.artifact_count + 1:04d}-{secrets.token_hex(4)}.png"
|
|
919
|
+
artifact_dir = _assert_owned_session_dir(
|
|
920
|
+
self._artifacts_root, session.artifact_dir, session.session_id
|
|
921
|
+
)
|
|
922
|
+
path = _safe_artifact_path(artifact_dir, filename)
|
|
923
|
+
_write_private_file(path, payload)
|
|
924
|
+
session.artifact_count += 1
|
|
925
|
+
return BrowserArtifact(
|
|
926
|
+
artifact_id=f"browser:{session.session_id}:{filename}",
|
|
927
|
+
relative_path=f"{artifact_dir.name}/{filename}",
|
|
928
|
+
media_type="image/png",
|
|
929
|
+
size_bytes=len(payload),
|
|
930
|
+
sha256=hashlib.sha256(payload).hexdigest(),
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def diagnostics(
|
|
934
|
+
self,
|
|
935
|
+
owner_id: str,
|
|
936
|
+
session_id: str,
|
|
937
|
+
*,
|
|
938
|
+
max_events: int | None = None,
|
|
939
|
+
timeout: float | None = None,
|
|
940
|
+
cancel: CancelCheck = None,
|
|
941
|
+
) -> dict[str, Any]:
|
|
942
|
+
session = self._owned_session(owner_id, session_id)
|
|
943
|
+
if max_events is None:
|
|
944
|
+
limit = self._config.max_diagnostic_events
|
|
945
|
+
elif isinstance(max_events, bool) or not isinstance(max_events, int):
|
|
946
|
+
raise BrowserValidationError("Browser diagnostic event limit is invalid.")
|
|
947
|
+
else:
|
|
948
|
+
limit = max_events
|
|
949
|
+
if not 1 <= limit <= self._config.max_diagnostic_events:
|
|
950
|
+
raise BrowserValidationError("Browser diagnostic event limit is invalid.")
|
|
951
|
+
with self._operation(session, cancel=cancel):
|
|
952
|
+
events = session.transport.drain_events(
|
|
953
|
+
session_id=session.cdp_session_id,
|
|
954
|
+
max_events=limit + 1,
|
|
955
|
+
timeout=_bounded_timeout(timeout, self._config.operation_timeout_seconds),
|
|
956
|
+
cancel=cancel,
|
|
957
|
+
)
|
|
958
|
+
allowed = {
|
|
959
|
+
"Runtime.consoleAPICalled": "console",
|
|
960
|
+
"Log.entryAdded": "console",
|
|
961
|
+
"Network.loadingFailed": "network",
|
|
962
|
+
"Network.responseReceived": "network",
|
|
963
|
+
}
|
|
964
|
+
output: list[dict[str, Any]] = []
|
|
965
|
+
for event in events:
|
|
966
|
+
method = str(event.get("method") or "")
|
|
967
|
+
category = allowed.get(method)
|
|
968
|
+
if category is None:
|
|
969
|
+
continue
|
|
970
|
+
output.append(
|
|
971
|
+
{
|
|
972
|
+
"category": category,
|
|
973
|
+
"method": method,
|
|
974
|
+
"params": _bounded_payload(
|
|
975
|
+
_sanitize_diagnostic_params(method, event.get("params", {})),
|
|
976
|
+
min(64 * 1024, self._config.max_snapshot_bytes),
|
|
977
|
+
),
|
|
978
|
+
}
|
|
979
|
+
)
|
|
980
|
+
if len(output) >= limit:
|
|
981
|
+
break
|
|
982
|
+
return {
|
|
983
|
+
"session_id": session.session_id,
|
|
984
|
+
"events": output,
|
|
985
|
+
"truncated": len(events) > limit,
|
|
986
|
+
"max_events": limit,
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
def read_artifact(
|
|
990
|
+
self,
|
|
991
|
+
owner_id: str,
|
|
992
|
+
session_id: str,
|
|
993
|
+
artifact_id: str,
|
|
994
|
+
*,
|
|
995
|
+
offset: int = 0,
|
|
996
|
+
max_bytes: int = 256 * 1024,
|
|
997
|
+
) -> dict[str, Any]:
|
|
998
|
+
session = self._owned_session(owner_id, session_id)
|
|
999
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1000
|
+
raise BrowserValidationError("Browser artifact offset is invalid.")
|
|
1001
|
+
if (
|
|
1002
|
+
isinstance(max_bytes, bool)
|
|
1003
|
+
or not isinstance(max_bytes, int)
|
|
1004
|
+
or not 1 <= max_bytes <= _MAX_ARTIFACT_CHUNK_BYTES
|
|
1005
|
+
):
|
|
1006
|
+
raise BrowserValidationError("Browser artifact chunk size is invalid.")
|
|
1007
|
+
parts = str(artifact_id).split(":", 2)
|
|
1008
|
+
if (
|
|
1009
|
+
len(parts) != 3
|
|
1010
|
+
or parts[0] != "browser"
|
|
1011
|
+
or parts[1] != session.session_id
|
|
1012
|
+
or _SCREENSHOT_NAME_RE.fullmatch(parts[2]) is None
|
|
1013
|
+
):
|
|
1014
|
+
raise BrowserValidationError("Browser artifact id is invalid.")
|
|
1015
|
+
artifact_dir = _assert_owned_session_dir(
|
|
1016
|
+
self._artifacts_root, session.artifact_dir, session.session_id
|
|
1017
|
+
)
|
|
1018
|
+
path = _safe_artifact_path(artifact_dir, parts[2])
|
|
1019
|
+
try:
|
|
1020
|
+
size = path.stat().st_size
|
|
1021
|
+
if not path.is_file() or path.is_symlink() or size > self._config.max_screenshot_bytes:
|
|
1022
|
+
raise BrowserSecurityError("Browser artifact is not a trusted screenshot.")
|
|
1023
|
+
if offset > size:
|
|
1024
|
+
raise BrowserValidationError("Browser artifact offset exceeds its size.")
|
|
1025
|
+
with path.open("rb") as handle:
|
|
1026
|
+
handle.seek(offset)
|
|
1027
|
+
payload = handle.read(max_bytes)
|
|
1028
|
+
except BrowserError:
|
|
1029
|
+
raise
|
|
1030
|
+
except OSError as exc:
|
|
1031
|
+
raise BrowserNotFoundError("Browser artifact was not found.") from exc
|
|
1032
|
+
next_offset = offset + len(payload)
|
|
1033
|
+
return {
|
|
1034
|
+
"artifact_id": artifact_id,
|
|
1035
|
+
"media_type": "image/png",
|
|
1036
|
+
"encoding": "base64",
|
|
1037
|
+
"content": base64.b64encode(payload).decode("ascii"),
|
|
1038
|
+
"offset": offset,
|
|
1039
|
+
"next_offset": next_offset,
|
|
1040
|
+
"size_bytes": size,
|
|
1041
|
+
"truncated": next_offset < size,
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
def click(
|
|
1045
|
+
self,
|
|
1046
|
+
owner_id: str,
|
|
1047
|
+
session_id: str,
|
|
1048
|
+
selector: str,
|
|
1049
|
+
*,
|
|
1050
|
+
timeout: float | None = None,
|
|
1051
|
+
cancel: CancelCheck = None,
|
|
1052
|
+
) -> dict[str, Any]:
|
|
1053
|
+
session = self._owned_session(owner_id, session_id)
|
|
1054
|
+
selector_value = _validated_selector(selector)
|
|
1055
|
+
with self._operation(session, cancel=cancel):
|
|
1056
|
+
call_timeout = _bounded_timeout(timeout, self._config.operation_timeout_seconds)
|
|
1057
|
+
node_id = self._query_node(session, selector_value, call_timeout, cancel)
|
|
1058
|
+
model = session.transport.call(
|
|
1059
|
+
"DOM.getBoxModel",
|
|
1060
|
+
{"nodeId": node_id},
|
|
1061
|
+
session_id=session.cdp_session_id,
|
|
1062
|
+
timeout=call_timeout,
|
|
1063
|
+
cancel=cancel,
|
|
1064
|
+
)
|
|
1065
|
+
x, y = _box_center(model)
|
|
1066
|
+
for event_type, button in (("mousePressed", "left"), ("mouseReleased", "left")):
|
|
1067
|
+
session.transport.call(
|
|
1068
|
+
"Input.dispatchMouseEvent",
|
|
1069
|
+
{"type": event_type, "x": x, "y": y, "button": button, "clickCount": 1},
|
|
1070
|
+
session_id=session.cdp_session_id,
|
|
1071
|
+
timeout=call_timeout,
|
|
1072
|
+
cancel=cancel,
|
|
1073
|
+
)
|
|
1074
|
+
return {"session_id": session.session_id, "clicked": True}
|
|
1075
|
+
|
|
1076
|
+
def type_text(
|
|
1077
|
+
self,
|
|
1078
|
+
owner_id: str,
|
|
1079
|
+
session_id: str,
|
|
1080
|
+
selector: str,
|
|
1081
|
+
text: str,
|
|
1082
|
+
*,
|
|
1083
|
+
replace: bool = True,
|
|
1084
|
+
timeout: float | None = None,
|
|
1085
|
+
cancel: CancelCheck = None,
|
|
1086
|
+
) -> dict[str, Any]:
|
|
1087
|
+
session = self._owned_session(owner_id, session_id)
|
|
1088
|
+
selector_value = _validated_selector(selector)
|
|
1089
|
+
if not isinstance(text, str) or len(text) > _TYPE_MAX_CHARS:
|
|
1090
|
+
raise BrowserValidationError("Browser input text is invalid or too long.")
|
|
1091
|
+
with self._operation(session, cancel=cancel):
|
|
1092
|
+
call_timeout = _bounded_timeout(timeout, self._config.operation_timeout_seconds)
|
|
1093
|
+
node_id = self._query_node(session, selector_value, call_timeout, cancel)
|
|
1094
|
+
session.transport.call(
|
|
1095
|
+
"DOM.focus",
|
|
1096
|
+
{"nodeId": node_id},
|
|
1097
|
+
session_id=session.cdp_session_id,
|
|
1098
|
+
timeout=call_timeout,
|
|
1099
|
+
cancel=cancel,
|
|
1100
|
+
)
|
|
1101
|
+
if replace:
|
|
1102
|
+
modifier = 4 if os.sys.platform == "darwin" else 2 # Meta or Control CDP mask.
|
|
1103
|
+
session.transport.call(
|
|
1104
|
+
"Input.dispatchKeyEvent",
|
|
1105
|
+
{"type": "keyDown", "key": "a", "code": "KeyA", "modifiers": modifier},
|
|
1106
|
+
session_id=session.cdp_session_id,
|
|
1107
|
+
timeout=call_timeout,
|
|
1108
|
+
cancel=cancel,
|
|
1109
|
+
)
|
|
1110
|
+
session.transport.call(
|
|
1111
|
+
"Input.dispatchKeyEvent",
|
|
1112
|
+
{"type": "keyUp", "key": "a", "code": "KeyA", "modifiers": modifier},
|
|
1113
|
+
session_id=session.cdp_session_id,
|
|
1114
|
+
timeout=call_timeout,
|
|
1115
|
+
cancel=cancel,
|
|
1116
|
+
)
|
|
1117
|
+
session.transport.call(
|
|
1118
|
+
"Input.dispatchKeyEvent",
|
|
1119
|
+
{"type": "keyDown", "key": "Backspace", "code": "Backspace"},
|
|
1120
|
+
session_id=session.cdp_session_id,
|
|
1121
|
+
timeout=call_timeout,
|
|
1122
|
+
cancel=cancel,
|
|
1123
|
+
)
|
|
1124
|
+
session.transport.call(
|
|
1125
|
+
"Input.dispatchKeyEvent",
|
|
1126
|
+
{"type": "keyUp", "key": "Backspace", "code": "Backspace"},
|
|
1127
|
+
session_id=session.cdp_session_id,
|
|
1128
|
+
timeout=call_timeout,
|
|
1129
|
+
cancel=cancel,
|
|
1130
|
+
)
|
|
1131
|
+
session.transport.call(
|
|
1132
|
+
"Input.insertText",
|
|
1133
|
+
{"text": text},
|
|
1134
|
+
session_id=session.cdp_session_id,
|
|
1135
|
+
timeout=call_timeout,
|
|
1136
|
+
cancel=cancel,
|
|
1137
|
+
)
|
|
1138
|
+
return {
|
|
1139
|
+
"session_id": session.session_id,
|
|
1140
|
+
"typed": True,
|
|
1141
|
+
"character_count": len(text),
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
def status(self, owner_id: str, session_id: str) -> BrowserSessionStatus:
|
|
1145
|
+
return self._status(self._owned_session(owner_id, session_id))
|
|
1146
|
+
|
|
1147
|
+
def list(self, owner_id: str) -> tuple[BrowserSessionStatus, ...]:
|
|
1148
|
+
owner = _validated_owner(owner_id)
|
|
1149
|
+
with self._lock:
|
|
1150
|
+
sessions = [session for session in self._sessions.values() if session.owner_id == owner]
|
|
1151
|
+
return tuple(
|
|
1152
|
+
sorted(
|
|
1153
|
+
(self._status(session) for session in sessions), key=lambda item: item.created_at
|
|
1154
|
+
)
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
def close(self, owner_id: str, session_id: str, *, delete_artifacts: bool = True) -> bool:
|
|
1158
|
+
owner = _validated_owner(owner_id)
|
|
1159
|
+
sid = _validated_session_id(session_id)
|
|
1160
|
+
with self._lock:
|
|
1161
|
+
session = self._sessions.get(sid)
|
|
1162
|
+
if session is None:
|
|
1163
|
+
return False
|
|
1164
|
+
if session.owner_id != owner:
|
|
1165
|
+
raise BrowserOwnershipError("Browser session is not owned by this caller.")
|
|
1166
|
+
if not session.operation_lock.acquire(blocking=False):
|
|
1167
|
+
raise BrowserLimitError("Browser session has an active operation and cannot close yet.")
|
|
1168
|
+
try:
|
|
1169
|
+
cleanup_failed = False
|
|
1170
|
+
try:
|
|
1171
|
+
session.egress_proxy.close(timeout=self._config.shutdown_grace_seconds)
|
|
1172
|
+
except Exception:
|
|
1173
|
+
cleanup_failed = True
|
|
1174
|
+
try:
|
|
1175
|
+
session.transport.close(timeout=self._config.shutdown_grace_seconds)
|
|
1176
|
+
except Exception:
|
|
1177
|
+
cleanup_failed = True
|
|
1178
|
+
try:
|
|
1179
|
+
self._terminator.terminate(
|
|
1180
|
+
session.process, grace_seconds=self._config.shutdown_grace_seconds
|
|
1181
|
+
)
|
|
1182
|
+
except Exception:
|
|
1183
|
+
cleanup_failed = True
|
|
1184
|
+
profile_removed = _remove_owned_session_dir(
|
|
1185
|
+
self._profiles_root, session.profile_dir, sid
|
|
1186
|
+
)
|
|
1187
|
+
artifacts_removed = (
|
|
1188
|
+
_remove_owned_session_dir(self._artifacts_root, session.artifact_dir, sid)
|
|
1189
|
+
if delete_artifacts
|
|
1190
|
+
else True
|
|
1191
|
+
)
|
|
1192
|
+
if cleanup_failed or not profile_removed or not artifacts_removed:
|
|
1193
|
+
raise BrowserLaunchError(
|
|
1194
|
+
"Browser stopped, but owned session data cleanup is incomplete; retry close."
|
|
1195
|
+
)
|
|
1196
|
+
with self._lock:
|
|
1197
|
+
if self._sessions.get(sid) is session:
|
|
1198
|
+
self._sessions.pop(sid)
|
|
1199
|
+
return True
|
|
1200
|
+
finally:
|
|
1201
|
+
session.operation_lock.release()
|
|
1202
|
+
|
|
1203
|
+
def close_all(self) -> None:
|
|
1204
|
+
with self._lock:
|
|
1205
|
+
sessions = list(self._sessions.values())
|
|
1206
|
+
cleanup_failed = False
|
|
1207
|
+
for session in sessions:
|
|
1208
|
+
session_failed = False
|
|
1209
|
+
try:
|
|
1210
|
+
session.egress_proxy.close(timeout=self._config.shutdown_grace_seconds)
|
|
1211
|
+
except Exception:
|
|
1212
|
+
session_failed = True
|
|
1213
|
+
try:
|
|
1214
|
+
session.transport.close(timeout=self._config.shutdown_grace_seconds)
|
|
1215
|
+
except Exception:
|
|
1216
|
+
session_failed = True
|
|
1217
|
+
try:
|
|
1218
|
+
self._terminator.terminate(
|
|
1219
|
+
session.process, grace_seconds=self._config.shutdown_grace_seconds
|
|
1220
|
+
)
|
|
1221
|
+
except Exception:
|
|
1222
|
+
session_failed = True
|
|
1223
|
+
profile_removed = _remove_owned_session_dir(
|
|
1224
|
+
self._profiles_root, session.profile_dir, session.session_id
|
|
1225
|
+
)
|
|
1226
|
+
artifacts_removed = _remove_owned_session_dir(
|
|
1227
|
+
self._artifacts_root, session.artifact_dir, session.session_id
|
|
1228
|
+
)
|
|
1229
|
+
if session_failed or not profile_removed or not artifacts_removed:
|
|
1230
|
+
cleanup_failed = True
|
|
1231
|
+
else:
|
|
1232
|
+
with self._lock:
|
|
1233
|
+
if self._sessions.get(session.session_id) is session:
|
|
1234
|
+
self._sessions.pop(session.session_id)
|
|
1235
|
+
if cleanup_failed:
|
|
1236
|
+
raise BrowserLaunchError(
|
|
1237
|
+
"One or more browser sessions did not close cleanly; retry shutdown cleanup."
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
def _reserve_start(self, owner: str) -> None:
|
|
1241
|
+
with self._lock:
|
|
1242
|
+
owned = sum(1 for item in self._sessions.values() if item.owner_id == owner)
|
|
1243
|
+
owner_starting = self._starting_by_owner.get(owner, 0)
|
|
1244
|
+
if len(self._sessions) + self._starting_total >= self._config.max_sessions_total:
|
|
1245
|
+
raise BrowserLimitError("The managed browser session limit has been reached.")
|
|
1246
|
+
if owned + owner_starting >= self._config.max_sessions_per_owner:
|
|
1247
|
+
raise BrowserLimitError("The managed browser owner session limit has been reached.")
|
|
1248
|
+
self._starting_total += 1
|
|
1249
|
+
self._starting_by_owner[owner] = owner_starting + 1
|
|
1250
|
+
|
|
1251
|
+
def _release_start(self, owner: str) -> None:
|
|
1252
|
+
with self._lock:
|
|
1253
|
+
self._starting_total = max(0, self._starting_total - 1)
|
|
1254
|
+
remaining = self._starting_by_owner.get(owner, 1) - 1
|
|
1255
|
+
if remaining <= 0:
|
|
1256
|
+
self._starting_by_owner.pop(owner, None)
|
|
1257
|
+
else:
|
|
1258
|
+
self._starting_by_owner[owner] = remaining
|
|
1259
|
+
|
|
1260
|
+
def _wait_for_devtools_endpoint(
|
|
1261
|
+
self, profile_dir: Path, process: ProcessHandle, *, cancel: CancelCheck
|
|
1262
|
+
) -> str:
|
|
1263
|
+
deadline = self._monotonic() + self._config.startup_timeout_seconds
|
|
1264
|
+
active_port = profile_dir / "DevToolsActivePort"
|
|
1265
|
+
while self._monotonic() < deadline:
|
|
1266
|
+
_check_cancel(cancel)
|
|
1267
|
+
exit_code = process.poll()
|
|
1268
|
+
if exit_code is not None:
|
|
1269
|
+
raise BrowserLaunchError("The managed browser exited before DevTools was ready.")
|
|
1270
|
+
parsed = _read_active_port(active_port, profile_dir)
|
|
1271
|
+
if parsed is not None:
|
|
1272
|
+
port, browser_path = parsed
|
|
1273
|
+
return f"ws://127.0.0.1:{port}{browser_path}"
|
|
1274
|
+
self._sleep(min(0.05, max(0.0, deadline - self._monotonic())))
|
|
1275
|
+
raise BrowserTimeoutError("The managed browser did not become ready in time.")
|
|
1276
|
+
|
|
1277
|
+
def _owned_session(self, owner_id: str, session_id: str) -> _BrowserSession:
|
|
1278
|
+
owner = _validated_owner(owner_id)
|
|
1279
|
+
sid = _validated_session_id(session_id)
|
|
1280
|
+
with self._lock:
|
|
1281
|
+
session = self._sessions.get(sid)
|
|
1282
|
+
if session is None:
|
|
1283
|
+
raise BrowserNotFoundError("Browser session was not found.")
|
|
1284
|
+
if session.owner_id != owner:
|
|
1285
|
+
raise BrowserOwnershipError("Browser session is not owned by this caller.")
|
|
1286
|
+
return session
|
|
1287
|
+
|
|
1288
|
+
def _operation(self, session: _BrowserSession, *, cancel: CancelCheck) -> _OperationGuard:
|
|
1289
|
+
return _OperationGuard(session, cancel=cancel)
|
|
1290
|
+
|
|
1291
|
+
def _status(self, session: _BrowserSession) -> BrowserSessionStatus:
|
|
1292
|
+
state = (
|
|
1293
|
+
"running"
|
|
1294
|
+
if session.process.poll() is None and session.egress_proxy.healthy
|
|
1295
|
+
else "crashed"
|
|
1296
|
+
)
|
|
1297
|
+
return BrowserSessionStatus(
|
|
1298
|
+
session_id=session.session_id,
|
|
1299
|
+
owner_id=session.owner_id,
|
|
1300
|
+
product=session.executable.product,
|
|
1301
|
+
state=state,
|
|
1302
|
+
created_at=session.created_at,
|
|
1303
|
+
allow_local_destinations=session.allow_local_destinations,
|
|
1304
|
+
local_destinations_loopback_only=session.local_destinations_loopback_only,
|
|
1305
|
+
active_url=session.active_url,
|
|
1306
|
+
artifact_count=session.artifact_count,
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
def _query_node(
|
|
1310
|
+
self,
|
|
1311
|
+
session: _BrowserSession,
|
|
1312
|
+
selector: str,
|
|
1313
|
+
timeout: float,
|
|
1314
|
+
cancel: CancelCheck,
|
|
1315
|
+
) -> int:
|
|
1316
|
+
document = session.transport.call(
|
|
1317
|
+
"DOM.getDocument",
|
|
1318
|
+
{"depth": 0, "pierce": False},
|
|
1319
|
+
session_id=session.cdp_session_id,
|
|
1320
|
+
timeout=timeout,
|
|
1321
|
+
cancel=cancel,
|
|
1322
|
+
)
|
|
1323
|
+
root = document.get("root")
|
|
1324
|
+
if not isinstance(root, Mapping):
|
|
1325
|
+
raise BrowserSecurityError("Browser DOM response was invalid.")
|
|
1326
|
+
root_id = _required_cdp_int(root, "nodeId")
|
|
1327
|
+
matched = session.transport.call(
|
|
1328
|
+
"DOM.querySelector",
|
|
1329
|
+
{"nodeId": root_id, "selector": selector},
|
|
1330
|
+
session_id=session.cdp_session_id,
|
|
1331
|
+
timeout=timeout,
|
|
1332
|
+
cancel=cancel,
|
|
1333
|
+
)
|
|
1334
|
+
node_id = _required_cdp_int(matched, "nodeId")
|
|
1335
|
+
if node_id <= 0:
|
|
1336
|
+
raise BrowserNotFoundError("Browser element was not found.")
|
|
1337
|
+
return node_id
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
class _OperationGuard:
|
|
1341
|
+
def __init__(self, session: _BrowserSession, *, cancel: CancelCheck) -> None:
|
|
1342
|
+
self._session = session
|
|
1343
|
+
self._cancel = cancel
|
|
1344
|
+
|
|
1345
|
+
def __enter__(self) -> None:
|
|
1346
|
+
_check_cancel(self._cancel)
|
|
1347
|
+
if self._session.process.poll() is not None:
|
|
1348
|
+
raise BrowserLaunchError("The managed browser process is no longer running.")
|
|
1349
|
+
if not self._session.egress_proxy.healthy:
|
|
1350
|
+
raise BrowserLaunchError("The managed browser egress proxy is no longer running.")
|
|
1351
|
+
if not self._session.operation_lock.acquire(blocking=False):
|
|
1352
|
+
raise BrowserLimitError(
|
|
1353
|
+
"Another browser operation is already running for this session."
|
|
1354
|
+
)
|
|
1355
|
+
try:
|
|
1356
|
+
_check_cancel(self._cancel)
|
|
1357
|
+
except BaseException:
|
|
1358
|
+
self._session.operation_lock.release()
|
|
1359
|
+
raise
|
|
1360
|
+
|
|
1361
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
1362
|
+
self._session.operation_lock.release()
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _validated_executable(candidate: Path) -> Path:
|
|
1366
|
+
try:
|
|
1367
|
+
path = candidate.resolve(strict=True)
|
|
1368
|
+
info = path.stat()
|
|
1369
|
+
except OSError as exc:
|
|
1370
|
+
raise BrowserValidationError("Browser executable does not exist.") from exc
|
|
1371
|
+
if not stat.S_ISREG(info.st_mode):
|
|
1372
|
+
raise BrowserValidationError("Browser executable path is not a regular file.")
|
|
1373
|
+
if os.name != "nt" and not os.access(path, os.X_OK):
|
|
1374
|
+
raise BrowserValidationError("Browser executable is not executable.")
|
|
1375
|
+
if os.name == "nt" and path.suffix.lower() != ".exe":
|
|
1376
|
+
raise BrowserValidationError("Browser executable must be an .exe file on Windows.")
|
|
1377
|
+
return path
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _product_for_path(path: Path) -> str:
|
|
1381
|
+
text = path.name.lower()
|
|
1382
|
+
if "edge" in text:
|
|
1383
|
+
return "edge"
|
|
1384
|
+
if "chromium" in text:
|
|
1385
|
+
return "chromium"
|
|
1386
|
+
return "chrome"
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _case_insensitive_get(environment: Mapping[str, str], key: str) -> str | None:
|
|
1390
|
+
for current, value in environment.items():
|
|
1391
|
+
if str(current).upper() == key:
|
|
1392
|
+
return str(value)
|
|
1393
|
+
return None
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _windows_taskkill_executable() -> Path | None:
|
|
1397
|
+
root_value = _case_insensitive_get(os.environ, "SYSTEMROOT")
|
|
1398
|
+
if not root_value:
|
|
1399
|
+
return None
|
|
1400
|
+
root = Path(root_value)
|
|
1401
|
+
if not root.is_absolute():
|
|
1402
|
+
return None
|
|
1403
|
+
try:
|
|
1404
|
+
resolved_root = root.resolve(strict=True)
|
|
1405
|
+
candidate = (resolved_root / "System32" / "taskkill.exe").resolve(strict=True)
|
|
1406
|
+
candidate.relative_to(resolved_root)
|
|
1407
|
+
if not candidate.is_file():
|
|
1408
|
+
return None
|
|
1409
|
+
except (OSError, ValueError):
|
|
1410
|
+
return None
|
|
1411
|
+
return candidate
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _prepare_data_root(root: Path, *, workspace_roots: Sequence[Path]) -> Path:
|
|
1415
|
+
data_root = Path(root).expanduser()
|
|
1416
|
+
if not data_root.is_absolute():
|
|
1417
|
+
raise BrowserValidationError("Managed browser data root must be absolute.")
|
|
1418
|
+
resolved_workspaces = tuple(Path(item).expanduser().resolve() for item in workspace_roots)
|
|
1419
|
+
candidate = data_root.resolve()
|
|
1420
|
+
for workspace in resolved_workspaces:
|
|
1421
|
+
if _is_within(candidate, workspace):
|
|
1422
|
+
raise BrowserSecurityError("Managed browser data root must be outside the workspace.")
|
|
1423
|
+
return _private_dir(candidate)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def _private_dir(path: Path) -> Path:
|
|
1427
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
1428
|
+
resolved = path.resolve(strict=True)
|
|
1429
|
+
if not resolved.is_dir():
|
|
1430
|
+
raise BrowserSecurityError("Managed browser data path is not a directory.")
|
|
1431
|
+
with suppress(OSError):
|
|
1432
|
+
resolved.chmod(0o700)
|
|
1433
|
+
return resolved
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _make_owned_session_dir(root: Path, session_id: str) -> Path:
|
|
1437
|
+
prefix = f"{session_id}-"
|
|
1438
|
+
path = Path(tempfile.mkdtemp(prefix=prefix, dir=root)).resolve(strict=True)
|
|
1439
|
+
if not _is_within(path, root):
|
|
1440
|
+
raise BrowserSecurityError("Managed browser session path escaped its root.")
|
|
1441
|
+
with suppress(OSError):
|
|
1442
|
+
path.chmod(0o700)
|
|
1443
|
+
_write_owned_marker(
|
|
1444
|
+
path / ".alysis-owned",
|
|
1445
|
+
_OwnershipMarker(
|
|
1446
|
+
session_id=session_id,
|
|
1447
|
+
owner_pid=os.getpid(),
|
|
1448
|
+
owner_identity=_process_identity(os.getpid()),
|
|
1449
|
+
browser_pid=None,
|
|
1450
|
+
browser_identity=None,
|
|
1451
|
+
),
|
|
1452
|
+
)
|
|
1453
|
+
return path
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _record_owned_browser_process(
|
|
1457
|
+
paths: Sequence[Path], *, session_id: str, process: ProcessHandle
|
|
1458
|
+
) -> None:
|
|
1459
|
+
pid = process.pid
|
|
1460
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or not 1 <= pid <= 2**31 - 1:
|
|
1461
|
+
raise BrowserLaunchError("Managed browser process identity is invalid.")
|
|
1462
|
+
identity = _process_identity(pid)
|
|
1463
|
+
if identity is None and isinstance(process, subprocess.Popen):
|
|
1464
|
+
raise BrowserLaunchError("Managed browser process identity could not be recorded safely.")
|
|
1465
|
+
for path in paths:
|
|
1466
|
+
marker_path = path / ".alysis-owned"
|
|
1467
|
+
marker = _read_owned_marker(marker_path)
|
|
1468
|
+
if marker.session_id != session_id:
|
|
1469
|
+
raise BrowserSecurityError("Managed browser ownership marker changed during startup.")
|
|
1470
|
+
_write_owned_marker(
|
|
1471
|
+
marker_path,
|
|
1472
|
+
_OwnershipMarker(
|
|
1473
|
+
session_id=session_id,
|
|
1474
|
+
owner_pid=marker.owner_pid,
|
|
1475
|
+
owner_identity=marker.owner_identity,
|
|
1476
|
+
browser_pid=pid if identity is not None else None,
|
|
1477
|
+
browser_identity=identity,
|
|
1478
|
+
),
|
|
1479
|
+
replace=True,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _remove_owned_session_dir(root: Path, path: Path, session_id: str) -> bool:
|
|
1484
|
+
try:
|
|
1485
|
+
resolved = path.resolve(strict=True)
|
|
1486
|
+
marker = resolved / ".alysis-owned"
|
|
1487
|
+
if not _is_within(resolved, root) or marker.is_symlink():
|
|
1488
|
+
return False
|
|
1489
|
+
if _read_owned_marker(marker).session_id != session_id:
|
|
1490
|
+
return False
|
|
1491
|
+
for attempt in range(20):
|
|
1492
|
+
try:
|
|
1493
|
+
shutil.rmtree(resolved, onerror=_retry_readonly_remove)
|
|
1494
|
+
return True
|
|
1495
|
+
except FileNotFoundError:
|
|
1496
|
+
return True
|
|
1497
|
+
except OSError:
|
|
1498
|
+
if attempt == 19:
|
|
1499
|
+
return False
|
|
1500
|
+
time.sleep(0.05)
|
|
1501
|
+
except (OSError, UnicodeError, ValueError):
|
|
1502
|
+
return not path.exists()
|
|
1503
|
+
return not resolved.exists()
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def _write_owned_marker(path: Path, marker: _OwnershipMarker, *, replace: bool = False) -> None:
|
|
1507
|
+
payload = json.dumps(
|
|
1508
|
+
{
|
|
1509
|
+
"schema_version": _OWNERSHIP_MARKER_VERSION,
|
|
1510
|
+
"session_id": marker.session_id,
|
|
1511
|
+
"owner_pid": marker.owner_pid,
|
|
1512
|
+
"owner_identity": marker.owner_identity,
|
|
1513
|
+
"browser_pid": marker.browser_pid,
|
|
1514
|
+
"browser_identity": marker.browser_identity,
|
|
1515
|
+
},
|
|
1516
|
+
sort_keys=True,
|
|
1517
|
+
separators=(",", ":"),
|
|
1518
|
+
).encode("ascii")
|
|
1519
|
+
if not replace:
|
|
1520
|
+
_write_private_file(path, payload)
|
|
1521
|
+
return
|
|
1522
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
1523
|
+
try:
|
|
1524
|
+
_write_private_file(temporary, payload)
|
|
1525
|
+
os.replace(temporary, path)
|
|
1526
|
+
finally:
|
|
1527
|
+
with suppress(OSError):
|
|
1528
|
+
temporary.unlink()
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def _read_owned_marker(marker: Path) -> _OwnershipMarker:
|
|
1532
|
+
"""Return the session and owner PID from a trusted marker.
|
|
1533
|
+
|
|
1534
|
+
Plain session-id markers are accepted for safe cleanup compatibility, but
|
|
1535
|
+
only versioned markers carry enough ownership evidence for crash scavenging.
|
|
1536
|
+
"""
|
|
1537
|
+
|
|
1538
|
+
raw = marker.read_text(encoding="ascii")
|
|
1539
|
+
if _SESSION_RE.fullmatch(raw):
|
|
1540
|
+
return _OwnershipMarker(raw, None, None, None, None)
|
|
1541
|
+
payload = json.loads(raw)
|
|
1542
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != _OWNERSHIP_MARKER_VERSION:
|
|
1543
|
+
raise ValueError("Unsupported browser ownership marker.")
|
|
1544
|
+
session_id = payload.get("session_id")
|
|
1545
|
+
owner_pid = payload.get("owner_pid")
|
|
1546
|
+
owner_identity = payload.get("owner_identity")
|
|
1547
|
+
browser_pid = payload.get("browser_pid")
|
|
1548
|
+
browser_identity = payload.get("browser_identity")
|
|
1549
|
+
if (
|
|
1550
|
+
not isinstance(session_id, str)
|
|
1551
|
+
or _SESSION_RE.fullmatch(session_id) is None
|
|
1552
|
+
or isinstance(owner_pid, bool)
|
|
1553
|
+
or not isinstance(owner_pid, int)
|
|
1554
|
+
or not 1 <= owner_pid <= 2**31 - 1
|
|
1555
|
+
or not isinstance(owner_identity, str)
|
|
1556
|
+
or not owner_identity
|
|
1557
|
+
or (
|
|
1558
|
+
browser_pid is not None
|
|
1559
|
+
and (
|
|
1560
|
+
isinstance(browser_pid, bool)
|
|
1561
|
+
or not isinstance(browser_pid, int)
|
|
1562
|
+
or not 1 <= browser_pid <= 2**31 - 1
|
|
1563
|
+
or not isinstance(browser_identity, str)
|
|
1564
|
+
or not browser_identity
|
|
1565
|
+
)
|
|
1566
|
+
)
|
|
1567
|
+
or (browser_pid is None and browser_identity is not None)
|
|
1568
|
+
):
|
|
1569
|
+
raise ValueError("Invalid browser ownership marker.")
|
|
1570
|
+
return _OwnershipMarker(
|
|
1571
|
+
session_id=session_id,
|
|
1572
|
+
owner_pid=owner_pid,
|
|
1573
|
+
owner_identity=owner_identity,
|
|
1574
|
+
browser_pid=browser_pid,
|
|
1575
|
+
browser_identity=browser_identity,
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _process_identity(pid: int) -> str | None:
|
|
1580
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or not 1 <= pid <= 2**31 - 1:
|
|
1581
|
+
return None
|
|
1582
|
+
if os.name == "nt":
|
|
1583
|
+
import ctypes
|
|
1584
|
+
|
|
1585
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1586
|
+
open_process = kernel32.OpenProcess
|
|
1587
|
+
open_process.argtypes = (ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32)
|
|
1588
|
+
open_process.restype = ctypes.c_void_p
|
|
1589
|
+
get_process_times = kernel32.GetProcessTimes
|
|
1590
|
+
get_process_times.argtypes = (
|
|
1591
|
+
ctypes.c_void_p,
|
|
1592
|
+
ctypes.POINTER(ctypes.c_uint64),
|
|
1593
|
+
ctypes.POINTER(ctypes.c_uint64),
|
|
1594
|
+
ctypes.POINTER(ctypes.c_uint64),
|
|
1595
|
+
ctypes.POINTER(ctypes.c_uint64),
|
|
1596
|
+
)
|
|
1597
|
+
get_process_times.restype = ctypes.c_int
|
|
1598
|
+
close_handle = kernel32.CloseHandle
|
|
1599
|
+
close_handle.argtypes = (ctypes.c_void_p,)
|
|
1600
|
+
close_handle.restype = ctypes.c_int
|
|
1601
|
+
handle = open_process(0x00001000, 0, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
1602
|
+
if not handle:
|
|
1603
|
+
return None
|
|
1604
|
+
try:
|
|
1605
|
+
creation = ctypes.c_uint64()
|
|
1606
|
+
exit_time = ctypes.c_uint64()
|
|
1607
|
+
kernel = ctypes.c_uint64()
|
|
1608
|
+
user = ctypes.c_uint64()
|
|
1609
|
+
if not get_process_times(
|
|
1610
|
+
handle,
|
|
1611
|
+
ctypes.byref(creation),
|
|
1612
|
+
ctypes.byref(exit_time),
|
|
1613
|
+
ctypes.byref(kernel),
|
|
1614
|
+
ctypes.byref(user),
|
|
1615
|
+
):
|
|
1616
|
+
return None
|
|
1617
|
+
return f"windows-filetime:{creation.value}"
|
|
1618
|
+
finally:
|
|
1619
|
+
close_handle(handle)
|
|
1620
|
+
proc_stat = Path("/proc") / str(pid) / "stat"
|
|
1621
|
+
if proc_stat.is_file():
|
|
1622
|
+
try:
|
|
1623
|
+
raw = proc_stat.read_text(encoding="ascii")
|
|
1624
|
+
_head, separator, tail = raw.rpartition(")")
|
|
1625
|
+
fields = tail.strip().split()
|
|
1626
|
+
if separator and len(fields) > 19:
|
|
1627
|
+
return f"proc-start-ticks:{fields[19]}"
|
|
1628
|
+
except (OSError, UnicodeError):
|
|
1629
|
+
return None
|
|
1630
|
+
ps = Path("/bin/ps")
|
|
1631
|
+
if ps.is_file():
|
|
1632
|
+
try:
|
|
1633
|
+
result = subprocess.run(
|
|
1634
|
+
[str(ps), "-o", "lstart=", "-p", str(pid)],
|
|
1635
|
+
check=False,
|
|
1636
|
+
stdin=subprocess.DEVNULL,
|
|
1637
|
+
stdout=subprocess.PIPE,
|
|
1638
|
+
stderr=subprocess.DEVNULL,
|
|
1639
|
+
timeout=1.0,
|
|
1640
|
+
shell=False,
|
|
1641
|
+
text=True,
|
|
1642
|
+
)
|
|
1643
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1644
|
+
return None
|
|
1645
|
+
value = result.stdout.strip()
|
|
1646
|
+
if result.returncode == 0 and value:
|
|
1647
|
+
return f"ps-lstart:{value}"
|
|
1648
|
+
return None
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def _process_is_alive(pid: int) -> bool:
|
|
1652
|
+
return _process_identity(pid) is not None
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _terminate_recovered_browser_process(pid: int, identity: str, *, grace_seconds: float) -> bool:
|
|
1656
|
+
if _process_identity(pid) != identity:
|
|
1657
|
+
return True
|
|
1658
|
+
if os.name == "nt":
|
|
1659
|
+
taskkill = _windows_taskkill_executable()
|
|
1660
|
+
if taskkill is None:
|
|
1661
|
+
return False
|
|
1662
|
+
for force in (False, True):
|
|
1663
|
+
if _process_identity(pid) != identity:
|
|
1664
|
+
return True
|
|
1665
|
+
args = [str(taskkill), "/PID", str(pid), "/T"]
|
|
1666
|
+
if force:
|
|
1667
|
+
args.append("/F")
|
|
1668
|
+
try:
|
|
1669
|
+
subprocess.run(
|
|
1670
|
+
args,
|
|
1671
|
+
check=False,
|
|
1672
|
+
stdin=subprocess.DEVNULL,
|
|
1673
|
+
stdout=subprocess.DEVNULL,
|
|
1674
|
+
stderr=subprocess.DEVNULL,
|
|
1675
|
+
timeout=grace_seconds,
|
|
1676
|
+
shell=False,
|
|
1677
|
+
)
|
|
1678
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1679
|
+
continue
|
|
1680
|
+
deadline = time.monotonic() + grace_seconds
|
|
1681
|
+
while time.monotonic() < deadline:
|
|
1682
|
+
if _process_identity(pid) != identity:
|
|
1683
|
+
return True
|
|
1684
|
+
time.sleep(0.05)
|
|
1685
|
+
return _process_identity(pid) != identity
|
|
1686
|
+
try:
|
|
1687
|
+
if os.getpgid(pid) != pid or os.getpgrp() == pid:
|
|
1688
|
+
return False
|
|
1689
|
+
except (OSError, AttributeError):
|
|
1690
|
+
return _process_identity(pid) != identity
|
|
1691
|
+
with suppress(ProcessLookupError, OSError):
|
|
1692
|
+
os.killpg(pid, signal.SIGTERM)
|
|
1693
|
+
deadline = time.monotonic() + grace_seconds
|
|
1694
|
+
while time.monotonic() < deadline:
|
|
1695
|
+
if _process_identity(pid) != identity:
|
|
1696
|
+
break
|
|
1697
|
+
time.sleep(0.05)
|
|
1698
|
+
# The group can retain descendants after the leader exits. Its numeric pgid
|
|
1699
|
+
# remains reserved while those descendants exist, so this targets the exact
|
|
1700
|
+
# group originally validated above rather than a new process with a reused pid.
|
|
1701
|
+
with suppress(ProcessLookupError, OSError):
|
|
1702
|
+
os.killpg(pid, signal.SIGKILL)
|
|
1703
|
+
deadline = time.monotonic() + grace_seconds
|
|
1704
|
+
while time.monotonic() < deadline:
|
|
1705
|
+
if _process_identity(pid) != identity:
|
|
1706
|
+
return True
|
|
1707
|
+
time.sleep(0.05)
|
|
1708
|
+
return _process_identity(pid) != identity
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _scavenge_stale_owned_dirs(roots: Sequence[Path], *, grace_seconds: float) -> None:
|
|
1712
|
+
"""Remove only versioned session directories whose owning process is gone."""
|
|
1713
|
+
|
|
1714
|
+
failures: list[Path] = []
|
|
1715
|
+
terminated_browsers: set[tuple[int, str]] = set()
|
|
1716
|
+
for root in roots:
|
|
1717
|
+
trusted_root = root.resolve(strict=True)
|
|
1718
|
+
try:
|
|
1719
|
+
candidates = tuple(trusted_root.iterdir())
|
|
1720
|
+
except OSError as exc:
|
|
1721
|
+
raise BrowserSecurityError(
|
|
1722
|
+
"Managed browser stale-resource cleanup could not inspect its private root."
|
|
1723
|
+
) from exc
|
|
1724
|
+
for candidate in candidates:
|
|
1725
|
+
try:
|
|
1726
|
+
if candidate.is_symlink() or not candidate.is_dir():
|
|
1727
|
+
continue
|
|
1728
|
+
resolved = candidate.resolve(strict=True)
|
|
1729
|
+
if resolved.parent != trusted_root:
|
|
1730
|
+
continue
|
|
1731
|
+
marker = resolved / ".alysis-owned"
|
|
1732
|
+
if marker.is_symlink() or not marker.is_file():
|
|
1733
|
+
continue
|
|
1734
|
+
ownership = _read_owned_marker(marker)
|
|
1735
|
+
if not resolved.name.startswith(f"{ownership.session_id}-"):
|
|
1736
|
+
continue
|
|
1737
|
+
if (
|
|
1738
|
+
ownership.owner_pid is None
|
|
1739
|
+
or ownership.owner_identity is None
|
|
1740
|
+
or _process_identity(ownership.owner_pid) == ownership.owner_identity
|
|
1741
|
+
):
|
|
1742
|
+
continue
|
|
1743
|
+
if ownership.browser_pid is not None and ownership.browser_identity is not None:
|
|
1744
|
+
browser_key = (ownership.browser_pid, ownership.browser_identity)
|
|
1745
|
+
if browser_key not in terminated_browsers:
|
|
1746
|
+
if not _terminate_recovered_browser_process(
|
|
1747
|
+
ownership.browser_pid,
|
|
1748
|
+
ownership.browser_identity,
|
|
1749
|
+
grace_seconds=grace_seconds,
|
|
1750
|
+
):
|
|
1751
|
+
failures.append(resolved)
|
|
1752
|
+
continue
|
|
1753
|
+
terminated_browsers.add(browser_key)
|
|
1754
|
+
if not _remove_owned_session_dir(trusted_root, resolved, ownership.session_id):
|
|
1755
|
+
failures.append(resolved)
|
|
1756
|
+
except (OSError, UnicodeError, ValueError):
|
|
1757
|
+
# Unrecognized directories are never deleted. They may belong to
|
|
1758
|
+
# another version or have been tampered with.
|
|
1759
|
+
continue
|
|
1760
|
+
if failures:
|
|
1761
|
+
raise BrowserSecurityError(
|
|
1762
|
+
"Managed browser stale owned-resource cleanup is incomplete; retry startup."
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _retry_readonly_remove(function: Callable[[str], Any], path: str, _error: Any) -> None:
|
|
1767
|
+
with suppress(OSError):
|
|
1768
|
+
os.chmod(path, stat.S_IWRITE)
|
|
1769
|
+
function(path)
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _assert_owned_session_dir(root: Path, path: Path, session_id: str) -> Path:
|
|
1773
|
+
try:
|
|
1774
|
+
resolved = path.resolve(strict=True)
|
|
1775
|
+
marker = resolved / ".alysis-owned"
|
|
1776
|
+
if not _is_within(resolved, root.resolve(strict=True)) or marker.is_symlink():
|
|
1777
|
+
raise BrowserSecurityError("Browser artifact directory is no longer trusted.")
|
|
1778
|
+
if _read_owned_marker(marker).session_id != session_id:
|
|
1779
|
+
raise BrowserSecurityError("Browser artifact directory is no longer trusted.")
|
|
1780
|
+
except BrowserSecurityError:
|
|
1781
|
+
raise
|
|
1782
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
1783
|
+
raise BrowserSecurityError("Browser artifact directory is no longer trusted.") from exc
|
|
1784
|
+
return resolved
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _validated_egress_proxy_endpoint(proxy: BrowserEgressProxy) -> tuple[str, int]:
|
|
1788
|
+
host = proxy.host
|
|
1789
|
+
port = proxy.port
|
|
1790
|
+
if (
|
|
1791
|
+
host != "127.0.0.1"
|
|
1792
|
+
or isinstance(port, bool)
|
|
1793
|
+
or not isinstance(port, int)
|
|
1794
|
+
or not 1 <= port <= 65_535
|
|
1795
|
+
):
|
|
1796
|
+
raise BrowserSecurityError("Managed browser egress proxy endpoint is not trusted.")
|
|
1797
|
+
if not proxy.healthy:
|
|
1798
|
+
raise BrowserLaunchError("Managed browser egress proxy did not start safely.")
|
|
1799
|
+
return host, port
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def _trusted_devtools_endpoint_port(websocket_url: str) -> int:
|
|
1803
|
+
try:
|
|
1804
|
+
parsed = urlsplit(websocket_url)
|
|
1805
|
+
port = parsed.port
|
|
1806
|
+
except ValueError as exc:
|
|
1807
|
+
raise BrowserSecurityError("Managed browser DevTools endpoint is not trusted.") from exc
|
|
1808
|
+
if (
|
|
1809
|
+
parsed.scheme != "ws"
|
|
1810
|
+
or parsed.hostname != "127.0.0.1"
|
|
1811
|
+
or port is None
|
|
1812
|
+
or not 1 <= port <= 65_535
|
|
1813
|
+
or not _DEVTOOLS_PATH_RE.fullmatch(parsed.path)
|
|
1814
|
+
or parsed.username is not None
|
|
1815
|
+
or parsed.password is not None
|
|
1816
|
+
or parsed.query
|
|
1817
|
+
or parsed.fragment
|
|
1818
|
+
):
|
|
1819
|
+
raise BrowserSecurityError("Managed browser DevTools endpoint is not trusted.")
|
|
1820
|
+
return port
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _browser_arguments(profile_dir: Path, proxy_host: str, proxy_port: int) -> tuple[str, ...]:
|
|
1824
|
+
if proxy_host != "127.0.0.1" or not 1 <= proxy_port <= 65_535:
|
|
1825
|
+
raise BrowserSecurityError("Managed browser egress proxy endpoint is not trusted.")
|
|
1826
|
+
return (
|
|
1827
|
+
"--headless=new",
|
|
1828
|
+
"--remote-debugging-address=127.0.0.1",
|
|
1829
|
+
"--remote-debugging-port=0",
|
|
1830
|
+
f"--user-data-dir={profile_dir}",
|
|
1831
|
+
"--no-first-run",
|
|
1832
|
+
"--no-default-browser-check",
|
|
1833
|
+
f"--proxy-server=http://{proxy_host}:{proxy_port}",
|
|
1834
|
+
"--proxy-bypass-list=<-loopback>",
|
|
1835
|
+
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
|
|
1836
|
+
"--disable-quic",
|
|
1837
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
1838
|
+
"--disable-background-networking",
|
|
1839
|
+
"--disable-component-update",
|
|
1840
|
+
"--disable-default-apps",
|
|
1841
|
+
"--disable-extensions",
|
|
1842
|
+
"--disable-sync",
|
|
1843
|
+
"--metrics-recording-only",
|
|
1844
|
+
"--password-store=basic",
|
|
1845
|
+
"--disable-features=Translate,MediaRouter,OptimizationHints",
|
|
1846
|
+
"about:blank",
|
|
1847
|
+
)
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _sanitized_browser_environment(source: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
1851
|
+
env = source or os.environ
|
|
1852
|
+
allowed_names = {"LANG", "LC_ALL", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "WINDIR"}
|
|
1853
|
+
output: dict[str, str] = {}
|
|
1854
|
+
for key, value in env.items():
|
|
1855
|
+
if str(key).upper() in allowed_names and "\x00" not in str(value):
|
|
1856
|
+
output[str(key)] = str(value)
|
|
1857
|
+
return output
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def _read_active_port(path: Path, profile_dir: Path) -> tuple[int, str] | None:
|
|
1861
|
+
try:
|
|
1862
|
+
info = path.lstat()
|
|
1863
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
1864
|
+
raise BrowserSecurityError("DevTools endpoint metadata was not a regular file.")
|
|
1865
|
+
if info.st_size > _ACTIVE_PORT_MAX_BYTES:
|
|
1866
|
+
raise BrowserSecurityError("DevTools endpoint metadata was too large.")
|
|
1867
|
+
resolved = path.resolve(strict=True)
|
|
1868
|
+
if resolved.parent != profile_dir.resolve(strict=True):
|
|
1869
|
+
raise BrowserSecurityError("DevTools endpoint metadata escaped its profile.")
|
|
1870
|
+
flags = os.O_RDONLY
|
|
1871
|
+
if hasattr(os, "O_BINARY"):
|
|
1872
|
+
flags |= os.O_BINARY
|
|
1873
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
1874
|
+
flags |= os.O_NOFOLLOW
|
|
1875
|
+
descriptor = os.open(path, flags)
|
|
1876
|
+
try:
|
|
1877
|
+
current = os.fstat(descriptor)
|
|
1878
|
+
if not stat.S_ISREG(current.st_mode) or current.st_size > _ACTIVE_PORT_MAX_BYTES:
|
|
1879
|
+
raise BrowserSecurityError("DevTools endpoint metadata was invalid.")
|
|
1880
|
+
payload = os.read(descriptor, _ACTIVE_PORT_MAX_BYTES + 1)
|
|
1881
|
+
finally:
|
|
1882
|
+
os.close(descriptor)
|
|
1883
|
+
if len(payload) > _ACTIVE_PORT_MAX_BYTES:
|
|
1884
|
+
raise BrowserSecurityError("DevTools endpoint metadata was too large.")
|
|
1885
|
+
text = payload.decode("ascii")
|
|
1886
|
+
except FileNotFoundError:
|
|
1887
|
+
return None
|
|
1888
|
+
except UnicodeError as exc:
|
|
1889
|
+
raise BrowserSecurityError("DevTools endpoint metadata was invalid.") from exc
|
|
1890
|
+
lines = text.splitlines()
|
|
1891
|
+
if len(lines) != 2:
|
|
1892
|
+
raise BrowserSecurityError("DevTools endpoint metadata was invalid.")
|
|
1893
|
+
try:
|
|
1894
|
+
port = int(lines[0])
|
|
1895
|
+
except ValueError as exc:
|
|
1896
|
+
raise BrowserSecurityError("DevTools endpoint port was invalid.") from exc
|
|
1897
|
+
browser_path = lines[1]
|
|
1898
|
+
if not 1 <= port <= 65_535 or not _DEVTOOLS_PATH_RE.fullmatch(browser_path):
|
|
1899
|
+
raise BrowserSecurityError("DevTools endpoint metadata was invalid.")
|
|
1900
|
+
return port, browser_path
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
def _resolve_addresses(
|
|
1904
|
+
host: str,
|
|
1905
|
+
port: int,
|
|
1906
|
+
*,
|
|
1907
|
+
resolver: AddressResolver | None,
|
|
1908
|
+
timeout: float,
|
|
1909
|
+
) -> tuple[str, ...]:
|
|
1910
|
+
try:
|
|
1911
|
+
ipaddress.ip_address(host)
|
|
1912
|
+
except ValueError:
|
|
1913
|
+
pass
|
|
1914
|
+
else:
|
|
1915
|
+
return (host,)
|
|
1916
|
+
if not _DNS_RESOLUTION_SLOTS.acquire(blocking=False):
|
|
1917
|
+
raise BrowserSecurityError("Browser destination resolution capacity was exhausted.")
|
|
1918
|
+
completed = threading.Event()
|
|
1919
|
+
outcome: dict[str, Any] = {}
|
|
1920
|
+
|
|
1921
|
+
def resolve() -> None:
|
|
1922
|
+
try:
|
|
1923
|
+
if resolver is not None:
|
|
1924
|
+
values = resolver(host, port)
|
|
1925
|
+
else:
|
|
1926
|
+
values = {
|
|
1927
|
+
item[4][0]
|
|
1928
|
+
for item in socket.getaddrinfo(
|
|
1929
|
+
host, port, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM
|
|
1930
|
+
)
|
|
1931
|
+
}
|
|
1932
|
+
result = tuple(dict.fromkeys(str(item) for item in values))
|
|
1933
|
+
for item in result:
|
|
1934
|
+
ipaddress.ip_address(item)
|
|
1935
|
+
outcome["result"] = result
|
|
1936
|
+
except BaseException as exc: # noqa: BLE001 - converted to a safe error below
|
|
1937
|
+
outcome["error"] = exc
|
|
1938
|
+
finally:
|
|
1939
|
+
_DNS_RESOLUTION_SLOTS.release()
|
|
1940
|
+
completed.set()
|
|
1941
|
+
|
|
1942
|
+
threading.Thread(target=resolve, name="alysis-browser-dns", daemon=True).start()
|
|
1943
|
+
if not completed.wait(timeout=timeout):
|
|
1944
|
+
raise BrowserTimeoutError("Browser destination resolution timed out.")
|
|
1945
|
+
if "error" in outcome:
|
|
1946
|
+
raise BrowserSecurityError(
|
|
1947
|
+
"Browser destination could not be resolved safely."
|
|
1948
|
+
) from outcome["error"]
|
|
1949
|
+
return tuple(outcome.get("result", ()))
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def _is_public_address(value: str) -> bool:
|
|
1953
|
+
address = ipaddress.ip_address(value)
|
|
1954
|
+
if isinstance(address, ipaddress.IPv6Address) and (
|
|
1955
|
+
address.ipv4_mapped is not None
|
|
1956
|
+
or address.sixtofour is not None
|
|
1957
|
+
or address.teredo is not None
|
|
1958
|
+
):
|
|
1959
|
+
return False
|
|
1960
|
+
return bool(address.is_global) and not any(
|
|
1961
|
+
(
|
|
1962
|
+
address.is_private,
|
|
1963
|
+
address.is_loopback,
|
|
1964
|
+
address.is_link_local,
|
|
1965
|
+
address.is_multicast,
|
|
1966
|
+
address.is_reserved,
|
|
1967
|
+
address.is_unspecified,
|
|
1968
|
+
)
|
|
1969
|
+
)
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def _is_safe_owned_preview_address(value: str) -> bool:
|
|
1973
|
+
address = ipaddress.ip_address(value)
|
|
1974
|
+
if isinstance(address, ipaddress.IPv6Address) and (
|
|
1975
|
+
address.ipv4_mapped is not None
|
|
1976
|
+
or address.sixtofour is not None
|
|
1977
|
+
or address.teredo is not None
|
|
1978
|
+
):
|
|
1979
|
+
return False
|
|
1980
|
+
return not any(
|
|
1981
|
+
(
|
|
1982
|
+
address.is_multicast,
|
|
1983
|
+
address.is_reserved,
|
|
1984
|
+
address.is_unspecified,
|
|
1985
|
+
)
|
|
1986
|
+
)
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def _owned_preview_origin_check(
|
|
1990
|
+
provider: PreviewUrlProvider | None,
|
|
1991
|
+
) -> OwnedPreviewOriginAllowed:
|
|
1992
|
+
def allowed(scheme: str, host: str, port: int) -> bool:
|
|
1993
|
+
if provider is None:
|
|
1994
|
+
return False
|
|
1995
|
+
try:
|
|
1996
|
+
candidates = provider()
|
|
1997
|
+
except Exception:
|
|
1998
|
+
return False
|
|
1999
|
+
for index, raw in enumerate(candidates):
|
|
2000
|
+
if index >= 64:
|
|
2001
|
+
break
|
|
2002
|
+
try:
|
|
2003
|
+
parsed = urlsplit(str(raw or "").strip())
|
|
2004
|
+
candidate_scheme = parsed.scheme.lower()
|
|
2005
|
+
candidate_host = (parsed.hostname or "").rstrip(".").lower()
|
|
2006
|
+
candidate_host = candidate_host.encode("idna").decode("ascii")
|
|
2007
|
+
candidate_port = parsed.port or (443 if candidate_scheme == "https" else 80)
|
|
2008
|
+
except (TypeError, ValueError, UnicodeError):
|
|
2009
|
+
continue
|
|
2010
|
+
if (
|
|
2011
|
+
candidate_scheme in {"http", "https"}
|
|
2012
|
+
and parsed.username is None
|
|
2013
|
+
and parsed.password is None
|
|
2014
|
+
and (candidate_scheme, candidate_host, candidate_port) == (scheme, host, port)
|
|
2015
|
+
):
|
|
2016
|
+
return True
|
|
2017
|
+
return False
|
|
2018
|
+
|
|
2019
|
+
return allowed
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
def _validated_owner(owner_id: str) -> str:
|
|
2023
|
+
value = str(owner_id or "").strip()
|
|
2024
|
+
if not _OWNER_RE.fullmatch(value):
|
|
2025
|
+
raise BrowserValidationError("Browser owner id is invalid.")
|
|
2026
|
+
return value
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def _validated_session_id(session_id: str) -> str:
|
|
2030
|
+
value = str(session_id or "").strip()
|
|
2031
|
+
if not _SESSION_RE.fullmatch(value):
|
|
2032
|
+
raise BrowserValidationError("Browser session id is invalid.")
|
|
2033
|
+
return value
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def _validated_selector(selector: str) -> str:
|
|
2037
|
+
if not isinstance(selector, str) or not selector or len(selector) > _SELECTOR_MAX_CHARS:
|
|
2038
|
+
raise BrowserValidationError("Browser selector is invalid or too long.")
|
|
2039
|
+
if "\x00" in selector:
|
|
2040
|
+
raise BrowserValidationError("Browser selector is invalid or too long.")
|
|
2041
|
+
return selector
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _bounded_timeout(value: float | None, default: float) -> float:
|
|
2045
|
+
if value is None:
|
|
2046
|
+
return default
|
|
2047
|
+
if (
|
|
2048
|
+
isinstance(value, bool)
|
|
2049
|
+
or not isinstance(value, int | float)
|
|
2050
|
+
or not 0.05 <= value <= default
|
|
2051
|
+
):
|
|
2052
|
+
raise BrowserValidationError("Browser operation timeout is invalid.")
|
|
2053
|
+
return float(value)
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def _check_cancel(cancel: CancelCheck) -> None:
|
|
2057
|
+
if cancel is not None and cancel():
|
|
2058
|
+
raise BrowserCancelledError("Browser operation was cancelled.")
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
def _required_cdp_string(payload: Mapping[str, Any], key: str) -> str:
|
|
2062
|
+
value = payload.get(key)
|
|
2063
|
+
if not isinstance(value, str) or not value or len(value) > 256:
|
|
2064
|
+
raise BrowserSecurityError("Browser CDP response was invalid.")
|
|
2065
|
+
return value
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
def _required_cdp_int(payload: Mapping[str, Any], key: str) -> int:
|
|
2069
|
+
value = payload.get(key)
|
|
2070
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
2071
|
+
raise BrowserSecurityError("Browser CDP response was invalid.")
|
|
2072
|
+
return value
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _box_center(payload: Mapping[str, Any]) -> tuple[float, float]:
|
|
2076
|
+
model = payload.get("model")
|
|
2077
|
+
if not isinstance(model, Mapping):
|
|
2078
|
+
raise BrowserSecurityError("Browser element box response was invalid.")
|
|
2079
|
+
quad = model.get("content") or model.get("border")
|
|
2080
|
+
if not isinstance(quad, Sequence) or isinstance(quad, str | bytes) or len(quad) != 8:
|
|
2081
|
+
raise BrowserSecurityError("Browser element box response was invalid.")
|
|
2082
|
+
try:
|
|
2083
|
+
numbers = [float(value) for value in quad]
|
|
2084
|
+
except (TypeError, ValueError) as exc:
|
|
2085
|
+
raise BrowserSecurityError("Browser element box response was invalid.") from exc
|
|
2086
|
+
if not all(math.isfinite(value) for value in numbers):
|
|
2087
|
+
raise BrowserSecurityError("Browser element box response was invalid.")
|
|
2088
|
+
return sum(numbers[0::2]) / 4.0, sum(numbers[1::2]) / 4.0
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _bounded_payload(value: Any, max_bytes: int) -> dict[str, Any]:
|
|
2092
|
+
safe = redact_secrets(value)
|
|
2093
|
+
try:
|
|
2094
|
+
encoded = json.dumps(safe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
2095
|
+
except (TypeError, ValueError) as exc:
|
|
2096
|
+
raise BrowserSecurityError("Browser CDP response was not JSON-compatible.") from exc
|
|
2097
|
+
if len(encoded) <= max_bytes:
|
|
2098
|
+
return {"data": safe, "truncated": False, "size_bytes": len(encoded)}
|
|
2099
|
+
preview = encoded[:max_bytes].decode("utf-8", errors="ignore")
|
|
2100
|
+
return {
|
|
2101
|
+
"data": {"preview": redact_secrets(preview)},
|
|
2102
|
+
"truncated": True,
|
|
2103
|
+
"size_bytes": len(encoded),
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def _sanitize_diagnostic_params(method: str, value: Any) -> dict[str, Any]:
|
|
2108
|
+
"""Return a useful CDP diagnostic allowlist without headers or credentials."""
|
|
2109
|
+
|
|
2110
|
+
if not isinstance(value, Mapping):
|
|
2111
|
+
return {}
|
|
2112
|
+
|
|
2113
|
+
def scalars(source: Mapping[str, Any], names: tuple[str, ...]) -> dict[str, Any]:
|
|
2114
|
+
output: dict[str, Any] = {}
|
|
2115
|
+
for name in names:
|
|
2116
|
+
if name not in source:
|
|
2117
|
+
continue
|
|
2118
|
+
item = source.get(name)
|
|
2119
|
+
if item is None or isinstance(item, str | int | float | bool):
|
|
2120
|
+
output[name] = redact_secrets(item)
|
|
2121
|
+
return output
|
|
2122
|
+
|
|
2123
|
+
if method == "Runtime.consoleAPICalled":
|
|
2124
|
+
output = scalars(value, ("type", "executionContextId", "timestamp"))
|
|
2125
|
+
raw_args = value.get("args")
|
|
2126
|
+
if isinstance(raw_args, Sequence) and not isinstance(raw_args, str | bytes):
|
|
2127
|
+
# Console values and descriptions are intentionally omitted. A page
|
|
2128
|
+
# can echo text entered through browser.type into console.log(), and
|
|
2129
|
+
# recognizable-secret redaction cannot protect arbitrary user input.
|
|
2130
|
+
output["args"] = [
|
|
2131
|
+
scalars(
|
|
2132
|
+
item,
|
|
2133
|
+
("type", "subtype"),
|
|
2134
|
+
)
|
|
2135
|
+
for item in raw_args[:50]
|
|
2136
|
+
if isinstance(item, Mapping)
|
|
2137
|
+
]
|
|
2138
|
+
return output
|
|
2139
|
+
|
|
2140
|
+
if method == "Log.entryAdded":
|
|
2141
|
+
entry = value.get("entry")
|
|
2142
|
+
if not isinstance(entry, Mapping):
|
|
2143
|
+
return {}
|
|
2144
|
+
output = scalars(
|
|
2145
|
+
entry,
|
|
2146
|
+
# Log text is another free-form console channel and is therefore
|
|
2147
|
+
# omitted for the same typed-input confidentiality boundary.
|
|
2148
|
+
("source", "level", "lineNumber", "timestamp", "category"),
|
|
2149
|
+
)
|
|
2150
|
+
if "url" in entry:
|
|
2151
|
+
output["url"] = _public_diagnostic_url(entry.get("url"))
|
|
2152
|
+
return {"entry": output}
|
|
2153
|
+
|
|
2154
|
+
if method == "Network.loadingFailed":
|
|
2155
|
+
return scalars(
|
|
2156
|
+
value,
|
|
2157
|
+
(
|
|
2158
|
+
"requestId",
|
|
2159
|
+
"timestamp",
|
|
2160
|
+
"type",
|
|
2161
|
+
"errorText",
|
|
2162
|
+
"canceled",
|
|
2163
|
+
"blockedReason",
|
|
2164
|
+
),
|
|
2165
|
+
)
|
|
2166
|
+
|
|
2167
|
+
if method == "Network.responseReceived":
|
|
2168
|
+
output = scalars(value, ("requestId", "loaderId", "timestamp", "type"))
|
|
2169
|
+
response = value.get("response")
|
|
2170
|
+
if isinstance(response, Mapping):
|
|
2171
|
+
safe_response = scalars(
|
|
2172
|
+
response,
|
|
2173
|
+
(
|
|
2174
|
+
"status",
|
|
2175
|
+
"statusText",
|
|
2176
|
+
"mimeType",
|
|
2177
|
+
"protocol",
|
|
2178
|
+
"fromDiskCache",
|
|
2179
|
+
"fromServiceWorker",
|
|
2180
|
+
"fromPrefetchCache",
|
|
2181
|
+
"encodedDataLength",
|
|
2182
|
+
"remoteIPAddress",
|
|
2183
|
+
"remotePort",
|
|
2184
|
+
"securityState",
|
|
2185
|
+
),
|
|
2186
|
+
)
|
|
2187
|
+
safe_response["url"] = _public_diagnostic_url(response.get("url"))
|
|
2188
|
+
output["response"] = safe_response
|
|
2189
|
+
return output
|
|
2190
|
+
|
|
2191
|
+
return {}
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def _public_diagnostic_url(value: Any) -> str | None:
|
|
2195
|
+
text = str(value or "").strip()
|
|
2196
|
+
if not text:
|
|
2197
|
+
return None
|
|
2198
|
+
try:
|
|
2199
|
+
parsed = urlsplit(text)
|
|
2200
|
+
scheme = parsed.scheme.lower()
|
|
2201
|
+
hostname = parsed.hostname
|
|
2202
|
+
if scheme not in {"http", "https"} or not hostname:
|
|
2203
|
+
return None
|
|
2204
|
+
host = f"[{hostname}]" if ":" in hostname else hostname
|
|
2205
|
+
if parsed.port is not None:
|
|
2206
|
+
host = f"{host}:{parsed.port}"
|
|
2207
|
+
public = urlunsplit((scheme, host, parsed.path or "/", "", ""))
|
|
2208
|
+
except (TypeError, ValueError):
|
|
2209
|
+
return None
|
|
2210
|
+
return str(redact_secrets(public))
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def _bounded_text(value: str, max_bytes: int) -> dict[str, Any]:
|
|
2214
|
+
safe = str(redact_secrets(value))
|
|
2215
|
+
encoded = safe.encode("utf-8")
|
|
2216
|
+
return {
|
|
2217
|
+
"text": encoded[:max_bytes].decode("utf-8", errors="ignore"),
|
|
2218
|
+
"truncated": len(encoded) > max_bytes,
|
|
2219
|
+
"size_bytes": len(encoded),
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def _safe_artifact_path(root: Path, filename: str) -> Path:
|
|
2224
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]{1,128}", filename):
|
|
2225
|
+
raise BrowserSecurityError("Browser artifact name was invalid.")
|
|
2226
|
+
path = root / filename
|
|
2227
|
+
if path.parent.resolve(strict=True) != root.resolve(strict=True):
|
|
2228
|
+
raise BrowserSecurityError("Browser artifact path escaped its root.")
|
|
2229
|
+
return path
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _write_private_file(path: Path, payload: bytes) -> None:
|
|
2233
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
2234
|
+
if hasattr(os, "O_BINARY"):
|
|
2235
|
+
flags |= os.O_BINARY
|
|
2236
|
+
descriptor = os.open(path, flags, 0o600)
|
|
2237
|
+
try:
|
|
2238
|
+
with os.fdopen(descriptor, "wb", closefd=False) as handle:
|
|
2239
|
+
handle.write(payload)
|
|
2240
|
+
handle.flush()
|
|
2241
|
+
os.fsync(handle.fileno())
|
|
2242
|
+
finally:
|
|
2243
|
+
os.close(descriptor)
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
def _is_within(path: Path, root: Path) -> bool:
|
|
2247
|
+
try:
|
|
2248
|
+
path.relative_to(root)
|
|
2249
|
+
except ValueError:
|
|
2250
|
+
return False
|
|
2251
|
+
return True
|