From 18686f411f2476c67cf3e191ee9aeb8a0d687f13 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:06:59 +0000 Subject: [PATCH 1/2] fix(shellenv): don't escape newlines in exported env var values exportify escaped newlines inside double-quoted export values as `\`. Inside double quotes the shell treats a backslash-newline as a line continuation and removes it, collapsing a multi-line value onto a single line. A bare newline inside double quotes is already literal, so the escaping was both unnecessary and corrupting. This corrupted, for example, a PROMPT_COMMAND set by bash-preexec whose `... 2>&1` and `__bp_interactive_mode` lines got joined into `... 2>&1__bp_interactive_mode`, producing a "1__bp_interactive_mode: ambiguous redirect" error at every prompt when running `eval "$(devbox global shellenv)"`. Drop `\n` from the set of characters escaped inside double quotes and add a regression test pinning the multi-line output. Fixes #2814 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WzomNNnDLmX4LMZ4aPtYvm --- internal/devbox/envvars.go | 10 +++++++++- internal/devbox/envvars_test.go | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 307546f30e0..0f0c5b95c7c 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': + // + // Note: a newline is NOT escaped. Inside double quotes a bare + // newline is already a literal newline, whereas a backslash + // followed by a newline is a line continuation that the shell + // removes entirely. Escaping newlines would collapse a + // multi-line value onto a single line and silently corrupt it + // (e.g. a PROMPT_COMMAND containing `... 2>&1\n__bp_...` would + // become `... 2>&1__bp_...`, causing an "ambiguous redirect"). + case '$', '`', '"', '\\': strb.WriteRune('\\') } strb.WriteRune(r) diff --git a/internal/devbox/envvars_test.go b/internal/devbox/envvars_test.go index d387feb8c19..70cd6ba7ff2 100644 --- a/internal/devbox/envvars_test.go +++ b/internal/devbox/envvars_test.go @@ -47,6 +47,29 @@ func TestExportifySkipsInvalidNames(t *testing.T) { } } +// TestExportifyPreservesNewlines ensures that a value containing newlines is +// emitted with literal (unescaped) newlines. Escaping a newline as `\` +// makes the shell treat it as a line continuation and strip it, collapsing a +// multi-line value onto a single line. That corrupted, for example, a +// PROMPT_COMMAND whose `... 2>&1` and `__bp_interactive_mode` lines got joined +// into `... 2>&1__bp_interactive_mode`, yielding a "1__bp_...: ambiguous +// redirect" error at every prompt. See issue #2814. +func TestExportifyPreservesNewlines(t *testing.T) { + value := "cmd_a >/dev/null 2>&1\n__bp_interactive_mode" + got := exportify(io.Discard, map[string]string{"PROMPT_COMMAND": value}) + + want := "export PROMPT_COMMAND=\"cmd_a >/dev/null 2>&1\n__bp_interactive_mode\";" + if got != want { + t.Errorf("exportify newline handling:\n got: %q\nwant: %q", got, want) + } + + // A backslash-newline (line continuation) must not appear: that is the + // exact corruption this test guards against. + if strings.Contains(got, "\\\n") { + t.Errorf("exportify escaped a newline as a line continuation, corrupting the value:\n%s", got) + } +} + func TestExportifyNushellSkipsInvalidNames(t *testing.T) { got := exportifyNushell(io.Discard, map[string]string{ "GOOD": "value", From 325554d1a26bf137cac7c7ceac29ab8e4898f021 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:10:17 +0000 Subject: [PATCH 2/2] fix(shellenv): satisfy varnamelen lint in exportify loop Rename the loop variable `r` to `char` and hoist the explanatory comment out of the loop body so the variable's declaration-to-use distance stays within golangci-lint's varnamelen max-distance. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WzomNNnDLmX4LMZ4aPtYvm --- internal/devbox/envvars.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 0f0c5b95c7c..9610bd4d20c 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -89,22 +89,21 @@ func exportify(w io.Writer, vars map[string]string) string { strb.WriteString("export ") strb.WriteString(key) strb.WriteString(`="`) - for _, r := range vars[key] { - switch r { + // Note: a newline is NOT escaped. Inside double quotes a bare + // newline is already a literal newline, whereas a backslash + // followed by a newline is a line continuation that the shell + // removes entirely. Escaping newlines would collapse a multi-line + // value onto a single line and silently corrupt it (e.g. a + // PROMPT_COMMAND containing `... 2>&1\n__bp_...` would become + // `... 2>&1__bp_...`, causing an "ambiguous redirect"). + for _, char := range vars[key] { + switch char { // Special characters inside double quotes: // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - // - // Note: a newline is NOT escaped. Inside double quotes a bare - // newline is already a literal newline, whereas a backslash - // followed by a newline is a line continuation that the shell - // removes entirely. Escaping newlines would collapse a - // multi-line value onto a single line and silently corrupt it - // (e.g. a PROMPT_COMMAND containing `... 2>&1\n__bp_...` would - // become `... 2>&1__bp_...`, causing an "ambiguous redirect"). case '$', '`', '"', '\\': strb.WriteRune('\\') } - strb.WriteRune(r) + strb.WriteRune(char) } strb.WriteString("\";\n") }