diff --git a/internal/proxy/codex_desktop_normalize.go b/internal/proxy/codex_desktop_normalize.go index 9b0e10684..e7f3433b2 100644 --- a/internal/proxy/codex_desktop_normalize.go +++ b/internal/proxy/codex_desktop_normalize.go @@ -63,16 +63,53 @@ func normalizeFullAccessExecTool(body []byte) ([]byte, error) { if !ok { return body, nil } + encodedTools, changed, err := normalizeFullAccessTools(rawTools) + if err != nil { + return nil, err + } + if !changed { + return body, nil + } + payload["tools"] = encodedTools + encoded, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode request: %w", err) + } + return encoded, nil +} + +// normalizeFullAccessTools preserves namespace declarations while rewriting +// their member functions using the same contract as top-level functions. +func normalizeFullAccessTools(rawTools json.RawMessage) (json.RawMessage, bool, error) { + if len(rawTools) == 0 { + return rawTools, false, nil + } var tools []json.RawMessage if err := json.Unmarshal(rawTools, &tools); err != nil { - return nil, fmt.Errorf("decode tools: %w", err) + return nil, false, fmt.Errorf("decode tools: %w", err) } changed := false for i, rawTool := range tools { var tool map[string]json.RawMessage if err := json.Unmarshal(rawTool, &tool); err != nil { - return nil, fmt.Errorf("decode tool: %w", err) + return nil, false, fmt.Errorf("decode tool: %w", err) + } + var toolType string + if err := json.Unmarshal(tool["type"], &toolType); err == nil && toolType == "namespace" { + members, membersChanged, err := normalizeFullAccessTools(tool["tools"]) + if err != nil { + return nil, false, fmt.Errorf("normalize namespace tools: %w", err) + } + if membersChanged { + tool["tools"] = members + tools[i], err = json.Marshal(tool) + if err != nil { + return nil, false, fmt.Errorf("encode namespace tool: %w", err) + } + changed = true + } + continue } var name string if err := json.Unmarshal(tool["name"], &name); err != nil || name != "exec_command" { @@ -81,11 +118,11 @@ func normalizeFullAccessExecTool(body []byte) ([]byte, error) { var parameters map[string]json.RawMessage if err := json.Unmarshal(tool["parameters"], ¶meters); err != nil { - return nil, fmt.Errorf("decode exec_command parameters: %w", err) + return nil, false, fmt.Errorf("decode exec_command parameters: %w", err) } var properties map[string]json.RawMessage if err := json.Unmarshal(parameters["properties"], &properties); err != nil { - return nil, fmt.Errorf("decode exec_command properties: %w", err) + return nil, false, fmt.Errorf("decode exec_command properties: %w", err) } toolChanged := false for _, property := range []string{"sandbox_permissions", "justification", "prefix_rule"} { @@ -101,45 +138,40 @@ func normalizeFullAccessExecTool(body []byte) ([]byte, error) { encodedProperties, err := json.Marshal(properties) if err != nil { - return nil, fmt.Errorf("encode exec_command properties: %w", err) + return nil, false, fmt.Errorf("encode exec_command properties: %w", err) } parameters["properties"] = encodedProperties if rawRequired, ok := parameters["required"]; ok { var required []string if err := json.Unmarshal(rawRequired, &required); err != nil { - return nil, fmt.Errorf("decode exec_command required properties: %w", err) + return nil, false, fmt.Errorf("decode exec_command required properties: %w", err) } required = slices.DeleteFunc(required, func(property string) bool { return property == "sandbox_permissions" || property == "justification" || property == "prefix_rule" }) parameters["required"], err = json.Marshal(required) if err != nil { - return nil, fmt.Errorf("encode exec_command required properties: %w", err) + return nil, false, fmt.Errorf("encode exec_command required properties: %w", err) } } tool["parameters"], err = json.Marshal(parameters) if err != nil { - return nil, fmt.Errorf("encode exec_command parameters: %w", err) + return nil, false, fmt.Errorf("encode exec_command parameters: %w", err) } tools[i], err = json.Marshal(tool) if err != nil { - return nil, fmt.Errorf("encode exec_command tool: %w", err) + return nil, false, fmt.Errorf("encode exec_command tool: %w", err) } } if !changed { - return body, nil + return rawTools, false, nil } encodedTools, err := json.Marshal(tools) if err != nil { - return nil, fmt.Errorf("encode tools: %w", err) + return nil, false, fmt.Errorf("encode tools: %w", err) } - payload["tools"] = encodedTools - encoded, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("encode request: %w", err) - } - return encoded, nil + return encodedTools, true, nil } func codexSandboxMode(body []byte) string { diff --git a/internal/proxy/codex_desktop_test.go b/internal/proxy/codex_desktop_test.go index bfd01db4f..3f2eb5fb8 100644 --- a/internal/proxy/codex_desktop_test.go +++ b/internal/proxy/codex_desktop_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -379,6 +380,76 @@ func TestNormalizeFullAccessExecToolLeavesSandboxedTurnUnchanged(t *testing.T) { } } +func TestNormalizeFullAccessNamespacedExecTool(t *testing.T) { + const tools = `[{"type":"namespace","name":"functions","description":"Command tools","tools":[ + {"type":"function","name":"exec_command","strict":false,"parameters":{"type":"object","properties":{"cmd":{"type":"string"},"sandbox_permissions":{"type":"string","enum":["use_default","require_escalated"]},"justification":{"type":"string"},"prefix_rule":{"type":"array","items":{"type":"string"}}},"required":["cmd","sandbox_permissions","justification","prefix_rule"],"additionalProperties":false}}, + {"type":"function","name":"other_tool","parameters":{"type":"object","properties":{"sandbox_permissions":{"type":"string"}}}} + ]}]` + for _, mode := range []string{"danger-full-access", "workspace-write", "read-only", ""} { + t.Run(mode, func(t *testing.T) { + metadata, err := json.Marshal(map[string]string{"sandbox_mode": mode}) + if err != nil { + t.Fatal(err) + } + body := []byte(fmt.Sprintf(`{"model":"glm-5.3-flash:cloud","client_metadata":{"x-codex-turn-metadata":%q},"tools":%s}`, metadata, tools)) + normalized, err := normalizeFullAccessExecTool(body) + if err != nil { + t.Fatal(err) + } + if mode != "danger-full-access" { + if !bytes.Equal(normalized, body) { + t.Fatalf("sandboxed request changed: %s", normalized) + } + return + } + var want, got map[string]any + if err := json.Unmarshal(body, &want); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(normalized, &got); err != nil { + t.Fatal(err) + } + namespace := want["tools"].([]any)[0].(map[string]any) + exec := namespace["tools"].([]any)[0].(map[string]any) + parameters := exec["parameters"].(map[string]any) + properties := parameters["properties"].(map[string]any) + for _, key := range []string{"sandbox_permissions", "justification", "prefix_rule"} { + delete(properties, key) + } + parameters["required"] = []any{"cmd"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("namespace tool normalization did not preserve the expected request: %s", normalized) + } + }) + } +} + +func TestNormalizeFullAccessNamespaceBoundaries(t *testing.T) { + for _, tt := range []struct { + name string + tools string + wantErr bool + }{ + {"missing members", `[{"type":"namespace","name":"functions"}]`, false}, + {"null members", `[{"type":"namespace","name":"functions","tools":null}]`, false}, + {"empty members", `[{"type":"namespace","name":"functions","tools":[]}]`, false}, + {"no escalation arguments", `[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"exec_command","parameters":{"properties":{"cmd":{"type":"string"}},"required":["cmd"]}}]}]`, false}, + {"malformed members", `[{"type":"namespace","name":"functions","tools":{}}]`, true}, + {"malformed parameters", `[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"exec_command","parameters":42}]}]`, true}, + } { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"client_metadata":{"x-codex-turn-metadata":{"sandbox_mode":"danger-full-access"}},"tools":` + tt.tools + `}`) + normalized, err := normalizeFullAccessExecTool(body) + if (err != nil) != tt.wantErr { + t.Fatalf("normalization error = %v, want error = %v", err, tt.wantErr) + } + if !tt.wantErr && !bytes.Equal(normalized, body) { + t.Fatalf("unchanged namespace was rewritten: %s", normalized) + } + }) + } +} + func TestLoadCatalogModelsReadsOptionalThinkingMetadata(t *testing.T) { path := filepath.Join(t.TempDir(), CodexDesktopModelCatalogFilename) data := []byte(`{"models":[{"slug":"legacy"},{"slug":"binary","thinking":{"supported":true,"levels":["none","medium"],"values":{"none":false,"medium":true}}}]}`)