Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion internal/keyconv/keyconv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions internal/keyconv/keyconv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading