castrel-proxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- castrel_proxy/__init__.py +22 -0
- castrel_proxy/cli/__init__.py +5 -0
- castrel_proxy/cli/commands.py +608 -0
- castrel_proxy/core/__init__.py +18 -0
- castrel_proxy/core/client_id.py +94 -0
- castrel_proxy/core/config.py +158 -0
- castrel_proxy/core/daemon.py +206 -0
- castrel_proxy/core/executor.py +166 -0
- castrel_proxy/data/__init__.py +1 -0
- castrel_proxy/data/default_whitelist.txt +229 -0
- castrel_proxy/mcp/__init__.py +8 -0
- castrel_proxy/mcp/manager.py +278 -0
- castrel_proxy/network/__init__.py +13 -0
- castrel_proxy/network/api_client.py +284 -0
- castrel_proxy/network/websocket_client.py +1148 -0
- castrel_proxy/operations/__init__.py +17 -0
- castrel_proxy/operations/document.py +343 -0
- castrel_proxy/security/__init__.py +17 -0
- castrel_proxy/security/whitelist.py +403 -0
- castrel_proxy-0.1.0.dist-info/METADATA +302 -0
- castrel_proxy-0.1.0.dist-info/RECORD +24 -0
- castrel_proxy-0.1.0.dist-info/WHEEL +4 -0
- castrel_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- castrel_proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client Unique Identifier Generation Module
|
|
3
|
+
|
|
4
|
+
Generates stable client IDs based on machine characteristics (hostname + MAC address)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import platform
|
|
9
|
+
import socket
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_client_id() -> str:
|
|
15
|
+
"""
|
|
16
|
+
Generate unique client identifier based on machine characteristics
|
|
17
|
+
|
|
18
|
+
Uses a combination of hostname and MAC address to generate a SHA256 hash,
|
|
19
|
+
ensuring the same machine always generates the same ID.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
str: 16-character unique client identifier
|
|
23
|
+
"""
|
|
24
|
+
# Get hostname
|
|
25
|
+
hostname = socket.gethostname()
|
|
26
|
+
|
|
27
|
+
# Get MAC address (as integer)
|
|
28
|
+
mac = uuid.getnode()
|
|
29
|
+
|
|
30
|
+
# Combine machine characteristics
|
|
31
|
+
identifier = f"{hostname}:{mac}"
|
|
32
|
+
|
|
33
|
+
# Generate SHA256 hash and take first 16 characters
|
|
34
|
+
hash_value = hashlib.sha256(identifier.encode()).hexdigest()[:16]
|
|
35
|
+
|
|
36
|
+
return hash_value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_machine_metadata() -> Dict[str, str]:
|
|
40
|
+
"""
|
|
41
|
+
Get current machine metadata information for sending to server
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
dict: Dictionary containing detailed machine information
|
|
45
|
+
"""
|
|
46
|
+
metadata = {}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# Get machine name
|
|
50
|
+
metadata["hostname"] = socket.gethostname()
|
|
51
|
+
except Exception:
|
|
52
|
+
metadata["hostname"] = "unknown"
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Get MAC address
|
|
56
|
+
mac = uuid.getnode()
|
|
57
|
+
mac_address = ":".join(["{:02x}".format((mac >> elements) & 0xFF) for elements in range(0, 48, 8)][::-1])
|
|
58
|
+
metadata["mac_address"] = mac_address
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Get operating system information
|
|
64
|
+
metadata["os"] = platform.system() # Windows, Linux, Darwin
|
|
65
|
+
metadata["os_version"] = platform.version()
|
|
66
|
+
metadata["os_release"] = platform.release()
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# Get machine architecture
|
|
72
|
+
metadata["architecture"] = platform.machine() # x86_64, arm64, etc.
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Get Python version
|
|
78
|
+
metadata["python_version"] = platform.python_version()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Get processor information
|
|
84
|
+
metadata["processor"] = platform.processor()
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Get platform information
|
|
90
|
+
metadata["platform"] = platform.platform()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
return metadata
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration File Management Module
|
|
3
|
+
|
|
4
|
+
Handles reading, writing, and validating the ~/.castrel/config.yaml configuration file
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
"""Configuration-related errors"""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Config:
|
|
21
|
+
"""Configuration management class"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
24
|
+
"""
|
|
25
|
+
Initialize configuration manager
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config_dir: Configuration directory path, defaults to ~/.castrel
|
|
29
|
+
"""
|
|
30
|
+
if config_dir is None:
|
|
31
|
+
self.config_dir = Path.home() / ".castrel"
|
|
32
|
+
else:
|
|
33
|
+
self.config_dir = Path(config_dir)
|
|
34
|
+
|
|
35
|
+
self.config_file = self.config_dir / "config.yaml"
|
|
36
|
+
|
|
37
|
+
def _ensure_config_dir(self):
|
|
38
|
+
"""Ensure configuration directory exists"""
|
|
39
|
+
try:
|
|
40
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise ConfigError(f"Failed to create config directory {self.config_dir}: {e}")
|
|
43
|
+
|
|
44
|
+
def save(self, server_url: str, verification_code: str, client_id: str, workspace_id: str) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Save configuration to file
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
server_url: Server URL
|
|
50
|
+
verification_code: Verification code
|
|
51
|
+
client_id: Client unique identifier
|
|
52
|
+
workspace_id: Workspace ID
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ConfigError: Raised when saving configuration fails
|
|
56
|
+
"""
|
|
57
|
+
self._ensure_config_dir()
|
|
58
|
+
|
|
59
|
+
config_data = {
|
|
60
|
+
"server_url": server_url,
|
|
61
|
+
"verification_code": verification_code,
|
|
62
|
+
"client_id": client_id,
|
|
63
|
+
"workspace_id": workspace_id,
|
|
64
|
+
"paired_at": datetime.utcnow().isoformat() + "Z",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
|
69
|
+
yaml.safe_dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise ConfigError(f"Failed to save configuration: {e}")
|
|
72
|
+
|
|
73
|
+
def load(self) -> dict:
|
|
74
|
+
"""
|
|
75
|
+
Load configuration from file
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
dict: Configuration dictionary
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ConfigError: Raised when configuration file doesn't exist or loading fails
|
|
82
|
+
"""
|
|
83
|
+
if not self.config_file.exists():
|
|
84
|
+
raise ConfigError("Configuration file does not exist. Please pair first using 'pair' command")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
|
88
|
+
config_data = yaml.safe_load(f)
|
|
89
|
+
|
|
90
|
+
if not config_data:
|
|
91
|
+
raise ConfigError("Configuration file is empty")
|
|
92
|
+
|
|
93
|
+
# Validate required fields
|
|
94
|
+
required_fields = ["server_url", "verification_code", "client_id", "workspace_id"]
|
|
95
|
+
for field in required_fields:
|
|
96
|
+
if field not in config_data:
|
|
97
|
+
raise ConfigError(f"Configuration file missing required field: {field}")
|
|
98
|
+
|
|
99
|
+
return config_data
|
|
100
|
+
|
|
101
|
+
except yaml.YAMLError as e:
|
|
102
|
+
raise ConfigError(f"Configuration file format error: {e}")
|
|
103
|
+
except Exception as e:
|
|
104
|
+
raise ConfigError(f"Failed to load configuration: {e}")
|
|
105
|
+
|
|
106
|
+
def exists(self) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Check if configuration file exists
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
bool: True if configuration file exists, False otherwise
|
|
112
|
+
"""
|
|
113
|
+
return self.config_file.exists()
|
|
114
|
+
|
|
115
|
+
def delete(self) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Delete configuration file
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ConfigError: Raised when deletion fails
|
|
121
|
+
"""
|
|
122
|
+
if not self.config_file.exists():
|
|
123
|
+
raise ConfigError("Configuration file does not exist")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self.config_file.unlink()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
raise ConfigError(f"Failed to delete configuration file: {e}")
|
|
129
|
+
|
|
130
|
+
def get_server_url(self) -> str:
|
|
131
|
+
"""Get server URL"""
|
|
132
|
+
return self.load()["server_url"]
|
|
133
|
+
|
|
134
|
+
def get_verification_code(self) -> str:
|
|
135
|
+
"""Get verification code"""
|
|
136
|
+
return self.load()["verification_code"]
|
|
137
|
+
|
|
138
|
+
def get_client_id(self) -> str:
|
|
139
|
+
"""Get client ID"""
|
|
140
|
+
return self.load()["client_id"]
|
|
141
|
+
|
|
142
|
+
def get_paired_at(self) -> Optional[str]:
|
|
143
|
+
"""Get pairing time"""
|
|
144
|
+
config = self.load()
|
|
145
|
+
return config.get("paired_at")
|
|
146
|
+
|
|
147
|
+
def get_workspace_id(self) -> str:
|
|
148
|
+
"""Get workspace ID"""
|
|
149
|
+
return self.load()["workspace_id"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Global configuration instance
|
|
153
|
+
_config = Config()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_config() -> Config:
|
|
157
|
+
"""Get global configuration instance"""
|
|
158
|
+
return _config
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Daemon Process Management Module
|
|
3
|
+
|
|
4
|
+
Handles background process management, PID files, and logging
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DaemonManager:
|
|
19
|
+
"""Daemon process manager"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, pid_file: Path, log_file: Path):
|
|
22
|
+
"""
|
|
23
|
+
Initialize daemon manager
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
pid_file: PID file path
|
|
27
|
+
log_file: Log file path
|
|
28
|
+
"""
|
|
29
|
+
self.pid_file = pid_file
|
|
30
|
+
self.log_file = log_file
|
|
31
|
+
|
|
32
|
+
def daemonize(self):
|
|
33
|
+
"""
|
|
34
|
+
Daemonize the current process (Unix double-fork)
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
OSError: If forking fails
|
|
38
|
+
"""
|
|
39
|
+
# Check if already running
|
|
40
|
+
if self.is_running():
|
|
41
|
+
pid = self.get_pid()
|
|
42
|
+
raise RuntimeError(f"Daemon already running with PID {pid}")
|
|
43
|
+
|
|
44
|
+
# Ensure parent directories exist
|
|
45
|
+
self.pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
# First fork
|
|
49
|
+
try:
|
|
50
|
+
pid = os.fork()
|
|
51
|
+
if pid > 0:
|
|
52
|
+
# Parent process exits
|
|
53
|
+
sys.exit(0)
|
|
54
|
+
except OSError as e:
|
|
55
|
+
logger.error(f"First fork failed: {e}")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
# Decouple from parent environment
|
|
59
|
+
os.chdir("/")
|
|
60
|
+
os.setsid()
|
|
61
|
+
os.umask(0)
|
|
62
|
+
|
|
63
|
+
# Second fork
|
|
64
|
+
try:
|
|
65
|
+
pid = os.fork()
|
|
66
|
+
if pid > 0:
|
|
67
|
+
# Parent process exits
|
|
68
|
+
sys.exit(0)
|
|
69
|
+
except OSError as e:
|
|
70
|
+
logger.error(f"Second fork failed: {e}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
# Redirect standard file descriptors
|
|
74
|
+
sys.stdout.flush()
|
|
75
|
+
sys.stderr.flush()
|
|
76
|
+
|
|
77
|
+
# Open log file
|
|
78
|
+
log_fd = os.open(str(self.log_file), os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
|
|
79
|
+
|
|
80
|
+
# Redirect stdin to /dev/null
|
|
81
|
+
with open(os.devnull, "r") as devnull:
|
|
82
|
+
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
|
83
|
+
|
|
84
|
+
# Redirect stdout and stderr to log file
|
|
85
|
+
os.dup2(log_fd, sys.stdout.fileno())
|
|
86
|
+
os.dup2(log_fd, sys.stderr.fileno())
|
|
87
|
+
|
|
88
|
+
os.close(log_fd)
|
|
89
|
+
|
|
90
|
+
# Write PID file
|
|
91
|
+
self._write_pid()
|
|
92
|
+
|
|
93
|
+
# Register cleanup on exit
|
|
94
|
+
atexit.register(self._cleanup)
|
|
95
|
+
|
|
96
|
+
# Handle termination signals
|
|
97
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
98
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
99
|
+
|
|
100
|
+
def _write_pid(self):
|
|
101
|
+
"""Write current process PID to file"""
|
|
102
|
+
pid = os.getpid()
|
|
103
|
+
with open(self.pid_file, "w") as f:
|
|
104
|
+
f.write(str(pid))
|
|
105
|
+
logger.info(f"Daemon started with PID {pid}")
|
|
106
|
+
|
|
107
|
+
def _cleanup(self):
|
|
108
|
+
"""Cleanup PID file on exit"""
|
|
109
|
+
if self.pid_file.exists():
|
|
110
|
+
self.pid_file.unlink()
|
|
111
|
+
logger.info("Daemon stopped, PID file removed")
|
|
112
|
+
|
|
113
|
+
def _signal_handler(self, signum, frame):
|
|
114
|
+
"""Handle termination signals"""
|
|
115
|
+
logger.info(f"Received signal {signum}, shutting down...")
|
|
116
|
+
sys.exit(0)
|
|
117
|
+
|
|
118
|
+
def get_pid(self) -> Optional[int]:
|
|
119
|
+
"""
|
|
120
|
+
Get PID from PID file
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Optional[int]: PID if file exists and is valid, None otherwise
|
|
124
|
+
"""
|
|
125
|
+
if not self.pid_file.exists():
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with open(self.pid_file, "r") as f:
|
|
130
|
+
pid = int(f.read().strip())
|
|
131
|
+
return pid
|
|
132
|
+
except (ValueError, IOError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def is_running(self) -> bool:
|
|
136
|
+
"""
|
|
137
|
+
Check if daemon process is running
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
bool: True if running, False otherwise
|
|
141
|
+
"""
|
|
142
|
+
pid = self.get_pid()
|
|
143
|
+
if pid is None:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Check if process exists (send signal 0)
|
|
148
|
+
os.kill(pid, 0)
|
|
149
|
+
return True
|
|
150
|
+
except OSError:
|
|
151
|
+
# Process doesn't exist, clean up stale PID file
|
|
152
|
+
self.pid_file.unlink()
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def stop(self) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
Stop daemon process
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
bool: True if stopped successfully, False if not running
|
|
161
|
+
"""
|
|
162
|
+
pid = self.get_pid()
|
|
163
|
+
if pid is None:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
if not self.is_running():
|
|
167
|
+
# Clean up stale PID file
|
|
168
|
+
if self.pid_file.exists():
|
|
169
|
+
self.pid_file.unlink()
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
# Send SIGTERM to gracefully terminate
|
|
174
|
+
os.kill(pid, signal.SIGTERM)
|
|
175
|
+
|
|
176
|
+
# Wait for process to exit (with timeout)
|
|
177
|
+
import time
|
|
178
|
+
|
|
179
|
+
for _ in range(50): # Wait up to 5 seconds
|
|
180
|
+
time.sleep(0.1)
|
|
181
|
+
if not self.is_running():
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
# If still running, force kill
|
|
185
|
+
if self.is_running():
|
|
186
|
+
os.kill(pid, signal.SIGKILL)
|
|
187
|
+
time.sleep(0.5)
|
|
188
|
+
|
|
189
|
+
return True
|
|
190
|
+
except OSError as e:
|
|
191
|
+
logger.error(f"Failed to stop daemon: {e}")
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_daemon_manager() -> DaemonManager:
|
|
196
|
+
"""
|
|
197
|
+
Get daemon manager instance
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
DaemonManager: Daemon manager with default paths
|
|
201
|
+
"""
|
|
202
|
+
config_dir = Path.home() / ".castrel"
|
|
203
|
+
pid_file = config_dir / "castrel-proxy.pid"
|
|
204
|
+
log_file = config_dir / "castrel-proxy.log"
|
|
205
|
+
|
|
206
|
+
return DaemonManager(pid_file, log_file)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command Executor Module
|
|
3
|
+
|
|
4
|
+
Responsible for executing shell commands and returning results
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExecutionResult:
|
|
13
|
+
"""Command execution result"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, exit_code: int, stdout: str, stderr: str, execution_time: float):
|
|
16
|
+
self.exit_code = exit_code
|
|
17
|
+
self.stdout = stdout
|
|
18
|
+
self.stderr = stderr
|
|
19
|
+
self.execution_time = execution_time
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> Dict:
|
|
22
|
+
"""Convert to dictionary format"""
|
|
23
|
+
return {
|
|
24
|
+
"exit_code": self.exit_code,
|
|
25
|
+
"stdout": self.stdout,
|
|
26
|
+
"stderr": self.stderr,
|
|
27
|
+
"execution_time": self.execution_time,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandExecutor:
|
|
32
|
+
"""Command executor"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, session_id: str, working_dir: Optional[str] = None, timeout: float = 300.0):
|
|
35
|
+
"""
|
|
36
|
+
Initialize command executor
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
session_id: Chat session ID (required)
|
|
40
|
+
working_dir: Working directory, if None uses ~/.castrel/${session_id}/ as default
|
|
41
|
+
timeout: Command execution timeout (seconds)
|
|
42
|
+
"""
|
|
43
|
+
self.session_id = session_id
|
|
44
|
+
|
|
45
|
+
# Set session directory
|
|
46
|
+
home_dir = os.path.expanduser("~")
|
|
47
|
+
self.session_dir = os.path.join(home_dir, ".castrel", session_id)
|
|
48
|
+
|
|
49
|
+
# Create session directory
|
|
50
|
+
os.makedirs(self.session_dir, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Set working directory (use session directory if not specified)
|
|
53
|
+
self.working_dir = working_dir if working_dir else self.session_dir
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
# Log file path
|
|
57
|
+
self.log_file = os.path.join(self.session_dir, "terminal.log")
|
|
58
|
+
|
|
59
|
+
async def execute(self, command: str, cwd: Optional[str] = None) -> ExecutionResult:
|
|
60
|
+
"""
|
|
61
|
+
Execute shell command asynchronously
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
command: Command to execute
|
|
65
|
+
cwd: Working directory (override mode), if specified temporarily uses this directory,
|
|
66
|
+
otherwise uses default working directory
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
ExecutionResult: Command execution result
|
|
70
|
+
"""
|
|
71
|
+
import time
|
|
72
|
+
|
|
73
|
+
start_time = time.time()
|
|
74
|
+
|
|
75
|
+
# Determine actual working directory to use
|
|
76
|
+
actual_cwd = cwd if cwd else self.working_dir
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
# Expand ~ and environment variables in path
|
|
80
|
+
working_dir = os.path.expanduser(os.path.expandvars(actual_cwd))
|
|
81
|
+
|
|
82
|
+
# Create subprocess to execute command
|
|
83
|
+
# Set stdin to DEVNULL to prevent command from waiting for input and hanging
|
|
84
|
+
process = await asyncio.create_subprocess_shell(
|
|
85
|
+
command,
|
|
86
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
87
|
+
stdout=asyncio.subprocess.PIPE,
|
|
88
|
+
stderr=asyncio.subprocess.PIPE,
|
|
89
|
+
cwd=working_dir,
|
|
90
|
+
env=os.environ.copy(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Wait for command to complete (with timeout)
|
|
94
|
+
try:
|
|
95
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=self.timeout)
|
|
96
|
+
except asyncio.TimeoutError:
|
|
97
|
+
# Timeout, terminate process
|
|
98
|
+
process.kill()
|
|
99
|
+
await process.wait()
|
|
100
|
+
execution_time = time.time() - start_time
|
|
101
|
+
result = ExecutionResult(
|
|
102
|
+
exit_code=-1,
|
|
103
|
+
stdout="",
|
|
104
|
+
stderr=f"Command execution timeout (exceeded {self.timeout} seconds)",
|
|
105
|
+
execution_time=execution_time,
|
|
106
|
+
)
|
|
107
|
+
# Log result
|
|
108
|
+
self._log_command(command, working_dir, result)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
# Decode output
|
|
112
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
113
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
114
|
+
exit_code = process.returncode
|
|
115
|
+
|
|
116
|
+
execution_time = time.time() - start_time
|
|
117
|
+
|
|
118
|
+
result = ExecutionResult(exit_code=exit_code, stdout=stdout, stderr=stderr, execution_time=execution_time)
|
|
119
|
+
|
|
120
|
+
# Log result
|
|
121
|
+
self._log_command(command, working_dir, result)
|
|
122
|
+
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
except Exception as e:
|
|
126
|
+
execution_time = time.time() - start_time
|
|
127
|
+
result = ExecutionResult(
|
|
128
|
+
exit_code=-2, stdout="", stderr=f"Command execution exception: {str(e)}", execution_time=execution_time
|
|
129
|
+
)
|
|
130
|
+
# Log result
|
|
131
|
+
self._log_command(command, actual_cwd, result)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
def _log_command(self, command: str, cwd: str, result: ExecutionResult):
|
|
135
|
+
"""
|
|
136
|
+
Log command execution to terminal.log
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command: Executed command
|
|
140
|
+
cwd: Working directory
|
|
141
|
+
result: Execution result
|
|
142
|
+
"""
|
|
143
|
+
from datetime import datetime
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
147
|
+
|
|
148
|
+
log_entry = f"""[{timestamp}] COMMAND: {command}
|
|
149
|
+
CWD: {cwd}
|
|
150
|
+
EXIT_CODE: {result.exit_code}
|
|
151
|
+
DURATION: {result.execution_time:.2f}s
|
|
152
|
+
STDOUT:
|
|
153
|
+
{result.stdout if result.stdout else "(empty)"}
|
|
154
|
+
STDERR:
|
|
155
|
+
{result.stderr if result.stderr else "(empty)"}
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
# Append to log file
|
|
161
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
162
|
+
f.write(log_entry)
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
# Logging failure should not affect command execution
|
|
166
|
+
print(f"Failed to log command: {e}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data files for Castrel Bridge Proxy"""
|