diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index e919868679..8f7863a4a0 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -4,7 +4,8 @@ `Harness` describes how to run a class of agents. It selects exactly one runtime variant—kagent, Codex, Claude, or BYO—and contains workload image/command/args, -literal environment values, WorkerPool configuration, and snapshot location. +tool images, literal environment values, WorkerPool configuration, and snapshot +location. `AgentTemplate` describes what the agent does. It contains model configuration, description and prompt, MCP tool bindings, skills, plugins, and Shared @@ -85,6 +86,58 @@ spec: Command and argument changes participate in revision identity. They affect newly prepared revisions, not existing Sessions pinned to an older revision. +### Tool images + +`spec.workload.toolImages` lists digest-pinned OCI images whose contents the +controller mounts read-only next to the runtime image, for every runtime +variant. Each entry is one Substrate image volume mounted at +`/opt/kagent/tools/`: + +```yaml +spec: + claude: {} + workload: + image: registry.example.com/claude-harness@sha256:... + toolImages: + - name: go + oci: registry.example.com/tools/go@sha256:... + - name: kubectl + oci: registry.example.com/tools/kubectl@sha256:... +``` + +The controller sets `KAGENT_TOOL_PATHS` to the colon-joined +`/opt/kagent/tools//bin` directories in list order. It never sets `PATH`, +because a template `PATH` would replace the image's. Each managed runtime +appends the listed directories to its own `PATH` at start, so the image's +binaries always win: the Claude and Codex harnesses extend the environment of +the CLI they spawn, the Go ADK extends its process environment before the +`bash` tool and stdio MCP servers inherit it, and the Python ADK extends +`os.environ` when `kagent-adk` starts (its `bash` tool still puts the venv +first). `KAGENT_TOOL_PATHS` is reserved; a Harness `spec.env` entry of that +name is rejected. + +An image may ship any number of binaries in its `bin/`. `PATH` resolves +conflicts: a binary the runtime image already ships is never shadowed by a +tool image, and when two tool images ship the same binary the earlier entry +wins. Nothing reports a shadowed binary. + +Tool images follow the same rules as every other image in the API: the +reference carries a sha256 digest and the controller resolves no tags. Keeping +a digest current is the job of whatever writes the Harness. A change prepares a +new revision; running Sessions keep theirs. Each entry costs one of Substrate's +32 volume and 32 mount slots, and a non-empty list costs one of its 32 +environment entries. Substrate pulls tool images with the node's image +credential provider, the same path as the runtime image. + +A tool image is a plain filesystem: it ships its files under one prefix with +the executables in `bin/`, its entrypoint is never run, and the mount is +read-only. Binaries must be static or self-contained for the runtime image +they are mounted into: the built-in Claude, Codex, and Go ADK images are +Alpine (musl), the Python ADK image is Debian (glibc). + +Tool images reach the runtime container only. A sandbox guest runs in its own +tools image and does not see them. + ## Prepared revision pipeline The controller compiles each Agent through one pipeline: @@ -229,7 +282,9 @@ images, and compatible worker hardware. provide this integration. Other images must implement the same TaskStore contract; an upstream A2A server with only an in-memory store is insufficient. Optional model, prompt, tool, skill, and plugin configuration is - supplied in the ADK-shaped format when requested. + supplied in the ADK-shaped format when requested. Tool images are mounted + under `/opt/kagent/tools` and listed in `KAGENT_TOOL_PATHS`; a BYO image + extends its own `PATH` from that variable. The kagent and BYO compilers serialize the same ADK `AgentConfig` contract. `adkconfig.Builder.Build` assembles the complete payload and its model diff --git a/docs/architecture/process-configuration.md b/docs/architecture/process-configuration.md index 24fd32be07..a9653cd409 100644 --- a/docs/architecture/process-configuration.md +++ b/docs/architecture/process-configuration.md @@ -147,7 +147,8 @@ Python integrations load this document with `KAgentConfig.from_file()` or receiv an explicit `RuntimeConfig`. Managed processes still materialize the private `KAGENT_CONFIG_JSON` and `KAGENT_AGENT_CARD_JSON` payloads supplied by Substrate. Actor/session identity, credential projection, and private bootstrap paths keep -their existing contracts. Substrate's limits remain 32 environment entries and +their existing contracts. `KAGENT_TOOL_PATHS` is present only when the Harness +lists tool images. Substrate's limits remain 32 environment entries and 32,768 characters per value. ## OpenTelemetry diff --git a/docs/architecture/sandboxes.md b/docs/architecture/sandboxes.md index f5bf5d611c..e30f02145b 100644 --- a/docs/architecture/sandboxes.md +++ b/docs/architecture/sandboxes.md @@ -182,7 +182,8 @@ separate and retains its own snapshot and execution-claim rules. The guest uses `agent-substrate/env/guest` for process and filesystem services. Kagent owns its main function, flags, logging, listener, readiness, and shutdown. The tools image supplies installed programs; an image volume supplies the managed -guest binary and replaces the tools image's entrypoint. +guest binary and replaces the tools image's entrypoint. Harness tool images +(`spec.workload.toolImages`) use the same image-volume mechanism. The Actor mounts a durable directory at `/data`. The guest creates `/data/workspace` before readiness and stores process output in `/data/guest-logs`. diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index b2a96439cc..0b2e4e52d4 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -22,6 +22,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/session" "github.com/kagent-dev/kagent/go/adk/pkg/telemetry" "github.com/kagent-dev/kagent/go/pkg/logging" + "github.com/kagent-dev/kagent/go/pkg/toolpath" "github.com/kagent-dev/kagent/go/pkg/tracing" ) @@ -45,6 +46,13 @@ func main() { } func run(logLevel, host, port, configDir string) error { + // The bash tool and stdio MCP servers inherit the process environment, so + // tool image binaries are exposed here, after the image's own PATH. + if path, ok := toolpath.ProcessPath(os.Environ()); ok { + if err := os.Setenv("PATH", path); err != nil { + return fmt.Errorf("expose tool image binaries on PATH: %w", err) + } + } if err := config.MaterializeFromEnv(configDir); err != nil { return fmt.Errorf("materialize agent config in %s: %w", configDir, err) } diff --git a/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml b/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml index 50d59e5113..e6a3ae4432 100644 --- a/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml @@ -246,6 +246,40 @@ spec: digest. pattern: ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ type: string + toolImages: + description: |- + ToolImages lists digest-pinned OCI images the controller mounts read-only + at /opt/kagent/tools/ in every runtime. KAGENT_TOOL_PATHS carries + the colon-joined /opt/kagent/tools//bin directories in list order; + each runtime appends them to PATH after the image's own binaries. + items: + description: HarnessToolImage is one read-only image volume + added to the runtime container. + properties: + name: + description: |- + Name selects the mount directory /opt/kagent/tools/. It must be a + DNS label of at most 58 characters: the controller prefixes it with + "tool-" to form the volume name, which is itself limited to 63. + maxLength: 58 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + oci: + description: OCI is the image reference pinned by sha256 + digest. Tags are not resolved. + maxLength: 512 + pattern: ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ + type: string + required: + - name + - oci + type: object + maxItems: 16 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map required: - image type: object diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index fb1c0d90a9..585df7f594 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -95,6 +95,43 @@ func TestConfigurationCRDValidation(t *testing.T) { }), wantReject: "spec.workload.image", }, + { + name: "Harness rejects tag-only tool image", + object: validHarness(namespace, "harness-tagged-tool-image", HarnessSpec{ + Kagent: &KagentHarness{}, + Workload: HarnessWorkload{ToolImages: []HarnessToolImage{{Name: "kubectl", OCI: "registry.example.com/kubectl:latest"}}}, + }), + wantReject: "spec.workload.toolImages[0].oci", + }, + { + name: "Harness rejects tool image name that is not a DNS label", + object: validHarness(namespace, "harness-tool-image-name", HarnessSpec{ + Kagent: &KagentHarness{}, + Workload: HarnessWorkload{ToolImages: []HarnessToolImage{{Name: "Kube_ctl", OCI: "registry.example.com/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}, + }), + wantReject: "spec.workload.toolImages[0].name", + }, + { + name: "Harness rejects duplicate tool image names", + object: validHarness(namespace, "harness-duplicate-tool-image", HarnessSpec{ + Kagent: &KagentHarness{}, + Workload: HarnessWorkload{ToolImages: []HarnessToolImage{ + {Name: "kubectl", OCI: "registry.example.com/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + {Name: "kubectl", OCI: "registry.example.com/kubectl@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}, + }}, + }), + wantReject: "Duplicate value", + }, + { + name: "Harness accepts digest-pinned tool images", + object: validHarness(namespace, "harness-tool-images", HarnessSpec{ + Kagent: &KagentHarness{}, + Workload: HarnessWorkload{ToolImages: []HarnessToolImage{ + {Name: "kubectl", OCI: "registry.example.com/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + {Name: "helm", OCI: "registry.example.com/helm@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}, + }}, + }), + }, { name: "Harness memory requires a model reference", object: validHarness(namespace, "harness-empty-memory-model", HarnessSpec{ diff --git a/go/api/v1alpha3/harness_types.go b/go/api/v1alpha3/harness_types.go index 3408165968..7db81b1ebc 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -126,6 +126,34 @@ type HarnessWorkload struct { // +kubebuilder:validation:MaxItems=64 // +optional Args []string `json:"args,omitempty"` + + // ToolImages lists digest-pinned OCI images the controller mounts read-only + // at /opt/kagent/tools/ in every runtime. KAGENT_TOOL_PATHS carries + // the colon-joined /opt/kagent/tools//bin directories in list order; + // each runtime appends them to PATH after the image's own binaries. + // +kubebuilder:validation:MaxItems=16 + // +listType=map + // +listMapKey=name + // +optional + ToolImages []HarnessToolImage `json:"toolImages,omitempty"` +} + +// HarnessToolImage is one read-only image volume added to the runtime container. +type HarnessToolImage struct { + // Name selects the mount directory /opt/kagent/tools/. It must be a + // DNS label of at most 58 characters: the controller prefixes it with + // "tool-" to form the volume name, which is itself limited to 63. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=58 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + // +required + Name string `json:"name"` + + // OCI is the image reference pinned by sha256 digest. Tags are not resolved. + // +kubebuilder:validation:MaxLength=512 + // +kubebuilder:validation:Pattern=`^[^[:space:]@]+@sha256:[a-f0-9]{64}$` + // +required + OCI string `json:"oci"` } // HarnessSpec defines a reusable runtime and its infrastructure policy. diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 458b39cf07..ef27b85272 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -791,6 +791,21 @@ func (in *HarnessStatus) DeepCopy() *HarnessStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HarnessToolImage) DeepCopyInto(out *HarnessToolImage) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarnessToolImage. +func (in *HarnessToolImage) DeepCopy() *HarnessToolImage { + if in == nil { + return nil + } + out := new(HarnessToolImage) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HarnessWorkload) DeepCopyInto(out *HarnessWorkload) { *out = *in @@ -804,6 +819,11 @@ func (in *HarnessWorkload) DeepCopyInto(out *HarnessWorkload) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.ToolImages != nil { + in, out := &in.ToolImages, &out.ToolImages + *out = make([]HarnessToolImage, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarnessWorkload. diff --git a/go/core/internal/substrate/actor_template.go b/go/core/internal/substrate/actor_template.go index bbc9513b0c..4169006b2e 100644 --- a/go/core/internal/substrate/actor_template.go +++ b/go/core/internal/substrate/actor_template.go @@ -13,10 +13,12 @@ import ( "google.golang.org/protobuf/proto" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/translator" "github.com/kagent-dev/kagent/go/core/pkg/env" + "github.com/kagent-dev/kagent/go/pkg/toolpath" "github.com/kagent-dev/kagent/go/pkg/tracing" ) @@ -36,6 +38,10 @@ const egressTrustMount = "/run/kagent/egress" const actorIdentityVolume = "actor-identity" const actorIdentityMount = "/run/kagent/identity" +// Tool image volumes carry a prefix so a tool named like a built-in volume +// cannot collide with it. +const toolImageVolumePrefix = "tool-" + var egressTrustEnvironment = map[string]struct{}{ "SSL_CERT_FILE": {}, "SSL_CERT_DIR": {}, "REQUESTS_CA_BUNDLE": {}, "AWS_CA_BUNDLE": {}, "NODE_EXTRA_CA_CERTS": {}, "CURL_CA_BUNDLE": {}, "GIT_SSL_CAINFO": {}, @@ -68,11 +74,21 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R if _, owned := egressTrustEnvironment[variable.Name]; owned { return nil, fmt.Errorf("runtime environment %q conflicts with gateway trust", variable.Name) } + if variable.Name == toolpath.EnvName { + return nil, fmt.Errorf("runtime environment %q is reserved for tool images", variable.Name) + } } for _, name := range []string{"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "AWS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO"} { environment = append(environment, corev1.EnvVar{Name: name, Value: egressTrustMount + "/trust-bundle.pem"}) } environment = append(environment, corev1.EnvVar{Name: "SSL_CERT_DIR", Value: egressTrustMount}) + toolVolumes, toolMounts, toolNames, err := toolImageVolumes(spec.ToolImages) + if err != nil { + return nil, err + } + if len(toolNames) > 0 { + environment = append(environment, corev1.EnvVar{Name: toolpath.EnvName, Value: toolpath.JoinBinDirs(toolNames)}) + } environment = append(environment, corev1.EnvVar{Name: "KAGENT_CONFIG_JSON", Value: string(spec.ConfigJSON)}, corev1.EnvVar{Name: "KAGENT_AGENT_CARD_JSON", Value: string(cardJSON)}, @@ -102,11 +118,11 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R Path: "/readyz", Port: 8081, }, TimeoutSeconds: 30}, - VolumeMounts: []*ateapipb.VolumeMount{ + VolumeMounts: append([]*ateapipb.VolumeMount{ {Name: durableDataVolume, MountPath: durableDataMount}, {Name: egressTrustVolume, MountPath: egressTrustMount}, {Name: actorIdentityVolume, MountPath: actorIdentityMount}, - }, + }, toolMounts...), }}, WorkerSelector: workerSelectorForPool(workerKey), SnapshotConfig: &ateapipb.SnapshotConfig{ @@ -115,7 +131,7 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R // for both until lifecycle v2 separates them again. OnCommit: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL, }, - Volumes: []*ateapipb.Volume{ + Volumes: append([]*ateapipb.Volume{ {Name: durableDataVolume, DurableDir: &ateapipb.DurableDirVolumeSource{}}, // Substrate regenerates this projection on Run and Restore. A fork // therefore routes storage calls as its own actor, never its source. @@ -129,11 +145,38 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R {Name: egressTrustVolume, SystemInfo: &ateapipb.SystemInfoVolumeSource{DataSources: []*ateapipb.SystemInfoDataSource{ {TrustBundle: &ateapipb.TrustBundleDataSource{Names: []string{"egress-mitm.ate.dev"}, Path: "trust-bundle.pem"}}, }}}, - }, + }, toolVolumes...), } return template, nil } +// toolImageVolumes projects each tool image as a read-only image volume mounted +// under toolpath.MountRoot. The CRD validates the entries; the check here keeps +// a hand-built revision on the same contract Substrate enforces. +func toolImageVolumes(images []translator.ToolImage) ([]*ateapipb.Volume, []*ateapipb.VolumeMount, []string, error) { + var volumes []*ateapipb.Volume + var mounts []*ateapipb.VolumeMount + var names []string + seen := make(map[string]struct{}, len(images)) + for _, image := range images { + if errs := validation.IsDNS1123Label(image.Name); len(errs) > 0 { + return nil, nil, nil, fmt.Errorf("tool image name %q: %s", image.Name, strings.Join(errs, "; ")) + } + if _, duplicate := seen[image.Name]; duplicate { + return nil, nil, nil, fmt.Errorf("tool image name %q is listed twice", image.Name) + } + seen[image.Name] = struct{}{} + if !pinnedSandboxImage.MatchString(image.Reference) { + return nil, nil, nil, fmt.Errorf("tool image %q must be pinned by sha256 digest", image.Name) + } + volumeName := toolImageVolumePrefix + image.Name + volumes = append(volumes, &ateapipb.Volume{Name: volumeName, Image: &ateapipb.ImageVolumeSource{Reference: image.Reference}}) + mounts = append(mounts, &ateapipb.VolumeMount{Name: volumeName, MountPath: toolpath.MountPath(image.Name)}) + names = append(names, image.Name) + } + return volumes, mounts, names, nil +} + // ActorTemplateSpecEqual compares the client-owned immutable fields of two // templates, excluding server-owned metadata and golden-snapshot status. func ActorTemplateSpecEqual(left, right *ateapipb.ActorTemplate) bool { diff --git a/go/core/internal/substrate/actor_template_test.go b/go/core/internal/substrate/actor_template_test.go index 8d5d937c5b..ead9c38d73 100644 --- a/go/core/internal/substrate/actor_template_test.go +++ b/go/core/internal/substrate/actor_template_test.go @@ -220,3 +220,90 @@ func TestActorTemplateEnvironmentValueSize(t *testing.T) { }) } } + +func toolImageRevision(images ...translator.ToolImage) *translator.Revision { + return &translator.Revision{ + Namespace: "agents", AgentName: "helper", + Image: "agent.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + WorkerPoolName: "default", SnapshotLocation: "snapshots", + ConfigJSON: []byte(`{}`), AgentCard: &a2apb.AgentCard{Name: "helper", Version: "v1", Capabilities: &a2apb.AgentCapabilities{}, + SupportedInterfaces: []*a2apb.AgentInterface{{Url: "http://127.0.0.1:80", ProtocolBinding: "GRPC", ProtocolVersion: "1.0"}}, DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}}, + ToolImages: images, + } +} + +func TestActorTemplateMountsToolImages(t *testing.T) { + const kubectlImage = "tools.example/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const helmImage = "tools.example/helm@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + spec := toolImageRevision(translator.ToolImage{Name: "kubectl", Reference: kubectlImage}, translator.ToolImage{Name: "helm", Reference: helmImage}) + revisionID, err := spec.Digest() + require.NoError(t, err) + template, err := ActorTemplateForRevision(spec, revisionID) + require.NoError(t, err) + + require.Len(t, template.Volumes, 5) + require.Equal(t, durableDataVolume, template.Volumes[0].GetName()) + require.Equal(t, actorIdentityVolume, template.Volumes[1].GetName()) + require.Equal(t, egressTrustVolume, template.Volumes[2].GetName()) + require.Equal(t, "tool-kubectl", template.Volumes[3].GetName()) + require.Equal(t, kubectlImage, template.Volumes[3].GetImage().GetReference()) + require.Equal(t, "tool-helm", template.Volumes[4].GetName()) + require.Equal(t, helmImage, template.Volumes[4].GetImage().GetReference()) + + container := template.GetContainers()[0] + require.Len(t, container.VolumeMounts, 5) + require.Equal(t, durableDataMount, container.VolumeMounts[0].GetMountPath()) + require.Equal(t, "tool-kubectl", container.VolumeMounts[3].GetName()) + require.Equal(t, "/opt/kagent/tools/kubectl", container.VolumeMounts[3].GetMountPath()) + require.Equal(t, "tool-helm", container.VolumeMounts[4].GetName()) + require.Equal(t, "/opt/kagent/tools/helm", container.VolumeMounts[4].GetMountPath()) + + environment := map[string]string{} + for _, variable := range container.Env { + environment[variable.Name] = variable.Value + } + require.Equal(t, "/opt/kagent/tools/kubectl/bin:/opt/kagent/tools/helm/bin", environment["KAGENT_TOOL_PATHS"]) +} + +func TestActorTemplateOmitsToolPathsWithoutToolImages(t *testing.T) { + spec := toolImageRevision() + revisionID, err := spec.Digest() + require.NoError(t, err) + template, err := ActorTemplateForRevision(spec, revisionID) + require.NoError(t, err) + require.Len(t, template.Volumes, 3) + require.Len(t, template.GetContainers()[0].VolumeMounts, 3) + for _, variable := range template.GetContainers()[0].Env { + require.NotEqual(t, "KAGENT_TOOL_PATHS", variable.Name) + } +} + +func TestActorTemplateRejectsUserToolPathsEnvironment(t *testing.T) { + spec := toolImageRevision() + spec.Environment = []corev1.EnvVar{{Name: "KAGENT_TOOL_PATHS", Value: "/x"}} + revisionID, err := spec.Digest() + require.NoError(t, err) + _, err = ActorTemplateForRevision(spec, revisionID) + require.ErrorContains(t, err, `"KAGENT_TOOL_PATHS" is reserved for tool images`) +} + +func TestActorTemplateRejectsInvalidToolImages(t *testing.T) { + const pinned = "tools.example/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + for _, tt := range []struct { + name string + images []translator.ToolImage + wantErr string + }{ + {name: "duplicate name", images: []translator.ToolImage{{Name: "kubectl", Reference: pinned}, {Name: "kubectl", Reference: pinned}}, wantErr: `"kubectl" is listed twice`}, + {name: "name is not a DNS label", images: []translator.ToolImage{{Name: "Kube_ctl", Reference: pinned}}, wantErr: `tool image name "Kube_ctl"`}, + {name: "tag-only reference", images: []translator.ToolImage{{Name: "kubectl", Reference: "tools.example/kubectl:latest"}}, wantErr: `"kubectl" must be pinned by sha256 digest`}, + } { + t.Run(tt.name, func(t *testing.T) { + spec := toolImageRevision(tt.images...) + revisionID, err := spec.Digest() + require.NoError(t, err) + _, err = ActorTemplateForRevision(spec, revisionID) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/go/core/internal/translator/byo/compiler_test.go b/go/core/internal/translator/byo/compiler_test.go index e1a822ff4e..49c81fb9de 100644 --- a/go/core/internal/translator/byo/compiler_test.go +++ b/go/core/internal/translator/byo/compiler_test.go @@ -121,4 +121,9 @@ func TestCompiledTelemetryFitsTheActorEnvironmentBudget(t *testing.T) { revision.Environment = append(revision.Environment, corev1.EnvVar{Name: "ONE_MORE", Value: "value"}) _, err = substrate.ActorTemplateForRevision(&revision.Revision, id) require.ErrorContains(t, err, "Substrate supports at most 32") + // A tool image list costs one variable of the same budget. + revision.Environment = revision.Environment[:len(revision.Environment)-1] + revision.ToolImages = []translator.ToolImage{{Name: "kubectl", Reference: "example.com/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}} + _, err = substrate.ActorTemplateForRevision(&revision.Revision, id) + require.ErrorContains(t, err, "Substrate supports at most 32") } diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index 7b73450b45..6528f5c46a 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -165,6 +165,9 @@ func (c *Compiler) compileConfiguration(ctx context.Context, agentName string, h } result.AgentName = agentName result.SandboxClass = (*workerPool).Spec.SandboxClass + for _, image := range harness.Spec.Workload.ToolImages { + result.ToolImages = append(result.ToolImages, ToolImage{Name: image.Name, Reference: image.OCI}) + } return result, nil } diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 4f701cd486..f2afa63ba3 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -985,3 +985,70 @@ func TestResolveModelConfigMistral(t *testing.T) { func testCompilerSettings() translator.CompilerSettings { return translator.CompilerSettings{APIURL: "http://kagent-controller.kagent:8083", GatewayURL: "http://kagent-controller.kagent:8083", LogLevel: "info"} } + +func TestCompileAgentCarriesToolImages(t *testing.T) { + const kubectlImage = "example.com/kubectl@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const helmImage = "example.com/helm@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + for _, harnessType := range []translator.HarnessType{ + translator.HarnessTypeKagent, translator.HarnessTypeCodex, translator.HarnessTypeClaude, translator.HarnessTypeBYO, + } { + t.Run(string(harnessType), func(t *testing.T) { + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: string(harnessType)}, + Spec: v1alpha3.HarnessSpec{ + Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "selected"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "assistant"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + } + model := modelConfig() + model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = "model-auth", "api-key" + switch harnessType { + case translator.HarnessTypeKagent: + harness.Spec.Kagent = &v1alpha3.KagentHarness{} + case translator.HarnessTypeCodex: + harness.Spec.Codex = &v1alpha3.CodexHarness{} + responses := v1alpha3.OpenAIAPIFormatResponses + model.Spec.OpenAI = &v1alpha3.OpenAIConfig{APIFormat: &responses} + case translator.HarnessTypeClaude: + harness.Spec.Claude = &v1alpha3.ClaudeHarness{} + model.Spec.Provider, model.Spec.Model = v1alpha3.ModelProviderAnthropic, "claude-sonnet-4-5" + case translator.HarnessTypeBYO: + harness.Spec.BYO = &v1alpha3.BYOHarness{} + harness.Spec.Workload.Command = []string{"/agent"} + template.Spec.ModelConfig = nil + } + objects := []any{ + model, harness, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "model-auth"}, Data: map[string][]byte{"api-key": []byte("secret")}}, + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "selected"}}, + } + plain, err := compiler(t, objects...).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + require.Nil(t, plain.ToolImages, "a Harness without tool images compiles to a nil list") + plainDigest, err := plain.Digest() + require.NoError(t, err) + + harness.Spec.Workload.ToolImages = []v1alpha3.HarnessToolImage{{Name: "kubectl", OCI: kubectlImage}, {Name: "helm", OCI: helmImage}} + originalHarness := harness.DeepCopy() + result, err := compiler(t, objects...).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + require.Equal(t, originalHarness, harness) + require.Equal(t, []translator.ToolImage{{Name: "kubectl", Reference: kubectlImage}, {Name: "helm", Reference: helmImage}}, result.ToolImages) + digest, err := result.Digest() + require.NoError(t, err) + require.NotEqual(t, plainDigest, digest, "tool images are part of revision identity") + + actor, err := substrate.ActorTemplateForRevision(&result.Revision, digest) + require.NoError(t, err) + container := actor.GetContainers()[0] + require.Equal(t, "/opt/kagent/tools/kubectl", container.GetVolumeMounts()[3].GetMountPath()) + require.Equal(t, "/opt/kagent/tools/helm", container.GetVolumeMounts()[4].GetMountPath()) + }) + } +} diff --git a/go/core/internal/translator/revision.go b/go/core/internal/translator/revision.go index 32a54e872b..10ecc6ef06 100644 --- a/go/core/internal/translator/revision.go +++ b/go/core/internal/translator/revision.go @@ -46,9 +46,11 @@ type Revision struct { Namespace string // Image and Environment describe the runtime container. - Image string - Command []string - Args []string + Image string + Command []string + Args []string + // ToolImages are mounted read-only next to the runtime image; see go/pkg/toolpath. + ToolImages []ToolImage Environment []corev1.EnvVar // ConfigJSON is injected into the runtime container verbatim. // AgentCard stays typed until a runtime or public protocol boundary renders it. @@ -69,6 +71,12 @@ type Revision struct { EgressDestinations []string } +// ToolImage is one digest-pinned image volume of a revision. +type ToolImage struct { + Name string + Reference string +} + // Equals compares the Agent Card's contents without inspecting protobuf caches. func (r Revision) Equals(other Revision) bool { if !proto.Equal(r.AgentCard, other.AgentCard) { @@ -97,6 +105,7 @@ func (r *Revision) Digest() (RevisionID, error) { Image string `json:"image"` Command []string `json:"command,omitempty"` Args []string `json:"args,omitempty"` + ToolImages []ToolImage `json:"toolImages,omitempty"` Environment []corev1.EnvVar `json:"environment"` ConfigJSON json.RawMessage `json:"config"` WorkerPoolName string `json:"workerPoolName"` @@ -111,7 +120,7 @@ func (r *Revision) Digest() (RevisionID, error) { WorkerPoolNamespace string `json:"workerPoolNamespace"` }{ AgentName: r.AgentName, AgentUID: r.AgentUID, Namespace: r.Namespace, - Image: r.Image, Command: r.Command, Args: r.Args, Environment: r.Environment, ConfigJSON: r.ConfigJSON, + Image: r.Image, Command: r.Command, Args: r.Args, ToolImages: r.ToolImages, Environment: r.Environment, ConfigJSON: r.ConfigJSON, WorkerPoolName: r.WorkerPoolName, SnapshotLocation: r.SnapshotLocation, Provenance: r.Provenance, Credentials: r.Credentials, EgressDestinations: r.EgressDestinations, SandboxClass: sandboxClass, WorkerPoolNamespace: r.Namespace, diff --git a/go/core/internal/translator/revision_test.go b/go/core/internal/translator/revision_test.go index ecde2c53c5..b06816568c 100644 --- a/go/core/internal/translator/revision_test.go +++ b/go/core/internal/translator/revision_test.go @@ -143,3 +143,29 @@ func TestRevisionDigestIncludesBinaryAgentCard(t *testing.T) { _, err = revision.Digest() require.Error(t, err) } + +func TestRevisionDigestIncludesToolImages(t *testing.T) { + revision := &Revision{Namespace: "agents", AgentName: "helper"} + withoutTools, err := revision.Digest() + require.NoError(t, err) + revision.ToolImages = []ToolImage{} + empty, err := revision.Digest() + require.NoError(t, err) + require.Equal(t, withoutTools, empty, "an empty list must keep existing revision identities") + + reference := "example.com/kubectl@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + revision.ToolImages = []ToolImage{{Name: "kubectl", Reference: reference}, {Name: "helm", Reference: reference}} + first, err := revision.Digest() + require.NoError(t, err) + require.NotEqual(t, withoutTools, first) + + revision.ToolImages = []ToolImage{{Name: "helm", Reference: reference}, {Name: "kubectl", Reference: reference}} + reordered, err := revision.Digest() + require.NoError(t, err) + require.NotEqual(t, first, reordered, "PATH order is part of the revision") + + revision.ToolImages[0].Reference = "example.com/helm@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + changed, err := revision.Digest() + require.NoError(t, err) + require.NotEqual(t, reordered, changed) +} diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index 0aca1a01d6..e9d2437229 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -16,6 +16,7 @@ import ( "github.com/kagent-dev/kagent/go/harness/claude/internal/driver" "github.com/kagent-dev/kagent/go/harness/internal/utils" otel "github.com/kagent-dev/kagent/go/pkg/telemetry" + "github.com/kagent-dev/kagent/go/pkg/toolpath" "github.com/kagent-dev/kagent/go/pkg/tracing" ) @@ -72,7 +73,7 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { } pluginDirs = materialized.ClaudeFormatPluginRoots() } - environment := setEnvironment(input.Environment, config.ClaudeConfigDirEnvName, claudeDir) + environment := childEnvironment(input.Environment, claudeDir) // The native runtime inherits the compiled identity through the standard // resource variable, so no user-supplied marker is required. environment, awaitTelemetry := nativeTelemetryEnvironment(tracing.ResourceEnvironment(otel.Environment(environment, cfg.RuntimeTelemetry.ExporterConfig()), cfg.RuntimeTelemetry.ChildResource()), cfg.RuntimeTelemetry) @@ -224,6 +225,13 @@ func nativeTelemetryEnvironment(environment []string, telemetry tracing.RuntimeT return environment, true } +// childEnvironment is the base environment of the Claude CLI. Tool image +// binaries follow the image PATH, so the pinned claude binary always wins; the +// harness's own lookup of that binary keeps the unextended process PATH. +func childEnvironment(environment []string, claudeDir string) []string { + return setEnvironment(toolpath.Extend(environment), config.ClaudeConfigDirEnvName, claudeDir) +} + func setEnvironment(environment []string, name, value string) []string { prefix := name + "=" result := make([]string, 0, len(environment)+1) diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index 317f7a10f0..f7423d6438 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -183,6 +183,18 @@ func TestMaterializeGoogleCredentials(t *testing.T) { } } +func TestChildEnvironmentAppendsToolImagePaths(t *testing.T) { + got := childEnvironment([]string{"PATH=/bin", "KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin"}, "/data/claude") + want := []string{"KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin", "PATH=/bin:/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin", "CLAUDE_CONFIG_DIR=/data/claude"} + if !slices.Equal(got, want) { + t.Fatalf("childEnvironment() = %v, want %v", got, want) + } + got = childEnvironment([]string{"PATH=/bin"}, "/data/claude") + if !slices.Equal(got, []string{"PATH=/bin", "CLAUDE_CONFIG_DIR=/data/claude"}) { + t.Fatalf("childEnvironment() without tool images = %v", got) + } +} + 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/go/harness/codex/internal/adapter/adapter.go b/go/harness/codex/internal/adapter/adapter.go index 079665a8d1..39f68f570a 100644 --- a/go/harness/codex/internal/adapter/adapter.go +++ b/go/harness/codex/internal/adapter/adapter.go @@ -17,6 +17,7 @@ import ( "github.com/kagent-dev/kagent/go/harness/codex/internal/driver" "github.com/kagent-dev/kagent/go/harness/internal/utils" sharedtelemetry "github.com/kagent-dev/kagent/go/pkg/telemetry" + "github.com/kagent-dev/kagent/go/pkg/toolpath" "github.com/kagent-dev/kagent/go/pkg/tracing" ) @@ -310,8 +311,10 @@ func reconcileGeneratedDir(directory string, keep map[string]struct{}) error { } // nativeEnvironment drops OTLP compression: the Codex exporter is built without -// gzip and refuses to start when asked for it. +// gzip and refuses to start when asked for it. Tool image binaries follow the +// image PATH, so the pinned codex binary always wins. func nativeEnvironment(environment []string, codexHome string, telemetry tracing.RuntimeTelemetry) []string { + environment = toolpath.Extend(environment) environment = tracing.ResourceEnvironment(setEnvironment(sharedtelemetry.Environment(environment, telemetry.ExporterConfig()), codexHomeEnv, codexHome), telemetry.ChildResource()) return slices.DeleteFunc(environment, func(item string) bool { return strings.HasPrefix(item, env.OTELExporterOTLPCompression.Name()+"=") diff --git a/go/harness/codex/internal/adapter/adapter_test.go b/go/harness/codex/internal/adapter/adapter_test.go index 56631d078d..a4f1b516af 100644 --- a/go/harness/codex/internal/adapter/adapter_test.go +++ b/go/harness/codex/internal/adapter/adapter_test.go @@ -300,6 +300,18 @@ func featureEnabled(output []byte, name string) bool { return false } +func TestNativeEnvironmentAppendsToolImagePaths(t *testing.T) { + telemetry := tracing.RuntimeTelemetry{Runtime: tracing.RuntimeCodex, AgentName: "demo-codex", AgentNamespace: "team"} + got := nativeEnvironment([]string{"PATH=/bin", "KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin"}, "/data/codex", telemetry) + if !slices.Contains(got, "PATH=/bin:/opt/kagent/tools/go/bin") || !slices.Contains(got, "KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin") { + t.Fatalf("environment = %v, want PATH extended by the tool image paths", got) + } + got = nativeEnvironment([]string{"PATH=/bin"}, "/data/codex", telemetry) + if !slices.Contains(got, "PATH=/bin") { + t.Fatalf("environment = %v, want the image PATH untouched without tool images", got) + } +} + func TestNativeEnvironmentDropsCompressionCodexCannotLoad(t *testing.T) { got := nativeEnvironment([]string{"PATH=/bin", "OTEL_EXPORTER_OTLP_COMPRESSION=gzip", "CODEX_HOME=/wrong"}, "/data/codex", tracing.RuntimeTelemetry{Runtime: tracing.RuntimeCodex, AgentName: "demo-codex", AgentNamespace: "team"}) diff --git a/go/pkg/toolpath/toolpath.go b/go/pkg/toolpath/toolpath.go new file mode 100644 index 0000000000..971dd2fce5 --- /dev/null +++ b/go/pkg/toolpath/toolpath.go @@ -0,0 +1,92 @@ +// Package toolpath is the contract between the controller and every runtime +// for Harness tool images: where the controller mounts them and how a runtime +// exposes their binaries on PATH. +package toolpath + +import ( + "slices" + "strings" +) + +const ( + // EnvName carries the colon-joined bin directories of the mounted tool images. + EnvName = "KAGENT_TOOL_PATHS" + // MountRoot is the directory under which each tool image is mounted by name. + MountRoot = "/opt/kagent/tools" + + pathEnvName = "PATH" +) + +// MountPath returns the read-only mount directory of a named tool image. +func MountPath(name string) string { + return MountRoot + "/" + name +} + +// BinDir returns the PATH entry of a named tool image. +func BinDir(name string) string { + return MountPath(name) + "/bin" +} + +// JoinBinDirs returns the EnvName value for the named tool images, in order. +func JoinBinDirs(names []string) string { + dirs := make([]string, 0, len(names)) + for _, name := range names { + dirs = append(dirs, BinDir(name)) + } + return strings.Join(dirs, ":") +} + +// ExtendPath appends every non-empty entry of toolPaths that path does not +// already contain. Appending keeps the image's own binaries ahead of the tools. +func ExtendPath(path, toolPaths string) string { + entries := strings.Split(path, ":") + if path == "" { + entries = nil + } + for dir := range strings.SplitSeq(toolPaths, ":") { + if dir == "" || slices.Contains(entries, dir) { + continue + } + entries = append(entries, dir) + } + return strings.Join(entries, ":") +} + +// ProcessPath returns PATH extended by the EnvName entry of environment. The +// second result is false when environment carries no tool paths. +func ProcessPath(environment []string) (string, bool) { + toolPaths, ok := lookup(environment, EnvName) + if !ok || toolPaths == "" { + return "", false + } + path, _ := lookup(environment, pathEnvName) + return ExtendPath(path, toolPaths), true +} + +// Extend returns environment with PATH extended by its own EnvName entry. It +// returns environment unchanged when no tool paths are set. +func Extend(environment []string) []string { + path, ok := ProcessPath(environment) + if !ok { + return environment + } + result := make([]string, 0, len(environment)+1) + for _, entry := range environment { + if name, _, _ := strings.Cut(entry, "="); name == pathEnvName { + continue + } + result = append(result, entry) + } + return append(result, pathEnvName+"="+path) +} + +func lookup(environment []string, name string) (string, bool) { + var value string + found := false + for _, entry := range environment { + if key, entryValue, ok := strings.Cut(entry, "="); ok && key == name { + value, found = entryValue, true + } + } + return value, found +} diff --git a/go/pkg/toolpath/toolpath_test.go b/go/pkg/toolpath/toolpath_test.go new file mode 100644 index 0000000000..c84c0b30a4 --- /dev/null +++ b/go/pkg/toolpath/toolpath_test.go @@ -0,0 +1,50 @@ +package toolpath + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJoinBinDirsKeepsListOrder(t *testing.T) { + require.Equal(t, "/opt/kagent/tools/kubectl/bin:/opt/kagent/tools/helm/bin", JoinBinDirs([]string{"kubectl", "helm"})) + require.Equal(t, "", JoinBinDirs(nil)) +} + +func TestExtendPathAppendsAfterImagePath(t *testing.T) { + got := ExtendPath("/usr/local/bin:/usr/bin", "/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin") + require.Equal(t, "/usr/local/bin:/usr/bin:/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin", got) +} + +func TestExtendPathSkipsEmptyAndDuplicateEntries(t *testing.T) { + got := ExtendPath("/usr/bin:/opt/kagent/tools/go/bin", ":/opt/kagent/tools/go/bin::/opt/kagent/tools/helm/bin:") + require.Equal(t, "/usr/bin:/opt/kagent/tools/go/bin:/opt/kagent/tools/helm/bin", got) +} + +func TestExtendPathStartsFromEmptyPath(t *testing.T) { + require.Equal(t, "/opt/kagent/tools/go/bin", ExtendPath("", "/opt/kagent/tools/go/bin")) +} + +func TestExtendIsNoopWithoutToolPaths(t *testing.T) { + environment := []string{"PATH=/bin", "HOME=/data"} + require.Equal(t, environment, Extend(environment)) + require.Equal(t, []string{"PATH=/bin", "KAGENT_TOOL_PATHS="}, Extend([]string{"PATH=/bin", "KAGENT_TOOL_PATHS="})) +} + +func TestExtendReplacesPathAndKeepsToolPaths(t *testing.T) { + got := Extend([]string{"PATH=/bin", "KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin", "HOME=/data", "PATH=/usr/bin"}) + require.Equal(t, []string{"KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin", "HOME=/data", "PATH=/usr/bin:/opt/kagent/tools/go/bin"}, got) +} + +func TestExtendAddsPathWhenImageHasNone(t *testing.T) { + got := Extend([]string{"KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin"}) + require.Equal(t, []string{"KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin", "PATH=/opt/kagent/tools/go/bin"}, got) +} + +func TestProcessPath(t *testing.T) { + path, ok := ProcessPath([]string{"PATH=/bin", "KAGENT_TOOL_PATHS=/opt/kagent/tools/go/bin"}) + require.True(t, ok) + require.Equal(t, "/bin:/opt/kagent/tools/go/bin", path) + _, ok = ProcessPath([]string{"PATH=/bin"}) + require.False(t, ok) +} diff --git a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml index 50d59e5113..e6a3ae4432 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml @@ -246,6 +246,40 @@ spec: digest. pattern: ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ type: string + toolImages: + description: |- + ToolImages lists digest-pinned OCI images the controller mounts read-only + at /opt/kagent/tools/ in every runtime. KAGENT_TOOL_PATHS carries + the colon-joined /opt/kagent/tools//bin directories in list order; + each runtime appends them to PATH after the image's own binaries. + items: + description: HarnessToolImage is one read-only image volume + added to the runtime container. + properties: + name: + description: |- + Name selects the mount directory /opt/kagent/tools/. It must be a + DNS label of at most 58 characters: the controller prefixes it with + "tool-" to form the volume name, which is itself limited to 63. + maxLength: 58 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + oci: + description: OCI is the image reference pinned by sha256 + digest. Tags are not resolved. + maxLength: 512 + pattern: ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ + type: string + required: + - name + - oci + type: object + maxItems: 16 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map required: - image type: object diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index fa9402d047..b00149eaec 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -13,7 +13,7 @@ from google.adk.cli.utils.agent_loader import AgentLoader from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig, bootstrap, configure_logging, instrument_app -from kagent.core._config_materialize import materialize_from_env +from kagent.core._config_materialize import extend_path_from_tool_images, materialize_from_env from kagent.core._runtime_config import RuntimeConfig, TokenPropagationConfig from kagent.core.telemetry import conv @@ -264,6 +264,7 @@ def test( def run_cli(): configure_logging() logger.info("Starting KAgent") + extend_path_from_tool_images() app() diff --git a/python/packages/kagent-adk/tests/unittests/test_config_materialize.py b/python/packages/kagent-adk/tests/unittests/test_config_materialize.py index bdf572d68a..29463bf824 100644 --- a/python/packages/kagent-adk/tests/unittests/test_config_materialize.py +++ b/python/packages/kagent-adk/tests/unittests/test_config_materialize.py @@ -1,7 +1,7 @@ import os import pytest -from kagent.core._config_materialize import materialize_from_env +from kagent.core._config_materialize import extend_path_from_tool_images, materialize_from_env def test_materializes_present_env_vars(tmp_path, monkeypatch): @@ -84,3 +84,30 @@ def fake_makedirs(path, *args, **kwargs): materialize_from_env(str(config_dir)) # must not raise assert (config_dir / "config.json").exists() + + +def test_extends_path_with_tool_paths(monkeypatch): + monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin") + monkeypatch.setenv("KAGENT_TOOL_PATHS", "/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin") + + extend_path_from_tool_images() + + assert os.environ["PATH"] == "/usr/local/bin:/usr/bin:/opt/kagent/tools/go/bin:/opt/kagent/tools/kubectl/bin" + + +def test_tool_paths_noop_when_unset(monkeypatch): + monkeypatch.setenv("PATH", "/usr/bin") + monkeypatch.delenv("KAGENT_TOOL_PATHS", raising=False) + + extend_path_from_tool_images() + + assert os.environ["PATH"] == "/usr/bin" + + +def test_tool_paths_skip_duplicates(monkeypatch): + monkeypatch.setenv("PATH", "/usr/bin:/opt/kagent/tools/go/bin") + monkeypatch.setenv("KAGENT_TOOL_PATHS", ":/opt/kagent/tools/go/bin::/opt/kagent/tools/helm/bin:") + + extend_path_from_tool_images() + + assert os.environ["PATH"] == "/usr/bin:/opt/kagent/tools/go/bin:/opt/kagent/tools/helm/bin" diff --git a/python/packages/kagent-core/src/kagent/core/_config_materialize.py b/python/packages/kagent-core/src/kagent/core/_config_materialize.py index 96ed446ae9..1662d752df 100644 --- a/python/packages/kagent-core/src/kagent/core/_config_materialize.py +++ b/python/packages/kagent-core/src/kagent/core/_config_materialize.py @@ -20,6 +20,9 @@ "KAGENT_AGENT_CARD_JSON": "agent-card.json", } +# Bin directories of the Harness tool images, colon-joined by the controller. +_KAGENT_TOOL_PATHS_ENV = "KAGENT_TOOL_PATHS" + # The bearer token is materialized to a fixed path outside the config dir, matching the Go ADK. _KAGENT_TOKEN_ENV = "KAGENT_TOKEN" _KAGENT_TOKEN_PATH = "/var/run/secrets/tokens/kagent-token" @@ -67,3 +70,19 @@ def materialize_from_env(config_dir: str) -> None: _materialize_env_to_file(_KAGENT_TOKEN_ENV, _KAGENT_TOKEN_PATH) except OSError as e: logger.warning("Could not materialize %s to %s: %s", _KAGENT_TOKEN_ENV, _KAGENT_TOKEN_PATH, e) + + +def extend_path_from_tool_images() -> None: + """Append the tool image bin directories to ``PATH`` so shell tools find them. + + Appending keeps the image's own binaries ahead of the tools. No-op when the controller + mounted no tool images. + """ + tool_paths = os.environ.get(_KAGENT_TOOL_PATHS_ENV, "") + if not tool_paths: + return + entries = [entry for entry in os.environ.get("PATH", "").split(":") if entry] + for entry in tool_paths.split(":"): + if entry and entry not in entries: + entries.append(entry) + os.environ["PATH"] = ":".join(entries) diff --git a/ui/src/api/domain/harnesses.ts b/ui/src/api/domain/harnesses.ts index bdd50303d7..508454501a 100644 --- a/ui/src/api/domain/harnesses.ts +++ b/ui/src/api/domain/harnesses.ts @@ -91,7 +91,13 @@ export interface HarnessSpec { claude?: Record; /** Bring your own: an image that serves kagent's A2A contract itself. */ byo?: Record; - workload: { image: string; command?: string[]; args?: string[] }; + workload: { + image: string; + command?: string[]; + args?: string[]; + /** Digest-pinned images mounted read-only at /opt/kagent/tools/; the controller puts their bin/ on KAGENT_TOOL_PATHS. */ + toolImages?: { name: string; oci: string }[]; + }; substrate: { workerPoolRef: { name: string }; snapshotPolicy: { location: string }; diff --git a/ui/src/components/harness-form/harnessDraft.test.ts b/ui/src/components/harness-form/harnessDraft.test.ts index 1df7b48e35..9555d861ec 100644 --- a/ui/src/components/harness-form/harnessDraft.test.ts +++ b/ui/src/components/harness-form/harnessDraft.test.ts @@ -16,6 +16,13 @@ describe("harness draft", () => { expect(harnessDraftProblems(draft)).toEqual([]); expect(harnessSpecFromDraft(draft, spec)).toEqual(spec); }); + it("keeps toolImages the form does not author", () => { + const toolImages = [{ name: "kubectl", oci: `ghcr.io/example/kubectl@sha256:${"b".repeat(64)}` }]; + const withTools: HarnessSpec = { ...spec, workload: { image, toolImages } }; + const next = harnessSpecFromDraft(harnessDraftFromSpec(withTools), withTools); + expect(next.workload).toEqual({ image, toolImages }); + expect(harnessSpecFromDraft(harnessDraftFromSpec(spec), spec).workload).toEqual({ image }); + }); it("switching adapter drops the old one so exactly one remains", () => { const next = harnessSpecFromDraft({ ...harnessDraftFromSpec(spec), adapter: "codex" }, spec); expect(next.kagent).toBeUndefined(); diff --git a/ui/src/components/harness-form/harnessDraft.ts b/ui/src/components/harness-form/harnessDraft.ts index 709b4c1f8e..80c00d69fa 100644 --- a/ui/src/components/harness-form/harnessDraft.ts +++ b/ui/src/components/harness-form/harnessDraft.ts @@ -40,6 +40,7 @@ export function harnessSpecFromDraft(draft: HarnessDraft, existing?: HarnessSpec // Exactly one adapter, which the CRD requires. [draft.adapter]: existing?.[draft.adapter] ?? {}, workload: { + ...(existing?.workload?.toolImages ? { toolImages: existing.workload.toolImages } : {}), image: draft.image.trim(), ...(draft.command.length > 0 ? { command: draft.command } : {}), ...(draft.args.length > 0 ? { args: draft.args } : {}),