diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ac4496831..a8882ba61 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -128,7 +128,7 @@ jobs: --set-string substrateWorkerPool.workerImage=ghcr.io/kagent-dev/substrate/ateom-gvisor:v${{ env.SUBSTRATE_VERSION }} run: | echo "Cache key: ${{ needs.setup.outputs.cache-key }}" - printf '%s\n' controller golang-adk claude-harness byo-a2a | xargs -P4 -n1 bash -c ' + printf '%s\n' controller golang-adk claude-harness codex-harness byo-a2a | xargs -P4 -n1 bash -c ' image="$1" DOCKER_BUILD_ARGS="--cache-from=type=gha,scope=${{ needs.setup.outputs.cache-key }}-e2e-${image} --cache-from=type=gha,scope=${{ env.CACHE_KEY_PREFIX }}-main-e2e-${image} --cache-to=type=gha,scope=${{ needs.setup.outputs.cache-key }}-e2e-${image},mode=max --platform=linux/amd64 --push" \ make GIT_COMMIT=e2e BUILD_DATE=1970-01-01 "build-${image}" @@ -150,6 +150,9 @@ jobs: CLAUDE_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/claude-harness:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') test -n "$CLAUDE_DIGEST" export KAGENT_E2E_CLAUDE_IMAGE="localhost:5001/kagent-dev/kagent/claude-harness@${CLAUDE_DIGEST}" + CODEX_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/codex-harness:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + test -n "$CODEX_DIGEST" + export KAGENT_E2E_CODEX_IMAGE="localhost:5001/kagent-dev/kagent/codex-harness@${CODEX_DIGEST}" envsubst < core/test/e2e/manifests/lifecycle.yaml.tmpl | kubectl apply -f - for _ in $(seq 1 60); do READY=$(kubectl get agenttemplate smoke -n kagent -o jsonpath='{.status.harnesses[?(@.harness=="kagent")].conditions[?(@.type=="Ready")].status}') @@ -411,6 +414,7 @@ jobs: - cli - golang-adk - claude-harness + - codex-harness runs-on: ubuntu-latest services: registry: diff --git a/.github/workflows/image-scan.yaml b/.github/workflows/image-scan.yaml index 0e40e4e8e..ac6bfed76 100644 --- a/.github/workflows/image-scan.yaml +++ b/.github/workflows/image-scan.yaml @@ -45,6 +45,9 @@ jobs: - build_target: claude-harness image_name: claude-harness tag_suffix: "" + - build_target: codex-harness + image_name: codex-harness + tag_suffix: "" runs-on: ubuntu-latest services: registry: diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index e8625478d..a2d2811d6 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -22,6 +22,7 @@ jobs: - kagent-adk - golang-adk - claude-harness + - codex-harness runs-on: ubuntu-latest permissions: contents: read diff --git a/Makefile b/Makefile index 1b0ace037..3e7a51077 100644 --- a/Makefile +++ b/Makefile @@ -56,16 +56,19 @@ KAGENT_ADK_IMAGE_NAME ?= kagent-adk GOLANG_ADK_IMAGE_NAME ?= golang-adk CLAUDE_HARNESS_IMAGE_NAME ?= claude-harness +CODEX_HARNESS_IMAGE_NAME ?= codex-harness CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) GOLANG_ADK_IMAGE_TAG ?= $(VERSION) CLAUDE_HARNESS_IMAGE_TAG ?= $(VERSION) +CODEX_HARNESS_IMAGE_TAG ?= $(VERSION) CONTROLLER_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CONTROLLER_IMAGE_NAME):$(CONTROLLER_IMAGE_TAG) UI_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(UI_IMAGE_NAME):$(UI_IMAGE_TAG) KAGENT_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KAGENT_ADK_IMAGE_NAME):$(KAGENT_ADK_IMAGE_TAG) GOLANG_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(GOLANG_ADK_IMAGE_NAME):$(GOLANG_ADK_IMAGE_TAG) CLAUDE_HARNESS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CLAUDE_HARNESS_IMAGE_NAME):$(CLAUDE_HARNESS_IMAGE_TAG) +CODEX_HARNESS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CODEX_HARNESS_IMAGE_NAME):$(CODEX_HARNESS_IMAGE_TAG) #take from go/go.mod AWK ?= $(shell command -v gawk || command -v awk) @@ -223,18 +226,20 @@ build-all: BUILD_ARGS ?= --progress=plain --builder $(BUILDX_BUILDER_NAME) --pla build-all: proto-generate buildx-create $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f go/Dockerfile ./go $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f go/harness/claude/Dockerfile ./go + $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f go/harness/codex/Dockerfile ./go $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f ui/Dockerfile ./ui $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f python/Dockerfile ./python .PHONY: build build: ## Build and push all component images -build: buildx-create build-ui build-kagent-adk build-golang-adk build-claude-harness build-controller +build: buildx-create build-ui build-kagent-adk build-golang-adk build-claude-harness build-codex-harness build-controller @echo "Build completed successfully." @echo "Controller Image: $(CONTROLLER_IMG)" @echo "UI Image: $(UI_IMG)" @echo "Kagent ADK Image: $(KAGENT_ADK_IMG)" @echo "Golang ADK Image: $(GOLANG_ADK_IMG)" @echo "Claude Harness Image: $(CLAUDE_HARNESS_IMG)" + @echo "Codex Harness Image: $(CODEX_HARNESS_IMG)" .PHONY: build-monitor build-monitor: ## Watch BuildKit process list inside the buildx container @@ -263,6 +268,7 @@ build-img-versions: ## Print the fully-qualified image tags for all components @echo kagent-adk=$(KAGENT_ADK_IMG) @echo golang-adk=$(GOLANG_ADK_IMG) @echo claude-harness=$(CLAUDE_HARNESS_IMG) + @echo codex-harness=$(CODEX_HARNESS_IMG) .PHONY: controller-manifests controller-manifests: ## Regenerate CRD manifests and copy them into the Helm chart @@ -307,6 +313,12 @@ build-claude-harness: buildx-create $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(CLAUDE_HARNESS_IMG) -f go/harness/claude/Dockerfile ./go $(DOCKER_PUSH) $(CLAUDE_HARNESS_IMG) +.PHONY: build-codex-harness +build-codex-harness: ## Build and push the native Codex Harness image +build-codex-harness: buildx-create + $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(CODEX_HARNESS_IMG) -f go/harness/codex/Dockerfile ./go + $(DOCKER_PUSH) $(CODEX_HARNESS_IMG) + .PHONY: push push: ## Push all component images (controller, ui, ADKs) push: push-controller push-ui push-kagent-adk push-golang-adk diff --git a/go/.gitignore b/go/.gitignore index 785401d67..e01adb783 100644 --- a/go/.gitignore +++ b/go/.gitignore @@ -24,3 +24,6 @@ Dockerfile.cross *.swp *.swo *~ + +# Schema bundle generated by Codex CLI +harness/codex/protocol/ diff --git a/go/README.md b/go/README.md index 599713284..4ad24f09e 100644 --- a/go/README.md +++ b/go/README.md @@ -1,6 +1,6 @@ # Kagent Go -This directory is a single Go module (`github.com/kagent-dev/kagent/go`) containing three top-level package trees that make up the Go components of Kagent. +This directory is a single Go module (`github.com/kagent-dev/kagent/go`) containing four top-level package trees that make up the Go components of Kagent. ## Packages @@ -9,6 +9,7 @@ This directory is a single Go module (`github.com/kagent-dev/kagent/go`) contain | **api** | `go/api/` | Shared types: CRD definitions, ADK model types, database models, HTTP client SDK | | **core** | `go/core/` | Infrastructure: Kubernetes controllers, HTTP server, CLI, database implementation | | **adk** | `go/adk/` | Go Agent Development Kit for building and running agents | +| **harness** | `go/harness/` | Native Claude and Codex Actor runtimes plus their shared A2A execution support | ### Dependency graph @@ -46,10 +47,15 @@ go/ │ ├── hack/ # Development utilities (mock LLM, config gen) │ └── test/e2e/ # End-to-end tests │ -└── adk/ # Go Agent Development Kit module - ├── cmd/ # ADK server entry point - ├── pkg/ # Agent runtime, models, MCP, sessions, skills - └── examples/ # Example tools (oneshot runner, BYO agent) +├── adk/ # Go Agent Development Kit +│ ├── cmd/ # ADK server entry point +│ ├── pkg/ # Agent runtime, models, MCP, sessions, skills +│ └── examples/ # Example tools (oneshot runner, BYO agent) +│ +└── harness/ # Native Harness Actor runtimes + ├── claude/ # Claude Code adapter and image + ├── codex/ # Codex App Server adapter and image + └── runtime/ # Shared A2A, continuation, and OS utilities ``` ## Building @@ -119,9 +125,13 @@ The controller embeds OCI manifest digests for agent workload images at **link t |---|---|---| | `golang-adk` | `build-golang-adk` | `AgentImageDigest` | - `kagent-adk` remains available as a base for Python BYO images, but is not a declarative runtime. +The native images use the root `build-claude-harness` and +`build-codex-harness` targets. Their digest-pinned references are supplied +explicitly through `Harness.spec.workload.image`; they are not controller +linker values. + ## Quick Testing with Oneshot The `adk/examples/oneshot` tool lets you test agent configs locally: diff --git a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml index 7cdb61678..c739cab36 100644 --- a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml +++ b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml @@ -471,6 +471,15 @@ spec: latestSuccessfulRevision: minLength: 1 type: string + warnings: + description: |- + Warnings reports non-blocking compatibility decisions made while compiling + this AgentTemplate for the Harness. + items: + type: string + maxItems: 100 + type: array + x-kubernetes-list-type: set required: - desiredRevision - harness diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index 06bcac016..1259cf166 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -229,6 +229,12 @@ type AgentTemplateHarnessStatus struct { // +kubebuilder:validation:MinLength=1 // +optional LatestSuccessfulRevision string `json:"latestSuccessfulRevision,omitempty"` + // Warnings reports non-blocking compatibility decisions made while compiling + // this AgentTemplate for the Harness. + // +kubebuilder:validation:MaxItems=100 + // +listType=set + // +optional + Warnings []string `json:"warnings,omitempty"` // +kubebuilder:validation:MaxItems=4 // +listType=map // +listMapKey=type diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 9c110de40..0f719178b 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -559,6 +559,11 @@ func (in *AgentTemplateConfigMapKeyReference) DeepCopy() *AgentTemplateConfigMap // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentTemplateHarnessStatus) DeepCopyInto(out *AgentTemplateHarnessStatus) { *out = *in + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]metav1.Condition, len(*in)) diff --git a/go/core/test/e2e/codex_interaction_test.go b/go/core/test/e2e/codex_interaction_test.go new file mode 100644 index 000000000..183e587d7 --- /dev/null +++ b/go/core/test/e2e/codex_interaction_test.go @@ -0,0 +1,497 @@ +package e2e_test + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/mockllm" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const codexE2EHarness = "codex-e2e" + +//go:embed mocks/invoke_codex_agent.json mocks/invoke_codex_builtin_tools.json mocks/invoke_codex_resources.json +var codexInteractionMocks embed.FS + +func TestE2ECodexMockInteractionResumeAndPersistence(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableModelURL(t, startMockLLMServer(t, codexInteractionMocks, "mocks/invoke_codex_agent.json")) + template := createCodexMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, codexE2EHarness, template) + + streamed := sendCodexStreaming(t, fixture, "Return exactly CODEX_MOCK_FIRST.") + if streamed.state != a2atype.TaskStateCompleted { + t.Fatalf("streamed mock Codex task state = %s, failure = %q, want COMPLETED", streamed.state, streamed.failureText) + } + if !streamed.sawWorking || !streamed.sawArtifact { + t.Fatalf("streamed mock Codex events: working=%t artifact=%t, want both", streamed.sawWorking, streamed.sawArtifact) + } + if !strings.Contains(streamed.text, "CODEX_MOCK_FIRST") { + t.Fatalf("streamed mock Codex response = %q, want CODEX_MOCK_FIRST", streamed.text) + } + first := getCodexTask(t, fixture, streamed.taskID) + if first.Status.State != a2atype.TaskStateCompleted || !strings.Contains(taskText(first), "CODEX_MOCK_FIRST") { + t.Fatalf("persisted first Codex task state = %s, text = %q", first.Status.State, taskText(first)) + } + + _, _, resumed := fixture.send(t, "Return exactly CODEX_MOCK_SECOND.") + if resumed.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("resumed mock Codex task state = %s, text = %q, want COMPLETED", resumed.Status.State, taskText(resumed)) + } + if text := taskText(resumed); !strings.Contains(text, "CODEX_MOCK_SECOND") { + t.Fatalf("resumed mock Codex response = %q, want CODEX_MOCK_SECOND", text) + } + assertCodexTaskHistory(t, fixture, first.ID, resumed.ID) +} + +func TestE2ECodexMockCheckpointForkAndResume(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableModelURL(t, startMockLLMServer(t, codexInteractionMocks, "mocks/invoke_codex_agent.json")) + template := createCodexMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, codexE2EHarness, template) + + _, _, first := fixture.send(t, "Return exactly CODEX_MOCK_FIRST.") + if first.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("initial mock Codex task state = %s, want COMPLETED", first.Status.State) + } + + created, err := fixture.checkpoints.CreateCheckpoint(fixture.ctx, &apiv1alpha1.CreateCheckpointRequest{ + Namespace: "kagent", AgentInstanceId: fixture.instanceID, RequestId: uuid.NewString(), + }) + if err != nil { + t.Fatalf("create Codex checkpoint: %v", err) + } + checkpointID := created.GetCheckpoint().GetId() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), time.Minute) + defer cancel() + _, cleanupErr := fixture.checkpoints.DeleteCheckpoint(ctx, &apiv1alpha1.DeleteCheckpointRequest{ + Namespace: "kagent", CheckpointId: checkpointID, + }) + if cleanupErr != nil && status.Code(cleanupErr) != codes.NotFound { + t.Errorf("delete Codex checkpoint: %v", cleanupErr) + } + }) + + forked, err := fixture.checkpoints.ForkAgentInstance(fixture.ctx, &apiv1alpha1.ForkAgentInstanceRequest{ + Namespace: "kagent", CheckpointId: checkpointID, RequestId: uuid.NewString(), + }) + if err != nil { + t.Fatalf("fork Codex AgentInstance: %v", err) + } + fork := forked.GetAgentInstance() + if fork.GetState() != apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_READY { + t.Fatalf("forked Codex AgentInstance state = %s, want READY", fork.GetState()) + } + forkID := fork.GetId() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), time.Minute) + defer cancel() + _, cleanupErr := fixture.instances.DeleteAgentInstance(ctx, &apiv1alpha1.DeleteAgentInstanceRequest{ + Namespace: "kagent", AgentInstanceId: forkID, + }) + if cleanupErr != nil && status.Code(cleanupErr) != codes.NotFound { + t.Errorf("delete forked Codex AgentInstance: %v", cleanupErr) + } + }) + + forkCtx, forkCancel := context.WithTimeout(metadata.AppendToOutgoingContext(t.Context(), + "x-user-id", "e2e", + "x-kagent-agent-instance-namespace", "kagent", + "x-kagent-agent-instance-id", forkID, + ), 4*time.Minute) + t.Cleanup(forkCancel) + listRequest, err := pbconv.ToProtoListTasksRequest(&a2atype.ListTasksRequest{ContextID: forkID, PageSize: 10}) + if err != nil { + t.Fatalf("build forked Codex task list request: %v", err) + } + listedResponse, err := fixture.client.ListTasks(forkCtx, listRequest) + if err != nil { + t.Fatalf("list forked Codex tasks: %v", err) + } + listed, err := pbconv.FromProtoListTasksResponse(listedResponse) + if err != nil || len(listed.Tasks) != 1 || listed.Tasks[0].ContextID != forkID || listed.Tasks[0].Status.State != a2atype.TaskStateCompleted { + t.Fatalf("forked Codex tasks = %+v, error %v; want one copied task in context %s", listed, err, forkID) + } + + forkFixture := &interactionFixture{ctx: forkCtx, client: fixture.client, instanceID: forkID} + _, _, resumed := forkFixture.send(t, "Return exactly CODEX_MOCK_SECOND.") + if resumed.Status.State != a2atype.TaskStateCompleted || !strings.Contains(taskText(resumed), "CODEX_MOCK_SECOND") { + t.Fatalf("forked Codex task state = %s, text = %q, want completed resumed response", resumed.Status.State, taskText(resumed)) + } +} + +func TestE2ECodexMockBuiltinToolEvents(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableModelURL(t, startMockLLMServer(t, codexInteractionMocks, "mocks/invoke_codex_builtin_tools.json")) + template := createCodexMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, codexE2EHarness, template) + + streamed := sendCodexStreaming(t, fixture, "Run the requested shell command.") + if streamed.state != a2atype.TaskStateCompleted || !strings.Contains(streamed.text, "CODEX_BUILTIN_TOOL_DONE") { + t.Fatalf("built-in tool task state = %s, text = %q, failure = %q", streamed.state, streamed.text, streamed.failureText) + } + assertCodexToolEvents(t, streamed.toolEvents, "command_execution") + assertCodexToolEvents(t, codexTaskToolEvents(getCodexTask(t, fixture, streamed.taskID)), "command_execution") +} + +func TestE2ECodexMockWholeServerMCP(t *testing.T) { + target := interactionTarget(t) + mcpURL, mcpMock := startMCPMock(t) + + kube := interactionKubeClient(t) + mcpServer := createCodexMCPServer(t, kube, mcpURL) + modelToolNamespace := codexMCPToolNamespace(mcpServer.Name) + modelURL := startCodexResourceMockLLM(t, modelToolNamespace) + model := createCodexMockModel(t, kube, modelURL) + template := createCodexMCPTemplate(t, kube, model.Name, mcpServer.Name) + fixture := newInteractionFixtureForHarnessTemplate(t, target, codexE2EHarness, template) + + streamed := sendCodexStreaming(t, fixture, "Add 3 and 5 using the configured MCP server.") + if streamed.state != a2atype.TaskStateCompleted || !strings.Contains(streamed.text, "CODEX_MCP_DONE result is 8") { + t.Fatalf("whole-server MCP task state = %s, text = %q, failure = %q", streamed.state, streamed.text, streamed.failureText) + } + sawToolCall := false + for _, request := range mcpMock.Requests() { + if bytes.Contains(request.Body, []byte(`"method":"tools/call"`)) && bytes.Contains(request.Body, []byte(`"name":"add_numbers"`)) { + sawToolCall = true + break + } + } + if !sawToolCall { + t.Fatal("mock MCP server did not receive an add_numbers tool call") + } + toolName := mcpServer.Name + ".add_numbers" + assertCodexToolEvents(t, streamed.toolEvents, toolName) + assertCodexToolEvents(t, codexTaskToolEvents(getCodexTask(t, fixture, streamed.taskID)), toolName) +} + +type codexStreamResult struct { + taskID a2atype.TaskID + state a2atype.TaskState + text string + sawWorking bool + sawArtifact bool + toolEvents []codexToolEvent + failureText string +} + +type codexToolEvent struct { + partType string + id string + name string +} + +func sendCodexStreaming(t *testing.T, fixture *interactionFixture, text string) codexStreamResult { + t.Helper() + _, request := newMessageRequest(t, text) + stream, err := fixture.client.SendStreamingMessage(fixture.ctx, request) + if err != nil { + t.Fatalf("start streaming Codex A2A message: %v", err) + } + var result codexStreamResult + var output strings.Builder + terminalEvents := 0 + for { + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + if terminalEvents != 1 { + t.Fatalf("Codex stream terminal event count = %d, want 1", terminalEvents) + } + if result.taskID == "" { + t.Fatal("Codex stream completed without a task ID") + } + result.text = output.String() + return result + } + if err != nil { + t.Fatalf("receive Codex task stream: %v", err) + } + if terminalEvents != 0 { + t.Fatalf("Codex stream emitted an event after terminal state %s", result.state) + } + event, err := pbconv.FromProtoStreamResponse(response) + if err != nil { + t.Fatalf("decode Codex task stream: %v", err) + } + if info := event.TaskInfo(); info.TaskID != "" { + result.taskID = info.TaskID + } + switch event := event.(type) { + case *a2atype.Task: + result.state = event.Status.State + if event.Status.State == a2atype.TaskStateWorking { + result.sawWorking = true + } + case *a2atype.TaskArtifactUpdateEvent: + result.sawArtifact = true + if event.Artifact != nil { + result.toolEvents = append(result.toolEvents, codexToolEvents(event.Artifact.Parts)...) + for _, part := range event.Artifact.Parts { + output.WriteString(part.Text()) + } + } + case *a2atype.TaskStatusUpdateEvent: + result.state = event.Status.State + if event.Status.State == a2atype.TaskStateWorking { + result.sawWorking = true + } + if event.Status.State == a2atype.TaskStateFailed && event.Status.Message != nil { + var parts []string + for _, part := range event.Status.Message.Parts { + parts = append(parts, part.Text()) + } + result.failureText = strings.Join(parts, "\n") + } + if event.Status.Message != nil { + result.toolEvents = append(result.toolEvents, codexToolEvents(event.Status.Message.Parts)...) + } + } + if result.state.Terminal() { + terminalEvents++ + } + } +} + +func codexToolEvents(parts []*a2atype.Part) []codexToolEvent { + var events []codexToolEvent + for _, part := range parts { + partType, _ := part.Metadata["kagent_type"].(string) + if partType != "function_call" && partType != "function_response" { + continue + } + data, ok := part.Data().(map[string]any) + if !ok { + continue + } + id, _ := data["id"].(string) + name, _ := data["name"].(string) + events = append(events, codexToolEvent{partType: partType, id: id, name: name}) + } + return events +} + +func codexTaskToolEvents(task *a2atype.Task) []codexToolEvent { + var events []codexToolEvent + for _, message := range task.History { + if message != nil { + events = append(events, codexToolEvents(message.Parts)...) + } + } + if task.Status.Message != nil { + events = append(events, codexToolEvents(task.Status.Message.Parts)...) + } + for _, artifact := range task.Artifacts { + if artifact != nil { + events = append(events, codexToolEvents(artifact.Parts)...) + } + } + return events +} + +func assertCodexToolEvents(t *testing.T, events []codexToolEvent, toolName string) { + t.Helper() + calls, responses := 0, 0 + ids := map[string]struct{}{} + for _, event := range events { + if event.name != toolName { + continue + } + if event.id == "" { + t.Fatalf("%s event for %s has no tool-use ID", event.partType, toolName) + } + switch event.partType { + case "function_call": + calls++ + ids[event.id] = struct{}{} + case "function_response": + responses++ + if _, ok := ids[event.id]; !ok { + t.Fatalf("response for %s tool-use ID %q has no preceding call", toolName, event.id) + } + } + } + if calls != 1 || responses != 1 { + t.Fatalf("A2A events for %s: calls=%d responses=%d, want one of each; all events=%#v", toolName, calls, responses, events) + } +} + +func getCodexTask(t *testing.T, fixture *interactionFixture, taskID a2atype.TaskID) *a2atype.Task { + t.Helper() + request, err := pbconv.ToProtoGetTaskRequest(&a2atype.GetTaskRequest{ID: taskID}) + if err != nil { + t.Fatalf("build GetTask request: %v", err) + } + response, err := fixture.client.GetTask(fixture.ctx, request) + if err != nil { + t.Fatalf("get Codex task %s: %v", taskID, err) + } + task, err := pbconv.FromProtoTask(response) + if err != nil { + t.Fatalf("decode Codex task %s: %v", taskID, err) + } + return task +} + +func assertCodexTaskHistory(t *testing.T, fixture *interactionFixture, taskIDs ...a2atype.TaskID) { + t.Helper() + request, err := pbconv.ToProtoListTasksRequest(&a2atype.ListTasksRequest{ContextID: fixture.instanceID}) + if err != nil { + t.Fatalf("build ListTasks request: %v", err) + } + response, err := fixture.client.ListTasks(fixture.ctx, request) + if err != nil { + t.Fatalf("list Codex tasks: %v", err) + } + listed, err := pbconv.FromProtoListTasksResponse(response) + if err != nil { + t.Fatalf("decode Codex task list: %v", err) + } + if len(listed.Tasks) != len(taskIDs) { + t.Fatalf("Codex task count = %d, want %d", len(listed.Tasks), len(taskIDs)) + } + want := make(map[a2atype.TaskID]struct{}, len(taskIDs)) + for _, taskID := range taskIDs { + want[taskID] = struct{}{} + } + for _, task := range listed.Tasks { + if _, ok := want[task.ID]; !ok { + t.Fatalf("listed unexpected Codex task %s", task.ID) + } + if task.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("listed Codex task %s state = %s, want COMPLETED", task.ID, task.Status.State) + } + } +} + +func createCodexMockTemplate(t *testing.T, baseURL string) string { + t.Helper() + kube := interactionKubeClient(t) + model := createCodexMockModel(t, kube, baseURL) + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "codex-interaction-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "codex"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, + Description: "Codex mockLLM interaction fixture", + SystemPrompt: "Reply concisely and follow the requested output format exactly.", + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, codexE2EHarness) + return template.Name +} + +func createCodexMockModel(t *testing.T, kube ctrlclient.Client, baseURL string) *v1alpha3.ModelConfig { + t.Helper() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "codex-mock-", Namespace: "kagent"}, + Data: map[string][]byte{"OPENAI_API_KEY": []byte("mock-key")}, + } + if err := kube.Create(t.Context(), secret); err != nil { + t.Fatalf("create Codex mock Secret: %v", err) + } + t.Cleanup(func() { + if err := kube.Delete(context.Background(), secret); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete Codex mock Secret: %v", err) + } + }) + responses := v1alpha3.OpenAIAPIFormatResponses + model := &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "codex-mock-", Namespace: "kagent"}, + Spec: v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderOpenAI, + Model: "gpt-5.2-codex", + APIKeySecret: secret.Name, APIKeySecretKey: "OPENAI_API_KEY", + OpenAI: &v1alpha3.OpenAIConfig{BaseURL: baseURL, APIFormat: &responses}, + }, + } + if err := kube.Create(t.Context(), model); err != nil { + t.Fatalf("create Codex mock ModelConfig: %v", err) + } + t.Cleanup(func() { + if err := kube.Delete(context.Background(), model); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete Codex mock ModelConfig: %v", err) + } + }) + return model +} + +func createCodexMCPServer(t *testing.T, kube ctrlclient.Client, mcpURL string) *v1alpha3.RemoteMCPServer { + t.Helper() + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "codex-resources-", Namespace: "kagent"}, + Spec: v1alpha3.RemoteMCPServerSpec{ + Description: "Codex whole-server MCP E2E fixture", + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: mcpURL, + }, + } + if err := kube.Create(t.Context(), server); err != nil { + t.Fatalf("create Codex RemoteMCPServer: %v", err) + } + t.Cleanup(func() { + if err := kube.Delete(context.Background(), server); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete Codex RemoteMCPServer: %v", err) + } + }) + return server +} + +func createCodexMCPTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, mcpServer string) string { + t.Helper() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "codex-resources-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "codex"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: modelConfig}, + Description: "Codex direct whole-server MCP E2E fixture", + SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: mcpServer}, + }}}, + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, codexE2EHarness) + return template.Name +} + +func startCodexResourceMockLLM(t *testing.T, toolNamespace string) string { + t.Helper() + raw, err := codexInteractionMocks.ReadFile("mocks/invoke_codex_resources.json") + if err != nil { + t.Fatalf("read Codex resource mock fixture: %v", err) + } + raw = bytes.ReplaceAll(raw, []byte("MCP_TOOL_NAMESPACE"), []byte(toolNamespace)) + raw = bytes.ReplaceAll(raw, []byte("MCP_TOOL_SEARCH_QUERY"), []byte(toolNamespace+" add_numbers")) + var cfg mockllm.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("decode Codex Responses mock fixture: %v", err) + } + return reachableModelURL(t, startMockLLMConfig(t, cfg)) +} + +func codexMCPToolNamespace(serverName string) string { + return "mcp__" + strings.ReplaceAll(serverName, "-", "_") +} diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index 9d69480d4..319605520 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -19,6 +19,25 @@ spec: --- apiVersion: kagent.dev/v1alpha3 kind: Harness +metadata: + name: codex-e2e + namespace: kagent +spec: + codex: {} + workload: + image: ${KAGENT_E2E_CODEX_IMAGE} + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ + allowedAgentTemplates: + selector: + matchLabels: + kagent.dev/e2e-runtime: codex +--- +apiVersion: kagent.dev/v1alpha3 +kind: Harness metadata: name: byo-e2e namespace: kagent diff --git a/go/core/test/e2e/mocks/invoke_codex_agent.json b/go/core/test/e2e/mocks/invoke_codex_agent.json new file mode 100644 index 000000000..bc193cad5 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_codex_agent.json @@ -0,0 +1,74 @@ +{ + "openai_response": [ + { + "name": "first_codex_request", + "match": { + "match_type": "contains", + "input": "Return exactly CODEX_MOCK_FIRST." + }, + "response": { + "id": "resp_codex_mock_first", + "object": "response", + "created_at": 1677652288, + "model": "gpt-5.2-codex", + "output": [ + { + "id": "msg_codex_mock_first", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "CODEX_MOCK_FIRST", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": false, + "tools": [], + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14 + } + } + }, + { + "name": "resumed_codex_request", + "match": { + "match_type": "contains", + "input": "Return exactly CODEX_MOCK_SECOND." + }, + "response": { + "id": "resp_codex_mock_second", + "object": "response", + "created_at": 1677652289, + "model": "gpt-5.2-codex", + "output": [ + { + "id": "msg_codex_mock_second", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "CODEX_MOCK_SECOND", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": false, + "tools": [], + "usage": { + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16 + } + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_codex_builtin_tools.json b/go/core/test/e2e/mocks/invoke_codex_builtin_tools.json new file mode 100644 index 000000000..49aff2583 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_codex_builtin_tools.json @@ -0,0 +1,55 @@ +{ + "openai_response": [ + { + "name": "codex_builtin_start", + "match": { + "match_type": "contains", + "input": "Run the requested shell command." + }, + "response": { + "id": "resp_codex_builtin_start", + "object": "response", + "created_at": 1677652290, + "model": "gpt-5.2-codex", + "output": [{ + "id": "fc_codex_shell", + "type": "function_call", + "status": "completed", + "call_id": "call_codex_shell", + "name": "shell_command", + "arguments": "{\"command\":\"printf CODEX_TOOL_FILE\",\"workdir\":\"/data/workspace\"}" + }], + "parallel_tool_calls": false, + "tools": [], + "usage": {"input_tokens": 10, "output_tokens": 8, "total_tokens": 18} + } + }, + { + "name": "codex_builtin_result", + "match": { + "match_type": "contains", + "input": "call_codex_shell" + }, + "response": { + "id": "resp_codex_builtin_done", + "object": "response", + "created_at": 1677652291, + "model": "gpt-5.2-codex", + "output": [{ + "id": "msg_codex_builtin_done", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "CODEX_BUILTIN_TOOL_DONE", + "annotations": [] + }] + }], + "parallel_tool_calls": false, + "tools": [], + "usage": {"input_tokens": 14, "output_tokens": 5, "total_tokens": 19} + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_codex_resources.json b/go/core/test/e2e/mocks/invoke_codex_resources.json new file mode 100644 index 000000000..2547933c3 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_codex_resources.json @@ -0,0 +1,80 @@ +{ + "openai_response": [ + { + "name": "codex_resource_start", + "match": { + "match_type": "contains", + "input": "Add 3 and 5 using the configured MCP server." + }, + "response": { + "id": "resp_codex_resource_start", + "object": "response", + "created_at": 1677652292, + "model": "gpt-5.2-codex", + "output": [{ + "id": "ts_codex_resource_add", + "type": "tool_search_call", + "status": "completed", + "call_id": "search_codex_resource_add", + "execution": "client", + "arguments": {"query":"MCP_TOOL_SEARCH_QUERY","limit":10} + }], + "parallel_tool_calls": false, + "tools": [], + "usage": {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19} + } + }, + { + "name": "codex_resource_call", + "match": { + "match_type": "contains", + "input": "search_codex_resource_add" + }, + "response": { + "id": "resp_codex_resource_call", + "object": "response", + "created_at": 1677652293, + "model": "gpt-5.2-codex", + "output": [{ + "id": "fc_codex_resource_add", + "type": "function_call", + "status": "completed", + "call_id": "call_codex_resource_add", + "namespace": "MCP_TOOL_NAMESPACE", + "name": "add_numbers", + "arguments": "{\"a\":3,\"b\":5}" + }], + "parallel_tool_calls": false, + "tools": [], + "usage": {"input_tokens": 16, "output_tokens": 7, "total_tokens": 23} + } + }, + { + "name": "codex_resource_result", + "match": { + "match_type": "contains", + "input": "Output:" + }, + "response": { + "id": "resp_codex_resource_done", + "object": "response", + "created_at": 1677652294, + "model": "gpt-5.2-codex", + "output": [{ + "id": "msg_codex_resource_done", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "CODEX_MCP_DONE result is 8", + "annotations": [] + }] + }], + "parallel_tool_calls": false, + "tools": [], + "usage": {"input_tokens": 16, "output_tokens": 7, "total_tokens": 23} + } + } + ] +} diff --git a/go/core/v2/controller/collections_test.go b/go/core/v2/controller/collections_test.go index c294abdb5..868e50b2d 100644 --- a/go/core/v2/controller/collections_test.go +++ b/go/core/v2/controller/collections_test.go @@ -187,6 +187,62 @@ func TestClaudeReconciliationCompilesActorTemplate(t *testing.T) { } } +func TestCodexReconciliationCompilesActorTemplate(t *testing.T) { + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + opts := krt.NewOptionsBuilder(stop, "test-codex", nil) + responses := kagentv1alpha3.OpenAIAPIFormatResponses + template := &kagentv1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "codex"}}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "model"}, SystemPrompt: "help"}, + } + codexHarness := harness("team-a", "codex", map[string]string{"runtime": "codex"}) + codexHarness.UID = "harness-uid" + codexHarness.Spec.Codex = &kagentv1alpha3.CodexHarness{} + codexHarness.Spec.Workload.Image = "example.com/codex@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + codexHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}, + } + model := &kagentv1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model", UID: "model-uid"}, Spec: kagentv1alpha3.ModelConfigSpec{ + Provider: kagentv1alpha3.ModelProviderOpenAI, Model: "gpt-5.2-codex", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + OpenAI: &kagentv1alpha3.OpenAIConfig{APIFormat: &responses}, + }} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model-auth", UID: "secret-uid"}, Data: map[string][]byte{"api-key": []byte("secret")}} + mock := krttest.NewMock(t, []any{ + template, + codexHarness, + model, + secret, + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "default"}}, + }) + templates := krttest.GetMockCollection[*kagentv1alpha3.AgentTemplate](mock) + pairs := newPairCollection(templates, krttest.GetMockCollection[*kagentv1alpha3.Harness](mock), opts) + configMaps := krttest.GetMockCollection[*corev1.ConfigMap](mock) + secrets := krttest.GetMockCollection[*corev1.Secret](mock) + _, resolvedModelConfigs := newModelConfigReconciliations( + krttest.GetMockCollection[*kagentv1alpha3.ModelConfig](mock), configMaps, secrets, opts, + ) + reconciliations := newPairReconciliations( + pairs, v2translator.Collections{ + AgentTemplates: templates, ResolvedModelConfigs: resolvedModelConfigs, + RemoteMCPServers: krttest.GetMockCollection[*kagentv1alpha3.RemoteMCPServer](mock), + ConfigMaps: configMaps, Secrets: secrets, + WorkerPools: krttest.GetMockCollection[*atev1alpha1.WorkerPool](mock), + }, krttest.GetMockCollection[ObservedActorTemplate](mock), opts, + ) + waitFor(t, func() bool { + states := reconciliations.List() + return len(states) == 1 && states[0].Failure == nil && states[0].DesiredActorTemplate != nil + }) + state := reconciliations.List()[0] + if state.Revision == nil || state.Revision.Environment[0].Name != "OPENAI_API_KEY" || state.Revision.Environment[0].Value != "secret" { + t.Fatalf("Codex revision environment = %#v", state.Revision) + } + if state.DesiredActorTemplate.GetContainers()[0].GetReadyz().GetHttpGet().GetPort() != 8081 { + t.Fatalf("Codex ActorTemplate readiness = %#v", state.DesiredActorTemplate.GetContainers()[0].GetReadyz()) + } +} + func TestReconciliationTracksSharedAgentTemplate(t *testing.T) { stop := make(chan struct{}) t.Cleanup(func() { close(stop) }) diff --git a/go/core/v2/controller/reconciler.go b/go/core/v2/controller/reconciler.go index d85a46b3a..ee2847e95 100644 --- a/go/core/v2/controller/reconciler.go +++ b/go/core/v2/controller/reconciler.go @@ -15,6 +15,7 @@ import ( v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" byotranslator "github.com/kagent-dev/kagent/go/core/v2/translator/byo" claudetranslator "github.com/kagent-dev/kagent/go/core/v2/translator/claude" + codextranslator "github.com/kagent-dev/kagent/go/core/v2/translator/codex" kagenttranslator "github.com/kagent-dev/kagent/go/core/v2/translator/kagent" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -25,7 +26,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" - ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) // PairReconciliation is the complete desired and observed state for one @@ -34,6 +34,7 @@ import ( type PairReconciliation struct { Pair AgentTemplateHarnessPair Revision *v2translator.Revision + Warnings []string RevisionID v2translator.RevisionID DesiredActorTemplate *ateapipb.ActorTemplate ObservedActorTemplate *ateapipb.ActorTemplate @@ -57,8 +58,9 @@ func newPairReconciliations( ) krt.Collection[PairReconciliation] { return krt.NewCollection(pairs, func(ctx krt.HandlerContext, pair AgentTemplateHarnessPair) *PairReconciliation { state := &PairReconciliation{Pair: pair} - revision, err := v2translator.NewCompiler(ctx, collections, map[v2translator.HarnessType]v2translator.HarnessCompiler{ + compilation, err := v2translator.NewCompiler(ctx, collections, map[v2translator.HarnessType]v2translator.HarnessCompiler{ v2translator.HarnessTypeKagent: kagenttranslator.NewCompiler(ctx, collections), + v2translator.HarnessTypeCodex: codextranslator.NewCompiler(ctx, collections), v2translator.HarnessTypeClaude: claudetranslator.NewCompiler(ctx, collections), v2translator.HarnessTypeBYO: byotranslator.NewCompiler(ctx, collections), }).CompileAgentTemplate(context.Background(), pair.Harness, pair.AgentTemplate) @@ -71,7 +73,9 @@ func newPairReconciliations( state.Failure = &ReconciliationFailure{Condition: condition, Reason: reason, Message: err.Error()} return state } + revision := &compilation.Revision state.Revision = revision + state.Warnings = append([]string(nil), compilation.Warnings...) state.RevisionID, err = revision.Digest() if err != nil { state.Failure = &ReconciliationFailure{Condition: kagentv1alpha3.AgentTemplateConditionCompatible, Reason: "RevisionInvalid", Message: err.Error()} @@ -255,15 +259,6 @@ func (r *Reconciler) reconcilePair(ctx context.Context, key string) error { if state.Revision == nil || state.RevisionID.IsZero() { return r.cleanupUnreferencedRevisions(ctx) } - for _, warning := range state.Revision.Warnings { - ctrllog.FromContext(ctx).Info("runtime configuration warning", - "namespace", state.Pair.AgentTemplate.Namespace, - "agentTemplate", state.Pair.AgentTemplate.Name, - "harness", state.Pair.Harness.Name, - "warning", warning, - ) - } - pair := dbpkg.AgentTemplateHarnessPair{ Namespace: state.Pair.AgentTemplate.Namespace, AgentTemplateName: state.Pair.AgentTemplate.Name, AgentTemplateUID: string(state.Pair.AgentTemplate.UID), HarnessName: state.Pair.Harness.Name, diff --git a/go/core/v2/controller/status.go b/go/core/v2/controller/status.go index 21a567c91..3c2c4c610 100644 --- a/go/core/v2/controller/status.go +++ b/go/core/v2/controller/status.go @@ -43,6 +43,7 @@ func statusForPair(state PairReconciliation, generation int64, latestSuccessful status := kagentv1alpha3.AgentTemplateHarnessStatus{ Harness: state.Pair.Harness.Name, DesiredRevision: desired, LatestSuccessfulRevision: latestSuccessful, } + status.Warnings = append([]string(nil), state.Warnings...) setPairCondition(&status, generation, kagentv1alpha3.AgentTemplateConditionAccepted, metav1.ConditionTrue, "Accepted", "Harness admission selector matches the AgentTemplate") if state.Failure != nil { if state.Failure.Condition != kagentv1alpha3.AgentTemplateConditionResolvedRefs { diff --git a/go/core/v2/controller/status_test.go b/go/core/v2/controller/status_test.go new file mode 100644 index 000000000..4192a5592 --- /dev/null +++ b/go/core/v2/controller/status_test.go @@ -0,0 +1,26 @@ +package controller + +import ( + "testing" + + kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" +) + +func TestStatusForPairPublishesCompilationWarnings(t *testing.T) { + warnings := []string{"partial MCP selection is not enforced"} + state := PairReconciliation{ + Pair: AgentTemplateHarnessPair{Harness: &kagentv1alpha3.Harness{}}, + Revision: &v2translator.Revision{}, + Warnings: warnings, + RevisionID: v2translator.RevisionID{1}, + } + status := statusForPair(state, 1, "") + if len(status.Warnings) != 1 || status.Warnings[0] != warnings[0] { + t.Fatalf("warnings = %v, want %v", status.Warnings, warnings) + } + warnings[0] = "changed" + if status.Warnings[0] == warnings[0] { + t.Fatal("status aliases mutable compilation warnings") + } +} diff --git a/go/core/v2/translator/byo/compiler.go b/go/core/v2/translator/byo/compiler.go index d0fd84593..29f63f630 100644 --- a/go/core/v2/translator/byo/compiler.go +++ b/go/core/v2/translator/byo/compiler.go @@ -23,7 +23,7 @@ func NewCompiler(ctx krt.HandlerContext, collections v2translator.Collections) * return &Compiler{config: adkconfig.NewBuilder(ctx, collections)} } -func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { +func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.CompileResult, error) { compiled, err := c.config.Build(ctx, input.Root) if err != nil { return nil, err @@ -48,13 +48,13 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput } slices.Sort(compiled.Egress) - return &v2translator.Revision{ + return &v2translator.CompileResult{Revision: v2translator.Revision{ Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, Image: harness.Spec.Workload.Image, Command: harness.Spec.Workload.Command, Args: harness.Spec.Workload.Args, Environment: environment, ConfigJSON: configJSON, AgentCardJSON: cardJSON, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), - }, nil + }}, nil } func agentTemplateCard(template *v1alpha3.AgentTemplate) *a2atype.AgentCard { diff --git a/go/core/v2/translator/claude/compiler.go b/go/core/v2/translator/claude/compiler.go index 23b2167b0..dcc80901c 100644 --- a/go/core/v2/translator/claude/compiler.go +++ b/go/core/v2/translator/claude/compiler.go @@ -21,29 +21,6 @@ import ( "k8s.io/apimachinery/pkg/types" ) -const ( - useBedrockEnv = "CLAUDE_CODE_USE_BEDROCK" - useVertexEnv = "CLAUDE_CODE_USE_VERTEX" - awsRegionEnv = "AWS_REGION" - awsAccessKeyEnv = "AWS_ACCESS_KEY_ID" - awsSecretKeyEnv = "AWS_SECRET_ACCESS_KEY" - awsSessionTokenEnv = "AWS_SESSION_TOKEN" - awsBedrockTokenEnv = "AWS_BEARER_TOKEN_BEDROCK" - anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" - anthropicBaseURLEnv = "ANTHROPIC_BASE_URL" - vertexProjectEnv = "ANTHROPIC_VERTEX_PROJECT_ID" - vertexRegionEnv = "CLOUD_ML_REGION" - sandboxEnv = "IS_SANDBOX" - preResponseFlushEnv = "KAGENT_PRE_RESPONSE_TRACE_FLUSH" -) - -var ownedEnvironment = map[string]struct{}{ - useBedrockEnv: {}, useVertexEnv: {}, awsRegionEnv: {}, awsAccessKeyEnv: {}, - awsSecretKeyEnv: {}, awsSessionTokenEnv: {}, awsBedrockTokenEnv: {}, - anthropicAPIKeyEnv: {}, anthropicBaseURLEnv: {}, vertexProjectEnv: {}, vertexRegionEnv: {}, - sandboxEnv: {}, claudeconfig.GoogleCredentialsJSONEnvName: {}, -} - type Compiler struct { ctx krt.HandlerContext collections v2translator.Collections @@ -53,7 +30,7 @@ func NewCompiler(ctx krt.HandlerContext, collections v2translator.Collections) * return &Compiler{ctx: ctx, collections: collections} } -func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { +func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.CompileResult, error) { if input == nil || input.Harness == nil || input.Root == nil || input.Root.Template == nil || input.Root.ResolvedModelConfig == nil || input.Root.ResolvedModelConfig.Config == nil { return nil, fmt.Errorf("claude compiler requires a resolved Harness, AgentTemplate, and ModelConfig") } @@ -80,11 +57,8 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput environment := append([]corev1.EnvVar(nil), providerEnvironment...) environment = append(environment, mcp.environment...) for _, variable := range input.Harness.Spec.Env { - if _, reserved := ownedEnvironment[variable.Name]; reserved { - return nil, v2translator.NewValidationError("Harness env %q conflicts with Claude's compiled provider configuration", variable.Name) - } - if strings.HasPrefix(variable.Name, mcpCredentialPrefix) { - return nil, v2translator.NewValidationError("Harness env %q conflicts with Claude's compiled MCP credentials", variable.Name) + if claudeconfig.OwnsEnvironment(variable.Name) { + return nil, v2translator.NewValidationError("Harness env %q conflicts with Claude-owned runtime configuration", variable.Name) } envVar := corev1.EnvVar{Name: variable.Name} if variable.Value != nil { @@ -97,8 +71,8 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput // Substrate v0.0.20 runs Actor processes as root even when the image declares // a non-root USER. Claude otherwise rejects --dangerously-skip-permissions. environment = append(environment, - corev1.EnvVar{Name: sandboxEnv, Value: "1"}, - corev1.EnvVar{Name: preResponseFlushEnv, Value: "true"}, + corev1.EnvVar{Name: claudeconfig.SandboxEnvName, Value: "1"}, + corev1.EnvVar{Name: claudeconfig.PreResponseTraceFlushEnvName, Value: "true"}, ) localAgents, err := c.compileLocalAgents(input.Root) @@ -136,13 +110,16 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput slices.Sort(egress) egress = slices.Compact(egress) template, harness := input.Root.Template, input.Harness - return &v2translator.Revision{ - Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, - Image: harness.Spec.Workload.Image, Environment: environment, - ConfigJSON: configJSON, AgentCardJSON: cardJSON, - WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, - SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - Provenance: provenance, EgressDestinations: egress, Warnings: mcp.warnings, + return &v2translator.CompileResult{ + Revision: v2translator.Revision{ + Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, + Image: harness.Spec.Workload.Image, Environment: environment, + ConfigJSON: configJSON, AgentCardJSON: cardJSON, + WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, + SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, + Provenance: provenance, EgressDestinations: egress, + }, + Warnings: mcp.warnings, }, nil } @@ -201,14 +178,14 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, false); err != nil { return nil, nil, err } - environment := []corev1.EnvVar{secretEnvironment(anthropicAPIKeyEnv, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey)} + environment := []corev1.EnvVar{secretEnvironment(claudeconfig.AnthropicAPIKeyEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey)} egress := []string{"api.anthropic.com"} if baseURL != "" { hostname, err := anthropicBaseURLHostname(baseURL) if err != nil { return nil, nil, err } - environment = append(environment, corev1.EnvVar{Name: anthropicBaseURLEnv, Value: baseURL}) + environment = append(environment, corev1.EnvVar{Name: claudeconfig.AnthropicBaseURLEnvName, Value: baseURL}) egress = []string{hostname} } return environment, egress, nil @@ -236,18 +213,18 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ if err != nil { return nil, nil, err } - environment := []corev1.EnvVar{{Name: useBedrockEnv, Value: "1"}, {Name: awsRegionEnv, Value: model.Spec.Bedrock.Region}} - if value := secret.Data[awsBedrockTokenEnv]; len(value) != 0 { - environment = append(environment, secretEnvironment(awsBedrockTokenEnv, secret.Name, awsBedrockTokenEnv)) + environment := []corev1.EnvVar{{Name: claudeconfig.UseBedrockEnvName, Value: "1"}, {Name: claudeconfig.AWSRegionEnvName, Value: model.Spec.Bedrock.Region}} + if value := secret.Data[claudeconfig.AWSBedrockTokenEnvName]; len(value) != 0 { + environment = append(environment, secretEnvironment(claudeconfig.AWSBedrockTokenEnvName, secret.Name, claudeconfig.AWSBedrockTokenEnvName)) } else { - for _, key := range []string{awsAccessKeyEnv, awsSecretKeyEnv} { + for _, key := range []string{claudeconfig.AWSAccessKeyEnvName, claudeconfig.AWSSecretKeyEnvName} { if len(secret.Data[key]) == 0 { - return nil, nil, v2translator.NewValidationError("Claude Bedrock Secret %q requires %s and %s, or %s", secret.Name, awsAccessKeyEnv, awsSecretKeyEnv, awsBedrockTokenEnv) + return nil, nil, v2translator.NewValidationError("Claude Bedrock Secret %q requires %s and %s, or %s", secret.Name, claudeconfig.AWSAccessKeyEnvName, claudeconfig.AWSSecretKeyEnvName, claudeconfig.AWSBedrockTokenEnvName) } environment = append(environment, secretEnvironment(key, secret.Name, key)) } - if len(secret.Data[awsSessionTokenEnv]) != 0 { - environment = append(environment, secretEnvironment(awsSessionTokenEnv, secret.Name, awsSessionTokenEnv)) + if len(secret.Data[claudeconfig.AWSSessionTokenEnvName]) != 0 { + environment = append(environment, secretEnvironment(claudeconfig.AWSSessionTokenEnvName, secret.Name, claudeconfig.AWSSessionTokenEnvName)) } } return environment, []string{"bedrock-runtime." + model.Spec.Bedrock.Region + ".amazonaws.com"}, nil @@ -266,7 +243,7 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ } cfg := model.Spec.AnthropicVertexAI return []corev1.EnvVar{ - {Name: useVertexEnv, Value: "1"}, {Name: vertexProjectEnv, Value: cfg.ProjectID}, {Name: vertexRegionEnv, Value: cfg.Location}, + {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, secretEnvironment(claudeconfig.GoogleCredentialsJSONEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey), }, []string{vertexHostname(cfg.Location), "oauth2.googleapis.com"}, nil default: diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go index 015524317..88439fe06 100644 --- a/go/core/v2/translator/claude/compiler_test.go +++ b/go/core/v2/translator/claude/compiler_test.go @@ -34,7 +34,7 @@ func TestCompileSupportedProviders(t *testing.T) { model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", APIKeySecret: "model-auth", APIKeySecretKey: "api-key"}, secretData: map[string][]byte{"api-key": []byte(credentialValue)}, - wantEnv: map[string]string{anthropicAPIKeyEnv: credentialValue}, + wantEnv: map[string]string{claudeconfig.AnthropicAPIKeyEnvName: credentialValue}, wantEgress: []string{"api.anthropic.com"}, }, { @@ -43,25 +43,25 @@ func TestCompileSupportedProviders(t *testing.T) { APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "http://host.docker.internal:8090/anthropic"}}, secretData: map[string][]byte{"api-key": []byte(credentialValue)}, - wantEnv: map[string]string{anthropicAPIKeyEnv: credentialValue, - anthropicBaseURLEnv: "http://host.docker.internal:8090/anthropic"}, + wantEnv: map[string]string{claudeconfig.AnthropicAPIKeyEnvName: credentialValue, + claudeconfig.AnthropicBaseURLEnvName: "http://host.docker.internal:8090/anthropic"}, wantEgress: []string{"host.docker.internal"}, }, { name: "Bedrock IAM", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", CacheTTL: "5m"}}, - secretData: map[string][]byte{awsAccessKeyEnv: []byte("access"), awsSecretKeyEnv: []byte(credentialValue), awsSessionTokenEnv: []byte("session")}, - wantEnv: map[string]string{useBedrockEnv: "1", awsRegionEnv: "us-east-1", awsAccessKeyEnv: "access", - awsSecretKeyEnv: credentialValue, awsSessionTokenEnv: "session"}, + secretData: map[string][]byte{claudeconfig.AWSAccessKeyEnvName: []byte("access"), claudeconfig.AWSSecretKeyEnvName: []byte(credentialValue), claudeconfig.AWSSessionTokenEnvName: []byte("session")}, + wantEnv: map[string]string{claudeconfig.UseBedrockEnvName: "1", claudeconfig.AWSRegionEnvName: "us-east-1", claudeconfig.AWSAccessKeyEnvName: "access", + claudeconfig.AWSSecretKeyEnvName: credentialValue, claudeconfig.AWSSessionTokenEnvName: "session"}, wantEgress: []string{"bedrock-runtime.us-east-1.amazonaws.com"}, }, { name: "Bedrock API key", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-west-2"}}, - secretData: map[string][]byte{awsBedrockTokenEnv: []byte(credentialValue)}, - wantEnv: map[string]string{useBedrockEnv: "1", awsRegionEnv: "us-west-2", awsBedrockTokenEnv: credentialValue}, + secretData: map[string][]byte{claudeconfig.AWSBedrockTokenEnvName: []byte(credentialValue)}, + wantEnv: map[string]string{claudeconfig.UseBedrockEnvName: "1", claudeconfig.AWSRegionEnvName: "us-west-2", claudeconfig.AWSBedrockTokenEnvName: credentialValue}, wantEgress: []string{"bedrock-runtime.us-west-2.amazonaws.com"}, }, { @@ -70,7 +70,7 @@ func TestCompileSupportedProviders(t *testing.T) { APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`)}, - wantEnv: map[string]string{useVertexEnv: "1", vertexProjectEnv: "project", vertexRegionEnv: "us-east5", + wantEnv: map[string]string{claudeconfig.UseVertexEnvName: "1", claudeconfig.VertexProjectEnvName: "project", claudeconfig.VertexRegionEnvName: "us-east5", claudeconfig.GoogleCredentialsJSONEnvName: `{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`}, wantEgress: []string{"oauth2.googleapis.com", "us-east5-aiplatform.googleapis.com"}, }, @@ -102,8 +102,8 @@ func TestCompileSupportedProviders(t *testing.T) { t.Errorf("environment[%s] = %q, want %q", name, gotEnvironment[name], value) } } - if gotEnvironment[sandboxEnv] != "1" { - t.Errorf("environment[%s] = %q, want %q", sandboxEnv, gotEnvironment[sandboxEnv], "1") + if gotEnvironment[claudeconfig.SandboxEnvName] != "1" { + t.Errorf("environment[%s] = %q, want %q", claudeconfig.SandboxEnvName, gotEnvironment[claudeconfig.SandboxEnvName], "1") } if !reflect.DeepEqual(revision.EgressDestinations, tt.wantEgress) { t.Errorf("egress = %v", revision.EgressDestinations) @@ -139,7 +139,7 @@ func TestCompileRejectsUnsupportedConfiguration(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - input, reader := testInput(t, tt.model, map[string][]byte{"api-key": []byte("secret"), awsAccessKeyEnv: []byte("access"), awsSecretKeyEnv: []byte("secret"), "credentials.json": []byte(`{"type":"service_account"}`)}) + input, reader := testInput(t, tt.model, map[string][]byte{"api-key": []byte("secret"), claudeconfig.AWSAccessKeyEnvName: []byte("access"), claudeconfig.AWSSecretKeyEnvName: []byte("secret"), "credentials.json": []byte(`{"type":"service_account"}`)}) _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) var validation *v2translator.ValidationError if !errors.As(err, &validation) { @@ -156,7 +156,7 @@ func TestCompileRejectsProviderOwnedHarnessEnvironment(t *testing.T) { } input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) value := "http://mock.example.com" - input.Harness.Spec.Env = []v1alpha3.HarnessEnvVar{{Name: anthropicBaseURLEnv, Value: &value}} + input.Harness.Spec.Env = []v1alpha3.HarnessEnvVar{{Name: claudeconfig.AnthropicBaseURLEnvName, Value: &value}} _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) var validation *v2translator.ValidationError if !errors.As(err, &validation) { @@ -245,7 +245,7 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { } compiled := cfg.MCPServers["math-server"] if compiled.Type != "http" || compiled.URL != server.Spec.URL || compiled.Headers["X-Tenant"] != "test" || - !strings.HasPrefix(compiled.Headers["Authorization"], "${"+mcpCredentialPrefix) { + !strings.HasPrefix(compiled.Headers["Authorization"], "${"+claudeconfig.MCPCredentialEnvPrefix) { t.Fatalf("compiled MCP = %#v", cfg.MCPServers) } if bytes.Contains(revision.ConfigJSON, []byte(credentialValue)) || bytes.Contains(revision.Provenance, []byte(credentialValue)) { @@ -253,7 +253,7 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { } foundSecret := false for _, variable := range revision.Environment { - if strings.HasPrefix(variable.Name, mcpCredentialPrefix) && variable.Value == credentialValue { + if strings.HasPrefix(variable.Name, claudeconfig.MCPCredentialEnvPrefix) && variable.Value == credentialValue { foundSecret = true } } @@ -308,6 +308,30 @@ func TestCompileWholeServerMCPSelectionWarnings(t *testing.T) { if len(revision.Warnings) != 1 || !strings.Contains(revision.Warnings[0], "no current discovered tool set") { t.Fatalf("stale discovery warnings = %v", revision.Warnings) } + + server.Status.ObservedGeneration = server.Generation + input.Root.MCPTools[0].Binding.Tools = nil + terminateOnClose := false + server.Spec.TLS = &v1alpha3.TLSConfig{DisableVerify: true} + server.Spec.Timeout = &metav1.Duration{Duration: time.Minute} + server.Spec.TerminateOnClose = &terminateOnClose + revision, err = NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + if err != nil { + t.Fatalf("unsupported MCP options Compile() error = %v", err) + } + if len(revision.Warnings) != 1 { + t.Fatalf("unsupported MCP option warnings = %v", revision.Warnings) + } + for _, field := range []string{"custom TLS configuration", "timeout", "terminateOnClose"} { + if !strings.Contains(revision.Warnings[0], field) { + t.Errorf("unsupported MCP option warning %q omits %q", revision.Warnings[0], field) + } + } + + server.Spec.Protocol = v1alpha3.RemoteMCPServerProtocol("STDIO") + if _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input); err == nil || !strings.Contains(err.Error(), "unsupported protocol") { + t.Fatalf("unsupported MCP protocol Compile() error = %v", err) + } } func TestCompileLocalSharedAgent(t *testing.T) { diff --git a/go/core/v2/translator/claude/mcp.go b/go/core/v2/translator/claude/mcp.go index d47920393..2c7c13c58 100644 --- a/go/core/v2/translator/claude/mcp.go +++ b/go/core/v2/translator/claude/mcp.go @@ -17,8 +17,6 @@ import ( "k8s.io/apimachinery/pkg/types" ) -const mcpCredentialPrefix = "KAGENT_CLAUDE_MCP_CREDENTIAL_" - type mcpCompilation struct { servers map[string]claudeconfig.MCPServer environment []corev1.EnvVar @@ -53,10 +51,13 @@ func (c *Compiler) compileMCP( if warning := mcpSelectionWarning(tool.Binding.Tools, server); warning != "" { result.warnings = append(result.warnings, warning) } - transport, err := claudeMCPTransport(server) + transport, compatibilityWarning, err := claudeMCPTransport(server) if err != nil { return mcpCompilation{}, err } + if compatibilityWarning != "" { + result.warnings = append(result.warnings, compatibilityWarning) + } hostname, err := mcpHostname(server.Spec.URL) if err != nil { return mcpCompilation{}, err @@ -113,23 +114,28 @@ func currentDiscoveredToolNames(server *v1alpha3.RemoteMCPServer) ([]string, boo return names, true } -func claudeMCPTransport(server *v1alpha3.RemoteMCPServer) (string, error) { +func claudeMCPTransport(server *v1alpha3.RemoteMCPServer) (string, string, error) { + var ignored []string if !server.Spec.TLS.IsEmpty() { - return "", v2translator.NewValidationError("Claude RemoteMCPServer %q does not support custom TLS configuration", server.Name) + ignored = append(ignored, "custom TLS configuration") } if server.Spec.Timeout != nil && server.Spec.Timeout.Duration != 30*time.Second { - return "", v2translator.NewValidationError("Claude RemoteMCPServer %q supports only the default 30s timeout", server.Name) + ignored = append(ignored, "timeout") } if server.Spec.TerminateOnClose != nil && !*server.Spec.TerminateOnClose { - return "", v2translator.NewValidationError("Claude RemoteMCPServer %q requires terminateOnClose", server.Name) + ignored = append(ignored, "terminateOnClose") + } + var warning string + if len(ignored) != 0 { + warning = fmt.Sprintf("Claude RemoteMCPServer %q ignores unsupported fields %s", server.Name, strings.Join(ignored, ", ")) } switch server.Spec.Protocol { case v1alpha3.RemoteMCPServerProtocolSse: - return "sse", nil + return "sse", warning, nil case "", v1alpha3.RemoteMCPServerProtocolStreamableHttp: - return "http", nil + return "http", warning, nil default: - return "", v2translator.NewValidationError("Claude RemoteMCPServer %q has unsupported protocol %q", server.Name, server.Spec.Protocol) + return "", "", v2translator.NewValidationError("Claude RemoteMCPServer %q has unsupported protocol %q", server.Name, server.Spec.Protocol) } } @@ -171,7 +177,7 @@ func (c *Compiler) compileMCPHeaders(ctx context.Context, namespace string, refs headers[ref.Name] = value case ref.ValueFrom.Type == v1alpha3.SecretValueSource: sum := sha256.Sum256([]byte(namespace + "\x00" + ref.ValueFrom.Name + "\x00" + ref.ValueFrom.Key)) - name := mcpCredentialPrefix + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) + name := claudeconfig.MCPCredentialEnvPrefix + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) headers[ref.Name] = "${" + name + "}" environment = append(environment, secretEnvironment(name, ref.ValueFrom.Name, ref.ValueFrom.Key)) default: diff --git a/go/core/v2/translator/codex/compiler.go b/go/core/v2/translator/codex/compiler.go new file mode 100644 index 000000000..61c37999d --- /dev/null +++ b/go/core/v2/translator/codex/compiler.go @@ -0,0 +1,398 @@ +// Package codex compiles resolved v1alpha3 inputs for the native Codex Harness adapter. +package codex + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/url" + "reflect" + "slices" + "strings" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + codexconfig "github.com/kagent-dev/kagent/go/harness/codex/config" + "istio.io/istio/pkg/kube/krt" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +const ( + codexHomeEnv = "CODEX_HOME" + openAIAPIKeyEnv = "OPENAI_API_KEY" + awsRegionEnv = "AWS_REGION" + awsBedrockTokenEnv = "AWS_BEARER_TOKEN_BEDROCK" + awsAccessKeyEnv = "AWS_ACCESS_KEY_ID" + awsSecretKeyEnv = "AWS_SECRET_ACCESS_KEY" + awsSessionTokenEnv = "AWS_SESSION_TOKEN" + mcpCredentialPrefix = "KAGENT_CODEX_MCP_CREDENTIAL_" +) + +var ownedEnvironment = map[string]struct{}{ + codexHomeEnv: {}, openAIAPIKeyEnv: {}, awsRegionEnv: {}, awsBedrockTokenEnv: {}, + awsAccessKeyEnv: {}, awsSecretKeyEnv: {}, awsSessionTokenEnv: {}, +} + +type Compiler struct { + ctx krt.HandlerContext + collections v2translator.Collections +} + +func NewCompiler(ctx krt.HandlerContext, collections v2translator.Collections) *Compiler { + return &Compiler{ctx: ctx, collections: collections} +} + +func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.CompileResult, error) { + if input == nil || input.Harness == nil || input.Root == nil || input.Root.Template == nil || input.Root.ResolvedModelConfig == nil || input.Root.ResolvedModelConfig.Config == nil { + return nil, fmt.Errorf("codex compiler requires a resolved Harness, AgentTemplate, and ModelConfig") + } + model := input.Root.ResolvedModelConfig.Config + if strings.TrimSpace(model.Spec.Model) == "" { + return nil, v2translator.NewValidationError("Codex ModelConfig model is required") + } + if len(model.Spec.DefaultHeaders) != 0 || !model.Spec.TLS.IsEmpty() || model.Spec.APIKeyPassthrough { + return nil, v2translator.NewValidationError("Codex does not support ModelConfig defaultHeaders, TLS, or apiKeyPassthrough") + } + + provider, providerEnvironment, egress, err := c.compileProvider(ctx, model) + if err != nil { + return nil, err + } + skillResources, skillEgress, err := v2translator.CompileSkillResources(input.Root.Template) + if err != nil { + return nil, err + } + mcp, err := c.compileMCP(ctx, input.Root.Template.Namespace, input.Root.MCPTools) + if err != nil { + return nil, err + } + environment := append(providerEnvironment, mcp.environment...) + for _, variable := range input.Harness.Spec.Env { + if _, reserved := ownedEnvironment[variable.Name]; reserved || strings.HasPrefix(variable.Name, mcpCredentialPrefix) { + return nil, v2translator.NewValidationError("Harness env %q conflicts with Codex's compiled configuration", variable.Name) + } + envVar := corev1.EnvVar{Name: variable.Name} + if variable.Value != nil { + envVar.Value = *variable.Value + } else { + envVar.ValueFrom = &corev1.EnvVarSource{SecretKeyRef: variable.CredentialRef.DeepCopy()} + } + environment = append(environment, envVar) + } + agents, err := compileAgents(input.Root) + if err != nil { + return nil, err + } + cfg := codexconfig.Production(model.Spec.Model, input.Root.Instruction) + cfg.Provider, cfg.Agents, cfg.MCPServers = provider, agents, mcp.servers + if len(skillResources.Skills) != 0 || len(skillResources.Plugins) != 0 { + cfg.SkillResources = &skillResources + } + if err := cfg.Validate(); err != nil { + return nil, v2translator.NewValidationError("invalid compiled Codex configuration: %v", err) + } + configJSON, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal Codex config: %w", err) + } + cardJSON, err := json.Marshal(agentTemplateCard(input.Root.Template)) + if err != nil { + return nil, fmt.Errorf("marshal Codex agent card: %w", err) + } + provenance, err := c.buildProvenance(ctx, input, environment, configJSON, cardJSON) + if err != nil { + return nil, fmt.Errorf("build Codex revision provenance: %w", err) + } + environment, err = c.resolveEnvironment(ctx, input.Harness.Namespace, environment) + if err != nil { + return nil, fmt.Errorf("resolve Codex runtime environment: %w", err) + } + egress = append(egress, skillEgress...) + egress = append(egress, mcp.egress...) + slices.Sort(egress) + egress = slices.Compact(egress) + template, harness := input.Root.Template, input.Harness + return &v2translator.CompileResult{ + Revision: v2translator.Revision{ + Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, + Image: harness.Spec.Workload.Image, Environment: environment, ConfigJSON: configJSON, AgentCardJSON: cardJSON, + WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, + Provenance: provenance, EgressDestinations: egress, + }, + Warnings: mcp.warnings, + }, nil +} + +func (c *Compiler) compileProvider(ctx context.Context, model *v1alpha3.ModelConfig) (codexconfig.Provider, []corev1.EnvVar, []string, error) { + switch model.Spec.Provider { + case v1alpha3.ModelProviderOpenAI: + if model.Spec.OpenAI == nil || model.Spec.OpenAI.APIFormat == nil || *model.Spec.OpenAI.APIFormat != v1alpha3.OpenAIAPIFormatResponses { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex OpenAI requires openAI.apiFormat responses") + } + options := *model.Spec.OpenAI + baseURL := strings.TrimSpace(options.BaseURL) + options.BaseURL, options.APIFormat = "", nil + if !reflect.DeepEqual(options, v1alpha3.OpenAIConfig{}) { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex does not support OpenAI provider options beyond baseUrl and apiFormat responses") + } + if err := c.requireSecretKey(ctx, model.Namespace, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey); err != nil { + return codexconfig.Provider{}, nil, nil, err + } + provider := codexconfig.Provider{Name: "openai", BaseURL: baseURL} + egress := []string{"api.openai.com"} + if baseURL != "" { + host, err := absoluteHTTPHostname(baseURL) + if err != nil { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex OpenAI baseUrl %v", err) + } + egress = []string{host} + } + return provider, []corev1.EnvVar{secretEnvironment(openAIAPIKeyEnv, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey)}, egress, nil + case v1alpha3.ModelProviderBedrock: + if model.Spec.Bedrock == nil || strings.TrimSpace(model.Spec.Bedrock.Region) == "" { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex Bedrock requires bedrock.region") + } + options := *model.Spec.Bedrock + region := strings.TrimSpace(options.Region) + if !strings.HasPrefix(model.Spec.Model, "gpt-") { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex Bedrock supports only OpenAI gpt-* model IDs") + } + options.Region = "" + if options.CacheTTL == "5m" { + options.CacheTTL = "" + } + if !reflect.DeepEqual(options, v1alpha3.BedrockConfig{}) { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex does not support Bedrock provider options beyond region") + } + if model.Spec.APIKeySecret == "" || model.Spec.APIKeySecretKey != "" { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex Bedrock requires apiKeySecret and an empty apiKeySecretKey") + } + secret, err := c.secret(ctx, model.Namespace, model.Spec.APIKeySecret) + if err != nil { + return codexconfig.Provider{}, nil, nil, err + } + environment := []corev1.EnvVar{{Name: awsRegionEnv, Value: region}} + if len(secret.Data[awsBedrockTokenEnv]) != 0 { + environment = append(environment, secretEnvironment(awsBedrockTokenEnv, secret.Name, awsBedrockTokenEnv)) + } else { + for _, key := range []string{awsAccessKeyEnv, awsSecretKeyEnv} { + if len(secret.Data[key]) == 0 { + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex Bedrock Secret %q requires %s and %s, or %s", secret.Name, awsAccessKeyEnv, awsSecretKeyEnv, awsBedrockTokenEnv) + } + environment = append(environment, secretEnvironment(key, secret.Name, key)) + } + if len(secret.Data[awsSessionTokenEnv]) != 0 { + environment = append(environment, secretEnvironment(awsSessionTokenEnv, secret.Name, awsSessionTokenEnv)) + } + } + return codexconfig.Provider{Name: "amazon-bedrock"}, environment, []string{"bedrock-runtime." + region + ".amazonaws.com"}, nil + default: + return codexconfig.Provider{}, nil, nil, v2translator.NewValidationError("Codex does not support ModelConfig provider %q", model.Spec.Provider) + } +} + +func compileAgents(root *v2translator.AgentInput) (map[string]codexconfig.Agent, error) { + if len(root.Shared) == 0 { + return nil, nil + } + agents := make(map[string]codexconfig.Agent, len(root.Shared)) + for _, binding := range root.Shared { + child := binding.Agent + if child == nil || child.Template == nil || child.ResolvedModelConfig == nil || child.ResolvedModelConfig.Config == nil { + return nil, fmt.Errorf("codex Shared agent %q is not fully resolved", binding.Name) + } + if len(child.MCPTools) != 0 || len(child.Shared) != 0 || len(child.Template.Spec.Tools) != 0 || len(child.Template.Spec.Skills) != 0 || len(child.Template.Spec.Plugins) != 0 { + return nil, v2translator.NewValidationError("Codex Shared agent %q cannot contain tools, skills, plugins, or nested agents", binding.Name) + } + if !sameProviderConfiguration(root.ResolvedModelConfig.Config.Spec, child.ResolvedModelConfig.Config.Spec) { + return nil, v2translator.NewValidationError("Codex Shared agent %q must use the root provider and authentication configuration", binding.Name) + } + if _, exists := agents[binding.Name]; exists { + return nil, v2translator.NewValidationError("duplicate Codex Shared agent name %q", binding.Name) + } + agents[binding.Name] = codexconfig.Agent{Description: binding.Description, Instruction: child.Instruction, Model: child.ResolvedModelConfig.Config.Spec.Model} + } + return agents, nil +} + +func sameProviderConfiguration(root, child v1alpha3.ModelConfigSpec) bool { + root.Model, child.Model = "", "" + return reflect.DeepEqual(root, child) +} + +func (c *Compiler) requireSecretKey(ctx context.Context, namespace, name, key string) error { + if name == "" || key == "" { + return v2translator.NewValidationError("Codex OpenAI requires apiKeySecret and apiKeySecretKey") + } + secret, err := c.secret(ctx, namespace, name) + if err != nil { + return err + } + if len(secret.Data[key]) == 0 { + return v2translator.NewValidationError("Codex credential Secret %q does not contain a non-empty key %q", name, key) + } + return nil +} + +func (c *Compiler) secret(ctx context.Context, namespace, name string) (*corev1.Secret, error) { + secret := krt.FetchOne(c.ctx, c.collections.Secrets, krt.FilterObjectName(types.NamespacedName{Namespace: namespace, Name: name})) + if secret == nil { + return nil, fmt.Errorf("read Codex credential Secret %q: not found", name) + } + return *secret, nil +} + +func secretEnvironment(environmentName, secretName, key string) corev1.EnvVar { + return corev1.EnvVar{Name: environmentName, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: key, + }}} +} + +func absoluteHTTPHostname(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" { + return "", fmt.Errorf("must be an absolute HTTP(S) URL without credentials or fragment") + } + return parsed.Hostname(), nil +} + +type provenanceEntry struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Key string `json:"key,omitempty"` + UID types.UID `json:"uid"` + Generation int64 `json:"generation,omitempty"` + Hash string `json:"hash"` +} + +func (c *Compiler) buildProvenance(ctx context.Context, input *v2translator.HarnessInput, environment []corev1.EnvVar, configJSON, cardJSON []byte) ([]byte, error) { + entries := []provenanceEntry{objectProvenance(v1alpha3.GroupVersion.String(), "Harness", input.Harness.Name, input.Harness.UID, input.Harness.Generation, input.Harness.Spec)} + entries = append(entries, + objectProvenance("kagent.internal/v1", "GeneratedInput", "config.json", "", 0, json.RawMessage(configJSON)), + objectProvenance("kagent.internal/v1", "GeneratedInput", "agent-card.json", "", 0, json.RawMessage(cardJSON)), + ) + seenObjects := map[string]struct{}{} + configMaps := map[string]struct{}{} + var addAgent func(*v2translator.AgentInput) + addAgent = func(agent *v2translator.AgentInput) { + model := agent.ResolvedModelConfig.Config + for _, object := range []struct { + kind, name string + uid types.UID + generation int64 + value any + }{ + {"AgentTemplate", agent.Template.Name, agent.Template.UID, agent.Template.Generation, agent.Template.Spec}, + {"ModelConfig", model.Name, model.UID, model.Generation, model.Spec}, + } { + identity := object.kind + "\x00" + object.name + if _, ok := seenObjects[identity]; !ok { + seenObjects[identity] = struct{}{} + entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), object.kind, object.name, object.uid, object.generation, object.value)) + } + } + if agent.Template.Spec.SystemPromptFrom != nil { + configMaps[agent.Template.Spec.SystemPromptFrom.Name] = struct{}{} + } + if agent.Template.Spec.PromptTemplate != nil { + for _, source := range agent.Template.Spec.PromptTemplate.DataSources { + configMaps[source.Name] = struct{}{} + } + } + for _, tool := range agent.MCPTools { + if tool.Server != nil { + identity := "RemoteMCPServer\x00" + tool.Server.Name + if _, ok := seenObjects[identity]; !ok { + seenObjects[identity] = struct{}{} + entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "RemoteMCPServer", tool.Server.Name, tool.Server.UID, tool.Server.Generation, tool.Server.Spec)) + } + for _, header := range tool.Server.Spec.HeadersFrom { + if header.ValueFrom != nil && header.ValueFrom.Type == v1alpha3.ConfigMapValueSource { + configMaps[header.ValueFrom.Name] = struct{}{} + } + } + } + } + for _, child := range agent.Shared { + addAgent(child.Agent) + } + } + addAgent(input.Root) + for name := range configMaps { + configMap := krt.FetchOne(c.ctx, c.collections.ConfigMaps, krt.FilterObjectName(types.NamespacedName{Namespace: input.Harness.Namespace, Name: name})) + if configMap == nil { + return nil, fmt.Errorf("ConfigMap %q not found", name) + } + entries = append(entries, objectProvenance("v1", "ConfigMap", name, (*configMap).UID, (*configMap).Generation, (*configMap).Data)) + } + seenSecrets := map[string]struct{}{} + for _, variable := range environment { + if variable.ValueFrom == nil || variable.ValueFrom.SecretKeyRef == nil { + continue + } + ref := variable.ValueFrom.SecretKeyRef + identity := ref.Name + "\x00" + ref.Key + if _, ok := seenSecrets[identity]; ok { + continue + } + seenSecrets[identity] = struct{}{} + secret, err := c.secret(ctx, input.Harness.Namespace, ref.Name) + if err != nil { + return nil, err + } + value, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) + } + hash := sha256.Sum256(value) + entries = append(entries, provenanceEntry{APIVersion: "v1", Kind: "Secret", Name: ref.Name, Key: ref.Key, UID: secret.UID, Hash: fmt.Sprintf("%x", hash[:])}) + } + slices.SortFunc(entries, func(a, b provenanceEntry) int { + return strings.Compare(a.APIVersion+"\x00"+a.Kind+"\x00"+a.Name+"\x00"+a.Key, b.APIVersion+"\x00"+b.Kind+"\x00"+b.Name+"\x00"+b.Key) + }) + return json.Marshal(entries) +} + +func objectProvenance(apiVersion, kind, name string, uid types.UID, generation int64, content any) provenanceEntry { + raw, _ := json.Marshal(content) + hash := sha256.Sum256(raw) + return provenanceEntry{APIVersion: apiVersion, Kind: kind, Name: name, UID: uid, Generation: generation, Hash: fmt.Sprintf("%x", hash[:])} +} + +func (c *Compiler) resolveEnvironment(ctx context.Context, namespace string, environment []corev1.EnvVar) ([]corev1.EnvVar, error) { + resolved := append([]corev1.EnvVar(nil), environment...) + for i, variable := range resolved { + if variable.ValueFrom == nil { + continue + } + if variable.ValueFrom.SecretKeyRef == nil { + return nil, fmt.Errorf("environment variable %q uses an unsupported value source", variable.Name) + } + ref := variable.ValueFrom.SecretKeyRef + secret, err := c.secret(ctx, namespace, ref.Name) + if err != nil { + return nil, err + } + value, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) + } + resolved[i].Value, resolved[i].ValueFrom = string(value), nil + } + return resolved, nil +} + +func agentTemplateCard(template *v1alpha3.AgentTemplate) *a2atype.AgentCard { + return &a2atype.AgentCard{ + Name: strings.ReplaceAll(template.Name, "-", "_"), Description: template.Spec.Description, Version: "v1", + SupportedInterfaces: []*a2atype.AgentInterface{{URL: "http://127.0.0.1:80", ProtocolBinding: a2atype.TransportProtocolGRPC, ProtocolVersion: a2atype.Version}}, + Capabilities: a2atype.AgentCapabilities{Streaming: true}, Skills: []a2atype.AgentSkill{}, + DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}, + } +} + +var _ v2translator.HarnessCompiler = (*Compiler)(nil) diff --git a/go/core/v2/translator/codex/compiler_test.go b/go/core/v2/translator/codex/compiler_test.go new file mode 100644 index 000000000..4330005c7 --- /dev/null +++ b/go/core/v2/translator/codex/compiler_test.go @@ -0,0 +1,212 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + codexconfig "github.com/kagent-dev/kagent/go/harness/codex/config" + "istio.io/istio/pkg/kube/krt" + "istio.io/istio/pkg/kube/krt/krttest" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const credentialValue = "credential-must-not-be-serialized" + +func TestCompileSupportedProviders(t *testing.T) { + responses := v1alpha3.OpenAIAPIFormatResponses + tests := []struct { + name string + model v1alpha3.ModelConfigSpec + secret map[string][]byte + provider string + environment map[string]string + egress []string + }{ + { + name: "OpenAI", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt-5.2-codex", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses}}, + secret: map[string][]byte{"api-key": []byte(credentialValue)}, provider: "openai", environment: map[string]string{openAIAPIKeyEnv: credentialValue}, egress: []string{"api.openai.com"}, + }, + { + name: "OpenAI gateway", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses, BaseURL: "https://gateway.example.com/v1"}}, + secret: map[string][]byte{"api-key": []byte(credentialValue)}, provider: "openai", environment: map[string]string{openAIAPIKeyEnv: credentialValue}, egress: []string{"gateway.example.com"}, + }, + { + name: "Bedrock API key", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "gpt-5.2", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", CacheTTL: "5m"}}, + secret: map[string][]byte{awsBedrockTokenEnv: []byte(credentialValue)}, provider: "amazon-bedrock", environment: map[string]string{awsRegionEnv: "us-east-1", awsBedrockTokenEnv: credentialValue}, egress: []string{"bedrock-runtime.us-east-1.amazonaws.com"}, + }, + { + name: "Bedrock IAM", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "gpt-5.2", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-west-2"}}, + secret: map[string][]byte{awsAccessKeyEnv: []byte("access"), awsSecretKeyEnv: []byte(credentialValue), awsSessionTokenEnv: []byte("session")}, provider: "amazon-bedrock", environment: map[string]string{awsRegionEnv: "us-west-2", awsAccessKeyEnv: "access", awsSecretKeyEnv: credentialValue, awsSessionTokenEnv: "session"}, egress: []string{"bedrock-runtime.us-west-2.amazonaws.com"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input, reader := testInput(t, test.model, test.secret) + revision, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + var cfg codexconfig.Config + if err := json.Unmarshal(revision.ConfigJSON, &cfg); err != nil { + t.Fatal(err) + } + if cfg.Provider.Name != test.provider || cfg.ExpectedCodexVersion != codexconfig.PinnedCodexVersion { + t.Fatalf("config = %#v", cfg) + } + gotEnvironment := map[string]string{} + for _, variable := range revision.Environment { + if variable.ValueFrom != nil { + t.Fatalf("unresolved environment = %#v", variable) + } + gotEnvironment[variable.Name] = variable.Value + } + for name, value := range test.environment { + if gotEnvironment[name] != value { + t.Errorf("environment[%s] = %q, want %q", name, gotEnvironment[name], value) + } + } + if !reflect.DeepEqual(revision.EgressDestinations, test.egress) { + t.Errorf("egress = %v, want %v", revision.EgressDestinations, test.egress) + } + if bytes.Contains(revision.ConfigJSON, []byte(credentialValue)) || bytes.Contains(revision.Provenance, []byte(credentialValue)) { + t.Fatal("credential leaked into immutable revision") + } + again, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + if err != nil || !reflect.DeepEqual(revision, again) { + t.Fatalf("compilation is not deterministic: %v", err) + } + }) + } +} + +func TestCompileRejectsUnsupportedProviderConfiguration(t *testing.T) { + responses, chat := v1alpha3.OpenAIAPIFormatResponses, v1alpha3.OpenAIAPIFormatChatCompletions + tests := []v1alpha3.ModelConfigSpec{ + {Provider: v1alpha3.ModelProviderAnthropic, Model: "claude"}, + {Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &chat}}, + {Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses, Temperature: "1"}}, + {Provider: v1alpha3.ModelProviderBedrock, Model: "gpt", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", PromptCaching: true}}, + } + for _, model := range tests { + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret"), awsAccessKeyEnv: []byte("access"), awsSecretKeyEnv: []byte("secret")}) + _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + var validation *v2translator.ValidationError + if !errors.As(err, &validation) { + t.Errorf("Compile(%s) error = %v, want validation", model.Provider, err) + } + } +} + +func TestCompileMCPAndSharedAgent(t *testing.T) { + responses := v1alpha3.OpenAIAPIFormatResponses + model := v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt-root", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses}} + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret"), "mcp-token": []byte(credentialValue)}) + server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "tools", Namespace: "test", UID: "mcp"}, Spec: v1alpha3.RemoteMCPServerSpec{ + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, URL: "https://mcp.example.com/mcp", HeadersFrom: []v1alpha3.ValueRef{{Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "model-auth", Key: "mcp-token"}}}, + }} + input.Root.MCPTools = []v2translator.ResolvedMCPTool{{Binding: v1alpha3.MCPToolBinding{Tools: []string{"read"}}, Server: server}} + childModel := model + childModel.Model = "gpt-child" + input.Root.Shared = []v2translator.AgentInputBinding{{Name: "reviewer", Description: "Reviews", Agent: &v2translator.AgentInput{ + Template: &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "child", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "child-model"}}}, + ResolvedModelConfig: &v2translator.ResolvedModelConfig{ + Config: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test"}, Spec: childModel}, + }, + Instruction: "Review carefully", + }}} + revision, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if len(revision.Warnings) != 0 { + t.Fatalf("MCP compatibility warnings = %v", revision.Warnings) + } + var cfg codexconfig.Config + if err := json.Unmarshal(revision.ConfigJSON, &cfg); err != nil { + t.Fatal(err) + } + if cfg.Agents["reviewer"].Model != "gpt-child" || !reflect.DeepEqual(cfg.MCPServers["tools"].EnabledTools, []string{"read"}) { + t.Fatalf("config = %#v", cfg) + } + if !strings.HasPrefix(cfg.MCPServers["tools"].Headers["Authorization"], "${"+mcpCredentialPrefix) { + t.Fatalf("MCP headers = %#v", cfg.MCPServers["tools"].Headers) + } + if !reflect.DeepEqual(revision.EgressDestinations, []string{"api.openai.com", "mcp.example.com"}) { + t.Fatalf("egress = %v", revision.EgressDestinations) + } +} + +func TestCompileMCPCompatibilityWarnings(t *testing.T) { + responses := v1alpha3.OpenAIAPIFormatResponses + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt-root", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses}, + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + terminateOnClose := false + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "tools", Namespace: "test", UID: "mcp"}, + Spec: v1alpha3.RemoteMCPServerSpec{ + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: "https://mcp.example.com/mcp", + TLS: &v1alpha3.TLSConfig{DisableVerify: true}, + Timeout: &metav1.Duration{Duration: time.Minute}, + TerminateOnClose: &terminateOnClose, + }, + } + input.Root.MCPTools = []v2translator.ResolvedMCPTool{{Server: server}} + + compilation, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + if err != nil { + t.Fatalf("Compile() error = %v", err) + } + if len(compilation.Warnings) != 1 { + t.Fatalf("MCP compatibility warnings = %v", compilation.Warnings) + } + for _, field := range []string{"custom TLS configuration", "timeout", "terminateOnClose"} { + if !strings.Contains(compilation.Warnings[0], field) { + t.Errorf("MCP compatibility warning %q omits %q", compilation.Warnings[0], field) + } + } + var cfg codexconfig.Config + if err := json.Unmarshal(compilation.ConfigJSON, &cfg); err != nil { + t.Fatal(err) + } + if _, exists := cfg.MCPServers[server.Name]; !exists { + t.Fatalf("config omits MCP server after compatibility warning: %#v", cfg.MCPServers) + } + + server.Spec.Protocol = v1alpha3.RemoteMCPServerProtocolSse + if _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input); err == nil || !strings.Contains(err.Error(), "requires Streamable HTTP") { + t.Fatalf("unsupported MCP protocol Compile() error = %v", err) + } +} + +func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[string][]byte) (*v2translator.HarnessInput, v2translator.Collections) { + t.Helper() + harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "codex", Namespace: "test", UID: "harness"}, Spec: v1alpha3.HarnessSpec{ + Codex: &v1alpha3.CodexHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/codex@sha256:" + strings.Repeat("a", 64)}, + Substrate: v1alpha3.HarnessSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}}, + }} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "model"}, Description: "assistant"}} + model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "test", UID: "model"}, Spec: modelSpec} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test", UID: "secret"}, Data: secretData} + mock := krttest.NewMock(t, []any{secret}) + collections := v2translator.Collections{ + Secrets: krttest.GetMockCollection[*corev1.Secret](mock), + ConfigMaps: krttest.GetMockCollection[*corev1.ConfigMap](mock), + } + return &v2translator.HarnessInput{Harness: harness, Root: &v2translator.AgentInput{ + Template: template, ResolvedModelConfig: &v2translator.ResolvedModelConfig{Config: model}, Instruction: "help carefully", + }}, collections +} diff --git a/go/core/v2/translator/codex/mcp.go b/go/core/v2/translator/codex/mcp.go new file mode 100644 index 000000000..0cf96aa75 --- /dev/null +++ b/go/core/v2/translator/codex/mcp.go @@ -0,0 +1,122 @@ +package codex + +import ( + "context" + "crypto/sha256" + "fmt" + "slices" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + codexconfig "github.com/kagent-dev/kagent/go/harness/codex/config" + "istio.io/istio/pkg/kube/krt" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +type mcpCompilation struct { + servers map[string]codexconfig.MCPServer + environment []corev1.EnvVar + egress []string + warnings []string +} + +func (c *Compiler) compileMCP(ctx context.Context, namespace string, tools []v2translator.ResolvedMCPTool) (mcpCompilation, error) { + if len(tools) == 0 { + return mcpCompilation{}, nil + } + result := mcpCompilation{servers: make(map[string]codexconfig.MCPServer, len(tools))} + identities := map[string]string{} + for _, tool := range tools { + server := tool.Server + if server == nil { + return mcpCompilation{}, fmt.Errorf("resolved Codex MCP binding has no server") + } + name := strings.ReplaceAll(server.Name, ".", "_") + if previous, ok := identities[name]; ok { + return mcpCompilation{}, v2translator.NewValidationError("Codex MCP servers %q and %q map to the same native name %q", previous, server.Name, name) + } + identities[name] = server.Name + if _, ok := result.servers[name]; ok { + return mcpCompilation{}, v2translator.NewValidationError("RemoteMCPServer %q is bound more than once", server.Name) + } + if server.Spec.Protocol != "" && server.Spec.Protocol != v1alpha3.RemoteMCPServerProtocolStreamableHttp { + return mcpCompilation{}, v2translator.NewValidationError("Codex RemoteMCPServer %q requires Streamable HTTP", server.Name) + } + if warning := codexMCPCompatibilityWarning(server); warning != "" { + result.warnings = append(result.warnings, warning) + } + host, err := absoluteHTTPHostname(server.Spec.URL) + if err != nil { + return mcpCompilation{}, v2translator.NewValidationError("Codex RemoteMCPServer %q URL %v", server.Name, err) + } + headers, environment, err := c.compileMCPHeaders(ctx, namespace, server.Spec.HeadersFrom) + if err != nil { + return mcpCompilation{}, fmt.Errorf("compile RemoteMCPServer %q headers: %w", server.Name, err) + } + selected := append([]string(nil), tool.Binding.Tools...) + slices.Sort(selected) + selected = slices.Compact(selected) + result.servers[name] = codexconfig.MCPServer{URL: server.Spec.URL, Headers: headers, EnabledTools: selected} + result.environment = append(result.environment, environment...) + result.egress = append(result.egress, host) + } + return result, nil +} + +func codexMCPCompatibilityWarning(server *v1alpha3.RemoteMCPServer) string { + var ignored []string + if !server.Spec.TLS.IsEmpty() { + ignored = append(ignored, "custom TLS configuration") + } + if server.Spec.Timeout != nil && server.Spec.Timeout.Duration != 30*time.Second { + ignored = append(ignored, "timeout") + } + if server.Spec.TerminateOnClose != nil && !*server.Spec.TerminateOnClose { + ignored = append(ignored, "terminateOnClose") + } + if len(ignored) == 0 { + return "" + } + return fmt.Sprintf("Codex RemoteMCPServer %q ignores unsupported fields %s", server.Name, strings.Join(ignored, ", ")) +} + +func (c *Compiler) compileMCPHeaders(ctx context.Context, namespace string, refs []v1alpha3.ValueRef) (map[string]string, []corev1.EnvVar, error) { + if len(refs) == 0 { + return nil, nil, nil + } + headers := make(map[string]string, len(refs)) + var environment []corev1.EnvVar + for _, ref := range refs { + if strings.TrimSpace(ref.Name) == "" { + return nil, nil, v2translator.NewValidationError("MCP header name is required") + } + if _, ok := headers[ref.Name]; ok { + return nil, nil, v2translator.NewValidationError("duplicate MCP header %q", ref.Name) + } + switch { + case ref.ValueFrom == nil: + headers[ref.Name] = ref.Value + case ref.ValueFrom.Type == v1alpha3.ConfigMapValueSource: + configMap := krt.FetchOne(c.ctx, c.collections.ConfigMaps, krt.FilterObjectName(types.NamespacedName{Namespace: namespace, Name: ref.ValueFrom.Name})) + if configMap == nil { + return nil, nil, fmt.Errorf("ConfigMap %q not found", ref.ValueFrom.Name) + } + value, ok := (*configMap).Data[ref.ValueFrom.Key] + if !ok { + return nil, nil, fmt.Errorf("ConfigMap %q does not contain key %q", ref.ValueFrom.Name, ref.ValueFrom.Key) + } + headers[ref.Name] = value + case ref.ValueFrom.Type == v1alpha3.SecretValueSource: + sum := sha256.Sum256([]byte(namespace + "\x00" + ref.ValueFrom.Name + "\x00" + ref.ValueFrom.Key)) + name := mcpCredentialPrefix + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) + headers[ref.Name] = "${" + name + "}" + environment = append(environment, secretEnvironment(name, ref.ValueFrom.Name, ref.ValueFrom.Key)) + default: + return nil, nil, v2translator.NewValidationError("unsupported MCP header value source %q", ref.ValueFrom.Type) + } + } + return headers, environment, nil +} diff --git a/go/core/v2/translator/compiler.go b/go/core/v2/translator/compiler.go index e535b8ed2..a35439a8e 100644 --- a/go/core/v2/translator/compiler.go +++ b/go/core/v2/translator/compiler.go @@ -32,9 +32,10 @@ const ( HarnessTypeBYO HarnessType = "byo" ) -// HarnessCompiler converts resolved, harness-neutral inputs into one runtime revision. +// HarnessCompiler converts resolved, harness-neutral inputs into one runtime +// revision and its user-facing diagnostics. type HarnessCompiler interface { - Compile(context.Context, *HarnessInput) (*Revision, error) + Compile(context.Context, *HarnessInput) (*CompileResult, error) } // ResolvedTree is the validated AgentTemplate topology for one Harness. @@ -90,8 +91,9 @@ func NewCompiler(ctx krt.HandlerContext, collections Collections, harnessCompile } // CompileAgentTemplate resolves an API v2 attachment into an immutable runtime -// revision. Nothing below this boundary needs to read the public API objects. -func (c *Compiler) CompileAgentTemplate(ctx context.Context, harness *v1alpha3.Harness, template *v1alpha3.AgentTemplate) (*Revision, error) { +// revision and user-facing diagnostics. Nothing below this boundary needs to +// read the public API objects. +func (c *Compiler) CompileAgentTemplate(ctx context.Context, harness *v1alpha3.Harness, template *v1alpha3.AgentTemplate) (*CompileResult, error) { harnessCompiler := c.harnessCompilers[harnessType(harness)] if harnessCompiler == nil { return nil, NewValidationError("Harness runtime is not supported by any compiler") diff --git a/go/core/v2/translator/compiler_test.go b/go/core/v2/translator/compiler_test.go index 29342e0e9..f7217bc9f 100644 --- a/go/core/v2/translator/compiler_test.go +++ b/go/core/v2/translator/compiler_test.go @@ -158,9 +158,9 @@ func TestResolveModelConfigRecordsFoundryEndpointReference(t *testing.T) { type testHarnessCompiler struct{ input *v2translator.HarnessInput } -func (c *testHarnessCompiler) Compile(_ context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { +func (c *testHarnessCompiler) Compile(_ context.Context, input *v2translator.HarnessInput) (*v2translator.CompileResult, error) { c.input = input - return &v2translator.Revision{AgentTemplateName: input.Root.Template.Name}, nil + return &v2translator.CompileResult{Revision: v2translator.Revision{AgentTemplateName: input.Root.Template.Name}}, nil } func TestCompilerAcceptsExternalHarnessCompiler(t *testing.T) { diff --git a/go/core/v2/translator/kagent/compiler.go b/go/core/v2/translator/kagent/compiler.go index 563963466..ab5e8d80d 100644 --- a/go/core/v2/translator/kagent/compiler.go +++ b/go/core/v2/translator/kagent/compiler.go @@ -31,7 +31,7 @@ func NewCompiler(ctx krt.HandlerContext, collections v2translator.Collections) * return &Compiler{config: adkconfig.NewBuilder(ctx, collections)} } -func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { +func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.CompileResult, error) { if err := requireModels(input.Root); err != nil { return nil, err } @@ -83,13 +83,12 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, fmt.Errorf("resolve runtime environment: %w", err) } slices.Sort(compiled.Egress) - - return &v2translator.Revision{ + return &v2translator.CompileResult{Revision: v2translator.Revision{ Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, Image: harness.Spec.Workload.Image, Environment: environment, ConfigJSON: configJSON, AgentCardJSON: cardJSON, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), - }, nil + }}, nil } func requireModels(input *v2translator.AgentInput) error { diff --git a/go/core/v2/translator/revision.go b/go/core/v2/translator/revision.go index 30f9d0e91..dba8deb21 100644 --- a/go/core/v2/translator/revision.go +++ b/go/core/v2/translator/revision.go @@ -24,6 +24,14 @@ func (id RevisionID) Short() string { return hex.EncodeToString(id[:shortRevisio // IsZero reports whether compilation has not produced an identity. func (id RevisionID) IsZero() bool { return id == RevisionID{} } +// CompileResult contains one immutable runtime revision and the non-blocking +// diagnostics produced while compiling it. Diagnostics are deliberately kept +// outside Revision because they do not describe runtime behavior. +type CompileResult struct { + Revision + Warnings []string +} + // Revision is the resolved runtime configuration for one immutable revision. type Revision struct { // These fields identify the public attachment that produced the revision. @@ -49,9 +57,6 @@ type Revision struct { Provenance json.RawMessage // EgressDestinations is the hostname allowlist required by this revision. EgressDestinations []string - // Warnings are non-blocking compilation diagnostics. They are deliberately - // excluded from Digest because they do not change runtime behavior. - Warnings []string } // Digest returns the immutable identity of every input that affects runtime diff --git a/go/core/v2/translator/revision_test.go b/go/core/v2/translator/revision_test.go index e602e1f10..77fa19737 100644 --- a/go/core/v2/translator/revision_test.go +++ b/go/core/v2/translator/revision_test.go @@ -24,14 +24,17 @@ func TestRevisionDigestIncludesProvenance(t *testing.T) { } } -func TestRevisionDigestExcludesWarnings(t *testing.T) { - revision := &Revision{Namespace: "agents", AgentTemplateName: "helper", HarnessName: "claude"} - first, err := revision.Digest() +func TestCompilationWarningsDoNotAffectRevisionDigest(t *testing.T) { + compilation := &CompileResult{Revision: Revision{Namespace: "agents", AgentTemplateName: "helper", HarnessName: "claude"}} + first, err := compilation.Digest() if err != nil { t.Fatal(err) } - revision.Warnings = []string{"partial MCP selection is not enforced"} - second, err := revision.Digest() + compilation.Warnings = []string{"partial MCP selection is not enforced"} + if len(compilation.Warnings) != 1 { + t.Fatalf("warnings = %v, want one warning", compilation.Warnings) + } + second, err := compilation.Digest() if err != nil { t.Fatal(err) } diff --git a/go/go.mod b/go/go.mod index 887bb2dda..c581c5af4 100644 --- a/go/go.mod +++ b/go/go.mod @@ -34,12 +34,13 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/kagent-dev/kmcp v0.3.0 - github.com/kagent-dev/mockllm v0.0.6 + github.com/kagent-dev/mockllm v0.0.7 github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/muesli/reflow v0.3.0 github.com/ollama/ollama v0.32.15 github.com/openai/openai-go/v3 v3.52.0 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pgvector/pgvector-go v0.4.1 github.com/pgvector/pgvector-go/pgx v0.4.1 github.com/prometheus/client_golang v1.24.1 @@ -345,7 +346,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect - github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go/go.sum b/go/go.sum index d037d367e..29ab0e602 100644 --- a/go/go.sum +++ b/go/go.sum @@ -703,8 +703,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= github.com/kagent-dev/kmcp v0.3.0 h1:CuF8LN6JbPoy75saRzwI5OIcSVZUgiwB0BeWtq+rAO4= github.com/kagent-dev/kmcp v0.3.0/go.mod h1:g7wS/3m2wonRo/1DMwVoHxnilr/urPgV2hwV1DwkwrQ= -github.com/kagent-dev/mockllm v0.0.6 h1:tCCUnweWven4Y8HTkdjtgAiNqWgFwk7WQ00cqO0F0RE= -github.com/kagent-dev/mockllm v0.0.6/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= +github.com/kagent-dev/mockllm v0.0.7 h1:ofucOQKKqarRICBjxJFKIpxD7NQOjVuA0Frujh96uJQ= +github.com/kagent-dev/mockllm v0.0.7/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085 h1:5bYxRc6K6+JiFMYGSJpQ7y18PzTGXUmUtVXY7Fqh0Ew= github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085/go.mod h1:vTMste7c+XiDOQyhe6iGPKLbMy3chY/h2fL3Dox5fAE= github.com/kagent-dev/substrate v0.0.22 h1:wA9W/VESYcbhZ/G44Thz+Alees5yqyBtC4o6hCASwhs= diff --git a/go/harness/README.md b/go/harness/README.md new file mode 100644 index 000000000..50467052d --- /dev/null +++ b/go/harness/README.md @@ -0,0 +1,70 @@ +# Native Harness runtimes + +This directory contains the Actor-side runtimes for native agent harnesses. +Each runtime receives compiler-owned configuration, runs its native agent +process, and translates native events into the shared runtime event model. The +shared A2A executor then exposes those events through Kagent's private A2A +endpoint. + +Controller-side compilation does not live here. It is under +[`core/v2/translator`](../core/v2/translator), where Kubernetes resources are +resolved into an immutable runtime revision and a versioned harness config. + +## Request flow + +```text +Harness + AgentTemplate + | + v +core/v2/translator/{claude,codex} controller-side compilation + | + v +KAGENT_CONFIG_JSON + environment immutable Actor inputs + | + v +harness/{claude,codex}/cmd process entrypoint + | + v +internal/adapter -> internal/driver + | + v +runtime events -> runtime/a2a private A2A task stream +``` + +## Layout + +| Path | Responsibility | +| --- | --- | +| [`claude`](claude/README.md) | Claude Code configuration, materialization, process protocol, and image | +| [`codex`](codex/README.md) | Codex App Server configuration, JSON-RPC driver, and image | +| [`runtime/runtime.go`](runtime/runtime.go) | Small runtime-neutral turn, event, sink, and outcome model | +| [`runtime/a2a`](runtime/a2a) | Serializes Actor execution and maps runtime events to upstream A2A events | +| [`runtime/continuation`](runtime/continuation) | Atomically persists the one native continuation ID owned by an Actor | +| [`runtime/utils`](runtime/utils) | Bounded diagnostic capture, private-file materialization, and process-group signaling | + +The native runtimes deliberately share only mechanisms. Claude- or +Codex-specific configuration and protocol behavior stays in the owning +harness. + +## Runtime state + +`DurableDir` contains private state that must survive Actor replacement and +snapshot restore, including the native continuation ID and native runtime home. +Ephemeral credentials and generated files that should not enter snapshots must +remain outside it. The utilities under `runtime/utils` enforce restrictive +permissions and atomic replacement for private files. + +Upstream A2A owns public tasks, contexts, history, and streaming semantics. A +native continuation ID is private Actor state; it is not a second public session +model. + +## Development + +From `go/`, run the focused unit tests with: + +```bash +go test ./harness/... +``` + +The root Makefile builds the runtime images with `make build-claude-harness` +and `make build-codex-harness`. diff --git a/go/harness/claude/README.md b/go/harness/claude/README.md index f597a7081..8323d58a9 100644 --- a/go/harness/claude/README.md +++ b/go/harness/claude/README.md @@ -4,6 +4,20 @@ The Claude Harness runs Claude Code as a native Kagent runtime. It compiles an `AgentTemplate` into Claude Code configuration, runs each turn in a Substrate Actor, and exposes the result through Kagent's A2A API. +## Code structure + +The controller and Actor share only the versioned config contract. Kubernetes +resolution stays in the controller; native process behavior stays in this +harness. + +| Path | Look here for | +| --- | --- | +| [`../../core/v2/translator/claude`](../../core/v2/translator/claude) | Translating `Harness`, `AgentTemplate`, model, MCP, plugin, and Secret inputs into a runtime revision and warnings | +| [`config/config.go`](config/config.go) | The versioned JSON contract shared by the compiler and runtime, including defaults and reserved environment variables | +| [`cmd/main.go`](cmd/main.go) | Actor startup, environment inputs, Claude version validation, continuation-store wiring, and private A2A startup | +| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, MCP config, and ephemeral provider credentials | +| [`internal/driver`](internal/driver) | Claude CLI arguments, stream-JSON parsing, runtime-event translation, cancellation, and process supervision | + ## Working - [x] Anthropic, Amazon Bedrock, and Vertex AI model providers @@ -24,7 +38,7 @@ Actor, and exposes the result through Kagent's A2A API. - [ ] Skills, MCP tools, and nested subagents on local subagents - [ ] Configuring Claude Code permission mode and trust boundary in Harness CRD -## Example Usage +## Example usage ```yaml apiVersion: kagent.dev/v1alpha3 @@ -65,10 +79,10 @@ spec: kind: RemoteMCPServer name: kagent-tool-server plugins: - - source: + - source: git: url: https://github.com/agentplugins/agent-plugins-example.git commit: 5f3f5084a821aefa792e79500dd8f0462ab83473 skills: - migrate-agent-plugin -``` \ No newline at end of file +``` diff --git a/go/harness/claude/cmd/main.go b/go/harness/claude/cmd/main.go index f6b432ce8..2375b417b 100644 --- a/go/harness/claude/cmd/main.go +++ b/go/harness/claude/cmd/main.go @@ -12,10 +12,11 @@ import ( "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/google/uuid" "github.com/kagent-dev/kagent/go/adk/pkg/app" "github.com/kagent-dev/kagent/go/harness/claude/internal/adapter" - "github.com/kagent-dev/kagent/go/harness/claude/internal/session" runtimea2a "github.com/kagent-dev/kagent/go/harness/runtime/a2a" + "github.com/kagent-dev/kagent/go/harness/runtime/continuation" ) const ( @@ -59,7 +60,7 @@ func run(ctx context.Context, check bool, getenv func(string) string, environmen if err != nil { return fmt.Errorf("configure Claude Harness: %w", err) } - validateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + validateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := runner.Validate(validateCtx); err != nil { return err @@ -67,7 +68,7 @@ func run(ctx context.Context, check bool, getenv func(string) string, environmen if check { return nil } - store, err := session.New(dataDir + "/adapter") + store, err := continuation.New(dataDir+"/adapter", "claude", validateSessionID) if err != nil { return err } @@ -82,6 +83,13 @@ func run(ctx context.Context, check bool, getenv func(string) string, environmen return application.Run() } +func validateSessionID(id string) error { + if _, err := uuid.Parse(id); err != nil { + return fmt.Errorf("invalid Claude session ID: %w", err) + } + return nil +} + func requiredEnvironment(getenv func(string) string, name string) ([]byte, error) { value := strings.TrimSpace(getenv(name)) if value == "" { diff --git a/go/harness/claude/cmd/main_test.go b/go/harness/claude/cmd/main_test.go index f4b552f5f..35203bb23 100644 --- a/go/harness/claude/cmd/main_test.go +++ b/go/harness/claude/cmd/main_test.go @@ -16,3 +16,12 @@ func TestRequiredEnvironment(t *testing.T) { t.Fatalf("requiredEnvironment() error = %v", err) } } + +func TestValidateSessionID(t *testing.T) { + if err := validateSessionID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); err != nil { + t.Fatalf("validateSessionID() error = %v", err) + } + if err := validateSessionID("not-a-session"); err == nil { + t.Fatal("validateSessionID() accepted an invalid UUID") + } +} diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go index ee32aeb87..172a3f4b9 100644 --- a/go/harness/claude/config/config.go +++ b/go/harness/claude/config/config.go @@ -16,11 +16,46 @@ import ( var agentNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) const ( - Version = 3 - PinnedClaudeVersion = "2.1.217" - GoogleCredentialsJSONEnvName = "KAGENT_CLAUDE_GOOGLE_CREDENTIALS_JSON" + Version = 3 + PinnedClaudeVersion = "2.1.217" + ClaudeConfigDirEnvName = "CLAUDE_CONFIG_DIR" + DisableUpdatesEnvName = "DISABLE_UPDATES" + GoogleApplicationCredentialsEnvName = "GOOGLE_APPLICATION_CREDENTIALS" + GoogleCredentialsJSONEnvName = "KAGENT_CLAUDE_GOOGLE_CREDENTIALS_JSON" + UseBedrockEnvName = "CLAUDE_CODE_USE_BEDROCK" + UseVertexEnvName = "CLAUDE_CODE_USE_VERTEX" + AWSRegionEnvName = "AWS_REGION" + AWSAccessKeyEnvName = "AWS_ACCESS_KEY_ID" + AWSSecretKeyEnvName = "AWS_SECRET_ACCESS_KEY" + AWSSessionTokenEnvName = "AWS_SESSION_TOKEN" + AWSBedrockTokenEnvName = "AWS_BEARER_TOKEN_BEDROCK" + AnthropicAPIKeyEnvName = "ANTHROPIC_API_KEY" + AnthropicBaseURLEnvName = "ANTHROPIC_BASE_URL" + VertexProjectEnvName = "ANTHROPIC_VERTEX_PROJECT_ID" + VertexRegionEnvName = "CLOUD_ML_REGION" + SandboxEnvName = "IS_SANDBOX" + PreResponseTraceFlushEnvName = "KAGENT_PRE_RESPONSE_TRACE_FLUSH" + MCPCredentialEnvPrefix = "KAGENT_CLAUDE_MCP_CREDENTIAL_" ) +// OwnsEnvironment reports whether the compiler or adapter reserves name for +// Claude runtime configuration. Harness authors cannot override these values. +func OwnsEnvironment(name string) bool { + if strings.HasPrefix(name, MCPCredentialEnvPrefix) { + return true + } + switch name { + case ClaudeConfigDirEnvName, DisableUpdatesEnvName, GoogleApplicationCredentialsEnvName, + GoogleCredentialsJSONEnvName, UseBedrockEnvName, UseVertexEnvName, AWSRegionEnvName, + AWSAccessKeyEnvName, AWSSecretKeyEnvName, AWSSessionTokenEnvName, AWSBedrockTokenEnvName, + AnthropicAPIKeyEnvName, AnthropicBaseURLEnvName, VertexProjectEnvName, VertexRegionEnvName, + SandboxEnvName, PreResponseTraceFlushEnvName: + return true + default: + return false + } +} + type Config struct { Version int `json:"version"` ClaudeExecutable string `json:"claude_executable"` diff --git a/go/harness/claude/config/config_test.go b/go/harness/claude/config/config_test.go index b79a6c82a..151f603f9 100644 --- a/go/harness/claude/config/config_test.go +++ b/go/harness/claude/config/config_test.go @@ -8,6 +8,22 @@ import ( "github.com/kagent-dev/kagent/go/api/agentplugin" ) +func TestOwnsEnvironment(t *testing.T) { + for _, name := range []string{ + AnthropicAPIKeyEnvName, + ClaudeConfigDirEnvName, + PreResponseTraceFlushEnvName, + MCPCredentialEnvPrefix + "ABC123", + } { + if !OwnsEnvironment(name) { + t.Errorf("OwnsEnvironment(%q) = false", name) + } + } + if OwnsEnvironment("USER_DEFINED") { + t.Fatal("OwnsEnvironment accepted a user-defined name") + } +} + func TestProductionRoundTrip(t *testing.T) { cfg := Production("claude-test", "help") if err := cfg.Validate(); err != nil { diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index 9c99c6a45..f8eff4e03 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -6,19 +6,13 @@ import ( "context" "encoding/json" "fmt" - "os" "path/filepath" "strings" "github.com/kagent-dev/kagent/go/core/v2/agentplugins" "github.com/kagent-dev/kagent/go/harness/claude/config" "github.com/kagent-dev/kagent/go/harness/claude/internal/driver" -) - -const ( - claudeConfigDirEnv = "CLAUDE_CONFIG_DIR" - disableUpdatesEnv = "DISABLE_UPDATES" - googleApplicationCredentialsEnv = "GOOGLE_APPLICATION_CREDENTIALS" + "github.com/kagent-dev/kagent/go/harness/runtime/utils" ) // Input contains compiler output and Actor-owned locations used to construct @@ -54,7 +48,7 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { {name: "Claude state", path: claudeDir}, {name: "generated Claude skills", path: filepath.Join(claudeDir, "skills")}, } { - if err := ensurePrivateDir(directory.path); err != nil { + if err := utils.EnsurePrivateDir(directory.path); err != nil { return nil, fmt.Errorf("prepare %s directory: %w", directory.name, err) } } @@ -66,21 +60,21 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { return nil, fmt.Errorf("materialize Claude skills: %w", err) } } - environment := setEnvironment(input.Environment, claudeConfigDirEnv, claudeDir) + environment := setEnvironment(input.Environment, config.ClaudeConfigDirEnvName, claudeDir) // The image and compiler pin an exact Claude version. Prevent both automatic // and manual update paths from changing that runtime after validation. - environment = setEnvironment(environment, disableUpdatesEnv, "1") + environment = setEnvironment(environment, config.DisableUpdatesEnvName, "1") environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) if err != nil { return nil, err } var mcpConfigPath string if len(mcpJSON) != 0 { - if err := ensurePrivateDir(input.EphemeralDir); err != nil { + if err := utils.EnsurePrivateDir(input.EphemeralDir); err != nil { return nil, fmt.Errorf("prepare ephemeral MCP directory: %w", err) } mcpConfigPath = filepath.Join(input.EphemeralDir, "mcp.json") - if err := replacePrivateFile(mcpConfigPath, mcpJSON); err != nil { + if err := utils.ReplacePrivateFile(mcpConfigPath, mcpJSON); err != nil { return nil, fmt.Errorf("materialize Claude MCP configuration: %w", err) } } @@ -93,27 +87,6 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { }), nil } -func replacePrivateFile(path string, contents []byte) error { - temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") - if err != nil { - return err - } - temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) - if err := temporary.Chmod(0o600); err != nil { - _ = temporary.Close() - return err - } - if _, err := temporary.Write(contents); err != nil { - _ = temporary.Close() - return err - } - if err := temporary.Close(); err != nil { - return err - } - return os.Rename(temporaryPath, path) -} - func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { // The compiler injects the Secret value as JSON, while Google ADC expects a // file path. Keep the credential in ephemeral Actor storage rather than the @@ -137,45 +110,14 @@ func materializeGoogleCredentials(environment []string, directory string) ([]str if !json.Valid([]byte(credentials)) { return nil, fmt.Errorf("%s must contain valid JSON", config.GoogleCredentialsJSONEnvName) } - if err := ensurePrivateDir(directory); err != nil { + if err := utils.EnsurePrivateDir(directory); err != nil { return nil, fmt.Errorf("prepare ephemeral credentials directory: %w", err) } path := filepath.Join(directory, "google-credentials.json") - temporary, err := os.CreateTemp(directory, ".google-credentials-*.tmp") - if err != nil { - return nil, fmt.Errorf("create temporary Google credentials: %w", err) - } - temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) - if err := temporary.Chmod(0o600); err != nil { - _ = temporary.Close() - return nil, fmt.Errorf("secure temporary Google credentials: %w", err) - } - if _, err := temporary.WriteString(credentials); err != nil { - _ = temporary.Close() + if err := utils.ReplacePrivateFile(path, []byte(credentials)); err != nil { return nil, fmt.Errorf("materialize Google credentials: %w", err) } - if err := temporary.Close(); err != nil { - return nil, fmt.Errorf("close Google credentials: %w", err) - } - if err := os.Rename(temporaryPath, path); err != nil { - return nil, fmt.Errorf("replace Google credentials: %w", err) - } - return setEnvironment(filtered, googleApplicationCredentialsEnv, path), nil -} - -func ensurePrivateDir(path string) error { - if err := os.MkdirAll(path, 0o700); err != nil { - return err - } - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("%q is not a directory", path) - } - return os.Chmod(path, 0o700) + return setEnvironment(filtered, config.GoogleApplicationCredentialsEnvName, path), nil } func setEnvironment(environment []string, name, value string) []string { diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index b5df63710..95fb4cbf8 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -99,7 +99,7 @@ func TestMaterializeGoogleCredentials(t *testing.T) { if string(contents) != raw { t.Fatalf("credentials = %q", contents) } - if len(environment) != 2 || environment[0] != "A=1" || environment[1] != googleApplicationCredentialsEnv+"="+path { + if len(environment) != 2 || environment[0] != "A=1" || environment[1] != config.GoogleApplicationCredentialsEnvName+"="+path { t.Fatalf("environment = %v", environment) } if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { diff --git a/go/harness/claude/internal/driver/parser.go b/go/harness/claude/internal/driver/parser.go index b565fda0f..53f24f69c 100644 --- a/go/harness/claude/internal/driver/parser.go +++ b/go/harness/claude/internal/driver/parser.go @@ -24,6 +24,8 @@ type contentBlockRef struct { index int } +// ParseJSONL parses a JSONL stream of Claude events and emits them to the +// provided event sink. func ParseJSONL(r io.Reader, maxEventBytes int, emit func(Event) error) error { if maxEventBytes <= 0 { return fmt.Errorf("max event bytes must be positive") diff --git a/go/harness/claude/internal/driver/process.go b/go/harness/claude/internal/driver/process.go index 799372571..0d19dd731 100644 --- a/go/harness/claude/internal/driver/process.go +++ b/go/harness/claude/internal/driver/process.go @@ -1,7 +1,8 @@ +// Package driver translates Claude Code's streaming process protocol into the +// runtime-neutral events consumed by the shared A2A executor. package driver import ( - "bytes" "context" "fmt" "os/exec" @@ -10,8 +11,11 @@ import ( "time" "github.com/kagent-dev/kagent/go/harness/runtime" + "github.com/kagent-dev/kagent/go/harness/runtime/utils" ) +// ProcessConfig contains validated, compiler-owned inputs for one Claude Code +// process. Actor-owned paths and environment are supplied by the adapter. type ProcessConfig struct { Executable string ExpectedVersion string @@ -27,14 +31,17 @@ type ProcessConfig struct { InterruptGrace time.Duration } +// ProcessDriver supervises one Claude Code process per runtime turn. type ProcessDriver struct { config ProcessConfig } +// NewProcessDriver constructs a Claude Code process driver. func NewProcessDriver(config ProcessConfig) *ProcessDriver { return &ProcessDriver{config: config} } +// Validate checks that the configured executable is the pinned Claude version. func (d *ProcessDriver) Validate(ctx context.Context) error { path, err := exec.LookPath(d.config.Executable) if err != nil { @@ -54,6 +61,7 @@ func (d *ProcessDriver) Validate(ctx context.Context) error { return nil } +// Args compiles one runtime turn into Claude Code command-line arguments. func (d *ProcessDriver) Args(turn runtime.Turn) []string { args := []string{ "-p", turn.Prompt, @@ -83,16 +91,17 @@ func (d *ProcessDriver) Args(turn runtime.Turn) []string { return args } +// Run supervises one Claude Code process and emits its ordered runtime events. func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { cmd := exec.Command(d.config.Executable, d.Args(turn)...) - configureProcessGroup(cmd) + utils.ConfigureProcessGroup(cmd) cmd.Dir = d.config.Workspace cmd.Env = append([]string(nil), d.config.Environment...) stdout, err := cmd.StdoutPipe() if err != nil { return runtime.Outcome{}, fmt.Errorf("open Claude stdout: %w", err) } - stderr := &boundedBuffer{max: d.config.MaxStderrBytes} + stderr := utils.NewBoundedBuffer(d.config.MaxStderrBytes) cmd.Stderr = stderr if err := cmd.Start(); err != nil { return runtime.Outcome{}, fmt.Errorf("start Claude: %w", err) @@ -172,6 +181,8 @@ func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime } } +// emitEvent translates a Claude event to a runtime event and emits it to the +// provided event sink, which is then consumed by the shared A2A executor. func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Outcome, error) { if terminal { return nil, fmt.Errorf("claude emitted activity after its terminal result") @@ -204,33 +215,16 @@ func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Out } func (d *ProcessDriver) terminate(cmd *exec.Cmd, waitDone <-chan error) { - _ = interruptProcessGroup(cmd.Process) + _ = utils.InterruptProcessGroup(cmd.Process) timer := time.NewTimer(d.config.InterruptGrace) defer timer.Stop() select { case <-waitDone: // The group leader can exit on the interrupt while a descendant that // ignores it remains alive. Kill any processes still in the group. - _ = killProcessGroup(cmd.Process) + _ = utils.KillProcessGroup(cmd.Process) case <-timer.C: - _ = killProcessGroup(cmd.Process) + _ = utils.KillProcessGroup(cmd.Process) <-waitDone } } - -type boundedBuffer struct { - bytes.Buffer - max int -} - -func (b *boundedBuffer) Write(p []byte) (int, error) { - original := len(p) - remaining := b.max - b.Len() - if remaining > 0 { - if len(p) > remaining { - p = p[:remaining] - } - _, _ = b.Buffer.Write(p) - } - return original, nil -} diff --git a/go/harness/claude/internal/driver/process_group_unix.go b/go/harness/claude/internal/driver/process_group_unix.go deleted file mode 100644 index ba01a2c59..000000000 --- a/go/harness/claude/internal/driver/process_group_unix.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build unix - -package driver - -import ( - "os" - "os/exec" - "syscall" -) - -func configureProcessGroup(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} -} - -func interruptProcessGroup(process *os.Process) error { - return syscall.Kill(-process.Pid, syscall.SIGINT) -} - -func killProcessGroup(process *os.Process) error { - return syscall.Kill(-process.Pid, syscall.SIGKILL) -} diff --git a/go/harness/claude/internal/driver/process_group_windows.go b/go/harness/claude/internal/driver/process_group_windows.go deleted file mode 100644 index f88bd0563..000000000 --- a/go/harness/claude/internal/driver/process_group_windows.go +++ /dev/null @@ -1,16 +0,0 @@ -package driver - -import ( - "os" - "os/exec" -) - -func configureProcessGroup(*exec.Cmd) {} - -func interruptProcessGroup(process *os.Process) error { - return process.Signal(os.Interrupt) -} - -func killProcessGroup(process *os.Process) error { - return process.Kill() -} diff --git a/go/harness/claude/internal/session/store.go b/go/harness/claude/internal/session/store.go deleted file mode 100644 index aa7ac6685..000000000 --- a/go/harness/claude/internal/session/store.go +++ /dev/null @@ -1,113 +0,0 @@ -package session - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - - "github.com/google/uuid" -) - -const stateVersion = 2 - -type state struct { - Version int `json:"version"` - Runtime string `json:"runtime"` - SessionID string `json:"session_id,omitempty"` -} - -type Store struct { - mu sync.RWMutex - path string - data state -} - -func New(durableDir string) (*Store, error) { - if err := os.MkdirAll(durableDir, 0o700); err != nil { - return nil, fmt.Errorf("create session state directory: %w", err) - } - if err := os.Chmod(durableDir, 0o700); err != nil { - return nil, fmt.Errorf("secure session state directory: %w", err) - } - s := &Store{path: filepath.Join(durableDir, "state.json")} - s.data = state{Version: stateVersion, Runtime: "claude"} - b, err := os.ReadFile(s.path) - if os.IsNotExist(err) { - return s, nil - } - if err != nil { - return nil, fmt.Errorf("read session state: %w", err) - } - if err := json.Unmarshal(b, &s.data); err != nil { - return nil, fmt.Errorf("decode session state: %w", err) - } - if s.data.Version != stateVersion || s.data.Runtime != "claude" { - return nil, fmt.Errorf("unsupported or corrupt Claude session state") - } - if s.data.SessionID != "" { - if err := validateSessionID(s.data.SessionID); err != nil { - return nil, fmt.Errorf("invalid persisted session state: %w", err) - } - } - return s, nil -} - -func (s *Store) Load() (string, bool, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.data.SessionID, s.data.SessionID != "", nil -} - -func (s *Store) Bind(nativeSessionID string) error { - if err := validateSessionID(nativeSessionID); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - if s.data.SessionID != "" && s.data.SessionID != nativeSessionID { - return fmt.Errorf("actor is already bound to another Claude session") - } - if s.data.SessionID == nativeSessionID { - return nil - } - next := state{Version: stateVersion, Runtime: "claude", SessionID: nativeSessionID} - b, err := json.MarshalIndent(next, "", " ") - if err != nil { - return fmt.Errorf("encode session state: %w", err) - } - tmp, err := os.CreateTemp(filepath.Dir(s.path), ".sessions-*.tmp") - if err != nil { - return fmt.Errorf("create temporary session state: %w", err) - } - tmpName := tmp.Name() - defer os.Remove(tmpName) - if err := tmp.Chmod(0o600); err != nil { - _ = tmp.Close() - return fmt.Errorf("secure temporary session state: %w", err) - } - if _, err := tmp.Write(b); err != nil { - _ = tmp.Close() - return fmt.Errorf("write temporary session state: %w", err) - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return fmt.Errorf("sync temporary session state: %w", err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close temporary session state: %w", err) - } - if err := os.Rename(tmpName, s.path); err != nil { - return fmt.Errorf("replace session state: %w", err) - } - s.data = next - return nil -} - -func validateSessionID(nativeSessionID string) error { - if _, err := uuid.Parse(nativeSessionID); err != nil { - return fmt.Errorf("invalid Claude session ID: %w", err) - } - return nil -} diff --git a/go/harness/claude/internal/session/store_test.go b/go/harness/claude/internal/session/store_test.go deleted file mode 100644 index 461965b33..000000000 --- a/go/harness/claude/internal/session/store_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package session - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -const ( - sessionID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" -) - -func TestStoreFirstSessionReloadAndPermissions(t *testing.T) { - dir := t.TempDir() - store, err := New(dir) - if err != nil { - t.Fatal(err) - } - if _, ok, err := store.Load(); err != nil || ok { - t.Fatalf("empty Load() = ok %t, err %v", ok, err) - } - if err := store.Bind(sessionID); err != nil { - t.Fatal(err) - } - reloaded, err := New(dir) - if err != nil { - t.Fatal(err) - } - got, ok, err := reloaded.Load() - if err != nil || !ok || got != sessionID { - t.Fatalf("reloaded Load() = %q, %t, %v", got, ok, err) - } - info, err := os.Stat(filepath.Join(dir, "state.json")) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0o600 { - t.Errorf("state permissions = %o, want 600", info.Mode().Perm()) - } - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 || strings.Contains(entries[0].Name(), ".tmp") { - t.Errorf("atomic replacement left entries: %v", entries) - } -} - -func TestStoreRejectsCorruptAndUnsupportedState(t *testing.T) { - tests := []string{ - "not json", - `{"version":1,"runtime":"claude","session_id":"` + sessionID + `"}`, - `{"version":2,"runtime":"codex","session_id":"` + sessionID + `"}`, - `{"version":2,"runtime":"claude","session_id":"not-a-session"}`, - } - for _, contents := range tests { - t.Run(contents, func(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(contents), 0o600); err != nil { - t.Fatal(err) - } - if _, err := New(dir); err == nil { - t.Fatal("New() succeeded for invalid state") - } - }) - } -} - -func TestStoreRejectsInvalidAndConflictingSessionIDs(t *testing.T) { - store, err := New(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if err := store.Bind("not-a-session"); err == nil { - t.Fatal("Bind() accepted invalid session ID") - } - if err := store.Bind(sessionID); err != nil { - t.Fatal(err) - } - if err := store.Bind(sessionID); err != nil { - t.Fatalf("idempotent Bind() failed: %v", err) - } - if err := store.Bind("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); err == nil { - t.Fatal("Bind() accepted a conflicting native session") - } -} diff --git a/go/harness/codex/Dockerfile b/go/harness/codex/Dockerfile new file mode 100644 index 000000000..10e0ce2cd --- /dev/null +++ b/go/harness/codex/Dockerfile @@ -0,0 +1,54 @@ +ARG BASE_IMAGE_REGISTRY=cgr.dev +ARG BUILDPLATFORM +FROM --platform=$BUILDPLATFORM ${BASE_IMAGE_REGISTRY}/chainguard/go:latest AS builder +ARG TARGETARCH +ARG TARGETOS + +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + go mod download +COPY api/ api/ +COPY adk/ adk/ +COPY core/ core/ +COPY harness/ harness/ +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ + go build -trimpath -o /kagent-codex ./harness/codex/cmd + +FROM alpine:3.22 +ARG CODEX_CLI_VERSION=0.148.0 +ARG CODEX_CLI_AMD64_SHA256=8c790500af2ba6e74ce4948fe26c651ac1f77f6dbb005b47c8d26ff711146262 +ARG CODEX_CLI_ARM64_SHA256=580db3c7411f5852b550876f185c30b61b674e01b948fd5030f2cd7a30db110a +ARG TARGETARCH +ARG VERSION + +RUN apk add --no-cache bash ca-certificates git libgcc libstdc++ \ + && case "${TARGETARCH}" in \ + amd64) platform="x86_64-unknown-linux-musl"; checksum="${CODEX_CLI_AMD64_SHA256}" ;; \ + arm64) platform="aarch64-unknown-linux-musl"; checksum="${CODEX_CLI_ARM64_SHA256}" ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && wget -T 60 -t 3 -O /tmp/codex.tar.gz \ + "https://github.com/openai/codex/releases/download/rust-v${CODEX_CLI_VERSION}/codex-package-${platform}.tar.gz" \ + && echo "${checksum} /tmp/codex.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/codex.tar.gz -C /usr/local \ + && rm /tmp/codex.tar.gz \ + && addgroup -S -g 65532 kagent \ + && adduser -S -D -H -u 65532 -G kagent -h /data -s /sbin/nologin kagent \ + && mkdir -p /data/adapter /data/codex /data/workspace \ + && chown -R 65532:65532 /data + +COPY --from=builder /kagent-codex /usr/local/bin/kagent-codex + +USER 65532:65532 +WORKDIR /data/workspace +EXPOSE 80 8081 + +LABEL org.opencontainers.image.source="https://github.com/kagent-dev/kagent" +LABEL org.opencontainers.image.description="Kagent Codex native Harness adapter" +LABEL org.opencontainers.image.version="$VERSION" + +ENTRYPOINT ["/usr/local/bin/kagent-codex"] diff --git a/go/harness/codex/README.md b/go/harness/codex/README.md new file mode 100644 index 000000000..5799e9574 --- /dev/null +++ b/go/harness/codex/README.md @@ -0,0 +1,104 @@ +# Codex Harness + +The Codex Harness compiles a `kagent.dev/v1alpha3` `AgentTemplate` into a +compiler-owned Codex configuration and runs one Codex App Server `0.148.0` +process for each public A2A Task. Its native thread and workspace are retained +in the Actor's `DurableDir`. + +## Code structure + +The controller and Actor share only the versioned config contract. Kubernetes +resolution stays in the controller; Codex App Server behavior stays in this +harness. + +| Path | Look here for | +| --- | --- | +| [`../../core/v2/translator/codex`](../../core/v2/translator/codex) | Translating `Harness`, `AgentTemplate`, model, MCP, plugin, and Secret inputs into a runtime revision | +| [`config/config.go`](config/config.go) | The versioned JSON contract shared by the compiler and runtime, including defaults and validation | +| [`cmd/main.go`](cmd/main.go) | Actor startup, environment inputs, Codex version validation, continuation-store wiring, and private A2A startup | +| [`internal/adapter`](internal/adapter) | Materializing `CODEX_HOME`, native TOML, shared agents, skills, and MCP configuration | +| [`internal/driver`](internal/driver) | App Server lifecycle, JSON-RPC framing, event translation, thread resume, cancellation, and process supervision | +| [`protocol/schema`](protocol/schema) | Generated App Server schema used as a development reference; regenerate it from the pinned CLI rather than hand-editing it | + +## Implemented support + +- OpenAI through a Secret-backed API key, the Responses API, and an optional + absolute HTTP(S) base URL. +- Amazon Bedrock through either `AWS_BEARER_TOKEN_BEDROCK` or standard AWS + access-key credentials in one Secret. +- Streaming text, command and file activity, direct Streamable HTTP MCP, and + native Shared agents. +- Standalone and plugin-selected skills without plugin hooks, commands, + executables, or implicit plugin MCP servers. +- Exact native thread resume and bounded cancellation through `turn/interrupt`. + +The adapter deliberately fixes native approvals to `never` and the native +sandbox to `danger-full-access`; the Substrate Actor remains the security +boundary. Account login, API-key passthrough, HITL, custom TLS, legacy SSE MCP, +Dedicated agents, checkpoint/fork guarantees, and configurable native policy +are not advertised. + +Runtime configuration is supplied through `KAGENT_CONFIG_JSON` and +`KAGENT_AGENT_CARD_JSON`. Private A2A is served on port 80 and readiness on +`/readyz` at port 8081. + +## Development + +The Codex driver launches App Server and communicates with it using JSON-RPC +2.0. To refresh the reference schemas from the pinned Codex CLI, run: + +```text +codex app-server generate-json-schema --out go/harness/codex/protocol/schema +``` + +For protocol details, see the [Codex App Server message +schema](https://learn.chatgpt.com/docs/app-server#message-schema). + +## Example + +```yaml +apiVersion: kagent.dev/v1alpha3 +kind: Harness +metadata: + name: codex-harness + namespace: kagent +spec: + codex: {} + workload: + image: ${KAGENT_CODEX_IMAGE_DIGEST} + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ + allowedAgentTemplates: + selector: + matchLabels: + kagent.dev/e2e-runtime: codex +--- +apiVersion: kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + labels: + kagent.dev/e2e-runtime: codex + name: kagent-codex + namespace: kagent +spec: + description: test + modelConfig: + name: default-model-config # This modelconfig must have openAI.apiFormat set to "responses" + systemPrompt: | + Follow the selected skill and use the configured MCP tool. + tools: + - mcp: + server: + kind: RemoteMCPServer + name: kagent-tool-server + plugins: + - source: + git: + url: https://github.com/agentplugins/agent-plugins-example.git + commit: 5f3f5084a821aefa792e79500dd8f0462ab83473 + skills: + - migrate-agent-plugin +``` diff --git a/go/harness/codex/cmd/main.go b/go/harness/codex/cmd/main.go new file mode 100644 index 000000000..24c884267 --- /dev/null +++ b/go/harness/codex/cmd/main.go @@ -0,0 +1,100 @@ +// Command kagent-codex runs the Codex Harness runtime adapter. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + "unicode" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/kagent-dev/kagent/go/adk/pkg/app" + "github.com/kagent-dev/kagent/go/harness/codex/internal/adapter" + runtimea2a "github.com/kagent-dev/kagent/go/harness/runtime/a2a" + "github.com/kagent-dev/kagent/go/harness/runtime/continuation" +) + +const ( + configEnv = "KAGENT_CONFIG_JSON" + agentCardEnv = "KAGENT_AGENT_CARD_JSON" + dataDir = "/data" + privatePort = "80" +) + +func main() { + check := flag.Bool("check", false, "validate configuration and Codex version, then exit") + flag.Parse() + if err := run(context.Background(), *check, os.Getenv, os.Environ()); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context, check bool, getenv func(string) string, environment []string) error { + configJSON, err := requiredEnvironment(getenv, configEnv) + if err != nil { + return err + } + agentCardJSON, err := requiredEnvironment(getenv, agentCardEnv) + if err != nil { + return err + } + var card a2atype.AgentCard + if err := json.Unmarshal(agentCardJSON, &card); err != nil { + return fmt.Errorf("decode agent card: %w", err) + } + if strings.TrimSpace(card.Name) == "" { + return fmt.Errorf("agent card name is required") + } + runner, err := adapter.New(ctx, adapter.Input{ + ConfigJSON: configJSON, Workspace: dataDir + "/workspace", DurableDir: dataDir, Environment: environment, + }) + if err != nil { + return fmt.Errorf("configure Codex Harness: %w", err) + } + validateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := runner.Validate(validateCtx); err != nil { + return err + } + if check { + return nil + } + store, err := continuation.New(dataDir+"/adapter", "codex", validateThreadID) + if err != nil { + return err + } + executor, err := runtimea2a.New(runner, store) + if err != nil { + return err + } + application, err := app.New(app.AppConfig{AgentCard: card, Port: privatePort, AppName: card.Name}, executor) + if err != nil { + return fmt.Errorf("construct private A2A app: %w", err) + } + return application.Run() +} + +func validateThreadID(id string) error { + if id == "" || len(id) > 256 { + return fmt.Errorf("invalid Codex thread ID length") + } + for _, character := range id { + if unicode.IsControl(character) || unicode.IsSpace(character) { + return fmt.Errorf("invalid Codex thread ID") + } + } + return nil +} + +func requiredEnvironment(getenv func(string) string, name string) ([]byte, error) { + value := strings.TrimSpace(getenv(name)) + if value == "" { + return nil, fmt.Errorf("%s is required", name) + } + return []byte(value), nil +} diff --git a/go/harness/codex/config/config.go b/go/harness/codex/config/config.go new file mode 100644 index 000000000..e50e571ef --- /dev/null +++ b/go/harness/codex/config/config.go @@ -0,0 +1,147 @@ +// Package config defines the versioned, non-secret Codex Harness runtime +// configuration shared by its compiler and Actor entrypoint. +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + "regexp" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/api/agentplugin" +) + +const ( + Version = 1 + PinnedCodexVersion = "0.148.0" +) + +var nativeNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Config is compiler-owned input to the native adapter. Credential values are +// supplied only through the process environment. +type Config struct { + Version int `json:"version"` + CodexExecutable string `json:"codex_executable"` + ExpectedCodexVersion string `json:"expected_codex_version"` + StrictVersion bool `json:"strict_version"` + Model string `json:"model"` + Provider Provider `json:"provider"` + DeveloperInstruction string `json:"developer_instruction,omitempty"` + Agents map[string]Agent `json:"agents,omitempty"` + SkillResources *agentplugin.Resources `json:"skill_resources,omitempty"` + MCPServers map[string]MCPServer `json:"mcp_servers,omitempty"` + MaxFrameBytes int `json:"max_frame_bytes"` + MaxStderrBytes int `json:"max_stderr_bytes"` + InterruptGraceMillis int `json:"interrupt_grace_millis"` +} + +type Provider struct { + Name string `json:"name"` + BaseURL string `json:"base_url,omitempty"` +} + +type Agent struct { + Description string `json:"description"` + Instruction string `json:"instruction"` + Model string `json:"model"` +} + +type MCPServer struct { + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + EnabledTools []string `json:"enabled_tools,omitempty"` +} + +func Production(model, instruction string) Config { + return Config{ + Version: Version, CodexExecutable: "codex", ExpectedCodexVersion: PinnedCodexVersion, + StrictVersion: true, Model: model, DeveloperInstruction: instruction, + MaxFrameBytes: 1 << 20, MaxStderrBytes: 64 << 10, InterruptGraceMillis: 2000, + } +} + +func Parse(data []byte) (Config, error) { + var cfg Config + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cfg); err != nil { + return Config{}, fmt.Errorf("decode config: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return Config{}, fmt.Errorf("decode config: trailing JSON value") + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func (c Config) Validate() error { + if c.Version != Version { + return fmt.Errorf("unsupported config version %d (want %d)", c.Version, Version) + } + if strings.TrimSpace(c.CodexExecutable) == "" || strings.TrimSpace(c.Model) == "" || strings.TrimSpace(c.Provider.Name) == "" { + return fmt.Errorf("codex executable, model, and provider are required") + } + if c.StrictVersion && strings.TrimSpace(c.ExpectedCodexVersion) == "" { + return fmt.Errorf("expected_codex_version is required when strict_version is enabled") + } + if c.MaxFrameBytes <= 0 || c.MaxStderrBytes <= 0 || c.InterruptGraceMillis <= 0 { + return fmt.Errorf("frame, stderr, and interrupt grace limits must be positive") + } + if c.Provider.Name != "openai" && c.Provider.Name != "amazon-bedrock" { + return fmt.Errorf("unsupported Codex provider %q", c.Provider.Name) + } + if c.Provider.BaseURL != "" { + if c.Provider.Name != "openai" { + return fmt.Errorf("base URL is supported only for the OpenAI provider") + } + if err := validateURL(c.Provider.BaseURL); err != nil { + return fmt.Errorf("invalid provider base URL: %w", err) + } + } + for name, agent := range c.Agents { + if !nativeNamePattern.MatchString(name) { + return fmt.Errorf("codex agent name %q must contain only letters, numbers, underscores, or hyphens", name) + } + if strings.TrimSpace(agent.Description) == "" || strings.TrimSpace(agent.Instruction) == "" || strings.TrimSpace(agent.Model) == "" { + return fmt.Errorf("codex agent %q requires description, instruction, and model", name) + } + } + for name, server := range c.MCPServers { + if !nativeNamePattern.MatchString(name) { + return fmt.Errorf("codex MCP server name %q must contain only letters, numbers, underscores, or hyphens", name) + } + if err := validateURL(server.URL); err != nil { + return fmt.Errorf("invalid Codex MCP server %q URL: %w", name, err) + } + seen := map[string]struct{}{} + for _, tool := range server.EnabledTools { + if strings.TrimSpace(tool) == "" { + return fmt.Errorf("codex MCP server %q has an empty enabled tool", name) + } + if _, ok := seen[tool]; ok { + return fmt.Errorf("codex MCP server %q has duplicate enabled tool %q", name, tool) + } + seen[tool] = struct{}{} + } + } + return nil +} + +func validateURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" { + return fmt.Errorf("must be an absolute HTTP(S) URL without credentials or fragment") + } + return nil +} + +func (c Config) InterruptGrace() time.Duration { + return time.Duration(c.InterruptGraceMillis) * time.Millisecond +} diff --git a/go/harness/codex/config/config_test.go b/go/harness/codex/config/config_test.go new file mode 100644 index 000000000..a35bd22a5 --- /dev/null +++ b/go/harness/codex/config/config_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestProductionRoundTrip(t *testing.T) { + cfg := Production("gpt-5.2-codex", "work carefully") + cfg.Provider = Provider{Name: "openai", BaseURL: "https://gateway.example.com/v1"} + cfg.Agents = map[string]Agent{"reviewer": {Description: "Reviews", Instruction: "Review", Model: "gpt-5.2-codex"}} + cfg.MCPServers = map[string]MCPServer{"tools": {URL: "https://mcp.example.com/mcp", EnabledTools: []string{"read"}}} + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + parsed, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + if parsed.ExpectedCodexVersion != PinnedCodexVersion || parsed.Provider.Name != "openai" { + t.Fatalf("parsed config = %#v", parsed) + } +} + +func TestParseRejectsUnsafeConfiguration(t *testing.T) { + base := Production("model", "instruction") + base.Provider = Provider{Name: "openai"} + tests := []struct { + name string + mutate func(*Config) + want string + }{ + {"version", func(c *Config) { c.Version++ }, "unsupported config version"}, + {"provider", func(c *Config) { c.Provider.Name = "other" }, "unsupported Codex provider"}, + {"URL", func(c *Config) { c.Provider.BaseURL = "https://user:pass@example.com" }, "invalid provider base URL"}, + {"agent name", func(c *Config) { + c.Agents = map[string]Agent{"../bad": {Description: "x", Instruction: "x", Model: "x"}} + }, "agent name"}, + {"duplicate tool", func(c *Config) { + c.MCPServers = map[string]MCPServer{"mcp": {URL: "https://example.com", EnabledTools: []string{"x", "x"}}} + }, "duplicate enabled tool"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := base + test.mutate(&cfg) + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(raw); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Parse() error = %v, want %q", err, test.want) + } + }) + } + if _, err := Parse([]byte(`{"version":1,"unknown":true}`)); err == nil { + t.Fatal("Parse() accepted an unknown field") + } +} diff --git a/go/harness/codex/internal/adapter/adapter.go b/go/harness/codex/internal/adapter/adapter.go new file mode 100644 index 000000000..590ba6a29 --- /dev/null +++ b/go/harness/codex/internal/adapter/adapter.go @@ -0,0 +1,224 @@ +// Package adapter validates and materializes compiler-owned Codex state. +package adapter + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kagent-dev/kagent/go/core/v2/agentplugins" + "github.com/kagent-dev/kagent/go/harness/codex/config" + "github.com/kagent-dev/kagent/go/harness/codex/internal/driver" + "github.com/kagent-dev/kagent/go/harness/runtime/utils" + "github.com/pelletier/go-toml/v2" +) + +const codexHomeEnv = "CODEX_HOME" + +// Input contains compiler output and Actor-owned locations used to construct +// the Codex driver. +type Input struct { + ConfigJSON []byte + Workspace string + DurableDir string + Environment []string +} + +// New validates and materializes Codex-owned state, then constructs its driver. +func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { + cfg, err := config.Parse(input.ConfigJSON) + if err != nil { + return nil, err + } + if !filepath.IsAbs(input.Workspace) || !filepath.IsAbs(input.DurableDir) { + return nil, fmt.Errorf("workspace and durable directories must be absolute paths") + } + codexHome := filepath.Join(input.DurableDir, "codex") + for _, directory := range []string{input.Workspace, codexHome, filepath.Join(codexHome, "agents"), filepath.Join(codexHome, "skills")} { + if err := utils.EnsurePrivateDir(directory); err != nil { + return nil, fmt.Errorf("prepare Codex directory %q: %w", directory, err) + } + } + if err := reconcileGeneratedDir(filepath.Join(codexHome, "agents"), agentFileNames(cfg.Agents)); err != nil { + return nil, fmt.Errorf("reconcile Codex agents: %w", err) + } + if err := reconcileGeneratedDir(filepath.Join(codexHome, "skills"), nil); err != nil { + return nil, fmt.Errorf("reconcile Codex skills: %w", err) + } + if cfg.SkillResources != nil { + if _, err := agentplugins.Materialize(ctx, *cfg.SkillResources, agentplugins.Paths{ + Packages: filepath.Join(codexHome, "packages"), + Skills: filepath.Join(codexHome, "skills"), + }); err != nil { + return nil, fmt.Errorf("materialize Codex skills: %w", err) + } + } + if err := materializeAgents(codexHome, cfg.Agents); err != nil { + return nil, err + } + configTOML, err := renderConfig(cfg, codexHome) + if err != nil { + return nil, err + } + if err := utils.ReplacePrivateFile(filepath.Join(codexHome, "config.toml"), configTOML); err != nil { + return nil, fmt.Errorf("materialize Codex configuration: %w", err) + } + environment := setEnvironment(input.Environment, codexHomeEnv, codexHome) + return driver.NewProcessDriver(driver.ProcessConfig{ + Executable: cfg.CodexExecutable, ExpectedVersion: cfg.ExpectedCodexVersion, StrictVersion: cfg.StrictVersion, + Workspace: input.Workspace, Model: cfg.Model, Provider: nativeProviderName(cfg.Provider.Name), + DeveloperInstruction: cfg.DeveloperInstruction, Environment: environment, + MaxFrameBytes: cfg.MaxFrameBytes, MaxStderrBytes: cfg.MaxStderrBytes, InterruptGrace: cfg.InterruptGrace(), + }), nil +} + +type nativeConfig struct { + Model string `toml:"model"` + ModelProvider string `toml:"model_provider"` + ApprovalPolicy string `toml:"approval_policy"` + SandboxMode string `toml:"sandbox_mode"` + WebSearch string `toml:"web_search"` + Analytics nativeAnalytics `toml:"analytics"` + ModelProviders map[string]nativeModelProvider `toml:"model_providers,omitempty"` + Agents map[string]nativeAgent `toml:"agents,omitempty"` + MCPServers map[string]nativeMCPServer `toml:"mcp_servers,omitempty"` +} + +type nativeAnalytics struct { + Enabled bool `toml:"enabled"` +} + +type nativeModelProvider struct { + Name string `toml:"name"` + WireAPI string `toml:"wire_api"` + EnvKey string `toml:"env_key"` + BaseURL string `toml:"base_url,omitempty"` +} + +type nativeAgent struct { + Description string `toml:"description"` + ConfigFile string `toml:"config_file"` +} + +type nativeMCPServer struct { + URL string `toml:"url"` + HTTPHeaders map[string]string `toml:"http_headers,omitempty,inline"` + EnvHTTPHeaders map[string]string `toml:"env_http_headers,omitempty,inline"` + EnabledTools []string `toml:"enabled_tools,omitempty"` +} + +type nativeAgentConfig struct { + Model string `toml:"model"` + DeveloperInstructions string `toml:"developer_instructions"` +} + +// renderConfig translates the versioned Kagent contract into the pinned +// Codex CLI's native TOML without copying credential values into durable state. +func renderConfig(cfg config.Config, codexHome string) ([]byte, error) { + native := nativeConfig{ + Model: cfg.Model, ModelProvider: nativeProviderName(cfg.Provider.Name), + ApprovalPolicy: "never", SandboxMode: "danger-full-access", WebSearch: "disabled", + Analytics: nativeAnalytics{Enabled: false}, + Agents: make(map[string]nativeAgent, len(cfg.Agents)), + MCPServers: make(map[string]nativeMCPServer, len(cfg.MCPServers)), + } + if cfg.Provider.Name == "openai" { + native.ModelProviders = map[string]nativeModelProvider{ + "kagent-openai": { + Name: "OpenAI", WireAPI: "responses", EnvKey: "OPENAI_API_KEY", BaseURL: cfg.Provider.BaseURL, + }, + } + } + for name, agent := range cfg.Agents { + native.Agents[name] = nativeAgent{ + Description: agent.Description, + ConfigFile: filepath.Join(codexHome, "agents", name+".toml"), + } + } + for name, server := range cfg.MCPServers { + literal, environment := map[string]string{}, map[string]string{} + for header, value := range server.Headers { + if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") { + environment[header] = strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}") + } else { + literal[header] = value + } + } + native.MCPServers[name] = nativeMCPServer{ + URL: server.URL, HTTPHeaders: literal, EnvHTTPHeaders: environment, EnabledTools: server.EnabledTools, + } + } + contents, err := toml.Marshal(native) + if err != nil { + return nil, fmt.Errorf("encode Codex configuration: %w", err) + } + return contents, nil +} + +func nativeProviderName(name string) string { + if name == "openai" { + return "kagent-openai" + } + return name +} + +// materializeAgents writes the per-agent config files referenced by config.toml. +func materializeAgents(codexHome string, agents map[string]config.Agent) error { + for name, agent := range agents { + contents, err := toml.Marshal(nativeAgentConfig{Model: agent.Model, DeveloperInstructions: agent.Instruction}) + if err != nil { + return fmt.Errorf("encode Codex agent %q configuration: %w", name, err) + } + if err := utils.ReplacePrivateFile(filepath.Join(codexHome, "agents", name+".toml"), contents); err != nil { + return fmt.Errorf("materialize Codex agent %q: %w", name, err) + } + } + return nil +} + +func agentFileNames(agents map[string]config.Agent) map[string]struct{} { + files := make(map[string]struct{}, len(agents)) + for name := range agents { + files[name+".toml"] = struct{}{} + } + return files +} + +// reconcileGeneratedDir removes compiler-owned files that are no longer in the +// desired config. Symlinks are rejected so cleanup cannot escape the directory. +func reconcileGeneratedDir(directory string, keep map[string]struct{}) error { + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + for _, entry := range entries { + if _, ok := keep[entry.Name()]; ok { + continue + } + path := filepath.Join(directory, entry.Name()) + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("generated path %q is a symlink", path) + } + if err := os.RemoveAll(path); err != nil { + return err + } + } + return nil +} + +func setEnvironment(environment []string, name, value string) []string { + prefix := name + "=" + result := make([]string, 0, len(environment)+1) + for _, item := range environment { + if !strings.HasPrefix(item, prefix) { + result = append(result, item) + } + } + return append(result, prefix+value) +} diff --git a/go/harness/codex/internal/adapter/adapter_test.go b/go/harness/codex/internal/adapter/adapter_test.go new file mode 100644 index 000000000..c77b145bf --- /dev/null +++ b/go/harness/codex/internal/adapter/adapter_test.go @@ -0,0 +1,163 @@ +package adapter + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/harness/codex/config" + "github.com/pelletier/go-toml/v2" +) + +func TestNewMaterializesCompilerOwnedConfiguration(t *testing.T) { + durable := filepath.Join(t.TempDir(), "data") + cfg := config.Production("gpt-5.2-codex", "line one\nline two") + cfg.Provider = config.Provider{Name: "openai", BaseURL: "https://gateway.example.com/v1"} + cfg.Agents = map[string]config.Agent{"reviewer": {Description: "Review carefully", Instruction: "Inspect \"all\" changes", Model: "gpt-5.2-codex"}} + cfg.MCPServers = map[string]config.MCPServer{"tools": { + URL: "https://mcp.example.com/mcp", Headers: map[string]string{"X-Tenant": "test", "Authorization": "${KAGENT_CODEX_MCP_CREDENTIAL_ABC}"}, EnabledTools: []string{"read"}, + }} + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + runner, err := New(context.Background(), Input{ConfigJSON: raw, Workspace: filepath.Join(durable, "workspace"), DurableDir: durable, Environment: []string{"PATH=/bin", "CODEX_HOME=/wrong"}}) + if err != nil { + t.Fatal(err) + } + if runner == nil { + t.Fatal("New() returned nil") + } + codexHome := filepath.Join(durable, "codex") + configPath := filepath.Join(codexHome, "config.toml") + agentPath := filepath.Join(codexHome, "agents", "reviewer.toml") + for _, path := range []string{configPath, agentPath} { + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("%s permissions = %v, %v", path, info, err) + } + } + configContents, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var native nativeConfig + if err := toml.Unmarshal(configContents, &native); err != nil { + t.Fatalf("decode generated Codex configuration: %v", err) + } + provider := native.ModelProviders["kagent-openai"] + server := native.MCPServers["tools"] + agent := native.Agents["reviewer"] + if native.ModelProvider != "kagent-openai" || provider.WireAPI != "responses" || provider.EnvKey != "OPENAI_API_KEY" || provider.BaseURL != cfg.Provider.BaseURL { + t.Fatalf("generated provider configuration = %#v, provider name = %q", provider, native.ModelProvider) + } + if server.EnvHTTPHeaders["Authorization"] != "KAGENT_CODEX_MCP_CREDENTIAL_ABC" || server.HTTPHeaders["X-Tenant"] != "test" || len(server.EnabledTools) != 1 || server.EnabledTools[0] != "read" { + t.Fatalf("generated MCP server configuration = %#v", server) + } + if agent.Description != "Review carefully" || agent.ConfigFile != agentPath { + t.Fatalf("generated agent registration = %#v", agent) + } + agentContents, err := os.ReadFile(agentPath) + if err != nil { + t.Fatal(err) + } + var agentConfig nativeAgentConfig + if err := toml.Unmarshal(agentContents, &agentConfig); err != nil { + t.Fatalf("decode generated Codex agent configuration: %v", err) + } + if agentConfig.Model != "gpt-5.2-codex" || agentConfig.DeveloperInstructions != `Inspect "all" changes` { + t.Fatalf("generated Codex agent configuration = %#v", agentConfig) + } +} + +func TestNewRejectsSymlinkedCodexHome(t *testing.T) { + durable := t.TempDir() + if err := os.Symlink(t.TempDir(), filepath.Join(durable, "codex")); err != nil { + t.Fatal(err) + } + cfg := config.Production("model", "instruction") + cfg.Provider = config.Provider{Name: "openai"} + raw, _ := json.Marshal(cfg) + if _, err := New(context.Background(), Input{ConfigJSON: raw, Workspace: filepath.Join(durable, "workspace"), DurableDir: durable}); err == nil { + t.Fatal("New() accepted symlinked Codex home") + } +} + +func TestPinnedCodexAcceptsGeneratedConfiguration(t *testing.T) { + executable, err := exec.LookPath("codex") + if err != nil { + t.Skip("pinned Codex CLI is not installed") + } + tests := []struct { + name string + provider config.Provider + }{ + {name: "OpenAI", provider: config.Provider{Name: "openai"}}, + {name: "OpenAI gateway", provider: config.Provider{Name: "openai", BaseURL: "https://gateway.example.com/v1"}}, + {name: "Bedrock", provider: config.Provider{Name: "amazon-bedrock"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertPinnedCodexAcceptsConfig(t, executable, test.provider) + }) + } +} + +func assertPinnedCodexAcceptsConfig(t *testing.T, executable string, provider config.Provider) { + t.Helper() + durable := filepath.Join(t.TempDir(), "data") + cfg := config.Production("gpt-5.2-codex", "work carefully") + cfg.Provider = provider + cfg.Agents = map[string]config.Agent{"reviewer": {Description: "Reviews", Instruction: "Review", Model: "gpt-5.2-codex"}} + cfg.MCPServers = map[string]config.MCPServer{"tools": {URL: "https://mcp.example.com/mcp", EnabledTools: []string{"read"}}} + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if _, err := New(context.Background(), Input{ConfigJSON: raw, Workspace: filepath.Join(durable, "workspace"), DurableDir: durable, Environment: os.Environ()}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable, "app-server", "--strict-config", "--stdio") + command.Env = append(os.Environ(), "CODEX_HOME="+filepath.Join(durable, "codex"), "OPENAI_API_KEY=test", "AWS_REGION=us-east-1", "AWS_BEARER_TOKEN_BEDROCK=test") + stdin, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr strings.Builder + command.Stderr = &stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + if _, err := stdin.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"kagent-test","version":"1"}}}` + "\n")); err != nil { + t.Fatal(err) + } + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil { + t.Fatalf("initialize generated Codex config: %v: %s", err, stderr.String()) + } + var response struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal([]byte(line), &response); err != nil { + t.Fatal(err) + } + if response.ID != 1 || len(response.Error) != 0 || len(response.Result) == 0 { + t.Fatalf("initialize response = %s, stderr = %s", line, stderr.String()) + } + _ = stdin.Close() + _ = command.Process.Kill() + _ = command.Wait() +} diff --git a/go/harness/codex/internal/driver/events.go b/go/harness/codex/internal/driver/events.go new file mode 100644 index 000000000..1fcb33c19 --- /dev/null +++ b/go/harness/codex/internal/driver/events.go @@ -0,0 +1,202 @@ +package driver + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +// eventTranslator validates one turn's native identities and normalizes the +// pinned App Server notification vocabulary into runtime events. +type eventTranslator struct { + threadID string + turnID string + tools map[string]string +} + +func newEventTranslator(threadID, turnID string) *eventTranslator { + return &eventTranslator{threadID: threadID, turnID: turnID, tools: make(map[string]string)} +} + +func (t *eventTranslator) translate(message rpcMessage, sink runtime.EventSink) (runtime.Outcome, bool, error) { + switch message.Method { + case "item/agentMessage/delta": + var params struct{ ThreadID, TurnID, ItemID, Delta string } + if err := json.Unmarshal(message.Params, ¶ms); err != nil { + return runtime.Outcome{}, false, fmt.Errorf("decode Codex text delta: %w", err) + } + if params.ThreadID != t.threadID || params.TurnID != t.turnID || params.ItemID == "" { + return runtime.Outcome{}, false, fmt.Errorf("codex text delta has mismatched identity") + } + return runtime.Outcome{}, false, sink.TextDelta(runtime.TextDelta{Text: params.Delta}) + case "item/started", "item/completed": + var params struct { + ThreadID, TurnID string + Item json.RawMessage + } + if err := json.Unmarshal(message.Params, ¶ms); err != nil { + return runtime.Outcome{}, false, fmt.Errorf("decode Codex item event: %w", err) + } + if params.ThreadID != t.threadID || params.TurnID != t.turnID { + return runtime.Outcome{}, false, fmt.Errorf("codex item event has mismatched identity") + } + if err := t.translateItem(message.Method == "item/completed", params.Item, sink); err != nil { + return runtime.Outcome{}, false, err + } + return runtime.Outcome{}, false, nil + case "turn/completed": + var params struct { + ThreadID string + Turn struct { + ID, Status string + Error *struct{ Message string } + } + } + if err := json.Unmarshal(message.Params, ¶ms); err != nil { + return runtime.Outcome{}, false, fmt.Errorf("decode Codex terminal event: %w", err) + } + if params.ThreadID != t.threadID || params.Turn.ID != t.turnID { + return runtime.Outcome{}, false, fmt.Errorf("codex terminal event has mismatched identity") + } + if err := t.closeActiveTools(sink); err != nil { + return runtime.Outcome{}, false, err + } + switch params.Turn.Status { + case "completed": + return runtime.Outcome{}, true, nil + case "interrupted": + return runtime.Outcome{Failure: &runtime.Failure{Message: "Codex execution was interrupted"}}, true, nil + case "failed": + return runtime.Outcome{Failure: &runtime.Failure{Message: "Codex execution failed"}}, true, nil + default: + return runtime.Outcome{}, false, fmt.Errorf("unsupported Codex terminal status %q", params.Turn.Status) + } + default: + // The pinned protocol is explicitly additive. Notifications unrelated to + // the public text/tool/terminal contract are safe to ignore. + return runtime.Outcome{}, false, nil + } +} + +func (t *eventTranslator) translateItem(completed bool, raw json.RawMessage, sink runtime.EventSink) error { + var item struct { + Type string `json:"type"` + ID string `json:"id"` + Command string `json:"command"` + CWD string `json:"cwd"` + AggregatedOutput string `json:"aggregatedOutput"` + ExitCode *int `json:"exitCode"` + Changes any `json:"changes"` + Server string `json:"server"` + Tool string `json:"tool"` + Arguments any `json:"arguments"` + Result any `json:"result"` + Error any `json:"error"` + Prompt string `json:"prompt"` + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &item); err != nil { + return fmt.Errorf("decode Codex thread item: %w", err) + } + if item.ID == "" { + return nil + } + name := "" + var arguments map[string]any + result := map[string]any{"status": item.Status} + switch item.Type { + case "commandExecution": + name, arguments = "command_execution", map[string]any{"command": bounded(item.Command), "cwd": bounded(item.CWD)} + result["output"] = bounded(item.AggregatedOutput) + if item.ExitCode != nil { + result["exitCode"] = *item.ExitCode + } + case "fileChange": + name, arguments = "file_change", map[string]any{"changes": boundValue(item.Changes)} + result["changes"] = boundValue(item.Changes) + case "mcpToolCall": + name, arguments = item.Server+"."+item.Tool, map[string]any{"arguments": boundValue(item.Arguments)} + result["result"], result["error"] = boundValue(item.Result), boundValue(item.Error) + case "collabAgentToolCall": + name, arguments = "Agent", map[string]any{"prompt": bounded(item.Prompt), "tool": item.Tool} + default: + return nil + } + if !completed { + if _, exists := t.tools[item.ID]; exists { + return fmt.Errorf("codex tool item %q started more than once", item.ID) + } + t.tools[item.ID] = name + return sink.ToolCall(runtime.ToolCall{ID: item.ID, Name: name, Arguments: arguments}) + } + startedName, exists := t.tools[item.ID] + if !exists { + return fmt.Errorf("codex tool item %q completed without starting", item.ID) + } + if startedName != name { + return fmt.Errorf("codex tool item %q changed name from %q to %q", item.ID, startedName, name) + } + delete(t.tools, item.ID) + return sink.ToolResult(runtime.ToolResult{ID: item.ID, Name: name, Result: result, IsError: item.Status == "failed"}) +} + +func (t *eventTranslator) closeActiveTools(sink runtime.EventSink) error { + ids := make([]string, 0, len(t.tools)) + for id := range t.tools { + ids = append(ids, id) + } + slices.Sort(ids) + for _, id := range ids { + if err := sink.ToolResult(runtime.ToolResult{ + ID: id, Name: t.tools[id], Result: map[string]any{"error": "Codex turn ended before tool completion"}, IsError: true, + }); err != nil { + return err + } + delete(t.tools, id) + } + return nil +} + +func rejectBufferedPostTerminalActivity(frames <-chan rpcFrame) error { + for { + select { + case frame, ok := <-frames: + if !ok { + return nil + } + if frame.err != nil { + return frame.err + } + if frame.message.Method == "turn/completed" { + return fmt.Errorf("codex emitted duplicate terminal event") + } + if strings.HasPrefix(frame.message.Method, "item/") || strings.HasPrefix(frame.message.Method, "turn/") { + return fmt.Errorf("codex emitted activity after its terminal event") + } + default: + return nil + } + } +} + +func boundValue(value any) any { + raw, err := json.Marshal(value) + if err != nil { + return map[string]any{"omitted": "unencodable payload"} + } + if len(raw) <= 16<<10 { + return value + } + return map[string]any{"omitted": "payload exceeded 16384 bytes"} +} + +func bounded(value string) string { + const limit = 16 << 10 + if len(value) <= limit { + return value + } + return value[:limit] + "…" +} diff --git a/go/harness/codex/internal/driver/events_test.go b/go/harness/codex/internal/driver/events_test.go new file mode 100644 index 000000000..72f02a2cd --- /dev/null +++ b/go/harness/codex/internal/driver/events_test.go @@ -0,0 +1,70 @@ +package driver + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +type recordingSink struct { + text strings.Builder + sessions []runtime.SessionStarted + calls []runtime.ToolCall + results []runtime.ToolResult +} + +func (s *recordingSink) SessionStarted(event runtime.SessionStarted) error { + s.sessions = append(s.sessions, event) + return nil +} +func (s *recordingSink) TextDelta(event runtime.TextDelta) error { + s.text.WriteString(event.Text) + return nil +} +func (s *recordingSink) ToolCall(event runtime.ToolCall) error { + s.calls = append(s.calls, event) + return nil +} +func (s *recordingSink) ToolResult(event runtime.ToolResult) error { + s.results = append(s.results, event) + return nil +} + +func TestTranslatePinnedNotifications(t *testing.T) { + sink := &recordingSink{} + messages := []string{ + `{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thread","turnId":"turn","itemId":"message","delta":"hello"}}`, + `{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thread","turnId":"turn","item":{"type":"commandExecution","id":"cmd","command":"pwd","commandActions":[],"cwd":"/data/workspace","status":"inProgress"}}}`, + `{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread","turnId":"turn","item":{"type":"commandExecution","id":"cmd","command":"pwd","commandActions":[],"cwd":"/data/workspace","aggregatedOutput":"/data/workspace","exitCode":0,"status":"completed"}}}`, + `{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thread","turnId":"turn","item":{"type":"mcpToolCall","id":"mcp","server":"tools","tool":"lookup","arguments":{"query":"safe"},"status":"inProgress"}}}`, + `{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread","turnId":"turn","item":{"type":"mcpToolCall","id":"mcp","server":"tools","tool":"lookup","arguments":{"query":"safe"},"result":{"content":"ok"},"status":"completed"}}}`, + `{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread","turn":{"id":"turn","status":"completed"}}}`, + } + terminal := 0 + translator := newEventTranslator("thread", "turn") + for _, raw := range messages { + var message rpcMessage + if err := json.Unmarshal([]byte(raw), &message); err != nil { + t.Fatal(err) + } + _, done, err := translator.translate(message, sink) + if err != nil { + t.Fatal(err) + } + if done { + terminal++ + } + } + if sink.text.String() != "hello" || len(sink.calls) != 2 || len(sink.results) != 2 || terminal != 1 { + t.Fatalf("sink = %#v, text %q, terminal %d", sink, sink.text.String(), terminal) + } + result, ok := sink.results[0].Result.(map[string]any) + if !ok || result["exitCode"] != 0 { + t.Fatalf("command result = %#v, want integer exitCode 0", sink.results[0].Result) + } + if sink.calls[1].Name != "tools.lookup" || sink.results[1].Name != "tools.lookup" { + t.Fatalf("MCP events = %#v, %#v, want tools.lookup", sink.calls[1], sink.results[1]) + } +} diff --git a/go/harness/codex/internal/driver/process.go b/go/harness/codex/internal/driver/process.go new file mode 100644 index 000000000..0185c9b2a --- /dev/null +++ b/go/harness/codex/internal/driver/process.go @@ -0,0 +1,224 @@ +// Package driver translates the Codex App Server protocol into the +// runtime-neutral events consumed by the shared A2A executor. +package driver + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/harness/runtime" + "github.com/kagent-dev/kagent/go/harness/runtime/utils" +) + +// ProcessConfig contains validated, compiler-owned inputs for one Codex App +// Server process. Actor-owned paths and environment are supplied by the adapter. +type ProcessConfig struct { + Executable string + ExpectedVersion string + StrictVersion bool + Workspace string + Model string + Provider string + DeveloperInstruction string + Environment []string + MaxFrameBytes int + MaxStderrBytes int + InterruptGrace time.Duration +} + +// ProcessDriver supervises one Codex App Server process per runtime turn. +type ProcessDriver struct{ config ProcessConfig } + +// NewProcessDriver constructs a Codex process driver. +func NewProcessDriver(config ProcessConfig) *ProcessDriver { return &ProcessDriver{config: config} } + +// Validate checks that the configured executable is the pinned Codex version. +func (d *ProcessDriver) Validate(ctx context.Context) error { + executable, err := exec.LookPath(d.config.Executable) + if err != nil { + return fmt.Errorf("find Codex executable %q: %w", d.config.Executable, err) + } + output, err := exec.CommandContext(ctx, executable, "--version").Output() + if err != nil { + return fmt.Errorf("read Codex version: %w", err) + } + version := strings.TrimSpace(string(output)) + if d.config.StrictVersion && version != "codex-cli "+d.config.ExpectedVersion { + return fmt.Errorf("codex version mismatch: got %q, expected %q", version, "codex-cli "+d.config.ExpectedVersion) + } + return nil +} + +// Run initializes App Server, starts or resumes the Actor's native thread, and +// emits the turn's ordered runtime events. +func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { + if strings.TrimSpace(turn.Prompt) == "" { + return runtime.Outcome{}, fmt.Errorf("codex prompt is required") + } + if err := rejectWorkspaceConfig(d.config.Workspace); err != nil { + return runtime.Outcome{}, err + } + executable, err := exec.LookPath(d.config.Executable) + if err != nil { + return runtime.Outcome{}, fmt.Errorf("find Codex executable %q: %w", d.config.Executable, err) + } + command := exec.Command(executable, "app-server", "--strict-config", "--stdio") + command.Dir, command.Env = d.config.Workspace, append([]string(nil), d.config.Environment...) + utils.ConfigureProcessGroup(command) + stdin, err := command.StdinPipe() + if err != nil { + return runtime.Outcome{}, fmt.Errorf("open Codex stdin: %w", err) + } + stdout, err := command.StdoutPipe() + if err != nil { + return runtime.Outcome{}, fmt.Errorf("open Codex stdout: %w", err) + } + stderr := utils.NewBoundedBuffer(d.config.MaxStderrBytes) + command.Stderr = stderr + if err := command.Start(); err != nil { + return runtime.Outcome{}, fmt.Errorf("start Codex App Server: %w", err) + } + wait := make(chan error, 1) + go func() { wait <- command.Wait(); close(wait) }() + client := newRPCClient(stdin, stdout, d.config.MaxFrameBytes) + defer func() { + _ = stdin.Close() + _ = utils.TerminateProcessGroup(command.Process) + select { + case <-wait: + case <-time.After(d.config.InterruptGrace): + _ = utils.KillProcessGroup(command.Process) + <-wait + } + }() + + if _, err := client.call(ctx, 1, "initialize", map[string]any{"clientInfo": map[string]string{"name": "kagent-codex", "version": "1"}}); err != nil { + return runtime.Outcome{}, d.protocolError(err, stderr) + } + if err := client.notify("initialized", map[string]any{}); err != nil { + return runtime.Outcome{}, d.protocolError(err, stderr) + } + threadID := turn.ContinuationID + if threadID == "" { + result, err := client.call(ctx, 2, "thread/start", map[string]any{ + "cwd": d.config.Workspace, "model": d.config.Model, "modelProvider": d.config.Provider, + "approvalPolicy": "never", "sandbox": "danger-full-access", "developerInstructions": d.config.DeveloperInstruction, + }) + if err != nil { + return runtime.Outcome{}, d.protocolError(err, stderr) + } + threadID, err = responseThreadID(result) + if err != nil { + return runtime.Outcome{}, err + } + } else { + result, err := client.call(ctx, 2, "thread/resume", map[string]any{ + "threadId": threadID, "cwd": d.config.Workspace, "model": d.config.Model, "modelProvider": d.config.Provider, + "approvalPolicy": "never", "sandbox": "danger-full-access", "developerInstructions": d.config.DeveloperInstruction, + }) + if err != nil { + return runtime.Outcome{}, d.protocolError(err, stderr) + } + resumedID, err := responseThreadID(result) + if err != nil { + return runtime.Outcome{}, err + } + if resumedID != threadID { + return runtime.Outcome{}, fmt.Errorf("codex resumed unexpected thread %q", resumedID) + } + } + if err := sink.SessionStarted(runtime.SessionStarted{ContinuationID: threadID}); err != nil { + return runtime.Outcome{}, err + } + result, err := client.call(ctx, 3, "turn/start", map[string]any{ + "threadId": threadID, "input": []map[string]any{{"type": "text", "text": turn.Prompt}}, + }) + if err != nil { + return runtime.Outcome{}, d.protocolError(err, stderr) + } + turnID, err := responseTurnID(result) + if err != nil { + return runtime.Outcome{}, err + } + return d.consume(ctx, client, command, wait, stderr, newEventTranslator(threadID, turnID), sink) +} + +func (d *ProcessDriver) consume(ctx context.Context, client *rpcClient, command *exec.Cmd, wait <-chan error, stderr *utils.BoundedBuffer, translator *eventTranslator, sink runtime.EventSink) (runtime.Outcome, error) { + for { + select { + case <-ctx.Done(): + interruptCtx, cancel := context.WithTimeout(context.Background(), d.config.InterruptGrace) + _, err := client.call(interruptCtx, 4, "turn/interrupt", map[string]string{"threadId": translator.threadID, "turnId": translator.turnID}) + cancel() + if err != nil { + _ = utils.KillProcessGroup(command.Process) + } else { + _ = utils.TerminateProcessGroup(command.Process) + } + select { + case <-wait: + case <-time.After(d.config.InterruptGrace): + _ = utils.KillProcessGroup(command.Process) + <-wait + } + return runtime.Outcome{}, ctx.Err() + case waitErr := <-wait: + if waitErr != nil { + return runtime.Outcome{}, fmt.Errorf("codex App Server exited: %w: %s", waitErr, strings.TrimSpace(stderr.String())) + } + return runtime.Outcome{}, fmt.Errorf("codex App Server exited without a terminal event") + case frame, ok := <-client.frames: + if !ok { + return runtime.Outcome{}, fmt.Errorf("codex protocol stream closed without a terminal event") + } + if frame.err != nil { + return runtime.Outcome{}, frame.err + } + message := frame.message + if message.Method == "" { + continue + } + if len(message.ID) != 0 { + return runtime.Outcome{}, fmt.Errorf("unsupported Codex server request %q", message.Method) + } + outcome, done, err := translator.translate(message, sink) + if err != nil { + return runtime.Outcome{}, err + } + if done { + if err := rejectBufferedPostTerminalActivity(client.frames); err != nil { + return runtime.Outcome{}, err + } + return outcome, nil + } + } + } +} + +func rejectWorkspaceConfig(workspace string) error { + path := filepath.Join(workspace, ".codex", "config.toml") + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect workspace Codex configuration: %w", err) + } + if info != nil { + return fmt.Errorf("workspace Codex configuration %q is not allowed", path) + } + return nil +} + +func (d *ProcessDriver) protocolError(err error, stderr *utils.BoundedBuffer) error { + message := strings.TrimSpace(stderr.String()) + if message == "" { + return err + } + return fmt.Errorf("%w: %s", err, message) +} diff --git a/go/harness/codex/internal/driver/process_test.go b/go/harness/codex/internal/driver/process_test.go new file mode 100644 index 000000000..5d7880437 --- /dev/null +++ b/go/harness/codex/internal/driver/process_test.go @@ -0,0 +1,97 @@ +package driver + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +func TestProcessDriverRunsPinnedProtocol(t *testing.T) { + directory := t.TempDir() + executable := filepath.Join(directory, "codex") + capture := filepath.Join(directory, "requests.jsonl") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "codex-cli 0.148.0" + exit 0 +fi +read initialize +printf '%s\n' "$initialize" >> "$CAPTURE" +printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"codex-app-server","version":"0.148.0"}}}' +read initialized +printf '%s\n' "$initialized" >> "$CAPTURE" +read thread +printf '%s\n' "$thread" >> "$CAPTURE" +printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"01900000-0000-7000-8000-000000000001"}}}' +read turn +printf '%s\n' "$turn" >> "$CAPTURE" +printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"turn":{"id":"01900000-0000-7000-8000-000000000002","status":"inProgress"}}}' +printf '%s\n' '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"01900000-0000-7000-8000-000000000001","turnId":"01900000-0000-7000-8000-000000000002","itemId":"message-1","delta":"hello"}}' +printf '%s\n' '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"01900000-0000-7000-8000-000000000001","turn":{"id":"01900000-0000-7000-8000-000000000002","status":"completed"}}}' +sleep 5 +` + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(directory, "workspace") + if err := os.Mkdir(workspace, 0o700); err != nil { + t.Fatal(err) + } + driver := NewProcessDriver(ProcessConfig{ + Executable: executable, ExpectedVersion: "0.148.0", StrictVersion: true, Workspace: workspace, + Model: "gpt-5.2-codex", Provider: "kagent-openai", DeveloperInstruction: "help", + Environment: append(os.Environ(), "CAPTURE="+capture), MaxFrameBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: 100 * time.Millisecond, + }) + if err := driver.Validate(context.Background()); err != nil { + t.Fatal(err) + } + sink := &recordingSink{} + outcome, err := driver.Run(context.Background(), runtime.Turn{Prompt: "say hello"}, sink) + if err != nil { + t.Fatal(err) + } + if outcome.Failure != nil || sink.text.String() != "hello" || len(sink.sessions) != 1 || sink.sessions[0].ContinuationID != "01900000-0000-7000-8000-000000000001" { + t.Fatalf("outcome = %#v, sink = %#v, text = %q", outcome, sink, sink.text.String()) + } + requests, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + for _, fragment := range []string{`"method":"initialize"`, `"method":"initialized"`, `"method":"thread/start"`, `"approvalPolicy":"never"`, `"sandbox":"danger-full-access"`, `"method":"turn/start"`, `"text":"say hello"`} { + if !bytes.Contains(requests, []byte(fragment)) { + t.Errorf("requests omit %s:\n%s", fragment, requests) + } + } + resumed := &recordingSink{} + if _, err := driver.Run(context.Background(), runtime.Turn{Prompt: "resume", ContinuationID: "01900000-0000-7000-8000-000000000001"}, resumed); err != nil { + t.Fatal(err) + } + requests, err = os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(requests, []byte(`"method":"thread/resume"`)) || !bytes.Contains(requests, []byte(`"threadId":"01900000-0000-7000-8000-000000000001"`)) { + t.Fatalf("resume did not select the exact native thread:\n%s", requests) + } +} + +func TestProcessDriverRejectsWorkspaceConfiguration(t *testing.T) { + workspace := t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, ".codex"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, ".codex", "config.toml"), []byte("model = \"injected\""), 0o600); err != nil { + t.Fatal(err) + } + driver := NewProcessDriver(ProcessConfig{Workspace: workspace}) + _, err := driver.Run(context.Background(), runtime.Turn{Prompt: "hello"}, &recordingSink{}) + if err == nil || !strings.Contains(err.Error(), "workspace Codex configuration") { + t.Fatalf("Run() error = %v", err) + } +} diff --git a/go/harness/codex/internal/driver/rpc.go b/go/harness/codex/internal/driver/rpc.go new file mode 100644 index 000000000..bc3399fcb --- /dev/null +++ b/go/harness/codex/internal/driver/rpc.go @@ -0,0 +1,169 @@ +package driver + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sync" +) + +type rpcMessage struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type rpcFrame struct { + message rpcMessage + err error +} + +// rpcClient owns newline-delimited JSON-RPC framing for one App Server +// process. App Server notifications share the same stream as call responses. +type rpcClient struct { + writer io.WriteCloser + writeMu sync.Mutex + frames chan rpcFrame +} + +func newRPCClient(writer io.WriteCloser, reader io.Reader, maxFrameBytes int) *rpcClient { + client := &rpcClient{writer: writer, frames: make(chan rpcFrame, 16)} + go client.read(reader, maxFrameBytes) + return client +} + +func (c *rpcClient) read(reader io.Reader, max int) { + defer close(c.frames) + buffered := bufio.NewReaderSize(reader, max+1) + for { + line, err := buffered.ReadSlice('\n') + if errors.Is(err, bufio.ErrBufferFull) { + c.frames <- rpcFrame{err: fmt.Errorf("codex frame exceeds %d bytes", max)} + return + } + if len(line) > max { + c.frames <- rpcFrame{err: fmt.Errorf("codex frame exceeds %d bytes", max)} + return + } + if len(bytes.TrimSpace(line)) != 0 { + var message rpcMessage + if decodeErr := json.Unmarshal(line, &message); decodeErr != nil { + c.frames <- rpcFrame{err: fmt.Errorf("decode Codex JSON-RPC frame: %w", decodeErr)} + return + } + // The pinned App Server has omitted jsonrpc on responses in some + // releases, so accept an absent version but reject an incompatible one. + if message.JSONRPC != "" && message.JSONRPC != "2.0" { + c.frames <- rpcFrame{err: fmt.Errorf("unsupported Codex JSON-RPC version %q", message.JSONRPC)} + return + } + c.frames <- rpcFrame{message: message} + } + if errors.Is(err, io.EOF) { + return + } + if err != nil { + c.frames <- rpcFrame{err: fmt.Errorf("read Codex JSON-RPC frame: %w", err)} + return + } + } +} + +func (c *rpcClient) call(ctx context.Context, id int, method string, params any) (json.RawMessage, error) { + if err := c.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage(fmt.Sprintf("%d", id)), Method: method}, params); err != nil { + return nil, err + } + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case frame, ok := <-c.frames: + if !ok { + return nil, fmt.Errorf("codex protocol stream closed while awaiting %s", method) + } + if frame.err != nil { + return nil, frame.err + } + message := frame.message + if message.Method != "" { + if len(message.ID) != 0 { + return nil, fmt.Errorf("unsupported Codex server request %q", message.Method) + } + // Initialization calls can receive additive notifications before + // their response. Turn notifications are consumed after turn/start. + continue + } + if string(message.ID) != fmt.Sprintf("%d", id) { + return nil, fmt.Errorf("unexpected Codex response ID %s", message.ID) + } + if message.Error != nil { + return nil, fmt.Errorf("codex %s failed: %s", method, bounded(message.Error.Message)) + } + return message.Result, nil + } + } +} + +func (c *rpcClient) notify(method string, params any) error { + return c.write(rpcMessage{JSONRPC: "2.0", Method: method}, params) +} + +func (c *rpcClient) write(message rpcMessage, params any) error { + if params != nil { + raw, err := json.Marshal(params) + if err != nil { + return err + } + message.Params = raw + } + data, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("encode Codex JSON-RPC frame: %w", err) + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + if _, err := c.writer.Write(append(data, '\n')); err != nil { + return fmt.Errorf("write Codex JSON-RPC frame: %w", err) + } + return nil +} + +func responseThreadID(raw json.RawMessage) (string, error) { + var response struct { + Thread struct { + ID string `json:"id"` + } `json:"thread"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return "", fmt.Errorf("decode Codex thread response: %w", err) + } + if response.Thread.ID == "" { + return "", fmt.Errorf("codex thread response omitted thread ID") + } + return response.Thread.ID, nil +} + +func responseTurnID(raw json.RawMessage) (string, error) { + var response struct { + Turn struct { + ID string `json:"id"` + } `json:"turn"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return "", fmt.Errorf("decode Codex turn response: %w", err) + } + if response.Turn.ID == "" { + return "", fmt.Errorf("codex turn response omitted turn ID") + } + return response.Turn.ID, nil +} diff --git a/go/harness/codex/internal/driver/rpc_test.go b/go/harness/codex/internal/driver/rpc_test.go new file mode 100644 index 000000000..9c1282e34 --- /dev/null +++ b/go/harness/codex/internal/driver/rpc_test.go @@ -0,0 +1,46 @@ +package driver + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestRPCClientRejectsOversizedAndUnexpectedRequests(t *testing.T) { + for _, test := range []struct { + name, input, want string + max int + }{ + {"oversized", strings.Repeat("x", 20) + "\n", "exceeds", 10}, + {"invalid JSON-RPC version", `{"jsonrpc":"1.0","id":1,"result":{}}` + "\n", "unsupported Codex JSON-RPC version", 1024}, + {"server request", `{"jsonrpc":"2.0","id":9,"method":"item/tool/requestUserInput","params":{}}` + "\n", "unsupported Codex server request", 1024}, + } { + t.Run(test.name, func(t *testing.T) { + client := newRPCClient(nopWriteCloser{Buffer: &bytes.Buffer{}}, strings.NewReader(test.input), test.max) + _, err := client.call(context.Background(), 1, "initialize", map[string]any{}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("call() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestRPCClientAcceptsOmittedJSONRPCVersion(t *testing.T) { + client := newRPCClient( + nopWriteCloser{Buffer: &bytes.Buffer{}}, + strings.NewReader(`{"id":1,"result":{"serverInfo":{"name":"codex-app-server"}}}`+"\n"), + 1024, + ) + result, err := client.call(context.Background(), 1, "initialize", map[string]any{}) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(result, []byte(`"name":"codex-app-server"`)) { + t.Fatalf("initialize result = %s", result) + } +} + +type nopWriteCloser struct{ *bytes.Buffer } + +func (n nopWriteCloser) Close() error { return nil } diff --git a/go/harness/codex/testdata/app-server-failure.jsonl b/go/harness/codex/testdata/app-server-failure.jsonl new file mode 100644 index 000000000..ce0d73340 --- /dev/null +++ b/go/harness/codex/testdata/app-server-failure.jsonl @@ -0,0 +1,2 @@ +{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread","turn":{"id":"turn","status":"failed","error":{"message":"scrubbed upstream failure"}}}} +{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread","turn":{"id":"turn","status":"interrupted"}}} diff --git a/go/harness/codex/testdata/app-server-success.jsonl b/go/harness/codex/testdata/app-server-success.jsonl new file mode 100644 index 000000000..2e266c34d --- /dev/null +++ b/go/harness/codex/testdata/app-server-success.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"codex-app-server","version":"0.148.0"}}} +{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"01900000-0000-7000-8000-000000000001"}}} +{"jsonrpc":"2.0","id":3,"result":{"turn":{"id":"01900000-0000-7000-8000-000000000002","status":"inProgress"}}} +{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"01900000-0000-7000-8000-000000000001","turnId":"01900000-0000-7000-8000-000000000002","itemId":"message-1","delta":"hello"}} +{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"01900000-0000-7000-8000-000000000001","turn":{"id":"01900000-0000-7000-8000-000000000002","status":"completed"}}} diff --git a/go/harness/codex/testdata/app-server-tools.jsonl b/go/harness/codex/testdata/app-server-tools.jsonl new file mode 100644 index 000000000..1e79c2028 --- /dev/null +++ b/go/harness/codex/testdata/app-server-tools.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thread","turnId":"turn","item":{"type":"commandExecution","id":"command-1","command":"pwd","commandActions":[],"cwd":"/data/workspace","status":"inProgress"}}} +{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread","turnId":"turn","item":{"type":"commandExecution","id":"command-1","command":"pwd","commandActions":[],"cwd":"/data/workspace","aggregatedOutput":"/data/workspace","exitCode":0,"status":"completed"}}} +{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thread","turnId":"turn","item":{"type":"fileChange","id":"file-1","changes":[{"path":"answer.txt","kind":"add"}],"status":"inProgress"}}} +{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread","turnId":"turn","item":{"type":"fileChange","id":"file-1","changes":[{"path":"answer.txt","kind":"add"}],"status":"completed"}}} +{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thread","turnId":"turn","item":{"type":"mcpToolCall","id":"mcp-1","server":"tools","tool":"lookup","arguments":{"query":"safe"},"status":"inProgress"}}} +{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread","turnId":"turn","item":{"type":"mcpToolCall","id":"mcp-1","server":"tools","tool":"lookup","arguments":{"query":"safe"},"result":{"content":"ok"},"status":"completed"}}} +{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thread","turnId":"turn","item":{"type":"collabAgentToolCall","id":"agent-1","tool":"spawn_agent","prompt":"review","senderThreadId":"thread","receiverThreadIds":[],"agentsStates":{},"status":"inProgress"}}} +{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread","turnId":"turn","item":{"type":"collabAgentToolCall","id":"agent-1","tool":"spawn_agent","prompt":"review","senderThreadId":"thread","receiverThreadIds":["child"],"agentsStates":{},"status":"completed"}}} diff --git a/go/harness/runtime/a2a/executor.go b/go/harness/runtime/a2a/executor.go index 615c48555..0cedeba93 100644 --- a/go/harness/runtime/a2a/executor.go +++ b/go/harness/runtime/a2a/executor.go @@ -12,6 +12,7 @@ import ( a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2asrv" + a2alog "github.com/a2aproject/a2a-go/v2/log" apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/harness/runtime" ) @@ -28,6 +29,9 @@ type ContinuationStore interface { Bind(continuationID string) error } +// Executor maps one native Harness conversation onto private A2A execution. +// Each Actor accepts only one active task so ordered native continuation and +// cancellation semantics remain unambiguous. type Executor struct { runner Runner continuation ContinuationStore @@ -56,6 +60,7 @@ var ( errYieldStopped = errors.New("A2A event consumer stopped") ) +// New constructs the shared executor used by native Harness implementations. func New(runner Runner, continuation ContinuationStore) (*Executor, error) { if runner == nil || continuation == nil { return nil, fmt.Errorf("runner and continuation store are required") @@ -63,6 +68,7 @@ func New(runner Runner, continuation ContinuationStore) (*Executor, error) { return &Executor{runner: runner, continuation: continuation}, nil } +// Execute validates and serializes one A2A request onto the native Runner. func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { return func(yield func(a2atype.Event, error) bool) { prompt, err := validateRequest(reqCtx) @@ -106,6 +112,7 @@ func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) return } if runErr != nil { + a2alog.Error(ctx, "Harness runtime execution failed", runErr) finish() message := taskMessage(reqCtx, "Harness runtime execution failed") message.SetMeta(apia2a.TimelinePositionMetadataKey, sink.nextTimelinePosition()) @@ -232,6 +239,7 @@ func taskMessage(reqCtx *a2asrv.ExecutorContext, text string) *a2atype.Message { return message } +// Cancel stops the matching active task and waits for its Runner to exit. func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { return func(yield func(a2atype.Event, error) bool) { if reqCtx == nil || reqCtx.TaskID == "" || reqCtx.ContextID == "" { diff --git a/go/harness/runtime/continuation/store.go b/go/harness/runtime/continuation/store.go new file mode 100644 index 000000000..e73796ef0 --- /dev/null +++ b/go/harness/runtime/continuation/store.go @@ -0,0 +1,95 @@ +// Package continuation persists the private native conversation identity owned +// by one Harness Actor. +package continuation + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/kagent-dev/kagent/go/harness/runtime/utils" +) + +const stateVersion = 2 + +type state struct { + Version int `json:"version"` + Runtime string `json:"runtime"` + ID string `json:"session_id,omitempty"` +} + +// Validator validates one runtime-specific opaque continuation ID. +type Validator func(string) error + +// Store is an atomic, actor-local continuation store. +type Store struct { + mu sync.RWMutex + path string + runtime string + validate Validator + data state +} + +// New loads or creates a continuation store for runtime. +func New(durableDir, runtime string, validate Validator) (*Store, error) { + if err := utils.EnsurePrivateDir(durableDir); err != nil { + return nil, fmt.Errorf("prepare continuation state directory: %w", err) + } + s := &Store{ + path: filepath.Join(durableDir, "state.json"), runtime: runtime, + validate: validate, data: state{Version: stateVersion, Runtime: runtime}, + } + b, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("read continuation state: %w", err) + } + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, fmt.Errorf("decode continuation state: %w", err) + } + if s.data.Version != stateVersion || s.data.Runtime != runtime { + return nil, fmt.Errorf("unsupported or corrupt %s continuation state", runtime) + } + if s.data.ID != "" { + if err := validate(s.data.ID); err != nil { + return nil, fmt.Errorf("invalid persisted continuation state: %w", err) + } + } + return s, nil +} + +// Load returns the currently bound continuation. +func (s *Store) Load() (string, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.data.ID, s.data.ID != "", nil +} + +// Bind atomically binds the Actor to one continuation identity. +func (s *Store) Bind(id string) error { + if err := s.validate(id); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.data.ID != "" && s.data.ID != id { + return fmt.Errorf("actor is already bound to another %s continuation", s.runtime) + } + if s.data.ID == id { + return nil + } + next := state{Version: stateVersion, Runtime: s.runtime, ID: id} + b, err := json.MarshalIndent(next, "", " ") + if err != nil { + return fmt.Errorf("encode continuation state: %w", err) + } + if err := utils.ReplacePrivateFile(s.path, b); err != nil { + return fmt.Errorf("persist continuation state: %w", err) + } + s.data = next + return nil +} diff --git a/go/harness/runtime/continuation/store_test.go b/go/harness/runtime/continuation/store_test.go new file mode 100644 index 000000000..f25d4a7fb --- /dev/null +++ b/go/harness/runtime/continuation/store_test.go @@ -0,0 +1,43 @@ +package continuation + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestStorePreservesStateFormatAndUsesRuntimeValidator(t *testing.T) { + directory := t.TempDir() + validate := func(id string) error { + if id != "opaque-thread" { + return errors.New("invalid ID") + } + return nil + } + store, err := New(directory, "codex", validate) + if err != nil { + t.Fatal(err) + } + if err := store.Bind("opaque-thread"); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(directory, "state.json")) + if err != nil { + t.Fatal(err) + } + want := "{\n \"version\": 2,\n \"runtime\": \"codex\",\n \"session_id\": \"opaque-thread\"\n}" + if string(contents) != want { + t.Fatalf("state = %s, want %s", contents, want) + } + reloaded, err := New(directory, "codex", validate) + if err != nil { + t.Fatal(err) + } + if id, ok, err := reloaded.Load(); err != nil || !ok || id != "opaque-thread" { + t.Fatalf("Load() = %q, %t, %v", id, ok, err) + } + if err := reloaded.Bind("different"); err == nil { + t.Fatal("Bind() accepted an invalid continuation") + } +} diff --git a/go/harness/runtime/utils/process_group_nonunix.go b/go/harness/runtime/utils/process_group_nonunix.go new file mode 100644 index 000000000..4c1d26b61 --- /dev/null +++ b/go/harness/runtime/utils/process_group_nonunix.go @@ -0,0 +1,26 @@ +//go:build !unix + +package utils + +import ( + "os" + "os/exec" +) + +// ConfigureProcessGroup is a no-op on platforms without Unix process groups. +func ConfigureProcessGroup(*exec.Cmd) {} + +// InterruptProcessGroup interrupts the process. +func InterruptProcessGroup(process *os.Process) error { + return process.Signal(os.Interrupt) +} + +// TerminateProcessGroup terminates the process. +func TerminateProcessGroup(process *os.Process) error { + return process.Kill() +} + +// KillProcessGroup kills the process. +func KillProcessGroup(process *os.Process) error { + return process.Kill() +} diff --git a/go/harness/runtime/utils/process_group_unix.go b/go/harness/runtime/utils/process_group_unix.go new file mode 100644 index 000000000..258482e73 --- /dev/null +++ b/go/harness/runtime/utils/process_group_unix.go @@ -0,0 +1,29 @@ +//go:build unix + +package utils + +import ( + "os" + "os/exec" + "syscall" +) + +// ConfigureProcessGroup prepares command for process-group signaling. +func ConfigureProcessGroup(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// InterruptProcessGroup interrupts the process group. +func InterruptProcessGroup(process *os.Process) error { + return syscall.Kill(-process.Pid, syscall.SIGINT) +} + +// TerminateProcessGroup terminates the process group. +func TerminateProcessGroup(process *os.Process) error { + return syscall.Kill(-process.Pid, syscall.SIGTERM) +} + +// KillProcessGroup kills the process group. +func KillProcessGroup(process *os.Process) error { + return syscall.Kill(-process.Pid, syscall.SIGKILL) +} diff --git a/go/harness/runtime/utils/utils.go b/go/harness/runtime/utils/utils.go new file mode 100644 index 000000000..958f8d6b2 --- /dev/null +++ b/go/harness/runtime/utils/utils.go @@ -0,0 +1,106 @@ +// Package utils provides small, shared operating-system helpers for Harness runtimes. +package utils + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" +) + +// BoundedBuffer retains a limited prefix of process output while reporting +// every write as consumed, so reaching the diagnostic limit never disrupts +// the child process. +type BoundedBuffer struct { + mu sync.Mutex + data []byte + limit int +} + +// NewBoundedBuffer returns an empty buffer that retains the first limit bytes. +func NewBoundedBuffer(limit int) *BoundedBuffer { + return &BoundedBuffer{limit: max(limit, 0)} +} + +// Write implements io.Writer. +func (b *BoundedBuffer) Write(data []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + remaining := b.limit - len(b.data) + if remaining > 0 { + b.data = append(b.data, data[:min(len(data), remaining)]...) + } + return len(data), nil +} + +// String returns a copy of the retained prefix as a string. +func (b *BoundedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.data) +} + +// EnsurePrivateDir creates path when necessary, rejects symlinks and +// non-directories, and enforces owner-only permissions. +func EnsurePrivateDir(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return fmt.Errorf("create private directory %q: %w", path, err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect private directory %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("private path %q is not a directory", path) + } + if err := os.Chmod(path, 0o700); err != nil { + return fmt.Errorf("secure private directory %q: %w", path, err) + } + return nil +} + +// ReplacePrivateFile atomically replaces path with owner-only contents. The +// temporary file is created beside path so the rename stays on one filesystem. +func ReplacePrivateFile(path string, contents []byte) (returnErr error) { + directory := filepath.Dir(path) + if err := EnsurePrivateDir(directory); err != nil { + return err + } + temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+"-*.tmp") + if err != nil { + return fmt.Errorf("create temporary file for %q: %w", path, err) + } + temporaryPath := temporary.Name() + closed, renamed := false, false + defer func() { + if !closed { + returnErr = errors.Join(returnErr, temporary.Close()) + } + if !renamed { + if err := os.Remove(temporaryPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary file %q: %w", temporaryPath, err)) + } + } + }() + if err := temporary.Chmod(0o600); err != nil { + return fmt.Errorf("secure temporary file for %q: %w", path, err) + } + if _, err := temporary.Write(contents); err != nil { + return fmt.Errorf("write temporary file for %q: %w", path, err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("sync temporary file for %q: %w", path, err) + } + if err := temporary.Close(); err != nil { + closed = true + return fmt.Errorf("close temporary file for %q: %w", path, err) + } + closed = true + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("replace private file %q: %w", path, err) + } + renamed = true + return nil +} diff --git a/go/harness/runtime/utils/utils_test.go b/go/harness/runtime/utils/utils_test.go new file mode 100644 index 000000000..bf19d7a6a --- /dev/null +++ b/go/harness/runtime/utils/utils_test.go @@ -0,0 +1,42 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBoundedBufferConsumesWritesAndBoundsRetainedOutput(t *testing.T) { + buffer := NewBoundedBuffer(5) + for _, value := range []string{"abc", "def", "ghi"} { + written, err := buffer.Write([]byte(value)) + if err != nil || written != len(value) { + t.Fatalf("Write(%q) = %d, %v", value, written, err) + } + } + if got := buffer.String(); got != "abcde" { + t.Fatalf("String() = %q, want %q", got, "abcde") + } +} + +func TestReplacePrivateFileAtomicallyReplacesPrivateContents(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "state.json") + for _, contents := range []string{"before", "after"} { + if err := ReplacePrivateFile(path, []byte(contents)); err != nil { + t.Fatal(err) + } + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(contents) != "after" { + t.Fatalf("contents = %q, want %q", contents, "after") + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("file permissions = %v, %v", info, err) + } + if info, err := os.Stat(filepath.Dir(path)); err != nil || info.Mode().Perm() != 0o700 { + t.Fatalf("directory permissions = %v, %v", info, err) + } +} diff --git a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml index 7cdb61678..c739cab36 100644 --- a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml @@ -471,6 +471,15 @@ spec: latestSuccessfulRevision: minLength: 1 type: string + warnings: + description: |- + Warnings reports non-blocking compatibility decisions made while compiling + this AgentTemplate for the Harness. + items: + type: string + maxItems: 100 + type: array + x-kubernetes-list-type: set required: - desiredRevision - harness