winterm-mcp 0.1.6__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 +2 -4
- winterm_mcp/constants.py +32 -0
- winterm_mcp/models.py +73 -0
- winterm_mcp/server.py +142 -36
- winterm_mcp/service.py +709 -427
- winterm_mcp/store.py +92 -0
- winterm_mcp/utils.py +206 -0
- {winterm_mcp-0.1.6.dist-info → winterm_mcp-0.1.7.dist-info}/METADATA +2 -1
- winterm_mcp-0.1.7.dist-info/RECORD +14 -0
- {winterm_mcp-0.1.6.dist-info → winterm_mcp-0.1.7.dist-info}/WHEEL +1 -1
- winterm_mcp-0.1.6.dist-info/RECORD +0 -10
- {winterm_mcp-0.1.6.dist-info → winterm_mcp-0.1.7.dist-info}/entry_points.txt +0 -0
- {winterm_mcp-0.1.6.dist-info → winterm_mcp-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {winterm_mcp-0.1.6.dist-info → winterm_mcp-0.1.7.dist-info}/top_level.txt +0 -0
winterm_mcp/service.py
CHANGED
|
@@ -1,427 +1,709 @@
|
|
|
1
|
-
"""
|
|
2
|
-
winterm服务模块 - 异步执行Windows终端命令服务
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
import threading
|
|
7
|
-
import uuid
|
|
8
|
-
import time
|
|
9
|
-
import os
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
working_directory
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
token
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1
|
+
"""
|
|
2
|
+
winterm服务模块 - 异步执行Windows终端命令服务
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
import time
|
|
9
|
+
import os
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime
|
|
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)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def setup_logging(level: int = logging.INFO) -> None:
|
|
42
|
+
"""
|
|
43
|
+
配置日志输出
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
level: 日志级别,默认 INFO
|
|
47
|
+
|
|
48
|
+
日志输出位置:
|
|
49
|
+
1. 控制台 (stderr)
|
|
50
|
+
2. 文件: %TEMP%/winterm-mcp.log 或 /tmp/winterm-mcp.log
|
|
51
|
+
|
|
52
|
+
可通过环境变量配置:
|
|
53
|
+
- WINTERM_LOG_LEVEL: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
|
54
|
+
- WINTERM_LOG_FILE: 自定义日志文件路径
|
|
55
|
+
"""
|
|
56
|
+
import tempfile
|
|
57
|
+
|
|
58
|
+
formatter = logging.Formatter(
|
|
59
|
+
"[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
|
|
60
|
+
datefmt="%Y-%m-%d %H:%M:%S"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
console_handler = logging.StreamHandler()
|
|
64
|
+
console_handler.setFormatter(formatter)
|
|
65
|
+
logger.addHandler(console_handler)
|
|
66
|
+
|
|
67
|
+
log_file = os.environ.get("WINTERM_LOG_FILE")
|
|
68
|
+
if not log_file:
|
|
69
|
+
log_file = os.path.join(tempfile.gettempdir(), "winterm-mcp.log")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
73
|
+
file_handler.setFormatter(formatter)
|
|
74
|
+
logger.addHandler(file_handler)
|
|
75
|
+
logger.info(f"Log file: {log_file}")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.warning(f"Failed to create log file {log_file}: {e}")
|
|
78
|
+
|
|
79
|
+
logger.setLevel(level)
|
|
80
|
+
|
|
81
|
+
env_level = os.environ.get("WINTERM_LOG_LEVEL", "").upper()
|
|
82
|
+
if env_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
|
83
|
+
logger.setLevel(getattr(logging, env_level))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_version() -> str:
|
|
87
|
+
"""
|
|
88
|
+
获取 winterm-mcp 版本号
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
版本号字符串
|
|
92
|
+
"""
|
|
93
|
+
return __version__
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CommandService:
|
|
97
|
+
"""
|
|
98
|
+
异步命令执行服务类,管理所有异步命令的执行和状态
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self):
|
|
102
|
+
self._store = CommandStore()
|
|
103
|
+
self._powershell_path: Optional[str] = None
|
|
104
|
+
self._cmd_path: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
def _get_powershell_path(self) -> str:
|
|
107
|
+
"""
|
|
108
|
+
获取 PowerShell 可执行文件路径(带缓存)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
PowerShell 可执行文件的绝对路径
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
FileNotFoundError: 如果找不到 PowerShell
|
|
115
|
+
"""
|
|
116
|
+
if self._powershell_path is None:
|
|
117
|
+
self._powershell_path = find_powershell()
|
|
118
|
+
logger.debug(f"PowerShell path cached: {self._powershell_path}")
|
|
119
|
+
return self._powershell_path
|
|
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
|
+
|
|
136
|
+
def run_command(
|
|
137
|
+
self,
|
|
138
|
+
command: str,
|
|
139
|
+
executable: Optional[str] = None,
|
|
140
|
+
args: Optional[List[str]] = None,
|
|
141
|
+
shell_type: Literal["powershell", "cmd", "executable"] = "executable",
|
|
142
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
143
|
+
working_directory: Optional[str] = None,
|
|
144
|
+
enable_streaming: bool = False,
|
|
145
|
+
) -> str:
|
|
146
|
+
"""
|
|
147
|
+
异步运行命令
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
command: 要执行的命令
|
|
151
|
+
executable: 可执行文件路径
|
|
152
|
+
args: 可执行文件参数
|
|
153
|
+
shell_type: Shell 类型 (powershell/cmd/executable)
|
|
154
|
+
timeout: 超时时间(秒)
|
|
155
|
+
working_directory: 工作目录
|
|
156
|
+
enable_streaming: 启用实时流式输出
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
命令执行的token
|
|
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
|
+
|
|
173
|
+
token = str(uuid.uuid4())
|
|
174
|
+
|
|
175
|
+
logger.info(
|
|
176
|
+
f"Submitting command: token={token}, shell={shell_type}, "
|
|
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,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
self._store.add_command(token, cmd_info)
|
|
204
|
+
|
|
205
|
+
thread = threading.Thread(
|
|
206
|
+
target=self._execute_command,
|
|
207
|
+
args=(token, command, executable, args, shell_type, timeout, working_directory, enable_streaming),
|
|
208
|
+
)
|
|
209
|
+
thread.daemon = True
|
|
210
|
+
thread.start()
|
|
211
|
+
|
|
212
|
+
return token
|
|
213
|
+
|
|
214
|
+
def _execute_command(
|
|
215
|
+
self,
|
|
216
|
+
token: str,
|
|
217
|
+
command: str,
|
|
218
|
+
executable: Optional[str],
|
|
219
|
+
args: Optional[List[str]],
|
|
220
|
+
shell_type: str,
|
|
221
|
+
timeout: int,
|
|
222
|
+
working_directory: Optional[str],
|
|
223
|
+
enable_streaming: bool,
|
|
224
|
+
):
|
|
225
|
+
"""
|
|
226
|
+
在单独线程中执行命令
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
start_time = time.time()
|
|
230
|
+
logger.debug(f"[{token}] Starting command execution...")
|
|
231
|
+
|
|
232
|
+
self._store.update_command(token, status="running")
|
|
233
|
+
|
|
234
|
+
encoding = "gbk"
|
|
235
|
+
|
|
236
|
+
if shell_type == "powershell":
|
|
237
|
+
ps_path = self._get_powershell_path()
|
|
238
|
+
logger.info(f"[{token}] Using PowerShell: {ps_path}")
|
|
239
|
+
cmd_args = [
|
|
240
|
+
ps_path,
|
|
241
|
+
"-NoProfile",
|
|
242
|
+
"-NoLogo",
|
|
243
|
+
"-NonInteractive",
|
|
244
|
+
"-ExecutionPolicy",
|
|
245
|
+
"Bypass",
|
|
246
|
+
"-Command",
|
|
247
|
+
command,
|
|
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]
|
|
253
|
+
else:
|
|
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
|
|
261
|
+
|
|
262
|
+
logger.debug(f"[{token}] Executing: {cmd_args}")
|
|
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(
|
|
380
|
+
cmd_args,
|
|
381
|
+
cols=PTY_COLS,
|
|
382
|
+
rows=PTY_ROWS,
|
|
383
|
+
cwd=cwd,
|
|
384
|
+
env=env or os.environ,
|
|
385
|
+
)
|
|
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 ""
|
|
434
|
+
execution_time = time.time() - start_time
|
|
435
|
+
|
|
436
|
+
logger.info(
|
|
437
|
+
f"[{token}] PTY command completed: exit_code={exit_code}, "
|
|
438
|
+
f"time={execution_time:.3f}s"
|
|
439
|
+
)
|
|
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
|
+
)
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
execution_time = time.time() - start_time
|
|
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
|
+
)
|
|
468
|
+
|
|
469
|
+
def query_command_status(self, token: str) -> Dict[str, Any]:
|
|
470
|
+
"""
|
|
471
|
+
查询命令执行状态
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
token: 命令的token
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
包含命令状态的字典
|
|
478
|
+
"""
|
|
479
|
+
logger.debug(f"Querying status for token: {token}")
|
|
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
|