Skip to content

Commit 06a45f3

Browse files
committed
.
1 parent 352cb5b commit 06a45f3

8 files changed

Lines changed: 840 additions & 33 deletions

File tree

code-interpreter/app/api/routes.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from app.app_configs import get_settings
1010
from app.models.schemas import (
11+
BashExecRequest,
12+
BashExecResponse,
1113
CreateSessionRequest,
1214
CreateSessionResponse,
1315
ExecuteFile,
@@ -21,7 +23,13 @@
2123
UploadFileResponse,
2224
WorkspaceFile,
2325
)
24-
from app.services.executor_base import EntryKind, StreamChunk, StreamResult, WorkspaceEntry
26+
from app.services.executor_base import (
27+
EntryKind,
28+
SessionNotFoundError,
29+
StreamChunk,
30+
StreamResult,
31+
WorkspaceEntry,
32+
)
2533
from app.services.executor_factory import execute_python, execute_python_streaming, get_executor
2634
from app.services.file_storage import FileStorageService
2735

@@ -321,3 +329,48 @@ def delete_session(session_id: str) -> Response:
321329
)
322330

323331
return Response(status_code=status.HTTP_204_NO_CONTENT)
332+
333+
334+
@router.post(
335+
"/sessions/{session_id}/bash",
336+
response_model=BashExecResponse,
337+
status_code=status.HTTP_200_OK,
338+
)
339+
def session_exec_bash(session_id: str, req: BashExecRequest) -> BashExecResponse:
340+
"""Run a bash command inside an existing session.
341+
342+
The session pod has no network access (enforced at session creation), and
343+
that restriction continues to apply for every command run via this route.
344+
"""
345+
settings = get_settings()
346+
if req.timeout_ms > settings.max_exec_timeout_ms:
347+
raise HTTPException(
348+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
349+
detail=f"timeout_ms exceeds maximum of {settings.max_exec_timeout_ms} ms",
350+
)
351+
352+
try:
353+
result = get_executor().execute_bash_in_session(
354+
session_id,
355+
cmd=req.cmd,
356+
timeout_ms=req.timeout_ms,
357+
max_output_bytes=settings.max_output_bytes,
358+
)
359+
except SessionNotFoundError as exc:
360+
raise HTTPException(
361+
status_code=status.HTTP_404_NOT_FOUND,
362+
detail=str(exc),
363+
) from exc
364+
except NotImplementedError as exc:
365+
raise HTTPException(
366+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
367+
detail=str(exc),
368+
) from exc
369+
370+
return BashExecResponse(
371+
stdout=result.stdout,
372+
stderr=result.stderr,
373+
exit_code=result.exit_code,
374+
timed_out=result.timed_out,
375+
duration_ms=result.duration_ms,
376+
)

code-interpreter/app/models/schemas.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,23 @@ class CreateSessionResponse(BaseModel):
147147
expires_at: float = Field(
148148
..., description="Unix timestamp when the session is scheduled to expire."
149149
)
150+
151+
152+
DEFAULT_BASH_TIMEOUT_MS = 30_000
153+
154+
155+
class BashExecRequest(BaseModel):
156+
cmd: StrictStr = Field(..., description="Bash command to execute in the session.")
157+
timeout_ms: StrictInt = Field(
158+
DEFAULT_BASH_TIMEOUT_MS,
159+
ge=1,
160+
description="Per-command execution timeout in milliseconds.",
161+
)
162+
163+
164+
class BashExecResponse(BaseModel):
165+
stdout: StrictStr
166+
stderr: StrictStr
167+
exit_code: int | None
168+
timed_out: bool
169+
duration_ms: StrictInt

code-interpreter/app/services/executor_base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ class SessionInfo:
120120
SESSION_EXPIRES_AT_KEY = "code-interpreter.expires-at"
121121

122122

