alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import hashlib
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import secrets
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
23
|
+
|
|
24
|
+
from .background_runner import _apply_env_overrides
|
|
25
|
+
from .branding import default_sandbox_docker_image
|
|
26
|
+
from .error_text import sanitize_optional_error_summary
|
|
27
|
+
from .sandbox_runner import (
|
|
28
|
+
_SENSITIVE_ENV_KEYS,
|
|
29
|
+
_build_bwrap_argv,
|
|
30
|
+
_build_docker_argv,
|
|
31
|
+
_docker_cleanup_container,
|
|
32
|
+
_supports_bwrap_unshare_cgroup,
|
|
33
|
+
)
|
|
34
|
+
from .sandbox_settings import ShellSandboxSettings
|
|
35
|
+
|
|
36
|
+
SERVICE_SCHEMA_VERSION = 1
|
|
37
|
+
SERVICE_STDOUT_LOG = "stdout.log"
|
|
38
|
+
SERVICE_STDERR_LOG = "stderr.log"
|
|
39
|
+
SERVICE_METADATA = "service.json"
|
|
40
|
+
SERVICE_PREVIEW_READY = "preview-ready.json"
|
|
41
|
+
SERVICE_PREVIEW_TOKEN = "preview-token"
|
|
42
|
+
_STOP_TIMEOUT_S = 3.0
|
|
43
|
+
_KILL_TIMEOUT_S = 1.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProcessOwnership(StrEnum):
|
|
47
|
+
SESSION = "SESSION"
|
|
48
|
+
DURABLE_SERVICE = "DURABLE_SERVICE"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DurableServiceStatus(StrEnum):
|
|
52
|
+
STARTING = "starting"
|
|
53
|
+
RUNNING = "running"
|
|
54
|
+
EXITED = "exited"
|
|
55
|
+
FAILED = "failed"
|
|
56
|
+
STOPPED = "stopped"
|
|
57
|
+
STALE = "stale"
|
|
58
|
+
UNKNOWN = "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ReadinessStatus(StrEnum):
|
|
62
|
+
READY = "ready"
|
|
63
|
+
NOT_READY = "not_ready"
|
|
64
|
+
FAILED = "failed"
|
|
65
|
+
UNSUPPORTED = "unsupported"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class DurableServiceStart:
|
|
70
|
+
service_id: str
|
|
71
|
+
payload: dict[str, Any]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DurableServiceManager:
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
root: Path,
|
|
79
|
+
state_dir: Path,
|
|
80
|
+
settings: ShellSandboxSettings,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.root = root.resolve()
|
|
83
|
+
self.state_dir = state_dir.resolve()
|
|
84
|
+
self.settings = settings
|
|
85
|
+
self._popens: dict[str, subprocess.Popen[bytes]] = {}
|
|
86
|
+
|
|
87
|
+
def start(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
cmd: str,
|
|
91
|
+
cwd: Path,
|
|
92
|
+
readiness: dict[str, Any] | None = None,
|
|
93
|
+
) -> DurableServiceStart:
|
|
94
|
+
cleaned_cmd = str(cmd or "").strip()
|
|
95
|
+
if not cleaned_cmd:
|
|
96
|
+
raise ValueError("cmd cannot be empty")
|
|
97
|
+
cwd_abs = cwd.resolve()
|
|
98
|
+
try:
|
|
99
|
+
cwd_abs.relative_to(self.root)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
raise ValueError(f"cwd escapes root: {cwd}") from exc
|
|
102
|
+
|
|
103
|
+
readiness_spec = normalize_readiness_spec(readiness)
|
|
104
|
+
return self._start_prepared(
|
|
105
|
+
cleaned_cmd=cleaned_cmd,
|
|
106
|
+
cwd_abs=cwd_abs,
|
|
107
|
+
readiness_spec=readiness_spec,
|
|
108
|
+
launch_builder=lambda service_id: self._build_launch(
|
|
109
|
+
cmd=cleaned_cmd,
|
|
110
|
+
cwd=cwd_abs,
|
|
111
|
+
service_id=service_id,
|
|
112
|
+
readiness=readiness_spec,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def resolve_preview_access(self, requested: object = "auto") -> str:
|
|
117
|
+
return _resolve_preview_access(
|
|
118
|
+
requested,
|
|
119
|
+
configured=getattr(self.settings, "preview_access", "auto"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def start_preview(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
cwd: Path,
|
|
126
|
+
access: str = "auto",
|
|
127
|
+
port: int | None = None,
|
|
128
|
+
) -> DurableServiceStart:
|
|
129
|
+
"""Start a constrained static preview using semantic network access."""
|
|
130
|
+
|
|
131
|
+
cwd_abs = cwd.resolve()
|
|
132
|
+
try:
|
|
133
|
+
cwd_abs.relative_to(self.root)
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise ValueError(f"cwd escapes root: {cwd}") from exc
|
|
136
|
+
if not cwd_abs.is_dir():
|
|
137
|
+
raise ValueError(f"preview cwd is not a directory: {cwd}")
|
|
138
|
+
if port is not None and (
|
|
139
|
+
isinstance(port, bool) or not isinstance(port, int) or port <= 0 or port > 65535
|
|
140
|
+
):
|
|
141
|
+
raise ValueError("preview port must be between 1 and 65535")
|
|
142
|
+
effective_access = self.resolve_preview_access(access)
|
|
143
|
+
token = secrets.token_urlsafe(32) if effective_access == "lan" else None
|
|
144
|
+
readiness_spec = normalize_readiness_spec(
|
|
145
|
+
{
|
|
146
|
+
"type": "preview_ready",
|
|
147
|
+
"timeout_s": 10.0,
|
|
148
|
+
"interval_s": 0.1,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _preview_launch(service_id: str) -> dict[str, Any]:
|
|
153
|
+
service_dir = self._service_dir(service_id)
|
|
154
|
+
ready_path = service_dir / SERVICE_PREVIEW_READY
|
|
155
|
+
token_path = service_dir / SERVICE_PREVIEW_TOKEN
|
|
156
|
+
argv = [
|
|
157
|
+
sys.executable,
|
|
158
|
+
"-m",
|
|
159
|
+
"alysis_code.preview_server",
|
|
160
|
+
"--root",
|
|
161
|
+
os.fspath(cwd_abs),
|
|
162
|
+
"--access",
|
|
163
|
+
effective_access,
|
|
164
|
+
"--ready-file",
|
|
165
|
+
os.fspath(ready_path),
|
|
166
|
+
]
|
|
167
|
+
if port is not None:
|
|
168
|
+
argv.extend(["--port", str(port)])
|
|
169
|
+
if token is not None:
|
|
170
|
+
_write_private_text(token_path, token)
|
|
171
|
+
argv.extend(["--token-file", os.fspath(token_path)])
|
|
172
|
+
return {
|
|
173
|
+
"backend": "host-preview",
|
|
174
|
+
"popen_args": argv,
|
|
175
|
+
"popen_cwd": self.root,
|
|
176
|
+
"shell": False,
|
|
177
|
+
"env": _safe_parent_env(),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return self._start_prepared(
|
|
181
|
+
cleaned_cmd=f"alysis-workspace-preview:{effective_access}",
|
|
182
|
+
cwd_abs=cwd_abs,
|
|
183
|
+
readiness_spec=readiness_spec,
|
|
184
|
+
launch_builder=_preview_launch,
|
|
185
|
+
metadata_extra={
|
|
186
|
+
"preview_access": effective_access,
|
|
187
|
+
"preview_authentication_required": token is not None,
|
|
188
|
+
},
|
|
189
|
+
metadata_from_readiness=_preview_metadata_from_readiness,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _start_prepared(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
cleaned_cmd: str,
|
|
196
|
+
cwd_abs: Path,
|
|
197
|
+
readiness_spec: dict[str, Any],
|
|
198
|
+
launch_builder: Callable[[str], dict[str, Any]],
|
|
199
|
+
metadata_extra: dict[str, Any] | None = None,
|
|
200
|
+
metadata_from_readiness: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
201
|
+
) -> DurableServiceStart:
|
|
202
|
+
service_id = f"svc_{uuid.uuid4().hex[:16]}"
|
|
203
|
+
service_dir = self._service_dir(service_id)
|
|
204
|
+
service_dir.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
stdout_path = service_dir / SERVICE_STDOUT_LOG
|
|
206
|
+
stderr_path = service_dir / SERVICE_STDERR_LOG
|
|
207
|
+
metadata_path = service_dir / SERVICE_METADATA
|
|
208
|
+
launch = launch_builder(service_id)
|
|
209
|
+
|
|
210
|
+
started_at = time.time()
|
|
211
|
+
try:
|
|
212
|
+
with (
|
|
213
|
+
stdout_path.open("ab", buffering=0) as stdout_fh,
|
|
214
|
+
stderr_path.open("ab", buffering=0) as stderr_fh,
|
|
215
|
+
):
|
|
216
|
+
popen = subprocess.Popen(
|
|
217
|
+
launch["popen_args"],
|
|
218
|
+
shell=bool(launch["shell"]),
|
|
219
|
+
cwd=str(launch["popen_cwd"]),
|
|
220
|
+
stdin=subprocess.DEVNULL,
|
|
221
|
+
stdout=stdout_fh,
|
|
222
|
+
stderr=stderr_fh,
|
|
223
|
+
env=launch["env"],
|
|
224
|
+
start_new_session=(os.name != "nt"),
|
|
225
|
+
creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0),
|
|
226
|
+
close_fds=True,
|
|
227
|
+
)
|
|
228
|
+
except BaseException:
|
|
229
|
+
self._safe_unlink(metadata_path)
|
|
230
|
+
self._safe_unlink(service_dir / SERVICE_PREVIEW_READY)
|
|
231
|
+
self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
self._popens[service_id] = popen
|
|
235
|
+
metadata = {
|
|
236
|
+
"schema_version": SERVICE_SCHEMA_VERSION,
|
|
237
|
+
"service_id": service_id,
|
|
238
|
+
"ownership": ProcessOwnership.DURABLE_SERVICE.value,
|
|
239
|
+
"status": DurableServiceStatus.RUNNING.value,
|
|
240
|
+
"pid": popen.pid,
|
|
241
|
+
"pgid": _process_group_id(popen.pid),
|
|
242
|
+
"pid_start_token": _pid_start_token(popen.pid),
|
|
243
|
+
"started_at_wall": started_at,
|
|
244
|
+
"root": os.fspath(self.root),
|
|
245
|
+
"cwd": os.fspath(cwd_abs),
|
|
246
|
+
"backend": str(launch["backend"]),
|
|
247
|
+
"container_name": launch.get("container_name"),
|
|
248
|
+
"cmd_sha256": hashlib.sha256(cleaned_cmd.encode("utf-8")).hexdigest(),
|
|
249
|
+
"readiness": readiness_spec,
|
|
250
|
+
"stdout_log_path": os.fspath(stdout_path),
|
|
251
|
+
"stderr_log_path": os.fspath(stderr_path),
|
|
252
|
+
}
|
|
253
|
+
metadata.update(metadata_extra or {})
|
|
254
|
+
self._write_metadata(metadata)
|
|
255
|
+
readiness_payload = self._check_readiness(
|
|
256
|
+
metadata=metadata,
|
|
257
|
+
timeout_s=_readiness_timeout(readiness_spec),
|
|
258
|
+
)
|
|
259
|
+
if readiness_payload["status"] != ReadinessStatus.READY.value:
|
|
260
|
+
self._terminate_loaded_metadata(metadata, remove_metadata=False)
|
|
261
|
+
status_payload = self.status(service_id)
|
|
262
|
+
self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
|
|
263
|
+
status_payload.pop("access_url", None)
|
|
264
|
+
status_payload["failure_category"] = "readiness_failed"
|
|
265
|
+
status_payload["readiness"] = readiness_payload
|
|
266
|
+
startup_error = _service_startup_error(stderr_path=stderr_path, stdout_path=stdout_path)
|
|
267
|
+
if startup_error:
|
|
268
|
+
status_payload["startup_error"] = startup_error
|
|
269
|
+
return DurableServiceStart(service_id=service_id, payload=status_payload)
|
|
270
|
+
if metadata_from_readiness is not None:
|
|
271
|
+
try:
|
|
272
|
+
metadata.update(metadata_from_readiness(readiness_payload))
|
|
273
|
+
self._write_metadata(metadata)
|
|
274
|
+
except BaseException:
|
|
275
|
+
self._terminate_loaded_metadata(metadata, remove_metadata=False)
|
|
276
|
+
self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
|
|
277
|
+
raise
|
|
278
|
+
return DurableServiceStart(service_id=service_id, payload=self.status(service_id))
|
|
279
|
+
|
|
280
|
+
def status(self, service_id: str) -> dict[str, Any]:
|
|
281
|
+
metadata = self._read_metadata(service_id)
|
|
282
|
+
if metadata is None:
|
|
283
|
+
return {
|
|
284
|
+
"service_id": service_id,
|
|
285
|
+
"ownership": ProcessOwnership.DURABLE_SERVICE.value,
|
|
286
|
+
"status": DurableServiceStatus.UNKNOWN.value,
|
|
287
|
+
"alive": False,
|
|
288
|
+
"readiness": {
|
|
289
|
+
"type": "process_alive",
|
|
290
|
+
"status": ReadinessStatus.FAILED.value,
|
|
291
|
+
"detail": "metadata not found",
|
|
292
|
+
},
|
|
293
|
+
"failure_category": "unknown_service",
|
|
294
|
+
}
|
|
295
|
+
alive, identity_valid = self._metadata_process_alive(metadata)
|
|
296
|
+
status = (
|
|
297
|
+
DurableServiceStatus.RUNNING.value
|
|
298
|
+
if alive
|
|
299
|
+
else DurableServiceStatus.STALE.value
|
|
300
|
+
if not identity_valid
|
|
301
|
+
else DurableServiceStatus.EXITED.value
|
|
302
|
+
)
|
|
303
|
+
metadata["status"] = status
|
|
304
|
+
self._write_metadata(metadata)
|
|
305
|
+
readiness = self._check_readiness(
|
|
306
|
+
metadata=metadata,
|
|
307
|
+
timeout_s=_readiness_timeout(dict(metadata.get("readiness") or {})),
|
|
308
|
+
)
|
|
309
|
+
payload = _metadata_public_payload(
|
|
310
|
+
metadata,
|
|
311
|
+
status=status,
|
|
312
|
+
alive=alive,
|
|
313
|
+
identity_valid=identity_valid,
|
|
314
|
+
readiness=readiness,
|
|
315
|
+
)
|
|
316
|
+
return self._attach_preview_access_url(payload, metadata)
|
|
317
|
+
|
|
318
|
+
def stop(self, service_id: str) -> dict[str, Any]:
|
|
319
|
+
metadata = self._read_metadata(service_id)
|
|
320
|
+
if metadata is None:
|
|
321
|
+
return {
|
|
322
|
+
"service_id": service_id,
|
|
323
|
+
"ownership": ProcessOwnership.DURABLE_SERVICE.value,
|
|
324
|
+
"status": DurableServiceStatus.UNKNOWN.value,
|
|
325
|
+
"stopped": False,
|
|
326
|
+
"failure_category": "unknown_service",
|
|
327
|
+
}
|
|
328
|
+
alive, identity_valid = self._metadata_process_alive(metadata)
|
|
329
|
+
if alive and not identity_valid:
|
|
330
|
+
payload = _metadata_public_payload(
|
|
331
|
+
metadata,
|
|
332
|
+
status=DurableServiceStatus.STALE.value,
|
|
333
|
+
alive=False,
|
|
334
|
+
identity_valid=False,
|
|
335
|
+
readiness={
|
|
336
|
+
"type": "process_alive",
|
|
337
|
+
"status": ReadinessStatus.FAILED.value,
|
|
338
|
+
"detail": "pid identity does not match durable service metadata",
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
payload["stopped"] = False
|
|
342
|
+
payload["failure_category"] = "stale_metadata_identity_mismatch"
|
|
343
|
+
metadata["status"] = DurableServiceStatus.STALE.value
|
|
344
|
+
self._write_metadata(metadata)
|
|
345
|
+
return payload
|
|
346
|
+
if alive:
|
|
347
|
+
self._terminate_loaded_metadata(metadata, remove_metadata=True)
|
|
348
|
+
else:
|
|
349
|
+
self._remove_metadata(metadata)
|
|
350
|
+
payload = _metadata_public_payload(
|
|
351
|
+
metadata,
|
|
352
|
+
status=DurableServiceStatus.STOPPED.value,
|
|
353
|
+
alive=False,
|
|
354
|
+
identity_valid=identity_valid,
|
|
355
|
+
readiness={
|
|
356
|
+
"type": str(dict(metadata.get("readiness") or {}).get("type") or "process_alive"),
|
|
357
|
+
"status": ReadinessStatus.FAILED.value,
|
|
358
|
+
"detail": "service stopped",
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
payload["stopped"] = True
|
|
362
|
+
return payload
|
|
363
|
+
|
|
364
|
+
def list_active(self) -> list[dict[str, Any]]:
|
|
365
|
+
if not self.state_dir.exists():
|
|
366
|
+
return []
|
|
367
|
+
services: list[dict[str, Any]] = []
|
|
368
|
+
for metadata_path in sorted(self.state_dir.glob(f"*/{SERVICE_METADATA}")):
|
|
369
|
+
service_id = metadata_path.parent.name
|
|
370
|
+
payload = self.status(service_id)
|
|
371
|
+
if payload.get("status") == DurableServiceStatus.RUNNING.value:
|
|
372
|
+
services.append(payload)
|
|
373
|
+
return services
|
|
374
|
+
|
|
375
|
+
def _build_launch(
|
|
376
|
+
self,
|
|
377
|
+
*,
|
|
378
|
+
cmd: str,
|
|
379
|
+
cwd: Path,
|
|
380
|
+
service_id: str,
|
|
381
|
+
readiness: dict[str, Any] | None = None,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
if self.settings.mode == "off":
|
|
384
|
+
return {
|
|
385
|
+
"backend": "host",
|
|
386
|
+
"popen_args": cmd,
|
|
387
|
+
"popen_cwd": cwd,
|
|
388
|
+
"shell": True,
|
|
389
|
+
"env": _safe_parent_env(),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
backend = self.settings.backend
|
|
393
|
+
has_bwrap = platform.system().lower() == "linux" and shutil.which("bwrap") is not None
|
|
394
|
+
has_docker = shutil.which("docker") is not None
|
|
395
|
+
if backend in {"auto", "bwrap"} and has_bwrap:
|
|
396
|
+
argv, env = _build_bwrap_argv(
|
|
397
|
+
root=self.root,
|
|
398
|
+
cwd=cwd,
|
|
399
|
+
cmd=cmd,
|
|
400
|
+
network=self.settings.network,
|
|
401
|
+
clear_env=self.settings.clear_env,
|
|
402
|
+
profile=self.settings.bwrap_profile,
|
|
403
|
+
unshare_cgroup=_supports_bwrap_unshare_cgroup(),
|
|
404
|
+
)
|
|
405
|
+
argv = [item for item in argv if item != "--die-with-parent"]
|
|
406
|
+
return {
|
|
407
|
+
"backend": "bwrap",
|
|
408
|
+
"popen_args": argv,
|
|
409
|
+
"popen_cwd": self.root,
|
|
410
|
+
"shell": False,
|
|
411
|
+
"env": env,
|
|
412
|
+
}
|
|
413
|
+
if backend in {"auto", "docker"} and has_docker:
|
|
414
|
+
published_ports: tuple[tuple[str, int, int], ...] = ()
|
|
415
|
+
readiness_spec = readiness or {}
|
|
416
|
+
if str(readiness_spec.get("type") or "") == "tcp":
|
|
417
|
+
host = str(readiness_spec.get("host") or "localhost").strip()
|
|
418
|
+
port = int(readiness_spec.get("port") or 0)
|
|
419
|
+
if not _host_resolves_only_to_loopback(host):
|
|
420
|
+
raise RuntimeError(
|
|
421
|
+
"Docker durable-service TCP readiness must resolve to a local loopback "
|
|
422
|
+
"interface. Use the workspace preview access policy for broader exposure."
|
|
423
|
+
)
|
|
424
|
+
if self.settings.network == "off":
|
|
425
|
+
raise RuntimeError(
|
|
426
|
+
"Docker networking is disabled. Use workspace_preview_start for static "
|
|
427
|
+
"localhost previews, or explicitly enable shell sandbox networking for "
|
|
428
|
+
"a containerized development server."
|
|
429
|
+
)
|
|
430
|
+
published_ports = ((_system_loopback_address(), port, port),)
|
|
431
|
+
container_name = f"alysis-svc-{service_id[-12:]}"
|
|
432
|
+
argv, env = _build_docker_argv(
|
|
433
|
+
root=self.root,
|
|
434
|
+
cwd=cwd,
|
|
435
|
+
cmd=cmd,
|
|
436
|
+
container_name=container_name,
|
|
437
|
+
network=self.settings.network,
|
|
438
|
+
docker_image=self.settings.docker_image or default_sandbox_docker_image("dev"),
|
|
439
|
+
clear_env=self.settings.clear_env,
|
|
440
|
+
pids_limit=self.settings.docker_pids_limit,
|
|
441
|
+
memory_limit=self.settings.docker_memory,
|
|
442
|
+
cpus=self.settings.docker_cpus,
|
|
443
|
+
read_only_rootfs=self.settings.docker_read_only,
|
|
444
|
+
protect_repo_meta=self.settings.protect_repo_meta,
|
|
445
|
+
env_allowlist=self.settings.docker_env_allowlist,
|
|
446
|
+
published_ports=published_ports,
|
|
447
|
+
)
|
|
448
|
+
return {
|
|
449
|
+
"backend": "docker",
|
|
450
|
+
"container_name": container_name,
|
|
451
|
+
"popen_args": argv,
|
|
452
|
+
"popen_cwd": self.root,
|
|
453
|
+
"shell": False,
|
|
454
|
+
"env": env,
|
|
455
|
+
}
|
|
456
|
+
if self.settings.mode == "warn":
|
|
457
|
+
raise RuntimeError(
|
|
458
|
+
"Durable service sandbox unavailable; host fallback is disabled for durable services."
|
|
459
|
+
)
|
|
460
|
+
raise RuntimeError(
|
|
461
|
+
"Shell sandbox strict mode is enabled, but no usable durable service backend is available."
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _check_readiness(
|
|
465
|
+
self,
|
|
466
|
+
*,
|
|
467
|
+
metadata: dict[str, Any],
|
|
468
|
+
timeout_s: float,
|
|
469
|
+
) -> dict[str, Any]:
|
|
470
|
+
readiness = dict(metadata.get("readiness") or {})
|
|
471
|
+
readiness_type = str(readiness.get("type") or "process_alive")
|
|
472
|
+
deadline = time.monotonic() + max(0.0, timeout_s)
|
|
473
|
+
interval_s = max(0.02, min(float(readiness.get("interval_s") or 0.1), 1.0))
|
|
474
|
+
last_detail = ""
|
|
475
|
+
while True:
|
|
476
|
+
alive, identity_valid = self._metadata_process_alive(metadata)
|
|
477
|
+
if readiness_type == "process_alive":
|
|
478
|
+
status = (
|
|
479
|
+
ReadinessStatus.READY if alive and identity_valid else ReadinessStatus.FAILED
|
|
480
|
+
)
|
|
481
|
+
return {
|
|
482
|
+
"type": readiness_type,
|
|
483
|
+
"status": status.value,
|
|
484
|
+
"detail": "process is alive"
|
|
485
|
+
if status == ReadinessStatus.READY
|
|
486
|
+
else "process is not alive",
|
|
487
|
+
}
|
|
488
|
+
if not alive or not identity_valid:
|
|
489
|
+
return {
|
|
490
|
+
"type": readiness_type,
|
|
491
|
+
"status": ReadinessStatus.FAILED.value,
|
|
492
|
+
"detail": "process is not alive",
|
|
493
|
+
}
|
|
494
|
+
if readiness_type == "tcp":
|
|
495
|
+
host = str(readiness.get("host") or "localhost")
|
|
496
|
+
port = int(readiness.get("port") or 0)
|
|
497
|
+
try:
|
|
498
|
+
with socket.create_connection((host, port), timeout=min(interval_s, 0.5)):
|
|
499
|
+
return {
|
|
500
|
+
"type": readiness_type,
|
|
501
|
+
"status": ReadinessStatus.READY.value,
|
|
502
|
+
"host": host,
|
|
503
|
+
"port": port,
|
|
504
|
+
"detail": "tcp connection succeeded",
|
|
505
|
+
}
|
|
506
|
+
except OSError as exc:
|
|
507
|
+
last_detail = str(exc)
|
|
508
|
+
elif readiness_type == "preview_ready":
|
|
509
|
+
service_id = str(metadata.get("service_id") or "")
|
|
510
|
+
ready_path = self._service_dir(service_id) / SERVICE_PREVIEW_READY
|
|
511
|
+
try:
|
|
512
|
+
ready = _read_preview_ready(
|
|
513
|
+
ready_path,
|
|
514
|
+
expected_access=str(metadata.get("preview_access") or ""),
|
|
515
|
+
)
|
|
516
|
+
except (OSError, ValueError) as exc:
|
|
517
|
+
last_detail = str(exc)
|
|
518
|
+
else:
|
|
519
|
+
host = str(ready["probe_host"])
|
|
520
|
+
port = int(ready["port"])
|
|
521
|
+
try:
|
|
522
|
+
with socket.create_connection((host, port), timeout=min(interval_s, 0.5)):
|
|
523
|
+
return {
|
|
524
|
+
"type": readiness_type,
|
|
525
|
+
"status": ReadinessStatus.READY.value,
|
|
526
|
+
"host": host,
|
|
527
|
+
"port": port,
|
|
528
|
+
"preview_urls": list(ready["preview_urls"]),
|
|
529
|
+
"access": str(ready["access"]),
|
|
530
|
+
"runtime": str(ready["runtime"]),
|
|
531
|
+
"authentication_required": bool(ready["authentication_required"]),
|
|
532
|
+
"detail": "preview server reported ready and accepted TCP",
|
|
533
|
+
}
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
last_detail = str(exc)
|
|
536
|
+
elif readiness_type == "unix_socket":
|
|
537
|
+
path = Path(str(readiness.get("path") or ""))
|
|
538
|
+
if path.exists():
|
|
539
|
+
return {
|
|
540
|
+
"type": readiness_type,
|
|
541
|
+
"status": ReadinessStatus.READY.value,
|
|
542
|
+
"path": os.fspath(path),
|
|
543
|
+
"detail": "unix socket exists",
|
|
544
|
+
}
|
|
545
|
+
last_detail = "unix socket does not exist"
|
|
546
|
+
elif readiness_type == "command":
|
|
547
|
+
command = str(readiness.get("command") or "").strip()
|
|
548
|
+
if not command:
|
|
549
|
+
return {
|
|
550
|
+
"type": readiness_type,
|
|
551
|
+
"status": ReadinessStatus.FAILED.value,
|
|
552
|
+
"detail": "readiness command is empty",
|
|
553
|
+
}
|
|
554
|
+
try:
|
|
555
|
+
result = subprocess.run(
|
|
556
|
+
command,
|
|
557
|
+
shell=True,
|
|
558
|
+
cwd=str(metadata.get("cwd") or self.root),
|
|
559
|
+
env=_safe_parent_env(),
|
|
560
|
+
capture_output=True,
|
|
561
|
+
text=True,
|
|
562
|
+
timeout=min(max(0.1, timeout_s), 5.0),
|
|
563
|
+
check=False,
|
|
564
|
+
)
|
|
565
|
+
except subprocess.TimeoutExpired:
|
|
566
|
+
last_detail = "readiness command timed out"
|
|
567
|
+
else:
|
|
568
|
+
if result.returncode == 0:
|
|
569
|
+
return {
|
|
570
|
+
"type": readiness_type,
|
|
571
|
+
"status": ReadinessStatus.READY.value,
|
|
572
|
+
"exit_code": result.returncode,
|
|
573
|
+
"detail": "readiness command passed",
|
|
574
|
+
}
|
|
575
|
+
last_detail = f"readiness command exited {result.returncode}"
|
|
576
|
+
else:
|
|
577
|
+
return {
|
|
578
|
+
"type": readiness_type,
|
|
579
|
+
"status": ReadinessStatus.UNSUPPORTED.value,
|
|
580
|
+
"detail": f"unsupported readiness type: {readiness_type}",
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if time.monotonic() >= deadline:
|
|
584
|
+
return {
|
|
585
|
+
"type": readiness_type,
|
|
586
|
+
"status": ReadinessStatus.FAILED.value,
|
|
587
|
+
"detail": last_detail or "readiness probe timed out",
|
|
588
|
+
}
|
|
589
|
+
time.sleep(interval_s)
|
|
590
|
+
|
|
591
|
+
def _metadata_process_alive(self, metadata: dict[str, Any]) -> tuple[bool, bool]:
|
|
592
|
+
pid = int(metadata.get("pid") or 0)
|
|
593
|
+
if pid <= 0:
|
|
594
|
+
return False, False
|
|
595
|
+
expected_token = str(metadata.get("pid_start_token") or "")
|
|
596
|
+
if expected_token:
|
|
597
|
+
current_token = _pid_start_token(pid)
|
|
598
|
+
if current_token and current_token != expected_token:
|
|
599
|
+
return True, False
|
|
600
|
+
popen = self._popens.get(str(metadata.get("service_id") or ""))
|
|
601
|
+
if popen is not None and popen.poll() is not None:
|
|
602
|
+
return False, True
|
|
603
|
+
return _pid_exists(pid), True
|
|
604
|
+
|
|
605
|
+
def _terminate_loaded_metadata(
|
|
606
|
+
self,
|
|
607
|
+
metadata: dict[str, Any],
|
|
608
|
+
*,
|
|
609
|
+
remove_metadata: bool,
|
|
610
|
+
) -> None:
|
|
611
|
+
service_id = str(metadata.get("service_id") or "")
|
|
612
|
+
popen = self._popens.get(service_id)
|
|
613
|
+
if str(metadata.get("backend") or "") == "docker" and metadata.get("container_name"):
|
|
614
|
+
_docker_cleanup_container(
|
|
615
|
+
str(metadata["container_name"]),
|
|
616
|
+
cwd=os.fspath(self.root),
|
|
617
|
+
env=_safe_parent_env(),
|
|
618
|
+
warning_callback=None,
|
|
619
|
+
reason="durable service stop",
|
|
620
|
+
quiet=True,
|
|
621
|
+
)
|
|
622
|
+
if popen is not None:
|
|
623
|
+
if popen.poll() is None:
|
|
624
|
+
_terminate_popen_tree(popen, timeout_s=_STOP_TIMEOUT_S)
|
|
625
|
+
else:
|
|
626
|
+
pid = int(metadata.get("pid") or 0)
|
|
627
|
+
pgid = metadata.get("pgid")
|
|
628
|
+
_terminate_pid_or_group(pid=pid, pgid=pgid, timeout_s=_STOP_TIMEOUT_S)
|
|
629
|
+
metadata["status"] = DurableServiceStatus.STOPPED.value
|
|
630
|
+
if remove_metadata:
|
|
631
|
+
self._remove_metadata(metadata)
|
|
632
|
+
else:
|
|
633
|
+
self._write_metadata(metadata)
|
|
634
|
+
|
|
635
|
+
def _read_metadata(self, service_id: str) -> dict[str, Any] | None:
|
|
636
|
+
normalized = _normalize_service_id(service_id)
|
|
637
|
+
if not normalized:
|
|
638
|
+
return None
|
|
639
|
+
path = self._service_dir(normalized) / SERVICE_METADATA
|
|
640
|
+
try:
|
|
641
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
642
|
+
except (OSError, json.JSONDecodeError):
|
|
643
|
+
return None
|
|
644
|
+
if not isinstance(raw, dict):
|
|
645
|
+
return None
|
|
646
|
+
if raw.get("service_id") != normalized:
|
|
647
|
+
return None
|
|
648
|
+
return raw
|
|
649
|
+
|
|
650
|
+
def _write_metadata(self, metadata: dict[str, Any]) -> None:
|
|
651
|
+
service_id = str(metadata.get("service_id") or "")
|
|
652
|
+
service_dir = self._service_dir(service_id)
|
|
653
|
+
service_dir.mkdir(parents=True, exist_ok=True)
|
|
654
|
+
path = service_dir / SERVICE_METADATA
|
|
655
|
+
path.write_text(
|
|
656
|
+
json.dumps(_sanitize_metadata(metadata), indent=2, sort_keys=True) + "\n",
|
|
657
|
+
encoding="utf-8",
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
def _remove_metadata(self, metadata: dict[str, Any]) -> None:
|
|
661
|
+
service_id = str(metadata.get("service_id") or "")
|
|
662
|
+
service_dir = self._service_dir(service_id)
|
|
663
|
+
self._safe_unlink(service_dir / SERVICE_METADATA)
|
|
664
|
+
self._safe_unlink(service_dir / SERVICE_PREVIEW_READY)
|
|
665
|
+
self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
|
|
666
|
+
|
|
667
|
+
def _attach_preview_access_url(
|
|
668
|
+
self,
|
|
669
|
+
payload: dict[str, Any],
|
|
670
|
+
metadata: dict[str, Any],
|
|
671
|
+
) -> dict[str, Any]:
|
|
672
|
+
preview_url = str(payload.get("preview_url") or "").strip()
|
|
673
|
+
if not preview_url:
|
|
674
|
+
return payload
|
|
675
|
+
if not bool(metadata.get("preview_authentication_required")):
|
|
676
|
+
payload["access_url"] = preview_url
|
|
677
|
+
return payload
|
|
678
|
+
service_id = str(metadata.get("service_id") or "")
|
|
679
|
+
try:
|
|
680
|
+
token = (
|
|
681
|
+
(self._service_dir(service_id) / SERVICE_PREVIEW_TOKEN)
|
|
682
|
+
.read_text(encoding="utf-8")
|
|
683
|
+
.strip()
|
|
684
|
+
)
|
|
685
|
+
except OSError:
|
|
686
|
+
return payload
|
|
687
|
+
if token:
|
|
688
|
+
payload["access_url"] = _url_with_preview_token(preview_url, token)
|
|
689
|
+
return payload
|
|
690
|
+
|
|
691
|
+
def _service_dir(self, service_id: str) -> Path:
|
|
692
|
+
normalized = _normalize_service_id(service_id)
|
|
693
|
+
if not normalized:
|
|
694
|
+
raise ValueError("invalid service_id")
|
|
695
|
+
return self.state_dir / normalized
|
|
696
|
+
|
|
697
|
+
@staticmethod
|
|
698
|
+
def _safe_unlink(path: Path) -> None:
|
|
699
|
+
try:
|
|
700
|
+
path.unlink()
|
|
701
|
+
except FileNotFoundError:
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def normalize_readiness_spec(raw: dict[str, Any] | None) -> dict[str, Any]:
|
|
706
|
+
if not raw:
|
|
707
|
+
return {"type": "process_alive", "timeout_s": 1.0, "interval_s": 0.1}
|
|
708
|
+
if not isinstance(raw, dict):
|
|
709
|
+
return {"type": "process_alive", "timeout_s": 1.0, "interval_s": 0.1}
|
|
710
|
+
readiness_type = str(raw.get("type") or "process_alive").strip().lower()
|
|
711
|
+
out: dict[str, Any] = {
|
|
712
|
+
"type": readiness_type,
|
|
713
|
+
"timeout_s": _bounded_float(raw.get("timeout_s"), default=5.0, low=0.0, high=30.0),
|
|
714
|
+
"interval_s": _bounded_float(raw.get("interval_s"), default=0.1, low=0.02, high=2.0),
|
|
715
|
+
}
|
|
716
|
+
if readiness_type == "tcp":
|
|
717
|
+
out["host"] = str(raw.get("host") or "localhost")
|
|
718
|
+
out["port"] = int(raw.get("port") or 0)
|
|
719
|
+
elif readiness_type == "unix_socket":
|
|
720
|
+
out["path"] = str(raw.get("path") or "")
|
|
721
|
+
elif readiness_type == "command":
|
|
722
|
+
out["command"] = str(raw.get("command") or "")
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _resolve_preview_access(requested: object, *, configured: object) -> str:
|
|
727
|
+
requested_value = str(requested or "auto").strip().lower()
|
|
728
|
+
configured_value = str(configured or "auto").strip().lower()
|
|
729
|
+
allowed = {"auto", "local", "lan"}
|
|
730
|
+
if requested_value not in allowed:
|
|
731
|
+
raise ValueError(f"preview access must be one of: {', '.join(sorted(allowed))}")
|
|
732
|
+
if configured_value not in allowed:
|
|
733
|
+
configured_value = "auto"
|
|
734
|
+
effective = configured_value if requested_value == "auto" else requested_value
|
|
735
|
+
# Auto is deliberately semantic. The current runtime policy selects a local
|
|
736
|
+
# preview, while keeping the tool contract stable for future remote runtimes.
|
|
737
|
+
return "local" if effective == "auto" else effective
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _host_resolves_only_to_loopback(host: str) -> bool:
|
|
741
|
+
cleaned = str(host or "").strip()
|
|
742
|
+
if not cleaned:
|
|
743
|
+
return False
|
|
744
|
+
try:
|
|
745
|
+
addresses = socket.getaddrinfo(
|
|
746
|
+
cleaned,
|
|
747
|
+
None,
|
|
748
|
+
family=socket.AF_UNSPEC,
|
|
749
|
+
type=socket.SOCK_STREAM,
|
|
750
|
+
)
|
|
751
|
+
except OSError:
|
|
752
|
+
return False
|
|
753
|
+
resolved: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
|
754
|
+
for candidate in addresses:
|
|
755
|
+
raw_address = str(candidate[4][0]).split("%", 1)[0]
|
|
756
|
+
try:
|
|
757
|
+
resolved.append(ipaddress.ip_address(raw_address))
|
|
758
|
+
except ValueError:
|
|
759
|
+
return False
|
|
760
|
+
return bool(resolved) and all(address.is_loopback for address in resolved)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _system_loopback_address() -> str:
|
|
764
|
+
candidates = socket.getaddrinfo(
|
|
765
|
+
"localhost",
|
|
766
|
+
None,
|
|
767
|
+
family=socket.AF_UNSPEC,
|
|
768
|
+
type=socket.SOCK_STREAM,
|
|
769
|
+
)
|
|
770
|
+
# Docker Desktop and WSL consistently support IPv4 host publication even
|
|
771
|
+
# when their IPv6 bridge is disabled. We still use the address returned by
|
|
772
|
+
# the operating system rather than embedding one in the service policy.
|
|
773
|
+
candidates.sort(key=lambda candidate: candidate[0] != socket.AF_INET)
|
|
774
|
+
for candidate in candidates:
|
|
775
|
+
raw_address = str(candidate[4][0]).split("%", 1)[0]
|
|
776
|
+
try:
|
|
777
|
+
address = ipaddress.ip_address(raw_address)
|
|
778
|
+
except ValueError:
|
|
779
|
+
continue
|
|
780
|
+
if address.is_loopback:
|
|
781
|
+
return address.compressed
|
|
782
|
+
raise RuntimeError("The operating system did not provide a local loopback address")
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _preview_metadata_from_readiness(readiness: dict[str, Any]) -> dict[str, Any]:
|
|
786
|
+
urls = [str(item) for item in readiness.get("preview_urls") or [] if str(item).strip()]
|
|
787
|
+
if not urls:
|
|
788
|
+
raise ValueError("preview readiness did not provide a reachable URL")
|
|
789
|
+
return {
|
|
790
|
+
"preview_url": urls[0],
|
|
791
|
+
"preview_urls": urls,
|
|
792
|
+
"preview_port": int(readiness.get("port") or 0),
|
|
793
|
+
"preview_runtime": str(readiness.get("runtime") or "unknown"),
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _read_preview_ready(path: Path, *, expected_access: str) -> dict[str, Any]:
|
|
798
|
+
try:
|
|
799
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
800
|
+
except FileNotFoundError as exc:
|
|
801
|
+
raise OSError("preview readiness file has not been created") from exc
|
|
802
|
+
except json.JSONDecodeError as exc:
|
|
803
|
+
raise ValueError("preview readiness file is invalid JSON") from exc
|
|
804
|
+
if not isinstance(raw, dict):
|
|
805
|
+
raise ValueError("preview readiness payload must be an object")
|
|
806
|
+
access = str(raw.get("access") or "").strip().lower()
|
|
807
|
+
if access not in {"local", "lan"} or access != expected_access:
|
|
808
|
+
raise ValueError("preview readiness access does not match the requested policy")
|
|
809
|
+
try:
|
|
810
|
+
port = int(raw.get("port") or 0)
|
|
811
|
+
except (TypeError, ValueError) as exc:
|
|
812
|
+
raise ValueError("preview readiness port is invalid") from exc
|
|
813
|
+
if port <= 0 or port > 65535:
|
|
814
|
+
raise ValueError("preview readiness port is out of range")
|
|
815
|
+
probe_host = str(raw.get("probe_host") or "").strip()
|
|
816
|
+
if not probe_host or not _host_resolves_only_to_loopback(probe_host):
|
|
817
|
+
raise ValueError("preview readiness probe host must resolve only to loopback")
|
|
818
|
+
authentication_required = bool(raw.get("authentication_required"))
|
|
819
|
+
if authentication_required != (access == "lan"):
|
|
820
|
+
raise ValueError("preview readiness authentication does not match the access policy")
|
|
821
|
+
urls: list[str] = []
|
|
822
|
+
for candidate in raw.get("preview_urls") or []:
|
|
823
|
+
value = str(candidate or "").strip()
|
|
824
|
+
parsed = urlsplit(value)
|
|
825
|
+
try:
|
|
826
|
+
parsed_port = parsed.port
|
|
827
|
+
except ValueError:
|
|
828
|
+
continue
|
|
829
|
+
if parsed.scheme != "http" or not parsed.hostname or parsed_port != port:
|
|
830
|
+
continue
|
|
831
|
+
urls.append(value)
|
|
832
|
+
if not urls:
|
|
833
|
+
raise ValueError("preview readiness URLs are invalid")
|
|
834
|
+
return {
|
|
835
|
+
"access": access,
|
|
836
|
+
"port": port,
|
|
837
|
+
"probe_host": probe_host,
|
|
838
|
+
"preview_urls": list(dict.fromkeys(urls)),
|
|
839
|
+
"runtime": str(raw.get("runtime") or "unknown"),
|
|
840
|
+
"authentication_required": authentication_required,
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _write_private_text(path: Path, value: str) -> None:
|
|
845
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
846
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
847
|
+
try:
|
|
848
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
849
|
+
handle.write(value)
|
|
850
|
+
handle.write("\n")
|
|
851
|
+
except BaseException:
|
|
852
|
+
try:
|
|
853
|
+
path.unlink()
|
|
854
|
+
except FileNotFoundError:
|
|
855
|
+
pass
|
|
856
|
+
raise
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _url_with_preview_token(url: str, token: str) -> str:
|
|
860
|
+
parsed = urlsplit(url)
|
|
861
|
+
query = parse_qsl(parsed.query, keep_blank_values=True)
|
|
862
|
+
query = [(key, value) for key, value in query if key != "alysis_token"]
|
|
863
|
+
query.append(("alysis_token", token))
|
|
864
|
+
return urlunsplit(
|
|
865
|
+
(parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _readiness_timeout(readiness: dict[str, Any]) -> float:
|
|
870
|
+
return _bounded_float(readiness.get("timeout_s"), default=5.0, low=0.0, high=30.0)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _bounded_float(value: Any, *, default: float, low: float, high: float) -> float:
|
|
874
|
+
try:
|
|
875
|
+
number = float(value)
|
|
876
|
+
except (TypeError, ValueError):
|
|
877
|
+
number = default
|
|
878
|
+
return max(low, min(high, number))
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _safe_parent_env() -> dict[str, str]:
|
|
882
|
+
blocked = {key.upper() for key in _SENSITIVE_ENV_KEYS}
|
|
883
|
+
return _apply_env_overrides(
|
|
884
|
+
{key: value for key, value in os.environ.items() if key.upper() not in blocked},
|
|
885
|
+
None,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _service_startup_error(*, stderr_path: Path, stdout_path: Path) -> str | None:
|
|
890
|
+
for path in (stderr_path, stdout_path):
|
|
891
|
+
try:
|
|
892
|
+
raw = path.read_bytes()[-16_384:]
|
|
893
|
+
except OSError:
|
|
894
|
+
continue
|
|
895
|
+
text = raw.decode("utf-8", errors="replace").strip()
|
|
896
|
+
summary = sanitize_optional_error_summary(text, max_chars=1000)
|
|
897
|
+
if summary:
|
|
898
|
+
return summary
|
|
899
|
+
return None
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _normalize_service_id(service_id: str) -> str:
|
|
903
|
+
cleaned = str(service_id or "").strip()
|
|
904
|
+
if not cleaned or any(ch not in "abcdefghijklmnopqrstuvwxyz0123456789_" for ch in cleaned):
|
|
905
|
+
return ""
|
|
906
|
+
return cleaned
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _metadata_public_payload(
|
|
910
|
+
metadata: dict[str, Any],
|
|
911
|
+
*,
|
|
912
|
+
status: str,
|
|
913
|
+
alive: bool,
|
|
914
|
+
identity_valid: bool,
|
|
915
|
+
readiness: dict[str, Any],
|
|
916
|
+
) -> dict[str, Any]:
|
|
917
|
+
payload = {
|
|
918
|
+
"service_id": metadata.get("service_id"),
|
|
919
|
+
"ownership": metadata.get("ownership") or ProcessOwnership.DURABLE_SERVICE.value,
|
|
920
|
+
"status": status,
|
|
921
|
+
"alive": bool(alive),
|
|
922
|
+
"identity_valid": bool(identity_valid),
|
|
923
|
+
"pid": metadata.get("pid"),
|
|
924
|
+
"pgid": metadata.get("pgid"),
|
|
925
|
+
"backend": metadata.get("backend"),
|
|
926
|
+
"readiness": readiness,
|
|
927
|
+
"start_timestamp": metadata.get("started_at_wall"),
|
|
928
|
+
"stdout_log_path": metadata.get("stdout_log_path"),
|
|
929
|
+
"stderr_log_path": metadata.get("stderr_log_path"),
|
|
930
|
+
"log_paths": {
|
|
931
|
+
"stdout": metadata.get("stdout_log_path"),
|
|
932
|
+
"stderr": metadata.get("stderr_log_path"),
|
|
933
|
+
},
|
|
934
|
+
"failure_category": None
|
|
935
|
+
if status == DurableServiceStatus.RUNNING.value
|
|
936
|
+
and readiness.get("status") == ReadinessStatus.READY.value
|
|
937
|
+
else "service_not_ready",
|
|
938
|
+
}
|
|
939
|
+
preview_url = str(metadata.get("preview_url") or "").strip()
|
|
940
|
+
if preview_url:
|
|
941
|
+
payload["preview_url"] = preview_url
|
|
942
|
+
preview_urls = [str(item) for item in metadata.get("preview_urls") or [] if str(item).strip()]
|
|
943
|
+
if preview_urls:
|
|
944
|
+
payload["preview_urls"] = preview_urls
|
|
945
|
+
preview_access = str(metadata.get("preview_access") or "").strip()
|
|
946
|
+
if preview_access:
|
|
947
|
+
payload["preview_access"] = preview_access
|
|
948
|
+
payload["authentication_required"] = bool(metadata.get("preview_authentication_required"))
|
|
949
|
+
payload["preview_runtime"] = str(metadata.get("preview_runtime") or "unknown")
|
|
950
|
+
payload["preview_port"] = int(metadata.get("preview_port") or 0)
|
|
951
|
+
return payload
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _sanitize_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|
955
|
+
allowed_keys = {
|
|
956
|
+
"schema_version",
|
|
957
|
+
"service_id",
|
|
958
|
+
"ownership",
|
|
959
|
+
"status",
|
|
960
|
+
"pid",
|
|
961
|
+
"pgid",
|
|
962
|
+
"pid_start_token",
|
|
963
|
+
"started_at_wall",
|
|
964
|
+
"root",
|
|
965
|
+
"cwd",
|
|
966
|
+
"backend",
|
|
967
|
+
"container_name",
|
|
968
|
+
"cmd_sha256",
|
|
969
|
+
"readiness",
|
|
970
|
+
"stdout_log_path",
|
|
971
|
+
"stderr_log_path",
|
|
972
|
+
"preview_url",
|
|
973
|
+
"preview_urls",
|
|
974
|
+
"preview_access",
|
|
975
|
+
"preview_authentication_required",
|
|
976
|
+
"preview_port",
|
|
977
|
+
"preview_runtime",
|
|
978
|
+
}
|
|
979
|
+
return {key: metadata[key] for key in sorted(allowed_keys) if key in metadata}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _pid_exists(pid: int) -> bool:
|
|
983
|
+
if pid <= 0:
|
|
984
|
+
return False
|
|
985
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
986
|
+
# ``os.kill(pid, 0)`` is not a non-mutating existence probe on Windows:
|
|
987
|
+
# CPython implements unsupported signals through TerminateProcess, so it
|
|
988
|
+
# can kill the service while it is still starting. Query its exit status
|
|
989
|
+
# through a read-only process handle instead.
|
|
990
|
+
import ctypes
|
|
991
|
+
from ctypes import wintypes
|
|
992
|
+
|
|
993
|
+
process_query_limited_information = 0x1000
|
|
994
|
+
still_active = 259
|
|
995
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
996
|
+
open_process = kernel32.OpenProcess
|
|
997
|
+
open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
998
|
+
open_process.restype = wintypes.HANDLE
|
|
999
|
+
get_exit_code_process = kernel32.GetExitCodeProcess
|
|
1000
|
+
get_exit_code_process.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
1001
|
+
get_exit_code_process.restype = wintypes.BOOL
|
|
1002
|
+
close_handle = kernel32.CloseHandle
|
|
1003
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
1004
|
+
close_handle.restype = wintypes.BOOL
|
|
1005
|
+
|
|
1006
|
+
handle = open_process(process_query_limited_information, False, pid)
|
|
1007
|
+
if not handle:
|
|
1008
|
+
return False
|
|
1009
|
+
try:
|
|
1010
|
+
exit_code = wintypes.DWORD()
|
|
1011
|
+
if not get_exit_code_process(handle, ctypes.byref(exit_code)):
|
|
1012
|
+
return False
|
|
1013
|
+
return int(exit_code.value) == still_active
|
|
1014
|
+
finally:
|
|
1015
|
+
close_handle(handle)
|
|
1016
|
+
try:
|
|
1017
|
+
os.kill(pid, 0)
|
|
1018
|
+
except OSError as exc:
|
|
1019
|
+
return exc.errno == errno.EPERM
|
|
1020
|
+
return True
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _pid_start_token(pid: int) -> str | None:
|
|
1024
|
+
stat_path = Path("/proc") / str(pid) / "stat"
|
|
1025
|
+
try:
|
|
1026
|
+
text = stat_path.read_text(encoding="utf-8")
|
|
1027
|
+
except OSError:
|
|
1028
|
+
return None
|
|
1029
|
+
try:
|
|
1030
|
+
after_comm = text.rsplit(") ", 1)[1]
|
|
1031
|
+
fields = after_comm.split()
|
|
1032
|
+
return f"linux:{fields[19]}"
|
|
1033
|
+
except (IndexError, ValueError):
|
|
1034
|
+
return None
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _process_group_id(pid: int) -> int | None:
|
|
1038
|
+
if os.name == "nt":
|
|
1039
|
+
return None
|
|
1040
|
+
try:
|
|
1041
|
+
return os.getpgid(pid)
|
|
1042
|
+
except OSError:
|
|
1043
|
+
return None
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _terminate_popen_tree(popen: subprocess.Popen[bytes], *, timeout_s: float) -> None:
|
|
1047
|
+
if popen.poll() is not None:
|
|
1048
|
+
return
|
|
1049
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
1050
|
+
_terminate_windows_process_tree(pid=popen.pid, timeout_s=timeout_s)
|
|
1051
|
+
else:
|
|
1052
|
+
pgid = _process_group_id(popen.pid)
|
|
1053
|
+
if pgid is not None:
|
|
1054
|
+
with _ignore_process_lookup():
|
|
1055
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
1056
|
+
else:
|
|
1057
|
+
with _ignore_process_lookup():
|
|
1058
|
+
popen.terminate()
|
|
1059
|
+
try:
|
|
1060
|
+
popen.wait(timeout=timeout_s)
|
|
1061
|
+
return
|
|
1062
|
+
except subprocess.TimeoutExpired:
|
|
1063
|
+
pass
|
|
1064
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
1065
|
+
_terminate_windows_process_tree(pid=popen.pid, timeout_s=_KILL_TIMEOUT_S)
|
|
1066
|
+
else:
|
|
1067
|
+
pgid = _process_group_id(popen.pid)
|
|
1068
|
+
if pgid is not None:
|
|
1069
|
+
with _ignore_process_lookup():
|
|
1070
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
1071
|
+
else:
|
|
1072
|
+
with _ignore_process_lookup():
|
|
1073
|
+
popen.kill()
|
|
1074
|
+
with _ignore_timeout():
|
|
1075
|
+
popen.wait(timeout=_KILL_TIMEOUT_S)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _terminate_pid_or_group(*, pid: int, pgid: Any, timeout_s: float) -> None:
|
|
1079
|
+
if pid <= 0:
|
|
1080
|
+
return
|
|
1081
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
1082
|
+
_terminate_windows_process_tree(pid=pid, timeout_s=timeout_s)
|
|
1083
|
+
return
|
|
1084
|
+
_signal_pid_or_group(pid=pid, pgid=pgid, sig=signal.SIGTERM)
|
|
1085
|
+
deadline = time.monotonic() + timeout_s
|
|
1086
|
+
while time.monotonic() < deadline:
|
|
1087
|
+
if not _pid_exists(pid):
|
|
1088
|
+
return
|
|
1089
|
+
if _pid_is_zombie(pid):
|
|
1090
|
+
_signal_pid_or_group(pid=pid, pgid=pgid, sig=signal.SIGKILL)
|
|
1091
|
+
return
|
|
1092
|
+
time.sleep(0.05)
|
|
1093
|
+
_signal_pid_or_group(pid=pid, pgid=pgid, sig=signal.SIGKILL)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _terminate_windows_process_tree(*, pid: int, timeout_s: float) -> None:
|
|
1097
|
+
if pid <= 0:
|
|
1098
|
+
return
|
|
1099
|
+
_ = timeout_s
|
|
1100
|
+
import ctypes
|
|
1101
|
+
from ctypes import wintypes
|
|
1102
|
+
|
|
1103
|
+
th32cs_snapprocess = 0x00000002
|
|
1104
|
+
process_terminate = 0x0001
|
|
1105
|
+
invalid_handle_value = ctypes.c_void_p(-1).value
|
|
1106
|
+
|
|
1107
|
+
class ProcessEntry32W(ctypes.Structure):
|
|
1108
|
+
_fields_ = [
|
|
1109
|
+
("dwSize", wintypes.DWORD),
|
|
1110
|
+
("cntUsage", wintypes.DWORD),
|
|
1111
|
+
("th32ProcessID", wintypes.DWORD),
|
|
1112
|
+
("th32DefaultHeapID", ctypes.c_size_t),
|
|
1113
|
+
("th32ModuleID", wintypes.DWORD),
|
|
1114
|
+
("cntThreads", wintypes.DWORD),
|
|
1115
|
+
("th32ParentProcessID", wintypes.DWORD),
|
|
1116
|
+
("pcPriClassBase", wintypes.LONG),
|
|
1117
|
+
("dwFlags", wintypes.DWORD),
|
|
1118
|
+
("szExeFile", wintypes.WCHAR * 260),
|
|
1119
|
+
]
|
|
1120
|
+
|
|
1121
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1122
|
+
create_snapshot = kernel32.CreateToolhelp32Snapshot
|
|
1123
|
+
create_snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
|
1124
|
+
create_snapshot.restype = wintypes.HANDLE
|
|
1125
|
+
process_first = kernel32.Process32FirstW
|
|
1126
|
+
process_first.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32W)]
|
|
1127
|
+
process_first.restype = wintypes.BOOL
|
|
1128
|
+
process_next = kernel32.Process32NextW
|
|
1129
|
+
process_next.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32W)]
|
|
1130
|
+
process_next.restype = wintypes.BOOL
|
|
1131
|
+
open_process = kernel32.OpenProcess
|
|
1132
|
+
open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
1133
|
+
open_process.restype = wintypes.HANDLE
|
|
1134
|
+
terminate_process = kernel32.TerminateProcess
|
|
1135
|
+
terminate_process.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
1136
|
+
terminate_process.restype = wintypes.BOOL
|
|
1137
|
+
close_handle = kernel32.CloseHandle
|
|
1138
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
1139
|
+
close_handle.restype = wintypes.BOOL
|
|
1140
|
+
|
|
1141
|
+
children_by_parent: dict[int, list[int]] = {}
|
|
1142
|
+
snapshot = create_snapshot(th32cs_snapprocess, 0)
|
|
1143
|
+
if snapshot and snapshot != invalid_handle_value:
|
|
1144
|
+
try:
|
|
1145
|
+
entry = ProcessEntry32W()
|
|
1146
|
+
entry.dwSize = ctypes.sizeof(entry)
|
|
1147
|
+
if process_first(snapshot, ctypes.byref(entry)):
|
|
1148
|
+
while True:
|
|
1149
|
+
child_pid = int(entry.th32ProcessID)
|
|
1150
|
+
parent_pid = int(entry.th32ParentProcessID)
|
|
1151
|
+
children_by_parent.setdefault(parent_pid, []).append(child_pid)
|
|
1152
|
+
if not process_next(snapshot, ctypes.byref(entry)):
|
|
1153
|
+
break
|
|
1154
|
+
finally:
|
|
1155
|
+
close_handle(snapshot)
|
|
1156
|
+
|
|
1157
|
+
ordered: list[int] = []
|
|
1158
|
+
pending = [pid]
|
|
1159
|
+
while pending:
|
|
1160
|
+
current = pending.pop()
|
|
1161
|
+
ordered.append(current)
|
|
1162
|
+
pending.extend(children_by_parent.get(current, ()))
|
|
1163
|
+
|
|
1164
|
+
for target_pid in reversed(ordered):
|
|
1165
|
+
handle = open_process(process_terminate, False, target_pid)
|
|
1166
|
+
if not handle:
|
|
1167
|
+
continue
|
|
1168
|
+
try:
|
|
1169
|
+
terminate_process(handle, 1)
|
|
1170
|
+
finally:
|
|
1171
|
+
close_handle(handle)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _signal_pid_or_group(*, pid: int, pgid: Any, sig: int) -> None:
|
|
1175
|
+
if os.name != "nt" and isinstance(pgid, int) and pgid > 0:
|
|
1176
|
+
try:
|
|
1177
|
+
os.killpg(pgid, sig)
|
|
1178
|
+
return
|
|
1179
|
+
except ProcessLookupError:
|
|
1180
|
+
return
|
|
1181
|
+
except PermissionError:
|
|
1182
|
+
# On macOS, an exited-but-unreaped process may keep its PID while its
|
|
1183
|
+
# process group is no longer signalable. Fall back to the leader PID;
|
|
1184
|
+
# a genuine permission failure still propagates from this call.
|
|
1185
|
+
pass
|
|
1186
|
+
with _ignore_process_lookup():
|
|
1187
|
+
os.kill(pid, sig)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _pid_is_zombie(pid: int) -> bool:
|
|
1191
|
+
if os.name == "nt" or pid <= 0:
|
|
1192
|
+
return False
|
|
1193
|
+
stat_path = Path("/proc") / str(pid) / "stat"
|
|
1194
|
+
try:
|
|
1195
|
+
text = stat_path.read_text(encoding="utf-8")
|
|
1196
|
+
except OSError:
|
|
1197
|
+
text = ""
|
|
1198
|
+
if text:
|
|
1199
|
+
try:
|
|
1200
|
+
state = text.rsplit(") ", 1)[1].split(maxsplit=1)[0]
|
|
1201
|
+
except (IndexError, ValueError):
|
|
1202
|
+
state = ""
|
|
1203
|
+
if state:
|
|
1204
|
+
return state == "Z"
|
|
1205
|
+
|
|
1206
|
+
ps = shutil.which("ps")
|
|
1207
|
+
if not ps:
|
|
1208
|
+
return False
|
|
1209
|
+
try:
|
|
1210
|
+
completed = subprocess.run(
|
|
1211
|
+
[ps, "-o", "stat=", "-p", str(pid)],
|
|
1212
|
+
check=False,
|
|
1213
|
+
capture_output=True,
|
|
1214
|
+
text=True,
|
|
1215
|
+
timeout=1.0,
|
|
1216
|
+
)
|
|
1217
|
+
except (OSError, subprocess.SubprocessError):
|
|
1218
|
+
return False
|
|
1219
|
+
state = completed.stdout.strip().split(maxsplit=1)[0] if completed.stdout.strip() else ""
|
|
1220
|
+
return state.startswith("Z")
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
class _ignore_process_lookup:
|
|
1224
|
+
def __enter__(self) -> None:
|
|
1225
|
+
return None
|
|
1226
|
+
|
|
1227
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
|
1228
|
+
return isinstance(exc, ProcessLookupError)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
class _ignore_timeout:
|
|
1232
|
+
def __enter__(self) -> None:
|
|
1233
|
+
return None
|
|
1234
|
+
|
|
1235
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
|
1236
|
+
return isinstance(exc, subprocess.TimeoutExpired)
|