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.
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
Description
A no-op round-trip through
PUT /api/settings/env(sending the file back to itself unchanged) rebuilds the file using.env.exampleas 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.exampleare silently DROPPED, including all GPU UUID assignments.No live containers broke during the test (services don't re-read
.envafter start), but a futuredocker compose down && up -dafter a "save settings" action could pin services to default values for any dropped key.Affected File(s)
dream-server/bin/dream-host-agent.py—_handle_env_update(the new endpoint added by PR fix(security): route .env writes through host agent, restore :ro mount Light-Heart-Labs/DreamServer#908)dream-server/extensions/services/dashboard-api/main.py— thePUT /api/settings/envroute that delegates to host-agentRoot Cause (inferred from observed behavior)
_handle_env_updatelooks like it parsesraw_textinto key/value pairs, then re-renders the file using.env.exampleas a template, substituting the parsed values. This explains both observed effects:.env.examplehas many more documentation comments than the runtime.env).raw_textbut not in.env.examplefalls off the floor — there's no key in the template to substitute into).The result is a file that LOOKS like a refreshed
.env.examplewith the user's values dropped in, rather than a faithful save of what the user submitted.Evidence
Keys dropped on this run
Comparing the pre-write file (preserved as
.env.backup.20260411-211429by host-agent's own backup mechanism) and the post-write file via:COMFYUI_GPU_UUIDEMBEDDINGS_GPU_UUIDWHISPER_GPU_UUIDJUPYTER_TOKENLLAMA_ARG_TENSOR_SPLITLLAMA_SERVER_GPU_UUIDSAll six are runtime values written by the installer /
dream-clifor GPU pinning and per-service tokens. None of them appear in.env.example(which is the documentation template) —.env.examplehas slots forWEBUI_SECRET,N8N_PASS,LITELLM_KEY,OPENCLAW_TOKEN, etc., but not for the GPU-UUID assignments that are derived at install time.Platform Analysis
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).Impact
Low-medium. No immediate failure observed — the running stack survived the test cleanly because services don't re-read
.envafter they start. But on the nextdocker 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_textthrough aPUTis "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
.envkeys ∪ 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_textverbatim. 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 whatPUT raw_textshould 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:
.envis mounted:roin dashboard-api (verified viadocker inspect)docker logs+ host-agent journal).env updated via host agent from 172.18.0.9 (backup=...)The single-writer security boundary that #317 asked for works. Only the round-trip fidelity is off.
Cross-references
:romount blocking PR fix(dashboard-api): non-blocking apply_template + resolve built-in extensions Light-Heart-Labs/DreamServer#909's template activation. Both findings live in the dashboard-api container's relationship with the host-agent.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.