Skip to content
This repository was archived by the owner on Feb 5, 2026. It is now read-only.

Commit 5de1500

Browse files
committed
fix UTs
1 parent fffdf86 commit 5de1500

26 files changed

+1190
-45
lines changed

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
[pytest]
2+
pythonpath = .
3+
asyncio_mode = auto
24
markers =
35
app_config: mark a test that requires the app config
6+
integration: mark a test as an integration test (may start real services)
7+
asyncio: mark a test as async
48
testpaths =
59
tests

taskweaver/ces/AGENTS.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ Connect to pre-started server. API key required.
171171
| POST | `/api/v1/sessions/{id}/execute` | Execute code |
172172
| GET | `/api/v1/sessions/{id}/stream/{exec_id}` | SSE stream |
173173
| POST | `/api/v1/sessions/{id}/variables` | Update variables |
174+
| POST | `/api/v1/sessions/{id}/files` | Upload file to session cwd |
174175
| GET | `/api/v1/sessions/{id}/artifacts/{file}` | Download artifact |
175176

176177
## Usage
@@ -236,6 +237,140 @@ with ExecutionClient(
236237
4. **Streaming**: SSE events for stdout/stderr during execution
237238
5. **Session Cleanup**: `DELETE /sessions/{id}` → Environment.stop_session()
238239

240+
## File Upload Flow
241+
242+
File upload enables the `/load` CLI command to transfer files from the client machine to the execution server's working directory. This is essential when the execution server runs in a container or on a remote machine where the client's local filesystem is not accessible.
243+
244+
### Architecture
245+
246+
```
247+
┌─────────────────────────────────────────────────────────────────────────────┐
248+
│ TASKWEAVER CLIENT │
249+
│ │
250+
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────────┐ │
251+
│ │ Session │───▶│ _upload_file│───▶│ ExecutionServiceClient │ │
252+
│ │ /load cmd │ │ (lazy) │ │ .upload_file() │ │
253+
│ └─────────────┘ └─────────────┘ └───────────────┬───────────────┘ │
254+
│ │ │
255+
│ ▼ │
256+
│ ┌───────────────────────────────┐ │
257+
│ │ ExecutionClient.upload_file() │ │
258+
│ │ - Read file content │ │
259+
│ │ - Base64 encode │ │
260+
│ │ - HTTP POST │ │
261+
│ └───────────────┬───────────────┘ │
262+
└────────────────────────────────────────────────────────┼────────────────────┘
263+
264+
│ POST /api/v1/sessions/{id}/files
265+
│ {filename, content (base64), encoding}
266+
267+
┌─────────────────────────────────────────────────────────────────────────────┐
268+
│ EXECUTION SERVER │
269+
│ │
270+
│ ┌───────────────────────────────────────────────────────────────────────┐ │
271+
│ │ routes.upload_file() │ │
272+
│ │ - Validate session exists │ │
273+
│ │ - Base64 decode content │ │
274+
│ │ - Call session_manager.upload_file() │ │
275+
│ └───────────────────────────────────────────────────────────────────────┘ │
276+
│ │ │
277+
│ ▼ │
278+
│ ┌───────────────────────────────────────────────────────────────────────┐ │
279+
│ │ ServerSessionManager.upload_file() │ │
280+
│ │ - Sanitize filename (prevent path traversal) │ │
281+
│ │ - Write to {session.cwd}/{filename} │ │
282+
│ │ - Return full path │ │
283+
│ └───────────────────────────────────────────────────────────────────────┘ │
284+
│ │ │
285+
│ ▼ │
286+
│ ┌───────────────────────────────────────────────────────────────────────┐ │
287+
│ │ Session Working Directory │ │
288+
│ │ /workspace/{session_id}/cwd/ │ │
289+
│ │ └── uploaded_file.csv │ │
290+
│ └───────────────────────────────────────────────────────────────────────┘ │
291+
└─────────────────────────────────────────────────────────────────────────────┘
292+
```
293+
294+
### Request/Response Models
295+
296+
```python
297+
# Request (server/models.py)
298+
class UploadFileRequest(BaseModel):
299+
filename: str # Target filename (basename extracted, path traversal prevented)
300+
content: str # File content (base64 encoded for binary)
301+
encoding: Literal["base64", "text"] = "base64"
302+
303+
# Response (server/models.py)
304+
class UploadFileResponse(BaseModel):
305+
filename: str # Uploaded filename
306+
status: Literal["uploaded"] = "uploaded"
307+
path: str # Full path where file was saved on server
308+
```
309+
310+
### Client Usage
311+
312+
```python
313+
from taskweaver.ces.client import ExecutionClient
314+
315+
with ExecutionClient(session_id="my-session", server_url="http://localhost:8000") as client:
316+
client.start()
317+
318+
# Upload a file
319+
with open("/local/path/data.csv", "rb") as f:
320+
content = f.read()
321+
saved_path = client.upload_file("data.csv", content)
322+
323+
# Now the file is available in the session's cwd
324+
result = client.execute_code("exec-1", "import pandas as pd; df = pd.read_csv('data.csv')")
325+
```
326+
327+
### Session Integration
328+
329+
The `Session` class uses a lazily-initialized upload client:
330+
331+
```python
332+
# In taskweaver/session/session.py
333+
class Session:
334+
def _get_upload_client(self):
335+
"""Lazy client creation - only created when first upload occurs."""
336+
if not hasattr(self, "_upload_client"):
337+
self._upload_client = self.exec_mgr.get_session_client(
338+
self.session_id,
339+
session_dir=self.workspace,
340+
cwd=self.execution_cwd,
341+
)
342+
self._upload_client_started = False
343+
344+
if not self._upload_client_started:
345+
self._upload_client.start()
346+
self._upload_client_started = True
347+
348+
return self._upload_client
349+
350+
def _upload_file(self, name: str, path: str = None, content: bytes = None) -> str:
351+
"""Upload file to execution server."""
352+
target_name = os.path.basename(name)
353+
354+
if path is not None:
355+
with open(path, "rb") as f:
356+
file_content = f.read()
357+
elif content is not None:
358+
file_content = content
359+
else:
360+
raise ValueError("path or content must be provided")
361+
362+
client = self._get_upload_client()
363+
client.upload_file(target_name, file_content)
364+
return target_name
365+
```
366+
367+
### Security Considerations
368+
369+
1. **Path Traversal Prevention**: Server sanitizes filename using `os.path.basename()` to prevent `../../etc/passwd` attacks
370+
2. **Session Isolation**: Files are written only to the session's own cwd directory
371+
3. **API Key Authentication**: Upload endpoint respects the same API key auth as other endpoints
372+
4. **Size Limits**: Large files should be chunked or streamed (not yet implemented)
373+
239374
## Custom Kernel Magics (kernel/ext.py)
240375

241376
```python
@@ -271,6 +406,11 @@ Unit tests in `tests/unit_tests/ces/`:
271406
| `test_server_launcher.py` | ServerLauncher (mocked subprocess/docker) |
272407
| `test_execution_service.py` | ExecutionServiceProvider |
273408

409+
**TODO**: Add tests for file upload functionality:
410+
- `ExecutionClient.upload_file()` - mock HTTP POST, verify base64 encoding
411+
- `ServerSessionManager.upload_file()` - verify file written, path traversal blocked
412+
- `routes.upload_file()` - integration test with mocked session manager
413+
274414
Run tests:
275415
```bash
276416
pytest tests/unit_tests/ces/ -v

taskweaver/ces/client/execution_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,37 @@ def download_artifact(self, filename: str) -> bytes:
431431
)
432432
return response.content
433433

