Skip to content

Commit 50448fe

Browse files
Merge pull request #35 from microsoft/kkaitepalli/exitcode-correction
Limit ShellCommunicator to bash
2 parents 7e5ace3 + 58daa58 commit 50448fe

4 files changed

Lines changed: 96 additions & 57 deletions

File tree

src/microbots/environment/local_docker/LocalDockerEnvironment.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,27 @@ def copy_to_container(self, src_path: str, dest_path: str) -> bool:
180180
if not os.path.exists(src_path):
181181
logger.error("❌ Source path does not exist: %s", src_path)
182182
return False
183-
183+
# Ensure destination directory exists inside container
184+
dest_dir = os.path.dirname(dest_path)
185+
if dest_dir and dest_dir != '/':
186+
# Check if directory exists inside the container first
187+
check_cmd = f"test -d {shlex.quote(dest_dir)}"
188+
check_result = self.execute(check_cmd)
189+
190+
if check_result.return_code != 0:
191+
logger.debug("📁 Creating destination directory inside container: %s", dest_dir)
192+
mkdir_cmd = f"mkdir -p {shlex.quote(dest_dir)}"
193+
mkdir_result = self.execute(mkdir_cmd)
194+
195+
if mkdir_result.return_code != 0:
196+
logger.error("❌ Failed to create destination directory %s: %s",
197+
dest_dir, mkdir_result.stderr)
198+
return False
199+
else:
200+
logger.debug("✅ Destination directory created: %s", dest_dir)
201+
else:
202+
logger.debug("✅ Destination directory already exists: %s", dest_dir)
203+
184204
# Use docker cp command to copy files/folders
185205
# Escape paths for shell safety
186206
src_escaped = shlex.quote(src_path)
@@ -189,7 +209,7 @@ def copy_to_container(self, src_path: str, dest_path: str) -> bool:
189209
# Build docker cp command
190210
cmd = f"docker cp {src_escaped} {self.container.id}:{dest_escaped}"
191211

192-
logger.info("📁 Copying %s to container:%s", src_path, dest_path)
212+
logger.debug("📁 Copying %s to container:%s", src_path, dest_path)
193213

