Skip to content

Commit 5eca3ab

Browse files
committed
feat: Enhance execution and state management with args and state restoration features
- Added an `args` parameter to the `execute_code` function, allowing users to pass command line arguments to the executed code. - Introduced `restore_state` field in the `RequestFile` model to facilitate state restoration from previously used files. - Updated `ExecuteCodeRequest` model to include `args` for better flexibility in code execution. - Enhanced `FileInfo` model with state-related fields (`execution_id`, `state_hash`, `last_used_at`) for improved state management. - Implemented state hash storage and retrieval in `StateService` for linking files to specific execution states. - Added integration tests to validate new features and ensure correct functionality across models and services.
1 parent 6f50193 commit 5eca3ab

13 files changed

Lines changed: 989 additions & 45 deletions

File tree

docker/repl_server.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ def execute_code(
291291
timeout: int = 30,
292292
working_dir: str = "/mnt/data",
293293
initial_state: str = None,
294-
capture_state: bool = False
294+
capture_state: bool = False,
295+
args: list = None
295296
) -> dict:
296297
"""Execute code in isolated namespace and capture output.
297298
@@ -301,6 +302,7 @@ def execute_code(
301302
working_dir: Working directory for execution
302303
initial_state: Base64-encoded cloudpickle state to restore before execution
303304
capture_state: Whether to capture and return state after execution
305+
args: Optional list of command line arguments
304306
305307
Returns:
306308
Dict with exit_code, stdout, stderr, execution_time_ms, and optionally state/state_errors
@@ -330,6 +332,12 @@ def execute_code(
330332

331333
exit_code = 0
332334

335+
# Save and set sys.argv if args provided
336+
original_argv = sys.argv
337+
if args is not None:
338+
# Set sys.argv to [script_name] + args (matches file-based execution)
339+
sys.argv = ['/mnt/data/code.py'] + list(args)
340+
333341
# Set up timeout handler
334342
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
335343
signal.alarm(timeout)
@@ -370,6 +378,9 @@ def execute_code(
370378
signal.alarm(0)
371379
signal.signal(signal.SIGALRM, old_handler)
372380

381+
# Restore sys.argv
382+
sys.argv = original_argv
383+
373384
# Restore working directory
374385
try:
375386
os.chdir(original_dir)
@@ -503,14 +514,16 @@ def main():
503514
working_dir = request.get("working_dir", "/mnt/data")
504515
initial_state = request.get("initial_state")
505516
capture_state = request.get("capture_state", False)
517+
args = request.get("args") # List of command line arguments
506518

507519
# Execute code with optional state persistence
508520
response = execute_code(
509521
code,
510522
timeout,
511523
working_dir,
512524
initial_state=initial_state,
513-
capture_state=capture_state
525+
capture_state=capture_state,
526+
args=args
514527
)
515528

516529
# Send response

src/models/exec.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class RequestFile(BaseModel):
2222
id: str
2323
session_id: str
2424
name: str
25+
restore_state: bool = Field(
26+
default=False,
27+
description="If true, restore Python state from when this file was last used"
28+
)
2529

2630

2731
class ExecRequest(BaseModel):

src/models/execution.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ class ExecuteCodeRequest(BaseModel):
7979
timeout: Optional[int] = Field(
8080
default=None, description="Execution timeout in seconds"
8181
)
82+
args: Optional[List[str]] = Field(
83+
default=None, description="Command line arguments to pass to the executed code"
84+
)
8285

8386

8487
class ExecuteCodeResponse(BaseModel):

src/models/files.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ class FileInfo(BaseModel):
4040
content_type: str
4141
created_at: datetime
4242
path: str = Field(..., description="File path in the session")
43+
# State restoration fields (for Python state-file linking)
44+
execution_id: Optional[str] = Field(
45+
default=None,
46+
description="ID of the execution that created/last used this file"
47+
)
48+
state_hash: Optional[str] = Field(
49+
default=None,
50+
description="SHA256 hash of the Python state when this file was last used"
51+
)
52+
last_used_at: Optional[datetime] = Field(
53+
default=None,
54+
description="Timestamp of when this file was last used in an execution"
55+
)
4356

4457
class Config:
4558
json_encoders = {datetime: lambda v: v.isoformat()}

src/services/container/manager.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ async def copy_to_container(
365365
return False
366366

367367
async def copy_content_to_container(
368-
self, container: Container, content: bytes, dest_path: str
368+
self, container: Container, content: bytes, dest_path: str, language: str = "py"
369369
) -> bool:
370370
"""Copy content directly to container without tempfiles.
371371
@@ -376,19 +376,25 @@ async def copy_content_to_container(
376376
container: Target container
377377
content: File content as bytes
378378
dest_path: Destination path in container (e.g., /mnt/data/file.py)
379+
language: Programming language (used to set correct file ownership)
379380
380381
Returns:
381382
True if successful, False otherwise
382383
"""
383384
try:
384385
loop = asyncio.get_event_loop()
385386

387+
# Get user ID for this language's container
388+
user_id = self.get_user_id_for_language(language)
389+
386390
# Build in-memory tar archive
387391
tar_buffer = io.BytesIO()
388392
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
389393
tarinfo = tarfile.TarInfo(name=dest_path.split("/")[-1])
390394
tarinfo.size = len(content)
391395
tarinfo.mode = 0o644
396+
tarinfo.uid = user_id
397+
tarinfo.gid = user_id
392398
tar.addfile(tarinfo, io.BytesIO(content))
393399

394400
tar_buffer.seek(0)

src/services/container/repl_executor.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def execute(
4343
code: str,
4444
timeout: int = None,
4545
working_dir: str = "/mnt/data",
46+
args: Optional[List[str]] = None,
4647
) -> Tuple[int, str, str]:
4748
"""Execute code in running REPL.
4849
@@ -51,6 +52,7 @@ async def execute(
5152
code: Python code to execute
5253
timeout: Maximum execution time in seconds
5354
working_dir: Working directory for code execution
55+
args: Optional list of command line arguments
5456
5557
Returns:
5658
Tuple of (exit_code, stdout, stderr)
@@ -62,6 +64,8 @@ async def execute(
6264

6365
# Build request
6466
request = {"code": code, "timeout": timeout, "working_dir": working_dir}
67+
if args:
68+
request["args"] = args
6569
request_json = json.dumps(request)
6670
request_bytes = request_json.encode("utf-8") + DELIMITER
6771

@@ -109,6 +113,7 @@ async def execute_with_state(
109113
working_dir: str = "/mnt/data",
110114
initial_state: Optional[str] = None,
111115
capture_state: bool = False,
116+
args: Optional[List[str]] = None,
112117
) -> Tuple[int, str, str, Optional[str], List[str]]:
113118
"""Execute code in running REPL with optional state persistence.
114119
@@ -119,6 +124,7 @@ async def execute_with_state(
119124
working_dir: Working directory for code execution
120125
initial_state: Base64-encoded state to restore before execution
121126
capture_state: Whether to capture state after execution
127+
args: Optional list of command line arguments
122128
123129
Returns:
124130
Tuple of (exit_code, stdout, stderr, new_state, state_errors)
@@ -138,6 +144,9 @@ async def execute_with_state(
138144
if capture_state:
139145
request["capture_state"] = True
140146

147+
if args:
148+
request["args"] = args
149+
141150
request_json = json.dumps(request)
142151
request_bytes = request_json.encode("utf-8") + DELIMITER
143152

src/services/execution/runner.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Code execution runner - core execution logic."""
22

33
import asyncio
4+
import shlex
45
from datetime import datetime, timedelta
56
from pathlib import Path
67
from typing import Any, Dict, List, Optional, Tuple
@@ -152,7 +153,7 @@ async def execute(
152153

153154
# Mount files if provided
154155
if files:
155-
await self._mount_files_to_container(container, files)
156+
await self._mount_files_to_container(container, files, request.language)
156157

157158
# Execute the code
158159
start_time = datetime.utcnow()
@@ -185,11 +186,13 @@ async def execute(
185186
request.timeout or settings.max_execution_time,
186187
initial_state=initial_state,
187188
capture_state=capture_state,
189+
args=request.args,
188190
)
189191
else:
190192
# Standard execution (no state persistence)
191193
exit_code, stdout, stderr = await self._execute_code_in_container(
192-
container, request.code, request.language, request.timeout
194+
container, request.code, request.language, request.timeout,
195+
args=request.args
193196
)
194197
end_time = datetime.utcnow()
195198

@@ -435,12 +438,20 @@ async def _execute_code_in_container(
435438
code: str,
436439
language: str,
437440
timeout: Optional[int] = None,
441+
args: Optional[List[str]] = None,
438442
) -> Tuple[int, str, str]:
439443
"""Execute code in the container.
440444
441445
For REPL-enabled containers (Python with REPL mode), uses the fast
442446
REPL executor which communicates with the pre-warmed Python interpreter.
443447
For other containers, uses the standard execution path.
448+
449+
Args:
450+
container: Docker container to execute in
451+
code: Code to execute
452+
language: Programming language
453+
timeout: Execution timeout in seconds
454+
args: Optional list of command line arguments
444455
"""
445456
language = language.lower()
446457
lang_config = get_language(language)
@@ -454,7 +465,7 @@ async def _execute_code_in_container(
454465
logger.debug(
455466
"Using REPL executor", container_id=container.id[:12], language=language
456467
)
457-
return await self._execute_via_repl(container, code, execution_timeout)
468+
return await self._execute_via_repl(container, code, execution_timeout, args=args)
458469

459470
# Standard execution path for non-REPL containers
460471
exec_command = lang_config.execution_command
@@ -480,13 +491,20 @@ async def _execute_code_in_container(
480491
# Direct memory-to-container transfer (no tempfiles)
481492
dest_path = f"/mnt/data/{code_filename}"
482493
if not await self.container_manager.copy_content_to_container(
483-
container, code.encode("utf-8"), dest_path
494+
container, code.encode("utf-8"), dest_path, language=language
484495
):
485496
return 1, "", "Failed to write code file to container"
486497

498+
# Build execution command with args if provided
499+
final_command = exec_command
500+
if args:
501+
# Safely quote each argument to prevent shell injection
502+
quoted_args = " ".join(shlex.quote(arg) for arg in args)
503+
final_command = f"{exec_command} {quoted_args}"
504+
487505
return await self.container_manager.execute_command(
488506
container,
489-
exec_command,
507+
final_command,
490508
timeout=execution_timeout,
491509
language=language,
492510
working_dir="/mnt/data",
@@ -521,21 +539,23 @@ def _is_repl_container(self, container: Container, language: str) -> bool:
521539
return False
522540

523541
async def _execute_via_repl(
524-
self, container: Container, code: str, timeout: int
542+
self, container: Container, code: str, timeout: int,
543+
args: Optional[List[str]] = None
525544
) -> Tuple[int, str, str]:
526545
"""Execute code via REPL server in container.
527546
528547
Args:
529548
container: Docker container with REPL server running
530549
code: Python code to execute
531550
timeout: Maximum execution time in seconds
551+
args: Optional list of command line arguments
532552
533553
Returns:
534554
Tuple of (exit_code, stdout, stderr)
535555
"""
536556
repl_executor = REPLExecutor(self.container_manager.client)
537557
return await repl_executor.execute(
538-
container, code, timeout=timeout, working_dir="/mnt/data"
558+
container, code, timeout=timeout, working_dir="/mnt/data", args=args
539559
)
540560

541561
async def _execute_via_repl_with_state(
@@ -545,6 +565,7 @@ async def _execute_via_repl_with_state(
545565
timeout: int,
546566
initial_state: Optional[str] = None,
547567
capture_state: bool = True,
568+
args: Optional[List[str]] = None,
548569
) -> Tuple[int, str, str, Optional[str], List[str]]:
549570
"""Execute code via REPL server with state persistence.
550571
@@ -554,6 +575,7 @@ async def _execute_via_repl_with_state(
554575
timeout: Maximum execution time in seconds
555576
initial_state: Base64-encoded state to restore before execution
556577
capture_state: Whether to capture state after execution
578+
args: Optional list of command line arguments
557579
558580
Returns:
559581
Tuple of (exit_code, stdout, stderr, new_state, state_errors)
@@ -566,10 +588,11 @@ async def _execute_via_repl_with_state(
566588
working_dir="/mnt/data",
567589
initial_state=initial_state,
568590
capture_state=capture_state,
591+
args=args,
569592
)
570593

571594
async def _mount_files_to_container(
572-
self, container: Container, files: List[Dict[str, Any]]
595+
self, container: Container, files: List[Dict[str, Any]], language: str = "py"
573596
) -> None:
574597
"""Mount files to container workspace."""
575598
try:
@@ -599,7 +622,7 @@ async def _mount_files_to_container(
599622
dest_path = f"/mnt/data/{normalized_filename}"
600623

601624
if await self.container_manager.copy_content_to_container(
602-
container, file_content, dest_path
625+
container, file_content, dest_path, language=language
603626
):
604627
logger.info(
605628
"Mounted file",

0 commit comments

Comments
 (0)