434+
def upload_file(
435+
self,
436+
filename: str,
437+
content: bytes,
438+
) -> str:
439+
"""Upload a file to the session's working directory.
440+
441+
Args:
442+
filename: Target filename.
443+
content: File content as bytes.
444+
445+
Returns:
446+
Path where the file was saved on the server.
447+
448+
Raises:
449+
ExecutionClientError: If upload fails.
450+
"""
451+
import base64
452+
453+
response = self._client.post(
454+
f"/api/v1/sessions/{self.session_id}/files",
455+
json={
456+
"filename": filename,
457+
"content": base64.b64encode(content).decode("ascii"),
458+
"encoding": "base64",
459+
},
460+
)
461+
result = self._handle_response(response)
462+
logger.info(f"Uploaded file {filename} to session {self.session_id}")
463+
return result.get("path", "")
464+
434465
def close(self) -> None:
435466
"""Close the HTTP client and release resources."""
436467
self._client.close()

taskweaver/ces/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ def execute_code(
111111
) -> ExecutionResult:
112112
...
113113

114+
@abstractmethod
115+
def upload_file(
116+
self,
117+
filename: str,
118+
content: bytes,
119+
) -> str:
120+
...
121+
114122

115123
KernelModeType = Literal["local", "container"]
116124

taskweaver/ces/manager/defer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ def execute_code(
9191
) -> ExecutionResult:
9292
return self._get_proxy_client().execute_code(exec_id, code, on_output=on_output)
9393

94+
def upload_file(
95+
self,
96+
filename: str,
97+
content: bytes,
98+
) -> str:
99+
return self._get_proxy_client().upload_file(filename, content)
100+
94101
def _get_proxy_client(self) -> Client:
95102
return self._init_deferred_var()()
96103

