diff --git a/api/v1alpha1/agent_types.go b/api/v1alpha1/agent_types.go index 7de83ff..396ee58 100644 --- a/api/v1alpha1/agent_types.go +++ b/api/v1alpha1/agent_types.go @@ -98,10 +98,13 @@ type AgentSpec struct { // Otherwise any non-empty value fails the agent run, for every provider: the // agent-executor's LLM API keys are process-wide, so an endpoint the operator has not // approved must not receive them. Once accepted, it is passed to gollm as - // ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider + // ClientOptions.URL; with the pinned gollm it is honored by modelProvider // "azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://); + // "anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process; // "openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead; - // "google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST. + // "local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or + // not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor + // process instead. // - skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false" // is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on // the agent-executor process (keeps TLS verification on); as a last resort, set diff --git a/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml b/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml index 143fc6b..bb6fa1b 100644 --- a/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml +++ b/charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml @@ -56,10 +56,13 @@ spec: Otherwise any non-empty value fails the agent run, for every provider: the agent-executor's LLM API keys are process-wide, so an endpoint the operator has not approved must not receive them. Once accepted, it is passed to gollm as - ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider + ClientOptions.URL; with the pinned gollm it is honored by modelProvider "azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://); + "anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process; "openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead; - "google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST. + "local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or + not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor + process instead. - skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false" is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on the agent-executor process (keeps TLS verification on); as a last resort, set diff --git a/config/crd/bases/ottoflow.nirmata.io_agents.yaml b/config/crd/bases/ottoflow.nirmata.io_agents.yaml index 143fc6b..bb6fa1b 100644 --- a/config/crd/bases/ottoflow.nirmata.io_agents.yaml +++ b/config/crd/bases/ottoflow.nirmata.io_agents.yaml @@ -56,10 +56,13 @@ spec: Otherwise any non-empty value fails the agent run, for every provider: the agent-executor's LLM API keys are process-wide, so an endpoint the operator has not approved must not receive them. Once accepted, it is passed to gollm as - ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider + ClientOptions.URL; with the pinned gollm it is honored by modelProvider "azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://); + "anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process; "openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead; - "google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST. + "local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or + not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor + process instead. - skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false" is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on the agent-executor process (keeps TLS verification on); as a last resort, set diff --git a/docs/user/reference/api/agent.md b/docs/user/reference/api/agent.md index d145cd9..d7f0df9 100644 --- a/docs/user/reference/api/agent.md +++ b/docs/user/reference/api/agent.md @@ -19,7 +19,7 @@ | `modelName` | string | No | Model identifier (e.g. `gpt-4`, `claude-3-opus`). Default depends on provider. | | `mcpTools` | []string | No | List of MCP tools the agent can use. Format: `"server:tool"` (e.g. `kubernetes-mcp:get-resource`). | | `outputExtraction` | [OutputExtraction](#outputextraction) | No | How to extract outputs from agent responses (json, regex, text). | -| `config` | map[string]string | No | Provider client options. Only `endpoint` and `skipVerifySSL` are read. `endpoint` is accepted **only** if the agent-executor operator has allowlisted its origin via `AGENT_LLM_ENDPOINT_ALLOWLIST` (Helm: `agentExecutor.llmEndpointAllowlist`), and only as an origin (`scheme://host[:port]`; a path, query, fragment or userinfo is rejected). With the allowlist empty (the default), any non-empty `endpoint` fails the agent run, for every provider, because the agent-executor's LLM API keys are shared by every Agent. Once accepted, `endpoint` is effective only for `azure-openai` (where it must be `https://`); `openai` uses `OPENAI_ENDPOINT`/`OPENAI_API_BASE` from the agent-executor environment, and `local` uses `LLAMACPP_HOST`. `skipVerifySSL: "true"` is **rejected** for every provider and fails the agent run; to reach an endpoint with a private CA, set `SSL_CERT_FILE` or `SSL_CERT_DIR` on the agent-executor process (as a last resort, `LLM_SKIP_VERIFY_SSL=true` there, a process-wide setting not scoped to one Agent). API keys are never read from `config` — they come from the agent-executor process environment. | +| `config` | map[string]string | No | Provider client options. Only `endpoint` and `skipVerifySSL` are read. `endpoint` is accepted **only** if the agent-executor operator has allowlisted its origin via `AGENT_LLM_ENDPOINT_ALLOWLIST` (Helm: `agentExecutor.llmEndpointAllowlist`), and only as an origin (`scheme://host[:port]`; a path, query, fragment or userinfo is rejected). With the allowlist empty (the default), any non-empty `endpoint` fails the agent run, for every provider, because the agent-executor's LLM API keys are shared by every Agent. Once accepted, `endpoint` is effective for `azure-openai` (where it must be `https://`); `anthropic` also honors it unless `ANTHROPIC_BASE_URL` is set; `openai` uses `OPENAI_ENDPOINT`/`OPENAI_API_BASE` from the agent-executor environment, and `local` uses `LLAMACPP_HOST`. `google`/`gemini` reject any `endpoint`, allowlisted or not, because their client ignores it; set `GOOGLE_GEMINI_BASE_URL` on the agent-executor instead. `skipVerifySSL: "true"` is **rejected** for every provider and fails the agent run; to reach an endpoint with a private CA, set `SSL_CERT_FILE` or `SSL_CERT_DIR` on the agent-executor process (as a last resort, `LLM_SKIP_VERIFY_SSL=true` there, a process-wide setting not scoped to one Agent). API keys are never read from `config` — they come from the agent-executor process environment. | | `serviceAccount` | string | No | Kubernetes service account for agent execution (RBAC). | | `serviceName` | string | No | Name of the AgentExecutor Service. Default: `ottoflow-agent-executor`. | | `serviceNamespace` | string | No | Namespace of the AgentExecutor Service. Default: `ottoflow`. | diff --git a/docs/user/reference/api/api-docs.md b/docs/user/reference/api/api-docs.md index 73e4658..50f9d01 100644 --- a/docs/user/reference/api/api-docs.md +++ b/docs/user/reference/api/api-docs.md @@ -78,7 +78,7 @@ _Appears in:_ | `serviceName` _string_ | ServiceName is the name of the AgentExecutor Service
If not specified, uses default: ottoflow-agent-executor | | Optional: \{\}
| | `serviceNamespace` _string_ | ServiceNamespace is the namespace of the AgentExecutor Service
Defaults to ottoflow | | Optional: \{\}
| | `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ | Resources defines resource requests/limits for agent execution
Currently used for future sandbox mode
All steps using this agent use these resources | | Optional: \{\}
| -| `config` _object (keys:string, values:string)_ | Config contains provider-specific client options. The executor reads exactly
two keys (internal/agent/default_executor.go):
- endpoint: accepted ONLY if the agent-executor operator has allowlisted its origin
via AGENT_LLM_ENDPOINT_ALLOWLIST (Helm: agentExecutor.llmEndpointAllowlist), and only
as an origin ("scheme://host[:port]"; a path, query, fragment or userinfo is rejected).
Otherwise any non-empty value fails the agent run, for every provider: the
agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
approved must not receive them. Once accepted, it is passed to gollm as
ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider
"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
"google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST.
- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
the agent-executor process (keeps TLS verification on); as a last resort, set
LLM_SKIP_VERIFY_SSL=true on that process, a process-wide setting not scoped to one Agent.
API keys are NOT read from here; they come from the agent-executor process
environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AZURE_OPENAI_API_KEY). | | Optional: \{\}
| +| `config` _object (keys:string, values:string)_ | Config contains provider-specific client options. The executor reads exactly
two keys (internal/agent/default_executor.go):
- endpoint: accepted ONLY if the agent-executor operator has allowlisted its origin
via AGENT_LLM_ENDPOINT_ALLOWLIST (Helm: agentExecutor.llmEndpointAllowlist), and only
as an origin ("scheme://host[:port]"; a path, query, fragment or userinfo is rejected).
Otherwise any non-empty value fails the agent run, for every provider: the
agent-executor's LLM API keys are process-wide, so an endpoint the operator has not
approved must not receive them. Once accepted, it is passed to gollm as
ClientOptions.URL; with the pinned gollm it is honored by modelProvider
"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);
"anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process;
"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;
"local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or
not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor
process instead.
- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"
is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on
the agent-executor process (keeps TLS verification on); as a last resort, set
LLM_SKIP_VERIFY_SSL=true on that process, a process-wide setting not scoped to one Agent.
API keys are NOT read from here; they come from the agent-executor process
environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AZURE_OPENAI_API_KEY). | | Optional: \{\}
| #### AgentStatus diff --git a/internal/agent/default_executor.go b/internal/agent/default_executor.go index 15db1bc..750d793 100644 --- a/internal/agent/default_executor.go +++ b/internal/agent/default_executor.go @@ -138,6 +138,14 @@ func (e *DefaultAgentExecutor) createLLMClient(ctx context.Context, agentCRD *ot // verification. Both checks run before any client exists. var opts []gollm.Option if endpoint := agentCRD.Spec.Config["endpoint"]; endpoint != "" { + // gollm's gemini client never reads ClientOptions.URL. + if provider == providerGoogle || provider == providerGemini { + return nil, fmt.Errorf("agent endpoint rejected: spec.config.endpoint (%q) is not permitted for "+ + "provider %s: %s", endpoint, provider, + fmt.Sprintf("spec.config.endpoint and %s have no effect for this provider — set "+ + "GOOGLE_GEMINI_BASE_URL in the agent-executor process environment instead (Helm: "+ + "agentExecutor.env)", AgentLLMEndpointAllowlistEnv)) + } parsed, err := allowedLLMEndpoint(endpoint) if err != nil { return nil, fmt.Errorf("agent endpoint rejected: spec.config.endpoint (%q) is not permitted for "+ diff --git a/internal/agent/default_executor_test.go b/internal/agent/default_executor_test.go index db8789b..1b28ffa 100644 --- a/internal/agent/default_executor_test.go +++ b/internal/agent/default_executor_test.go @@ -80,6 +80,8 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { Describe("spec.config.endpoint / skipVerifySSL: operator allowlist gate", func() { allProviders := []string{"anthropic", "azure-openai", "openai", "google", "gemini", "local"} + nonGeminiProviders := []string{"anthropic", "azure-openai", "openai", "local"} + geminiProviders := []string{"google", "gemini"} newAgent := func(provider string, config map[string]string) *ottoflowv1alpha1.Agent { return &ottoflowv1alpha1.Agent{ @@ -122,7 +124,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { It("default closed: an unset or empty-ish allowlist refuses every provider", func() { for _, envVal := range []string{"", " ", ",", " , ", ",,", "\t\n"} { GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, envVal) - assertRefused(allProviders, map[string]string{"endpoint": "https://unlisted.example.com"}, + assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://unlisted.example.com"}, "allowlisted no LLM endpoints") } }) @@ -145,7 +147,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { DescribeTable("refuses an endpoint whose origin differs from the allowlisted one", func(endpoint string) { GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example") - assertRefused(allProviders, map[string]string{"endpoint": endpoint}, "not in the operator's allowlist") + assertRefused(nonGeminiProviders, map[string]string{"endpoint": endpoint}, "not in the operator's allowlist") }, Entry("host suffix", "https://llm.corp.example.other.example"), Entry("scheme", "http://llm.corp.example"), @@ -155,14 +157,14 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { It("refuses userinfo on an allowlisted host", func() { GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example") - assertRefused(allProviders, map[string]string{"endpoint": "https://other.example@llm.corp.example"}, + assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://other.example@llm.corp.example"}, "userinfo is not permitted") }) It("refuses a path, query or fragment on an allowlisted origin", func() { GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example") for _, ep := range []string{"https://llm.corp.example/v1", "https://llm.corp.example?x=1", "https://llm.corp.example#f"} { - assertRefused(allProviders, map[string]string{"endpoint": ep}, "path/query/fragment are not permitted") + assertRefused(nonGeminiProviders, map[string]string{"endpoint": ep}, "path/query/fragment are not permitted") } }) @@ -177,7 +179,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { err := ValidateLLMEndpointAllowlist() Expect(err).To(HaveOccurred(), entry) Expect(err.Error()).To(ContainSubstring("origin only"), entry) - assertRefused(allProviders, map[string]string{"endpoint": "https://llm.corp.example"}, "origin only") + assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://llm.corp.example"}, "origin only") } }) @@ -195,7 +197,7 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { " https://llm.corp.example , , ", } { GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, envVal) - assertRefused(allProviders, map[string]string{"endpoint": "https://definitely-not-listed.invalid"}, + assertRefused(nonGeminiProviders, map[string]string{"endpoint": "https://definitely-not-listed.invalid"}, "not in the operator's allowlist") } }) @@ -215,12 +217,20 @@ var _ = Describe("DefaultAgentExecutor createLLMClient", func() { It("refuses skipVerifySSL=true for every provider, with or without an allowlisted endpoint", func() { GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, "https://llm.corp.example") assertRefused(allProviders, map[string]string{"skipVerifySSL": "true"}, "no longer honored") - assertRefused(allProviders, map[string]string{ + assertRefused(nonGeminiProviders, map[string]string{ "endpoint": "https://llm.corp.example", "skipVerifySSL": "true", }, "no longer honored") }) + It("refuses a google/gemini endpoint before the allowlist is consulted", func() { + for _, allow := range []string{"", "https://llm.corp.example/v1", "https://llm.corp.example"} { + GinkgoT().Setenv(AgentLLMEndpointAllowlistEnv, allow) + assertRefused(geminiProviders, map[string]string{"endpoint": "https://llm.corp.example"}, + "GOOGLE_GEMINI_BASE_URL") + } + }) + It("keeps skipVerifySSL=\"false\" legal", func() { factory := &mockLLMClientFactory{client: &mockGollmClient{}} e := NewDefaultAgentExecutorWithLLMFactory(nil, factory) diff --git a/internal/webhook/agent_mcpserver_validators.go b/internal/webhook/agent_mcpserver_validators.go index 35c7fe2..3b49801 100644 --- a/internal/webhook/agent_mcpserver_validators.go +++ b/internal/webhook/agent_mcpserver_validators.go @@ -65,9 +65,11 @@ func validateAgentConfig(a *ottoflowv1alpha1.Agent) admission.Warnings { warnings = append(warnings, fmt.Sprintf( "spec.config.skipVerifySSL must be \"true\" or \"false\"; got %q, which is treated as false", val)) case k == "endpoint" && val != "": - warnings = append(warnings, "spec.config.endpoint is honored only if it is origin-only "+ - "(scheme://host[:port]) and the agent-executor operator has allowlisted that origin "+ - "(AGENT_LLM_ENDPOINT_ALLOWLIST / Helm agentExecutor.llmEndpointAllowlist); otherwise the agent run will FAIL.") + warnings = append(warnings, "spec.config.endpoint is honored only for providers that read it (anthropic, azure-openai), "+ + "and only if it is origin-only (no path, query or fragment) and the agent-executor operator "+ + "has allowlisted its origin (AGENT_LLM_ENDPOINT_ALLOWLIST / Helm "+ + "agentExecutor.llmEndpointAllowlist). Otherwise the agent run will FAIL. "+ + "google, gemini and nirmata always FAIL when it is set; openai and local ignore it.") } } diff --git a/internal/webhook/agent_validator_test.go b/internal/webhook/agent_validator_test.go index 429cb88..db321fc 100644 --- a/internal/webhook/agent_validator_test.go +++ b/internal/webhook/agent_validator_test.go @@ -51,7 +51,7 @@ func TestAgentValidatorConfigWarnings(t *testing.T) { name: "endpoint is reported as allowlist-gated", config: map[string]string{"endpoint": "https://llm.example.com"}, wantCount: 1, - wantContains: []string{"endpoint", "AGENT_LLM_ENDPOINT_ALLOWLIST", "origin-only", "FAIL"}, + wantContains: []string{"endpoint", "AGENT_LLM_ENDPOINT_ALLOWLIST", "origin-only", "FAIL", "google, gemini"}, }, { // The CRD doc-comment advertises apiKey/baseURL, but no executor in this build diff --git a/samples/workflows/features/agent-custom-endpoint.yaml b/samples/workflows/features/agent-custom-endpoint.yaml index a89ca94..b02da25 100644 --- a/samples/workflows/features/agent-custom-endpoint.yaml +++ b/samples/workflows/features/agent-custom-endpoint.yaml @@ -13,7 +13,7 @@ # # Listing an origin trusts it with the agent-executor's LLM credentials: any # Agent may name a listed origin, and every provider client that uses the -# endpoint (with the pinned gollm, azure-openai) sends its credential there +# endpoint (with the pinned gollm, azure-openai and anthropic) sends its credential there # together with the full prompt. Do not allowlist a host you do not control. # # Agent.spec.config effectiveness by provider, once an origin IS allowlisted: @@ -26,7 +26,10 @@ # OPENAI_ENDPOINT or OPENAI_API_BASE process environment variable # (set via the agent-executor pod's env, e.g. Helm # `agentExecutor.env`). -# * gemini / anthropic: IGNORED entirely. +# * anthropic: honored unless the agent-executor sets ANTHROPIC_BASE_URL. +# * gemini / google: REJECTED, even when the origin is allowlisted, +# because the client ignores the field. Set GOOGLE_GEMINI_BASE_URL on +# the agent-executor process instead (Helm `agentExecutor.env`). # * local: IGNORED — the local (llama.cpp) provider uses LLAMACPP_HOST # instead (see agent-local-llamacpp.yaml). # - skipVerifySSL: