From 0e5a0496f8b1e1c0caec0c172c8fb2fc9c57b854 Mon Sep 17 00:00:00 2001 From: Timo Derstappen Date: Fri, 11 Sep 2026 09:19:58 +0200 Subject: [PATCH] feat(api): a per-source credential for private git skill and plugin sources A skill or plugin source on a private repository has no credential path: the materialiser shells out to git with the process environment and no helper, so an AgentTemplate cannot take skills from a private repository at all. AgentTemplate.spec.skills[].source.git and plugins[].source.git gain an optional credentialRef, a key in a same-namespace Secret (https only, enforced by CEL). Git speaks Basic authentication, so the key holds the base64 of ":" for the URL's host; GitHub takes "x-access-token:". The credential follows the gateway injection path every other credential takes. CompileCredentials binds each referenced key to the source URL's host as "Authorization: Basic ", an ate-secret:// URI the egress gateway resolves on every request to that host; the actor never holds the token, and the materialiser clones with no credential of its own. The compiler still records one Secret-backed environment variable per referenced key, KAGENT_ARTIFACT_CREDENTIAL_, which reaches the actor as the inert placeholder like a model key, so a missing Secret or key fails compilation as a reference failure (ResolvedRefs=False) that is retried when the Secret appears. A rotation is not a new revision: the gateway reads the Secret live. Substrate matches credentials by hostname, so one credential serves every source of an agent tree on that host, public repositories included, and two different Secrets for one host are refused at compile time. A wrong token fails the golden boot on the host's 401. Signed-off-by: Quentin Bisson --- .../crd/bases/api.kagent.dev_agents.yaml | 70 +++++++++++++++ .../bases/api.kagent.dev_agenttemplates.yaml | 70 +++++++++++++++ go/api/v1alpha3/agenttemplate_types.go | 9 ++ go/api/v1alpha3/zz_generated.deepcopy.go | 7 +- .../internal/translator/adkconfig/builder.go | 9 +- .../internal/translator/claude/compiler.go | 9 +- go/core/internal/translator/codex/compiler.go | 9 +- go/core/internal/translator/compiler_test.go | 86 +++++++++++++++++++ go/core/internal/translator/credentials.go | 26 +++++- go/core/internal/translator/skills.go | 70 +++++++++++---- .../templates/api.kagent.dev_agents.yaml | 70 +++++++++++++++ .../api.kagent.dev_agenttemplates.yaml | 70 +++++++++++++++ 12 files changed, 475 insertions(+), 30 deletions(-) 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}$