@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0
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.
- package/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- package/package.json +9 -2
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pid_is_running(pid: int) -> bool:
|
|
18
|
+
"""Check process liveness using the host platform's stable primitive."""
|
|
19
|
+
|
|
20
|
+
if pid <= 0:
|
|
21
|
+
return False
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
try:
|
|
24
|
+
process = subprocess.run(
|
|
25
|
+
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
26
|
+
text=True,
|
|
27
|
+
stdout=subprocess.PIPE,
|
|
28
|
+
stderr=subprocess.DEVNULL,
|
|
29
|
+
timeout=3,
|
|
30
|
+
)
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
output = process.stdout or ""
|
|
34
|
+
return str(pid) in output and "No tasks" not in output and "INFO:" not in output
|
|
35
|
+
try:
|
|
36
|
+
os.kill(pid, 0)
|
|
37
|
+
return True
|
|
38
|
+
except (OSError, SystemError):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalized_listener_host(host: str) -> str:
|
|
43
|
+
value = str(host or "").strip().lower()
|
|
44
|
+
if value.startswith("[") and value.endswith("]"):
|
|
45
|
+
value = value[1:-1]
|
|
46
|
+
if value == "localhost":
|
|
47
|
+
return "127.0.0.1"
|
|
48
|
+
try:
|
|
49
|
+
return str(ipaddress.ip_address(value.split("%", 1)[0]))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _listener_host_conflicts(local_host: str, bind_host: str | None) -> bool:
|
|
55
|
+
if not bind_host:
|
|
56
|
+
return True
|
|
57
|
+
local = _normalized_listener_host(local_host)
|
|
58
|
+
wanted = _normalized_listener_host(bind_host)
|
|
59
|
+
if local in {"0.0.0.0", "::", "*"} or wanted in {"0.0.0.0", "::", "*"}:
|
|
60
|
+
return True
|
|
61
|
+
try:
|
|
62
|
+
local_ip = ipaddress.ip_address(local)
|
|
63
|
+
wanted_ip = ipaddress.ip_address(wanted)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return local == wanted
|
|
66
|
+
return local_ip == wanted_ip
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_listener_endpoint(endpoint: str) -> tuple[str, str]:
|
|
70
|
+
value = str(endpoint or "").strip()
|
|
71
|
+
if value.startswith("[") and "]:" in value:
|
|
72
|
+
closing = value.rfind("]:")
|
|
73
|
+
return value[1:closing], value[closing + 2:]
|
|
74
|
+
if ":" not in value:
|
|
75
|
+
return value, ""
|
|
76
|
+
host, port = value.rsplit(":", 1)
|
|
77
|
+
return host, port
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def windows_pids_on_port(port: int, host: str | None = None) -> list[int]:
|
|
81
|
+
if os.name != "nt":
|
|
82
|
+
return []
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
86
|
+
text=True,
|
|
87
|
+
stdout=subprocess.PIPE,
|
|
88
|
+
stderr=subprocess.DEVNULL,
|
|
89
|
+
timeout=5,
|
|
90
|
+
)
|
|
91
|
+
except Exception:
|
|
92
|
+
return []
|
|
93
|
+
pids: set[int] = set()
|
|
94
|
+
for line in proc.stdout.splitlines():
|
|
95
|
+
if "LISTENING" not in line:
|
|
96
|
+
continue
|
|
97
|
+
parts = line.split()
|
|
98
|
+
if len(parts) < 5:
|
|
99
|
+
continue
|
|
100
|
+
local_host, local_port = _split_listener_endpoint(parts[1])
|
|
101
|
+
if local_port != str(port) or not _listener_host_conflicts(local_host, host):
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
pids.add(int(parts[-1]))
|
|
105
|
+
except ValueError:
|
|
106
|
+
continue
|
|
107
|
+
return sorted(pids)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _linux_procfs_host(encoded: str, *, ipv6: bool) -> str:
|
|
111
|
+
raw = bytes.fromhex(encoded)
|
|
112
|
+
if ipv6:
|
|
113
|
+
raw = b"".join(raw[index:index + 4][::-1] for index in range(0, 16, 4))
|
|
114
|
+
return str(ipaddress.IPv6Address(raw))
|
|
115
|
+
return str(ipaddress.IPv4Address(raw[::-1]))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def linux_procfs_pids_on_port(port: int, host: str | None = None) -> list[int]:
|
|
119
|
+
if os.name == "nt":
|
|
120
|
+
return []
|
|
121
|
+
wanted_port = f"{int(port):04X}"
|
|
122
|
+
inodes: set[str] = set()
|
|
123
|
+
for table in (Path("/proc/net/tcp"), Path("/proc/net/tcp6")):
|
|
124
|
+
try:
|
|
125
|
+
lines = table.read_text(encoding="utf-8", errors="replace").splitlines()[1:]
|
|
126
|
+
except Exception:
|
|
127
|
+
continue
|
|
128
|
+
for line in lines:
|
|
129
|
+
parts = line.split()
|
|
130
|
+
if len(parts) < 10:
|
|
131
|
+
continue
|
|
132
|
+
local_addr = parts[1]
|
|
133
|
+
state = parts[3]
|
|
134
|
+
inode = parts[9]
|
|
135
|
+
if state != "0A" or ":" not in local_addr:
|
|
136
|
+
continue
|
|
137
|
+
encoded_host, encoded_port = local_addr.rsplit(":", 1)
|
|
138
|
+
try:
|
|
139
|
+
local_host = _linux_procfs_host(encoded_host, ipv6=table.name == "tcp6")
|
|
140
|
+
except (ValueError, ipaddress.AddressValueError):
|
|
141
|
+
continue
|
|
142
|
+
if (
|
|
143
|
+
encoded_port.upper() == wanted_port
|
|
144
|
+
and inode
|
|
145
|
+
and inode != "0"
|
|
146
|
+
and _listener_host_conflicts(local_host, host)
|
|
147
|
+
):
|
|
148
|
+
inodes.add(inode)
|
|
149
|
+
if not inodes:
|
|
150
|
+
return []
|
|
151
|
+
pids: set[int] = set()
|
|
152
|
+
current = os.getpid()
|
|
153
|
+
parent = os.getppid()
|
|
154
|
+
try:
|
|
155
|
+
proc_entries = list(Path("/proc").iterdir())
|
|
156
|
+
except Exception:
|
|
157
|
+
return []
|
|
158
|
+
for entry in proc_entries:
|
|
159
|
+
if not entry.name.isdigit():
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
pid = int(entry.name)
|
|
163
|
+
except ValueError:
|
|
164
|
+
continue
|
|
165
|
+
if pid in (current, parent):
|
|
166
|
+
continue
|
|
167
|
+
fd_dir = entry / "fd"
|
|
168
|
+
try:
|
|
169
|
+
fds = list(fd_dir.iterdir())
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
for fd in fds:
|
|
173
|
+
try:
|
|
174
|
+
target = os.readlink(fd)
|
|
175
|
+
except Exception:
|
|
176
|
+
continue
|
|
177
|
+
match = re.match(r"socket:\[(\d+)\]$", target)
|
|
178
|
+
if match and match.group(1) in inodes:
|
|
179
|
+
pids.add(pid)
|
|
180
|
+
break
|
|
181
|
+
return sorted(pids)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def posix_pids_on_port(
|
|
185
|
+
port: int,
|
|
186
|
+
procfs_lookup: Callable[..., list[int]] = linux_procfs_pids_on_port,
|
|
187
|
+
host: str | None = None,
|
|
188
|
+
) -> list[int]:
|
|
189
|
+
if os.name == "nt":
|
|
190
|
+
return []
|
|
191
|
+
pids: set[int] = set(procfs_lookup(port, host) if host else procfs_lookup(port))
|
|
192
|
+
if host and Path("/proc/net/tcp").exists():
|
|
193
|
+
return sorted(pid for pid in pids if pid not in (os.getpid(), os.getppid()))
|
|
194
|
+
|
|
195
|
+
def add_ints(text: str, *, skip_port: bool = False) -> None:
|
|
196
|
+
for value in re.findall(r"\b\d+\b", text or ""):
|
|
197
|
+
try:
|
|
198
|
+
pid = int(value)
|
|
199
|
+
except ValueError:
|
|
200
|
+
continue
|
|
201
|
+
if skip_port and pid == port:
|
|
202
|
+
continue
|
|
203
|
+
pids.add(pid)
|
|
204
|
+
|
|
205
|
+
commands: list[tuple[list[str], str]] = [
|
|
206
|
+
(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], "plain"),
|
|
207
|
+
(["fuser", "-n", "tcp", str(port)], "fuser"),
|
|
208
|
+
(["ss", "-ltnp", f"sport = :{port}"], "ss"),
|
|
209
|
+
(["netstat", "-ltnp"], "netstat"),
|
|
210
|
+
]
|
|
211
|
+
for cmd, kind in commands:
|
|
212
|
+
if not shutil.which(cmd[0]):
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
proc = subprocess.run(
|
|
216
|
+
cmd,
|
|
217
|
+
text=True,
|
|
218
|
+
stdout=subprocess.PIPE,
|
|
219
|
+
stderr=subprocess.PIPE,
|
|
220
|
+
timeout=5,
|
|
221
|
+
)
|
|
222
|
+
except Exception:
|
|
223
|
+
continue
|
|
224
|
+
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
|
225
|
+
if kind == "plain":
|
|
226
|
+
add_ints(text)
|
|
227
|
+
elif kind == "fuser":
|
|
228
|
+
add_ints(text.split(":", 1)[1] if ":" in text else text, skip_port=True)
|
|
229
|
+
elif kind == "ss":
|
|
230
|
+
for value in re.findall(r"pid=(\d+)", text):
|
|
231
|
+
add_ints(value)
|
|
232
|
+
else:
|
|
233
|
+
for line in text.splitlines():
|
|
234
|
+
if "LISTEN" not in line or f":{port}" not in line:
|
|
235
|
+
continue
|
|
236
|
+
match = re.search(r"\s(\d+)/", line)
|
|
237
|
+
if match:
|
|
238
|
+
add_ints(match.group(1))
|
|
239
|
+
return sorted(pid for pid in pids if pid not in (os.getpid(), os.getppid()))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class ProcessQueryServices:
|
|
244
|
+
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run
|
|
245
|
+
username: Callable[[], str] = getpass.getuser
|
|
246
|
+
current_pid: Callable[[], int] = os.getpid
|
|
247
|
+
parent_pid: Callable[[], int] = os.getppid
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class ProcessSignalServices:
|
|
252
|
+
kill: Callable[[int, int], None]
|
|
253
|
+
pid_is_running: Callable[[int], bool]
|
|
254
|
+
now: Callable[[], float] = time.time
|
|
255
|
+
sleep: Callable[[float], None] = time.sleep
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class ProcessControlServices:
|
|
260
|
+
query: ProcessQueryServices
|
|
261
|
+
signals: ProcessSignalServices
|
|
262
|
+
log: Callable[[str, str], None]
|
|
263
|
+
output: Callable[[str], None] = print
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class ProcessTreeController:
|
|
267
|
+
"""Inspect and terminate one process tree through explicit query/signal ports."""
|
|
268
|
+
|
|
269
|
+
def __init__(self, services: ProcessControlServices, *, platform_name: str = os.name) -> None:
|
|
270
|
+
self._services = services
|
|
271
|
+
self._platform_name = platform_name
|
|
272
|
+
|
|
273
|
+
def terminate_pid(self, pid: int, label: str, *, quiet: bool = False) -> bool:
|
|
274
|
+
if not self._services.signals.pid_is_running(pid):
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
if self._platform_name == "nt":
|
|
279
|
+
self._services.query.run(
|
|
280
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
281
|
+
stdout=subprocess.DEVNULL,
|
|
282
|
+
stderr=subprocess.DEVNULL,
|
|
283
|
+
timeout=8,
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
self._services.signals.kill(pid, signal.SIGTERM)
|
|
287
|
+
deadline = self._services.signals.now() + 4
|
|
288
|
+
while deadline > self._services.signals.now() and self._services.signals.pid_is_running(pid):
|
|
289
|
+
self._services.signals.sleep(0.1)
|
|
290
|
+
if self._services.signals.pid_is_running(pid):
|
|
291
|
+
self._services.signals.kill(pid, signal.SIGKILL)
|
|
292
|
+
if not quiet:
|
|
293
|
+
self._services.output(f"Stopped existing {label} session (pid {pid}).")
|
|
294
|
+
return True
|
|
295
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
296
|
+
self._services.log(
|
|
297
|
+
"WARN",
|
|
298
|
+
f"process_tree_terminate_failed label={label!r} pid={pid} error={type(exc).__name__}: {exc}",
|
|
299
|
+
)
|
|
300
|
+
if not quiet:
|
|
301
|
+
self._services.output(f"Could not stop existing {label} session ({type(exc).__name__}).")
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
def terminate_port(
|
|
305
|
+
self,
|
|
306
|
+
port: int,
|
|
307
|
+
label: str,
|
|
308
|
+
*,
|
|
309
|
+
quiet: bool = False,
|
|
310
|
+
pids_on_port: Callable[[int], list[int]],
|
|
311
|
+
stopped_noun: str = "listener(s)",
|
|
312
|
+
) -> bool:
|
|
313
|
+
pids = pids_on_port(port)
|
|
314
|
+
protected = {
|
|
315
|
+
self._services.query.current_pid(),
|
|
316
|
+
self._services.query.parent_pid(),
|
|
317
|
+
}
|
|
318
|
+
stopped = False
|
|
319
|
+
for pid in pids:
|
|
320
|
+
if pid in protected:
|
|
321
|
+
continue
|
|
322
|
+
stopped = self.terminate_pid(pid, label, quiet=True) or stopped
|
|
323
|
+
if stopped and not quiet:
|
|
324
|
+
self._services.output(
|
|
325
|
+
f"Stopped existing {label} {stopped_noun}: "
|
|
326
|
+
f"{', '.join(map(str, pids))}."
|
|
327
|
+
)
|
|
328
|
+
return stopped
|
|
329
|
+
|
|
330
|
+
def descendant_pids(self, pid: int) -> list[int]:
|
|
331
|
+
if pid <= 0 or self._platform_name == "nt":
|
|
332
|
+
return []
|
|
333
|
+
try:
|
|
334
|
+
result = self._services.query.run(
|
|
335
|
+
["ps", "-eo", "pid=,ppid="],
|
|
336
|
+
text=True,
|
|
337
|
+
stdout=subprocess.PIPE,
|
|
338
|
+
stderr=subprocess.DEVNULL,
|
|
339
|
+
timeout=5,
|
|
340
|
+
)
|
|
341
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
342
|
+
self._services.log("WARN", f"process_tree_query_failed pid={pid} error={type(exc).__name__}: {exc}")
|
|
343
|
+
return []
|
|
344
|
+
children: dict[int, list[int]] = {}
|
|
345
|
+
for line in result.stdout.splitlines():
|
|
346
|
+
parts = line.strip().split()
|
|
347
|
+
if len(parts) < 2:
|
|
348
|
+
continue
|
|
349
|
+
try:
|
|
350
|
+
child, parent = int(parts[0]), int(parts[1])
|
|
351
|
+
except ValueError:
|
|
352
|
+
continue
|
|
353
|
+
children.setdefault(parent, []).append(child)
|
|
354
|
+
descendants: list[int] = []
|
|
355
|
+
stack = list(children.get(pid, []))
|
|
356
|
+
while stack:
|
|
357
|
+
child = stack.pop()
|
|
358
|
+
if child in descendants:
|
|
359
|
+
continue
|
|
360
|
+
descendants.append(child)
|
|
361
|
+
stack.extend(children.get(child, []))
|
|
362
|
+
return descendants
|
|
363
|
+
|
|
364
|
+
def parent_pid_and_command(self, pid: int) -> tuple[int, str] | None:
|
|
365
|
+
if pid <= 0 or self._platform_name == "nt":
|
|
366
|
+
return None
|
|
367
|
+
try:
|
|
368
|
+
result = self._services.query.run(
|
|
369
|
+
["ps", "-p", str(pid), "-o", "ppid=,command="],
|
|
370
|
+
text=True,
|
|
371
|
+
stdout=subprocess.PIPE,
|
|
372
|
+
stderr=subprocess.DEVNULL,
|
|
373
|
+
timeout=5,
|
|
374
|
+
)
|
|
375
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
376
|
+
self._services.log("WARN", f"process_parent_query_failed pid={pid} error={type(exc).__name__}: {exc}")
|
|
377
|
+
return None
|
|
378
|
+
parts = result.stdout.strip().split(maxsplit=1)
|
|
379
|
+
if not parts:
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
parent = int(parts[0])
|
|
383
|
+
except ValueError:
|
|
384
|
+
return None
|
|
385
|
+
return parent, parts[1] if len(parts) > 1 else ""
|
|
386
|
+
|
|
387
|
+
def client_wrapper_parent_pids(self, pid: int) -> list[int]:
|
|
388
|
+
wrappers: list[int] = []
|
|
389
|
+
current = pid
|
|
390
|
+
protected = {self._services.query.current_pid(), self._services.query.parent_pid()}
|
|
391
|
+
for _ in range(4):
|
|
392
|
+
parent_info = self.parent_pid_and_command(current)
|
|
393
|
+
if parent_info is None:
|
|
394
|
+
break
|
|
395
|
+
parent, command = parent_info
|
|
396
|
+
if parent <= 0 or parent in protected:
|
|
397
|
+
break
|
|
398
|
+
if "ciel-runtime" not in command and "ciel_runtime.py" not in command:
|
|
399
|
+
break
|
|
400
|
+
if " serve" in command or " mcp-proxy" in command:
|
|
401
|
+
break
|
|
402
|
+
wrappers.append(parent)
|
|
403
|
+
current = parent
|
|
404
|
+
return wrappers
|
|
405
|
+
|
|
406
|
+
def terminate_tree(self, pid: int, label: str, *, quiet: bool = False) -> bool:
|
|
407
|
+
if pid <= 0:
|
|
408
|
+
return False
|
|
409
|
+
if self._platform_name == "nt":
|
|
410
|
+
return self.terminate_pid(pid, label, quiet=quiet)
|
|
411
|
+
protected = {self._services.query.current_pid(), self._services.query.parent_pid()}
|
|
412
|
+
targets = [item for item in [pid, *self.descendant_pids(pid)] if item > 0 and item not in protected]
|
|
413
|
+
if not targets:
|
|
414
|
+
return False
|
|
415
|
+
stopped = False
|
|
416
|
+
for target in targets:
|
|
417
|
+
try:
|
|
418
|
+
if self._services.signals.pid_is_running(target):
|
|
419
|
+
self._services.signals.kill(target, signal.SIGTERM)
|
|
420
|
+
stopped = True
|
|
421
|
+
except OSError as exc:
|
|
422
|
+
self._services.log(
|
|
423
|
+
"WARN",
|
|
424
|
+
f"process_tree_signal_failed signal=TERM pid={target} error={type(exc).__name__}: {exc}",
|
|
425
|
+
)
|
|
426
|
+
deadline = self._services.signals.now() + 4
|
|
427
|
+
while self._services.signals.now() < deadline:
|
|
428
|
+
if not any(self._services.signals.pid_is_running(target) for target in targets):
|
|
429
|
+
break
|
|
430
|
+
self._services.signals.sleep(0.1)
|
|
431
|
+
for target in targets:
|
|
432
|
+
if not self._services.signals.pid_is_running(target):
|
|
433
|
+
continue
|
|
434
|
+
try:
|
|
435
|
+
self._services.signals.kill(target, signal.SIGKILL)
|
|
436
|
+
stopped = True
|
|
437
|
+
except OSError as exc:
|
|
438
|
+
self._services.log(
|
|
439
|
+
"WARN",
|
|
440
|
+
f"process_tree_signal_failed signal=KILL pid={target} error={type(exc).__name__}: {exc}",
|
|
441
|
+
)
|
|
442
|
+
if stopped and not quiet:
|
|
443
|
+
self._services.output(f"Stopped existing {label} session(s): {', '.join(map(str, targets))}.")
|
|
444
|
+
return stopped
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@dataclass(frozen=True)
|
|
448
|
+
class ProcessInspectionServices:
|
|
449
|
+
run: Callable[..., subprocess.CompletedProcess[str]]
|
|
450
|
+
read_bytes: Callable[[Path], bytes]
|
|
451
|
+
readlink: Callable[[Path], str]
|
|
452
|
+
username: Callable[[], str]
|
|
453
|
+
log: Callable[[str, str], None]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def process_command_line(
|
|
457
|
+
pid: int,
|
|
458
|
+
services: ProcessInspectionServices,
|
|
459
|
+
*,
|
|
460
|
+
platform_name: str = os.name,
|
|
461
|
+
) -> str:
|
|
462
|
+
if pid <= 0:
|
|
463
|
+
return ""
|
|
464
|
+
if platform_name == "nt":
|
|
465
|
+
script = (
|
|
466
|
+
"Get-CimInstance Win32_Process -Filter "
|
|
467
|
+
f'"ProcessId = {int(pid)}" | Select-Object -ExpandProperty CommandLine'
|
|
468
|
+
)
|
|
469
|
+
command = ["powershell", "-NoProfile", "-Command", script]
|
|
470
|
+
else:
|
|
471
|
+
command = ["ps", "-p", str(pid), "-o", "command="]
|
|
472
|
+
try:
|
|
473
|
+
result = services.run(
|
|
474
|
+
command,
|
|
475
|
+
text=True,
|
|
476
|
+
stdout=subprocess.PIPE,
|
|
477
|
+
stderr=subprocess.DEVNULL,
|
|
478
|
+
timeout=5,
|
|
479
|
+
)
|
|
480
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
481
|
+
services.log(
|
|
482
|
+
"WARN",
|
|
483
|
+
f"process_command_line_query_failed pid={pid} platform={platform_name} "
|
|
484
|
+
f"error={type(exc).__name__}: {exc}",
|
|
485
|
+
)
|
|
486
|
+
return ""
|
|
487
|
+
return result.stdout.strip()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def process_environ_contains(
|
|
491
|
+
pid: int,
|
|
492
|
+
key: str,
|
|
493
|
+
value: str | None,
|
|
494
|
+
services: ProcessInspectionServices,
|
|
495
|
+
*,
|
|
496
|
+
platform_name: str = os.name,
|
|
497
|
+
) -> bool:
|
|
498
|
+
if platform_name == "nt" or pid <= 0:
|
|
499
|
+
return False
|
|
500
|
+
path = Path("/proc") / str(pid) / "environ"
|
|
501
|
+
try:
|
|
502
|
+
data = services.read_bytes(path)
|
|
503
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
|
504
|
+
return False
|
|
505
|
+
except OSError as exc:
|
|
506
|
+
services.log(
|
|
507
|
+
"WARN",
|
|
508
|
+
f"process_environ_query_failed pid={pid} error={type(exc).__name__}: {exc}",
|
|
509
|
+
)
|
|
510
|
+
return False
|
|
511
|
+
needle = f"{key}=".encode()
|
|
512
|
+
for item in data.split(b"\0"):
|
|
513
|
+
if not item.startswith(needle):
|
|
514
|
+
continue
|
|
515
|
+
if value is None:
|
|
516
|
+
return True
|
|
517
|
+
return item[len(needle) :].decode("utf-8", errors="replace") == value
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def process_cwd(
|
|
522
|
+
pid: int,
|
|
523
|
+
services: ProcessInspectionServices,
|
|
524
|
+
*,
|
|
525
|
+
platform_name: str = os.name,
|
|
526
|
+
) -> Path | None:
|
|
527
|
+
if platform_name == "nt" or pid <= 0:
|
|
528
|
+
return None
|
|
529
|
+
path = Path("/proc") / str(pid) / "cwd"
|
|
530
|
+
try:
|
|
531
|
+
return Path(services.readlink(path)).resolve()
|
|
532
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
|
533
|
+
return None
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
services.log(
|
|
536
|
+
"WARN",
|
|
537
|
+
f"process_cwd_query_failed pid={pid} error={type(exc).__name__}: {exc}",
|
|
538
|
+
)
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def posix_process_rows(services: ProcessInspectionServices) -> list[tuple[int, str, str]]:
|
|
543
|
+
try:
|
|
544
|
+
result = services.run(
|
|
545
|
+
["ps", "-u", services.username(), "-o", "pid=,stat=,command="],
|
|
546
|
+
text=True,
|
|
547
|
+
stdout=subprocess.PIPE,
|
|
548
|
+
stderr=subprocess.DEVNULL,
|
|
549
|
+
timeout=5,
|
|
550
|
+
)
|
|
551
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
552
|
+
services.log(
|
|
553
|
+
"WARN",
|
|
554
|
+
f"process_list_query_failed platform=posix error={type(exc).__name__}: {exc}",
|
|
555
|
+
)
|
|
556
|
+
return []
|
|
557
|
+
rows: list[tuple[int, str, str]] = []
|
|
558
|
+
for line in result.stdout.splitlines():
|
|
559
|
+
parts = line.strip().split(maxsplit=2)
|
|
560
|
+
if len(parts) < 3:
|
|
561
|
+
continue
|
|
562
|
+
try:
|
|
563
|
+
pid = int(parts[0])
|
|
564
|
+
except ValueError:
|
|
565
|
+
continue
|
|
566
|
+
rows.append((pid, parts[1], parts[2]))
|
|
567
|
+
return rows
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def terminate_matching_processes(
|
|
571
|
+
needles: list[str],
|
|
572
|
+
label: str,
|
|
573
|
+
services: ProcessControlServices,
|
|
574
|
+
*,
|
|
575
|
+
quiet: bool = False,
|
|
576
|
+
platform_name: str = os.name,
|
|
577
|
+
) -> bool:
|
|
578
|
+
if platform_name == "nt":
|
|
579
|
+
matched = _windows_matching_processes(needles, services)
|
|
580
|
+
if matched is None:
|
|
581
|
+
return False
|
|
582
|
+
stopped = _terminate_windows_processes(matched, label, services)
|
|
583
|
+
else:
|
|
584
|
+
matched = _posix_matching_processes(needles, services)
|
|
585
|
+
if matched is None:
|
|
586
|
+
return False
|
|
587
|
+
stopped = _terminate_posix_processes(matched, label, services)
|
|
588
|
+
if stopped and not quiet:
|
|
589
|
+
services.output(f"Stopped existing {label} session(s): {', '.join(map(str, matched))}.")
|
|
590
|
+
return stopped
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _windows_matching_processes(
|
|
594
|
+
needles: list[str], services: ProcessControlServices
|
|
595
|
+
) -> list[int] | None:
|
|
596
|
+
script = "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
|
|
597
|
+
try:
|
|
598
|
+
result = services.query.run(
|
|
599
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
600
|
+
text=True,
|
|
601
|
+
stdout=subprocess.PIPE,
|
|
602
|
+
stderr=subprocess.DEVNULL,
|
|
603
|
+
timeout=8,
|
|
604
|
+
)
|
|
605
|
+
rows = json.loads(result.stdout or "[]")
|
|
606
|
+
except Exception as exc:
|
|
607
|
+
services.log("WARN", f"process_query_failed platform=windows error={type(exc).__name__}: {exc}")
|
|
608
|
+
return None
|
|
609
|
+
if isinstance(rows, dict):
|
|
610
|
+
rows = [rows]
|
|
611
|
+
matched: list[int] = []
|
|
612
|
+
for row in rows if isinstance(rows, list) else []:
|
|
613
|
+
try:
|
|
614
|
+
pid = int(row.get("ProcessId"))
|
|
615
|
+
except (AttributeError, TypeError, ValueError):
|
|
616
|
+
continue
|
|
617
|
+
command = str(row.get("CommandLine") or "")
|
|
618
|
+
if pid in {services.query.current_pid(), services.query.parent_pid()} or not command:
|
|
619
|
+
continue
|
|
620
|
+
if all(needle in command for needle in needles):
|
|
621
|
+
matched.append(pid)
|
|
622
|
+
return matched
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _terminate_windows_processes(
|
|
626
|
+
matched: list[int], label: str, services: ProcessControlServices
|
|
627
|
+
) -> bool:
|
|
628
|
+
stopped = False
|
|
629
|
+
for pid in matched:
|
|
630
|
+
try:
|
|
631
|
+
services.query.run(
|
|
632
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
633
|
+
stdout=subprocess.DEVNULL,
|
|
634
|
+
stderr=subprocess.DEVNULL,
|
|
635
|
+
timeout=8,
|
|
636
|
+
)
|
|
637
|
+
stopped = True
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
services.log(
|
|
640
|
+
"WARN",
|
|
641
|
+
f"process_terminate_failed platform=windows label={label!r} pid={pid} "
|
|
642
|
+
f"error={type(exc).__name__}: {exc}",
|
|
643
|
+
)
|
|
644
|
+
return stopped
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _posix_matching_processes(
|
|
648
|
+
needles: list[str], services: ProcessControlServices
|
|
649
|
+
) -> list[int] | None:
|
|
650
|
+
try:
|
|
651
|
+
result = services.query.run(
|
|
652
|
+
["ps", "-u", services.query.username(), "-o", "pid=,stat=,command="],
|
|
653
|
+
text=True,
|
|
654
|
+
stdout=subprocess.PIPE,
|
|
655
|
+
stderr=subprocess.DEVNULL,
|
|
656
|
+
timeout=5,
|
|
657
|
+
)
|
|
658
|
+
except Exception as exc:
|
|
659
|
+
services.log("WARN", f"process_query_failed platform=posix error={type(exc).__name__}: {exc}")
|
|
660
|
+
return None
|
|
661
|
+
matched: list[int] = []
|
|
662
|
+
for line in result.stdout.splitlines():
|
|
663
|
+
parts = line.strip().split(maxsplit=2)
|
|
664
|
+
if len(parts) < 3:
|
|
665
|
+
continue
|
|
666
|
+
try:
|
|
667
|
+
pid = int(parts[0])
|
|
668
|
+
except ValueError:
|
|
669
|
+
continue
|
|
670
|
+
stat, command = parts[1], parts[2]
|
|
671
|
+
if pid in {services.query.current_pid(), services.query.parent_pid()} or stat.startswith("Z"):
|
|
672
|
+
continue
|
|
673
|
+
if all(needle in command for needle in needles):
|
|
674
|
+
matched.append(pid)
|
|
675
|
+
return matched
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _terminate_posix_processes(
|
|
679
|
+
matched: list[int], label: str, services: ProcessControlServices
|
|
680
|
+
) -> bool:
|
|
681
|
+
stopped = False
|
|
682
|
+
for pid in matched:
|
|
683
|
+
try:
|
|
684
|
+
services.signals.kill(pid, signal.SIGTERM)
|
|
685
|
+
stopped = True
|
|
686
|
+
except Exception as exc:
|
|
687
|
+
services.log(
|
|
688
|
+
"WARN",
|
|
689
|
+
f"process_terminate_failed platform=posix signal=TERM label={label!r} pid={pid} "
|
|
690
|
+
f"error={type(exc).__name__}: {exc}",
|
|
691
|
+
)
|
|
692
|
+
deadline = services.signals.now() + 3
|
|
693
|
+
while services.signals.now() < deadline:
|
|
694
|
+
if not any(services.signals.pid_is_running(pid) for pid in matched):
|
|
695
|
+
break
|
|
696
|
+
services.signals.sleep(0.1)
|
|
697
|
+
for pid in matched:
|
|
698
|
+
if not services.signals.pid_is_running(pid):
|
|
699
|
+
continue
|
|
700
|
+
try:
|
|
701
|
+
services.signals.kill(pid, signal.SIGKILL)
|
|
702
|
+
except Exception as exc:
|
|
703
|
+
services.log(
|
|
704
|
+
"WARN",
|
|
705
|
+
f"process_terminate_failed platform=posix signal=KILL label={label!r} pid={pid} "
|
|
706
|
+
f"error={type(exc).__name__}: {exc}",
|
|
707
|
+
)
|
|
708
|
+
return stopped
|