From 4014513b94d6dca4abfdef14fc018145a57cb41a Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 21:17:21 -0700 Subject: [PATCH] fix(dotenv): empty value with inline comment was returning the comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's own .env contains lines like CONFIGS_DIR= # Path to workspace-configs-templates/... where the value is empty + an inline comment. The pre-fix parser: 1. v = " # Path to ..." 2. TrimLeft → "# Path to ..." 3. Inline-comment loop looked for " #" or "\t#" — neither matches because the leading whitespace is gone. 4. Returned the comment text as the value. Result: os.Setenv("CONFIGS_DIR", "# Path to ...") clobbered the auto- discovery fallback. The TemplatesHandler then opened the comment as a directory, ReadDir errored silently, and GET /templates returned []. Canvas's Templates panel showed "No templates found in workspace-configs-templates/" even though 8 valid templates existed on disk. Fix: strip leading whitespace from the value FIRST, then run a position-aware comment scan that treats `#` as a comment marker iff it's at the start of the (trimmed) value or preceded by whitespace. A bare `#` mid-value (e.g. `KEY=token#fragment`) still survives. Quoted-value handling moved above the comment scan so `KEY="value # not"` keeps the `#` as part of the value — pulled the quote-detection into the same TrimLeft-then-check shape as the bare path. The unterminated-quote case still falls through to bare-value handling. Three regression tests added covering the exact .env line that broke (`CONFIGS_DIR= # ...`), spaces-only with comment, and tab- only with comment. Verified end-to-end: GET /templates now returns all 8 templates. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace-server/cmd/server/dotenv.go | 39 ++++++++++++++-------- workspace-server/cmd/server/dotenv_test.go | 12 +++++++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/workspace-server/cmd/server/dotenv.go b/workspace-server/cmd/server/dotenv.go index ee5765d3..02d504a5 100644 --- a/workspace-server/cmd/server/dotenv.go +++ b/workspace-server/cmd/server/dotenv.go @@ -144,23 +144,34 @@ func parseDotEnvLine(line string) (string, string, bool) { } k := strings.TrimSpace(line[:eq]) v := line[eq+1:] - // Quoted value: strip one matched pair of surrounding quotes and - // take the contents verbatim (no inline-comment splitting). Matches - // the godotenv convention so values with leading/trailing spaces or - // `#` survive round-trip. + // Trim leading whitespace so a quoted value's opening quote is at + // v[0]. The comment-detection loop below then treats the position + // after the trim as "start of value" — `KEY= # comment` has its + // `#` at the new v[0] (preceded only by whitespace in the source) + // and is correctly classified as an empty value followed by a + // comment, not as a value of `# comment`. v = strings.TrimLeft(v, " \t") - if len(v) >= 2 { - first := v[0] - if (first == '"' || first == '\'') && v[len(v)-1] == first { - return k, v[1 : len(v)-1], true + // Quoted value: strip one matched pair of surrounding quotes and + // take the contents verbatim (no inline-comment splitting). Must + // happen BEFORE comment detection so `KEY="value # not a comment"` + // keeps the `#` as part of the value. + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') { + quote := v[0] + if end := strings.IndexByte(v[1:], quote); end >= 0 { + return k, v[1 : 1+end], true } + // Unterminated quote — fall through to bare-value handling + // (treats the opening quote as a literal char in the value). } - // Bare value: strip inline comment introduced by whitespace + `#`. - // A bare `#` inside the value (no preceding whitespace) is part of - // the value — matches dotenv parsers and lets `KEY=token#fragment` - // round-trip. - for _, sep := range []string{" #", "\t#"} { - if i := strings.Index(v, sep); i >= 0 { + // Bare value: strip inline comment. A `#` is a comment marker iff + // it's at the start of the (trimmed) value OR is preceded by + // whitespace. `KEY=token#fragment` keeps the `#` as part of the + // value because v[i-1] is alphanum. + for i := 0; i < len(v); i++ { + if v[i] != '#' { + continue + } + if i == 0 || v[i-1] == ' ' || v[i-1] == '\t' { v = v[:i] break } diff --git a/workspace-server/cmd/server/dotenv_test.go b/workspace-server/cmd/server/dotenv_test.go index 2ce1159b..411ad596 100644 --- a/workspace-server/cmd/server/dotenv_test.go +++ b/workspace-server/cmd/server/dotenv_test.go @@ -36,6 +36,18 @@ func TestParseDotEnvLine(t *testing.T) { {in: "FOO=", k: "FOO", v: "", ok: true, comment: "empty value"}, {in: "ADMIN_TOKEN=", k: "ADMIN_TOKEN", v: "", ok: true, comment: "empty value (production gate sentinel)"}, + // Regression: the repo's own .env contains lines like + // `CONFIGS_DIR= # Path to ...` where the value + // is empty + an inline comment. Pre-fix parser stripped leading + // whitespace BEFORE detecting the comment, leaving `#` at v[0] + // with nothing preceding it, so the inline-comment check missed + // it and the comment text was returned as the value. Server + // then tried to use the comment as a directory path and template + // loading silently failed (GET /templates returned []). + {in: "CONFIGS_DIR= # Path to /var/foo (auto-discovered if empty)", k: "CONFIGS_DIR", v: "", ok: true, comment: "empty value with leading whitespace + inline comment"}, + {in: "FOO= # comment", k: "FOO", v: "", ok: true, comment: "spaces-only value with inline comment"}, + {in: "FOO=\t# comment", k: "FOO", v: "", ok: true, comment: "tab-only value with inline comment"}, + // `export` prefix: shell-friendly .env files (direnv, .envrc-style) // — the prefix must be stripped, NOT folded into the key. {in: "export FOO=bar", k: "FOO", v: "bar", ok: true, comment: "export prefix stripped"},