123+
class SessionNotFoundError(LookupError):
124+
"""Raised when a session ID does not refer to an existing session."""
125+
126+
def __init__(self, session_id: str) -> None:
127+
super().__init__(f"Session '{session_id}' not found")
128+
self.session_id = session_id
129+
130+
123131
class ExecutorProtocol(Protocol):
124132
def execute_python(
125133
self,
@@ -206,6 +214,21 @@ def reap_expired_sessions(self) -> int:
206214
"""Delete sessions whose TTL has elapsed. Returns number reaped."""
207215
return 0
208216

217+
def execute_bash_in_session(
218+
self,
219+
session_id: str,
220+
*,
221+
cmd: str,
222+
timeout_ms: int,
223+
max_output_bytes: int,
224+
) -> ExecutionResult:
225+
"""Run a bash command inside an existing session.
226+
227+
Raises ``SessionNotFoundError`` when the session does not exist.
228+
Network restrictions established at session creation remain in force.
229+
"""
230+
raise NotImplementedError(f"{type(self).__name__} does not support sessions")
231+
209232
@staticmethod
210233
def truncate_output(stream: bytes, max_bytes: int) -> str:
211234
if len(stream) <= max_bytes:

code-interpreter/app/services/executor_docker.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ExecutionResult,
3131
HealthCheck,
3232
SessionInfo,
33+
SessionNotFoundError,
3334
StreamChunk,
3435
StreamEvent,
3536
StreamResult,
@@ -40,6 +41,12 @@
4041
logger = logging.getLogger(__name__)
4142

4243

44+
def _looks_like_missing_container(stderr: bytes) -> bool:
45+
"""Heuristic: ``docker exec`` writes these to stderr when the target is gone."""
46+
text = stderr.decode("utf-8", errors="replace").lower()
47+
return "no such container" in text or "is not running" in text
48+
49+
4350
@dataclass
4451
class _ExecContext:
4552
"""Holds the live container and process for the duration of an execution."""
@@ -502,6 +509,71 @@ def reap_expired_sessions(self) -> int:
502509
logger.warning("Failed to reap session container %s: %s", name, rm_result.stderr)
503510
return reaped
504511

512+
def execute_bash_in_session(
513+
self,
514+
session_id: str,
515+
*,
516+
cmd: str,
517+
timeout_ms: int,
518+
max_output_bytes: int,
519+
) -> ExecutionResult:
520+
"""Run a bash command inside an existing session container.
521+
522+
The container was created with ``--network none`` at session-create time
523+
and that network namespace is what the exec inherits — no additional
524+
flags are needed (or accepted) for ``docker exec``.
525+
"""
526+
if not session_id.startswith(SESSION_NAME_PREFIX):
527+
raise SessionNotFoundError(session_id)
528+
529+
exec_cmd = [
530+
self.docker_binary,
531+
"exec",
532+
"-u",
533+
"65532:65532",
534+
session_id,
535+
"bash",
536+
"-c",
537+
cmd,
538+
]
539+
540+
start = time.perf_counter()
541+
proc = subprocess.Popen( # nosec B603
542+
exec_cmd,
543+
stdin=subprocess.DEVNULL,
544+
stdout=subprocess.PIPE,
545+
stderr=subprocess.PIPE,
546+
)
547+
548+
try:
549+
stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout_ms / 1000.0)
550+
timed_out = False
551+
except subprocess.TimeoutExpired:
552+
timed_out = True
553+
# Kill bash inside the container; pkill matches all bash procs in the
554+
# container — acceptable since the agent runs commands sequentially.
555+
subprocess.run( # nosec B603
556+
[self.docker_binary, "exec", session_id, "pkill", "-9", "bash"],
557+
capture_output=True,
558+
)
559+
proc.kill()
560+
stdout_bytes, stderr_bytes = proc.communicate()
561+
562+
duration_ms = int((time.perf_counter() - start) * 1000)
563+
exit_code = None if timed_out else proc.returncode
564+
565+
if not timed_out and proc.returncode != 0 and _looks_like_missing_container(stderr_bytes):
566+
raise SessionNotFoundError(session_id)
567+
568+
return ExecutionResult(
569+
stdout=self.truncate_output(stdout_bytes or b"", max_output_bytes),
570+
stderr=self.truncate_output(stderr_bytes or b"", max_output_bytes),
571+
exit_code=exit_code,
572+
timed_out=timed_out,
573+
duration_ms=duration_ms,
574+
files=tuple(),
575+
)
576+
505577
def execute_python(
506578
self,
507579
*,

code-interpreter/app/services/executor_kubernetes.py

Lines changed: 102 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
ExecutionResult,
3838
HealthCheck,
3939
SessionInfo,
40+
SessionNotFoundError,
4041
StreamChunk,
4142
StreamEvent,
4243
StreamResult,
@@ -376,19 +377,63 @@ def _upload_tar_to_pod(self, pod_name: str, tar_archive: bytes) -> None:
376377
f"stderr: {tar_stderr.decode('utf-8', errors='replace')}"
377378
)
378379

