From 37646efbbf89fc41c61d79a627c47208dd3a171f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 14:07:07 +0000 Subject: [PATCH 1/2] fix(shellenv): preserve newlines in exported env values exportify escaped a newline inside a double-quoted value as a backslash-newline. In POSIX shells a backslash-newline is a line continuation that is removed on eval, so any multiline env value had its newlines silently deleted, joining adjacent lines. For a multiline PROMPT_COMMAND this turned `... 2>&1\foo` into `... 2>&1foo`, making the shell emit "ambiguous redirect" on every prompt. A literal newline inside double quotes is preserved as-is and needs no escaping, so stop escaping it. Fixes #2814 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016JemcBvhaZqahAzACV4aQF --- internal/devbox/envvars.go | 10 +++++++++- internal/devbox/envvars_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 307546f30e0..0f00a935c6f 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -93,7 +93,15 @@ func exportify(w io.Writer, vars map[string]string) string { switch r { // Special characters inside double quotes: // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - case '$', '`', '"', '\\', '\n': + // + // A newline is intentionally NOT escaped. Inside double + // quotes a literal newline is preserved as-is, but a + // backslash-newline is a line continuation that the shell + // removes entirely. Escaping it would silently join adjacent + // lines of a multiline value and corrupt it (see #2814, where + // a multiline PROMPT_COMMAND turned `2>&1\foo` into + // `2>&1foo` and produced an "ambiguous redirect" error). + case '$', '`', '"', '\\': strb.WriteRune('\\') } strb.WriteRune(r) diff --git a/internal/devbox/envvars_test.go b/internal/devbox/envvars_test.go index d387feb8c19..3bc673bd2d9 100644 --- a/internal/devbox/envvars_test.go +++ b/internal/devbox/envvars_test.go @@ -47,6 +47,28 @@ func TestExportifySkipsInvalidNames(t *testing.T) { } } +// TestExportifyMultilineValue ensures that env vars whose values contain +// newlines are emitted with literal newlines inside the double quotes, not a +// backslash-newline. A backslash-newline is a shell line continuation that gets +// removed on eval, which would join adjacent lines and corrupt the value (see +// issue #2814: a multiline PROMPT_COMMAND produced an "ambiguous redirect"). +func TestExportifyMultilineValue(t *testing.T) { + got := exportify(io.Discard, map[string]string{ + "MULTI": "line1\nline2", + }) + + want := "export MULTI=\"line1\nline2\";" + if got != want { + t.Errorf("exportify multiline value = %q, want %q", got, want) + } + + // A backslash-newline (line continuation) must not appear; that is the + // exact corruption we are guarding against. + if strings.Contains(got, "\\\n") { + t.Errorf("exportify escaped a newline as a line continuation, got:\n%s", got) + } +} + func TestExportifyNushellSkipsInvalidNames(t *testing.T) { got := exportifyNushell(io.Discard, map[string]string{ "GOOD": "value", From 07059d3f653cbad1c02040513ea57f3cd542a8c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 14:10:09 +0000 Subject: [PATCH 2/2] fix(shellenv): move escape comment out of loop to satisfy varnamelen The explanatory comment inside the rune loop widened the scope of the single-letter loop variable `r`, tripping golangci-lint's varnamelen. Move the comment above the loop so the loop body matches the original structure; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016JemcBvhaZqahAzACV4aQF --- internal/devbox/envvars.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 0f00a935c6f..304d71ae909 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -89,18 +89,18 @@ func exportify(w io.Writer, vars map[string]string) string { strb.WriteString("export ") strb.WriteString(key) strb.WriteString(`="`) + // Escape the characters that are special inside double quotes: + // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 + // + // A newline is intentionally NOT escaped. Inside double quotes a + // literal newline is preserved as-is, but a backslash-newline is a + // line continuation that the shell removes entirely. Escaping it + // would silently join adjacent lines of a multiline value and + // corrupt it (see #2814, where a multiline PROMPT_COMMAND turned + // `2>&1\foo` into `2>&1foo` and produced an "ambiguous + // redirect" error). for _, r := range vars[key] { switch r { - // Special characters inside double quotes: - // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - // - // A newline is intentionally NOT escaped. Inside double - // quotes a literal newline is preserved as-is, but a - // backslash-newline is a line continuation that the shell - // removes entirely. Escaping it would silently join adjacent - // lines of a multiline value and corrupt it (see #2814, where - // a multiline PROMPT_COMMAND turned `2>&1\foo` into - // `2>&1foo` and produced an "ambiguous redirect" error). case '$', '`', '"', '\\': strb.WriteRune('\\') }