diff --git a/internal/keyconv/keyconv.go b/internal/keyconv/keyconv.go index 1606c52..15faf63 100644 --- a/internal/keyconv/keyconv.go +++ b/internal/keyconv/keyconv.go @@ -18,11 +18,31 @@ func SnakeToCamel(m map[string]any) map[string]any { return transformKeys(m, snakeToCamel) } +// passthroughKey reports whether a field's VALUE is a user-defined key/value map +// (env var names, HTTP header names) whose nested keys are data — not gcplane +// field names — and must be preserved verbatim. Converting them corrupts the +// keys: camelToSnake("GH_TOKEN") = "gh_token", which the gh CLI never reads, and +// likewise mangles MCP header names and workstation defaultEnv keys. +func passthroughKey(k string) bool { + switch k { + case "env", "encrypted_env", "encryptedEnv", "headers", "defaultEnv", "default_env": + return true + } + return false +} + // transformKeys recursively applies a key transformation function to all map keys. +// The KEY of a passthrough field is still converted (e.g. encryptedEnv↔encrypted_env), +// but its VALUE map's keys are left verbatim. func transformKeys(m map[string]any, fn func(string) string) map[string]any { out := make(map[string]any, len(m)) for k, v := range m { - out[fn(k)] = transformValue(v, fn) + nk := fn(k) + if passthroughKey(k) || passthroughKey(nk) { + out[nk] = v + } else { + out[nk] = transformValue(v, fn) + } } return out } diff --git a/internal/keyconv/keyconv_test.go b/internal/keyconv/keyconv_test.go index 588505b..b372986 100644 --- a/internal/keyconv/keyconv_test.go +++ b/internal/keyconv/keyconv_test.go @@ -170,3 +170,40 @@ func TestRoundTrip(t *testing.T) { t.Errorf("round-trip failed: got %v, want %v", roundTripped, original) } } + +// Env var names, HTTP header names, and defaultEnv keys are user data, not field +// names — they must survive conversion verbatim (regression: GH_TOKEN was +// snake-cased to gh_token, which the gh CLI never reads). +func TestKeyConv_PreservesPassthroughMapKeys(t *testing.T) { + in := map[string]any{ + "binaryName": "gh", + "env": map[string]any{"GH_TOKEN": "x", "GITHUB_API_URL": "y"}, + "encryptedEnv": map[string]any{"SECRET_KEY": "z"}, + "headers": map[string]any{"Authorization": "Bearer t", "X-API-Key": "k"}, + "defaultEnv": map[string]any{"MY_SECRET": "s"}, + } + out := CamelToSnake(in) + + if _, ok := out["binary_name"]; !ok { + t.Error("binaryName should convert to binary_name") + } + check := func(field, key string) { + m, ok := out[field].(map[string]any) + if !ok { + t.Fatalf("%s missing/not a map: %v", field, out[field]) + } + if _, ok := m[key]; !ok { + t.Errorf("%s key %q must be preserved verbatim, got %v", field, key, m) + } + } + check("env", "GH_TOKEN") + check("env", "GITHUB_API_URL") + check("encrypted_env", "SECRET_KEY") // field name converts; nested keys don't + check("headers", "Authorization") + check("headers", "X-API-Key") + check("default_env", "MY_SECRET") + + if env := SnakeToCamel(out)["env"].(map[string]any); env["GH_TOKEN"] == nil { + t.Errorf("round-trip lost env GH_TOKEN: %v", env) + } +}