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
7 changes: 5 additions & 2 deletions api/v1alpha1/agent_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions charts/ottoflow/crds/ottoflow.nirmata.io_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions config/crd/bases/ottoflow.nirmata.io_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/user/reference/api/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
2 changes: 1 addition & 1 deletion docs/user/reference/api/api-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ _Appears in:_
| `serviceName` _string_ | ServiceName is the name of the AgentExecutor Service<br />If not specified, uses default: ottoflow-agent-executor | | Optional: \{\} <br /> |
| `serviceNamespace` _string_ | ServiceNamespace is the namespace of the AgentExecutor Service<br />Defaults to ottoflow | | Optional: \{\} <br /> |
| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)_ | Resources defines resource requests/limits for agent execution<br />Currently used for future sandbox mode<br />All steps using this agent use these resources | | Optional: \{\} <br /> |
| `config` _object (keys:string, values:string)_ | Config contains provider-specific client options. The executor reads exactly<br />two keys (internal/agent/default_executor.go):<br />- endpoint: accepted ONLY if the agent-executor operator has allowlisted its origin<br />via AGENT_LLM_ENDPOINT_ALLOWLIST (Helm: agentExecutor.llmEndpointAllowlist), and only<br />as an origin ("scheme://host[:port]"; a path, query, fragment or userinfo is rejected).<br />Otherwise any non-empty value fails the agent run, for every provider: the<br />agent-executor's LLM API keys are process-wide, so an endpoint the operator has not<br />approved must not receive them. Once accepted, it is passed to gollm as<br />ClientOptions.URL; with the pinned gollm it is honored ONLY by modelProvider<br />"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);<br />"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;<br />"google", "gemini" and "anthropic" ignore it; "local" uses LLAMACPP_HOST.<br />- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"<br />is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on<br />the agent-executor process (keeps TLS verification on); as a last resort, set<br />LLM_SKIP_VERIFY_SSL=true on that process, a process-wide setting not scoped to one Agent.<br />API keys are NOT read from here; they come from the agent-executor process<br />environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AZURE_OPENAI_API_KEY). | | Optional: \{\} <br /> |
| `config` _object (keys:string, values:string)_ | Config contains provider-specific client options. The executor reads exactly<br />two keys (internal/agent/default_executor.go):<br />- endpoint: accepted ONLY if the agent-executor operator has allowlisted its origin<br />via AGENT_LLM_ENDPOINT_ALLOWLIST (Helm: agentExecutor.llmEndpointAllowlist), and only<br />as an origin ("scheme://host[:port]"; a path, query, fragment or userinfo is rejected).<br />Otherwise any non-empty value fails the agent run, for every provider: the<br />agent-executor's LLM API keys are process-wide, so an endpoint the operator has not<br />approved must not receive them. Once accepted, it is passed to gollm as<br />ClientOptions.URL; with the pinned gollm it is honored by modelProvider<br />"azure-openai" (overrides AZURE_OPENAI_ENDPOINT; the endpoint must be https://);<br />"anthropic" honors it unless ANTHROPIC_BASE_URL is set on the process;<br />"openai" reads OPENAI_ENDPOINT/OPENAI_API_BASE from the process environment instead;<br />"local" uses LLAMACPP_HOST. "google" and "gemini" reject any endpoint, allowlisted or<br />not, because their client ignores it; set GOOGLE_GEMINI_BASE_URL on the agent-executor<br />process instead.<br />- skipVerifySSL: "true" is REJECTED for every provider and fails the agent run; "false"<br />is accepted. To reach an endpoint with a private CA, set SSL_CERT_FILE or SSL_CERT_DIR on<br />the agent-executor process (keeps TLS verification on); as a last resort, set<br />LLM_SKIP_VERIFY_SSL=true on that process, a process-wide setting not scoped to one Agent.<br />API keys are NOT read from here; they come from the agent-executor process<br />environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AZURE_OPENAI_API_KEY). | | Optional: \{\} <br /> |


#### AgentStatus
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/default_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "+
Expand Down
24 changes: 17 additions & 7 deletions internal/agent/default_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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")
}
})
Expand All @@ -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"),
Expand All @@ -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")
}
})

Expand All @@ -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")
}
})

Expand All @@ -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")
}
})
Expand All @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions internal/webhook/agent_mcpserver_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/webhook/agent_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions samples/workflows/features/agent-custom-endpoint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading