diff --git a/pkg/workflow/threat_detection_external.go b/pkg/workflow/threat_detection_external.go index 2536fa173b0..478063ee7c6 100644 --- a/pkg/workflow/threat_detection_external.go +++ b/pkg/workflow/threat_detection_external.go @@ -3,7 +3,6 @@ package workflow import ( "fmt" - "net" "slices" "strconv" "strings" @@ -25,9 +24,12 @@ func (c *Compiler) buildPrepareDetectionEngineConfigForExternalDetectorStep(data if data.SafeOutputs != nil && data.SafeOutputs.ThreatDetection != nil && data.SafeOutputs.ThreatDetection.Model != "" { detectionData.Model = data.SafeOutputs.ThreatDetection.Model } - provider := NewCodexEngine().ResolveLLMProvider(detectionData) - profile := llmProviderProfileFor(provider) - codexAPIBase := "http://" + net.JoinHostPort(constants.AWFAPIProxyContainerIP, strconv.Itoa(profile.gatewayPort)) + // Reuse the agent job's provider endpoint resolution so detection never pins + // Codex at a non-OpenAI api-proxy ingress (e.g. the Anthropic port 10001, + // which rejects Codex requests with 403 "Credentials for Anthropic ... are + // not configured"). Codex speaks the OpenAI Responses wire API, so only the + // OpenAI (10000) or Copilot (10002) ingress can serve it. + codexAPIBase := NewCodexEngine().getOpenAIProxyProviderBaseURL(detectionData) codexWSSBase := codexProxyWebsocketBaseURL(codexAPIBase) codexConfig := buildExternalDetectorCodexConfig(codexAPIBase, codexWSSBase) codexConfigDelimiter := GenerateHeredocDelimiterFromContent("CODEX_DETECTION_CONFIG", codexConfig) diff --git a/pkg/workflow/threat_detection_helpers.go b/pkg/workflow/threat_detection_helpers.go index e3f131b7d3e..2ce4eb24994 100644 --- a/pkg/workflow/threat_detection_helpers.go +++ b/pkg/workflow/threat_detection_helpers.go @@ -144,6 +144,9 @@ func buildExternalDetectorWorkflowData(data *WorkflowData, engineID string) *Wor "bash": []any{"*"}, } d.EngineConfig = resolveExternalDetectorEngineConfig(data, engineID) + if engineID == "codex" && NewCodexEngine().ResolveLLMProvider(d) != LLMProviderGitHub { + d.EngineConfig.LLMProvider = LLMProviderOpenAI + } d.EngineConfig.Env = mergeThreatDetectionEngineEnv(data, d.EngineConfig.Env) if d.EngineConfig.APITarget == "" && data.EngineConfig != nil { d.EngineConfig.APITarget = data.EngineConfig.APITarget diff --git a/pkg/workflow/threat_detection_isolation_test.go b/pkg/workflow/threat_detection_isolation_test.go index d577e621c1c..ae238cfcc2c 100644 --- a/pkg/workflow/threat_detection_isolation_test.go +++ b/pkg/workflow/threat_detection_isolation_test.go @@ -716,6 +716,85 @@ Test workflow` } } +// TestExternalDetectorCodexConfigUsesOpenAIProxyPort verifies that the detection +// config.toml pins Codex to an ingress that speaks the OpenAI Responses wire API. +// Pointing it at the Anthropic ingress (port 10001) makes every detection request +// fail with 403 "Credentials for Anthropic (port 10001) are not configured", so the +// engine never produces a verdict and the detection job fails with ERR_SYSTEM. +func TestExternalDetectorCodexConfigUsesOpenAIProxyPort(t *testing.T) { + tests := []struct { + name string + engineYAML string + }{ + { + name: "default provider", + engineYAML: "engine:\n id: codex\n", + }, + { + name: "anthropic provider override", + engineYAML: "engine:\n id: codex\n model-provider: anthropic\n", + }, + } + + openAIBaseURL := "http://" + net.JoinHostPort(constants.AWFAPIProxyContainerIP, strconv.Itoa(constants.CodexLLMGatewayPort)) + anthropicHostPort := net.JoinHostPort(constants.AWFAPIProxyContainerIP, strconv.Itoa(constants.ClaudeLLMGatewayPort)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + + tmpDir := testutil.TempDir(t, "test-external-detector-codex-port-*") + workflowPath := filepath.Join(tmpDir, "test-codex-port.md") + + workflowContent := `--- +on: push +` + tt.engineYAML + `safe-outputs: + create-issue: +features: + gh-aw-detection: true +--- +Test workflow` + + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + result, err := os.ReadFile(stringutil.MarkdownToLockFile(workflowPath)) + if err != nil { + t.Fatalf("Failed to read compiled workflow: %v", err) + } + + detectionSection := extractJobSection(string(result), "detection") + if detectionSection == "" { + t.Fatal("Detection job not found in compiled workflow") + } + + for _, key := range []string{"base_url", "api_base"} { + expected := key + ` = "` + openAIBaseURL + `"` + if !strings.Contains(detectionSection, expected) { + t.Errorf("Codex detection config must set %s to the OpenAI ingress (%s)", key, openAIBaseURL) + } + } + expectedWSS := `wss_base = "ws://` + net.JoinHostPort(constants.AWFAPIProxyContainerIP, strconv.Itoa(constants.CodexLLMGatewayPort)) + `"` + if !strings.Contains(detectionSection, expectedWSS) { + t.Errorf("Codex detection config must set wss_base to the OpenAI ingress (%s)", expectedWSS) + } + if strings.Contains(detectionSection, `base_url = "http://`+anthropicHostPort+`"`) { + t.Errorf("Codex detection config must never point at the Anthropic ingress (%s)", anthropicHostPort) + } + if !strings.Contains(detectionSection, `CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}`) { + t.Error("Codex detection execution must use the OpenAI credential expression") + } + if strings.Contains(detectionSection, `CODEX_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}`) { + t.Error("Codex detection execution must never use the Anthropic credential expression") + } + }) + } +} + // TestExternalDetectorCodexConfigModelProviderAtRoot verifies that the top-level // model_provider selector is emitted before any TOML table header ([history], // [model_providers.*]). If it appears after [history], TOML parses it as