@@ -159,23 +159,50 @@ func TestResolvedEnv_RealShell_Deterministic(t *testing.T) {
159159 require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
160160}
161161
162−// TestResolvedEnv_RealShell_NounsetRegression is the single most
163−// important assertion in this file: an unset variable is an error, not
164−// an empty expansion. Swapping the hand-rolled parser for mvdan's
165−// default-expansion behaviour without nounset would re-introduce
166−// Defect 1 via a typo'd variable name.
167−func TestResolvedEnv_RealShell_NounsetRegression(t *testing.T) {
162+// TestResolvedEnv_RealShell_UnsetExpandsEmpty pins Phase 2's lenient
163+// default: an unset bare $VAR expands to the empty string, matching
164+// bash. The silent-empty-credential class of bug that motivated Phase
165+// 1's nounset-on default is already prevented by the pure-function
166+// error-returning contract of ResolvedEnv, so we no longer rely on
167+// nounset to catch typo'd variable names. Users who want strict
168+// behaviour for a required credential opt in per-reference with
169+// ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
170+func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
168171 t.Parallel()
169172
170173 m := MCPConfig{Env: map[string]string{
171− // Intentional typo: user meant $FORGEJO_TOKEN.
174+ // Intentional typo: user meant $FORGEJO_TOKEN. Under Phase 2
175+ // defaults this expands to "" rather than erroring, matching
176+ // bash's behaviour on bare $VAR.
172177 "FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
173178 }}
174179 got, err := m.ResolvedEnv(realShellResolver(nil))
175− require.Error(t, err, "unset var must not silently expand to empty")
180+ require.NoError(t, err, "unset var must expand to empty, not error")
181+ require.Equal(t, []string{"FORGEJO_ACCESS_TOKEN="}, got)
182+}
183+
184+// TestResolvedEnv_RealShell_ColonQuestionIsStrict pins the opt-in
185+// strictness contract: ${VAR:?msg} must hard-error when VAR is unset,
186+// regardless of the global NoUnset toggle. This is the recommended
187+// mechanism for required credentials under the lenient default, so a
188+// future refactor that accidentally swallows ${VAR:?...} errors would
189+// silently ship empty tokens to MCP servers again.
190+func TestResolvedEnv_RealShell_ColonQuestionIsStrict(t *testing.T) {
191+ t.Parallel()
192+
193+ m := MCPConfig{Env: map[string]string{
194+ "FORGEJO_ACCESS_TOKEN": "${FORGJO_TOKEN:?set FORGJO_TOKEN}",
195+ }}
196+ got, err := m.ResolvedEnv(realShellResolver(nil))
197+ require.Error(t, err, "${VAR:?msg} must error when VAR is unset")
176198 require.Nil(t, got)
177− require.Contains(t, err.Error(), "FORGEJO_ACCESS_TOKEN")
178− require.Contains(t, err.Error(), "$FORGJO_TOKEN")
199+ // The resolver wraps with the env key and the user-written
200+ // template; the inner shell error carries the :? message so
201+ // users learn which credential is missing and why.
202+ msg := err.Error()
203+ require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN")
204+ require.Contains(t, msg, "${FORGJO_TOKEN:?set FORGJO_TOKEN}")
205+ require.Contains(t, msg, "set FORGJO_TOKEN")
179206}
180207
181208// TestResolvedEnv_RealShell_FailureDetail pins that a failing inner