From 86c39fd110beb3c972d71a4cc64d8dce11b3d5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 21:04:18 +0300 Subject: [PATCH 1/2] fix(security): route .env writes through host agent, restore :ro mount Commit c7ffea39 (settings environment editor) changed the dashboard-api .env bind mount from :ro to writable so the new PUT /api/settings/env endpoint could call _write_text_atomic() from inside the container. This introduces a container-escape risk: any RCE in the dashboard-api container (dependency CVE, SSRF chain, malicious extension manifest) can now overwrite .env at the filesystem level, bypassing the API key check. An attacker can plant a known DREAM_AGENT_KEY and reach the host agent for full container lifecycle control. Restore the mount to :ro and route env writes through a new host-agent endpoint POST /v1/env/update, mirroring the pattern _do_model_activate already uses for model-switch .env updates. The dashboard-api keeps its validation (_prepare_env_save) for UX and passes the prepared raw_text to the host agent via _call_agent_env_update. The host agent re-validates every key against .env.schema.json (defense in depth), rejects values with embedded control characters, acquires _model_activate_lock non-blocking to avoid racing model switches, backs up the previous .env under DATA_DIR/config-backups/, and writes atomically via tempfile + os.replace. Includes 9 unit tests covering happy path, 413 oversize body, 400 unknown key / malformed line / control-char value, 409 lock contention, and 500 missing schema. Deletes three helpers (_write_text_atomic, _resolve_env_backup_root, _display_backup_path) that are no longer referenced after the refactor. Known residual: an attacker with an already-valid DREAM_AGENT_KEY can still set any schema-allowed key. This is the intended threat model for the endpoint; the raw_text API is a tradeoff vs. structured key-value JSON and is worth revisiting in future hardening. Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/bin/dream-host-agent.py | 110 ++++++++++++ dream-server/docker-compose.base.yml | 2 +- .../extensions/services/dashboard-api/main.py | 97 ++++------ .../dashboard-api/tests/test_host_agent.py | 166 ++++++++++++++++++ .../dashboard-api/tests/test_settings_env.py | 12 ++ 5 files changed, 322 insertions(+), 65 deletions(-) diff --git a/dream-server/bin/dream-host-agent.py b/dream-server/bin/dream-host-agent.py index cab211504..5d9f0b467 100755 --- a/dream-server/bin/dream-host-agent.py +++ b/dream-server/bin/dream-host-agent.py @@ -598,6 +598,8 @@ def do_POST(self): self._handle_model_delete() elif self.path == "/v1/compose/invalidate-cache": self._handle_invalidate_compose_cache() + elif self.path == "/v1/env/update": + self._handle_env_update() else: json_response(self, 404, {"error": "Not found"}) @@ -609,6 +611,114 @@ def _handle_invalidate_compose_cache(self): logger.info("compose-flags cache invalidated") json_response(self, 200, {"status": "ok"}) + def _handle_env_update(self): + """Write a validated .env file. Dashboard-api delegates here because the + container mount is :ro — only the host agent may write secrets to disk. + + Bypasses read_json_body() because the default 16 KB body limit truncates + real .env files (.env.example alone is ~11 KB).""" + if not check_auth(self): + return + + client_ip = self.client_address[0] if hasattr(self, "client_address") else "?" + MAX_ENV_BODY = 65536 # env files routinely exceed the default 16 KB cap + + try: + length = int(self.headers.get("Content-Length", "0")) + except (TypeError, ValueError): + logger.warning("env_update rejected: invalid Content-Length from %s", client_ip) + json_response(self, 400, {"error": "Invalid Content-Length"}) + return + if length <= 0: + logger.warning("env_update rejected: empty body from %s", client_ip) + json_response(self, 400, {"error": "Empty body"}) + return + if length > MAX_ENV_BODY: + logger.warning("env_update rejected: body too large (%d bytes) from %s", length, client_ip) + json_response(self, 413, {"error": f"Body too large: {length} > {MAX_ENV_BODY}"}) + return + try: + raw = self.rfile.read(length) + body = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc: + logger.warning("env_update rejected: invalid JSON from %s: %s", client_ip, exc) + json_response(self, 400, {"error": f"Invalid JSON: {exc}"}) + return + + raw_text = body.get("raw_text") + if not isinstance(raw_text, str) or not raw_text.strip(): + logger.warning("env_update rejected: raw_text missing/empty from %s", client_ip) + json_response(self, 400, {"error": "raw_text required"}) + return + backup = body.get("backup", True) + + schema_path = INSTALL_DIR / ".env.schema.json" + if not schema_path.exists(): + logger.warning("env_update rejected: schema missing at %s (request from %s)", schema_path, client_ip) + json_response(self, 500, {"error": f".env.schema.json not found at {schema_path}"}) + return + try: + with open(schema_path, encoding="utf-8") as f: + schema = json.load(f) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("env_update rejected: failed to read schema (request from %s): %s", client_ip, exc) + json_response(self, 500, {"error": f"Failed to read .env.schema.json: {exc}"}) + return + allowed_keys = set(schema.get("properties", {}).keys()) + + for line in raw_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "=" not in stripped: + logger.warning("env_update rejected: malformed line %r from %s", stripped[:80], client_ip) + json_response(self, 400, {"error": f"Malformed line: {stripped[:80]}"}) + return + key, _, value = stripped.partition("=") + key = key.strip() + if key not in allowed_keys: + logger.warning("env_update rejected: unknown key %r from %s", key, client_ip) + json_response(self, 400, {"error": f"Unknown key: {key}"}) + return + # Defense in depth: reject values containing control chars (null bytes, + # escape sequences, etc.). splitlines() already consumed \n/\r/\u2028/\u2029; + # this catches the residual edge cases flagged by security review. + if any(ord(c) < 32 and c != "\t" for c in value): + logger.warning("env_update rejected: control char in value for key %r from %s", key, client_ip) + json_response(self, 400, {"error": f"Value contains control characters for key: {key}"}) + return + + # Coordinate with model activation, which also writes .env under this lock. + if not _model_activate_lock.acquire(blocking=False): + logger.warning("env_update rejected: lock contention from %s", client_ip) + json_response(self, 409, {"error": "Model activation or another env update in progress; try again shortly"}) + return + + env_path = INSTALL_DIR / ".env" + backup_relative_path = None + try: + if backup and env_path.exists(): + backup_dir = DATA_DIR / "config-backups" + backup_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + backup_path = backup_dir / f".env.backup.{timestamp}" + shutil.copy2(env_path, backup_path) + backup_relative_path = f"data/{backup_path.relative_to(DATA_DIR).as_posix()}" + + payload_text = raw_text if raw_text.endswith("\n") else raw_text + "\n" + tmp_path = env_path.with_name(".env.tmp") + tmp_path.write_text(payload_text, encoding="utf-8") + os.replace(str(tmp_path), str(env_path)) + except OSError as exc: + logger.warning("env_update OSError from %s: %s", client_ip, exc) + json_response(self, 500, {"error": str(exc)}) + return + finally: + _model_activate_lock.release() + + logger.info(".env updated via host agent from %s (backup=%s)", client_ip, backup_relative_path or "none") + json_response(self, 200, {"status": "ok", "backup_path": backup_relative_path}) + def _handle_core_recreate(self): if not check_auth(self): return diff --git a/dream-server/docker-compose.base.yml b/dream-server/docker-compose.base.yml index c1bbae224..a8fb58241 100644 --- a/dream-server/docker-compose.base.yml +++ b/dream-server/docker-compose.base.yml @@ -175,7 +175,7 @@ services: - ./scripts:/dream-server/scripts:ro - ./config:/dream-server/config:ro - ./extensions:/dream-server/extensions:ro - - ./.env:/dream-server/.env + - ./.env:/dream-server/.env:ro - ./.env.example:/dream-server/.env.example:ro - ./.env.schema.json:/dream-server/.env.schema.json:ro - ./data:/data diff --git a/dream-server/extensions/services/dashboard-api/main.py b/dream-server/extensions/services/dashboard-api/main.py index 2e923a993..d65d409c6 100644 --- a/dream-server/extensions/services/dashboard-api/main.py +++ b/dream-server/extensions/services/dashboard-api/main.py @@ -744,6 +744,23 @@ def _call_agent_core_recreate(service_ids: list[str]) -> dict[str, Any]: return json.loads(response.read().decode("utf-8")) +def _call_agent_env_update(raw_text: str) -> dict[str, Any]: + """Route .env writes through the host agent (filesystem is :ro in container).""" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {DREAM_AGENT_KEY}", + } + data = json.dumps({"raw_text": raw_text, "backup": True}).encode("utf-8") + request = urllib.request.Request( + f"{AGENT_URL}/v1/env/update", + data=data, + headers=headers, + method="POST", + ) + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + + def _check_host_agent_available() -> bool: try: with urllib.request.urlopen(f"{AGENT_URL}/health", timeout=3) as response: @@ -804,45 +821,6 @@ def _relative_install_path(path: Path) -> str: return str(path).replace("\\", "/") -def _resolve_env_backup_root() -> Path: - backup_root = Path(DATA_DIR) / "config-backups" - backup_root.mkdir(parents=True, exist_ok=True) - return backup_root - - -def _display_backup_path(path: Path) -> str: - data_root = Path(DATA_DIR) - try: - return f"data/{path.relative_to(data_root).as_posix()}" - except ValueError: - return str(path).replace("\\", "/") - - -def _write_text_atomic(path: Path, raw_text: str): - temp_path = path.with_name(f"{path.name}.tmp-{os.getpid()}-{int(time.time() * 1000)}") - try: - try: - temp_path.write_text(raw_text, encoding="utf-8") - if path.exists(): - try: - shutil.copymode(path, temp_path) - except OSError: - pass - os.replace(temp_path, path) - return - except PermissionError: - if not path.exists(): - raise - path.write_text(raw_text, encoding="utf-8") - return - finally: - try: - if temp_path.exists(): - temp_path.unlink() - except OSError: - pass - - def _prepare_env_save(payload: dict[str, Any]) -> tuple[str, list[dict[str, Any]], dict[str, Any]]: mode = payload.get("mode", "form") env_path = _resolve_runtime_env_path() @@ -1365,7 +1343,6 @@ async def api_settings_env_save( payload: dict[str, Any] = Body(...), api_key: str = Depends(verify_api_key), ): - env_path = _resolve_runtime_env_path() raw_text, issues, apply_plan = await asyncio.to_thread(_prepare_env_save, payload) if issues: raise HTTPException( @@ -1376,35 +1353,27 @@ async def api_settings_env_save( }, ) - env_path.parent.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - backup_root = await asyncio.to_thread(_resolve_env_backup_root) - backup_path = backup_root / f".env.backup.{timestamp}" - backup_relative = None - - if env_path.exists(): - try: - shutil.copy2(env_path, backup_path) - backup_relative = _display_backup_path(backup_path) - except OSError as exc: - raise HTTPException( - status_code=500, - detail={ - "message": "Could not create a configuration backup before saving.", - "reason": str(exc), - }, - ) from exc - try: - await asyncio.to_thread(_write_text_atomic, env_path, raw_text) + agent_resp = await asyncio.to_thread(_call_agent_env_update, raw_text) + except urllib.error.HTTPError as exc: + detail = f"Host agent returned HTTP {exc.code}." + try: + err_payload = json.loads(exc.read().decode("utf-8")) + detail = err_payload.get("error", detail) + except Exception: + pass + raise HTTPException(status_code=503, detail={"message": detail}) from exc + except urllib.error.URLError as exc: + raise HTTPException( + status_code=503, + detail={"message": "Dream host agent is not reachable. Start the host agent, then try again."}, + ) from exc except OSError as exc: raise HTTPException( status_code=500, - detail={ - "message": "Could not write the updated environment file.", - "reason": str(exc), - }, + detail={"message": "Could not contact host agent to write environment file.", "reason": str(exc)}, ) from exc + backup_relative = agent_resp.get("backup_path") _clear_settings_caches() result = await asyncio.to_thread( diff --git a/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py b/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py index 7a6b3af19..63e6c54b0 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py @@ -1,9 +1,13 @@ """Tests for dream-host-agent.py — _parse_mem_value and _iso_now.""" import importlib.util +import io +import json import sys from pathlib import Path, PurePosixPath +import pytest + # Import the host agent module from bin/ using importlib. # The module has an ``if __name__ == "__main__":`` guard so no server starts. _agent_path = Path(__file__).resolve().parents[4] / "bin" / "dream-host-agent.py" @@ -227,3 +231,165 @@ def test_setup_hook_uses_resolve_hook_with_post_install(self): "setup_hook must use _resolve_hook(..., 'post_install'); " "the legacy _resolve_setup_hook has been removed" ) + + +# --- _handle_env_update --- + + +class _FakeHandler: + """Minimal stand-in for BaseHTTPRequestHandler used by _handle_env_update.""" + + def __init__(self, body: bytes, headers=None): + merged = { + "Authorization": "Bearer test-key", + "Content-Length": str(len(body)), + } + if headers: + merged.update(headers) + self.headers = merged + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.client_address = ("127.0.0.1", 12345) + self.response_code = None + self.response_headers = [] + + def send_response(self, code): + self.response_code = code + + def send_header(self, name, value): + self.response_headers.append((name, value)) + + def end_headers(self): + pass + + def parse_response(self): + # json_response writes the JSON body via wfile.write() + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +@pytest.fixture +def env_update_env(tmp_path, monkeypatch): + """Wire up INSTALL_DIR/DATA_DIR/AGENT_API_KEY for _handle_env_update tests.""" + install_dir = tmp_path / "install" + install_dir.mkdir() + data_dir = tmp_path / "data" + data_dir.mkdir() + + schema = { + "properties": { + "DREAM_AGENT_KEY": {"type": "string"}, + "GGUF_FILE": {"type": "string"}, + } + } + (install_dir / ".env.schema.json").write_text(json.dumps(schema), encoding="utf-8") + (install_dir / ".env").write_text("DREAM_AGENT_KEY=existing\n", encoding="utf-8") + + monkeypatch.setattr(_mod, "INSTALL_DIR", install_dir) + monkeypatch.setattr(_mod, "DATA_DIR", data_dir) + monkeypatch.setattr(_mod, "AGENT_API_KEY", "test-key") + return install_dir, data_dir + + +def _make_body(raw_text: str, backup: bool = True) -> bytes: + return json.dumps({"raw_text": raw_text, "backup": backup}).encode("utf-8") + + +class TestHandleEnvUpdate: + + def test_happy_path_writes_file_and_returns_backup(self, env_update_env): + install_dir, data_dir = env_update_env + body = _make_body("DREAM_AGENT_KEY=newvalue\nGGUF_FILE=/models/foo.gguf\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 200 + resp = handler.parse_response() + assert resp["status"] == "ok" + assert resp["backup_path"].startswith("data/config-backups/.env.backup.") + env_text = (install_dir / ".env").read_text(encoding="utf-8") + assert "DREAM_AGENT_KEY=newvalue" in env_text + assert "GGUF_FILE=/models/foo.gguf" in env_text + # backup file actually exists where the response says it does + backup_files = list((data_dir / "config-backups").glob(".env.backup.*")) + assert len(backup_files) == 1 + + def test_413_oversize_body(self, env_update_env): + # Construct headers claiming body is too large; rfile content is irrelevant. + handler = _FakeHandler(b"x", headers={"Content-Length": str(_mod.MAX_BODY + 999999) if hasattr(_mod, "MAX_BODY") else "100000"}) + # MAX_ENV_BODY is hard-coded to 65536 inside the handler. + handler.headers["Content-Length"] = "70000" + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 413 + assert "too large" in handler.parse_response()["error"].lower() + + def test_400_unknown_key(self, env_update_env): + body = _make_body("NOT_IN_SCHEMA=foo\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 400 + assert "Unknown key" in handler.parse_response()["error"] + + def test_400_malformed_line(self, env_update_env): + body = _make_body("THIS_LINE_HAS_NO_EQUALS\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 400 + assert "Malformed line" in handler.parse_response()["error"] + + def test_400_control_char_in_value(self, env_update_env): + body = _make_body("DREAM_AGENT_KEY=foo\x00bar\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 400 + assert "control characters" in handler.parse_response()["error"] + + def test_400_control_char_escape_sequence(self, env_update_env): + # ESC (0x1b) — common in injected ANSI sequences + body = _make_body("DREAM_AGENT_KEY=foo\x1b[31mbar\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 400 + + def test_tab_in_value_is_allowed(self, env_update_env): + # Tab is the only sub-32 char that should pass through. + body = _make_body("DREAM_AGENT_KEY=foo\tbar\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 200 + + def test_409_lock_contention(self, env_update_env): + body = _make_body("DREAM_AGENT_KEY=newvalue\n") + handler = _FakeHandler(body) + + assert _mod._model_activate_lock.acquire(blocking=False) + try: + _mod.AgentHandler._handle_env_update(handler) + finally: + _mod._model_activate_lock.release() + + assert handler.response_code == 409 + assert "in progress" in handler.parse_response()["error"] + + def test_500_missing_schema(self, env_update_env): + install_dir, _ = env_update_env + (install_dir / ".env.schema.json").unlink() + body = _make_body("DREAM_AGENT_KEY=newvalue\n") + handler = _FakeHandler(body) + + _mod.AgentHandler._handle_env_update(handler) + + assert handler.response_code == 500 + assert ".env.schema.json not found" in handler.parse_response()["error"] diff --git a/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py b/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py index 566b1a594..32936bfc9 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py @@ -64,6 +64,18 @@ def settings_env_fixture(tmp_path, monkeypatch): monkeypatch.setattr("main._resolve_runtime_env_path", lambda: env_path) monkeypatch.setattr("main.DATA_DIR", str(data_root)) + def fake_env_update(raw_text): + backup_dir = data_root / "config-backups" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / ".env.backup.test" + if env_path.exists(): + backup_path.write_bytes(env_path.read_bytes()) + payload = raw_text if raw_text.endswith("\n") else raw_text + "\n" + env_path.write_text(payload, encoding="utf-8") + return {"backup_path": "data/config-backups/.env.backup.test"} + + monkeypatch.setattr("main._call_agent_env_update", fake_env_update) + def fake_resolve_template(name: str): if name == ".env.example": return example_path From 36f941226bbf973cdce5f90528075612d0df36b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 14:50:53 +0300 Subject: [PATCH 2/2] fix(env-update): accept non-schema keys and preserve extras on round-trip Two fixes for the .env write path: 1. Host agent: change strict reject (400) to warn-and-accept for keys not in .env.schema.json. Extension install hooks and GPU pinning write keys that are absent from the core schema (e.g. JWT_SECRET from LibreChat, COMFYUI_GPU_UUID from the installer). Rejecting them made the dashboard Settings save unusable after any extension install. 2. Dashboard API: _render_env_from_values no longer drops extras with empty values. The filter `value != ""` silently discarded keys like LLAMA_ARG_TENSOR_SPLIT="" on round-trip, even though empty values are semantically meaningful (disabling a setting). Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/bin/dream-host-agent.py | 13 ++++++++--- .../extensions/services/dashboard-api/main.py | 2 +- .../dashboard-api/tests/test_host_agent.py | 13 +++++++---- .../dashboard-api/tests/test_settings_env.py | 22 +++++++++++++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/dream-server/bin/dream-host-agent.py b/dream-server/bin/dream-host-agent.py index 5d9f0b467..2972b2f4e 100755 --- a/dream-server/bin/dream-host-agent.py +++ b/dream-server/bin/dream-host-agent.py @@ -676,10 +676,17 @@ def _handle_env_update(self): return key, _, value = stripped.partition("=") key = key.strip() - if key not in allowed_keys: - logger.warning("env_update rejected: unknown key %r from %s", key, client_ip) - json_response(self, 400, {"error": f"Unknown key: {key}"}) + if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', key): + logger.warning("env_update rejected: invalid key name %r from %s", key[:40], client_ip) + json_response(self, 400, {"error": f"Invalid key name: {key[:40]}"}) return + if key not in allowed_keys: + # Warn but accept — extension install hooks and GPU pinning write + # keys that are not in the core schema (e.g. JWT_SECRET from + # LibreChat, COMFYUI_GPU_UUID from the installer). Rejecting + # them breaks the dashboard Settings save for any install that + # has ever enabled an extension. + logger.info("env_update: non-schema key %r from %s (accepted)", key, client_ip) # Defense in depth: reject values containing control chars (null bytes, # escape sequences, etc.). splitlines() already consumed \n/\r/\u2028/\u2029; # this catches the residual edge cases flagged by security review. diff --git a/dream-server/extensions/services/dashboard-api/main.py b/dream-server/extensions/services/dashboard-api/main.py index d65d409c6..b6c60753d 100644 --- a/dream-server/extensions/services/dashboard-api/main.py +++ b/dream-server/extensions/services/dashboard-api/main.py @@ -709,7 +709,7 @@ def _render_env_from_values(values: dict[str, str]) -> str: output_lines.append(line) - extras = [(key, value) for key, value in values.items() if key not in seen and value != ""] + extras = [(key, value) for key, value in values.items() if key not in seen] if extras: if output_lines and output_lines[-1] != "": output_lines.append("") diff --git a/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py b/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py index 63e6c54b0..25be3733e 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_host_agent.py @@ -325,14 +325,19 @@ def test_413_oversize_body(self, env_update_env): assert handler.response_code == 413 assert "too large" in handler.parse_response()["error"].lower() - def test_400_unknown_key(self, env_update_env): - body = _make_body("NOT_IN_SCHEMA=foo\n") + def test_accepts_unknown_key_with_warning(self, env_update_env): + """Non-schema keys are accepted (warn, not reject) so extension-added + keys (e.g. JWT_SECRET from LibreChat) don't break Settings save.""" + install_dir, data_dir = env_update_env + (install_dir / ".env").write_text("DREAM_AGENT_KEY=old\n", encoding="utf-8") + body = _make_body("DREAM_AGENT_KEY=old\nNOT_IN_SCHEMA=foo\n") handler = _FakeHandler(body) _mod.AgentHandler._handle_env_update(handler) - assert handler.response_code == 400 - assert "Unknown key" in handler.parse_response()["error"] + assert handler.response_code == 200 + env_text = (install_dir / ".env").read_text(encoding="utf-8") + assert "NOT_IN_SCHEMA=foo" in env_text def test_400_malformed_line(self, env_update_env): body = _make_body("THIS_LINE_HAS_NO_EQUALS\n") diff --git a/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py b/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py index 32936bfc9..6a4b1b054 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py @@ -287,3 +287,25 @@ def test_api_settings_env_apply_rejects_disallowed_service(test_client): assert response.status_code == 400 assert "not eligible" in response.json()["detail"]["message"].lower() + + +# --- Render round-trip fidelity --- + + +def test_render_env_preserves_extras_with_empty_values(): + """Keys with empty values must survive _render_env_from_values round-trip. + + Regression guard for fork issue #335: the old filter + ``value != ""`` silently dropped keys like LLAMA_ARG_TENSOR_SPLIT="" + on every save. + """ + from main import _render_env_from_values + + values = { + "LLM_BACKEND": "local", + "TENSOR_SPLIT": "", # intentionally empty + "GPU_UUID": "GPU-abc123", + } + rendered = _render_env_from_values(values) + assert "TENSOR_SPLIT=" in rendered + assert "GPU_UUID=GPU-abc123" in rendered