taskweaver/ces/manager/execution_service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ def execute_code(
107107
raise RuntimeError("Client not started")
108108
return self._client.execute_code(exec_id, code, on_output=on_output)
109109

110+
def upload_file(
111+
self,
112+
filename: str,
113+
content: bytes,
114+
) -> str:
115+
"""Upload a file to the session's working directory."""
116+
if self._client is None:
117+
raise RuntimeError("Client not started")
118+
return self._client.upload_file(filename, content)
119+
110120

111121
class ExecutionServiceProvider(Manager):
112122
"""Manager implementation that uses the HTTP execution server.

taskweaver/ces/manager/sub_proc.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ def execute_code(
6060
on_output=on_output,
6161
)
6262

63+
def upload_file(
64+
self,
65+
filename: str,
66+
content: bytes,
67+
) -> str:
68+
"""Upload a file to the session's working directory.
69+
70+
For subprocess mode, this writes directly to the local filesystem
71+
since the subprocess shares the same filesystem as the caller.
72+
"""
73+
# Sanitize filename to prevent path traversal
74+
safe_filename = os.path.basename(filename)
75+
file_path = os.path.join(self.cwd, safe_filename)
76+
77+
with open(file_path, "wb") as f:
78+
f.write(content)
79+
80+
return file_path
81+
6382

6483
class SubProcessManager(Manager):
6584
def __init__(

taskweaver/ces/runtime/context.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,22 @@ def get_session_var(
157157

158158
def extract_visible_variables(self, local_ns: Dict[str, Any]) -> List[Tuple[str, str]]:
159159
ignore_names = {
160+
# IPython/Jupyter internals
160161
"__builtins__",
161162
"In",
162163
"Out",
163164
"get_ipython",
164165
"exit",
165166
"quit",
167+
# Common library aliases
166168
"pd",
167169
"np",
168170
"plt",
171+
# REPL/kernel internals
172+
"original_ps1",
173+
"is_wsl",
174+
"PS1",
175+
"REPLHooks",
169176
}
170177

171178
visible: List[Tuple[str, str]] = []
@@ -194,7 +201,12 @@ def extract_visible_variables(self, local_ns: Dict[str, Any]) -> List[Tuple[str,
194201
continue
195202

196203
try:
197-
rendered = repr(value)
204+
# Use str() for strings to avoid extra quotes from repr()
205+
# e.g., repr("hello") returns "'hello'" but str("hello") returns "hello"
206+
if isinstance(value, str):
207+
rendered = value
208+
else:
209+
rendered = repr(value)
198210
except Exception:
199211
rendered = "<unrepresentable>"
200212

taskweaver/ces/server/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ class UpdateVariablesRequest(BaseModel):
3939
variables: Dict[str, str] = Field(..., description="Session variables to update")
4040

4141

42+
class UploadFileRequest(BaseModel):
43+
"""Request to upload a file to a session's working directory."""
44+
45+
filename: str = Field(..., description="Target filename in the session's cwd")
46+
content: str = Field(..., description="File content (base64 encoded for binary, plain for text)")
47+
encoding: Literal["base64", "text"] = Field("base64", description="Content encoding")
48+
49+
4250
# =============================================================================
4351
# Response Models
4452
# =============================================================================
@@ -128,6 +136,14 @@ class UpdateVariablesResponse(BaseModel):
128136
variables: Dict[str, str] = Field(..., description="Updated variables")
129137

130138

139+
class UploadFileResponse(BaseModel):
140+
"""Response after uploading a file."""
141+
142+
filename: str = Field(..., description="Uploaded filename")
143+
status: Literal["uploaded"] = "uploaded"
144+
path: str = Field(..., description="Path where file was saved")
145+
146+
131147
class ErrorResponse(BaseModel):
132148
"""Standard error response."""
133149

taskweaver/ces/server/routes.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
StopSessionResponse,
2727
UpdateVariablesRequest,
2828
UpdateVariablesResponse,
29+
UploadFileRequest,
30+
UploadFileResponse,
2931
execution_result_to_response,
3032
)
3133
from taskweaver.ces.server.session_manager import ServerSessionManager
@@ -439,6 +441,39 @@ async def update_variables(
439441
raise HTTPException(status_code=500, detail=str(e))
440442

441443

444+
@router.post(
445+
"/sessions/{session_id}/files",
446+
response_model=UploadFileResponse,
447+
dependencies=[Depends(verify_api_key)],
448+
)
449+
async def upload_file(
450+
session_id: str,
451+
request: UploadFileRequest,
452+
session_manager: ServerSessionManager = Depends(get_session_manager),
453+
) -> UploadFileResponse:
454+
"""Upload a file to a session's working directory."""
455+
if not session_manager.session_exists(session_id):
456+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
457+
458+
try:
459+
import base64
460+
461+
if request.encoding == "base64":
462+
content = base64.b64decode(request.content)
463+
else:
464+
content = request.content.encode("utf-8")
465+
466+
file_path = session_manager.upload_file(session_id, request.filename, content)
467+
return UploadFileResponse(
468+
filename=request.filename,
469+
status="uploaded",
470+
path=file_path,
471+
)
472+
except Exception as e:
473+
logger.error(f"Failed to upload file {request.filename} to session {session_id}: {e}")
474+
raise HTTPException(status_code=500, detail=str(e))
475+
476+
442477
# =============================================================================
443478
# Artifacts
444479
# =============================================================================

0 commit comments

Comments
 (0)