Skip to content

Commit fbaf2e8

Browse files
committed
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
1 parent 004ee89 commit fbaf2e8

4 files changed

Lines changed: 74 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.11.31] - 2026-03-30
8+
9+
### Changed
10+
11+
- 🗂️ **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).
12+
713
## [0.11.30] - 2026-03-25
814

915
### Changed

open_terminal/env.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,13 @@ def _resolve_file_env(var: str, default: str = "") -> str:
161161
config.get("info", ""),
162162
)
163163

164+
# How long (in seconds) to keep per-session cwd entries in memory.
165+
# Sliding window — refreshed on every access.
166+
SESSION_CWD_TTL: float = float(
167+
os.environ.get(
168+
"OPEN_TERMINAL_SESSION_CWD_TTL",
169+
config.get("session_cwd_ttl", 604_800), # 7 days
170+
)
171+
)
172+
164173

open_terminal/main.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
2525
from pydantic import BaseModel, Field
2626

27-
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
27+
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
2828
from open_terminal.utils.runner import PipeRunner, ProcessRunner, create_runner
2929
from open_terminal.utils.fs import UserFS
3030

@@ -269,6 +269,40 @@ class BackgroundProcess:
269269
_EXPIRY_SECONDS = 300 # auto-clean finished processes after 5 min
270270

271271

272+
# ---------------------------------------------------------------------------
273+
# Per-session working directory tracking
274+
# ---------------------------------------------------------------------------
275+
# Maps session_id → (absolute_cwd_path, last_accessed_timestamp).
276+
# Replaces the old os.chdir() approach which was process-global and unsafe
277+
# with concurrent sessions.
278+
_session_cwds: dict[str, tuple[str, float]] = {}
279+
280+
281+
282+
def _expire_session_cwds():
283+
"""Remove session cwd entries that haven't been accessed within the TTL."""
284+
now = time.time()
285+
expired = [sid for sid, (_, ts) in _session_cwds.items() if now - ts > SESSION_CWD_TTL]
286+
for sid in expired:
287+
del _session_cwds[sid]
288+
289+
290+
def _get_session_cwd(session_id: str | None, fs: "UserFS") -> str:
291+
"""Return the tracked cwd for *session_id*, or ``fs.home`` as default."""
292+
_expire_session_cwds()
293+
if session_id and session_id in _session_cwds:
294+
cwd, _ = _session_cwds[session_id]
295+
_session_cwds[session_id] = (cwd, time.time()) # refresh TTL
296+
return cwd
297+
return fs.home
298+
299+
300+
def _set_session_cwd(session_id: str | None, path: str):
301+
"""Store a session's cwd. No-op if *session_id* is ``None``."""
302+
if session_id:
303+
_session_cwds[session_id] = (path, time.time())
304+
305+
272306
from open_terminal.utils.log import log_process, read_log
273307

274308

@@ -378,26 +412,29 @@ async def get_info():
378412
include_in_schema=False,
379413
dependencies=[Depends(verify_api_key)],
380414
)
381-
async def get_cwd(fs: UserFS = Depends(get_filesystem)):
382-
return {"cwd": fs.home}
415+
async def get_cwd(
416+
http_request: Request,
417+
fs: UserFS = Depends(get_filesystem),
418+
):
419+
session_id = http_request.headers.get("x-session-id")
420+
return {"cwd": _get_session_cwd(session_id, fs)}
383421

384422

385423
@app.post(
386424
"/files/cwd",
387425
include_in_schema=False,
388426
dependencies=[Depends(verify_api_key)],
389427
)
390-
async def set_cwd(request: MkdirRequest, fs: UserFS = Depends(get_filesystem)):
428+
async def set_cwd(
429+
http_request: Request,
430+
request: MkdirRequest,
431+
fs: UserFS = Depends(get_filesystem),
432+
):
433+
session_id = http_request.headers.get("x-session-id")
391434
target = fs.resolve_path(request.path)
392-
if fs.username:
393-
# In multi-user mode, cwd is per-user; don't touch the global server cwd.
394-
return {"cwd": target}
395-
if not await fs.isdir(target):
435+
if not fs.username and not await fs.isdir(target):
396436
raise HTTPException(status_code=404, detail="Directory not found")
397-
try:
398-
os.chdir(target)
399-
except OSError as e:
400-
raise HTTPException(status_code=400, detail=str(e))
437+
_set_session_cwd(session_id, target)
401438
return {"cwd": target}
402439

403440

@@ -1055,7 +1092,8 @@ async def execute(
10551092
),
10561093
):
10571094
fs = get_filesystem(http_request)
1058-
cwd = fs.resolve_path(request.cwd) if request.cwd else (fs.home if fs.username else None)
1095+
session_id = http_request.headers.get("x-session-id")
1096+
cwd = fs.resolve_path(request.cwd) if request.cwd else _get_session_cwd(session_id, fs)
10591097

10601098
subprocess_env = {**os.environ, **request.env} if request.env else None
10611099
runner = await create_runner(
@@ -1437,16 +1475,21 @@ async def create_terminal(request: Request):
14371475
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
14381476

14391477
fs = get_filesystem(request)
1478+
1479+
# Use per-session cwd if available, else fall back to home
1480+
session_id = request.headers.get("x-session-id")
1481+
session_cwd = _get_session_cwd(session_id, fs) if session_id else None
1482+
14401483
if fs.username:
14411484
shell_cmd = [
14421485
"script", "-qc",
14431486
f"sudo -i -u {fs.username}",
14441487
"/dev/null",
14451488
]
1446-
cwd = fs.home
1489+
cwd = session_cwd or fs.home
14471490
else:
14481491
shell_cmd = [os.environ.get("SHELL", "/bin/sh")]
1449-
cwd = os.getcwd()
1492+
cwd = session_cwd or os.getcwd()
14501493

14511494
spawn_env = os.environ.copy()
14521495
spawn_env.setdefault("TERM", TERMINAL_TERM)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "open-terminal"
3-
version = "0.11.30"
3+
version = "0.11.31"
44
description = "A remote terminal API."
55
readme = "README.md"
66
authors = [

0 commit comments

Comments
 (0)