winterm-mcp 0.1.5__py3-none-any.whl → 0.1.7__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.
- winterm_mcp/__init__.py +32 -9
- winterm_mcp/__main__.py +11 -10
- winterm_mcp/constants.py +32 -0
- winterm_mcp/models.py +73 -0
- winterm_mcp/server.py +285 -173
- winterm_mcp/service.py +544 -239
- winterm_mcp/store.py +92 -0
- winterm_mcp/utils.py +206 -0
- {winterm_mcp-0.1.5.dist-info → winterm_mcp-0.1.7.dist-info}/METADATA +3 -2
- winterm_mcp-0.1.7.dist-info/RECORD +14 -0
- {winterm_mcp-0.1.5.dist-info → winterm_mcp-0.1.7.dist-info}/WHEEL +1 -1
- winterm_mcp-0.1.5.dist-info/RECORD +0 -10
- {winterm_mcp-0.1.5.dist-info → winterm_mcp-0.1.7.dist-info}/entry_points.txt +0 -0
- {winterm_mcp-0.1.5.dist-info → winterm_mcp-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {winterm_mcp-0.1.5.dist-info → winterm_mcp-0.1.7.dist-info}/top_level.txt +0 -0
winterm_mcp/service.py
CHANGED
|
@@ -7,51 +7,67 @@ import threading
|
|
|
7
7
|
import uuid
|
|
8
8
|
import time
|
|
9
9
|
import os
|
|
10
|
-
import shutil
|
|
11
10
|
import logging
|
|
12
11
|
from datetime import datetime
|
|
13
|
-
from typing import Dict, Optional, Any, List
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
from typing import Dict, Optional, Any, List, Literal
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import winpty
|
|
16
|
+
WINPTY_AVAILABLE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
WINPTY_AVAILABLE = False
|
|
19
|
+
|
|
20
|
+
from .models import CommandInfo, QueryStatusResponse, RunCommandParams
|
|
21
|
+
from .store import CommandStore
|
|
22
|
+
from .utils import find_powershell, find_cmd, resolve_executable_path, strip_ansi_codes
|
|
23
|
+
from .constants import (
|
|
24
|
+
NAME,
|
|
25
|
+
VERSION,
|
|
26
|
+
ENV_POWERSHELL_PATH,
|
|
27
|
+
ENV_CMD_PATH,
|
|
28
|
+
ENV_PYTHON_PATH,
|
|
29
|
+
PTY_COLS,
|
|
30
|
+
PTY_ROWS,
|
|
31
|
+
MIN_TIMEOUT,
|
|
32
|
+
MAX_TIMEOUT,
|
|
33
|
+
DEFAULT_TIMEOUT,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = VERSION
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(NAME)
|
|
20
39
|
|
|
21
40
|
|
|
22
41
|
def setup_logging(level: int = logging.INFO) -> None:
|
|
23
42
|
"""
|
|
24
43
|
配置日志输出
|
|
25
|
-
|
|
44
|
+
|
|
26
45
|
Args:
|
|
27
46
|
level: 日志级别,默认 INFO
|
|
28
|
-
|
|
47
|
+
|
|
29
48
|
日志输出位置:
|
|
30
49
|
1. 控制台 (stderr)
|
|
31
50
|
2. 文件: %TEMP%/winterm-mcp.log 或 /tmp/winterm-mcp.log
|
|
32
|
-
|
|
51
|
+
|
|
33
52
|
可通过环境变量配置:
|
|
34
53
|
- WINTERM_LOG_LEVEL: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
|
35
54
|
- WINTERM_LOG_FILE: 自定义日志文件路径
|
|
36
55
|
"""
|
|
37
56
|
import tempfile
|
|
38
|
-
|
|
57
|
+
|
|
39
58
|
formatter = logging.Formatter(
|
|
40
59
|
"[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
|
|
41
60
|
datefmt="%Y-%m-%d %H:%M:%S"
|
|
42
61
|
)
|
|
43
|
-
|
|
44
|
-
# 控制台输出
|
|
62
|
+
|
|
45
63
|
console_handler = logging.StreamHandler()
|
|
46
64
|
console_handler.setFormatter(formatter)
|
|
47
65
|
logger.addHandler(console_handler)
|
|
48
|
-
|
|
49
|
-
# 文件输出
|
|
66
|
+
|
|
50
67
|
log_file = os.environ.get("WINTERM_LOG_FILE")
|
|
51
68
|
if not log_file:
|
|
52
|
-
# 默认日志文件路径
|
|
53
69
|
log_file = os.path.join(tempfile.gettempdir(), "winterm-mcp.log")
|
|
54
|
-
|
|
70
|
+
|
|
55
71
|
try:
|
|
56
72
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
57
73
|
file_handler.setFormatter(formatter)
|
|
@@ -59,126 +75,38 @@ def setup_logging(level: int = logging.INFO) -> None:
|
|
|
59
75
|
logger.info(f"Log file: {log_file}")
|
|
60
76
|
except Exception as e:
|
|
61
77
|
logger.warning(f"Failed to create log file {log_file}: {e}")
|
|
62
|
-
|
|
78
|
+
|
|
63
79
|
logger.setLevel(level)
|
|
64
|
-
|
|
65
|
-
# 检查环境变量设置日志级别
|
|
80
|
+
|
|
66
81
|
env_level = os.environ.get("WINTERM_LOG_LEVEL", "").upper()
|
|
67
82
|
if env_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
|
68
83
|
logger.setLevel(getattr(logging, env_level))
|
|
69
84
|
|
|
70
85
|
|
|
71
|
-
# PowerShell 可执行文件的标准路径(按优先级排序)
|
|
72
|
-
POWERSHELL_PATHS: List[str] = [
|
|
73
|
-
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
|
|
74
|
-
r"C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe",
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
# PowerShell Core (pwsh) 的常见路径
|
|
78
|
-
PWSH_PATHS: List[str] = [
|
|
79
|
-
r"C:\Program Files\PowerShell\7\pwsh.exe",
|
|
80
|
-
r"C:\Program Files (x86)\PowerShell\7\pwsh.exe",
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
# 环境变量名称
|
|
84
|
-
ENV_POWERSHELL_PATH = "WINTERM_POWERSHELL_PATH"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _find_powershell() -> str:
|
|
88
|
-
"""
|
|
89
|
-
查找可用的 PowerShell 可执行文件路径
|
|
90
|
-
|
|
91
|
-
查找顺序:
|
|
92
|
-
1. 环境变量 WINTERM_POWERSHELL_PATH(用户自定义)
|
|
93
|
-
2. Windows PowerShell 标准路径
|
|
94
|
-
3. PowerShell Core 标准路径
|
|
95
|
-
4. PATH 环境变量中的 powershell/pwsh(兼容正常环境)
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
PowerShell 可执行文件的绝对路径
|
|
99
|
-
|
|
100
|
-
Raises:
|
|
101
|
-
FileNotFoundError: 如果找不到 PowerShell
|
|
102
|
-
"""
|
|
103
|
-
logger.debug("Starting PowerShell path discovery...")
|
|
104
|
-
|
|
105
|
-
# 1. 检查用户配置的环境变量
|
|
106
|
-
custom_path = os.environ.get(ENV_POWERSHELL_PATH)
|
|
107
|
-
if custom_path:
|
|
108
|
-
logger.debug(f"Found env var {ENV_POWERSHELL_PATH}={custom_path}")
|
|
109
|
-
if os.path.isfile(custom_path):
|
|
110
|
-
logger.info(f"Using custom PowerShell path: {custom_path}")
|
|
111
|
-
return custom_path
|
|
112
|
-
else:
|
|
113
|
-
logger.warning(
|
|
114
|
-
f"Custom PowerShell path not found: {custom_path}, "
|
|
115
|
-
"falling back to standard paths"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
# 2. 检查 Windows PowerShell 标准路径
|
|
119
|
-
for path in POWERSHELL_PATHS:
|
|
120
|
-
logger.debug(f"Checking standard path: {path}")
|
|
121
|
-
if os.path.isfile(path):
|
|
122
|
-
logger.info(f"Found Windows PowerShell: {path}")
|
|
123
|
-
return path
|
|
124
|
-
|
|
125
|
-
# 3. 检查 PowerShell Core 标准路径
|
|
126
|
-
for path in PWSH_PATHS:
|
|
127
|
-
logger.debug(f"Checking PowerShell Core path: {path}")
|
|
128
|
-
if os.path.isfile(path):
|
|
129
|
-
logger.info(f"Found PowerShell Core: {path}")
|
|
130
|
-
return path
|
|
131
|
-
|
|
132
|
-
# 4. 尝试 PATH 环境变量(兼容正常环境)
|
|
133
|
-
logger.debug("Checking PATH environment variable...")
|
|
134
|
-
ps_path = shutil.which("powershell")
|
|
135
|
-
if ps_path:
|
|
136
|
-
logger.info(f"Found PowerShell in PATH: {ps_path}")
|
|
137
|
-
return ps_path
|
|
138
|
-
|
|
139
|
-
pwsh_path = shutil.which("pwsh")
|
|
140
|
-
if pwsh_path:
|
|
141
|
-
logger.info(f"Found pwsh in PATH: {pwsh_path}")
|
|
142
|
-
return pwsh_path
|
|
143
|
-
|
|
144
|
-
# 所有方法都失败
|
|
145
|
-
checked_paths = POWERSHELL_PATHS + PWSH_PATHS
|
|
146
|
-
error_msg = (
|
|
147
|
-
f"PowerShell not found. "
|
|
148
|
-
f"Set {ENV_POWERSHELL_PATH} environment variable or "
|
|
149
|
-
f"ensure PowerShell is installed. "
|
|
150
|
-
f"Checked paths: {', '.join(checked_paths)}"
|
|
151
|
-
)
|
|
152
|
-
logger.error(error_msg)
|
|
153
|
-
raise FileNotFoundError(error_msg)
|
|
154
|
-
|
|
155
|
-
|
|
156
86
|
def get_version() -> str:
|
|
157
87
|
"""
|
|
158
88
|
获取 winterm-mcp 版本号
|
|
159
|
-
|
|
89
|
+
|
|
160
90
|
Returns:
|
|
161
91
|
版本号字符串
|
|
162
92
|
"""
|
|
163
93
|
return __version__
|
|
164
94
|
|
|
165
95
|
|
|
166
|
-
class
|
|
96
|
+
class CommandService:
|
|
167
97
|
"""
|
|
168
98
|
异步命令执行服务类,管理所有异步命令的执行和状态
|
|
169
99
|
"""
|
|
170
100
|
|
|
171
101
|
def __init__(self):
|
|
172
|
-
self.
|
|
173
|
-
self.lock = threading.Lock()
|
|
102
|
+
self._store = CommandStore()
|
|
174
103
|
self._powershell_path: Optional[str] = None
|
|
104
|
+
self._cmd_path: Optional[str] = None
|
|
175
105
|
|
|
176
106
|
def _get_powershell_path(self) -> str:
|
|
177
107
|
"""
|
|
178
108
|
获取 PowerShell 可执行文件路径(带缓存)
|
|
179
109
|
|
|
180
|
-
首次调用时查找并缓存路径,后续调用直接返回缓存值。
|
|
181
|
-
|
|
182
110
|
Returns:
|
|
183
111
|
PowerShell 可执行文件的绝对路径
|
|
184
112
|
|
|
@@ -186,58 +114,97 @@ class RunCmdService:
|
|
|
186
114
|
FileNotFoundError: 如果找不到 PowerShell
|
|
187
115
|
"""
|
|
188
116
|
if self._powershell_path is None:
|
|
189
|
-
self._powershell_path =
|
|
117
|
+
self._powershell_path = find_powershell()
|
|
190
118
|
logger.debug(f"PowerShell path cached: {self._powershell_path}")
|
|
191
119
|
return self._powershell_path
|
|
192
120
|
|
|
121
|
+
def _get_cmd_path(self) -> str:
|
|
122
|
+
"""
|
|
123
|
+
获取 CMD 可执行文件路径(带缓存)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
CMD 可执行文件的绝对路径
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
FileNotFoundError: 如果找不到 CMD
|
|
130
|
+
"""
|
|
131
|
+
if self._cmd_path is None:
|
|
132
|
+
self._cmd_path = find_cmd()
|
|
133
|
+
logger.debug(f"CMD path cached: {self._cmd_path}")
|
|
134
|
+
return self._cmd_path
|
|
135
|
+
|
|
193
136
|
def run_command(
|
|
194
137
|
self,
|
|
195
138
|
command: str,
|
|
196
|
-
|
|
197
|
-
|
|
139
|
+
executable: Optional[str] = None,
|
|
140
|
+
args: Optional[List[str]] = None,
|
|
141
|
+
shell_type: Literal["powershell", "cmd", "executable"] = "executable",
|
|
142
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
198
143
|
working_directory: Optional[str] = None,
|
|
144
|
+
enable_streaming: bool = False,
|
|
199
145
|
) -> str:
|
|
200
146
|
"""
|
|
201
147
|
异步运行命令
|
|
202
148
|
|
|
203
149
|
Args:
|
|
204
150
|
command: 要执行的命令
|
|
205
|
-
|
|
151
|
+
executable: 可执行文件路径
|
|
152
|
+
args: 可执行文件参数
|
|
153
|
+
shell_type: Shell 类型 (powershell/cmd/executable)
|
|
206
154
|
timeout: 超时时间(秒)
|
|
207
155
|
working_directory: 工作目录
|
|
156
|
+
enable_streaming: 启用实时流式输出
|
|
208
157
|
|
|
209
158
|
Returns:
|
|
210
159
|
命令执行的token
|
|
211
160
|
"""
|
|
161
|
+
if not command:
|
|
162
|
+
raise ValueError("command cannot be empty")
|
|
163
|
+
|
|
164
|
+
if len(command) > 1000:
|
|
165
|
+
raise ValueError("command length cannot exceed 1000 characters")
|
|
166
|
+
|
|
167
|
+
if timeout < MIN_TIMEOUT or timeout > MAX_TIMEOUT:
|
|
168
|
+
raise ValueError(f"timeout must be between {MIN_TIMEOUT} and {MAX_TIMEOUT}")
|
|
169
|
+
|
|
170
|
+
if shell_type not in ["powershell", "cmd", "executable"]:
|
|
171
|
+
raise ValueError("shell_type must be 'powershell', 'cmd', or 'executable'")
|
|
172
|
+
|
|
212
173
|
token = str(uuid.uuid4())
|
|
213
|
-
|
|
174
|
+
|
|
214
175
|
logger.info(
|
|
215
176
|
f"Submitting command: token={token}, shell={shell_type}, "
|
|
216
|
-
f"timeout={timeout}, cwd={working_directory}"
|
|
177
|
+
f"timeout={timeout}, cwd={working_directory}, streaming={enable_streaming}"
|
|
178
|
+
)
|
|
179
|
+
logger.debug(
|
|
180
|
+
f"Command content: {command[:100]}"
|
|
181
|
+
f"{'...' if len(command) > 100 else ''}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
cmd_info = CommandInfo(
|
|
185
|
+
token=token,
|
|
186
|
+
executable=executable or "",
|
|
187
|
+
args=args or [],
|
|
188
|
+
command=command,
|
|
189
|
+
shell_type=shell_type,
|
|
190
|
+
status="pending",
|
|
191
|
+
start_time=datetime.now(),
|
|
192
|
+
timeout=timeout,
|
|
193
|
+
working_directory=working_directory,
|
|
194
|
+
stdout="",
|
|
195
|
+
stderr="",
|
|
196
|
+
exit_code=None,
|
|
197
|
+
execution_time=None,
|
|
198
|
+
timeout_occurred=False,
|
|
199
|
+
pty_process=None,
|
|
200
|
+
enable_streaming=enable_streaming,
|
|
217
201
|
)
|
|
218
|
-
logger.debug(f"Command content: {command[:100]}{'...' if len(command) > 100 else ''}")
|
|
219
|
-
|
|
220
|
-
cmd_info = {
|
|
221
|
-
"token": token,
|
|
222
|
-
"command": command,
|
|
223
|
-
"shell_type": shell_type,
|
|
224
|
-
"status": "pending",
|
|
225
|
-
"start_time": datetime.now(),
|
|
226
|
-
"timeout": timeout,
|
|
227
|
-
"working_directory": working_directory,
|
|
228
|
-
"stdout": "",
|
|
229
|
-
"stderr": "",
|
|
230
|
-
"exit_code": None,
|
|
231
|
-
"execution_time": None,
|
|
232
|
-
"timeout_occurred": False,
|
|
233
|
-
}
|
|
234
202
|
|
|
235
|
-
|
|
236
|
-
self.commands[token] = cmd_info
|
|
203
|
+
self._store.add_command(token, cmd_info)
|
|
237
204
|
|
|
238
205
|
thread = threading.Thread(
|
|
239
206
|
target=self._execute_command,
|
|
240
|
-
args=(token, command, shell_type, timeout, working_directory),
|
|
207
|
+
args=(token, command, executable, args, shell_type, timeout, working_directory, enable_streaming),
|
|
241
208
|
)
|
|
242
209
|
thread.daemon = True
|
|
243
210
|
thread.start()
|
|
@@ -248,9 +215,12 @@ class RunCmdService:
|
|
|
248
215
|
self,
|
|
249
216
|
token: str,
|
|
250
217
|
command: str,
|
|
218
|
+
executable: Optional[str],
|
|
219
|
+
args: Optional[List[str]],
|
|
251
220
|
shell_type: str,
|
|
252
221
|
timeout: int,
|
|
253
222
|
working_directory: Optional[str],
|
|
223
|
+
enable_streaming: bool,
|
|
254
224
|
):
|
|
255
225
|
"""
|
|
256
226
|
在单独线程中执行命令
|
|
@@ -259,14 +229,11 @@ class RunCmdService:
|
|
|
259
229
|
start_time = time.time()
|
|
260
230
|
logger.debug(f"[{token}] Starting command execution...")
|
|
261
231
|
|
|
262
|
-
|
|
263
|
-
if token in self.commands:
|
|
264
|
-
self.commands[token]["status"] = "running"
|
|
232
|
+
self._store.update_command(token, status="running")
|
|
265
233
|
|
|
266
234
|
encoding = "gbk"
|
|
267
235
|
|
|
268
236
|
if shell_type == "powershell":
|
|
269
|
-
# 使用绝对路径调用 PowerShell,避免 PATH 环境变量限制
|
|
270
237
|
ps_path = self._get_powershell_path()
|
|
271
238
|
logger.info(f"[{token}] Using PowerShell: {ps_path}")
|
|
272
239
|
cmd_args = [
|
|
@@ -279,90 +246,225 @@ class RunCmdService:
|
|
|
279
246
|
"-Command",
|
|
280
247
|
command,
|
|
281
248
|
]
|
|
249
|
+
elif shell_type == "cmd":
|
|
250
|
+
cmd_path = self._get_cmd_path()
|
|
251
|
+
logger.info(f"[{token}] Using CMD: {cmd_path}")
|
|
252
|
+
cmd_args = [cmd_path, "/c", command]
|
|
282
253
|
else:
|
|
283
|
-
|
|
284
|
-
|
|
254
|
+
if executable:
|
|
255
|
+
resolved_exec = resolve_executable_path(executable)
|
|
256
|
+
logger.info(f"[{token}] Using executable: {resolved_exec}")
|
|
257
|
+
cmd_args = [resolved_exec] + (args or [])
|
|
258
|
+
else:
|
|
259
|
+
logger.debug(f"[{token}] Using shell=True for command")
|
|
260
|
+
cmd_args = command
|
|
285
261
|
|
|
286
262
|
logger.debug(f"[{token}] Executing: {cmd_args}")
|
|
287
|
-
|
|
288
|
-
|
|
263
|
+
|
|
264
|
+
env = None
|
|
265
|
+
python_path = os.environ.get(ENV_PYTHON_PATH)
|
|
266
|
+
if python_path and os.path.isfile(python_path):
|
|
267
|
+
env = os.environ.copy()
|
|
268
|
+
python_dir = os.path.dirname(python_path)
|
|
269
|
+
env["PATH"] = f"{python_dir}{os.pathsep}{env.get('PATH', '')}"
|
|
270
|
+
logger.debug(f"[{token}] Using custom Python path: {python_path}")
|
|
271
|
+
|
|
272
|
+
if enable_streaming and WINPTY_AVAILABLE:
|
|
273
|
+
self._execute_with_pty(
|
|
274
|
+
token, cmd_args, shell_type, timeout, working_directory, env, start_time
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
self._execute_with_subprocess(
|
|
278
|
+
token, cmd_args, shell_type, timeout, working_directory, env, start_time, encoding
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
except FileNotFoundError as e:
|
|
282
|
+
execution_time = time.time() - start_time
|
|
283
|
+
logger.error(f"[{token}] Executable not found: {e}")
|
|
284
|
+
self._store.update_command(
|
|
285
|
+
token,
|
|
286
|
+
status="not_found",
|
|
287
|
+
stdout="",
|
|
288
|
+
stderr=f"Executable not found: {e}",
|
|
289
|
+
exit_code=-2,
|
|
290
|
+
execution_time=int(execution_time * 1000),
|
|
291
|
+
)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
execution_time = time.time() - start_time
|
|
294
|
+
logger.error(f"[{token}] Command failed with exception: {e}")
|
|
295
|
+
self._store.update_command(
|
|
296
|
+
token,
|
|
297
|
+
status="completed",
|
|
298
|
+
stdout="",
|
|
299
|
+
stderr=str(e),
|
|
300
|
+
exit_code=-1,
|
|
301
|
+
execution_time=int(execution_time * 1000),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _execute_with_subprocess(
|
|
305
|
+
self,
|
|
306
|
+
token: str,
|
|
307
|
+
cmd_args: List[str] | str,
|
|
308
|
+
shell_type: str,
|
|
309
|
+
timeout: int,
|
|
310
|
+
working_directory: Optional[str],
|
|
311
|
+
env: Optional[Dict[str, str]],
|
|
312
|
+
start_time: float,
|
|
313
|
+
encoding: str,
|
|
314
|
+
):
|
|
315
|
+
"""
|
|
316
|
+
使用 subprocess 执行命令
|
|
317
|
+
"""
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
cmd_args,
|
|
320
|
+
capture_output=True,
|
|
321
|
+
text=True,
|
|
322
|
+
timeout=timeout,
|
|
323
|
+
cwd=working_directory,
|
|
324
|
+
encoding=encoding,
|
|
325
|
+
stdin=subprocess.DEVNULL,
|
|
326
|
+
env=env,
|
|
327
|
+
shell=(shell_type == "executable" and isinstance(cmd_args, str)),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
execution_time = time.time() - start_time
|
|
331
|
+
|
|
332
|
+
stdout_clean = strip_ansi_codes(result.stdout) if result.stdout else ""
|
|
333
|
+
stderr_clean = strip_ansi_codes(result.stderr) if result.stderr else ""
|
|
334
|
+
|
|
335
|
+
logger.info(
|
|
336
|
+
f"[{token}] Command completed: exit_code={result.returncode}, "
|
|
337
|
+
f"time={execution_time:.3f}s"
|
|
338
|
+
)
|
|
339
|
+
logger.debug(
|
|
340
|
+
f"[{token}] stdout: "
|
|
341
|
+
f"{stdout_clean[:200] if stdout_clean else '(empty)'}"
|
|
342
|
+
)
|
|
343
|
+
logger.debug(
|
|
344
|
+
f"[{token}] stderr: "
|
|
345
|
+
f"{stderr_clean[:200] if stderr_clean else '(empty)'}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
self._store.update_command(
|
|
349
|
+
token,
|
|
350
|
+
status="completed",
|
|
351
|
+
stdout=stdout_clean,
|
|
352
|
+
stderr=stderr_clean,
|
|
353
|
+
exit_code=result.returncode,
|
|
354
|
+
execution_time=int(execution_time * 1000),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def _execute_with_pty(
|
|
358
|
+
self,
|
|
359
|
+
token: str,
|
|
360
|
+
cmd_args: List[str] | str,
|
|
361
|
+
shell_type: str,
|
|
362
|
+
timeout: int,
|
|
363
|
+
working_directory: Optional[str],
|
|
364
|
+
env: Optional[Dict[str, str]],
|
|
365
|
+
start_time: float,
|
|
366
|
+
):
|
|
367
|
+
"""
|
|
368
|
+
使用 winpty 执行命令,支持交互式输入和实时流式输出
|
|
369
|
+
"""
|
|
370
|
+
if isinstance(cmd_args, str):
|
|
371
|
+
logger.warning(f"[{token}] PTY mode requires list of arguments, falling back to subprocess")
|
|
372
|
+
self._execute_with_subprocess(
|
|
373
|
+
token, cmd_args, shell_type, timeout, working_directory, env, start_time, "gbk"
|
|
374
|
+
)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
cwd = working_directory or os.getcwd()
|
|
379
|
+
pty = winpty.PtyProcess.spawn(
|
|
289
380
|
cmd_args,
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
encoding=encoding,
|
|
295
|
-
stdin=subprocess.DEVNULL, # 防止等待输入导致挂起
|
|
381
|
+
cols=PTY_COLS,
|
|
382
|
+
rows=PTY_ROWS,
|
|
383
|
+
cwd=cwd,
|
|
384
|
+
env=env or os.environ,
|
|
296
385
|
)
|
|
297
386
|
|
|
387
|
+
self._store.update_command(token, pty_process=pty)
|
|
388
|
+
logger.info(f"[{token}] PTY process started: pid={pty.pid}")
|
|
389
|
+
|
|
390
|
+
stdout_buffer = ""
|
|
391
|
+
stderr_buffer = ""
|
|
392
|
+
timeout_timer = None
|
|
393
|
+
timeout_occurred = False
|
|
394
|
+
|
|
395
|
+
def on_timeout():
|
|
396
|
+
nonlocal timeout_occurred
|
|
397
|
+
timeout_occurred = True
|
|
398
|
+
logger.warning(f"[{token}] Command timed out after {timeout}s")
|
|
399
|
+
if pty and pty.isalive():
|
|
400
|
+
pty.terminate()
|
|
401
|
+
|
|
402
|
+
if timeout > 0:
|
|
403
|
+
timeout_timer = threading.Timer(timeout, on_timeout)
|
|
404
|
+
timeout_timer.start()
|
|
405
|
+
|
|
406
|
+
def on_data(data: str):
|
|
407
|
+
nonlocal stdout_buffer
|
|
408
|
+
stdout_buffer += data
|
|
409
|
+
self._store.update_command(
|
|
410
|
+
token,
|
|
411
|
+
stdout=stdout_buffer,
|
|
412
|
+
last_output_timestamp=int(time.time() * 1000),
|
|
413
|
+
)
|
|
414
|
+
logger.debug(f"[{token}] Received {len(data)} bytes of output")
|
|
415
|
+
|
|
416
|
+
pty.set_winpty_size(PTY_COLS, PTY_ROWS)
|
|
417
|
+
|
|
418
|
+
while pty.isalive() and not timeout_occurred:
|
|
419
|
+
try:
|
|
420
|
+
data = pty.read()
|
|
421
|
+
if data:
|
|
422
|
+
on_data(data)
|
|
423
|
+
time.sleep(0.01)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.error(f"[{token}] Error reading from PTY: {e}")
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
if timeout_timer:
|
|
429
|
+
timeout_timer.cancel()
|
|
430
|
+
|
|
431
|
+
exit_code = pty.get_exitstatus() if not pty.isalive() else -1
|
|
432
|
+
|
|
433
|
+
stdout_clean = strip_ansi_codes(stdout_buffer) if stdout_buffer else ""
|
|
298
434
|
execution_time = time.time() - start_time
|
|
299
|
-
|
|
435
|
+
|
|
300
436
|
logger.info(
|
|
301
|
-
f"[{token}]
|
|
437
|
+
f"[{token}] PTY command completed: exit_code={exit_code}, "
|
|
302
438
|
f"time={execution_time:.3f}s"
|
|
303
439
|
)
|
|
304
|
-
logger.debug(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
440
|
+
logger.debug(
|
|
441
|
+
f"[{token}] stdout: "
|
|
442
|
+
f"{stdout_clean[:200] if stdout_clean else '(empty)'}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
self._store.update_command(
|
|
446
|
+
token,
|
|
447
|
+
status="terminated" if timeout_occurred else "completed",
|
|
448
|
+
stdout=stdout_clean,
|
|
449
|
+
stderr=stderr_buffer,
|
|
450
|
+
exit_code=exit_code,
|
|
451
|
+
execution_time=int(execution_time * 1000),
|
|
452
|
+
timeout_occurred=timeout_occurred,
|
|
453
|
+
pty_process=None,
|
|
454
|
+
)
|
|
318
455
|
|
|
319
|
-
except FileNotFoundError as e:
|
|
320
|
-
execution_time = time.time() - start_time
|
|
321
|
-
logger.error(f"[{token}] PowerShell not found: {e}")
|
|
322
|
-
with self.lock:
|
|
323
|
-
if token in self.commands:
|
|
324
|
-
self.commands[token].update(
|
|
325
|
-
{
|
|
326
|
-
"status": "completed",
|
|
327
|
-
"stdout": "",
|
|
328
|
-
"stderr": f"PowerShell not found: {e}",
|
|
329
|
-
"exit_code": -2,
|
|
330
|
-
"execution_time": execution_time,
|
|
331
|
-
"timeout_occurred": False,
|
|
332
|
-
}
|
|
333
|
-
)
|
|
334
|
-
except subprocess.TimeoutExpired:
|
|
335
|
-
execution_time = time.time() - start_time
|
|
336
|
-
logger.warning(f"[{token}] Command timed out after {timeout}s")
|
|
337
|
-
with self.lock:
|
|
338
|
-
if token in self.commands:
|
|
339
|
-
self.commands[token].update(
|
|
340
|
-
{
|
|
341
|
-
"status": "completed",
|
|
342
|
-
"stdout": "",
|
|
343
|
-
"stderr": (
|
|
344
|
-
f"Command timed out after {timeout} seconds"
|
|
345
|
-
),
|
|
346
|
-
"exit_code": -1,
|
|
347
|
-
"execution_time": execution_time,
|
|
348
|
-
"timeout_occurred": True,
|
|
349
|
-
}
|
|
350
|
-
)
|
|
351
456
|
except Exception as e:
|
|
352
457
|
execution_time = time.time() - start_time
|
|
353
|
-
logger.error(f"[{token}]
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
"timeout_occurred": False,
|
|
364
|
-
}
|
|
365
|
-
)
|
|
458
|
+
logger.error(f"[{token}] PTY execution failed: {e}")
|
|
459
|
+
self._store.update_command(
|
|
460
|
+
token,
|
|
461
|
+
status="completed",
|
|
462
|
+
stdout="",
|
|
463
|
+
stderr=str(e),
|
|
464
|
+
exit_code=-1,
|
|
465
|
+
execution_time=int(execution_time * 1000),
|
|
466
|
+
pty_process=None,
|
|
467
|
+
)
|
|
366
468
|
|
|
367
469
|
def query_command_status(self, token: str) -> Dict[str, Any]:
|
|
368
470
|
"""
|
|
@@ -375,30 +477,233 @@ class RunCmdService:
|
|
|
375
477
|
包含命令状态的字典
|
|
376
478
|
"""
|
|
377
479
|
logger.debug(f"Querying status for token: {token}")
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
480
|
+
|
|
481
|
+
cmd_info = self._store.get_command(token)
|
|
482
|
+
|
|
483
|
+
if not cmd_info:
|
|
484
|
+
logger.warning(f"Token not found: {token}")
|
|
485
|
+
return {
|
|
486
|
+
"token": token,
|
|
487
|
+
"status": "not_found",
|
|
488
|
+
"message": "Token not found",
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
logger.debug(f"[{token}] Status: {cmd_info.status}")
|
|
492
|
+
|
|
493
|
+
if cmd_info.status == "running":
|
|
494
|
+
return {"token": cmd_info.token, "status": "running"}
|
|
495
|
+
elif cmd_info.status in ["completed", "pending", "not_found", "terminated"]:
|
|
496
|
+
return {
|
|
497
|
+
"token": cmd_info.token,
|
|
498
|
+
"status": cmd_info.status,
|
|
499
|
+
"exit_code": cmd_info.exit_code,
|
|
500
|
+
"stdout": cmd_info.stdout,
|
|
501
|
+
"stderr": cmd_info.stderr,
|
|
502
|
+
"execution_time": cmd_info.execution_time,
|
|
503
|
+
"timeout_occurred": cmd_info.timeout_occurred,
|
|
504
|
+
}
|
|
505
|
+
else:
|
|
506
|
+
return {"token": cmd_info.token, "status": cmd_info.status}
|
|
507
|
+
|
|
508
|
+
def enhanced_query_command_status(
|
|
509
|
+
self, token: str, since_timestamp: Optional[int] = None
|
|
510
|
+
) -> Dict[str, Any]:
|
|
511
|
+
"""
|
|
512
|
+
增强版状态查询,支持流式输出
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
token: 命令令牌
|
|
516
|
+
since_timestamp: 只返回此时间戳之后的输出(毫秒)
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
包含命令状态和结果的字典
|
|
520
|
+
"""
|
|
521
|
+
logger.debug(f"Enhanced query for token: {token}, since: {since_timestamp}")
|
|
522
|
+
|
|
523
|
+
cmd_info = self._store.get_command(token)
|
|
524
|
+
|
|
525
|
+
if not cmd_info:
|
|
526
|
+
logger.warning(f"Token not found: {token}")
|
|
527
|
+
return {
|
|
528
|
+
"token": token,
|
|
529
|
+
"status": "not_found",
|
|
530
|
+
"message": "Token not found",
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
result = {
|
|
534
|
+
"token": cmd_info.token,
|
|
535
|
+
"status": cmd_info.status,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if cmd_info.status != "running":
|
|
539
|
+
result.update({
|
|
540
|
+
"exit_code": cmd_info.exit_code,
|
|
541
|
+
"stdout": cmd_info.stdout,
|
|
542
|
+
"stderr": cmd_info.stderr,
|
|
543
|
+
"execution_time": cmd_info.execution_time,
|
|
544
|
+
"timeout_occurred": cmd_info.timeout_occurred,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
if since_timestamp is not None and cmd_info.last_output_timestamp > since_timestamp:
|
|
548
|
+
result["stdout"] = cmd_info.stdout
|
|
549
|
+
result["stderr"] = cmd_info.stderr
|
|
550
|
+
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
def send_command_input(
|
|
554
|
+
self, token: str, input: str, append_newline: bool = True
|
|
555
|
+
) -> Dict[str, Any]:
|
|
556
|
+
"""
|
|
557
|
+
向运行中的命令发送输入
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
token: 命令令牌
|
|
561
|
+
input: 要发送的输入
|
|
562
|
+
append_newline: 是否追加换行符
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
包含操作结果的字典
|
|
566
|
+
"""
|
|
567
|
+
logger.debug(f"Sending input to token: {token}")
|
|
568
|
+
|
|
569
|
+
cmd_info = self._store.get_command(token)
|
|
570
|
+
|
|
571
|
+
if not cmd_info:
|
|
572
|
+
logger.warning(f"Token not found: {token}")
|
|
573
|
+
return {
|
|
574
|
+
"success": False,
|
|
575
|
+
"message": "Token not found",
|
|
576
|
+
"token": token,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if cmd_info.status != "running":
|
|
580
|
+
logger.warning(f"[{token}] Command is not running: {cmd_info.status}")
|
|
581
|
+
return {
|
|
582
|
+
"success": False,
|
|
583
|
+
"message": f"Command is not running: {cmd_info.status}",
|
|
584
|
+
"token": token,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if not cmd_info.pty_process:
|
|
588
|
+
logger.warning(f"[{token}] No PTY process available")
|
|
589
|
+
return {
|
|
590
|
+
"success": False,
|
|
591
|
+
"message": "PTY process not available",
|
|
592
|
+
"token": token,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
input_data = input
|
|
597
|
+
if append_newline:
|
|
598
|
+
input_data += "\r\n"
|
|
599
|
+
|
|
600
|
+
cmd_info.pty_process.write(input_data)
|
|
601
|
+
logger.debug(f"[{token}] Input sent successfully")
|
|
602
|
+
return {
|
|
603
|
+
"success": True,
|
|
604
|
+
"message": "Input sent successfully",
|
|
605
|
+
"token": token,
|
|
606
|
+
}
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.error(f"[{token}] Failed to send input: {e}")
|
|
609
|
+
return {
|
|
610
|
+
"success": False,
|
|
611
|
+
"message": str(e),
|
|
612
|
+
"token": token,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
def terminate_command(self, token: str) -> Dict[str, Any]:
|
|
616
|
+
"""
|
|
617
|
+
终止运行中的命令
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
token: 命令令牌
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
包含操作结果的字典
|
|
624
|
+
"""
|
|
625
|
+
logger.debug(f"Terminating token: {token}")
|
|
626
|
+
|
|
627
|
+
cmd_info = self._store.get_command(token)
|
|
628
|
+
|
|
629
|
+
if not cmd_info:
|
|
630
|
+
logger.warning(f"Token not found: {token}")
|
|
631
|
+
return {
|
|
632
|
+
"success": False,
|
|
633
|
+
"message": "Token not found",
|
|
634
|
+
"token": token,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if cmd_info.status != "running":
|
|
638
|
+
logger.warning(f"[{token}] Command is not running: {cmd_info.status}")
|
|
639
|
+
return {
|
|
640
|
+
"success": False,
|
|
641
|
+
"message": f"Command is not running: {cmd_info.status}",
|
|
642
|
+
"token": token,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
if cmd_info.pty_process:
|
|
647
|
+
cmd_info.pty_process.terminate()
|
|
648
|
+
logger.debug(f"[{token}] PTY process terminated")
|
|
649
|
+
|
|
650
|
+
self._store.update_command(
|
|
651
|
+
token,
|
|
652
|
+
status="terminated",
|
|
653
|
+
exit_code=-1,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
logger.info(f"[{token}] Command terminated successfully")
|
|
657
|
+
return {
|
|
658
|
+
"success": True,
|
|
659
|
+
"message": "Command terminated successfully",
|
|
660
|
+
"token": token,
|
|
661
|
+
}
|
|
662
|
+
except Exception as e:
|
|
663
|
+
logger.error(f"[{token}] Failed to terminate command: {e}")
|
|
664
|
+
return {
|
|
665
|
+
"success": False,
|
|
666
|
+
"message": str(e),
|
|
667
|
+
"token": token,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
def get_version_info(self) -> Dict[str, Any]:
|
|
671
|
+
"""
|
|
672
|
+
获取版本信息
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
包含版本信息的字典
|
|
676
|
+
"""
|
|
677
|
+
import sys
|
|
678
|
+
import platform
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
ps_path = None
|
|
682
|
+
ps_error = None
|
|
683
|
+
try:
|
|
684
|
+
ps_path = find_powershell()
|
|
685
|
+
except FileNotFoundError as e:
|
|
686
|
+
ps_error = str(e)
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
"version": get_version(),
|
|
690
|
+
"service_status": "running",
|
|
691
|
+
"python_version": sys.version,
|
|
692
|
+
"platform": sys.platform,
|
|
693
|
+
"arch": platform.machine(),
|
|
694
|
+
"env": {
|
|
695
|
+
"WINTERM_POWERSHELL_PATH": os.environ.get(ENV_POWERSHELL_PATH),
|
|
696
|
+
"WINTERM_CMD_PATH": os.environ.get(ENV_CMD_PATH),
|
|
697
|
+
"WINTERM_PYTHON_PATH": os.environ.get(ENV_PYTHON_PATH),
|
|
698
|
+
"WINTERM_LOG_LEVEL": os.environ.get("WINTERM_LOG_LEVEL"),
|
|
699
|
+
},
|
|
700
|
+
}
|
|
701
|
+
except Exception as e:
|
|
702
|
+
return {
|
|
703
|
+
"version": get_version(),
|
|
704
|
+
"service_status": "error",
|
|
705
|
+
"error": str(e),
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
RunCmdService = CommandService
|