open-webui / open-webui/terminals
bug: Embedded terminal shows "connection closed" in Per chat context mode — orchestrator WS dial omits the chat-scoped x-session-id ownership header (Shared mode unaffected)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 108
- Forks
- 23
- Avg merge
- 12h 8m
- Merged PRs (30d)
- 1
Description
Description
With an orchestrator deployment (terminals + per-session open-terminal containers, Docker backend), the embedded terminal panel in Open WebUI works in Terminal Contexts "Shared" mode but never connects in "Per chat" mode: the xterm stays completely black, and typing anything surfaces "connection closed".
Everything else works in Per chat mode (model tools, file browser) — only the interactive WebSocket terminal dies, and only in Per chat.
Root cause: the session container's WebSocket ownership check compares the chat_id stored at session-creation time against the WS handshake — and the orchestrator's WS dial does not forward the x-session-id header that the session-creating POST carried. In Per chat mode that header is present (non-empty), so the check always fails with 4004 "Session not found". In Shared mode the POST carries neither ownership header, so the check passes.
Reproduction
- Orchestrator connection, Terminal Contexts → Chat = "Per chat".
- Open a saved chat's terminal panel → the terminal stays black; type anything → "connection closed".
- Switch to "Shared", open the terminal panel → the terminal connects and works.
Expected behavior
The embedded terminal connects in every Terminal Contexts mode. The orchestrator should present the same ownership identity to the session container on the WebSocket dial that was used when the session was created.
Actual behavior
Per chat mode only: the three-leg WebSocket chain establishes (browser → open-webui → terminals → container all log [accepted] / connection open), then the container closes the last leg with 4004 "Session not found" during its post-auth ownership check. The close is relayed back to the browser ("connection closed"). The container sends no PTY output first, hence the black screen.
Root cause analysis
- Session creation (HTTP POST): Open WebUI's
proxy_terminalinjectsX-User-Id, and the browser sendsx-session-idin Per chat mode (chat-scoped cwd tracking). The orchestrator's_proxy_request(routers/proxy.py) stripsx-user-idfrom the forwarded headers but forwardsx-session-idunchanged:
The container'sheaders = dict(request.headers) headers["authorization"] = f"Bearer {instance.api_key}" for h in ("host", "transfer-encoding", "connection", "x-user-id", CONTEXT_HEADER.lower()): headers.pop(h, None) # x-session-id is NOT strippedcreate_terminal()therefore storesuser_id=""andchat_id="<non-empty in Per chat mode>". - Terminal attach (WebSocket): the orchestrator's
_ws_proxy()dials the container with
and its first-message auth carries onlyupstream = await websockets.connect(upstream_url, compression=...) # no headers{"type": "auth", "token": instance.api_key}— nox-session-idhandshake header and nochat_idin the payload. - Ownership check on the container (
open_terminal/main.py,ws_terminal):
In Per chat mode the storedif (session["user_id"] != ws.headers.get("x-user-id", "") # "" vs "" → passes or session["chat_id"] != ws.headers.get("x-session-id", payload.get("chat_id", ""))): # stored vs "" → FAILS await ws.close(code=4004, reason="Session not found")chat_idis non-empty → always fails. In Shared mode the POST carried neither header → both stored values are""→ passes. This explains the mode asymmetry exactly.
The identity the session was created with is never re-asserted on the WebSocket dial.
Suggested fix direction
- terminals: persist the ownership identity at POST time (the orchestrator has both values: the effective user from
verify_user_id, and the forwardedx-session-id) and re-assert it on the WS dial:websockets.connect(upstream_url, compression=..., additional_headers={"x-user-id": <effective user>, "x-session-id": <as forwarded on POST>}) - Alternative (open-terminal side): when the WS client authenticates with the container's own API key (i.e. the trusted orchestrator), skip the header-ownership check — it is meaningful for direct same-container clients, not for the orchestrator.
- Observability: the container logs
connection openat accept time (before auth/ownership), and the orchestrator logs the upstream close only at debug level — a4004close is invisible at INFO on both sides. Logging upstream WS close code/reason at INFO would have made this diagnosable in minutes.
Environment
- Terminals:
ghcr.io/open-webui/terminals:main(revisione15903af) - Open Terminal:
ghcr.io/open-webui/open-terminal:latest(revision72fb783b) - Open WebUI:
v0.11.3(integration peer) - Orchestrator backend:
TERMINALS_BACKEND=docker, Docker 29.8.0, Docker Compose v5.5.1 - OS: Linux (Docker host); client: Chrome
Logs
All legs establish; the close is invisible at INFO level:
POST /p/<policy-id>/api/terminals 200
WebSocket /p/<policy-id>/api/terminals/8067ea3b?user_id=<user-id> [accepted]
connection open
--- session container ---
POST /api/terminals 200 OK
WebSocket /api/terminals/8067ea3b [accepted]
connection open
The actual close (4004 "Session not found", proven by the reproducer above) never reaches the visible logs.
Notes
- I searched open issues before filing; open-terminal#154 (
/files/serve404, Windows path corruption) and terminals#36 (prior fix for the same class: a proxy path omitting a context header) are the closest, neither matches. - A separate Open WebUI issue covers the HTTP file-preview path (
files/servefalls back to thedefaultcontext in Per chat mode, breaking HTML preview) — independent endpoint, filed separately. - The reproducer creates short-lived shell sessions; no data is modified.
Confirmation
- I have searched for existing issues and found none describing this problem
- I have included a minimal reproducer with actual output
- I have included my environment details and logs
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with _proxy_request and _ws_proxy in routers/proxy.py, then inspect create_terminal and ws_terminal in open_terminal/main.py, tracing the ownership values from POST through the WebSocket dial. Confirm the fix with a Per chat reproduction and verify that Shared mode still connects without weakening the ownership check.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, python
- Domain
- api, backend, devops
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100