alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,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