Skip to content

bug: PUT /api/settings/env round-trip rebuilds file from .env.example template, drops keys not in schema #335

@yasinBursali

Description

@yasinBursali

Bug Report: PUT /api/settings/env round-trip rebuilds file from .env.example template, drops keys not in schema

Severity: Low-Medium
Category: Data Fidelity / Settings Persistence
Platform: All (Linux, macOS, Windows/WSL2)
Confidence: Confirmed

Adjacent to: #317 (security: .env bind-mount changed from :ro to writable). The security boundary that #317 asks for is restored by PR Light-Heart-Labs#908 and works end-to-end (verified — see my comment on #317). This issue is about data fidelity in the new write path, not about the security model.

Description

A no-op round-trip through PUT /api/settings/env (sending the file back to itself unchanged) rebuilds the file using .env.example as a structural template instead of writing the input bytes verbatim. The pre-write file was 105 lines / 64 keys. The post-write file is 229 lines / 61 keys with an entirely different comment header. Six keys present in the input but absent from .env.example are silently DROPPED, including all GPU UUID assignments.

No live containers broke during the test (services don't re-read .env after start), but a future docker compose down && up -d after a "save settings" action could pin services to default values for any dropped key.

Affected File(s)

Root Cause (inferred from observed behavior)

_handle_env_update looks like it parses raw_text into key/value pairs, then re-renders the file using .env.example as a template, substituting the parsed values. This explains both observed effects:

  • The doubled line count (.env.example has many more documentation comments than the runtime .env).
  • The dropped keys (anything in raw_text but not in .env.example falls off the floor — there's no key in the template to substitute into).

The result is a file that LOOKS like a refreshed .env.example with the user's values dropped in, rather than a faithful save of what the user submitted.

Evidence

$ KEY=$(grep ^DASHBOARD_API_KEY= ~/dream-server/.env | cut -d= -f2)

# Capture pre-write state
$ wc -l ~/dream-server/.env
105 /home/rosenrot/dream-server/.env
$ grep -cE "^[A-Z_]+=" ~/dream-server/.env
64
$ md5sum ~/dream-server/.env > /tmp/pre.md5

# No-op round trip — read the file, write it back unchanged via the host-agent route
$ python3 -c "
import json, urllib.request
key='$KEY'
raw=open('/home/rosenrot/dream-server/.env').read()
req=urllib.request.Request(
    'http://127.0.0.1:3002/api/settings/env',
    method='PUT',
    headers={'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'},
    data=json.dumps({'raw_text': raw}).encode(),
)
print(urllib.request.urlopen(req).status)
"
200

# Post-write state
$ wc -l ~/dream-server/.env
229 /home/rosenrot/dream-server/.env       ← grew by 124 lines
$ grep -cE "^[A-Z_]+=" ~/dream-server/.env
61                                          ← lost 3 keys net (dropped 6, added 3 from .env.example)
$ diff /tmp/pre.md5 <(md5sum ~/dream-server/.env)
< (different md5)

Keys dropped on this run

Comparing the pre-write file (preserved as .env.backup.20260411-211429 by host-agent's own backup mechanism) and the post-write file via:

$ diff <(grep -oE "^[A-Z_][A-Z0-9_]*=" data/config-backups/.env.backup.20260411-211429 | sort -u) \
       <(grep -oE "^[A-Z_][A-Z0-9_]*=" .env | sort -u)
Key dropped Used by
COMFYUI_GPU_UUID ComfyUI container GPU pinning (when ComfyUI is installed)
EMBEDDINGS_GPU_UUID TEI embeddings container GPU pinning
WHISPER_GPU_UUID Whisper STT container GPU pinning
JUPYTER_TOKEN Jupyter (when installed as a user extension)
LLAMA_ARG_TENSOR_SPLIT llama-server multi-GPU tensor split
LLAMA_SERVER_GPU_UUIDS llama-server GPU pinning

All six are runtime values written by the installer / dream-cli for GPU pinning and per-service tokens. None of them appear in .env.example (which is the documentation template) — .env.example has slots for WEBUI_SECRET, N8N_PASS, LITELLM_KEY, OPENCLAW_TOKEN, etc., but not for the GPU-UUID assignments that are derived at install time.

Platform Analysis

  • macOS: Affected — same Python code path runs in the host-agent regardless of host OS.
  • Linux: Affected, verified end-to-end on WSL2.
  • Windows/WSL2: Affected, verified.

Reproduction

Fresh install on a branch with PR Light-Heart-Labs#908 merged (i.e. the integration of the open PR stack Light-Heart-Labs#893–909 against Light-Heart-Labs/DreamServer@c0600ca).

1. Verify host-agent is the new version (post-#908): systemctl --user restart dream-host-agent.service
   (See #334 — the installer doesn't restart it automatically.)

2. KEY=$(grep ^DASHBOARD_API_KEY= ~/dream-server/.env | cut -d= -f2)
   wc -l ~/dream-server/.env                       # → 105
   grep -cE "^[A-Z_]+=" ~/dream-server/.env        # → 64

3. Send the file back to itself unchanged via PUT /api/settings/env (use the python snippet
   in the Evidence section above).

4. wc -l ~/dream-server/.env                       # → 229   (grew)
   grep -cE "^[A-Z_]+=" ~/dream-server/.env        # → 61    (shrank)

5. Compare against the host-agent's own backup at ~/dream-server/data/config-backups/.env.backup.<timestamp>:
   the dropped keys are visible in the diff.

Impact

Low-medium. No immediate failure observed — the running stack survived the test cleanly because services don't re-read .env after they start. But on the next docker compose down && up -d (e.g. an upgrade or reboot), services that depended on the dropped GPU UUID assignments will fall back to defaults. On WSL2 / multi-GPU hosts that can mean wrong GPU attachment, and on Tier 1 single-GPU systems the practical effect is usually invisible (one GPU, default selection works).

The risk is that any user who saves settings via the new dashboard editor — even unchanged — degrades their .env. The settings editor's most common path is "open, change one field, save", which would silently drop everything not in the schema.

The principle-of-least-surprise expectation when sending raw_text through a PUT is "the bytes I sent are the bytes that get written" (or at least: "the keys I sent are preserved").

Suggested Approach

Option A — preserve all keys (recommended): render the file using a CANONICAL list of keys = (existing .env keys ∪ schema keys). Substitute schema keys into the template positions; append non-schema keys to the bottom (or in their original order). Preserve all values.

Option B — true byte-for-byte round-trip: write raw_text verbatim. Validate only that no schema constraints are violated (no unknown control characters, no keys with reserved names, etc.), then write the bytes as-is. This is the principle-of-least-surprise fix and matches what PUT raw_text should naturally mean.

I'd recommend B because it has the simplest mental model and makes the security check (host-agent as single writer) the only nontrivial thing the route does. The current behavior (template-based render) is doing two jobs at once and silently doing the second one wrong.

Note

This is secondary to the higher-severity finding about Light-Heart-Labs#908 + Light-Heart-Labs#909 mount conflict (filed as #331). The main Light-Heart-Labs#908 write functionality works correctly:

  • .env is mounted :ro in dashboard-api (verified via docker inspect)
  • Direct write attempts inside the container are rejected
  • PUT via dashboard-api delegates to host-agent (verified via docker logs + host-agent journal)
  • Host-agent journal: .env updated via host agent from 172.18.0.9 (backup=...)
  • Backup file is created on disk

The single-writer security boundary that #317 asked for works. Only the round-trip fidelity is off.

Cross-references


Filed during full-stack integration test of open PR stack Light-Heart-Labs#893–909 on Light-Heart-Labs/DreamServer@c0600ca3. Environment: WSL2 / Ubuntu 24.04 / NVIDIA RTX 3070 Laptop / Tier 1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions