Skip to content

Commit cfe76b3

Browse files
committed
feat: add Bash support to code execution and enhance session handling
1 parent 8e42642 commit cfe76b3

8 files changed

Lines changed: 303 additions & 10 deletions

File tree

app/api/base.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
# Initialize services
3131
file_manager = FileManager()
3232

33-
SUPPORTED_LANGUAGES = {"py", "r"} # Python and R are supported
33+
SUPPORTED_LANGUAGES = {"py", "r", "bash", "js", "ts"} # Python, R, Bash, JavaScript (Node.js) and TypeScript
3434
MAX_RETRIES = 3
3535

3636

@@ -68,6 +68,14 @@ async def execute_code(
6868
"summary": "Random Number in R",
6969
"value": {"code": "cat(sample(1:100, 1))", "lang": "r"},
7070
},
71+
"Hello World (Node.js)": {
72+
"summary": "Hello World in JavaScript",
73+
"value": {"code": "console.log('Hello, world!')", "lang": "js"},
74+
},
75+
"Hello World (TypeScript)": {
76+
"summary": "Hello World in TypeScript",
77+
"value": {"code": "const msg: string = 'Hello, world!'; console.log(msg)", "lang": "ts"},
78+
},
7179
}
7280
),
7381
],
@@ -77,12 +85,19 @@ async def execute_code(
7785

7886
if request.lang not in SUPPORTED_LANGUAGES:
7987
raise BadLanguageException( # noqa: F821
80-
message=f"Language '{request.lang}' is not supported. Please use Python ('py') or R ('r')."
88+
message=f"Language '{request.lang}' is not supported. Please use Python ('py'), R ('r'), Bash ('bash'), JavaScript ('js') or TypeScript ('ts')."
8189
)
8290

8391
try:
84-
# Use the session ID from the first file if available, otherwise create new
85-
session_id = request.files[0].session_id if request.files else generate_id()
92+
# Session resolution order: explicit session_id from the request body
93+
# (sent by LibreChat's bash_tool to continue a sandbox session),
94+
# then the session of the first referenced file, otherwise a new one
95+
if request.session_id:
96+
session_id = request.session_id
97+
elif request.files:
98+
session_id = request.files[0].session_id
99+
else:
100+
session_id = generate_id()
86101
logger.info(f"Using session ID: {session_id}")
87102

88103
# Get associated files if any
@@ -105,6 +120,12 @@ async def execute_code(
105120
result["stdout"] = "Empty. Make sure to explicitly print the results in Python"
106121
elif request.lang == "r":
107122
result["stdout"] = "Empty. Make sure to use print() or cat() to display results in R"
123+
elif request.lang == "bash":
124+
result["stdout"] = "Empty. Make sure the command writes its results to stdout (e.g. echo, cat)"
125+
elif request.lang == "js":
126+
result["stdout"] = "Empty. Make sure to explicitly console.log() the results in JavaScript"
127+
elif request.lang == "ts":
128+
result["stdout"] = "Empty. Make sure to explicitly console.log() the results in TypeScript"
108129
else:
109130
result["stdout"] = "Empty. Make sure to explicitly output the results"
110131

@@ -119,6 +140,12 @@ async def execute_code(
119140
version_info = f"Python {sys.version.split()[0]}"
120141
elif request.lang == "r":
121142
version_info = "R (Jupyter R-notebook)"
143+
elif request.lang == "bash":
144+
version_info = "Bash (Jupyter scipy-notebook)"
145+
elif request.lang == "js":
146+
version_info = "JavaScript (Node.js 24)"
147+
elif request.lang == "ts":
148+
version_info = "TypeScript (Node.js 24, type stripping)"
122149
else:
123150
version_info = f"Unknown language: {request.lang}"
124151

app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ async def lifespan(app: FastAPI):
2626

2727
# Initialize Docker executor
2828
await docker_executor.initialize()
29-
29+
3030
# Start cleanup service
3131
await cleanup_service.start()
3232

app/models/base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ class CodeExecutionRequest(BaseModel):
3232
lang: str = Field(
3333
...,
3434
description="The programming language of the code",
35-
examples=["py", "r"],
36-
pattern="^(c|cpp|d|f90|go|java|js|php|py|rs|ts|r)$",
35+
examples=["py", "r", "bash", "js", "ts"],
36+
pattern="^(c|cpp|d|f90|go|java|js|php|py|rs|ts|r|bash)$",
3737
)
3838
args: Optional[List[str]] = Field(None, description="Optional command line arguments to pass to the program")
39+
session_id: Optional[str] = Field(
40+
None,
41+
description="Optional session identifier to continue an existing sandbox session",
42+
pattern="^[A-Za-z0-9_-]{21}$",
43+
)
3944
user_id: Optional[str] = Field(None, description="Optional user identifier")
4045
entity_id: Optional[str] = Field(
4146
None,

app/services/docker_executor.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,27 @@ class DockerExecutor:
5252
LANGUAGE_EXECUTORS = {
5353
"py": ["python", "-c"],
5454
"r": ["Rscript", "-e"],
55+
"bash": ["bash", "-c"],
56+
"js": ["node", "-e"],
57+
# Node 24 strips TypeScript types natively; run eval input as a TS ES module
58+
"ts": ["node", "--input-type=module-typescript", "-e"],
59+
}
60+
61+
# Unprivileged user (user:group) inside each language's container image.
62+
# Jupyter images ship with jovyan:users, the official Node image with node:node.
63+
DEFAULT_CONTAINER_USER = ("jovyan", "users")
64+
LANGUAGE_CONTAINER_USERS = {
65+
"js": ("node", "node"),
66+
"ts": ("node", "node"),
5567
}
5668

5769
# Language-specific messages
5870
LANGUAGE_SPECIFIC_MESSAGES = {
5971
"py": {"empty_output": "Empty. Make sure to explicitly print() the results in Python"},
6072
"r": {"empty_output": "Empty. Make sure to use print() or cat() to display results in R"},
73+
"bash": {"empty_output": "Empty. Make sure the command writes its results to stdout (e.g. echo, cat)"},
74+
"js": {"empty_output": "Empty. Make sure to explicitly console.log() the results in JavaScript"},
75+
"ts": {"empty_output": "Empty. Make sure to explicitly console.log() the results in TypeScript"},
6176
}
6277

6378
def __init__(self):
@@ -271,7 +286,7 @@ async def execute(
271286
self,
272287
code: str,
273288
session_id: str,
274-
lang: Literal["py", "r"],
289+
lang: Literal["py", "r", "bash", "js", "ts"],
275290
files: Optional[List[Dict[str, Any]]] = None,
276291
config: Optional[Dict[str, Any]] = None,
277292
) -> Dict[str, Any]:
@@ -407,8 +422,12 @@ async def execute(
407422
await asyncio.sleep(0.1)
408423

409424
# Fix permissions for mounted directory
425+
exec_user, exec_group = self.LANGUAGE_CONTAINER_USERS.get(lang, self.DEFAULT_CONTAINER_USER)
410426
exec = await container.exec(
411-
cmd=["chown", "-R", "jovyan:users", self.DATA_MOUNT], user="root", stdout=True, stderr=True
427+
cmd=["chown", "-R", f"{exec_user}:{exec_group}", self.DATA_MOUNT],
428+
user="root",
429+
stdout=True,
430+
stderr=True,
412431
)
413432
# Use raw API call to get output
414433
exec_url = f"exec/{exec._id}/start"
@@ -430,7 +449,7 @@ async def execute(
430449
logger.info(f"Using execution command: {exec_cmd}")
431450

432451
# Execute the code with the appropriate interpreter
433-
exec = await container.exec(cmd=[*exec_cmd, code], user="jovyan", stdout=True, stderr=True)
452+
exec = await container.exec(cmd=[*exec_cmd, code], user=exec_user, stdout=True, stderr=True)
434453
# Use raw API call to get output
435454
exec_url = f"exec/{exec._id}/start"
436455
async with self._docker._query(

app/shared/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,20 @@ def HOST_FILE_UPLOAD_PATH_ABS(self) -> Path:
8686

8787
PY_CONTAINER_IMAGE: str = "jupyter/scipy-notebook:latest"
8888
R_CONTAINER_IMAGE: str = "jupyter/r-notebook:latest"
89+
BASH_CONTAINER_IMAGE: str = "jupyter/scipy-notebook:latest"
90+
# Node 24 (LTS) runs TypeScript natively via type stripping, so one image covers both
91+
JS_CONTAINER_IMAGE: str = "node:24-slim"
92+
TS_CONTAINER_IMAGE: str = "node:24-slim"
8993

9094
@property
9195
def LANGUAGE_CONTAINERS(self) -> Dict[str, str]:
9296
"""Map language codes to container images."""
9397
return {
9498
"py": self.PY_CONTAINER_IMAGE,
9599
"r": self.R_CONTAINER_IMAGE,
100+
"bash": self.BASH_CONTAINER_IMAGE,
101+
"js": self.JS_CONTAINER_IMAGE,
102+
"ts": self.TS_CONTAINER_IMAGE,
96103
}
97104

98105
# Docker execution settings
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from fastapi.testclient import TestClient
2+
from app.main import app
3+
4+
client = TestClient(app)
5+
6+
7+
def test_bash_code_execution():
8+
"""Test executing a simple bash command."""
9+
response = client.post("/v1/execute", json={"code": "echo 'Hello from Bash!'", "lang": "bash"})
10+
11+
assert response.status_code == 200
12+
result = response.json()
13+
assert result["run"]["status"] == "ok"
14+
assert "Hello from Bash!" in result["run"]["stdout"]
15+
assert result["run"]["stderr"] == ""
16+
assert isinstance(result["files"], list)
17+
assert result["language"] == "bash"
18+
assert "Bash" in result["version"]
19+
20+
21+
def test_bash_python_invocation():
22+
"""Test running python via bash, as LibreChat's bash_tool does."""
23+
response = client.post("/v1/execute", json={"code": "python3 -c 'print(1232**4)'", "lang": "bash"})
24+
25+
assert response.status_code == 200
26+
result = response.json()
27+
assert result["run"]["status"] == "ok"
28+
assert str(1232**4) in result["run"]["stdout"]
29+
30+
31+
def test_bash_code_execution_error():
32+
"""Test executing a bash command that fails."""
33+
response = client.post("/v1/execute", json={"code": "cat /nonexistent/file.txt", "lang": "bash"})
34+
35+
assert response.status_code == 200
36+
result = response.json()
37+
assert result["run"]["status"] == "error"
38+
assert isinstance(result["files"], list)
39+
40+
41+
def test_bash_empty_output_message():
42+
"""Test that a command with no output returns the bash-specific hint."""
43+
response = client.post("/v1/execute", json={"code": "true", "lang": "bash"})
44+
45+
assert response.status_code == 200
46+
result = response.json()
47+
assert result["run"]["status"] == "ok"
48+
assert result["run"]["stdout"] == "Empty. Make sure the command writes its results to stdout (e.g. echo, cat)"
49+
50+
51+
def test_bash_session_continuity():
52+
"""Test that files persist across executions when session_id is sent in the body."""
53+
# First call: write a file, no session_id provided
54+
response1 = client.post(
55+
"/v1/execute", json={"code": "echo 'persisted content' > /mnt/data/note.txt", "lang": "bash"}
56+
)
57+
58+
assert response1.status_code == 200
59+
result1 = response1.json()
60+
assert result1["run"]["status"] == "ok"
61+
session_id = result1["session_id"]
62+
assert session_id
63+
# The new file should be detected as an output file
64+
assert any(f["name"] == "note.txt" for f in result1["files"])
65+
66+
# Second call: continue the same session via session_id and read the file back
67+
response2 = client.post(
68+
"/v1/execute", json={"code": "cat /mnt/data/note.txt", "lang": "bash", "session_id": session_id}
69+
)
70+
71+
assert response2.status_code == 200
72+
result2 = response2.json()
73+
assert result2["run"]["status"] == "ok"
74+
assert "persisted content" in result2["run"]["stdout"]
75+
assert result2["session_id"] == session_id
76+
77+
78+
def test_session_id_path_traversal_rejected():
79+
"""Test that a session_id with path traversal is rejected with a 422 before execution."""
80+
response = client.post(
81+
"/v1/execute",
82+
json={"code": "cat /mnt/data/secret", "lang": "bash", "session_id": "../../etc/passwd"},
83+
)
84+
85+
assert response.status_code == 422
86+
87+
88+
def test_librechat_bash_exec():
89+
"""Test the LibreChat exec route with bash, matching bash_tool's request/response contract."""
90+
response = client.post("/v1/librechat/exec", json={"code": "echo 'librechat bash'", "lang": "bash"})
91+
92+
assert response.status_code == 200
93+
result = response.json()
94+
assert "session_id" in result
95+
assert "librechat bash" in result["stdout"]
96+
assert "stderr" in result
97+
98+
99+
def test_librechat_bash_session_continuity():
100+
"""Test LibreChat-style session continuation: write in call 1, cat in call 2."""
101+
response1 = client.post(
102+
"/v1/librechat/exec", json={"code": "printf 'step one' > /mnt/data/state.txt", "lang": "bash"}
103+
)
104+
105+
assert response1.status_code == 200
106+
session_id = response1.json()["session_id"]
107+
108+
response2 = client.post(
109+
"/v1/librechat/exec", json={"code": "cat /mnt/data/state.txt", "lang": "bash", "session_id": session_id}
110+
)
111+
112+
assert response2.status_code == 200
113+
result2 = response2.json()
114+
assert "step one" in result2["stdout"]
115+
assert result2["session_id"] == session_id
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from fastapi.testclient import TestClient
2+
from app.main import app
3+
4+
client = TestClient(app)
5+
6+
7+
def test_js_code_execution():
8+
"""Test executing simple JavaScript code with Node.js."""
9+
response = client.post("/v1/execute", json={"code": "console.log('Hello from Node!')", "lang": "js"})
10+
11+
assert response.status_code == 200
12+
result = response.json()
13+
assert result["run"]["status"] == "ok"
14+
assert "Hello from Node!" in result["run"]["stdout"]
15+
assert result["run"]["stderr"] == ""
16+
assert isinstance(result["files"], list)
17+
assert result["language"] == "js"
18+
assert "JavaScript" in result["version"]
19+
20+
21+
def test_js_code_execution_error():
22+
"""Test executing JavaScript code that throws."""
23+
response = client.post("/v1/execute", json={"code": "throw new Error('boom')", "lang": "js"})
24+
25+
assert response.status_code == 200
26+
result = response.json()
27+
assert result["run"]["status"] == "error"
28+
assert isinstance(result["files"], list)
29+
30+
31+
def test_js_empty_output_message():
32+
"""Test that JavaScript code with no output returns the js-specific hint."""
33+
response = client.post("/v1/execute", json={"code": "const x = 1 + 1", "lang": "js"})
34+
35+
assert response.status_code == 200
36+
result = response.json()
37+
assert result["run"]["status"] == "ok"
38+
assert result["run"]["stdout"] == "Empty. Make sure to explicitly console.log() the results in JavaScript"
39+
40+
41+
def test_js_file_output():
42+
"""Test that files written by JavaScript code are detected as output files."""
43+
response = client.post(
44+
"/v1/execute",
45+
json={
46+
"code": "const fs = require('fs'); fs.writeFileSync('/mnt/data/out.txt', 'node output'); console.log('written')",
47+
"lang": "js",
48+
},
49+
)
50+
51+
assert response.status_code == 200
52+
result = response.json()
53+
assert result["run"]["status"] == "ok"
54+
assert any(f["name"] == "out.txt" for f in result["files"])
55+
56+
57+
def test_ts_code_execution():
58+
"""Test executing TypeScript code with type annotations via Node.js type stripping."""
59+
code = "const greet = (name: string): string => `Hello, ${name}!`; console.log(greet('TypeScript'))"
60+
response = client.post("/v1/execute", json={"code": code, "lang": "ts"})
61+
62+
assert response.status_code == 200
63+
result = response.json()
64+
assert result["run"]["status"] == "ok"
65+
assert "Hello, TypeScript!" in result["run"]["stdout"]
66+
assert result["language"] == "ts"
67+
assert "TypeScript" in result["version"]
68+
69+
70+
def test_ts_interface_execution():
71+
"""Test that interfaces and typed objects work in TypeScript."""
72+
code = (
73+
"interface Point { x: number; y: number }\n"
74+
"const p: Point = { x: 3, y: 4 };\n"
75+
"console.log(Math.sqrt(p.x ** 2 + p.y ** 2))"
76+
)
77+
response = client.post("/v1/execute", json={"code": code, "lang": "ts"})
78+
79+
assert response.status_code == 200
80+
result = response.json()
81+
assert result["run"]["status"] == "ok"
82+
assert "5" in result["run"]["stdout"]
83+
84+
85+
def test_ts_empty_output_message():
86+
"""Test that TypeScript code with no output returns the ts-specific hint."""
87+
response = client.post("/v1/execute", json={"code": "const x: number = 42", "lang": "ts"})
88+
89+
assert response.status_code == 200
90+
result = response.json()
91+
assert result["run"]["status"] == "ok"
92+
assert result["run"]["stdout"] == "Empty. Make sure to explicitly console.log() the results in TypeScript"

0 commit comments

Comments
 (0)