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.
@@ -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"""