@@ -1590,18 +1590,13 @@ func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T)
1590 require.Equal(t, "env-api-key", pc.APIKey)
1590 require.Equal(t, "env-api-key", pc.APIKey)
1593−// TestConfig_configureProviders_ProviderHeaderResolveFailure pins the
1593+// TestConfig_configureProviders_ProviderHeaderResolveError pins
1594−// intentional divergence at load.go:225: a provider header whose
1594+// Phase 2 design decision #14: a failing $(cmd) in a provider header
1595−// expansion fails must NOT fail the whole config load. It logs an
1595+// must fail the provider load with a clear message that names the
1596−// error, keeps the literal template in the resolved header map, and
1596+// offending header. The Phase 1 log-and-continue divergence at
1597−// moves on. The MCP contract (hard error on failed expansion) does not
1597+// load.go:225 is gone; provider headers now share the MCP error
1598−// apply here because many providers ship DefaultHeaders that reference
1598+// contract.
1599−// env vars which are legitimately unset on most hosts.
1599+func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1601−// If this test ever flips to requiring an error, read PLAN.md "Design
1602−// decisions" item 4 before changing the production code — the
1603−// divergence is deliberate.
1604−func TestConfig_configureProviders_ProviderHeaderResolveFailure(t *testing.T) {
1605 knownProviders := []catwalk.Provider{
1600 knownProviders := []catwalk.Provider{
1607 ID: "openai",
1602 ID: "openai",
@@ -1615,17 +1610,9 @@ func TestConfig_configureProviders_ProviderHeaderResolveFailure(t *testing.T) {
1615 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1610 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1616 "openai": {
1611 "openai": {
1617 ExtraHeaders: map[string]string{
1612 ExtraHeaders: map[string]string{
1618− // Failing $(...) — inner command exits 1, no stdout.
1613+ // Failing $(...) — inner command exits 1. Must
1614+ // propagate as an error, not a silent truncation.
1619 "X-Broken": "$(false)",
1615 "X-Broken": "$(false)",
1620− // Unset var — under Phase 2 lenient nounset this
1621− // resolves cleanly to "" rather than erroring;
1622− // Step 10 will rewrite this whole test once the
1623− // empty-drop rule replaces the log-and-continue
1624− // loop at load.go:225.
1625− "X-Missing": "$PROVIDER_HEADER_NEVER_SET",
1626− // Happy path: must still be resolved, proving the
1627− // failure in X-Broken did not abort the loop.
1628− "X-Static": "kept-literal",
@@ -1639,20 +1626,117 @@ func TestConfig_configureProviders_ProviderHeaderResolveFailure(t *testing.T) {
1639 resolver := NewShellVariableResolver(testEnv)
1626 resolver := NewShellVariableResolver(testEnv)
1641 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1628 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1642− require.NoError(t, err, "provider load must tolerate failing header expansion")
1629+ require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
1630+ require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1633+// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads pins
1634+// Phase 2 design decisions #11 and #18 from the provider angle: a
1635+// Catwalk-style default header like
1636+// "OpenAI-Organization": "$OPENAI_ORG_ID" must load cleanly under
1637+// lenient nounset (unset → "" → header dropped), not fail the load
1638+// and not leave the literal template on the wire.
1639+func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1640+ knownProviders := []catwalk.Provider{
1643+ APIKey: "$OPENAI_API_KEY",
1644+ APIEndpoint: "https://api.openai.com/v1",
1645+ Models: []catwalk.Model{{ID: "test-model"}},
1646+ DefaultHeaders: map[string]string{
1647+ "OpenAI-Organization": "$OPENAI_ORG_ID",
1653+ cfg.setDefaults("/tmp", "")
1655+ testEnv := env.NewFromMap(map[string]string{
1656+ "OPENAI_API_KEY": "test-key",
1657+ "PATH": os.Getenv("PATH"),
1659+ resolver := NewShellVariableResolver(testEnv)
1661+ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1662+ require.NoError(t, err, "optional env-gated header must not fail the load")
1644 pc, ok := cfg.Providers.Get("openai")
1664 pc, ok := cfg.Providers.Get("openai")
1645 require.True(t, ok, "openai provider must still be configured")
1665 require.True(t, ok, "openai provider must still be configured")
1666+ _, present := pc.ExtraHeaders["OpenAI-Organization"]
1667+ require.False(t, present, "header whose value resolves to empty must be absent")
1670+// TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
1671+// decision #18 for the literal case: a user-authored
1672+// "X-Custom": "" in extra_headers is absent from the resolved map.
1673+// Applies to both known- and custom-provider paths; this test
1674+// exercises the custom-provider loop.
1675+func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
1677+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1679+ APIKey: "test-key",
1680+ BaseURL: "https://my-llm.example.com/v1",
1681+ Type: catwalk.TypeOpenAI,
1682+ Models: []catwalk.Model{{ID: "m"}},
1683+ ExtraHeaders: map[string]string{
1685+ "X-Kept": "present",
1690+ cfg.setDefaults("/tmp", "")
1647− // X-Broken still errors on $(false) so its literal template is
1692+ testEnv := env.NewFromMap(map[string]string{
1648− // preserved by the log-and-continue loop. X-Missing resolves
1693+ "PATH": os.Getenv("PATH"),
1649− // cleanly to "" under lenient nounset: the current divergence
1694+ })
1650− // only kicks in on resolver errors, so a non-erroring empty
1695+ resolver := NewShellVariableResolver(testEnv)
1651− // expansion writes through. The happy-path header is resolved
1696+
1652− // through the shell (pass-through for a literal value). Step 10
1697+ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, []catwalk.Provider{})
1653− // will flip this whole surface to the MCP contract plus the
1698+ require.NoError(t, err)
1654− // empty-drop rule from design decision #18.
1699+
1655− require.Equal(t, "$(false)", pc.ExtraHeaders["X-Broken"])
1700+ pc, ok := cfg.Providers.Get("my-llm")
1701+ require.True(t, ok)
1702+ _, present := pc.ExtraHeaders["X-Custom"]
1703+ require.False(t, present, "literal empty-string header must be dropped")
1704+ require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1707+// TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
1708+// decision #18 for the non-failing empty case: $(echo) exits 0 with
1709+// empty output, resolves cleanly to "", and must be dropped the same
1710+// way an unset bare $VAR is. Exercises the known-provider loop.
1711+func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
1712+ knownProviders := []catwalk.Provider{
1715+ APIKey: "$OPENAI_API_KEY",
1716+ APIEndpoint: "https://api.openai.com/v1",
1717+ Models: []catwalk.Model{{ID: "test-model"}},
1718+ DefaultHeaders: map[string]string{
1719+ "X-Empty": "$(echo)",
1720+ "X-Kept": "present",
1726+ cfg.setDefaults("/tmp", "")
1728+ testEnv := env.NewFromMap(map[string]string{
1729+ "OPENAI_API_KEY": "test-key",
1730+ "PATH": os.Getenv("PATH"),
1732+ resolver := NewShellVariableResolver(testEnv)
1734+ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1735+ require.NoError(t, err)
1737+ pc, ok := cfg.Providers.Get("openai")
1738+ require.True(t, ok)
1656− require.Equal(t, "", pc.ExtraHeaders["X-Missing"])
1739+ _, present := pc.ExtraHeaders["X-Empty"]
1740+ require.False(t, present, "$(echo) → empty → header must be dropped")
1657− require.Equal(t, "kept-literal", pc.ExtraHeaders["X-Static"])
1741+ require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])