mirror of
https://github.com/open-webui/open-terminal.git
synced 2026-11-04 08:10:53 +00:00
feat: per-session working directory tracking via X-Session-Id header
Replace the process-global os.chdir() with an in-memory dictionary keyed by session ID (passed via X-Session-Id header). Multiple concurrent chat sessions now maintain independent working directories. - GET/POST /files/cwd read X-Session-Id to resolve per-session cwd - POST /execute falls back to session cwd when no cwd param provided - POST /api/terminals spawns PTY in the session's cwd - 7-day sliding TTL for session entries - Fully backward compatible: no header = fs.home default
This commit is contained in:
1 parent
004ee891e6
commit
fbaf2e8e69
4 files changed
+74
-16
No files matched your search
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.11.31] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗂️ **Per-session working directory** — replaced the process-global `os.chdir()` with an in-memory, session-aware dictionary keyed by `X-Session-Id` header. Multiple concurrent chat sessions now maintain independent working directories. `GET/POST /files/cwd`, `POST /execute`, and `POST /api/terminals` all read the header to resolve the correct cwd. Sessions without a header fall back to `fs.home`. Entries expire after 7 days of inactivity (sliding TTL), configurable via `OPEN_TERMINAL_SESSION_CWD_TTL` (or `session_cwd_ttl` in config.toml).
|
||||
|
||||
## [0.11.30] - 2026-03-25
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -161,4 +161,13 @@ OPEN_TERMINAL_INFO = os.environ.get(
|
||||
config.get("info", ""),
|
||||
)
|
||||
|
||||
# How long (in seconds) to keep per-session cwd entries in memory.
|
||||
# Sliding window — refreshed on every access.
|
||||
SESSION_CWD_TTL: float = float(
|
||||
os.environ.get(
|
||||
"OPEN_TERMINAL_SESSION_CWD_TTL",
|
||||
config.get("session_cwd_ttl", 604_800), # 7 days
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+58
-15
@@ -24,7 +24,7 @@ from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from open_terminal.env import API_KEY, BINARY_FILE_MIME_PREFIXES, CORS_ALLOWED_ORIGINS, ENABLE_NOTEBOOKS, ENABLE_SYSTEM_PROMPT, ENABLE_TERMINAL, EXECUTE_DESCRIPTION, EXECUTE_TIMEOUT, LOG_DIR, MAX_TERMINAL_SESSIONS, MULTI_USER, OPEN_TERMINAL_INFO, PROCESS_LOG_RETENTION, SYSTEM_PROMPT, TERMINAL_TERM
|
||||
from open_terminal.env import API_KEY, BINARY_FILE_MIME_PREFIXES, CORS_ALLOWED_ORIGINS, ENABLE_NOTEBOOKS, ENABLE_SYSTEM_PROMPT, ENABLE_TERMINAL, EXECUTE_DESCRIPTION, EXECUTE_TIMEOUT, LOG_DIR, MAX_TERMINAL_SESSIONS, MULTI_USER, OPEN_TERMINAL_INFO, PROCESS_LOG_RETENTION, SESSION_CWD_TTL, SYSTEM_PROMPT, TERMINAL_TERM
|
||||
from open_terminal.utils.runner import PipeRunner, ProcessRunner, create_runner
|
||||
from open_terminal.utils.fs import UserFS
|
||||
|
||||
@@ -269,6 +269,40 @@ _processes: dict[str, BackgroundProcess] = {}
|
||||
_EXPIRY_SECONDS = 300 # auto-clean finished processes after 5 min
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-session working directory tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps session_id → (absolute_cwd_path, last_accessed_timestamp).
|
||||
# Replaces the old os.chdir() approach which was process-global and unsafe
|
||||
# with concurrent sessions.
|
||||
_session_cwds: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
|
||||
def _expire_session_cwds():
|
||||
"""Remove session cwd entries that haven't been accessed within the TTL."""
|
||||
now = time.time()
|
||||
expired = [sid for sid, (_, ts) in _session_cwds.items() if now - ts > SESSION_CWD_TTL]
|
||||
for sid in expired:
|
||||
del _session_cwds[sid]
|
||||
|
||||
|
||||
def _get_session_cwd(session_id: str | None, fs: "UserFS") -> str:
|
||||
"""Return the tracked cwd for *session_id*, or ``fs.home`` as default."""
|
||||
_expire_session_cwds()
|
||||
if session_id and session_id in _session_cwds:
|
||||
cwd, _ = _session_cwds[session_id]
|
||||
_session_cwds[session_id] = (cwd, time.time()) # refresh TTL
|
||||
return cwd
|
||||
return fs.home
|
||||
|
||||
|
||||
def _set_session_cwd(session_id: str | None, path: str):
|
||||
"""Store a session's cwd. No-op if *session_id* is ``None``."""
|
||||
if session_id:
|
||||
_session_cwds[session_id] = (path, time.time())
|
||||
|
||||
|
||||
from open_terminal.utils.log import log_process, read_log
|
||||
|
||||
|
||||
@@ -378,8 +412,12 @@ if OPEN_TERMINAL_INFO:
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
)
|
||||
async def get_cwd(fs: UserFS = Depends(get_filesystem)):
|
||||
return {"cwd": fs.home}
|
||||
async def get_cwd(
|
||||
http_request: Request,
|
||||
fs: UserFS = Depends(get_filesystem),
|
||||
):
|
||||
session_id = http_request.headers.get("x-session-id")
|
||||
return {"cwd": _get_session_cwd(session_id, fs)}
|
||||
|
||||
|
||||
@app.post(
|
||||
@@ -387,17 +425,16 @@ async def get_cwd(fs: UserFS = Depends(get_filesystem)):
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(verify_api_key)],
|
||||
)
|
||||
async def set_cwd(request: MkdirRequest, fs: UserFS = Depends(get_filesystem)):
|
||||
async def set_cwd(
|
||||
http_request: Request,
|
||||
request: MkdirRequest,
|
||||
fs: UserFS = Depends(get_filesystem),
|
||||
):
|
||||
session_id = http_request.headers.get("x-session-id")
|
||||
target = fs.resolve_path(request.path)
|
||||
if fs.username:
|
||||
# In multi-user mode, cwd is per-user; don't touch the global server cwd.
|
||||
return {"cwd": target}
|
||||
if not await fs.isdir(target):
|
||||
if not fs.username and not await fs.isdir(target):
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
try:
|
||||
os.chdir(target)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_set_session_cwd(session_id, target)
|
||||
return {"cwd": target}
|
||||
|
||||
|
||||
@@ -1055,7 +1092,8 @@ async def execute(
|
||||
),
|
||||
):
|
||||
fs = get_filesystem(http_request)
|
||||
cwd = fs.resolve_path(request.cwd) if request.cwd else (fs.home if fs.username else None)
|
||||
session_id = http_request.headers.get("x-session-id")
|
||||
cwd = fs.resolve_path(request.cwd) if request.cwd else _get_session_cwd(session_id, fs)
|
||||
|
||||
subprocess_env = {**os.environ, **request.env} if request.env else None
|
||||
runner = await create_runner(
|
||||
@@ -1437,16 +1475,21 @@ if ENABLE_TERMINAL:
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
|
||||
|
||||
fs = get_filesystem(request)
|
||||
|
||||
# Use per-session cwd if available, else fall back to home
|
||||
session_id = request.headers.get("x-session-id")
|
||||
session_cwd = _get_session_cwd(session_id, fs) if session_id else None
|
||||
|
||||
if fs.username:
|
||||
shell_cmd = [
|
||||
"script", "-qc",
|
||||
f"sudo -i -u {fs.username}",
|
||||
"/dev/null",
|
||||
]
|
||||
cwd = fs.home
|
||||
cwd = session_cwd or fs.home
|
||||
else:
|
||||
shell_cmd = [os.environ.get("SHELL", "/bin/sh")]
|
||||
cwd = os.getcwd()
|
||||
cwd = session_cwd or os.getcwd()
|
||||
|
||||
spawn_env = os.environ.copy()
|
||||
spawn_env.setdefault("TERM", TERMINAL_TERM)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "open-terminal"
|
||||
version = "0.11.30"
|
||||
version = "0.11.31"
|
||||
description = "A remote terminal API."
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
|
||||
Reference in new issue
Block a user