`AttachmentService.save_upload_file` joined the client-supplied multipart
filename onto a fresh temp directory without sanitization. A filename with
`../` segments escaped the temp dir, so the upload wrote attacker-controlled
bytes to an arbitrary host path and the `finally` cleanup `unlink` then
deleted that same traversed path — arbitrary file write and delete behind an
endpoint that only requires an unauthenticated session id.
Reduce the filename to its basename (normalising POSIX and Windows
separators, falling back to `upload.bin` for empty/`.`/`..`) before building
the temp path, so writes stay confined to the temp directory.
Adds regression tests: a parametrized check of the basename normalisation and
an end-to-end test proving a traversal filename can no longer overwrite or
delete a victim file outside the temp/WareHouse area.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Decouple WebSocket connection from session lifecycle: workflows continue running after disconnect
- Message buffering with ring buffer (max 1000) for chat history replay on reconnect
- Session garbage collection: 24-hour TTL for terminal sessions via background asyncio task
- Multi-tab support: last tab wins, old WebSocket closed on new connection for same session
- Cancel now sends explicit WebSocket message instead of relying on disconnect detection
- Replace hardcoded API keys and BASE_URL with ${API_KEY}/${BASE_URL} placeholders in yaml configs
Using atexit to clean up temporary zip files is unreliable because
atexit handlers only run when the process exits, not after each
download. This means temp files accumulate on disk, one per download,
until the server restarts.
Replace with Starlette's BackgroundTask which runs cleanup after
the response is fully sent, ensuring temp files are deleted promptly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WebSocketManager.send_message_sync is called from background worker threads
(via asyncio.get_event_loop().run_in_executor) during workflow execution — by
WebSocketLogger, ArtifactDispatcher, and WebPromptChannel.
Previous implementation:
try:
loop = asyncio.get_running_loop()
if loop.is_running():
asyncio.create_task(...) # path only reachable from main thread
else:
asyncio.run(...) # creates a NEW event loop
except RuntimeError:
asyncio.run(...) # also creates a new event loop
The problem: WebSocket objects are bound to the *main* uvicorn event loop.
asyncio.run() spins up a separate event loop and calls websocket.send_text()
there, which in Python 3.12 raises:
RuntimeError: Task got Future attached to a different loop
...causing all log/artifact/prompt messages emitted from workflow threads to be
silently dropped or to crash the worker thread.
Fix:
- Store the event loop that created the first WebSocket connection as
self._owner_loop (captured in connect(), which always runs on the main loop).
- send_message_sync schedules the coroutine on that loop via
asyncio.run_coroutine_threadsafe(), then waits with a 10 s timeout.
- Calling from the main thread still works (run_coroutine_threadsafe is safe
when called from any thread, including the loop thread itself).
Added 7 tests covering:
- send from main thread
- send from worker thread (verifies send_text runs on the owner loop thread)
- 8 concurrent workers with no lost messages
- send after disconnect does not crash
- send before connect (no owner loop) does not crash
- owner loop captured on first connect
- owner loop stable across multiple connects