diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index e135a3551c..31a4c18b0a 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -62,6 +62,7 @@ overrides of these variables are rejected. | Foundry Anthropic API key | `x-api-key: ` | | Gemini API key | `x-goog-api-key: ` | | Bedrock bearer token | `authorization: Bearer ` | +| Vertex AI credential | `authorization: Bearer `, minted by the configured provider class from the Secret | | RemoteMCPServer Secret-backed header | Configured header; Secret contains its full value | Provider endpoint overrides determine the allowed HTTP(S) origin. Egress rules @@ -79,9 +80,31 @@ Harness and SandboxTemplate environment entries accept only literal `value` strings, including empty strings. Configure Secret-backed credentials on ModelConfig, RemoteMCPServer, or [EgressPolicy](egress-policies.md) for gateway injection. -AWS IAM signing keys, Google service-account keys, and OAuth client credentials -require mechanisms beyond static header injection and are rejected rather than -serialized into runtimes. +Each ModelConfig provider resolves its `apiKeySecret` through a Substrate +credential provider class, the authority of the `ate-secret://` URI. The class +is `k8s.io` unless the controller setting +`runtimeDefaults.modelCredentialAuthorities` (Helm value +`config.runtimeDefaults.modelCredentialAuthorities`) names another one for that +provider; `k8s.io` injects the Secret value as stored. Another class needs a +Substrate egress gateway that routes it to a credential provider deployed +beside Substrate, or the gateway fails the request closed. RemoteMCPServer and +EgressPolicy credentials always use `k8s.io`. + +A `GeminiVertexAI` or `AnthropicVertexAI` ModelConfig with `apiKeySecret` binds +`authorization: Bearer` on the Vertex AI origin for its location. Its provider +needs a class in `runtimeDefaults.modelCredentialAuthorities` that mints access +tokens: `k8s.io` would set the stored key itself as the bearer token, so the +compiler rejects a Vertex AI `apiKeySecret` that resolves to it. The provider +class owns the Secret's format, typically a service account key, so the +compiler does not inspect it. Vertex AI ModelConfigs do not support +`tokenPassthrough`. The runtime never holds the Secret: the Go and Python ADKs +run with `KAGENT_SKIP_VERTEX_AUTH=true` and Claude Code with +`CLAUDE_CODE_SKIP_VERTEX_AUTH=1`, and each sends the placeholder +`Authorization: Bearer kagent-credential-injected` for the gateway to replace, +since the gateway replaces a header a request carries and adds none. + +AWS IAM signing keys and OAuth client credentials require mechanisms beyond +static header injection and are rejected rather than serialized into runtimes. Caller-token passthrough is set with `tokenPassthrough` on ModelConfig or RemoteMCPServer. It forwards the bearer token from the incoming A2A request @@ -192,6 +215,5 @@ TLS settings and warn when ignoring RemoteMCPServer TLS settings. Runtime trust for gateway injection is configured by the platform. `apiKeySecret` remains available for supported gateway credentials. Secret-backed -AWS IAM signing, Vertex service-account, and SAP OAuth credentials still fail -compilation; sharing this field with supported providers does not enable those -credential modes. +AWS IAM signing and SAP OAuth credentials still fail compilation; sharing this +field with supported providers does not enable those credential modes. diff --git a/docs/env.md b/docs/env.md index 00e4fe226a..ab4bd9e997 100644 --- a/docs/env.md +++ b/docs/env.md @@ -31,6 +31,7 @@ This reference covers user-configurable settings for the controller, CLI, standa | `GOOGLE_CLOUD_PROJECT` | String | `(none)` | Google Cloud project ID for Vertex AI. | | `GOOGLE_CLOUD_REGION` | String | `(none)` | Go ADK Vertex AI region fallback when GOOGLE_CLOUD_LOCATION is unset. | | `GOOGLE_GENAI_USE_VERTEXAI` | String | `(none)` | When set to 'true', use Vertex AI for Gemini models. | +| `KAGENT_SKIP_VERTEX_AUTH` | Boolean | `false` | When true, call Vertex AI without Google credentials of the runtime's own; the egress gateway sets the access token. | | `MISTRAL_API_BASE` | String | `(none)` | Custom base URL for the Mistral AI API (defaults to https://api.mistral.ai/v1). | | `MISTRAL_API_KEY` | String | `(none)` | API key for Mistral AI. | | `OLLAMA_API_BASE` | String | `(none)` | Base URL for the Ollama API endpoint; falls back to http://localhost:11434 when model configuration and this variable are unset. | diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 179a9dd5a1..2e2fb6a40f 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -325,23 +325,15 @@ func CreateLLM(ctx context.Context, m *configv1.ADKModelConfig) (adkmodel.LLM, e }) case *configv1.ADKModelConfig_GeminiVertexAi: - project := env.GoogleCloudProject.Get() - location := env.GoogleCloudLocation.Get() - if location == "" { - location = env.GoogleCloudRegion.Get() - } - if project == "" || location == "" { - return nil, fmt.Errorf("GeminiVertexAI requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (or GOOGLE_CLOUD_REGION) environment variables") - } modelName := m.Model if modelName == "" { modelName = DefaultGeminiModel } - return adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{ - Backend: genai.BackendVertexAI, - Project: project, - Location: location, - }) + clientConfig, err := geminiVertexAIClientConfig(m) + if err != nil { + return nil, err + } + return adkgemini.NewModel(ctx, modelName, clientConfig) case *configv1.ADKModelConfig_Anthropic: modelName := m.Model @@ -432,7 +424,8 @@ func CreateLLM(ctx context.Context, m *configv1.ADKModelConfig) (adkmodel.LLM, e case *configv1.ADKModelConfig_GeminiAnthropic: // GeminiAnthropic = Claude models accessed through Google Cloud Vertex AI. - // Uses the Anthropic SDK's built-in Vertex AI support with Application Default Credentials. + // Uses the Anthropic SDK's built-in Vertex AI support with Application + // Default Credentials, or with the egress gateway's access token. project := env.GoogleCloudProject.Get() region := env.GoogleCloudLocation.Get() if region == "" { @@ -488,6 +481,37 @@ func CreateLLM(ctx context.Context, m *configv1.ADKModelConfig) (adkmodel.LLM, e } } +// geminiVertexAIClientConfig is the genai client configuration for Gemini on +// Vertex AI. Under KAGENT_SKIP_VERTEX_AUTH the egress gateway sets the access +// token on every call: handed an HTTP client, the SDK sends the request as it +// is and never looks for Application Default Credentials, and the request +// carries the placeholder bearer, since the gateway replaces the Authorization +// header a request carries and adds none. +func geminiVertexAIClientConfig(m *configv1.ADKModelConfig) (*genai.ClientConfig, error) { + project := env.GoogleCloudProject.Get() + location := env.GoogleCloudLocation.Get() + if location == "" { + location = env.GoogleCloudRegion.Get() + } + if project == "" || location == "" { + return nil, fmt.Errorf("GeminiVertexAI requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (or GOOGLE_CLOUD_REGION) environment variables") + } + clientConfig := &genai.ClientConfig{ + Backend: genai.BackendVertexAI, + Project: project, + Location: location, + } + if env.SkipVertexAuth.Get() { + httpClient, err := models.BuildHTTPClient(transportConfigFromBase(m, nil)) + if err != nil { + return nil, fmt.Errorf("failed to build HTTP client for Gemini on Vertex AI: %w", err) + } + clientConfig.HTTPClient = httpClient + clientConfig.HTTPOptions.Headers = http.Header{"Authorization": {"Bearer " + models.VertexGatewayToken}} + } + return clientConfig, nil +} + // transportConfigFromBase builds a TransportConfig from the shared model fields. func transportConfigFromBase(b *configv1.ADKModelConfig, timeout *time.Duration) models.TransportConfig { return models.TransportConfig{ diff --git a/go/adk/pkg/agent/agent_test.go b/go/adk/pkg/agent/agent_test.go index 607313d3bf..d5fd6307f2 100644 --- a/go/adk/pkg/agent/agent_test.go +++ b/go/adk/pkg/agent/agent_test.go @@ -718,3 +718,36 @@ func TestUsesRawOutputSchema(t *testing.T) { }) } } + +// Under KAGENT_SKIP_VERTEX_AUTH the Gemini Vertex AI client is built without +// Application Default Credentials and carries the placeholder bearer, since +// the gateway replaces the Authorization header a request carries and adds +// none. +func TestGeminiVertexAIClientConfigSendsGatewayPlaceholder(t *testing.T) { + t.Setenv("GOOGLE_CLOUD_PROJECT", "project") + t.Setenv("GOOGLE_CLOUD_LOCATION", "europe-west4") + t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/credentials.json") + var got *http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}]}`)) + })) + t.Cleanup(server.Close) + + model := &configv1.ADKModelConfig{Model: "gemini-2.5-flash", Provider: &configv1.ADKModelConfig_GeminiVertexAi{GeminiVertexAi: &configv1.ADKGeminiVertexAI{}}} + llm, err := CreateLLM(t.Context(), model) + require.NoError(t, err) + require.NotNil(t, llm) + + clientConfig, err := geminiVertexAIClientConfig(model) + require.NoError(t, err) + clientConfig.HTTPOptions.BaseURL = server.URL + client, err := genai.NewClient(t.Context(), clientConfig) + require.NoError(t, err) + _, err = client.Models.GenerateContent(t.Context(), "gemini-2.5-flash", genai.Text("hi"), nil) + require.NoError(t, err) + require.Equal(t, "/v1beta1/projects/project/locations/europe-west4/publishers/google/models/gemini-2.5-flash:generateContent", got.URL.Path) + require.Equal(t, "Bearer "+models.VertexGatewayToken, got.Header.Get("Authorization")) +} diff --git a/go/adk/pkg/models/anthropic.go b/go/adk/pkg/models/anthropic.go index 483c081473..9a71b34f7f 100644 --- a/go/adk/pkg/models/anthropic.go +++ b/go/adk/pkg/models/anthropic.go @@ -8,6 +8,8 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/vertex" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" @@ -94,21 +96,27 @@ func newAnthropicModelFromConfig(ctx context.Context, config *AnthropicConfig, a }, nil } -// NewAnthropicVertexAIModel creates an Anthropic model that authenticates -// via Google Cloud Vertex AI using Application Default Credentials (ADC). -// This is used for the GeminiAnthropic / AnthropicVertexAI provider type. +// NewAnthropicVertexAIModel creates an Anthropic model for Claude on Google +// Cloud Vertex AI, the GeminiAnthropic / AnthropicVertexAI provider type. It +// authenticates with Application Default Credentials (ADC), unless +// KAGENT_SKIP_VERTEX_AUTH says the egress gateway sets the access token on +// every call: then the client never looks for ADC and sends the placeholder +// bearer the gateway replaces. func NewAnthropicVertexAIModel(ctx context.Context, config *AnthropicConfig, region, projectID string) (*AnthropicModel, error) { logger := logging.FromContext(ctx) - opts := []option.RequestOption{ - vertex.WithGoogleAuth(ctx, region, projectID), - } - // Create HTTP client with timeout, custom headers, TLS, and passthrough httpClient, err := BuildHTTPClient(config.TransportConfig) if err != nil { return nil, err } - opts = append(opts, option.WithHTTPClient(httpClient)) + // The Vertex option wraps the HTTP client passed before it with its + // authorization; a client passed after it would replace the authorized one. + opts := []option.RequestOption{option.WithHTTPClient(httpClient)} + if env.SkipVertexAuth.Get() { + opts = append(opts, vertex.WithCredentials(ctx, region, projectID, gatewayCredentials())) + } else { + opts = append(opts, vertex.WithGoogleAuth(ctx, region, projectID)) + } client := anthropic.NewClient(opts...) logger.InfoContext(ctx, "initialized Anthropic Vertex AI model", "model", config.Model, "region", region, "project", projectID) @@ -119,3 +127,17 @@ func NewAnthropicVertexAIModel(ctx context.Context, config *AnthropicConfig, reg Logger: logger, }, nil } + +// VertexGatewayToken is the placeholder access token a Vertex AI call carries +// when the egress gateway authenticates it (KAGENT_SKIP_VERTEX_AUTH). The +// gateway replaces the Authorization header a request carries with the access +// token its credential provider serves and adds none, so a call without the +// placeholder leaves unauthenticated. +const VertexGatewayToken = "kagent-credential-injected" + +// gatewayCredentials stand in for Google credentials when the egress gateway +// authenticates the Vertex AI call: the SDK's Vertex adaptation needs a token +// source to build its client, and sends the placeholder as its bearer. +func gatewayCredentials() *google.Credentials { + return &google.Credentials{TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: VertexGatewayToken})} +} diff --git a/go/adk/pkg/models/anthropic_vertex_test.go b/go/adk/pkg/models/anthropic_vertex_test.go new file mode 100644 index 0000000000..9fa5421583 --- /dev/null +++ b/go/adk/pkg/models/anthropic_vertex_test.go @@ -0,0 +1,94 @@ +package models + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/stretchr/testify/require" +) + +// Under KAGENT_SKIP_VERTEX_AUTH the Vertex AI client is built without +// Application Default Credentials and sends the Vertex-shaped request with the +// placeholder bearer the gateway replaces. +func TestNewAnthropicVertexAIModelWithGatewayCredential(t *testing.T) { + t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/credentials.json") + var got *http.Request + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + body, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5@20250929","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + t.Cleanup(server.Close) + + model, err := NewAnthropicVertexAIModel(t.Context(), &AnthropicConfig{Model: "claude-sonnet-4-5@20250929"}, "us-east5", "project") + require.NoError(t, err) + _, err = model.Client.Messages.New(t.Context(), anthropic.MessageNewParams{ + Model: "claude-sonnet-4-5@20250929", MaxTokens: 16, + Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))}, + }, option.WithBaseURL(server.URL)) + require.NoError(t, err) + require.Equal(t, "/v1/projects/project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", got.URL.Path) + require.Equal(t, "Bearer "+VertexGatewayToken, got.Header.Get("Authorization")) + var sent map[string]any + require.NoError(t, json.Unmarshal(body, &sent)) + require.Equal(t, "vertex-2023-10-16", sent["anthropic_version"]) + require.NotContains(t, sent, "model") +} + +// Without KAGENT_SKIP_VERTEX_AUTH the client authorizes requests with +// Application Default Credentials on top of the configured transport, so both +// the ADC access token and the transport's headers reach Vertex AI. +func TestNewAnthropicVertexAIModelWithApplicationDefaultCredentials(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"adc-access-token","token_type":"Bearer","expires_in":3600}`)) + })) + t.Cleanup(tokenServer.Close) + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + credentials, err := json.Marshal(map[string]string{ + "type": "service_account", + "project_id": "project", + "client_email": "agent@project.iam.gserviceaccount.com", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})), + "token_uri": tokenServer.URL, + }) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "credentials.json") + require.NoError(t, os.WriteFile(path, credentials, 0o600)) + t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "false") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path) + + var got *http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5@20250929","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + t.Cleanup(server.Close) + + config := &AnthropicConfig{Model: "claude-sonnet-4-5@20250929", TransportConfig: TransportConfig{Headers: map[string]string{"X-Team": "platform"}}} + model, err := NewAnthropicVertexAIModel(t.Context(), config, "us-east5", "project") + require.NoError(t, err) + _, err = model.Client.Messages.New(t.Context(), anthropic.MessageNewParams{ + Model: "claude-sonnet-4-5@20250929", MaxTokens: 16, + Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))}, + }, option.WithBaseURL(server.URL)) + require.NoError(t, err) + require.Equal(t, "Bearer adc-access-token", got.Header.Get("Authorization")) + require.Equal(t, "platform", got.Header.Get("X-Team")) +} diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index f35d448ea4..ae51c62b83 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1857,10 +1857,16 @@ type RuntimeDefaultsConfig struct { TokenPropagation *TokenPropagationConfig `protobuf:"bytes,6,opt,name=token_propagation,json=tokenPropagation,proto3" json:"token_propagation,omitempty"` BuiltinTools *BuiltinToolsConfig `protobuf:"bytes,7,opt,name=builtin_tools,json=builtinTools,proto3" json:"builtin_tools,omitempty"` // Per-agent runtime limits, in Kubernetes quantity notation. - Cpu *string `protobuf:"bytes,8,opt,name=cpu,proto3,oneof" json:"cpu,omitempty"` - Memory *string `protobuf:"bytes,9,opt,name=memory,proto3,oneof" json:"memory,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Cpu *string `protobuf:"bytes,8,opt,name=cpu,proto3,oneof" json:"cpu,omitempty"` + Memory *string `protobuf:"bytes,9,opt,name=memory,proto3,oneof" json:"memory,omitempty"` + // The Substrate credential provider class, the authority of the + // ate-secret:// URI, that the egress gateway resolves a ModelConfig's + // apiKeySecret through, keyed by ModelConfig provider. A provider absent + // here uses k8s.io, which injects the Secret value as it is stored. + // GeminiVertexAI and AnthropicVertexAI need a class that mints access tokens. + ModelCredentialAuthorities map[string]string `protobuf:"bytes,10,rep,name=model_credential_authorities,json=modelCredentialAuthorities,proto3" json:"model_credential_authorities,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RuntimeDefaultsConfig) Reset() { @@ -1956,6 +1962,13 @@ func (x *RuntimeDefaultsConfig) GetMemory() string { return "" } +func (x *RuntimeDefaultsConfig) GetModelCredentialAuthorities() map[string]string { + if x != nil { + return x.ModelCredentialAuthorities + } + return nil +} + // BuiltinToolsConfig configures built-in tools independently of skills. type BuiltinToolsConfig struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2307,7 +2320,7 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\bendpoint\x18\x01 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01R\bendpoint\x12;\n" + "\bprotocol\x18\x02 \x01(\tB\x1a\xbaH\x17r\x15R\x04grpcR\rhttp/protobufH\x00R\bprotocol\x88\x01\x01\x12G\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationB\x12\xbaH\x0f\xaa\x01\f\"\x04\b\x80\xa3\x052\x04\x10\xc0\x84=R\atimeoutB\v\n" + - "\t_protocol\"\x9a\a\n" + + "\t_protocol\"\xb8\v\n" + "\x15RuntimeDefaultsConfig\x12L\n" + "\x11control_plane_url\x18\x01 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x00R\x0fcontrolPlaneUrl\x88\x01\x01\x12A\n" + "\vgateway_url\x18\x02 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x01R\n" + @@ -2319,7 +2332,13 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\x11token_propagation\x18\x06 \x01(\v2(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12I\n" + "\rbuiltin_tools\x18\a \x01(\v2$.kagent.config.v1.BuiltinToolsConfigR\fbuiltinTools\x12\x1e\n" + "\x03cpu\x18\b \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x03R\x03cpu\x88\x01\x01\x12$\n" + - "\x06memory\x18\t \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x04R\x06memory\x88\x01\x01B\x14\n" + + "\x06memory\x18\t \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x04R\x06memory\x88\x01\x01\x12\xcc\x03\n" + + "\x1cmodel_credential_authorities\x18\n" + + " \x03(\v2G.kagent.config.v1.RuntimeDefaultsConfig.ModelCredentialAuthoritiesEntryB\xc0\x02\xbaH\xbc\x02\x9a\x01\xb8\x02\"{ryR\tAnthropicR\x11AnthropicVertexAIR\vAzureOpenAIR\aBedrockR\aFoundryR\x06GeminiR\x0eGeminiVertexAIR\aMistralR\x06OllamaR\x06OpenAIR\tSAPAICore*\xb8\x01\xba\x01m\n" + + "\x1fmodel_credential_authorities.ip\x12 kagent.config.v1.ConnectionConfig @@ -2390,7 +2410,7 @@ var file_kagent_config_v1_config_proto_depIdxs = []int32{ 5, // 3: kagent.config.v1.CLIConfig.install:type_name -> kagent.config.v1.InstallConfig 13, // 4: kagent.config.v1.CLIConfig.database:type_name -> kagent.config.v1.DatabaseConfig 1, // 5: kagent.config.v1.CLIConfig.tui:type_name -> kagent.config.v1.TUIConfig - 30, // 6: kagent.config.v1.ConnectionConfig.timeout:type_name -> google.protobuf.Duration + 31, // 6: kagent.config.v1.ConnectionConfig.timeout:type_name -> google.protobuf.Duration 6, // 7: kagent.config.v1.InstallConfig.kagent:type_name -> kagent.config.v1.ChartConfig 6, // 8: kagent.config.v1.InstallConfig.substrate:type_name -> kagent.config.v1.ChartConfig 6, // 9: kagent.config.v1.InstallConfig.pod_certificate:type_name -> kagent.config.v1.ChartConfig @@ -2414,34 +2434,35 @@ var file_kagent_config_v1_config_proto_depIdxs = []int32{ 14, // 27: kagent.config.v1.DatabaseConfig.owner:type_name -> kagent.config.v1.DatabaseLoginConfig 14, // 28: kagent.config.v1.DatabaseConfig.read_write:type_name -> kagent.config.v1.DatabaseLoginConfig 15, // 29: kagent.config.v1.DatabaseConfig.pool:type_name -> kagent.config.v1.PostgresPoolConfig - 30, // 30: kagent.config.v1.PostgresPoolConfig.max_conn_idle_time:type_name -> google.protobuf.Duration - 30, // 31: kagent.config.v1.PostgresPoolConfig.max_conn_lifetime:type_name -> google.protobuf.Duration - 30, // 32: kagent.config.v1.SessionsConfig.idle_ttl:type_name -> google.protobuf.Duration - 30, // 33: kagent.config.v1.SessionsConfig.share_max_ttl:type_name -> google.protobuf.Duration - 30, // 34: kagent.config.v1.SessionsConfig.expiration_interval:type_name -> google.protobuf.Duration - 30, // 35: kagent.config.v1.SessionsConfig.quiescence_recovery_interval:type_name -> google.protobuf.Duration - 30, // 36: kagent.config.v1.PushConfig.recovery_interval:type_name -> google.protobuf.Duration - 30, // 37: kagent.config.v1.PushConfig.send_timeout:type_name -> google.protobuf.Duration - 30, // 38: kagent.config.v1.SandboxConfig.default_ttl:type_name -> google.protobuf.Duration - 30, // 39: kagent.config.v1.SandboxConfig.max_ttl:type_name -> google.protobuf.Duration - 30, // 40: kagent.config.v1.SandboxConfig.expiration_interval:type_name -> google.protobuf.Duration - 30, // 41: kagent.config.v1.ScheduledRunsConfig.poll_interval:type_name -> google.protobuf.Duration - 30, // 42: kagent.config.v1.ScheduledRunsConfig.execution_interval:type_name -> google.protobuf.Duration - 30, // 43: kagent.config.v1.RuntimeRevisionsConfig.gc_interval:type_name -> google.protobuf.Duration + 31, // 30: kagent.config.v1.PostgresPoolConfig.max_conn_idle_time:type_name -> google.protobuf.Duration + 31, // 31: kagent.config.v1.PostgresPoolConfig.max_conn_lifetime:type_name -> google.protobuf.Duration + 31, // 32: kagent.config.v1.SessionsConfig.idle_ttl:type_name -> google.protobuf.Duration + 31, // 33: kagent.config.v1.SessionsConfig.share_max_ttl:type_name -> google.protobuf.Duration + 31, // 34: kagent.config.v1.SessionsConfig.expiration_interval:type_name -> google.protobuf.Duration + 31, // 35: kagent.config.v1.SessionsConfig.quiescence_recovery_interval:type_name -> google.protobuf.Duration + 31, // 36: kagent.config.v1.PushConfig.recovery_interval:type_name -> google.protobuf.Duration + 31, // 37: kagent.config.v1.PushConfig.send_timeout:type_name -> google.protobuf.Duration + 31, // 38: kagent.config.v1.SandboxConfig.default_ttl:type_name -> google.protobuf.Duration + 31, // 39: kagent.config.v1.SandboxConfig.max_ttl:type_name -> google.protobuf.Duration + 31, // 40: kagent.config.v1.SandboxConfig.expiration_interval:type_name -> google.protobuf.Duration + 31, // 41: kagent.config.v1.ScheduledRunsConfig.poll_interval:type_name -> google.protobuf.Duration + 31, // 42: kagent.config.v1.ScheduledRunsConfig.execution_interval:type_name -> google.protobuf.Duration + 31, // 43: kagent.config.v1.RuntimeRevisionsConfig.gc_interval:type_name -> google.protobuf.Duration 25, // 44: kagent.config.v1.TelemetryConfig.traces:type_name -> kagent.config.v1.OTLPConfig 25, // 45: kagent.config.v1.TelemetryConfig.metrics:type_name -> kagent.config.v1.OTLPConfig 25, // 46: kagent.config.v1.TelemetryConfig.logs:type_name -> kagent.config.v1.OTLPConfig 29, // 47: kagent.config.v1.TelemetryConfig.resource_attributes:type_name -> kagent.config.v1.TelemetryConfig.ResourceAttributesEntry - 30, // 48: kagent.config.v1.OTLPConfig.timeout:type_name -> google.protobuf.Duration + 31, // 48: kagent.config.v1.OTLPConfig.timeout:type_name -> google.protobuf.Duration 4, // 49: kagent.config.v1.RuntimeDefaultsConfig.logging:type_name -> kagent.config.v1.LoggingConfig 24, // 50: kagent.config.v1.RuntimeDefaultsConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig 28, // 51: kagent.config.v1.RuntimeDefaultsConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig 27, // 52: kagent.config.v1.RuntimeDefaultsConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig - 53, // [53:53] is the sub-list for method output_type - 53, // [53:53] is the sub-list for method input_type - 53, // [53:53] is the sub-list for extension type_name - 53, // [53:53] is the sub-list for extension extendee - 0, // [0:53] is the sub-list for field type_name + 30, // 53: kagent.config.v1.RuntimeDefaultsConfig.model_credential_authorities:type_name -> kagent.config.v1.RuntimeDefaultsConfig.ModelCredentialAuthoritiesEntry + 54, // [54:54] is the sub-list for method output_type + 54, // [54:54] is the sub-list for method input_type + 54, // [54:54] is the sub-list for extension type_name + 54, // [54:54] is the sub-list for extension extendee + 0, // [0:54] is the sub-list for field type_name } func init() { file_kagent_config_v1_config_proto_init() } @@ -2475,7 +2496,7 @@ func file_kagent_config_v1_config_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_config_v1_config_proto_rawDesc), len(file_kagent_config_v1_config_proto_rawDesc)), NumEnums: 0, - NumMessages: 30, + NumMessages: 31, NumExtensions: 0, NumServices: 0, }, diff --git a/go/core/internal/egress/credentials.go b/go/core/internal/egress/credentials.go index 55f4aae43a..44fd8b2ee4 100644 --- a/go/core/internal/egress/credentials.go +++ b/go/core/internal/egress/credentials.go @@ -2,6 +2,7 @@ package egress import ( "fmt" + "net" "net/url" "slices" "strings" @@ -13,6 +14,16 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) +// KubernetesSecretAuthority is the Substrate credential provider class, the +// authority of an ate-secret:// URI, that injects a Secret value unchanged. +const KubernetesSecretAuthority = "k8s.io" + +// CredentialURI names one Secret data entry of the default locator for a +// Substrate credential provider class. +func CredentialURI(authority, namespace, name, key string) string { + return "ate-secret://" + authority + "/default/" + namespace + "/" + name + "/" + key +} + // Credential binds a provider reference to an exact destination and HTTP header. // Values are fetched by the gateway and never enter a runtime revision. type Credential struct { @@ -136,7 +147,8 @@ func validateActorJWT(source *ActorJWT) error { } // validCredentialURI accepts ate-secret:///. Provider names are -// lowercase DNS names without ports. k8s.io paths name one Secret key. +// lowercase DNS names without ports that are not IP addresses. k8s.io paths +// name one Secret key. func validCredentialURI(raw string) error { u, err := url.Parse(raw) if err != nil || u.Scheme != "ate-secret" || u.Opaque != "" { @@ -145,15 +157,15 @@ func validCredentialURI(raw string) error { if u.User != nil || u.ForceQuery || u.RawQuery != "" || strings.Contains(raw, "#") { return fmt.Errorf("credential URI cannot carry user information, a query or a fragment") } - if u.Host != u.Hostname() || len(validation.IsDNS1123Subdomain(u.Host)) != 0 { - return fmt.Errorf("credential provider must be a lowercase DNS name without a port") + if u.Host != u.Hostname() || len(validation.IsDNS1123Subdomain(u.Host)) != 0 || net.ParseIP(u.Host) != nil { + return fmt.Errorf("credential provider must be a lowercase DNS name without a port, not an IP address") } // Substrate requires at least two nonempty, unescaped path segments. parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") if u.EscapedPath() != u.Path || !strings.HasPrefix(u.Path, "/") || len(parts) < 2 || slices.Contains(parts, "") { return fmt.Errorf("credential URI must have at least two nonempty path segments without percent encoding") } - if u.Host != "k8s.io" { + if u.Host != KubernetesSecretAuthority { return nil } if len(parts) != 4 || parts[0] != "default" { diff --git a/go/core/internal/egress/credentials_test.go b/go/core/internal/egress/credentials_test.go index 90781b2139..efe8dffc1a 100644 --- a/go/core/internal/egress/credentials_test.go +++ b/go/core/internal/egress/credentials_test.go @@ -51,6 +51,7 @@ func TestCanonicalCredentials(t *testing.T) { {"encoded unreserved character", func(c *Credential) { c.URI = "ate-secret://provider.example/default/%74oken" }}, {"unescaped space", func(c *Credential) { c.URI = "ate-secret://provider.example/default/session token" }}, {"encoded Kubernetes key", func(c *Credential) { c.URI = "ate-secret://k8s.io/default/team/auth/%74oken" }}, + {"IP provider", func(c *Credential) { c.URI = "ate-secret://192.0.2.1/default/team/auth/token" }}, {"missing locator", func(c *Credential) { c.URI = "ate-secret://k8s.io/team/auth/token" }}, {"unsupported locator", func(c *Credential) { c.URI = "ate-secret://k8s.io/remote/team/auth/token" }}, {"query", func(c *Credential) { c.URI += "?key=other" }}, @@ -66,6 +67,12 @@ func TestCanonicalCredentials(t *testing.T) { other.URI = "ate-secret://k8s.io/default/team/other/token" _, err = CanonicalCredentials([]Credential{base, other}) require.ErrorContains(t, err, "conflicting credentials") + + // Any provider class the gateway routes is a DNS subdomain authority. + minted := Credential{Origin: "https://us-east5-aiplatform.googleapis.com", Header: "authorization", Prefix: "Bearer ", URI: CredentialURI("tokens.example.com", "team", "vertex", "credentials.json")} + got, err = CanonicalCredentials([]Credential{minted}) + require.NoError(t, err) + require.Equal(t, "ate-secret://tokens.example.com/default/team/vertex/credentials.json", got[0].URI) } func TestCanonicalGatewayTargets(t *testing.T) { diff --git a/go/core/internal/translator/actor_jwt_test.go b/go/core/internal/translator/actor_jwt_test.go index dba6713a6e..2ee0956601 100644 --- a/go/core/internal/translator/actor_jwt_test.go +++ b/go/core/internal/translator/actor_jwt_test.go @@ -18,7 +18,7 @@ func TestCompileActorJWTUnsupportedProviders(t *testing.T) { t.Run(string(spec.Provider), func(t *testing.T) { spec.Model = "test:cloud" spec.ActorJWT = &v1alpha3.ActorJWT{Audiences: []string{"model"}} - environment, bindings, passthrough, err := CompileCredentials(credentialInput(spec), nil, nil) + environment, bindings, passthrough, err := CompileCredentials(credentialInput(spec), nil, nil, nil) require.ErrorContains(t, err, "does not support actorJwt") require.Empty(t, environment) require.Empty(t, bindings) @@ -33,7 +33,7 @@ func TestCompileActorJWTCredentials(t *testing.T) { input.Root.MCPTools = []ResolvedMCPTool{{Server: &v1alpha3.RemoteMCPServer{Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://mcp.example.com/tools", ActorJWT: token}}}} placeholder, err := ActorJWTEnvironment(input.Root.ResolvedModelConfig) require.NoError(t, err) - environment, bindings, passthrough, err := CompileCredentials(input, nil, []corev1.EnvVar{placeholder}) + environment, bindings, passthrough, err := CompileCredentials(input, nil, []corev1.EnvVar{placeholder}, nil) require.NoError(t, err) require.Empty(t, passthrough) require.Equal(t, []corev1.EnvVar{{Name: "ANTHROPIC_API_KEY", Value: CredentialPlaceholder}}, environment) @@ -53,14 +53,14 @@ func TestCompileActorJWTCredentials(t *testing.T) { require.Equal(t, "x-actor", access.Credentials[0].Header) require.Equal(t, bindings[0].ActorJWT, access.Credentials[0].ActorJWT) input.Root.ResolvedModelConfig.Config.Spec.DefaultHeaders = map[string]string{"X-Api-Key": "conflict"} - environment, bindings, passthrough, err = CompileCredentials(input, nil, nil) + environment, bindings, passthrough, err = CompileCredentials(input, nil, nil, nil) require.Empty(t, environment) require.Empty(t, bindings) require.Empty(t, passthrough) require.ErrorContains(t, err, "defaultHeaders") input.Root.ResolvedModelConfig.Config.Spec.DefaultHeaders = nil input.Root.MCPTools[0].Server.Spec.HeadersFrom = []v1alpha3.ValueRef{{Name: "AUTHORIZATION", Value: "conflict"}} - environment, bindings, passthrough, err = CompileCredentials(input, nil, nil) + environment, bindings, passthrough, err = CompileCredentials(input, nil, nil, nil) require.Empty(t, environment) require.Empty(t, bindings) require.Empty(t, passthrough) diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index 9e8b754214..1a2e6e88d3 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -105,9 +105,6 @@ func (c *Builder) compileAgent(ctx context.Context, input *translator.AgentInput return nil, fmt.Errorf("render ModelConfig %q: %w", modelConfig.Name, err) } } - if modelRuntime.HasUnsupportedVolumes { - return nil, translator.NewValidationError("ModelConfig requires volume mounts unsupported by Substrate ActorTemplate") - } stream := new(true) if modelConfig != nil && modelConfig.Spec.Stream != nil { stream = modelConfig.Spec.Stream @@ -316,6 +313,10 @@ func agentConfigDestinations(cfg *configv1.ADKAgentConfig, modelConfig *v1alpha3 destinations = append(destinations, "https://api.anthropic.com:443") case v1alpha3.ModelProviderGemini: destinations = append(destinations, "https://generativelanguage.googleapis.com:443") + case v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI: + if vertex := translator.VertexAIConfig(&modelConfig.Spec); vertex != nil { + destinations = append(destinations, translator.VertexAIOrigin(vertex.Location)) + } case v1alpha3.ModelProviderMistral: if mistral := modelConfig.Spec.Mistral; mistral == nil || mistral.BaseURL == nil || *mistral.BaseURL == "" { destinations = append(destinations, "https://api.mistral.ai:443") diff --git a/go/core/internal/translator/adkconfig/builder_test.go b/go/core/internal/translator/adkconfig/builder_test.go index ebf8092be6..db1420df42 100644 --- a/go/core/internal/translator/adkconfig/builder_test.go +++ b/go/core/internal/translator/adkconfig/builder_test.go @@ -278,6 +278,49 @@ func TestOllamaEgressDestination(t *testing.T) { }) } +// A Vertex AI ModelConfig with apiKeySecret compiles for the Substrate runtime: +// no volume, no Secret in the environment, and the location's endpoint allowed. +func TestBuildVertexAIGatewayCredential(t *testing.T) { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"} + for _, model := range []v1alpha3.ModelConfigSpec{ + {Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-pro", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}}, + {Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + } { + t.Run(string(model.Provider), func(t *testing.T) { + build := func(t *testing.T, key string) (*Result, error) { + t.Helper() + spec := model + spec.APIKeySecret, spec.APIKeySecretKey = "vertex-auth", "credentials.json" + config := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "vertex", Namespace: "test"}, Spec: spec} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "vertex-auth", Namespace: "test"}, Data: map[string][]byte{"credentials.json": []byte(key)}} + mock := krttest.NewMock(t, []any{config, secret}) + collections := translator.Collections{Secrets: krttest.GetMockCollection[*corev1.Secret](mock)} + resolved, err := translator.ResolveModelConfig(krt.TestingDummyContext{}, collections, krttest.GetMockCollection[*v1alpha3.ModelConfig](mock).List()[0]) + require.NoError(t, err) + collections.ResolvedModelConfigs = krttest.GetMockCollection[translator.ResolvedModelConfig](krttest.NewMock(t, []any{*resolved})) + return NewBuilder(krt.TestingDummyContext{}, collections).Build(t.Context(), &translator.HarnessInput{ + Harness: &translator.HarnessConfiguration{Spec: v1alpha3.HarnessSpec{Kagent: &v1alpha3.KagentHarness{}}}, + Root: &translator.AgentInput{Template: &translator.TemplateConfiguration{Name: "pi", Namespace: "test"}, ResolvedModelConfig: resolved}, + }) + } + // The provider class that fetches the Secret owns its format, so an + // access token and a service account key both compile. + for _, data := range []string{"ya29.access-token", `{"type":"service_account","private_key":"private"}`} { + result, err := build(t, data) + require.NoError(t, err) + require.Equal(t, []string{"https://aiplatform.googleapis.com:443"}, result.Egress) + environment := map[string]string{} + for _, variable := range result.Environment { + require.Nil(t, variable.ValueFrom, "%s reads a Secret", variable.Name) + environment[variable.Name] = variable.Value + } + require.Equal(t, "true", environment[env.SkipVertexAuth.Name()]) + require.NotContains(t, environment, env.GoogleApplicationCredentials.Name()) + } + }) + } +} + func contextTestCollections(t *testing.T, models ...*v1alpha3.ModelConfig) translator.Collections { t.Helper() objects := make([]any, 0, 2*len(models)) diff --git a/go/core/internal/translator/adkconfig/model.go b/go/core/internal/translator/adkconfig/model.go index 0929aea753..06edf5946d 100644 --- a/go/core/internal/translator/adkconfig/model.go +++ b/go/core/internal/translator/adkconfig/model.go @@ -19,21 +19,15 @@ import ( ) // modelDeploymentData collects the Kubernetes inputs required by a provider. -// Volumes are retained even though the current Substrate ActorTemplate path -// rejects them, so the compiler can report incompatibility instead of silently -// dropping credentials. type modelDeploymentData struct { - EnvVars []corev1.EnvVar - Volumes []corev1.Volume - VolumeMounts []corev1.VolumeMount + EnvVars []corev1.EnvVar } // modelRuntime is the provider-neutral result consumed by the rest of the // compiler. type modelRuntime struct { - Model *configv1.ADKModelConfig - Environment []corev1.EnvVar - HasUnsupportedVolumes bool + Model *configv1.ADKModelConfig + Environment []corev1.EnvVar } // resolveModel collapses provider-specific translation output into the subset @@ -43,13 +37,22 @@ func resolveModel(resolved *translator.ResolvedModelConfig) (*modelRuntime, erro if err != nil { return nil, err } - return &modelRuntime{ - Model: model, Environment: data.EnvVars, - HasUnsupportedVolumes: len(data.Volumes) > 0 || len(data.VolumeMounts) > 0, - }, nil + return &modelRuntime{Model: model, Environment: data.EnvVars}, nil } -const googleCredsVolumeName = "google-creds" +// vertexAIEnvironment names the Vertex AI project and location. With an +// apiKeySecret the egress gateway sets the access token on every call, so the +// runtime skips Google authentication of its own and never holds the Secret. +func vertexAIEnvironment(model *v1alpha3.ModelConfig, vertex *v1alpha3.BaseVertexAIConfig) []corev1.EnvVar { + environment := []corev1.EnvVar{ + {Name: env.GoogleCloudProject.Name(), Value: vertex.ProjectID}, + {Name: env.GoogleCloudLocation.Name(), Value: vertex.Location}, + } + if model.Spec.APIKeySecret != "" { + environment = append(environment, corev1.EnvVar{Name: env.SkipVertexAuth.Name(), Value: "true"}) + } + return environment +} // tlsInsecureSkipVerify leaves empty configs unset to preserve SDK client defaults. func tlsInsecureSkipVerify(tlsConfig *v1alpha3.TLSConfig) *bool { @@ -240,36 +243,11 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode if model.Spec.GeminiVertexAI == nil { return nil, nil, fmt.Errorf("GeminiVertexAI model config is required") } - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudProject.Name(), - Value: model.Spec.GeminiVertexAI.ProjectID, - }) - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudLocation.Name(), - Value: model.Spec.GeminiVertexAI.Location, - }) modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.GoogleGenAIUseVertexAI.Name(), Value: "true", }) - if model.Spec.APIKeySecret != "" { - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleApplicationCredentials.Name(), - Value: "/creds/" + model.Spec.APIKeySecretKey, - }) - modelDeploymentData.Volumes = append(modelDeploymentData.Volumes, corev1.Volume{ - Name: googleCredsVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: model.Spec.APIKeySecret, - }, - }, - }) - modelDeploymentData.VolumeMounts = append(modelDeploymentData.VolumeMounts, corev1.VolumeMount{ - Name: googleCredsVolumeName, - MountPath: "/creds", - }) - } + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, vertexAIEnvironment(model, &model.Spec.GeminiVertexAI.BaseVertexAIConfig)...) gemini := &configv1.ADKModelConfig{ Model: model.Spec.Model, Headers: model.Spec.DefaultHeaders, @@ -288,32 +266,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode if model.Spec.AnthropicVertexAI == nil { return nil, nil, fmt.Errorf("AnthropicVertexAI model config is required") } - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudProject.Name(), - Value: model.Spec.AnthropicVertexAI.ProjectID, - }) - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudLocation.Name(), - Value: model.Spec.AnthropicVertexAI.Location, - }) - if model.Spec.APIKeySecret != "" { - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleApplicationCredentials.Name(), - Value: "/creds/" + model.Spec.APIKeySecretKey, - }) - modelDeploymentData.Volumes = append(modelDeploymentData.Volumes, corev1.Volume{ - Name: googleCredsVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: model.Spec.APIKeySecret, - }, - }, - }) - modelDeploymentData.VolumeMounts = append(modelDeploymentData.VolumeMounts, corev1.VolumeMount{ - Name: googleCredsVolumeName, - MountPath: "/creds", - }) - } + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, vertexAIEnvironment(model, &model.Spec.AnthropicVertexAI.BaseVertexAIConfig)...) anthropic := &configv1.ADKModelConfig{ Model: model.Spec.Model, Headers: model.Spec.DefaultHeaders, diff --git a/go/core/internal/translator/adkconfig/model_test.go b/go/core/internal/translator/adkconfig/model_test.go index dca3e76d22..a1ed7ecb03 100644 --- a/go/core/internal/translator/adkconfig/model_test.go +++ b/go/core/internal/translator/adkconfig/model_test.go @@ -83,6 +83,48 @@ func TestRenderBedrockCredentialsFromReferences(t *testing.T) { } } +// The egress gateway sets a Vertex AI access token: the runtime skips Google +// authentication of its own and gets neither the Secret nor a credential file. +func TestTranslateVertexAIEnvironment(t *testing.T) { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"} + for _, tt := range []struct { + name string + spec v1alpha3.ModelConfigSpec + want []string + }{ + { + name: "Gemini with a key", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-pro", APIKeySecret: "vertex-auth", APIKeySecretKey: "credentials.json", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleGenAIUseVertexAI.Name(), env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name(), env.SkipVertexAuth.Name()}, + }, + { + name: "Anthropic with a key", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", APIKeySecret: "vertex-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name(), env.SkipVertexAuth.Name()}, + }, + { + name: "Anthropic without a key", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name()}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + resolved := &translator.ResolvedModelConfig{Config: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "test"}, Spec: tt.spec}} + _, data, err := translateModel(resolved) + require.NoError(t, err) + names := make([]string, 0, len(data.EnvVars)) + for _, variable := range data.EnvVars { + require.Nil(t, variable.ValueFrom, "%s reads a Secret", variable.Name) + names = append(names, variable.Name) + if variable.Name == env.SkipVertexAuth.Name() { + require.Equal(t, "true", variable.Value) + } + } + require.ElementsMatch(t, tt.want, names) + }) + } +} + // The Ollama key reaches the agent as an environment variable read from a // Secret, never serialized into the agent config, and the host is written only // when the operator set one — an empty value lets the runtime apply its own diff --git a/go/core/internal/translator/adkconfig/runtime.go b/go/core/internal/translator/adkconfig/runtime.go index 81f28f2172..4949b3c769 100644 --- a/go/core/internal/translator/adkconfig/runtime.go +++ b/go/core/internal/translator/adkconfig/runtime.go @@ -32,9 +32,6 @@ func (c *Builder) buildModel(namespace string, ref v1alpha3.ObjectReference) (*m if err != nil { return nil, err } - if runtime.HasUnsupportedVolumes { - return nil, translator.NewValidationError("ModelConfig requires volume mounts unsupported by Substrate ActorTemplate") - } return &modelResult{ Resolved: resolved, Model: runtime.Model, Environment: runtime.Environment, Egress: agentConfigDestinations(&configv1.ADKAgentConfig{}, resolved.Config, runtime.Model), diff --git a/go/core/internal/translator/byo/compiler.go b/go/core/internal/translator/byo/compiler.go index cc3e30e5d3..013bc7a0b6 100644 --- a/go/core/internal/translator/byo/compiler.go +++ b/go/core/internal/translator/byo/compiler.go @@ -70,7 +70,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, compiled.Models, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, compiled.Models, environment, c.settings) if err != nil { return nil, err } diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index b3081f6fa6..c6ad9b287b 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -72,7 +72,8 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) environment := append([]corev1.EnvVar(nil), providerEnvironment...) environment = append(environment, mcp.environment...) for _, variable := range input.Harness.Spec.Env { - if claudeconfig.OwnsEnvironment(variable.Name) { + providerOwned := slices.ContainsFunc(providerEnvironment, func(owned corev1.EnvVar) bool { return owned.Name == variable.Name }) + if claudeconfig.OwnsEnvironment(variable.Name) || providerOwned { return nil, translator.NewValidationError("Harness env %q conflicts with Claude-owned runtime configuration", variable.Name) } environment = append(environment, corev1.EnvVar{Name: variable.Name, Value: variable.Value}) @@ -111,7 +112,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build Claude revision provenance: %w", err) } - environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, nil, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, nil, environment, c.settings) if err != nil { return nil, err } @@ -203,7 +204,7 @@ func (c *Compiler) provider(ctx context.Context, resolved *translator.ResolvedMo } } if model.Spec.ActorJWT == nil { - if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, false); err != nil { + if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey); err != nil { return nil, nil, err } credential = secretEnvironment(claudeconfig.AnthropicAPIKeyEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey) @@ -268,14 +269,19 @@ func (c *Compiler) provider(ctx context.Context, resolved *translator.ResolvedMo if !reflect.DeepEqual(options, v1alpha3.AnthropicVertexAIConfig{}) { return nil, nil, translator.NewValidationError("Claude does not support AnthropicVertexAI provider options beyond projectID and location yet") } - if err := c.requireGoogleCredentials(ctx, model); err != nil { + if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey); err != nil { return nil, nil, err } cfg := model.Spec.AnthropicVertexAI + // The gateway replaces the placeholder bearer with the access token its + // configured provider class serves, so Claude Code skips its own Google + // authentication and the Secret never enters the runtime. The gateway + // replaces a header a request carries and adds none. return []corev1.EnvVar{ - {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, - secretEnvironment(claudeconfig.GoogleCredentialsJSONEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey), - }, []string{"https://" + translator.VertexHostname(cfg.Location) + ":443", "https://oauth2.googleapis.com:443"}, nil + {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.SkipVertexAuthEnvName, Value: "1"}, + {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, + {Name: claudeconfig.AnthropicCustomHeadersEnvName, Value: "Authorization: Bearer " + translator.CredentialPlaceholder}, + }, []string{translator.VertexAIOrigin(cfg.Location)}, nil default: return nil, nil, translator.NewValidationError("Claude does not support ModelConfig provider %q", model.Spec.Provider) } @@ -289,7 +295,7 @@ func anthropicBaseURLOrigin(raw string) (string, error) { return egress.Origin(parsed), nil } -func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelConfig, name, key string, requireJSON bool) error { +func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelConfig, name, key string) error { if name == "" || key == "" { return translator.NewValidationError("Claude %s requires apiKeySecret and apiKeySecretKey", model.Spec.Provider) } @@ -301,40 +307,6 @@ func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelCo if !ok || len(value) == 0 { return translator.NewValidationError("Claude credential Secret %q does not contain a non-empty key %q", name, key) } - if requireJSON && !json.Valid(value) { - return translator.NewValidationError("Claude Vertex credential Secret %q key %q must contain valid JSON", name, key) - } - return nil -} - -func (c *Compiler) requireGoogleCredentials(ctx context.Context, model *v1alpha3.ModelConfig) error { - if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, true); err != nil { - return err - } - secret, err := c.secret(ctx, model.Namespace, model.Spec.APIKeySecret) - if err != nil { - return err - } - var credentials struct { - Type string `json:"type"` - ProjectID string `json:"project_id"` - TokenURI string `json:"token_uri"` - } - if err := json.Unmarshal(secret.Data[model.Spec.APIKeySecretKey], &credentials); err != nil { - return translator.NewValidationError("decode Claude Vertex credentials: %v", err) - } - if credentials.Type != "service_account" { - return translator.NewValidationError("Claude Vertex credentials must be a service_account key in the first release") - } - if credentials.ProjectID != model.Spec.AnthropicVertexAI.ProjectID { - return translator.NewValidationError("Claude Vertex credential project_id must match anthropicVertexAI.projectID") - } - if credentials.TokenURI != "" { - parsed, err := url.Parse(credentials.TokenURI) - if err != nil || parsed.Scheme != "https" || parsed.Hostname() != "oauth2.googleapis.com" { - return translator.NewValidationError("Claude Vertex credential token_uri must use https://oauth2.googleapis.com") - } - } return nil } diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index e8e6104bca..3df51071ba 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" "istio.io/istio/pkg/kube/krt" "istio.io/istio/pkg/kube/krt/krttest" corev1 "k8s.io/api/core/v1" @@ -18,6 +19,7 @@ import ( configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/internal/translator" claudeconfig "github.com/kagent-dev/kagent/go/harness/claude/config" @@ -28,12 +30,14 @@ const credentialValue = "credential-must-not-be-serialized" func TestCompileProviderCredentials(t *testing.T) { tests := []struct { - name string - model v1alpha3.ModelConfigSpec - secretData map[string][]byte - wantEnv map[string]string - wantEgress []string - wantErr string + name string + model v1alpha3.ModelConfigSpec + secretData map[string][]byte + wantEnv map[string]string + wantEgress []string + wantCredentials []egress.Credential + wantErr string + authorities map[string]string }{ { name: "Actor JWT Anthropic", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", ActorJWT: &v1alpha3.ActorJWT{Audiences: []string{"model"}}}, @@ -78,19 +82,43 @@ func TestCompileProviderCredentials(t *testing.T) { wantEgress: []string{"http://kagent-controller.kagent:8083", "https://bedrock-runtime.us-west-2.amazonaws.com:443"}, }, { - name: "Anthropic Vertex AI", + // k8s.io would set the stored service account key as the bearer token. + name: "Anthropic Vertex AI on the Kubernetes class", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, - secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`)}, - wantErr: "cannot use gateway header injection", + secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","private_key":"` + credentialValue + `"}`)}, + wantErr: "set runtimeDefaults.modelCredentialAuthorities.AnthropicVertexAI", + }, + { + name: "Anthropic Vertex AI minting authority", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, + secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","private_key":"` + credentialValue + `"}`)}, + authorities: map[string]string{"AnthropicVertexAI": "tokens.example.com"}, + wantEnv: map[string]string{claudeconfig.UseVertexEnvName: "1", claudeconfig.SkipVertexAuthEnvName: "1", + claudeconfig.VertexProjectEnvName: "project", claudeconfig.VertexRegionEnvName: "us-east5", + claudeconfig.AnthropicCustomHeadersEnvName: "Authorization: Bearer " + translator.CredentialPlaceholder}, + wantEgress: []string{"http://kagent-controller.kagent:8083", "https://us-east5-aiplatform.googleapis.com:443"}, + wantCredentials: []egress.Credential{{Origin: "https://us-east5-aiplatform.googleapis.com:443", Header: "authorization", Prefix: "Bearer ", + URI: "ate-secret://tokens.example.com/default/test/model-auth/credentials.json"}}, + }, + { + // Ambient Google credentials do not reach an actor. + name: "Anthropic Vertex AI without a Secret", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, + wantErr: "requires apiKeySecret and apiKeySecretKey", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input, reader := testInput(t, tt.model, tt.secretData) - revision, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(context.Background(), input) + settings := testRuntimeDefaults() + settings.ModelCredentialAuthorities = tt.authorities + revision, err := NewCompiler(krt.TestingDummyContext{}, reader, settings).Compile(t.Context(), input) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected unsupported credential error, got %v", err) @@ -125,6 +153,10 @@ func TestCompileProviderCredentials(t *testing.T) { if !reflect.DeepEqual(revision.EgressDestinations, tt.wantEgress) { t.Errorf("egress = %v", revision.EgressDestinations) } + if tt.wantCredentials != nil { + require.Equal(t, tt.wantCredentials, revision.Credentials) + } + require.NotContains(t, gotEnvironment, claudeconfig.GoogleApplicationCredentialsEnvName) if bytes.Contains(revision.ConfigJSON, []byte(credentialValue)) || bytes.Contains(revision.Provenance, []byte(credentialValue)) { t.Fatal("compiled config or provenance contains credential material") } @@ -132,7 +164,7 @@ func TestCompileProviderCredentials(t *testing.T) { t.Fatalf("provenance contains gateway-managed Secret: %s", revision.Provenance) } - again, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(context.Background(), input) + again, err := NewCompiler(krt.TestingDummyContext{}, reader, settings).Compile(t.Context(), input) if err != nil || !reflect.DeepEqual(revision, again) { t.Fatalf("compilation is not deterministic: %v", err) } @@ -183,6 +215,21 @@ func TestCompileRejectsProviderOwnedHarnessEnvironment(t *testing.T) { } } +// The Vertex AI provider sets ANTHROPIC_CUSTOM_HEADERS to the placeholder +// bearer the gateway replaces, so a Harness cannot set it beside that provider. +func TestCompileRejectsHarnessEnvironmentTheProviderSets(t *testing.T) { + model := v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"}}} + input, reader := testInput(t, model, map[string][]byte{"credentials.json": []byte(credentialValue)}) + for _, name := range []string{claudeconfig.AnthropicCustomHeadersEnvName, claudeconfig.SkipVertexAuthEnvName, claudeconfig.GoogleApplicationCredentialsEnvName} { + input.Harness.Spec.Env = []v1alpha3.RuntimeEnvVar{{Name: name, Value: "x-other: value"}} + _, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(t.Context(), input) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation, name) + } +} + func TestCompileAllowsRuntimeOTELEndpoint(t *testing.T) { model := v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index e02da8e2d5..c3bbab9222 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -131,7 +131,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build Codex revision provenance: %w", err) } - environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, nil, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, nil, environment, c.settings) if err != nil { return nil, err } diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index caa5614f73..79dddf7bea 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -20,6 +20,7 @@ import ( runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/internal/translator" byotranslator "github.com/kagent-dev/kagent/go/core/internal/translator/byo" @@ -209,15 +210,20 @@ func compiler(t *testing.T, objects ...any) *translator.Compiler { } func compilerWithImages(t *testing.T, images *configv1.HarnessImagesConfig, objects ...any) *translator.Compiler { + t.Helper() + return compilerWithSettings(t, images, testRuntimeDefaults(), objects...) +} + +func compilerWithSettings(t *testing.T, images *configv1.HarnessImagesConfig, settings *configv1.RuntimeDefaultsConfig, objects ...any) *translator.Compiler { t.Helper() collections := mockCollections(t, append(objects, defaultWorkerPool())...) ctx := krt.TestingDummyContext{} return translator.NewCompiler(ctx, collections, map[translator.HarnessType]translator.HarnessCompiler{ - translator.HarnessTypeKagent: kagenttranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - translator.HarnessTypeCodex: codextranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - translator.HarnessTypeClaude: claudetranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - translator.HarnessTypeBYO: byotranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - }, images, testRuntimeDefaults()) + translator.HarnessTypeKagent: kagenttranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeCodex: codextranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeClaude: claudetranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeBYO: byotranslator.NewCompiler(ctx, collections, settings), + }, images, settings) } func defaultWorkerPool() *atev1.WorkerPool { @@ -664,6 +670,120 @@ func TestCompilerPermitsBYOWithoutModelConfig(t *testing.T) { require.Nil(t, adapter.input.Root.ResolvedModelConfig) } +func gatewayCredentialHarness() *v1alpha3.Harness { + return &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: new("example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, + SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } +} + +// A Vertex AI ModelConfig with a service account key compiles for the kagent +// harness: the key is bound on the Google access-token provider class for the +// gateway to exchange, the runtime skips Google authentication of its own, and +// nothing of the key reaches the revision. +func TestCompileAgentBindsVertexAIAtGateway(t *testing.T) { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"} + for _, tt := range []struct { + name string + spec v1alpha3.ModelConfigSpec + }{ + {"Gemini", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-pro", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}}}, + {"Anthropic", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}}, + } { + for _, authority := range []struct { + name, set, want string + data string + wantErr string + }{ + // k8s.io would set the stored service account key as the bearer token. + {"default authority", "", "", `{"type":"service_account","private_key":"private-key-must-not-be-serialized"}`, "set runtimeDefaults.modelCredentialAuthorities." + string(tt.spec.Provider)}, + {"minting authority", "tokens.example.com", "tokens.example.com", `{"type":"service_account","private_key":"private-key-must-not-be-serialized"}`, ""}, + } { + t.Run(tt.name+"/"+authority.name, func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "vertex-auth", Namespace: "test"}, + Data: map[string][]byte{"credentials.json": []byte(authority.data)}, + } + model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "default-model", Namespace: "test"}, Spec: tt.spec} + model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = secret.Name, "credentials.json" + settings := testRuntimeDefaults() + if authority.set != "" { + settings.ModelCredentialAuthorities = map[string]string{string(tt.spec.Provider): authority.set} + } + harness := gatewayCredentialHarness() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "help"}, + } + spec, err := compilerWithSettings(t, testHarnessImages, settings, model, secret, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + if authority.wantErr != "" { + require.ErrorContains(t, err, authority.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, []egress.Credential{{ + Origin: "https://us-east5-aiplatform.googleapis.com:443", Header: "authorization", Prefix: "Bearer ", + URI: "ate-secret://" + authority.want + "/default/test/vertex-auth/credentials.json", + }}, spec.Credentials) + environment := map[string]string{} + for _, variable := range spec.Environment { + require.Nil(t, variable.ValueFrom, "%s is unresolved", variable.Name) + environment[variable.Name] = variable.Value + } + require.Equal(t, "true", environment["KAGENT_SKIP_VERTEX_AUTH"]) + require.NotContains(t, environment, "GOOGLE_APPLICATION_CREDENTIALS") + require.Contains(t, spec.EgressDestinations, "https://us-east5-aiplatform.googleapis.com:443") + require.NotContains(t, spec.EgressDestinations, "https://oauth2.googleapis.com:443") + require.False(t, bytes.Contains(spec.ConfigJSON, []byte("must-not-be-serialized")) || bytes.Contains(spec.Provenance, []byte("must-not-be-serialized")), "runtime revision contains credential material") + }) + } + } +} + +// Credentials the runtime would have to sign or exchange itself have no +// gateway binding, so the compiler refuses them rather than placing them in +// the runtime. +func TestCompileAgentRejectsRuntimeSigningCredentials(t *testing.T) { + for _, tt := range []struct { + name string + spec v1alpha3.ModelConfigSpec + data map[string][]byte + }{ + { + name: "Bedrock IAM keys", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1"}}, + data: map[string][]byte{"AWS_ACCESS_KEY_ID": []byte("access"), "AWS_SECRET_ACCESS_KEY": []byte("secret")}, + }, + { + name: "SAP AI Core client credentials", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderSAPAICore, Model: "gpt-4o", SAPAICore: &v1alpha3.SAPAICoreConfig{BaseURL: "https://api.ai.example.com", ResourceGroup: "default", AuthURL: "https://auth.example.com"}}, + data: map[string][]byte{"client_id": []byte("client"), "client_secret": []byte("secret")}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test"}, Data: tt.data} + model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "default-model", Namespace: "test"}, Spec: tt.spec} + model.Spec.APIKeySecret = secret.Name + harness := gatewayCredentialHarness() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: model.Name}, SystemPrompt: "help"}, + } + _, err := compiler(t, model, secret, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, "cannot use gateway header injection") + }) + } +} + func TestCompileAgentInjectsCredentialsAtGateway(t *testing.T) { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "mcp-auth", Namespace: "test"}, diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 6c5a47d087..b462edd516 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -11,6 +11,7 @@ import ( "k8s.io/utils/ptr" "github.com/kagent-dev/kagent/go/adk/pkg/models" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" @@ -101,18 +102,18 @@ func canonicalOrigin(endpoint string) (*url.URL, error) { // outside the agent tree (such as memory embeddings) are supplied separately. // Compilation accepts resolved objects directly, so enforce credential // exclusivity here as well as at Kubernetes admission. -func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, environment []corev1.EnvVar) ([]corev1.EnvVar, []egress.Credential, []egresspolicy.GatewayTarget, error) { +func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, environment []corev1.EnvVar, settings *configv1.RuntimeDefaultsConfig) ([]corev1.EnvVar, []egress.Credential, []egresspolicy.GatewayTarget, error) { var bindings []egress.Credential boundModels := map[string]bool{} boundMCP := map[string]bool{} var passthrough []passthroughClient var directEndpoints []string - bind := func(rawURL, header, prefix, namespace, name, key string) error { + bind := func(rawURL, header, prefix, authority, namespace, name, key string) error { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" { return NewValidationError("credential destination must be an absolute HTTP(S) URL without user information or fragment") } - uri := "ate-secret://k8s.io/default/" + namespace + "/" + name + "/" + key + uri := egress.CredentialURI(authority, namespace, name, key) bindings = append(bindings, egress.Credential{Origin: egress.Origin(u), Header: header, Prefix: prefix, URI: uri}) return nil } @@ -153,7 +154,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, for _, ref := range tool.Server.Spec.HeadersFrom { if ref.ValueFrom != nil && ref.ValueFrom.Type == v1alpha3.SecretValueSource { boundMCP[ref.ValueFrom.Name+"\x00"+ref.ValueFrom.Key] = true - if err := bind(tool.Server.Spec.URL, ref.Name, "", tool.Server.Namespace, ref.ValueFrom.Name, ref.ValueFrom.Key); err != nil { + if err := bind(tool.Server.Spec.URL, ref.Name, "", egress.KubernetesSecretAuthority, tool.Server.Namespace, ref.ValueFrom.Name, ref.ValueFrom.Key); err != nil { return err } } @@ -175,27 +176,30 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, if _, err := ActorJWTEnvironment(resolved); err != nil { return nil, nil, nil, err } - _, endpoint, header, prefix := modelCredentialTarget(resolved) - origin, err := canonicalOrigin(endpoint) + target := modelCredentialTarget(resolved, nil) + origin, err := canonicalOrigin(target.endpoint) if err != nil { return nil, nil, nil, err } - bindings = append(bindings, egress.Credential{Origin: origin.String(), Header: header, Prefix: prefix, ActorJWT: actorJWTSource(model.Spec.ActorJWT)}) + bindings = append(bindings, egress.Credential{Origin: origin.String(), Header: target.header, Prefix: target.prefix, ActorJWT: actorJWTSource(model.Spec.ActorJWT)}) continue } if ptr.Deref(model.Spec.TokenPassthrough, false) || model.Spec.APIKeySecret == "" { continue } - name, endpoint, header, prefix := modelCredentialTarget(resolved) - if name == "" { + target := modelCredentialTarget(resolved, settings) + if target.name == "" { continue } + if target.name == vertexAICredentialName && target.authority == egress.KubernetesSecretAuthority { + return nil, nil, nil, NewValidationError("ModelConfig %q provider %s needs a credential provider class that serves Vertex AI access tokens; set runtimeDefaults.modelCredentialAuthorities.%s", model.Name, model.Spec.Provider, model.Spec.Provider) + } key := model.Spec.APIKeySecretKey if model.Spec.Provider == v1alpha3.ModelProviderBedrock { key = env.AWSBearerTokenBedrock.Name() bearer := false for _, variable := range environment { - if variable.Name == name && variable.ValueFrom != nil && variable.ValueFrom.SecretKeyRef != nil && variable.ValueFrom.SecretKeyRef.Name == model.Spec.APIKeySecret && variable.ValueFrom.SecretKeyRef.Key == key { + if variable.Name == target.name && variable.ValueFrom != nil && variable.ValueFrom.SecretKeyRef != nil && variable.ValueFrom.SecretKeyRef.Name == model.Spec.APIKeySecret && variable.ValueFrom.SecretKeyRef.Key == key { bearer = true } } @@ -203,10 +207,10 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, continue } } - if err := bind(endpoint, header, prefix, model.Namespace, model.Spec.APIKeySecret, key); err != nil { + if err := bind(target.endpoint, target.header, target.prefix, target.authority, model.Namespace, model.Spec.APIKeySecret, key); err != nil { return nil, nil, nil, err } - boundModels[name+"\x00"+model.Spec.APIKeySecret+"\x00"+key] = true + boundModels[target.name+"\x00"+model.Spec.APIKeySecret+"\x00"+key] = true } for _, resolved := range models { spec := resolved.Config.Spec @@ -214,9 +218,12 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, directEndpoints = append(directEndpoints, modelEndpoints(resolved, environment)...) continue } - _, endpoint, header, prefix := modelCredentialTarget(resolved) + if VertexAIConfig(&spec) != nil { + return nil, nil, nil, NewValidationError("ModelConfig %q provider %s does not support tokenPassthrough", resolved.Config.Name, spec.Provider) + } + target := modelCredentialTarget(resolved, settings) passthrough = append(passthrough, passthroughClient{ - client: fmt.Sprintf("ModelConfig %q", resolved.Config.Name), endpoint: endpoint, header: header, prefix: prefix, + client: fmt.Sprintf("ModelConfig %q", resolved.Config.Name), endpoint: target.endpoint, header: target.header, prefix: target.prefix, explicit: slices.Collect(maps.Keys(spec.DefaultHeaders)), }) } @@ -248,8 +255,25 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, return result, access.Credentials, access.TokenPassthroughTargets, nil } -func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, header, prefix string) { +// vertexAICredentialName keys a Vertex AI binding. No variable carries the +// Secret, and the space keeps the name from matching any environment variable. +const vertexAICredentialName = "Vertex AI access token" + +// credentialTarget is where a ModelConfig credential is injected and which +// Substrate credential provider class resolves it. name is the SDK variable the +// placeholder replaces and keys the binding; an empty name means the provider +// has no gateway credential. +type credentialTarget struct { + name, endpoint, header, prefix, authority string +} + +func modelCredentialTarget(resolved *ResolvedModelConfig, settings *configv1.RuntimeDefaultsConfig) credentialTarget { spec := resolved.Config.Spec + target := credentialTarget{authority: settings.GetModelCredentialAuthorities()[string(spec.Provider)]} + if target.authority == "" { + target.authority = egress.KubernetesSecretAuthority + } + var name, endpoint, header, prefix string switch spec.Provider { case v1alpha3.ModelProviderOpenAI: name, endpoint, header, prefix = env.OpenAIAPIKey.Name(), "https://api.openai.com", "authorization", "Bearer " @@ -268,6 +292,13 @@ func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, heade } case v1alpha3.ModelProviderGemini: name, endpoint, header = env.GoogleAPIKey.Name(), "https://generativelanguage.googleapis.com", "x-goog-api-key" + case v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI: + // Vertex AI accepts only OAuth 2.0 access tokens, which the configured + // provider class mints from the service account key the Secret holds. + name, header, prefix = vertexAICredentialName, "authorization", "Bearer " + if vertex := VertexAIConfig(&spec); vertex != nil { + endpoint = "https://" + VertexHostname(vertex.Location) + } case v1alpha3.ModelProviderMistral: name, endpoint, header, prefix = env.MistralAPIKey.Name(), "https://api.mistral.ai", "authorization", "Bearer " if spec.Mistral != nil && spec.Mistral.BaseURL != nil && *spec.Mistral.BaseURL != "" { @@ -305,7 +336,8 @@ func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, heade header = "x-api-key" } } - return name, endpoint, header, prefix + target.name, target.endpoint, target.header, target.prefix = name, endpoint, header, prefix + return target } // modelEndpoints returns every endpoint a model client calls, keyed or not. @@ -332,7 +364,7 @@ func modelEndpoints(resolved *ResolvedModelConfig, environment []corev1.EnvVar) return nil } var endpoints []string - if _, endpoint, _, _ := modelCredentialTarget(resolved); endpoint != "" { + if endpoint := modelCredentialTarget(resolved, nil).endpoint; endpoint != "" { endpoints = append(endpoints, endpoint) } if spec.Provider == v1alpha3.ModelProviderSAPAICore && spec.SAPAICore != nil { @@ -341,31 +373,14 @@ func modelEndpoints(resolved *ResolvedModelConfig, environment []corev1.EnvVar) endpoints = append(endpoints, spec.SAPAICore.AuthURL) } } - var vertex *v1alpha3.BaseVertexAIConfig - switch { - case spec.Provider == v1alpha3.ModelProviderGeminiVertexAI && spec.GeminiVertexAI != nil: - vertex = &spec.GeminiVertexAI.BaseVertexAIConfig - case spec.Provider == v1alpha3.ModelProviderAnthropicVertexAI && spec.AnthropicVertexAI != nil: - vertex = &spec.AnthropicVertexAI.BaseVertexAIConfig - } - if vertex != nil { - endpoints = append(endpoints, "https://"+VertexHostname(vertex.Location), "https://oauth2.googleapis.com") + // Without apiKeySecret, Vertex AI clients exchange ambient Google + // credentials for access tokens themselves. + if VertexAIConfig(&spec) != nil && spec.APIKeySecret == "" { + endpoints = append(endpoints, "https://oauth2.googleapis.com") } return endpoints } -// VertexHostname returns the Vertex AI API host for a location. -func VertexHostname(location string) string { - switch location { - case "global": - return "aiplatform.googleapis.com" - case "us", "eu": - return "aiplatform." + location + ".rep.googleapis.com" - default: - return location + "-aiplatform.googleapis.com" - } -} - // ActorJWTEnvironment supplies the existing SDK credential placeholder for an // Actor-authenticated model. The token itself is owned by Substrate's gateway. func ActorJWTEnvironment(resolved *ResolvedModelConfig) (corev1.EnvVar, error) { @@ -373,22 +388,22 @@ func ActorJWTEnvironment(resolved *ResolvedModelConfig) (corev1.EnvVar, error) { switch spec.Provider { case v1alpha3.ModelProviderBedrock: return corev1.EnvVar{}, NewValidationError("Bedrock requires AWS credentials and does not support actorJwt") - case v1alpha3.ModelProviderGemini, v1alpha3.ModelProviderOllama: + case v1alpha3.ModelProviderGemini, v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI, v1alpha3.ModelProviderOllama: return corev1.EnvVar{}, NewValidationError("%s does not support actorJwt", spec.Provider) } if spec.TokenPassthrough != nil || spec.APIKeySecret != "" || spec.APIKeySecretKey != "" { return corev1.EnvVar{}, NewValidationError("actorJwt, apiKeySecret and tokenPassthrough are mutually exclusive") } - name, endpoint, header, _ := modelCredentialTarget(resolved) - if name == "" || endpoint == "" || header == "" { + target := modelCredentialTarget(resolved, nil) + if target.name == "" || target.endpoint == "" || target.header == "" { return corev1.EnvVar{}, NewValidationError("ModelConfig %q actorJwt requires a provider with a credential header and endpoint", resolved.Config.Name) } for key := range spec.DefaultHeaders { - if strings.EqualFold(key, header) { - return corev1.EnvVar{}, NewValidationError("defaultHeaders cannot override the actorJwt credential header %q", header) + if strings.EqualFold(key, target.header) { + return corev1.EnvVar{}, NewValidationError("defaultHeaders cannot override the actorJwt credential header %q", target.header) } } - return corev1.EnvVar{Name: name, Value: CredentialPlaceholder}, nil + return corev1.EnvVar{Name: target.name, Value: CredentialPlaceholder}, nil } func actorJWTSource(config *v1alpha3.ActorJWT) *egress.ActorJWT { diff --git a/go/core/internal/translator/credentials_test.go b/go/core/internal/translator/credentials_test.go index e816b9086b..adc67512af 100644 --- a/go/core/internal/translator/credentials_test.go +++ b/go/core/internal/translator/credentials_test.go @@ -7,7 +7,9 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) @@ -35,7 +37,7 @@ func TestCompileCredentialDestinations(t *testing.T) { } input := credentialInput(test.spec) environment := []corev1.EnvVar{credentialEnv(test.env, "auth", test.spec.APIKeySecretKey)} - got, bindings, _, err := CompileCredentials(input, nil, environment) + got, bindings, _, err := CompileCredentials(input, nil, environment, nil) require.NoError(t, err) require.Equal(t, CredentialPlaceholder, got[0].Value) require.Nil(t, got[0].ValueFrom) @@ -49,37 +51,142 @@ func TestCompileCredentialDestinations(t *testing.T) { } } +// A Vertex AI key is bound on the provider class configured for its provider, +// which mints the bearer token the gateway sets; no variable carries the key, +// so the environment compiles unchanged. +func TestCompileCredentialsBindsVertexAIWithoutEnvironment(t *testing.T) { + for _, test := range []struct { + name string + spec v1alpha3.ModelConfigSpec + host string + }{ + {"Gemini regional", vertexSpec(v1alpha3.ModelProviderGeminiVertexAI, "us-central1"), "us-central1-aiplatform.googleapis.com"}, + {"Anthropic global", vertexSpec(v1alpha3.ModelProviderAnthropicVertexAI, "global"), "aiplatform.googleapis.com"}, + {"Anthropic multi-region", vertexSpec(v1alpha3.ModelProviderAnthropicVertexAI, "eu"), "aiplatform.eu.rep.googleapis.com"}, + } { + t.Run(test.name, func(t *testing.T) { + spec := test.spec + spec.APIKeySecret, spec.APIKeySecretKey = "auth", "credentials.json" + settings := &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{string(spec.Provider): "tokens.example.com"}} + environment := []corev1.EnvVar{{Name: "GOOGLE_CLOUD_LOCATION", Value: "somewhere"}} + got, bindings, _, err := CompileCredentials(credentialInput(spec), nil, environment, settings) + require.NoError(t, err) + require.Equal(t, environment, got) + require.Equal(t, []egress.Credential{{ + Origin: "https://" + test.host + ":443", Header: "authorization", Prefix: "Bearer ", + URI: "ate-secret://tokens.example.com/default/team/auth/credentials.json", + }}, bindings) + }) + } +} + +// k8s.io would set the stored service account key itself as the bearer token, +// so a Vertex AI key needs a class configured for its provider. +func TestCompileCredentialsRejectsVertexAIOnKubernetesClass(t *testing.T) { + for _, provider := range []v1alpha3.ModelProvider{v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI} { + for _, settings := range []*configv1.RuntimeDefaultsConfig{ + nil, + {ModelCredentialAuthorities: map[string]string{"OpenAI": "tokens.example.com"}}, + {ModelCredentialAuthorities: map[string]string{string(provider): "k8s.io"}}, + } { + t.Run(string(provider), func(t *testing.T) { + spec := vertexSpec(provider, "us-central1") + spec.APIKeySecret, spec.APIKeySecretKey = "auth", "credentials.json" + compiled, bindings, _, err := CompileCredentials(credentialInput(spec), nil, nil, settings) + require.ErrorContains(t, err, "set runtimeDefaults.modelCredentialAuthorities."+string(provider)) + require.Nil(t, compiled) + require.Nil(t, bindings) + }) + } + } +} + +func TestCompileCredentialsRejectsVertexAITokenPassthrough(t *testing.T) { + for _, provider := range []v1alpha3.ModelProvider{v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI} { + t.Run(string(provider), func(t *testing.T) { + spec := vertexSpec(provider, "us-central1") + spec.TokenPassthrough = new(true) + _, _, origins, err := CompileCredentials(credentialInput(spec), nil, nil, nil) + require.ErrorContains(t, err, "does not support tokenPassthrough") + require.Nil(t, origins) + }) + } +} + +func vertexSpec(provider v1alpha3.ModelProvider, location string) v1alpha3.ModelConfigSpec { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: location} + if provider == v1alpha3.ModelProviderGeminiVertexAI { + return v1alpha3.ModelConfigSpec{Provider: provider, GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}} + } + return v1alpha3.ModelConfigSpec{Provider: provider, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}} +} + +func TestCompileCredentialsModelAuthority(t *testing.T) { + settings := &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{"OpenAI": "tokens.example.com"}} + server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "mcp", Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://mcp.example.com/mcp", + HeadersFrom: []v1alpha3.ValueRef{{Name: "X-API-Key", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.SecretValueSource, Name: "mcp", Key: "token", + }}}, + }} + input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "auth", APIKeySecretKey: "token"}) + input.Root.MCPTools = []ResolvedMCPTool{{Server: server}} + environment := []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token"), credentialEnv("KAGENT_CREDENTIAL_MCP", "mcp", "token")} + + _, bindings, _, err := CompileCredentials(input, nil, environment, settings) + require.NoError(t, err) + require.Equal(t, []egress.Credential{ + {Origin: "https://api.openai.com:443", Header: "authorization", Prefix: "Bearer ", URI: "ate-secret://tokens.example.com/default/team/auth/token"}, + // MCP headers are not ModelConfig credentials and keep the Kubernetes class. + {Origin: "https://mcp.example.com:443", Header: "x-api-key", URI: "ate-secret://k8s.io/default/team/mcp/token"}, + }, bindings) + + settings.ModelCredentialAuthorities["OpenAI"] = "192.0.2.1" + compiled, _, _, err := CompileCredentials(input, nil, environment, settings) + require.ErrorContains(t, err, "not an IP address") + require.Nil(t, compiled) +} + +func TestCompileCredentialsRejectsRuntimeSigningSecrets(t *testing.T) { + for _, name := range []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "SAP_AI_CORE_CLIENT_ID", "SAP_AI_CORE_CLIENT_SECRET", "GOOGLE_APPLICATION_CREDENTIALS"} { + t.Run(name, func(t *testing.T) { + _, _, _, err := CompileCredentials(credentialInput(v1alpha3.ModelConfigSpec{}), nil, []corev1.EnvVar{credentialEnv(name, "auth", "token")}, nil) + require.ErrorContains(t, err, "cannot use gateway header injection") + }) + } +} + func TestCompileCredentialsRejectsConflictingSharedModels(t *testing.T) { spec := v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "root", APIKeySecretKey: "token"} input := credentialInput(spec) spec.APIKeySecret = "child" input.Root.Shared = []AgentInputBinding{{Agent: credentialInput(spec).Root}} // The ADK environment is deduplicated, but every model still needs its own binding. - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}, nil) require.ErrorContains(t, err, "conflicting credentials") require.Nil(t, environment) input.Root.Shared[0].Agent.ResolvedModelConfig.Config.Spec.OpenAI = &v1alpha3.OpenAIConfig{BaseURL: "https://child.example.com/v1"} - _, bindings, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}) + _, bindings, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}, nil) require.NoError(t, err) require.Len(t, bindings, 2) } func TestCompileCredentialsRejectsLocalSecrets(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{}) - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("AWS_SECRET_ACCESS_KEY", "auth", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("AWS_SECRET_ACCESS_KEY", "auth", "token")}, nil) require.ErrorContains(t, err, "cannot use gateway header injection") require.Nil(t, environment) } func TestCompileCredentialsPreservesPassthrough(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: new(true)}) - _, bindings, _, err := CompileCredentials(input, nil, nil) + _, bindings, _, err := CompileCredentials(input, nil, nil, nil) require.NoError(t, err) require.Empty(t, bindings) input.Root.Shared = []AgentInputBinding{{Agent: credentialInput(v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "auth", APIKeySecretKey: "token", }).Root}} - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}, nil) require.ErrorContains(t, err, "mixes tokenPassthrough with clients that do not use it") require.Nil(t, environment) } @@ -168,16 +275,16 @@ func TestModelCredentialTargetOllamaCloud(t *testing.T) { Spec: tt.spec, }, } - name, endpoint, _, _ := modelCredentialTarget(resolved) - require.Equal(t, tt.wantName, name) - require.Equal(t, tt.wantEndpoint, endpoint) + target := modelCredentialTarget(resolved, nil) + require.Equal(t, tt.wantName, target.name) + require.Equal(t, tt.wantEndpoint, target.endpoint) }) } } func TestPassthroughCredentialUsesCanonicalOrigin(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: new(true), OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://API.OPENAI.COM.:0443/v1"}}) - environment, credentials, targets, err := CompileCredentials(input, nil, nil) + environment, credentials, targets, err := CompileCredentials(input, nil, nil, nil) require.NoError(t, err) require.Empty(t, environment) require.Empty(t, credentials) @@ -190,7 +297,7 @@ func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { }} input := credentialInput(v1alpha3.ModelConfigSpec{}) input.Root.MCPTools = []ResolvedMCPTool{{Server: passthrough}} - _, _, targets, err := CompileCredentials(input, nil, nil) + _, _, targets, err := CompileCredentials(input, nil, nil, nil) require.NoError(t, err) require.Equal(t, []egresspolicy.GatewayTarget{{Origin: "https://mcp.example.com:443", Header: "authorization", Prefix: "Bearer "}}, targets) @@ -202,7 +309,7 @@ func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { }}}, }} input.Root.MCPTools = append(input.Root.MCPTools, ResolvedMCPTool{Server: keyed}) - environment, _, _, err := CompileCredentials(input, nil, nil) + environment, _, _, err := CompileCredentials(input, nil, nil, nil) require.ErrorContains(t, err, "mixes tokenPassthrough with clients that do not use it") require.Nil(t, environment) } @@ -303,7 +410,7 @@ func TestCompileCredentialsTokenPassthroughTargets(t *testing.T) { if extra != nil { extra = append(extra, input.Root.ResolvedModelConfig) } - _, _, targets, err := CompileCredentials(input, extra, test.environment) + _, _, targets, err := CompileCredentials(input, extra, test.environment, nil) if test.wantErr != "" { require.ErrorContains(t, err, test.wantErr) var validation *ValidationError @@ -322,11 +429,11 @@ func TestSameNamedSecretsInDifferentNamespaces(t *testing.T) { child := credentialInput(spec).Root child.ResolvedModelConfig.Config.Namespace = "shared" input.Root.Shared = []AgentInputBinding{{Agent: child}} - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}, nil) require.Nil(t, environment) require.ErrorContains(t, err, "conflicting credentials", "same Secret name does not make credentials equivalent across namespaces") child.ResolvedModelConfig.Config.Spec.OpenAI = &v1alpha3.OpenAIConfig{BaseURL: "https://shared.example.com/v1"} - _, credentials, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}) + _, credentials, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}, nil) require.NoError(t, err) var uris []string for _, credential := range credentials { diff --git a/go/core/internal/translator/egress.go b/go/core/internal/translator/egress.go index 48689aa0df..a8af84db1d 100644 --- a/go/core/internal/translator/egress.go +++ b/go/core/internal/translator/egress.go @@ -127,7 +127,7 @@ func compileEgressPolicy(namespace string, policy *v1alpha3.EgressPolicy, getSec if secret.Name == "" || secret.Key == "" || (secret.Optional != nil && *secret.Optional) { return egress.Access{}, NewValidationError("requires a non-optional Secret name and key") } - binding.URI = "ate-secret://k8s.io/default/" + namespace + "/" + secret.Name + "/" + secret.Key + binding.URI = egress.CredentialURI(egress.KubernetesSecretAuthority, namespace, secret.Name, secret.Key) if _, err := egress.CanonicalCredentials([]egress.Credential{binding}); err != nil { return egress.Access{}, err } diff --git a/go/core/internal/translator/kagent/compiler.go b/go/core/internal/translator/kagent/compiler.go index dfb51bbce0..cfe42aac7b 100644 --- a/go/core/internal/translator/kagent/compiler.go +++ b/go/core/internal/translator/kagent/compiler.go @@ -63,7 +63,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, compiled.Models, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, compiled.Models, environment, c.settings) if err != nil { return nil, err } diff --git a/go/core/internal/translator/vertex.go b/go/core/internal/translator/vertex.go new file mode 100644 index 0000000000..7aa0c48e51 --- /dev/null +++ b/go/core/internal/translator/vertex.go @@ -0,0 +1,39 @@ +package translator + +import ( + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// VertexAIConfig is the project and location of a Vertex AI ModelConfig, for +// either provider that reaches Vertex AI; nil for every other provider or an +// incomplete spec. +func VertexAIConfig(spec *v1alpha3.ModelConfigSpec) *v1alpha3.BaseVertexAIConfig { + switch spec.Provider { + case v1alpha3.ModelProviderGeminiVertexAI: + if spec.GeminiVertexAI != nil { + return &spec.GeminiVertexAI.BaseVertexAIConfig + } + case v1alpha3.ModelProviderAnthropicVertexAI: + if spec.AnthropicVertexAI != nil { + return &spec.AnthropicVertexAI.BaseVertexAIConfig + } + } + return nil +} + +// VertexHostname returns the Vertex AI API host for a location. +func VertexHostname(location string) string { + switch location { + case "global": + return "aiplatform.googleapis.com" + case "us", "eu": + return "aiplatform." + location + ".rep.googleapis.com" + default: + return location + "-aiplatform.googleapis.com" + } +} + +// VertexAIOrigin is the egress origin of VertexHostname. +func VertexAIOrigin(location string) string { + return "https://" + VertexHostname(location) + ":443" +} diff --git a/go/core/pkg/config/helm_test.go b/go/core/pkg/config/helm_test.go index 8c49153290..b5727ecfad 100644 --- a/go/core/pkg/config/helm_test.go +++ b/go/core/pkg/config/helm_test.go @@ -113,6 +113,10 @@ func TestHelmControllerConfig(t *testing.T) { require.True(t, push.Push.AllowHttp) require.True(t, push.Push.AllowPrivateNetworks) require.NotEqual(t, checksum, pushChecksum) + authorities, _, err := render("config:\n runtimeDefaults:\n modelCredentialAuthorities: {GeminiVertexAI: tokens.example.com}\n") + require.NoError(t, err) + require.Equal(t, map[string]string{"GeminiVertexAI": "tokens.example.com"}, authorities.RuntimeDefaults.GetModelCredentialAuthorities()) + require.Equal(t, "http://demo-controller.team.svc.cluster.local:8083", authorities.RuntimeDefaults.GetControlPlaneUrl()) tools, toolsChecksum, err := render("config:\n runtimeDefaults:\n builtinTools:\n disableAskUser: true\n") require.NoError(t, err) require.True(t, tools.GetRuntimeDefaults().GetBuiltinTools().GetDisableAskUser()) diff --git a/go/core/pkg/config/load_test.go b/go/core/pkg/config/load_test.go index 89d2dc2566..fd993616da 100644 --- a/go/core/pkg/config/load_test.go +++ b/go/core/pkg/config/load_test.go @@ -1,10 +1,13 @@ package config_test import ( + "os" + "slices" "testing" "time" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/core/pkg/config" @@ -61,6 +64,11 @@ func TestControllerConfig(t *testing.T) { {"runtime service name", "runtimeDefaults: {telemetry: {resourceAttributes: {service.name: shared}}}", true}, {"runtime service namespace", "runtimeDefaults: {telemetry: {resourceAttributes: {service.namespace: shared}}}", true}, {"runtime resource attributes", "runtimeDefaults: {telemetry: {resourceAttributes: {deployment.environment.name: production}}}", false}, + {"model credential authority", "runtimeDefaults: {modelCredentialAuthorities: {GeminiVertexAI: tokens.example.com, AnthropicVertexAI: tokens.example.com}}", false}, + {"model credential authority for an unknown provider", "runtimeDefaults: {modelCredentialAuthorities: {Vertex: tokens.example.com}}", true}, + {"model credential authority with a port", "runtimeDefaults: {modelCredentialAuthorities: {OpenAI: 'tokens.example.com:50051'}}", true}, + {"uppercase model credential authority", "runtimeDefaults: {modelCredentialAuthorities: {OpenAI: Tokens.Example.com}}", true}, + {"IP model credential authority", "runtimeDefaults: {modelCredentialAuthorities: {OpenAI: 192.0.2.1}}", true}, {"controller service labels", "telemetry: {resourceAttributes: {service.name: controller, service.namespace: control-plane}}", false}, } { t.Run(tc.name, func(t *testing.T) { @@ -80,6 +88,40 @@ func TestControllerConfig(t *testing.T) { } } +// The provider keys modelCredentialAuthorities accepts are the ModelConfig +// provider enum of the published CRD. +func TestModelCredentialAuthorityProvidersMatchModelConfig(t *testing.T) { + type schema struct { + Enum []string `yaml:"enum"` + Properties map[string]schema `yaml:"properties"` + } + type version struct { + Name string `yaml:"name"` + Schema struct { + OpenAPIV3Schema schema `yaml:"openAPIV3Schema"` + } `yaml:"schema"` + } + var crd struct { + Spec struct { + Versions []version `yaml:"versions"` + } `yaml:"spec"` + } + data, err := os.ReadFile("../../../../helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml") + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(data, &crd)) + index := slices.IndexFunc(crd.Spec.Versions, func(v version) bool { return v.Name == "v1alpha3" }) + require.GreaterOrEqual(t, index, 0) + providers := crd.Spec.Versions[index].Schema.OpenAPIV3Schema.Properties["spec"].Properties["provider"].Enum + require.NotEmpty(t, providers) + for _, provider := range providers { + cfg := &configv1.ControllerConfig{} + config.DefaultController(cfg) + setLogins(cfg) + cfg.RuntimeDefaults.ModelCredentialAuthorities = map[string]string{provider: "tokens.example.com"} + require.NoError(t, config.Validate(cfg), provider) + } +} + func TestDatabaseSkipMigrations(t *testing.T) { for _, tc := range []struct { name, input string diff --git a/go/core/pkg/env/providers.go b/go/core/pkg/env/providers.go index ea050c1a9b..5769eac928 100644 --- a/go/core/pkg/env/providers.go +++ b/go/core/pkg/env/providers.go @@ -105,6 +105,16 @@ var ( "Path to Google Cloud service account JSON key file.", ComponentAgentRuntime, ) + + // The compiler sets this for a Vertex AI ModelConfig whose service account + // key the egress gateway exchanges for an access token on every call, the + // counterpart of Claude Code's CLAUDE_CODE_SKIP_VERTEX_AUTH. + SkipVertexAuth = RegisterBoolVar( + "KAGENT_SKIP_VERTEX_AUTH", + false, + "When true, call Vertex AI without Google credentials of the runtime's own; the egress gateway sets the access token.", + ComponentAgentRuntime, + ) ) // AWS / Bedrock diff --git a/go/go.mod b/go/go.mod index 7c46b9db3d..83f78a6142 100644 --- a/go/go.mod +++ b/go/go.mod @@ -68,6 +68,7 @@ require ( go.opentelemetry.io/otel/trace v1.47.0 go.opentelemetry.io/proto/otlp v1.11.1 golang.org/x/net v0.61.0 + golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 @@ -462,7 +463,6 @@ require ( golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect golang.org/x/exp/typeparams v0.0.0-20260718201538-764159d718ef // indirect golang.org/x/mod v0.42.0 // indirect - golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect diff --git a/go/harness/claude/README.md b/go/harness/claude/README.md index c22daf60e4..49ff68bfa8 100644 --- a/go/harness/claude/README.md +++ b/go/harness/claude/README.md @@ -15,12 +15,12 @@ harness. | [`../../core/internal/translator/claude`](../../core/internal/translator/claude) | Translating `Harness`, `AgentTemplate`, model, MCP, plugin, and Secret inputs into a runtime revision and warnings | | [`config/config.go`](config/config.go) | The versioned JSON contract shared by the compiler and runtime, including defaults and reserved environment variables | | [`cmd/main.go`](cmd/main.go) | Actor startup, environment inputs, Claude version validation, continuation-store wiring, and private A2A startup | -| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, MCP config, and ephemeral provider credentials | +| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, and MCP config | | [`internal/driver`](internal/driver) | Claude CLI arguments, stream-JSON parsing, runtime-event translation, cancellation, and process supervision | ## Working -- [x] Anthropic API keys and Amazon Bedrock bearer tokens, injected by the gateway +- [x] Anthropic API keys, Amazon Bedrock bearer tokens, and Vertex AI access tokens, injected by the gateway - [x] Streaming text, tool calls, and tool results over A2A - [x] Task cancellation - [x] Durable Claude session resume between turns @@ -32,8 +32,12 @@ harness. - [x] Native ask-user questions through durable deferred input Credentials use [Substrate gateway injection](../../../docs/architecture/credential-injection.md). -AWS IAM keys and Vertex service-account keys require local signing and are rejected -by the compiler. Harness environment entries accept only literal values. +The Vertex AI access token comes from the credential provider class configured +for `AnthropicVertexAI`; Claude Code runs with `CLAUDE_CODE_SKIP_VERTEX_AUTH` +and never holds the Secret, and +`ANTHROPIC_CUSTOM_HEADERS` carries the placeholder bearer the gateway replaces. +AWS IAM keys require local signing and are rejected by the compiler. Harness +environment entries accept only literal values. Production runs retain Claude's native retry defaults. E2E fixtures opt into zero retries through an internal test switch while upstream denial handling diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go index 684d001145..31aff3a586 100644 --- a/go/harness/claude/config/config.go +++ b/go/harness/claude/config/config.go @@ -26,9 +26,9 @@ const ( DisableUpdatesEnvName = "DISABLE_UPDATES" MaxRetriesEnvName = "CLAUDE_CODE_MAX_RETRIES" GoogleApplicationCredentialsEnvName = "GOOGLE_APPLICATION_CREDENTIALS" - GoogleCredentialsJSONEnvName = "KAGENT_CLAUDE_GOOGLE_CREDENTIALS_JSON" UseBedrockEnvName = "CLAUDE_CODE_USE_BEDROCK" UseVertexEnvName = "CLAUDE_CODE_USE_VERTEX" + SkipVertexAuthEnvName = "CLAUDE_CODE_SKIP_VERTEX_AUTH" AWSRegionEnvName = "AWS_REGION" AWSAccessKeyEnvName = "AWS_ACCESS_KEY_ID" AWSSecretKeyEnvName = "AWS_SECRET_ACCESS_KEY" @@ -36,6 +36,7 @@ const ( AWSBedrockTokenEnvName = "AWS_BEARER_TOKEN_BEDROCK" AnthropicAPIKeyEnvName = "ANTHROPIC_API_KEY" AnthropicBaseURLEnvName = "ANTHROPIC_BASE_URL" + AnthropicCustomHeadersEnvName = "ANTHROPIC_CUSTOM_HEADERS" VertexProjectEnvName = "ANTHROPIC_VERTEX_PROJECT_ID" VertexRegionEnvName = "CLOUD_ML_REGION" SandboxEnvName = "IS_SANDBOX" @@ -50,7 +51,7 @@ func OwnsEnvironment(name string) bool { } switch name { case ClaudeConfigDirEnvName, DisableUpdatesEnvName, MaxRetriesEnvName, GoogleApplicationCredentialsEnvName, - GoogleCredentialsJSONEnvName, UseBedrockEnvName, UseVertexEnvName, AWSRegionEnvName, + UseBedrockEnvName, UseVertexEnvName, SkipVertexAuthEnvName, AWSRegionEnvName, AWSAccessKeyEnvName, AWSSecretKeyEnvName, AWSSessionTokenEnvName, AWSBedrockTokenEnvName, AnthropicAPIKeyEnvName, AnthropicBaseURLEnvName, VertexProjectEnvName, VertexRegionEnvName, SandboxEnvName, "CLAUDE_CODE_ENABLE_TELEMETRY", diff --git a/go/harness/claude/config/config_test.go b/go/harness/claude/config/config_test.go index 999a770a93..a693a89443 100644 --- a/go/harness/claude/config/config_test.go +++ b/go/harness/claude/config/config_test.go @@ -13,6 +13,8 @@ func TestOwnsEnvironment(t *testing.T) { for _, name := range []string{ AnthropicAPIKeyEnvName, ClaudeConfigDirEnvName, + GoogleApplicationCredentialsEnvName, + SkipVertexAuthEnvName, MaxRetriesEnvName, "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", "OTEL_TRACES_EXPORTER", diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index 73c18a9b7a..7ebafbf030 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -4,7 +4,6 @@ package adapter import ( "context" - "encoding/json" "fmt" "maps" "path/filepath" @@ -91,10 +90,6 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { if slices.Contains(input.Environment, env.TestClaudeDisableRetries+"=true") { environment = setEnvironment(environment, config.MaxRetriesEnvName, "0") } - environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) - if err != nil { - return nil, err - } protectedServers := approvalServerNames(cfg.MCPServers) if _, exists := cfg.MCPServers[approvalMCPServerName]; exists { return nil, fmt.Errorf("Claude MCP server name %q is reserved for human approval", approvalMCPServerName) @@ -154,39 +149,6 @@ func approvalServerNames(servers map[string]config.MCPServer) (protected []strin return protected } -func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { - // The compiler injects the Secret value as JSON, while Google ADC expects a - // file path. Keep the credential in ephemeral Actor storage rather than the - // well-known path under /data, which is durable and may be snapshotted. - prefix := config.GoogleCredentialsJSONEnvName + "=" - var credentials string - filtered := make([]string, 0, len(environment)) - for _, item := range environment { - if strings.HasPrefix(item, prefix) { - if credentials != "" { - return nil, fmt.Errorf("%s is configured more than once", config.GoogleCredentialsJSONEnvName) - } - credentials = strings.TrimPrefix(item, prefix) - continue - } - filtered = append(filtered, item) - } - if credentials == "" { - return filtered, nil - } - if !json.Valid([]byte(credentials)) { - return nil, fmt.Errorf("%s must contain valid JSON", config.GoogleCredentialsJSONEnvName) - } - if err := utils.EnsurePrivateDir(directory); err != nil { - return nil, fmt.Errorf("prepare ephemeral credentials directory: %w", err) - } - path := filepath.Join(directory, "google-credentials.json") - if err := utils.ReplacePrivateFile(path, []byte(credentials)); err != nil { - return nil, fmt.Errorf("materialize Google credentials: %w", err) - } - return setEnvironment(filtered, config.GoogleApplicationCredentialsEnvName, path), nil -} - // nativeTelemetryEnvironment turns on Claude Code telemetry for the signals the // controller exports, and its content flags when capture is on. It reports // whether telemetry is on. diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index 834a44c72e..c67732da4e 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -231,29 +231,6 @@ func TestNewRejectsInvalidInput(t *testing.T) { } } -func TestMaterializeGoogleCredentials(t *testing.T) { - dir := t.TempDir() - raw := `{"type":"service_account","project_id":"test"}` - environment, err := materializeGoogleCredentials([]string{"A=1", config.GoogleCredentialsJSONEnvName + "=" + raw}, dir) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "google-credentials.json") - contents, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(contents) != raw { - t.Fatalf("credentials = %q", contents) - } - if len(environment) != 2 || environment[0] != "A=1" || environment[1] != config.GoogleApplicationCredentialsEnvName+"="+path { - t.Fatalf("environment = %v", environment) - } - if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { - t.Fatalf("credential permissions = %v, %v", info, err) - } -} - func TestSetEnvironmentOverridesExistingValue(t *testing.T) { got := setEnvironment([]string{"A=1", "A=2", "B=3"}, "A", "4") want := []string{"B=3", "A=4"} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 213877bcb6..0bd9706d99 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -216,6 +216,14 @@ config: logging: level: info telemetry: {} + # Credential provider class (the ate-secret:// URI authority) the Substrate + # egress gateway resolves a ModelConfig's apiKeySecret through, keyed by + # ModelConfig provider. A provider absent here uses k8s.io, which injects + # the Secret value as stored. Another class must be routed by the gateway. + # GeminiVertexAI and AnthropicVertexAI need a class that mints access tokens. + # modelCredentialAuthorities: + # GeminiVertexAI: tokens.example.com + modelCredentialAuthorities: {} # ============================================================================== # CONTROLLER DEPLOYMENT diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index 2b017f744e..7ac5637315 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -383,6 +383,41 @@ message RuntimeDefaultsConfig { // Per-agent runtime limits, in Kubernetes quantity notation. optional string cpu = 8 [(buf.validate.field).string.min_len = 1]; optional string memory = 9 [(buf.validate.field).string.min_len = 1]; + // The Substrate credential provider class, the authority of the + // ate-secret:// URI, that the egress gateway resolves a ModelConfig's + // apiKeySecret through, keyed by ModelConfig provider. A provider absent + // here uses k8s.io, which injects the Secret value as it is stored. + // GeminiVertexAI and AnthropicVertexAI need a class that mints access tokens. + map model_credential_authorities = 10 [(buf.validate.field).map = { + keys: { + string: { + in: [ + "Anthropic", + "AnthropicVertexAI", + "AzureOpenAI", + "Bedrock", + "Foundry", + "Gemini", + "GeminiVertexAI", + "Mistral", + "Ollama", + "OpenAI", + "SAPAICore" + ] + } + } + values: { + cel: { + id: "model_credential_authorities.ip" + message: "a credential authority must be a DNS name, not an IP address" + expression: "!this.isIp()" + } + string: { + max_len: 253 + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" + } + } + }]; } // BuiltinToolsConfig configures built-in tools independently of skills. diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py b/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py index 3dc1a9fde6..1e965aa387 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py @@ -20,6 +20,7 @@ from kagent.adk._bearer_token import bearer_token from kagent.adk.models._ssl import create_ssl_context +from kagent.adk.models._vertex import vertex_client_kwargs from kagent.adk.types import EmbeddingConfig from ._azure import ( @@ -299,7 +300,7 @@ async def _embed_google(self, texts: List[str]) -> List[List[float]]: from google.genai import types as genai_types if self.config.provider.lower() == "vertex_ai": - client = genai.Client(vertexai=True) + client = genai.Client(vertexai=True, **(vertex_client_kwargs() or {})) else: client = genai.Client() diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py new file mode 100644 index 0000000000..f2f7711582 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py @@ -0,0 +1,54 @@ +"""Vertex AI authentication through the Substrate egress gateway.""" + +from __future__ import annotations + +import os +import re +from typing import Any, Optional + +from anthropic import AsyncAnthropicVertex +from google.adk.models.anthropic_llm import Claude as ClaudeLLM +from google.oauth2.credentials import Credentials + +SKIP_VERTEX_AUTH_ENV = "KAGENT_SKIP_VERTEX_AUTH" + +# The egress gateway replaces the Authorization header a request carries with +# the access token its credential provider serves and adds none, so a call +# without this placeholder leaves unauthenticated. +VERTEX_GATEWAY_TOKEN = "kagent-credential-injected" + + +def skip_vertex_auth() -> bool: + """Reports whether the egress gateway sets the Vertex AI access token.""" + return os.environ.get(SKIP_VERTEX_AUTH_ENV, "").strip().lower() in ("1", "t", "true") + + +def vertex_client_kwargs() -> Optional[dict[str, Any]]: + """google-genai Client arguments for Vertex AI under KAGENT_SKIP_VERTEX_AUTH. + + A token without an expiry is never refreshed, so the client looks for no + Application Default Credentials and sends the placeholder bearer. + """ + if not skip_vertex_auth(): + return None + return {"credentials": Credentials(token=VERTEX_GATEWAY_TOKEN)} + + +def claude_vertex_llm(model: str) -> ClaudeLLM: + """Claude on Vertex AI. Under KAGENT_SKIP_VERTEX_AUTH its client sends the + gateway placeholder bearer instead of resolving Google credentials. + + The project and location come from the environment, or from a model given + as a full ``projects//locations//...`` resource name. + """ + if not skip_vertex_auth(): + return ClaudeLLM(model=model) + project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") + location = os.environ.get("GOOGLE_CLOUD_LOCATION") + match = re.search(r"projects/([^/]+)/locations/([^/]+)/", model) + if match: + project_id, location = match.group(1), match.group(2) + if not project_id or not location: + raise ValueError(f"Claude model {model!r} on Vertex AI needs GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION") + client = AsyncAnthropicVertex(project_id=project_id, region=location, access_token=VERTEX_GATEWAY_TOKEN) + return ClaudeLLM(model=model, client=client) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 7872aaba6d..9b113d28e8 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -10,7 +10,6 @@ from google.adk.agents.callback_context import CallbackContext from google.adk.agents.llm_agent import ToolUnion from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.models.anthropic_llm import Claude as ClaudeLLM from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams from google.protobuf.json_format import Parse, ParseDict from kagent.config.v1.adk_pb2 import ( @@ -101,6 +100,7 @@ from kagent.adk.models._openai import FoundryOpenAI from kagent.adk.models._openai import OpenAI as OpenAINative from kagent.adk.models._ssl import create_ssl_context +from kagent.adk.models._vertex import claude_vertex_llm, vertex_client_kwargs from kagent.adk.tools.ask_user_tool import AskUserTool logger = logging.getLogger(__name__) @@ -584,9 +584,10 @@ def _create_llm_from_model_config(model_config: ModelConfig): return KAgentGeminiVertexAILlm( model=model_config.model, max_output_tokens=optional_field(settings, "max_output_tokens"), + client_kwargs=vertex_client_kwargs(), ) if provider == "gemini_anthropic": - return ClaudeLLM(model=model_config.model) + return claude_vertex_llm(model_config.model) if provider == "ollama": ollama_options = _convert_ollama_options(dict(settings.options)) # api key passthrough is not applicable for ollama diff --git a/python/packages/kagent-adk/tests/unittests/models/test_vertex.py b/python/packages/kagent-adk/tests/unittests/models/test_vertex.py new file mode 100644 index 0000000000..8949b65604 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/models/test_vertex.py @@ -0,0 +1,111 @@ +"""Tests for Vertex AI authentication through the egress gateway.""" + +import google.auth +import httpx +import httpx2 +import pytest +from google.genai import Client, types + +from kagent.adk.models._vertex import ( + VERTEX_GATEWAY_TOKEN, + claude_vertex_llm, + skip_vertex_auth, + vertex_client_kwargs, +) + + +@pytest.fixture +def vertex_env(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "project") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-east5") + + def no_ambient_credentials(*args, **kwargs): + raise AssertionError("Application Default Credentials must not be resolved") + + monkeypatch.setattr(google.auth, "default", no_ambient_credentials) + return monkeypatch + + +@pytest.mark.parametrize( + ("value", "want"), + [("true", True), ("1", True), ("TRUE", True), ("false", False), ("", False), ("yes", False)], +) +def test_skip_vertex_auth(monkeypatch, value, want): + monkeypatch.setenv("KAGENT_SKIP_VERTEX_AUTH", value) + assert skip_vertex_auth() is want + + +def test_vertex_client_kwargs_without_switch(monkeypatch): + monkeypatch.delenv("KAGENT_SKIP_VERTEX_AUTH", raising=False) + assert vertex_client_kwargs() is None + + +def test_gemini_vertex_sends_gateway_placeholder(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json={"candidates": [{"content": {"role": "model", "parts": [{"text": "ok"}]}}]}) + + client = Client( + vertexai=True, + project="project", + location="us-east5", + http_options=types.HttpOptions(client_args={"transport": httpx.MockTransport(handler)}), + **vertex_client_kwargs(), + ) + client.models.generate_content(model="gemini-2.5-pro", contents="hi") + + assert len(seen) == 1 + assert seen[0].url.host == "us-east5-aiplatform.googleapis.com" + assert seen[0].headers["authorization"] == f"Bearer {VERTEX_GATEWAY_TOKEN}" + + +@pytest.mark.asyncio +async def test_claude_vertex_sends_gateway_placeholder(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response( + 200, + json={ + "id": "msg", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + model = claude_vertex_llm("claude-sonnet-4-5@20250929") + client = model.client.with_options(http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) + await client.messages.create( + model="claude-sonnet-4-5@20250929", max_tokens=1, messages=[{"role": "user", "content": "hi"}] + ) + + assert len(seen) == 1 + assert seen[0].url.host == "us-east5-aiplatform.googleapis.com" + assert seen[0].headers["authorization"] == f"Bearer {VERTEX_GATEWAY_TOKEN}" + + +def test_claude_vertex_keeps_google_auth_without_switch(vertex_env): + vertex_env.delenv("KAGENT_SKIP_VERTEX_AUTH", raising=False) + assert claude_vertex_llm("claude-sonnet-4-5@20250929").client is None + + +def test_claude_vertex_resource_name_sets_project_and_location(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + client = claude_vertex_llm("projects/other/locations/europe-west1/publishers/anthropic/models/claude").client + assert (client.project_id, client.region) == ("other", "europe-west1") + + +def test_claude_vertex_requires_project_and_location(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + vertex_env.delenv("GOOGLE_CLOUD_LOCATION") + with pytest.raises(ValueError, match="GOOGLE_CLOUD_LOCATION"): + claude_vertex_llm("claude-sonnet-4-5@20250929")