194214
# Execute the copy command
195215
result = subprocess.run(
@@ -230,14 +250,28 @@ def copy_from_container(self, src_path: str, dest_path: str) -> bool:
230250
return False
231251

232252
try:
253+
# Check if source path exists inside the container
254+
check_cmd = f"test -e {shlex.quote(src_path)}"
255+
check_result = self.execute(check_cmd)
256+
257+
if check_result.return_code != 0:
258+
logger.error("❌ Source path does not exist in container: %s", src_path)
259+
return False
260+
261+
# Check if destination directory exists on host machine
262+
dest_dir = os.path.dirname(dest_path)
263+
if not os.path.exists(dest_dir):
264+
logger.error("❌ Destination directory does not exist on host: %s", dest_dir)
265+
return False
266+
233267
# Escape paths for shell safety
234268
src_escaped = shlex.quote(src_path)
235269
dest_escaped = shlex.quote(dest_path)
236270

237271
# Build docker cp command
238272
cmd = f"docker cp {self.container.id}:{src_escaped} {dest_escaped}"
239273

240-
logger.info("📁 Copying container:%s to %s", src_path, dest_path)
274+
logger.debug("📁 Copying container:%s to %s", src_path, dest_path)
241275

242276
# Execute the copy command
243277
result = subprocess.run(
@@ -249,7 +283,7 @@ def copy_from_container(self, src_path: str, dest_path: str) -> bool:
249283
)
250284

251285
if result.returncode == 0:
252-
logger.info("✅ Successfully copied container:%s to %s", src_path, dest_path)
286+
logger.info("✅ Successfully copied from container:%s to %s", src_path, dest_path)
253287
return True
254288
else:
255289
logger.error("❌ Failed to copy file. Error: %s", result.stderr)

src/microbots/environment/local_docker/image_builder/ShellCommunicator.py

Lines changed: 35 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ def __init__(self, shell_type: str = "bash", encoding: str = "utf-8"):
3434
Initialize the shell communicator.
3535
3636
Args:
37-
shell_type: Type of shell ("powershell", "cmd", "bash", "python")
37+
shell_type: Type of shell (only "bash" is supported)
3838
encoding: Text encoding for communication
3939
"""
4040
self.shell_type = shell_type.lower()
41+
if self.shell_type != "bash":
42+
raise ValueError(f"Unsupported shell type: {shell_type}. Only 'bash' is supported.")
4143
self.encoding = encoding
4244
self.process: Optional[subprocess.Popen] = None
4345
self.output_queue = queue.Queue()
@@ -47,13 +49,9 @@ def __init__(self, shell_type: str = "bash", encoding: str = "utf-8"):
4749
self.error_thread: Optional[threading.Thread] = None
4850
self.output_callback: Optional[Callable] = None
4951

50-
# Define shell commands
52+
# Define shell commands - only supporting bash
5153
self.shell_commands = {
52-
"powershell": ["powershell.exe", "-NoLogo", "-NoExit"],
53-
"cmd": ["cmd.exe", "/k"],
5454
"bash": ["bash"],
55-
"python": [sys.executable, "-i"],
56-
"wsl": ["wsl.exe"],
5755
}
5856

5957
def start_session(self) -> bool:
@@ -134,7 +132,6 @@ def _re_escape(self, command: str) -> str:
134132
# command = command.replace("&lt;", "<").replace("&gt;", ">")
135133
return command
136134

137-
# TODO: Exit code is not properly captured. Need to fix it.
138135
def send_command(
139136
self, command: str, wait_for_output: bool = True, timeout: float = 300
140137
) -> CmdReturn:
@@ -155,28 +152,24 @@ def send_command(
155152

156153
try:
157154
command = self._re_escape(command)
158-
# Send the command
159-
self.process.stdin.write(command + "\n")
160-
self.process.stdin.flush()
161-
logger.debug("➡️ Sent command: %s", command)
162-
155+
163156
if not wait_for_output:
157+
# Send the command without marker for async execution
158+
self.process.stdin.write(command + "\n")
159+
self.process.stdin.flush()
160+
logger.debug("➡️ Sent async command: %s", command)
164161
return CmdReturn(stdout="ASYNC: Not waiting for completion", stderr="", return_code=0)
165162

166163
# Generate a unique command completion marker
167164
marker = f"__COMMAND_COMPLETE_{int(time.time() * 1000000)}__"
168165

169-
# Send the marker command based on shell type
170-
if self.shell_type in ["bash", "wsl"]:
171-
self.process.stdin.write(f"echo '{marker}'; echo $? > /tmp/last_exit_code\n")
172-
elif self.shell_type == "powershell":
173-
self.process.stdin.write(f"echo '{marker}'; echo $LASTEXITCODE\n")
174-
elif self.shell_type == "cmd":
175-
self.process.stdin.write(f"echo {marker} & echo %ERRORLEVEL%\n")
176-
elif self.shell_type == "python":
177-
self.process.stdin.write(f"print('{marker}')\n")
178-
166+
# For bash only: Send command + marker in a single line to capture correct exit code
167+
combined_command = f"{command}; echo '{marker}' $?"
168+
169+
# Send the combined command
170+
self.process.stdin.write(combined_command + "\n")
179171
self.process.stdin.flush()
172+
logger.debug("➡️ Sent command: %s", command)
180173

181174
# Collect output until marker is found or timeout
182175
output_lines = []
@@ -193,23 +186,24 @@ def send_command(
193186
# Check if this is our completion marker
194187
if marker in line:
195188
marker_found = True
196-
# For bash/wsl, try to get the exit code from the next line
197-
if self.shell_type in ["bash", "wsl"]:
198-
try:
199-
# Try to get exit code from next output
200-
stream_type, exit_code_line = self.output_queue.get(timeout=0.5)
201-
if exit_code_line.strip().isdigit():
202-
last_exit_code = int(exit_code_line.strip())
203-
except (queue.Empty, ValueError):
204-
pass
205-
elif self.shell_type in ["powershell", "cmd"]:
206-
try:
207-
# Try to get exit code from next output
208-
stream_type, exit_code_line = self.output_queue.get(timeout=0.5)
209-
if exit_code_line.strip().isdigit():
210-
last_exit_code = int(exit_code_line.strip())
211-
except (queue.Empty, ValueError):
212-
pass
189+
# For bash, the exit code is on the same line after the marker
190+
try:
191+
# Extract exit code from the same line as the marker
192+
# Format: "__COMMAND_COMPLETE_xxxxx__ exit_code"
193+
parts = line.split()
194+
if len(parts) >= 2:
195+
exit_code_str = parts[-1].strip()
196+
# Handle bash exit codes (0-255 only)
197+
if exit_code_str.isdigit():
198+
last_exit_code = int(exit_code_str)
199+
else:
200+
last_exit_code = 1
201+
else:
202+
last_exit_code = 1
203+
except (ValueError, AttributeError, IndexError):
204+
# Default to 1 if parsing fails
205+
last_exit_code = 1
206+
logger.debug("🔍 Found marker with exit code: %s", last_exit_code)
213207
continue
214208

215209
# Add output to appropriate list
@@ -236,7 +230,6 @@ def send_command(
236230
except queue.Empty:
237231
break
238232

239-
# TODO: Final return code is not correct. Need a fix
240233
final_return_code = last_exit_code if marker_found else (1 if error_lines else 0)
241234

242235
# Handle timeout case
@@ -295,13 +288,8 @@ def close_session(self):
295288

296289
if self.process:
297290
try:
298-
# Try to terminate gracefully
299-
if self.shell_type == "powershell":
300-
self.send_command("exit", wait_for_output=False)
301-
elif self.shell_type == "cmd":
302-
self.send_command("exit", wait_for_output=False)
303-
else:
304-
self.send_command("exit", wait_for_output=False)
291+
# Try to terminate gracefully with bash exit command
292+
self.send_command("exit", wait_for_output=False)
305293

306294
# Wait a bit for graceful shutdown
307295
time.sleep(1)

src/microbots/environment/local_docker/image_builder/dockerShell.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
import os
2+
import logging
23

34
import uvicorn
45
from fastapi import FastAPI
56
from pydantic import BaseModel
67
from ShellCommunicator import ShellCommunicator
78

9+
# Configure logging to see all logs including ShellCommunicator
10+
logging.basicConfig(
11+
level=logging.DEBUG,
12+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
13+
handlers=[
14+
logging.StreamHandler() # Ensure logs go to stdout
15+
]
16+
)
17+
18+
# Set specific logger levels
19+
logging.getLogger('ShellCommunicator').setLevel(logging.DEBUG)
20+
logging.getLogger('uvicorn').setLevel(logging.INFO)
21+
822
shell = ShellCommunicator("bash")
923
shell.start_session()
1024

test/environment/local_docker/TestFileCopy.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
"""
55

66
import os
7-
import tempfile
8-
import unittest
97
import sys
10-
8+
from pathlib import Path
9+
import logging
10+
logger = logging.getLogger(__name__)
11+
logging.basicConfig(level=logging.INFO)
1112
# Add src directory to path to import from local source
1213
sys.path.insert(
1314
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../src"))
@@ -25,8 +26,9 @@ def test_copy_file(self):
2526

2627
try:
2728
# Copy to container
28-
# Give absolute path
29-
result = env.copy_to_container("/home/kkaitepalli/minions/README.md", "/var/log/README.md")
29+
# Get path to countries.txt file specifically
30+
countries_file_path = Path(__file__).parent.parent.parent / "bot" / "countries_to_capital" / "countries_dir" / "countries.txt"
31+
result = env.copy_to_container(str(countries_file_path), "/var/log/")
3032

3133
# Verify
3234
print(f"Copy result: {result}")
@@ -36,7 +38,8 @@ def test_copy_file(self):
3638
print("❌ Copy failed")
3739

3840
# Test copying from container to host
39-
result_back = env.copy_from_container("/var/log/README.md", "/home/kkaitepalli/temp/README_copied.md")
41+
# Use /tmp/ which is available and writable on all systems
42+
result_back = env.copy_from_container("/var/log/countries.txt", "/tmp/")
4043
print(f"Copy back result: {result_back}")
4144
if result_back:
4245
print("✅ Copy back succeeded")

0 commit comments

Comments
 (0)