Skip to content

Commit 6906af3

Browse files
authored
Merge branch 'master' into feat/lark-integration
2 parents 887ef4c + 5dfc1a9 commit 6906af3

File tree

4 files changed

+211
-0
lines changed

4 files changed

+211
-0
lines changed

camel/toolkits/terminal_toolkit/terminal_toolkit.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import sys
2020
import threading
2121
import time
22+
import uuid
2223
from queue import Empty, Full, Queue
2324
from typing import Any, Dict, List, Optional
2425

@@ -1228,6 +1229,134 @@ def shell_ask_user_for_help(self, id: str, prompt: str) -> str:
12281229
except EOFError:
12291230
return f"User input interrupted for session '{id}'."
12301231

1232+
def shell_write_content_to_file(self, content: str, file_path: str) -> str:
1233+
r"""Writes the specified content to a file at the given path.
1234+
1235+
Args:
1236+
content (str): The content to write to the file.
1237+
file_path (str): The path to the file where the content should
1238+
be written. Can be absolute or relative to working_dir.
1239+
1240+
Returns:
1241+
str: A confirmation message indicating success or an error message.
1242+
"""
1243+
# For local backend, resolve relative paths to working_dir
1244+
if not self.use_docker_backend and self.working_dir:
1245+
if not os.path.isabs(file_path):
1246+
file_path = os.path.normpath(
1247+
os.path.join(self.working_dir, file_path)
1248+
)
1249+
else:
1250+
file_path = os.path.normpath(file_path)
1251+
file_path = os.path.abspath(file_path)
1252+
1253+
# Safe mode path containment check for local backend
1254+
if self.safe_mode:
1255+
working_dir_normalized = os.path.normpath(
1256+
os.path.abspath(self.working_dir)
1257+
)
1258+
# Use os.path.commonpath for secure path containment check
1259+
try:
1260+
common = os.path.commonpath(
1261+
[file_path, working_dir_normalized]
1262+
)
1263+
if common != working_dir_normalized:
1264+
return (
1265+
"Error: Cannot write to a file outside of the "
1266+
"working directory in safe mode."
1267+
)
1268+
except ValueError:
1269+
# Paths are on different drives (Windows) or invalid
1270+
return (
1271+
"Error: Cannot write to a file outside of the "
1272+
"working directory in safe mode."
1273+
)
1274+
1275+
log_entry = (
1276+
f"--- Writing content to file at {time.ctime()} ---\n"
1277+
f"> {file_path}\n"
1278+
)
1279+
if self.use_docker_backend:
1280+
temp_host_path = None
1281+
try:
1282+
# Ensure parent directory exists in container
1283+
parent_dir = os.path.dirname(file_path)
1284+
if parent_dir:
1285+
quoted_dir = shlex.quote(parent_dir)
1286+
mkdir_cmd = f'sh -lc "mkdir -p {quoted_dir}"'
1287+
mkdir_exec = self.docker_api_client.exec_create(
1288+
self.container.id, mkdir_cmd
1289+
)
1290+
self.docker_api_client.exec_start(mkdir_exec['Id'])
1291+
1292+
# Write content to a temporary file on the host inside log_dir
1293+
temp_file_name = f"temp_{uuid.uuid4().hex}.txt"
1294+
temp_host_path = os.path.join(self.log_dir, temp_file_name)
1295+
with open(temp_host_path, "w", encoding="utf-8") as f:
1296+
f.write(content)
1297+
# Copy the temporary file into the Docker container
1298+
dest_path_in_container = file_path
1299+
container_dest = (
1300+
f"{self.container.name}:{dest_path_in_container}"
1301+
)
1302+
subprocess.run(
1303+
['docker', 'cp', temp_host_path, container_dest],
1304+
check=True,
1305+
capture_output=True,
1306+
text=True,
1307+
)
1308+
1309+
log_entry += f"\n-------- \n{content}\n--------\n"
1310+
with open(self.blocking_log_file, "a", encoding="utf-8") as f:
1311+
f.write(log_entry + "\n")
1312+
return (
1313+
f"Content successfully written to '{file_path}' "
1314+
f"in Docker container."
1315+
)
1316+
except subprocess.CalledProcessError as e:
1317+
log_entry += f"--- Error ---\n{e.stderr}\n"
1318+
with open(self.blocking_log_file, "a", encoding="utf-8") as f:
1319+
f.write(log_entry + "\n")
1320+
return (
1321+
f"Error writing to file '{file_path}' "
1322+
f"in Docker container: {e.stderr}"
1323+
)
1324+
except Exception as e:
1325+
log_entry += f"--- Error ---\n{e}\n"
1326+
with open(self.blocking_log_file, "a", encoding="utf-8") as f:
1327+
f.write(log_entry + "\n")
1328+
return (
1329+
f"Error writing to file '{file_path}' "
1330+
f"in Docker container: {e}"
1331+
)
1332+
finally:
1333+
# Clean up the temporary file
1334+
if temp_host_path and os.path.exists(temp_host_path):
1335+
try:
1336+
os.remove(temp_host_path)
1337+
except OSError:
1338+
pass
1339+
1340+
else:
1341+
try:
1342+
# Ensure parent directory exists
1343+
parent_dir = os.path.dirname(file_path)
1344+
if parent_dir:
1345+
os.makedirs(parent_dir, exist_ok=True)
1346+
1347+
with open(file_path, "w", encoding="utf-8") as f:
1348+
f.write(content)
1349+
1350+
log_entry += f"\n-------- \n{content}\n--------\n"
1351+
with open(self.blocking_log_file, "a", encoding="utf-8") as f:
1352+
f.write(log_entry + "\n")
1353+
return f"Content successfully written to '{file_path}'."
1354+
except Exception as e:
1355+
log_entry += f"--- Error ---\n{e}\n"
1356+
with open(self.blocking_log_file, "a", encoding="utf-8") as f:
1357+
f.write(log_entry + "\n")
1358+
return f"Error writing to file '{file_path}': {e}"
1359+
12311360
def __enter__(self):
12321361
r"""Context manager entry."""
12331362
return self
@@ -1275,6 +1404,7 @@ def get_tools(self) -> List[FunctionTool]:
12751404
return [
12761405
FunctionTool(self.shell_exec),
12771406
FunctionTool(self.shell_view),
1407+
FunctionTool(self.shell_write_content_to_file),
12781408
FunctionTool(self.shell_write_to_process),
12791409
FunctionTool(self.shell_kill_process),
12801410
FunctionTool(self.shell_ask_user_for_help),

docs/mintlify/reference/camel.toolkits.terminal_toolkit.terminal_toolkit.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,25 @@ This method can handle different scenarios:
275275
str: The output from the shell session after the user's command has
276276
been executed, or help information for general queries.
277277

278+
<a id="camel.toolkits.terminal_toolkit.terminal_toolkit.TerminalToolkit.shell_write_content_to_file"></a>
279+
280+
### shell_write_content_to_file
281+
282+
```python
283+
def shell_write_content_to_file(self, content: str, file_path: str):
284+
```
285+
286+
Writes the specified content to a file at the given path.
287+
288+
**Parameters:**
289+
290+
- **content** (str): The content to write to the file.
291+
- **file_path** (str): The path to the file where the content should be written. Can be absolute or relative to working_dir.
292+
293+
**Returns:**
294+
295+
str: A confirmation message indicating success or an error message.
296+
278297
<a id="camel.toolkits.terminal_toolkit.terminal_toolkit.TerminalToolkit.__enter__"></a>
279298

280299
### __enter__

misc/wechat.jpeg

-6.65 KB
Loading

test/toolkits/test_terminal_toolkit.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,65 @@ def test_shell_exec_multiple_sessions(terminal_toolkit, temp_dir):
9696
# For non-blocking mode, sessions should be created immediately
9797
assert session1 in terminal_toolkit.shell_sessions
9898
assert session2 in terminal_toolkit.shell_sessions
99+
100+
101+
def test_shell_write_content_to_file_basic(temp_dir, request):
102+
"""Test basic file writing functionality."""
103+
toolkit = TerminalToolkit(working_directory=str(temp_dir), safe_mode=False)
104+
request.addfinalizer(toolkit.cleanup)
105+
106+
test_content = "Hello, World!"
107+
test_file = temp_dir / "test_write.txt"
108+
109+
result = toolkit.shell_write_content_to_file(test_content, str(test_file))
110+
assert "successfully" in result.lower()
111+
assert test_file.exists()
112+
assert test_file.read_text() == test_content
113+
114+
115+
def test_shell_write_content_to_file_with_subdirectory(temp_dir, request):
116+
"""Test file writing with automatic parent directory creation."""
117+
toolkit = TerminalToolkit(working_directory=str(temp_dir), safe_mode=False)
118+
request.addfinalizer(toolkit.cleanup)
119+
120+
test_content = "Nested content"
121+
# Create a path with non-existent subdirectory
122+
test_file = temp_dir / "subdir" / "nested" / "test.txt"
123+
124+
result = toolkit.shell_write_content_to_file(test_content, str(test_file))
125+
assert "successfully" in result.lower()
126+
assert test_file.exists()
127+
assert test_file.read_text() == test_content
128+
129+
130+
def test_shell_write_content_to_file_safe_mode_relative_path(
131+
temp_dir, request
132+
):
133+
"""Test safe mode with relative paths resolves correctly."""
134+
toolkit = TerminalToolkit(working_directory=str(temp_dir), safe_mode=True)
135+
request.addfinalizer(toolkit.cleanup)
136+
137+
test_content = "Safe mode content"
138+
# Use a relative path - should be resolved relative to working_dir
139+
result = toolkit.shell_write_content_to_file(test_content, "relative.txt")
140+
assert "successfully" in result.lower()
141+
# File should be created inside working_dir
142+
expected_file = temp_dir / "relative.txt"
143+
assert expected_file.exists()
144+
assert expected_file.read_text() == test_content
145+
146+
147+
def test_shell_write_content_to_file_safe_mode_blocks_path_traversal(
148+
temp_dir, request
149+
):
150+
"""Test that safe mode blocks path traversal attempts."""
151+
toolkit = TerminalToolkit(working_directory=str(temp_dir), safe_mode=True)
152+
request.addfinalizer(toolkit.cleanup)
153+
154+
test_content = "Malicious content"
155+
# Attempt path traversal
156+
result = toolkit.shell_write_content_to_file(
157+
test_content, "../outside_working_dir.txt"
158+
)
159+
assert "error" in result.lower()
160+
assert "outside" in result.lower() or "working directory" in result.lower()

0 commit comments

Comments
 (0)