From 88f7acd601be4b0c9280778c848e3713f0bc8661 Mon Sep 17 00:00:00 2001 From: Kristy Tian Date: Mon, 17 Aug 2026 13:41:17 -0700 Subject: [PATCH 1/4] agents: allow image composition plugins --- pkg/api/v1alpha1/agent.go | 6 ++-- .../v1alpha1/agent_harness_validate_test.go | 31 +++++++++++++++++-- pkg/api/v1alpha1/agent_validate.go | 17 +++++----- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/pkg/api/v1alpha1/agent.go b/pkg/api/v1alpha1/agent.go index 572c0ddef..4f69890f4 100644 --- a/pkg/api/v1alpha1/agent.go +++ b/pkg/api/v1alpha1/agent.go @@ -48,9 +48,9 @@ type AgentSpec struct { // Composition — top-level, harness-agnostic references to what the agent // is assembled from. The selected Deployment harness materializes what it // supports and drops-with-warning the rest (capability matrix). Plugins, - // Skills, and Instructions require compatibleHarnesses because a prebuilt - // Image cannot consume them by itself. MCPServers flow to harness runtimes - // and remain available to any other runtime that supports MCP. Each ref's + // Skills, and Instructions can also be delivered directly to an + // image Agent. MCPServers flow to harness runtimes and remain available to + // any other runtime that supports MCP. Each ref's // Kind defaults to the field's resource kind; empty Tag means "resolve // latest at reference time". Plugins []ResourceRef `json:"plugins,omitempty" yaml:"plugins,omitempty"` diff --git a/pkg/api/v1alpha1/agent_harness_validate_test.go b/pkg/api/v1alpha1/agent_harness_validate_test.go index 5d6f0b9ef..0ebdc1f9c 100644 --- a/pkg/api/v1alpha1/agent_harness_validate_test.go +++ b/pkg/api/v1alpha1/agent_harness_validate_test.go @@ -65,12 +65,39 @@ func TestAgentHarnessValidate(t *testing.T) { wantErr: "must be \"Prompt\"", }, { - name: "composition requires harness compatibility", + name: "image agent with plugins does not require a harness", spec: AgentSpec{ Plugins: []ResourceRef{{Kind: KindPlugin, Name: "x"}}, Source: &AgentSource{Image: "ghcr.io/org/agent:1.0.0"}, }, - wantErr: "require compatibleHarnesses", + }, + { + name: "image agent with skills does not require a harness", + spec: AgentSpec{ + Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}}, + Source: &AgentSource{Image: "ghcr.io/org/agent:1.0.0"}, + }, + }, + { + name: "image agent with instructions does not require a harness", + spec: AgentSpec{ + Instructions: &ResourceRef{Kind: KindPrompt, Name: "x"}, + Source: &AgentSource{Image: "ghcr.io/org/agent:1.0.0"}, + }, + }, + { + name: "plugins without image or harness remain rejected", + spec: AgentSpec{ + Plugins: []ResourceRef{{Kind: KindPlugin, Name: "x"}}, + }, + wantErr: "plugins require compatibleHarnesses or source image", + }, + { + name: "skills without image or harness remain rejected", + spec: AgentSpec{ + Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}}, + }, + wantErr: "skills/instructions require compatibleHarnesses or source image", }, } diff --git a/pkg/api/v1alpha1/agent_validate.go b/pkg/api/v1alpha1/agent_validate.go index cc217bf59..0c3352973 100644 --- a/pkg/api/v1alpha1/agent_validate.go +++ b/pkg/api/v1alpha1/agent_validate.go @@ -3,6 +3,7 @@ package v1alpha1 import ( "context" "fmt" + "strings" "k8s.io/utils/ptr" ) @@ -82,8 +83,8 @@ func validateAgentSpec(s *AgentSpec) FieldErrors { // Composition refs default their Kind IN PLACE — the deploy-time resolver // does no defaulting, so the persisted ref must carry the kind. MCPServers - // are available to any MCP-capable runtime; plugins/skills/instructions are - // harness composition inputs and are gated below. + // are available to any MCP-capable runtime; plugins/skills/instructions can + // also be delivered to an image Agent and are gated below. errs = append(errs, validateResourceRefs("spec.mcpServers", s.MCPServers, KindMCPServer)...) errs = append(errs, validateResourceRefs("spec.plugins", s.Plugins, KindPlugin)...) errs = append(errs, validateResourceRefs("spec.skills", s.Skills, KindSkill)...) @@ -94,11 +95,13 @@ func validateAgentSpec(s *AgentSpec) FieldErrors { errs = append(errs, validateResourceRefs("spec.instructions", []ResourceRef{*s.Instructions}, KindPrompt)...) } - // Plugins/skills/instructions only apply to harness-compatible agents — a - // prebuilt Image cannot consume injected files by itself. - if (len(s.Plugins) > 0 || len(s.Skills) > 0 || s.Instructions != nil) && - len(s.CompatibleHarnesses) == 0 { - errs.Append("spec", fmt.Errorf("%w: plugins/skills/instructions require compatibleHarnesses", ErrInvalidFormat)) + hasHarnessCompat := len(s.CompatibleHarnesses) > 0 + hasImage := s.Source != nil && strings.TrimSpace(s.Source.Image) != "" + if len(s.Plugins) > 0 && !hasHarnessCompat && !hasImage { + errs.Append("spec", fmt.Errorf("%w: plugins require compatibleHarnesses or source image", ErrInvalidFormat)) + } + if (len(s.Skills) > 0 || s.Instructions != nil) && !hasHarnessCompat && !hasImage { + errs.Append("spec", fmt.Errorf("%w: skills/instructions require compatibleHarnesses or source image", ErrInvalidFormat)) } return errs From c2126f091ee4afc5f5562be40fd933891f86158f Mon Sep 17 00:00:00 2001 From: Kristy Tian Date: Mon, 17 Aug 2026 22:15:22 -0700 Subject: [PATCH 2/4] agents: pin whitespace-image and repository-source rejection --- .../v1alpha1/agent_harness_validate_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pkg/api/v1alpha1/agent_harness_validate_test.go b/pkg/api/v1alpha1/agent_harness_validate_test.go index 0ebdc1f9c..5c1ba8fba 100644 --- a/pkg/api/v1alpha1/agent_harness_validate_test.go +++ b/pkg/api/v1alpha1/agent_harness_validate_test.go @@ -99,6 +99,30 @@ func TestAgentHarnessValidate(t *testing.T) { }, wantErr: "skills/instructions require compatibleHarnesses or source image", }, + { + name: "whitespace image does not count as a source image", + spec: AgentSpec{ + Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}}, + Source: &AgentSource{Image: " "}, + }, + wantErr: "skills/instructions require compatibleHarnesses or source image", + }, + { + name: "repository source does not count for skills: nothing delivers composed files into a repository-built artifact", + spec: AgentSpec{ + Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}}, + Source: &AgentSource{Repository: &Repository{URL: "https://github.com/org/agent"}}, + }, + wantErr: "skills/instructions require compatibleHarnesses or source image", + }, + { + name: "repository source does not count for plugins", + spec: AgentSpec{ + Plugins: []ResourceRef{{Kind: KindPlugin, Name: "x"}}, + Source: &AgentSource{Repository: &Repository{URL: "https://github.com/org/agent"}}, + }, + wantErr: "plugins require compatibleHarnesses or source image", + }, } for _, tt := range tests { From 5511ee0b1b9ea4ed87d2d0b0d1c2dd53ed7af5a8 Mon Sep 17 00:00:00 2001 From: Kristy Tian Date: Wed, 19 Aug 2026 22:35:28 -0700 Subject: [PATCH 3/4] fix: reject whitespace-padded agent images --- pkg/api/v1alpha1/agent_harness_validate_test.go | 8 ++++++++ pkg/api/v1alpha1/agent_validate.go | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/pkg/api/v1alpha1/agent_harness_validate_test.go b/pkg/api/v1alpha1/agent_harness_validate_test.go index 5c1ba8fba..a672f8024 100644 --- a/pkg/api/v1alpha1/agent_harness_validate_test.go +++ b/pkg/api/v1alpha1/agent_harness_validate_test.go @@ -107,6 +107,14 @@ func TestAgentHarnessValidate(t *testing.T) { }, wantErr: "skills/instructions require compatibleHarnesses or source image", }, + { + name: "image with surrounding whitespace is rejected", + spec: AgentSpec{ + Skills: []ResourceRef{{Kind: KindSkill, Name: "x"}}, + Source: &AgentSource{Image: " ghcr.io/org/agent:1.0.0 "}, + }, + wantErr: "spec.source.image", + }, { name: "repository source does not count for skills: nothing delivers composed files into a repository-built artifact", spec: AgentSpec{ diff --git a/pkg/api/v1alpha1/agent_validate.go b/pkg/api/v1alpha1/agent_validate.go index 0c3352973..3c56800e2 100644 --- a/pkg/api/v1alpha1/agent_validate.go +++ b/pkg/api/v1alpha1/agent_validate.go @@ -67,6 +67,12 @@ func validateAgentSpec(s *AgentSpec) FieldErrors { errs.Append("spec.title", validateTitle(s.Title)) errs.Append("spec.iconUrl", validateIconURL(s.IconURL)) if s.Source != nil { + if s.Source.Image != strings.TrimSpace(s.Source.Image) { + errs.Append( + "spec.source.image", + fmt.Errorf("%w: must not contain leading or trailing whitespace", ErrInvalidFormat), + ) + } for _, e := range validateRepository(s.Source.Repository) { errs.Append("spec.source."+e.Path, e.Cause) } From e7aef0d5e7ebc59866b9d0bf62cc5d6c16fcac6f Mon Sep 17 00:00:00 2001 From: Kristy Tian Date: Thu, 20 Aug 2026 22:07:31 -0700 Subject: [PATCH 4/4] deployment: fingerprint image agent composition refs --- internal/registry/controller/controller.go | 2 +- pkg/types/fingerprint.go | 13 +--- pkg/types/fingerprint_test.go | 78 +++++++++++++++++++--- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/internal/registry/controller/controller.go b/internal/registry/controller/controller.go index 3dbfebc91..0ab132116 100644 --- a/internal/registry/controller/controller.go +++ b/internal/registry/controller/controller.go @@ -121,7 +121,7 @@ func (c *DeploymentController) FullReconcile(ctx context.Context) (int, error) { // HandleEvent maps a source invalidation to Deployment work. Dependency changes // intentionally use a full Deployment scan for this first controller foundation. -// Agent harness composition refs (Plugins, Skills, and Prompt instructions) and +// Agent composition refs (Plugins, Skills, and Prompt instructions) and // Model selection are dependency events so changes requeue Deployments that may // depend on their resolved state. func (c *DeploymentController) HandleEvent(ctx context.Context, event v1alpha1store.ControlPlaneEvent) (int, error) { diff --git a/pkg/types/fingerprint.go b/pkg/types/fingerprint.go index 33ca63d3e..a8aca2c24 100644 --- a/pkg/types/fingerprint.go +++ b/pkg/types/fingerprint.go @@ -241,7 +241,7 @@ func defaultApplyDependencies(ctx context.Context, in ApplyInput) ([]v1alpha1.Ob } hasModelRef := modelRef != nil hasAgentRefs := ok && agent != nil && - (len(agent.Spec.MCPServers) > 0 || hasHarnessCompositionRefs(in.Deployment, agent)) + (len(agent.Spec.MCPServers) > 0 || hasAgentCompositionRefs(agent)) if in.Getter == nil { if hasModelRef || hasAgentRefs { return nil, fmt.Errorf("fingerprint: getter required to resolve dependency refs") @@ -268,9 +268,6 @@ func defaultApplyDependencies(ctx context.Context, in ApplyInput) ([]v1alpha1.Ob if err != nil { return nil, err } - if !deploymentSelectsHarness(in.Deployment) { - return deps, nil - } deps, err = appendResolvedRefs(ctx, deps, in.Getter, agent.Metadata.NamespaceOrDefault(), agent.Spec.Plugins, v1alpha1.KindPlugin, "target spec.plugins") if err != nil { return nil, err @@ -288,15 +285,11 @@ func defaultApplyDependencies(ctx context.Context, in ApplyInput) ([]v1alpha1.Ob return deps, nil } -func hasHarnessCompositionRefs(deployment *v1alpha1.Deployment, agent *v1alpha1.Agent) bool { - return deploymentSelectsHarness(deployment) && agent != nil && +func hasAgentCompositionRefs(agent *v1alpha1.Agent) bool { + return agent != nil && (len(agent.Spec.Plugins) > 0 || len(agent.Spec.Skills) > 0 || agent.Spec.Instructions != nil) } -func deploymentSelectsHarness(deployment *v1alpha1.Deployment) bool { - return deployment != nil && deployment.Spec.Harness != nil -} - func appendResolvedRefs( ctx context.Context, deps []v1alpha1.Object, diff --git a/pkg/types/fingerprint_test.go b/pkg/types/fingerprint_test.go index a888eb127..4a8683f58 100644 --- a/pkg/types/fingerprint_test.go +++ b/pkg/types/fingerprint_test.go @@ -244,8 +244,12 @@ func TestDefaultApplyFingerprintIncludesAgentHarnessCompositionDependencies(t *t } } -func TestDefaultApplyFingerprintIgnoresHarnessCompositionWhenDeploymentDoesNotSelectHarness(t *testing.T) { +func TestDefaultApplyFingerprintIncludesImageAgentCompositionDependencies(t *testing.T) { in := testApplyInput() + in.Deployment.Spec.TargetRef = v1alpha1.ResourceRef{ + Kind: v1alpha1.KindAgent, + Name: "assistant", + } in.Target = &v1alpha1.Agent{ TypeMeta: v1alpha1.TypeMeta{Kind: v1alpha1.KindAgent}, Metadata: v1alpha1.ObjectMeta{ @@ -253,24 +257,80 @@ func TestDefaultApplyFingerprintIgnoresHarnessCompositionWhenDeploymentDoesNotSe Name: "assistant", }, Spec: v1alpha1.AgentSpec{ - CompatibleHarnesses: []v1alpha1.HarnessCompatibility{{Type: "claude-code"}}, - Plugins: []v1alpha1.ResourceRef{{Name: "deploy-tools"}}, - Skills: []v1alpha1.ResourceRef{{Name: "weather"}}, - Instructions: &v1alpha1.ResourceRef{Name: "writer-instructions"}, + Source: &v1alpha1.AgentSource{Image: "ghcr.io/example/assistant:1.0.0"}, + Plugins: []v1alpha1.ResourceRef{{Name: "deploy-tools"}}, + Skills: []v1alpha1.ResourceRef{{Name: "weather"}}, + Instructions: &v1alpha1.ResourceRef{Name: "writer-instructions"}, }, } + pluginCommit := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + skillCommit := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + promptText := "Write concise rollout notes." in.Getter = func(_ context.Context, ref v1alpha1.ResourceRef) (v1alpha1.Object, error) { - t.Fatalf("unexpected dependency resolution without deployment harness selection: %+v", ref) - return nil, nil + switch ref.Kind { + case v1alpha1.KindPlugin: + return testPlugin(ref.Namespace, ref.Name, pluginCommit), nil + case v1alpha1.KindSkill: + return testSkill(ref.Namespace, ref.Name, skillCommit), nil + case v1alpha1.KindPrompt: + return testPrompt(ref.Namespace, ref.Name, promptText), nil + default: + t.Fatalf("unexpected dependency ref: %+v", ref) + return nil, nil + } } result, err := DefaultApplyFingerprintResult(context.Background(), in, ApplyFingerprintOptions{AdapterType: "test"}) if err != nil { t.Fatalf("DefaultApplyFingerprintResult: %v", err) } - if len(result.Dependencies) != 0 { - t.Fatalf("dependencies = %+v, want none without deployment harness selection", result.Dependencies) + if len(result.Dependencies) != 3 { + t.Fatalf("dependencies = %+v, want Plugin, Skill, and Prompt", result.Dependencies) + } + + tests := []struct { + name string + update func() + }{ + { + name: "Plugin", + update: func() { + pluginCommit = "cccccccccccccccccccccccccccccccccccccccc" + }, + }, + { + name: "Skill", + update: func() { + skillCommit = "dddddddddddddddddddddddddddddddddddddddd" + }, + }, + { + name: "Prompt", + update: func() { + promptText = "Write detailed rollout notes." + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pluginCommit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + skillCommit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + promptText = "Write concise rollout notes." + test.update() + + changed, err := DefaultApplyFingerprintResult( + context.Background(), + in, + ApplyFingerprintOptions{AdapterType: "test"}, + ) + if err != nil { + t.Fatalf("DefaultApplyFingerprintResult after %s change: %v", test.name, err) + } + if changed.Fingerprint == result.Fingerprint { + t.Fatalf("fingerprint did not change after resolved %s changed", test.name) + } + }) } }