379-
def _kill_python_process(self, pod_name: str) -> None:
380-
"""Kill the Python process running in the pod."""
380+
def _kill_processes_in_pod(self, pod_name: str, process_name: str) -> None:
381+
"""Best-effort SIGKILL of all processes named ``process_name`` in the pod."""
381382
try:
382383
self._stream_pod_exec(
383384
pod_name,
384-
command=["pkill", "-9", "python"],
385+
command=["pkill", "-9", process_name],
385386
stderr=False,
386387
stdin=False,
387388
stdout=False,
388389
tty=False,
389390
)
390391
except Exception:
391-
logger.warning("Failed to kill Python process in pod %s", pod_name, exc_info=True)
392+
logger.warning(
393+
"Failed to kill %s process in pod %s", process_name, pod_name, exc_info=True
394+
)
395+
396+
def _kill_python_process(self, pod_name: str) -> None:
397+
"""Kill the Python process running in the pod."""
398+
self._kill_processes_in_pod(pod_name, "python")
399+
400+
def _drain_exec_stream(
401+
self,
402+
exec_resp: ws_client.WSClient,
403+
timeout_ms: int,
404+
) -> tuple[bytes, bytes, int | None, bool]:
405+
"""Read stdout/stderr from an exec stream until completion or timeout.
406+
407+
Returns ``(stdout_bytes, stderr_bytes, exit_code, timed_out)``.
408+
"""
409+
stdout_data = b""
410+
stderr_data = b""
411+
exit_code: int | None = None
412+
timed_out = False
413+
414+
end_time = time.time() + timeout_ms / 1000.0
415+
416+
while exec_resp.is_open():
417+
remaining = end_time - time.time()
418+
if remaining <= 0:
419+
timed_out = True
420+
break
421+
422+
exec_resp.update(timeout=min(remaining, 1))
423+
424+
if exec_resp.peek_stdout():
425+
stdout_data += exec_resp.read_stdout().encode("utf-8")
426+
427+
if exec_resp.peek_stderr():
428+
stderr_data += exec_resp.read_stderr().encode("utf-8")
429+
430+
error = exec_resp.read_channel(ws_client.ERROR_CHANNEL)
431+
if error:
432+
exit_code = _parse_exit_code(error)
433+
break
434+
435+
exec_resp.close()
436+
return stdout_data, stderr_data, exit_code, timed_out
392437

393438
@contextmanager
394439
def _run_in_pod(
@@ -702,6 +747,56 @@ def reap_expired_sessions(self) -> int:
702747
logger.info("Reaped expired session pod %s", metadata.name)
703748
return reaped
704749

750+
def execute_bash_in_session(
751+
self,
752+
session_id: str,
753+
*,
754+
cmd: str,
755+
timeout_ms: int,
756+
max_output_bytes: int,
757+
) -> ExecutionResult:
758+
"""Run a bash command inside an existing session pod.
759+
760+
Network restrictions established at pod creation (the iptables init
761+
container) remain in force — exec inherits the pod's network namespace.
762+
"""
763+
if not session_id.startswith(SESSION_NAME_PREFIX):
764+
raise SessionNotFoundError(session_id)
765+
766+
try:
767+
self.v1.read_namespaced_pod(session_id, self.namespace)
768+
except ApiException as e:
769+
if e.status == 404:
770+
raise SessionNotFoundError(session_id) from e
771+
raise
772+
773+
start = time.perf_counter()
774+
exec_resp = self._stream_pod_exec(
775+
session_id,
776+
command=["bash", "-c", cmd],
777+
stderr=True,
778+
stdin=False,
779+
stdout=True,
780+
tty=False,
781+
)
782+
783+
stdout_data, stderr_data, exit_code, timed_out = self._drain_exec_stream(
784+
exec_resp, timeout_ms
785+
)
786+
787+
if timed_out:
788+
self._kill_processes_in_pod(session_id, "bash")
789+
790+
duration_ms = int((time.perf_counter() - start) * 1000)
791+
return ExecutionResult(
792+
stdout=self.truncate_output(stdout_data, max_output_bytes),
793+
stderr=self.truncate_output(stderr_data, max_output_bytes),
794+
exit_code=None if timed_out else exit_code,
795+
timed_out=timed_out,
796+
duration_ms=duration_ms,
797+
files=tuple(),
798+
)
799+
705800
def execute_python(
706801
self,
707802
*,
@@ -731,34 +826,9 @@ def execute_python(
731826
logger.debug("Writing stdin to Python process")
732827
ctx.exec_resp.write_stdin(stdin)
733828

734-
stdout_data = b""
735-
stderr_data = b""
736-
exit_code: int | None = None
737-
timed_out = False
738-
739-
timeout_sec = timeout_ms / 1000.0
740-
end_time = time.time() + timeout_sec
741-
742-
while ctx.exec_resp.is_open():
743-
remaining = end_time - time.time()
744-
if remaining <= 0:
745-
timed_out = True
746-
break
747-
748-
ctx.exec_resp.update(timeout=min(remaining, 1))
749-
750-
if ctx.exec_resp.peek_stdout():
751-
stdout_data += ctx.exec_resp.read_stdout().encode("utf-8")
752-
753-
if ctx.exec_resp.peek_stderr():
754-
stderr_data += ctx.exec_resp.read_stderr().encode("utf-8")
755-
756-
error = ctx.exec_resp.read_channel(ws_client.ERROR_CHANNEL)
757-
if error:
758-
exit_code = _parse_exit_code(error)
759-
break
760-
761-
ctx.exec_resp.close()
829+
stdout_data, stderr_data, exit_code, timed_out = self._drain_exec_stream(
830+
ctx.exec_resp, timeout_ms
831+
)
762832

763833
if timed_out:
764834
self._kill_python_process(ctx.pod_name)

0 commit comments

Comments
 (0)