[GH-ISSUE #58] 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) #29

Open
opened 2026-10-18 21:13:34 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @Tchipsy on GitHub (Sep 13, 2026).
Original GitHub issue: https://github.com/open-webui/terminals/issues/58

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

  1. Orchestrator connection, Terminal Contexts → Chat = "Per chat".
  2. Open a saved chat's terminal panel → the terminal stays black; type anything → "connection closed".
  3. 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

  1. Session creation (HTTP POST): Open WebUI's proxy_terminal injects X-User-Id, and the browser sends x-session-id in Per chat mode (chat-scoped cwd tracking). The orchestrator's _proxy_request (routers/proxy.py) strips x-user-id from the forwarded headers but forwards x-session-id unchanged:
    headers = 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 stripped
    
    The container's create_terminal() therefore stores user_id="" and chat_id="<non-empty in Per chat mode>".
  2. Terminal attach (WebSocket): the orchestrator's _ws_proxy() dials the container with
    upstream = await websockets.connect(upstream_url, compression=...)   # no headers
    
    and its first-message auth carries only {"type": "auth", "token": instance.api_key} — no x-session-id handshake header and no chat_id in the payload.
  3. Ownership check on the container (open_terminal/main.py, ws_terminal):
    if (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")
    
    In Per chat mode the stored chat_id is 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 forwarded x-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 open at accept time (before auth/ownership), and the orchestrator logs the upstream close only at debug level — a 4004 close 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 (revision e15903af)
  • Open Terminal: ghcr.io/open-webui/open-terminal:latest (revision 72fb783b)
  • 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/serve 404, 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/serve falls back to the default context 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
Originally created by @Tchipsy on GitHub (Sep 13, 2026). Original GitHub issue: https://github.com/open-webui/terminals/issues/58 ### 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 1. Orchestrator connection, **Terminal Contexts → Chat = "Per chat"**. 2. Open a saved chat's terminal panel → the terminal stays black; type anything → **"connection closed"**. 3. 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 1. **Session creation (HTTP POST):** Open WebUI's `proxy_terminal` injects `X-User-Id`, and the browser sends `x-session-id` in Per chat mode (chat-scoped cwd tracking). The orchestrator's `_proxy_request` (`routers/proxy.py`) **strips `x-user-id`** from the forwarded headers but **forwards `x-session-id` unchanged**: ```python headers = 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 stripped ``` The container's `create_terminal()` therefore stores `user_id=""` and `chat_id="<non-empty in Per chat mode>"`. 2. **Terminal attach (WebSocket):** the orchestrator's `_ws_proxy()` dials the container with ```python upstream = await websockets.connect(upstream_url, compression=...) # no headers ``` and its first-message auth carries only `{"type": "auth", "token": instance.api_key}` — no `x-session-id` handshake header and no `chat_id` in the payload. 3. **Ownership check on the container** (`open_terminal/main.py`, `ws_terminal`): ```python if (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") ``` In **Per chat** mode the stored `chat_id` is 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 forwarded `x-session-id`) and re-assert it on the WS dial: ```python 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 open` at accept time (before auth/ownership), and the orchestrator logs the upstream close only at debug level — a `4004` close 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` (revision `e15903af`) - Open Terminal: `ghcr.io/open-webui/open-terminal:latest` (revision `72fb783b`) - 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/serve` 404, 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/serve` falls back to the `default` context in Per chat mode, breaking HTML preview) — independent endpoint, filed separately. - The reproducer creates short-lived shell sessions; no data is modified. ### Confirmation - [x] I have searched for existing issues and found none describing this problem - [x] I have included a minimal reproducer with actual output - [x] I have included my environment details and logs
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-webui/terminals#29
No description provided.