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)

Open
#58 0 comments 0 reactions 0 assignees View on GitHub

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
  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

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.