diff --git a/go/api/config/crd/bases/api.kagent.dev_agents.yaml b/go/api/config/crd/bases/api.kagent.dev_agents.yaml index 84023a3624..1a8c4b618b 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agents.yaml @@ -389,6 +389,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -397,6 +426,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -502,6 +537,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -510,6 +574,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml b/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml index ebe57de679..95f0337e25 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml @@ -144,6 +144,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -152,6 +181,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -255,6 +290,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -263,6 +327,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index b306126311..82b01232cd 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -102,6 +102,8 @@ type AgentTemplateSkill struct { } // GitArtifact identifies immutable content at a full Git commit ID. +// +kubebuilder:validation:XValidation:rule="!has(self.credentialRef) || (self.credentialRef.name.size() > 0 && self.credentialRef.key.size() > 0)",message="credentialRef name and key must not be empty" +// +kubebuilder:validation:XValidation:rule="!has(self.credentialRef) || self.url.startsWith('https://')",message="credentialRef requires an https URL" type GitArtifact struct { // +kubebuilder:validation:Pattern=`^https?://[^[:space:]]+$` // +kubebuilder:validation:MinLength=1 @@ -110,6 +112,13 @@ type GitArtifact struct { // +kubebuilder:validation:Pattern=`^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$` // +required Commit string `json:"commit"` + // CredentialRef references a key in a same-namespace Secret whose value is + // the base64 of ":" for the URL's host (GitHub takes + // "x-access-token:"). The egress gateway sends it as + // "Authorization: Basic" on every request the actor makes to that host; + // the actor never holds it. + // +optional + CredentialRef *corev1.SecretKeySelector `json:"credentialRef,omitempty"` } // S3Object identifies one immutable S3 object version. diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 59a3d9ddf2..fdc53e3855 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -375,7 +375,7 @@ func (in *ArtifactSource) DeepCopyInto(out *ArtifactSource) { if in.Git != nil { in, out := &in.Git, &out.Git *out = new(GitArtifact) - **out = **in + (*in).DeepCopyInto(*out) } if in.Bucket != nil { in, out := &in.Bucket, &out.Bucket @@ -614,6 +614,11 @@ func (in *GeminiVertexAIConfig) DeepCopy() *GeminiVertexAIConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitArtifact) DeepCopyInto(out *GitArtifact) { *out = *in + if in.CredentialRef != nil { + in, out := &in.CredentialRef, &out.CredentialRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitArtifact. diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index 5f5a2a48a5..e730666727 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -107,13 +107,14 @@ func (c *Builder) compileAgent(ctx context.Context, input *v2translator.AgentInp stream = modelConfig.Spec.Stream } cfg := &adk.AgentConfig{Model: modelRuntime.Model, Description: input.Template.Spec.Description, Instruction: input.Instruction, Stream: stream} - pluginConfig, pluginEgress, err := v2translator.CompileSkillResources(input.Template) + skills, err := v2translator.CompileSkillResources(input.Template) if err != nil { return nil, err } - if len(pluginConfig.Skills) > 0 || len(pluginConfig.Plugins) > 0 { - cfg.AgentPlugins = &pluginConfig + if len(skills.Resources.Skills) > 0 || len(skills.Resources.Plugins) > 0 { + cfg.AgentPlugins = &skills.Resources } + modelRuntime.Environment = append(modelRuntime.Environment, skills.Environment...) for _, tool := range input.MCPTools { headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, input.Template.Namespace, tool.Server.Spec.HeadersFrom) if err != nil { @@ -129,7 +130,7 @@ func (c *Builder) compileAgent(ctx context.Context, input *v2translator.AgentInp result := &Result{ Config: cfg, Templates: []*v2translator.TemplateConfiguration{input.Template}, Environment: modelRuntime.Environment, - Egress: append(agentConfigDestinations(cfg, modelConfig, modelRuntime.Model), pluginEgress...), + Egress: append(agentConfigDestinations(cfg, modelConfig, modelRuntime.Model), skills.Egress...), } if modelConfig != nil { result.Models = []*v2translator.ResolvedModelConfig{input.ResolvedModelConfig} diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index 625b7d8a95..5d9db2fc45 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -57,7 +57,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } - skillResources, skillEgress, err := v2translator.CompileSkillResources(input.Root.Template) + skills, err := v2translator.CompileSkillResources(input.Root.Template) if err != nil { return nil, err } @@ -67,6 +67,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput } environment := append([]corev1.EnvVar(nil), providerEnvironment...) environment = append(environment, mcp.environment...) + environment = append(environment, skills.Environment...) harnessAttributes := v2translator.HarnessResourceAttributes(input.Harness) for _, variable := range input.Harness.Spec.Env { if v2translator.IsResourceAttributesVariable(variable.Name) { @@ -99,8 +100,8 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput config := claudeconfig.Production(model.Spec.Model, input.Root.Instruction) config.Agents = localAgents config.RuntimeTelemetry = runtimeTelemetry - if len(skillResources.Skills) != 0 || len(skillResources.Plugins) != 0 { - config.SkillResources = &skillResources + if len(skills.Resources.Skills) != 0 || len(skills.Resources.Plugins) != 0 { + config.SkillResources = &skills.Resources } config.MCPServers = mcp.servers if err := config.Validate(); err != nil { @@ -123,7 +124,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, err } - egress = append(egress, skillEgress...) + egress = append(egress, skills.Egress...) egress = append(egress, mcp.egress...) egress = append(egress, telemetryConfig.Destinations()...) egress = append(egress, "http://"+utils.GetControllerName()+"."+utils.GetResourceNamespace()+":8083") diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index 13a51cef5e..674c63abc1 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -73,7 +73,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } - skillResources, skillEgress, err := v2translator.CompileSkillResources(input.Root.Template) + skills, err := v2translator.CompileSkillResources(input.Root.Template) if err != nil { return nil, err } @@ -82,6 +82,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, err } environment := append(providerEnvironment, mcp.environment...) + environment = append(environment, skills.Environment...) harnessAttributes := v2translator.HarnessResourceAttributes(input.Harness) for _, variable := range input.Harness.Spec.Env { if v2translator.IsResourceAttributesVariable(variable.Name) { @@ -115,8 +116,8 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput cfg.Telemetry.Logs = &codexconfig.OTLPExporter{Endpoint: logConfig.Endpoint, Protocol: logConfig.Protocol} } } - if len(skillResources.Skills) != 0 || len(skillResources.Plugins) != 0 { - cfg.SkillResources = &skillResources + if len(skills.Resources.Skills) != 0 || len(skills.Resources.Plugins) != 0 { + cfg.SkillResources = &skills.Resources } if err := cfg.Validate(); err != nil { return nil, v2translator.NewValidationError("invalid compiled Codex configuration: %v", err) @@ -137,7 +138,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } - egress = append(egress, skillEgress...) + egress = append(egress, skills.Egress...) egress = append(egress, mcp.egress...) egress = append(egress, telemetryConfig.Destinations()...) egress = append(egress, "http://"+utils.GetControllerName()+"."+utils.GetResourceNamespace()+":8083") diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 00469ab8e2..037a47f0d3 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "slices" "strings" "testing" @@ -11,6 +12,7 @@ import ( atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/adk" "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" v2translator "github.com/kagent-dev/kagent/go/core/internal/translator" byotranslator "github.com/kagent-dev/kagent/go/core/internal/translator/byo" @@ -978,3 +980,87 @@ func TestResolveModelConfigMistral(t *testing.T) { resolved = mockCollections(t, model).ResolvedModelConfigs.List()[0] require.Equal(t, "APIKeySecretNotFound", resolved.Failure().Reason) } + +func TestCompileAgentKeepsSkillCredentialsOutOfConfig(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "skills-git-auth", Namespace: "test", UID: types.UID("secret-uid")}, + Data: map[string][]byte{"token": []byte("ghp_private-skills-token")}, + } + credentialRef := &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret.Name}, Key: "token"} + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, + Skills: []v1alpha3.AgentTemplateSkill{ + {Name: "runbooks", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/private-skills", Commit: "cccccccccccccccccccccccccccccccccccccccc", CredentialRef: credentialRef, + }, Path: "runbooks"}}, + {Name: "public", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/public-skills", Commit: "dddddddddddddddddddddddddddddddddddddddd", + }}}, + }, + Plugins: []v1alpha3.PluginBundle{{ + Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/private-plugins", Commit: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", CredentialRef: credentialRef, + }}, + Skills: []string{"deploy"}, + }}, + }, + } + spec, err := compiler(t, modelConfig(), secret).CompileAgent(t.Context(), inlineAgent(skillCredentialHarness(), template)) + require.NoError(t, err) + + if bytes.Contains(spec.ConfigJSON, secret.Data["token"]) || bytes.Contains(spec.Provenance, secret.Data["token"]) { + t.Fatal("runtime revision contains the skill credential value") + } + var config adk.AgentConfig + require.NoError(t, json.Unmarshal(spec.ConfigJSON, &config)) + require.NotNil(t, config.AgentPlugins) + require.Len(t, config.AgentPlugins.Skills, 2) + require.Len(t, config.AgentPlugins.Plugins, 1) + + var artifactCredentials []corev1.EnvVar + for _, variable := range spec.Environment { + require.Nil(t, variable.ValueFrom, "runtime revision environment contains unresolved valueFrom: %+v", variable) + if strings.HasPrefix(variable.Name, v2translator.ArtifactCredentialEnvPrefix) { + artifactCredentials = append(artifactCredentials, variable) + } + } + require.Len(t, artifactCredentials, 1, "one Secret key compiles to one variable") + require.Equal(t, v2translator.CredentialPlaceholder, artifactCredentials[0].Value, "the actor holds a placeholder, the gateway the credential") + require.Contains(t, spec.Credentials, egress.Credential{ + Hostname: "github.com", Header: "authorization", Prefix: "Basic ", URI: "ate-secret://k8s.io/default/test/" + secret.Name + "/token", + }, "credentials: %+v", spec.Credentials) + require.Zero(t, bytes.Count(spec.Provenance, []byte(`"kind":"Secret"`)), "a rotation of the credential is not a new revision; the gateway reads the Secret live: %s", spec.Provenance) + require.Contains(t, spec.EgressDestinations, "https://github.com:443") +} + +func TestCompileAgentReportsAMissingSkillCredentialSecret(t *testing.T) { + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, + Skills: []v1alpha3.AgentTemplateSkill{{Name: "runbooks", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/private-skills", Commit: "cccccccccccccccccccccccccccccccccccccccc", + CredentialRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "missing"}, Key: "token"}, + }}}}, + }, + } + _, err := compiler(t, modelConfig()).CompileAgent(t.Context(), inlineAgent(skillCredentialHarness(), template)) + require.ErrorContains(t, err, `secret "missing" not found`) + var validation *v2translator.ValidationError + require.False(t, errors.As(err, &validation), "a missing Secret is a reference failure the controller retries, not a validation error") +} + +func skillCredentialHarness() *v1alpha3.Harness { + return &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } +} diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 6872047f52..2e01599f72 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -23,6 +23,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, var bindings []egress.Credential boundModels := map[string]bool{} boundMCP := map[string]bool{} + boundArtifacts := map[string]bool{} bind := func(rawURL, header, prefix, 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 != "" { @@ -48,6 +49,28 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } } } + if agent.Template != nil { + // Git speaks Basic authentication, so the Secret key holds the + // base64 of ":", ready to follow the scheme. + bindArtifact := func(source v1alpha3.ArtifactSource) error { + if source.Git == nil || source.Git.CredentialRef == nil { + return nil + } + ref := source.Git.CredentialRef + boundArtifacts[ref.Name+"\x00"+ref.Key] = true + return bind(source.Git.URL, "authorization", "Basic ", agent.Template.Namespace, ref.Name, ref.Key) + } + for _, skill := range agent.Template.Spec.Skills { + if err := bindArtifact(skill.Source); err != nil { + return err + } + } + for _, plugin := range agent.Template.Spec.Plugins { + if err := bindArtifact(plugin.Source); err != nil { + return err + } + } + } for _, child := range agent.Shared { if err := visit(child.Agent); err != nil { return err @@ -112,7 +135,8 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } ref := variable.ValueFrom.SecretKeyRef isMCP := strings.HasPrefix(variable.Name, "KAGENT_CREDENTIAL_") || strings.HasPrefix(variable.Name, "KAGENT_CODEX_MCP_CREDENTIAL_") || strings.HasPrefix(variable.Name, "KAGENT_CLAUDE_MCP_CREDENTIAL_") - if ref == nil || (!boundModels[variable.Name+"\x00"+ref.Name+"\x00"+ref.Key] && (!isMCP || !boundMCP[ref.Name+"\x00"+ref.Key])) { + isArtifact := strings.HasPrefix(variable.Name, ArtifactCredentialEnvPrefix) + if ref == nil || (!boundModels[variable.Name+"\x00"+ref.Name+"\x00"+ref.Key] && (!isMCP || !boundMCP[ref.Name+"\x00"+ref.Key]) && (!isArtifact || !boundArtifacts[ref.Name+"\x00"+ref.Key])) { return nil, nil, NewValidationError("environment credential %q cannot use gateway header injection; local signing and arbitrary secret environment variables are unsupported", variable.Name) } result[i].Value, result[i].ValueFrom = CredentialPlaceholder, nil diff --git a/go/core/internal/translator/skills.go b/go/core/internal/translator/skills.go index 39b6d8fd8b..f02f54f2a5 100644 --- a/go/core/internal/translator/skills.go +++ b/go/core/internal/translator/skills.go @@ -1,53 +1,91 @@ package translator import ( + "crypto/sha256" + "fmt" "net/url" "strings" "github.com/kagent-dev/kagent/go/api/agentplugin" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/egress" + corev1 "k8s.io/api/core/v1" ) +// ArtifactCredentialEnvPrefix starts the environment variables that name the +// Secret keys artifact sources authenticate with. The suffix identifies the +// Secret key, so sources sharing a credential share one variable. The actor +// receives the variable with an inert placeholder: the egress gateway injects +// the credential into the request to the source's host. +const ArtifactCredentialEnvPrefix = "KAGENT_ARTIFACT_CREDENTIAL_" + +// CompiledSkillResources is the harness-neutral result of compiling skills and +// plugins: the resources to materialize, the origins they are fetched from, and +// the Secret-backed environment the credentials need. Credential values stay +// out of Resources; only the variable names are serialized. +type CompiledSkillResources struct { + Resources agentplugin.Resources + Egress []string + Environment []corev1.EnvVar +} + // CompileSkillResources translates portable AgentTemplate skill selections // into the runtime-neutral resource contract shared by Harness adapters. -func CompileSkillResources(template *TemplateConfiguration) (agentplugin.Resources, []string, error) { - resources := agentplugin.Resources{ +func CompileSkillResources(template *TemplateConfiguration) (CompiledSkillResources, error) { + compiled := CompiledSkillResources{Resources: agentplugin.Resources{ Skills: make([]agentplugin.Skill, 0, len(template.Spec.Skills)), Plugins: make([]agentplugin.Bundle, 0, len(template.Spec.Plugins)), - } + }} selected := make(map[string]struct{}) - var egress []string + credentials := map[string]struct{}{} + compile := func(artifact v1alpha3.ArtifactSource) agentplugin.Source { + source, credential := compileArtifactSource(template.Namespace, artifact) + compiled.Egress = appendArtifactSourceDestination(compiled.Egress, source) + if credential != nil { + if _, exists := credentials[credential.Name]; !exists { + credentials[credential.Name] = struct{}{} + compiled.Environment = append(compiled.Environment, *credential) + } + } + return source + } for _, skill := range template.Spec.Skills { if _, exists := selected[skill.Name]; exists { - return agentplugin.Resources{}, nil, NewValidationError("duplicate skill name %q", skill.Name) + return CompiledSkillResources{}, NewValidationError("duplicate skill name %q", skill.Name) } selected[skill.Name] = struct{}{} - source := compileArtifactSource(skill.Source) - resources.Skills = append(resources.Skills, agentplugin.Skill{Name: skill.Name, Source: source}) - egress = appendArtifactSourceDestination(egress, source) + compiled.Resources.Skills = append(compiled.Resources.Skills, agentplugin.Skill{Name: skill.Name, Source: compile(skill.Source)}) } for _, plugin := range template.Spec.Plugins { for _, name := range plugin.Skills { if _, exists := selected[name]; exists { - return agentplugin.Resources{}, nil, NewValidationError("duplicate skill name %q", name) + return CompiledSkillResources{}, NewValidationError("duplicate skill name %q", name) } selected[name] = struct{}{} } - source := compileArtifactSource(plugin.Source) - resources.Plugins = append(resources.Plugins, agentplugin.Bundle{ - Source: source, + compiled.Resources.Plugins = append(compiled.Resources.Plugins, agentplugin.Bundle{ + Source: compile(plugin.Source), Skills: append([]string(nil), plugin.Skills...), }) - egress = appendArtifactSourceDestination(egress, source) } - return resources, egress, nil + return compiled, nil } -func compileArtifactSource(source v1alpha3.ArtifactSource) agentplugin.Source { +// compileArtifactSource returns the runtime source and, for a git source with +// a credentialRef, the Secret-backed environment variable that records the +// credential in the revision. The variable name derives from the Secret +// identity so the same credential compiles to the same variable in every +// revision. +func compileArtifactSource(namespace string, source v1alpha3.ArtifactSource) (agentplugin.Source, *corev1.EnvVar) { result := agentplugin.Source{OCI: source.OCI, Path: source.Path} + var credential *corev1.EnvVar if source.Git != nil { result.Git = &agentplugin.GitSource{URL: source.Git.URL, Commit: source.Git.Commit} + if ref := source.Git.CredentialRef; ref != nil { + sum := sha256.Sum256([]byte(namespace + "\x00" + ref.Name + "\x00" + ref.Key)) + name := ArtifactCredentialEnvPrefix + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) + credential = &corev1.EnvVar{Name: name, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: ref.DeepCopy()}} + } } if source.Bucket != nil { result.S3 = &agentplugin.S3Source{ @@ -58,7 +96,7 @@ func compileArtifactSource(source v1alpha3.ArtifactSource) agentplugin.Source { Region: source.Bucket.S3.Region, } } - return result + return result, credential } func appendArtifactSourceDestination(destinations []string, source agentplugin.Source) []string { diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 84023a3624..1a8c4b618b 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -389,6 +389,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -397,6 +426,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -502,6 +537,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -510,6 +574,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml index ebe57de679..95f0337e25 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml @@ -144,6 +144,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -152,6 +181,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -255,6 +290,35 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway sends it as + "Authorization: Basic" on every request the actor makes to that host; + the actor never holds it. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -263,6 +327,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$