diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e6340a..1d9d98e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,23 +24,26 @@ jobs: - name: Download modules run: go mod download - - name: Verify gofmt - run: | - FILES=$(find . -name '*.go' -not -path './.git/*') - UNFORMATTED=$(gofmt -l $FILES) - if [ -n "$UNFORMATTED" ]; then - echo "Unformatted Go files:" - echo "$UNFORMATTED" - exit 1 - fi + - name: Download nested ANF snapshot modules + working-directory: tools/anf-snapshot + run: go mod download - name: Verify go.mod/go.sum are tidy run: | go mod tidy git diff --exit-code go.mod go.sum - - name: Run go vet - run: go vet ./... + - name: Verify nested ANF snapshot go.mod/go.sum are tidy + working-directory: tools/anf-snapshot + run: | + go mod tidy + git diff --exit-code go.mod go.sum + + - name: Run canonical lint + run: make lint + + - name: Run nested ANF snapshot vet + run: make vet-anf-snapshot - name: Install envtest binaries run: | @@ -51,6 +54,9 @@ jobs: - name: Run Go tests run: go test ./api/... ./pkg/... ./internal/... -count=1 + - name: Run nested ANF snapshot tests + run: make test-anf-snapshot GO_TEST_FLAGS="-count=1" + landing-build: name: Landing Lint and Build runs-on: ubuntu-latest diff --git a/.github/workflows/oss-private-boundary-guard.yml b/.github/workflows/oss-private-boundary-guard.yml index e866975..81ff947 100644 --- a/.github/workflows/oss-private-boundary-guard.yml +++ b/.github/workflows/oss-private-boundary-guard.yml @@ -19,6 +19,10 @@ jobs: with: fetch-depth: 0 + - name: Test boundary scanner + shell: bash + run: bash tests/smoke/test_oss_private_boundary.sh + - name: Validate OSS/private boundary policy shell: bash run: | diff --git a/.github/workflows/test-gates.yml b/.github/workflows/test-gates.yml index 5644848..7baf31a 100644 --- a/.github/workflows/test-gates.yml +++ b/.github/workflows/test-gates.yml @@ -10,7 +10,9 @@ on: - "cmd/**" - "agents/**" - "charts/**" - - "scripts/scan_secrets.sh" + - "scripts/**" + - "tests/smoke/**" + - "examples/**" - ".github/workflows/test-gates.yml" push: branches: @@ -23,7 +25,9 @@ on: - "cmd/**" - "agents/**" - "charts/**" - - "scripts/scan_secrets.sh" + - "scripts/**" + - "tests/smoke/**" + - "examples/**" - ".github/workflows/test-gates.yml" schedule: - cron: "0 3 * * *" @@ -42,6 +46,9 @@ jobs: - name: Quick secret scan run: make scan-secrets + - name: Run booth CLI smoke tests + run: bash tests/smoke/test_demo_booth_cli.sh + - name: Set up Go uses: actions/setup-go@v5 with: @@ -59,5 +66,31 @@ jobs: - name: Run Go tests run: make test-go + - name: Install helm-unittest plugin + run: | + # Build the plugin from an immutable source commit. Do not run its + # installer hook, which downloads a mutable release asset. + plugin_dir="${RUNNER_TEMP}/helm-unittest" + git clone --filter=blob:none https://github.com/helm-unittest/helm-unittest.git "${plugin_dir}" + git -C "${plugin_dir}" checkout --detach 9cf59a78dbb89f3e3c70c62d2570cd7e96b97845 + test "$(git -C "${plugin_dir}" rev-parse HEAD)" = "9cf59a78dbb89f3e3c70c62d2570cd7e96b97845" + ( + cd "${plugin_dir}" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOTOOLCHAIN=go1.26.0 \ + go build -trimpath -buildvcs=false -o untt-linux-amd64 \ + -ldflags "-X github.com/helm-unittest/helm-unittest/internal/build.version=1.1.1 -extldflags '-static'" \ + ./cmd/helm-unittest + ) + helm_plugins="$(helm env | sed -n 's/^HELM_PLUGINS="\(.*\)"$/\1/p')" + test -n "${helm_plugins}" + install_dir="${helm_plugins}/unittest" + mkdir -p "${install_dir}" + install -m 0755 "${plugin_dir}/untt-linux-amd64" "${install_dir}/untt-linux-amd64" + install -m 0644 "${plugin_dir}/plugin.yaml" "${install_dir}/plugin.yaml" + helm plugin list | grep -Eq '^unittest[[:space:]]+1\.1\.1([[:space:]]|$)' + + - name: Run Helm unit tests + run: helm unittest charts + - name: Lint Helm chart run: make helm-lint diff --git a/.gitignore b/.gitignore index 00a490a..6a2a209 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ manager manager bin/ +# Portable Agentic Summit fallback. Other logs remain ignored. +!demos/demo-claude-anf.log + # Strategic documents (pitch decks, large media) *.mp4 *.pptx diff --git a/Makefile b/Makefile index 80f37f1..7d0b2c5 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ SETUP_ENVTEST := $(LOCALBIN)/setup-envtest STATICCHECK := $(LOCALBIN)/staticcheck CONTROLLER_GEN := $(LOCALBIN)/controller-gen -.PHONY: help test validate test-unit test-go test-python setup-envtest test-controller fmt fmt-check vet lint build build-agentctl build-agentctl-web install-agentctl scan-secrets clean-venv check-python-version helm-lint test-cluster test-smoke test-e2e-cluster manifests generate +.PHONY: help test validate test-unit test-go test-anf-snapshot vet-anf-snapshot verify-anf-snapshot test-python setup-envtest test-controller fmt fmt-anf-snapshot fmt-check fmt-check-anf-snapshot vet lint staticcheck-anf-snapshot build build-agentctl build-agentctl-web build-anf-snapshot install-agentctl scan-secrets clean-venv check-python-version helm-lint test-cluster test-smoke test-e2e-cluster manifests generate .DEFAULT_GOAL := help @@ -27,15 +27,21 @@ help: @echo " make test - Run go + python tests" @echo " make validate - Run canonical validation sequence" @echo " make test-go - Run go tests (excluding envtest controller)" + @echo " make test-anf-snapshot - Run nested ANF snapshot tests" + @echo " make vet-anf-snapshot - Run nested ANF snapshot vet checks" @echo " make test-python - Run python agent tests (creates .venv automatically)" @echo " make test-controller - Run envtest controller suite" @echo " make fmt - Format go code" + @echo " make fmt-anf-snapshot - Format nested ANF snapshot go code" @echo " make fmt-check - Fail if go files are not formatted" + @echo " make fmt-check-anf-snapshot - Check nested ANF snapshot formatting" @echo " make vet - Run go vet" @echo " make lint - Run lint checks" + @echo " make staticcheck-anf-snapshot - Run nested ANF snapshot staticcheck" @echo " make manifests - Generate CRD manifests" @echo " make generate - Generate code (deepcopy + go generate)" @echo " make build - Build go binaries" + @echo " make build-anf-snapshot - Build the live ANF namespace snapshot CLI" @echo " make helm-lint - Lint the Helm chart" @echo " make scan-secrets - Run repository secret scan" @echo "" @@ -44,9 +50,9 @@ help: @echo " make test-smoke - Smoke tests only (cluster must be pre-installed)" @echo " make test-e2e-cluster - E2E tests only (cluster must be pre-installed)" -test: test-go test-python +test: test-go test-anf-snapshot test-python -validate: fmt-check lint test-controller test-go test-python helm-lint +validate: fmt-check lint test-controller test-go test-anf-snapshot vet-anf-snapshot test-python helm-lint test-unit: test @@ -59,6 +65,16 @@ test-go: fi; \ $(GO) test $$pkgs $(GO_TEST_FLAGS) +test-anf-snapshot: + @echo "Running nested ANF snapshot tests..." + @cd tools/anf-snapshot && $(GO) test ./... $(GO_TEST_FLAGS) + +vet-anf-snapshot: + @echo "Running nested ANF snapshot vet checks..." + @cd tools/anf-snapshot && $(GO) vet ./... + +verify-anf-snapshot: test-anf-snapshot vet-anf-snapshot + check-python-version: @$(PYTHON) -c 'import sys; assert sys.version_info < (3, 13), "test-python requires Python <= 3.12 due dependency pins; run with PYTHON=python3.12"' @@ -116,11 +132,18 @@ test-controller: $(SETUP_ENVTEST) @assets="$$( $(SETUP_ENVTEST) use -p path $(ENVTEST_K8S_VERSION) )"; \ KUBEBUILDER_ASSETS="$$assets" $(GO) test ./internal/controller/... $(GO_TEST_FLAGS) -fmt: +fmt: fmt-anf-snapshot @echo "Formatting Go code..." @$(GO) fmt ./... -fmt-check: +fmt-anf-snapshot: + @echo "Formatting nested ANF snapshot Go code..." + @files="$$(find tools/anf-snapshot -type f -name '*.go' -print)"; \ + if [[ -n "$$files" ]]; then \ + gofmt -w $$files; \ + fi + +fmt-check: fmt-check-anf-snapshot @echo "Checking Go formatting..." @unformatted="$$(gofmt -l $$(git ls-files '*.go'))"; \ if [[ -n "$$unformatted" ]]; then \ @@ -129,11 +152,24 @@ fmt-check: exit 1; \ fi +fmt-check-anf-snapshot: + @echo "Checking nested ANF snapshot Go formatting..." + @unformatted="$$(gofmt -l $$(find tools/anf-snapshot -type f -name '*.go' -print))"; \ + if [[ -n "$$unformatted" ]]; then \ + echo "The following nested ANF snapshot files are not gofmt-formatted:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi + vet: @echo "Running go vet..." @$(GO) vet ./... -lint: vet fmt-check $(STATICCHECK) +staticcheck-anf-snapshot: $(STATICCHECK) + @echo "Running nested ANF snapshot staticcheck..." + @cd tools/anf-snapshot && PATH="$(LOCALBIN):$$PATH" $(STATICCHECK) ./... + +lint: vet fmt-check staticcheck-anf-snapshot $(STATICCHECK) @echo "Running staticcheck..." @PATH="$(LOCALBIN):$$PATH" $(STATICCHECK) ./... @@ -151,6 +187,11 @@ build-agentctl-web: @mkdir -p bin @$(GO) build -o bin/agentctl-web ./cmd/agentctl-web/... +build-anf-snapshot: + @echo "Building anf-snapshot binary..." + @mkdir -p bin + @cd tools/anf-snapshot && $(GO) build -o ../../bin/anf-snapshot . + install-agentctl: build-agentctl @if [[ ! -f bin/agentctl ]]; then \ echo "agentctl binary not found at bin/agentctl"; \ diff --git a/api/v1alpha1/agentworkload_compatibility_test.go b/api/v1alpha1/agentworkload_compatibility_test.go index f086e52..779cc2f 100644 --- a/api/v1alpha1/agentworkload_compatibility_test.go +++ b/api/v1alpha1/agentworkload_compatibility_test.go @@ -18,7 +18,15 @@ package v1alpha1 import ( "encoding/json" + "os" + "strings" "testing" + + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsvalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" ) func TestAgentWorkloadCompatibility_OlderStyleObjectStillValid(t *testing.T) { @@ -249,3 +257,162 @@ func TestAgentWorkloadCompatibility_EvolvingOptionalFieldsMatrix(t *testing.T) { }) } } + +func TestAgentWorkloadCompatibility_ExecutionRuntimeIsAdditive(t *testing.T) { + legacyStatus := []byte(`{ + "apiVersion": "agentic.clawdlinux.org/v1alpha1", + "kind": "AgentWorkload", + "metadata": {"name": "legacy-execution"}, + "status": { + "argoWorkflow": { + "name": "legacy-workflow", + "namespace": "argo-workflows", + "uid": "legacy-uid" + } + } + }`) + + var legacy AgentWorkload + if err := json.Unmarshal(legacyStatus, &legacy); err != nil { + t.Fatalf("failed to unmarshal legacy execution ref: %v", err) + } + if legacy.Status.ArgoWorkflow == nil { + t.Fatal("legacy execution ref was not decoded") + } + if legacy.Status.ArgoWorkflow.Runtime != "" { + t.Fatalf("legacy execution runtime = %q, want empty compatibility fallback", legacy.Status.ArgoWorkflow.Runtime) + } + + legacy.Status.ArgoWorkflow.Runtime = "pod" + encoded, err := json.Marshal(&legacy) + if err != nil { + t.Fatalf("failed to marshal execution ref with runtime: %v", err) + } + var roundTrip AgentWorkload + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatalf("failed to round-trip execution ref with runtime: %v", err) + } + if got := roundTrip.Status.ArgoWorkflow.Runtime; got != "pod" { + t.Fatalf("round-trip execution runtime = %q, want pod", got) + } +} + +func TestAgentWorkloadCompatibility_CRDObjectiveLength(t *testing.T) { + validator := agentWorkloadCRDValidator(t) + + testCases := []struct { + name string + objective string + wantValid bool + }{ + {name: "4 KiB objective", objective: strings.Repeat("a", 4096), wantValid: true}, + {name: "32 KiB objective", objective: strings.Repeat("a", 32768), wantValid: true}, + {name: "over 32 KiB objective", objective: strings.Repeat("a", 32769), wantValid: false}, + {name: "32768 multibyte characters", objective: strings.Repeat("é", 32768), wantValid: true}, + {name: "32769 multibyte characters", objective: strings.Repeat("é", 32769), wantValid: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + object := map[string]interface{}{ + "apiVersion": "agentic.clawdlinux.org/v1alpha1", + "kind": "AgentWorkload", + "metadata": map[string]interface{}{ + "name": "objective-length-test", + }, + "spec": map[string]interface{}{ + "objective": tc.objective, + }, + } + errs := apiextensionsvalidation.ValidateCustomResource(field.NewPath("agentworkload"), object, validator) + if tc.wantValid && len(errs) != 0 { + t.Fatalf("objective length %d should be valid: %v", len(tc.objective), errs) + } + if !tc.wantValid && len(errs) == 0 { + t.Fatalf("objective length %d should be rejected", len(tc.objective)) + } + }) + } +} + +func TestAgentWorkloadCompatibility_WebhookAcceptsBoundedContextObjective(t *testing.T) { + objective := strings.Repeat("a", 4096) + workload := &AgentWorkload{ + Spec: AgentWorkloadSpec{ + Objective: &objective, + Agents: []string{"agent1"}, + }, + } + + if err := workload.ValidateCreate(); err != nil { + t.Fatalf("4 KiB objective allowed by the CRD must pass webhook validation: %v", err) + } +} + +func TestAgentWorkloadCompatibility_CRDAndWebhookAgreeOnMultibyteObjective(t *testing.T) { + objective := strings.Repeat("é", 32768) + workload := &AgentWorkload{ + Spec: AgentWorkloadSpec{ + Objective: &objective, + Agents: []string{"agent1"}, + }, + } + + if err := workload.ValidateCreate(); err != nil { + t.Fatalf("webhook rejected CRD-valid multibyte objective: %v", err) + } +} + +func TestAgentWorkloadCompatibility_CRDExecutionRuntime(t *testing.T) { + schema := agentWorkloadCRDSchema(t) + statusSchema, ok := schema.Properties["status"] + if !ok { + t.Fatal("AgentWorkload CRD schema has no status property") + } + executionSchema, ok := statusSchema.Properties["argoWorkflow"] + if !ok { + t.Fatal("AgentWorkload CRD schema has no status.argoWorkflow property") + } + runtimeSchema, ok := executionSchema.Properties["runtime"] + if !ok { + t.Fatal("AgentWorkload CRD schema has no status.argoWorkflow.runtime property") + } + if runtimeSchema.Type != "string" { + t.Fatalf("status.argoWorkflow.runtime type = %q, want string", runtimeSchema.Type) + } +} + +func agentWorkloadCRDValidator(t *testing.T) apiextensionsvalidation.SchemaValidator { + t.Helper() + schema := agentWorkloadCRDSchema(t) + validator, _, err := apiextensionsvalidation.NewSchemaValidator(&schema) + if err != nil { + t.Fatalf("create AgentWorkload schema validator: %v", err) + } + return validator +} + +func agentWorkloadCRDSchema(t *testing.T) apiextensions.JSONSchemaProps { + t.Helper() + + data, err := os.ReadFile("../../config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml") + if err != nil { + t.Fatalf("read AgentWorkload CRD: %v", err) + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(data, &crd); err != nil { + t.Fatalf("decode AgentWorkload CRD: %v", err) + } + if len(crd.Spec.Versions) != 1 || crd.Spec.Versions[0].Schema == nil || crd.Spec.Versions[0].Schema.OpenAPIV3Schema == nil { + t.Fatalf("AgentWorkload CRD has no v1alpha1 OpenAPI schema") + } + var schema apiextensions.JSONSchemaProps + if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps( + crd.Spec.Versions[0].Schema.OpenAPIV3Schema, + &schema, + nil, + ); err != nil { + t.Fatalf("convert AgentWorkload schema: %v", err) + } + return schema +} diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index 117a544..037c678 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -35,9 +35,9 @@ type AgentWorkloadSpec struct { // +optional MCPServerEndpoint *string `json:"mcpServerEndpoint,omitempty"` - // objective is the high-level goal for the agent (e.g. "optimize cluster performance") + // objective is the user task plus compact structured context for the agent. // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=1000 + // +kubebuilder:validation:MaxLength=32768 // +optional Objective *string `json:"objective,omitempty"` @@ -163,9 +163,9 @@ type AgentPersona struct { // +optional MemoryScope string `json:"memoryScope,omitempty"` - // SystemPromptAppend is injected at the END of the base system prompt. - // Use this to encode persona-specific instructions without overriding - // the operator's base safety and governance prompt. + // SystemPromptAppend is a user-controlled runtime persona preference. + // Runtime adapters may append it to prompts they construct. The built-in direct + // model-routing path treats it as labeled, untrusted user content, never system content. // +optional SystemPromptAppend string `json:"systemPromptAppend,omitempty"` @@ -304,9 +304,13 @@ type Action struct { Approved *bool `json:"approved,omitempty"` } -// ArgoWorkflowRef references an Argo Workflow CR -// Used to track the associated workflow for this AgentWorkload +// ArgoWorkflowRef references a runtime execution. The historical name remains +// for API compatibility. type ArgoWorkflowRef struct { + // runtime is the adapter key used to create the execution. + // +optional + Runtime string `json:"runtime,omitempty"` + // name is the name of the Argo Workflow CR Name string `json:"name,omitempty"` @@ -342,8 +346,8 @@ type AgentWorkloadStatus struct { // +optional ExecutedActions []Action `json:"executedActions,omitempty"` - // argoWorkflow references the associated Argo Workflow CR - // Set when the operator creates a workflow for Argo orchestration + // argoWorkflow references the runtime execution created for this workload. + // The historical field name remains for API compatibility. // +optional ArgoWorkflow *ArgoWorkflowRef `json:"argoWorkflow,omitempty"` diff --git a/api/v1alpha1/agentworkload_webhook.go b/api/v1alpha1/agentworkload_webhook.go index 089dae2..fbfa3f2 100644 --- a/api/v1alpha1/agentworkload_webhook.go +++ b/api/v1alpha1/agentworkload_webhook.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -153,8 +154,9 @@ func (r *AgentWorkload) validate() error { if len(*r.Spec.Objective) == 0 { allErrs = append(allErrs, "objective must not be empty") } - if len(*r.Spec.Objective) > 1000 { - allErrs = append(allErrs, fmt.Sprintf("objective must be ≤ 1000 characters, got %d", len(*r.Spec.Objective))) + objectiveLength := utf8.RuneCountInString(*r.Spec.Objective) + if objectiveLength > 32768 { + allErrs = append(allErrs, fmt.Sprintf("objective must be at most 32768 characters, got %d", objectiveLength)) } } diff --git a/api/v1alpha1/agentworkload_webhook_test.go b/api/v1alpha1/agentworkload_webhook_test.go index fe36afa..4e86b40 100644 --- a/api/v1alpha1/agentworkload_webhook_test.go +++ b/api/v1alpha1/agentworkload_webhook_test.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha1 import ( + "strings" "testing" ) @@ -132,25 +133,59 @@ func TestWebhook_RejectEmptyAgents(t *testing.T) { } func TestWebhook_RejectObjectiveTooLong(t *testing.T) { - longObjective := "" - for i := 0; i < 1001; i++ { - longObjective += "a" - } + longObjective := strings.Repeat("a", 32769) workload := &AgentWorkload{ Spec: AgentWorkloadSpec{ WorkloadType: stringPtr("generic"), MCPServerEndpoint: stringPtr("https://localhost:8000"), - Objective: &longObjective, // > 1000 chars + Objective: &longObjective, Agents: []string{"agent1"}, }, } err := workload.ValidateCreate() if err == nil { - t.Error("Expected validation error for objective > 1000 chars, got nil") + t.Error("Expected validation error for objective > 32768 characters, got nil") } else { - t.Logf("✅ Correctly rejected: %v", err) + t.Logf("Correctly rejected: %v", err) + } +} + +func TestWebhook_AcceptsObjectiveAtLimit(t *testing.T) { + objective := strings.Repeat("a", 32768) + workload := &AgentWorkload{ + Spec: AgentWorkloadSpec{ + WorkloadType: stringPtr("generic"), + MCPServerEndpoint: stringPtr("https://localhost:8000"), + Objective: &objective, + Agents: []string{"agent1"}, + }, + } + + if err := workload.ValidateCreate(); err != nil { + t.Fatalf("objective at 32768-character limit was rejected: %v", err) + } +} + +func TestWebhook_ObjectiveLimitCountsUnicodeCharacters(t *testing.T) { + objective := strings.Repeat("é", 32768) + workload := &AgentWorkload{ + Spec: AgentWorkloadSpec{ + WorkloadType: stringPtr("generic"), + MCPServerEndpoint: stringPtr("https://localhost:8000"), + Objective: &objective, + Agents: []string{"agent1"}, + }, + } + + if err := workload.ValidateCreate(); err != nil { + t.Fatalf("32768-character multibyte objective was rejected: %v", err) + } + + objective += "é" + if err := workload.ValidateCreate(); err == nil { + t.Fatal("32769-character multibyte objective was accepted") } } diff --git a/charts/charts/litellm/templates/configmap.yaml b/charts/charts/litellm/templates/configmap.yaml index 149faa8..6ba6bdf 100644 --- a/charts/charts/litellm/templates/configmap.yaml +++ b/charts/charts/litellm/templates/configmap.yaml @@ -11,14 +11,17 @@ data: master_key: "os.environ/LITELLM_MASTER_KEY" database_url: null model_list: + {{- if .Values.builtinOpenAIModelsEnabled }} - model_name: clawdlinux-openai litellm_params: model: openai/gpt-4o-mini api_key: "os.environ/OPENAI_API_KEY" + {{- end }} - model_name: clawdlinux-anthropic litellm_params: model: anthropic/claude-haiku-4-5-20251001 api_key: "os.environ/ANTHROPIC_API_KEY" + {{- if .Values.builtinOpenAIModelsEnabled }} - model_name: gpt-4o litellm_params: model: openai/gpt-4o @@ -27,6 +30,7 @@ data: litellm_params: model: openai/gpt-4o-mini api_key: "os.environ/OPENAI_API_KEY" + {{- end }} {{- range .Values.models }} - {{ toYaml . | nindent 8 | trim }} {{- end }} diff --git a/charts/charts/litellm/values.yaml b/charts/charts/litellm/values.yaml index b1c620d..c7f6360 100644 --- a/charts/charts/litellm/values.yaml +++ b/charts/charts/litellm/values.yaml @@ -21,6 +21,7 @@ masterKey: "" # Recommended for booth and production deployments. existingSecret: "" allowPlaintextKeys: false +builtinOpenAIModelsEnabled: true models: [] # LiteLLM Routing Configuration diff --git a/charts/tests/litellm_existing_secret_test.yaml b/charts/tests/litellm_existing_secret_test.yaml index 09b2735..b87c66c 100644 --- a/charts/tests/litellm_existing_secret_test.yaml +++ b/charts/tests/litellm_existing_secret_test.yaml @@ -28,6 +28,22 @@ tests: count: 0 template: charts/litellm/templates/secret.yaml + - it: can disable built-in OpenAI models without disabling Anthropic + set: + litellm: + enabled: true + existingSecret: clawdlinux-demo-litellm + builtinOpenAIModelsEnabled: false + asserts: + - notMatchRegex: + path: data["config.yaml"] + pattern: 'model_name: (clawdlinux-openai|gpt-4o|gpt-4o-mini)' + template: charts/litellm/templates/configmap.yaml + - matchRegex: + path: data["config.yaml"] + pattern: 'model_name: clawdlinux-anthropic[\s\S]*model: anthropic/claude-haiku-4-5-20251001[\s\S]*api_key: "os.environ/ANTHROPIC_API_KEY"' + template: charts/litellm/templates/configmap.yaml + - it: renders all provider keys in the chart-managed Secret set: litellm: diff --git a/charts/values.schema.json b/charts/values.schema.json index c7cebd2..b83b3cf 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -127,6 +127,10 @@ "description": "Pre-created Secret containing OpenAI, Anthropic, and LiteLLM master keys" }, "allowPlaintextKeys": { "type": "boolean" }, + "builtinOpenAIModelsEnabled": { + "type": "boolean", + "description": "Render the built-in OpenAI model aliases" + }, "models": { "type": "array", "items": { "type": "object" } diff --git a/cmd/agentctl-web/demo_test.go b/cmd/agentctl-web/demo_test.go index 00bf15d..3e210c7 100644 --- a/cmd/agentctl-web/demo_test.go +++ b/cmd/agentctl-web/demo_test.go @@ -1,18 +1,29 @@ package main import ( + "bytes" + "context" + "errors" + "io/fs" "net/http" "net/http/httptest" "strings" "testing" + "testing/fstest" + "time" "github.com/Clawdlinux/agentic-operator-core/pkg/agentctl" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" ) -func TestHandleDemoRendersBoothStory(t *testing.T) { +func TestHandleDemoRendersEvidenceConsole(t *testing.T) { server, err := NewServer(nil, nil, TemplatesFS()) if err != nil { t.Fatalf("NewServer() error = %v", err) @@ -29,40 +40,494 @@ func TestHandleDemoRendersBoothStory(t *testing.T) { body := res.Body.String() for _, want := range []string{ - "Secure Research Swarm", - "Policy Gate", - "Cost Attribution", - "Audit Proof", - "Run demo", - "every 5s", + `href="/theme/clawdlinux-theme.css"`, + `src="/theme/clawdlinux-wordmark.svg"`, + `alt="Clawdlinux"`, + "Governance evidence console", + ">CONFIG ONLY", + ">PRIOR RUN", + "No OPA evidence", + "Runtime pod state unavailable", + "No packet enforcement test", + "gVisor admission dry-run", + "NetworkPolicy object presence", + "The audit receipt is a prior-run fixture. This console does not query current-run audit generation.", + "no pod or packet test", + `hx-trigger="every 5s"`, } { if !strings.Contains(body, want) { t.Fatalf("demo body missing %q", want) } } + + sharedThemeIndex := strings.Index(body, `href="/theme/clawdlinux-theme.css"`) + localStyleIndex := strings.Index(body, `href="/static/style.css"`) + if sharedThemeIndex == -1 || localStyleIndex == -1 || sharedThemeIndex > localStyleIndex { + t.Fatal("shared theme must load before local styles") + } + if count := strings.Count(body, "OPA"); count != 1 { + t.Fatalf("demo body contains %d OPA references, want disclaimer only", count) + } + + for _, stale := range []string{ + "OPA checks tool scope", + "Policy gated", + "gpt-4o-mini", + "Secure Research Swarm", + "$0.0142", + "Audit trail", + "Audit ready", + "Current audit trail", + "Current attestation", + "Attestation verified", + "Current workload generated no audit fixture", + "not generated by the current workload", + "3 agents", + "No external data egress", + "AgentWorkload running", + "Live agent graph", + "Agent graph", + "Agentic Operator", + } { + if strings.Contains(body, stale) { + t.Fatalf("demo body contains stale claim %q", stale) + } + } + + for label, want := range map[string]int{ + ">LIVE": 0, + ">CONFIG ONLY": 1, + ">PRIOR RUN": 1, + } { + if count := strings.Count(body, label); count != want { + t.Errorf("demo body contains %d %s labels, want %d", count, label, want) + } + } } -func TestHandleDemoRendersLiveRuntimePods(t *testing.T) { - client := &agentctl.Client{ - Kube: fake.NewClientset(&corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secure-research-swarm-runner", - Namespace: "argo-workflows", - Labels: map[string]string{ - "agentic.io/job-id": "secure-research-swarm", - agentctl.RoleLabelKey: "researcher", - "app.kubernetes.io/part-of": "clawdlinux", +func TestDemoHandlerAllowsUnauthenticatedDemoRoute(t *testing.T) { + server, err := NewServer(nil, nil, TemplatesFS()) + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/demo", nil) + res := httptest.NewRecorder() + demoHandler(server).ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", res.Code, http.StatusOK) + } + if location := res.Header().Get("Location"); location != "" { + t.Fatalf("Location = %q, want no authentication redirect", location) + } + if body := res.Body.String(); !strings.Contains(body, "Governance evidence console") { + t.Fatal("demo route did not render the evidence console") + } +} + +func TestHandleDemoReturnsAtDeadlineWhenSourceIgnoresCancellation(t *testing.T) { + server, err := NewServer(nil, nil, TemplatesFS()) + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + const pollTimeout = 30 * time.Millisecond + server.demoPollTimeout = pollTimeout + blockedStarted := make(chan struct{}) + releaseBlocked := make(chan struct{}) + server.demoPodSource = func(context.Context) ([]agentctl.RuntimePodRow, error) { + close(blockedStarted) + <-releaseBlocked + return []agentctl.RuntimePodRow{{Name: "late-runtime-pod"}}, nil + } + server.demoWorkloadSource = func(context.Context) ([]agentctl.WorkloadRow, error) { + return []agentctl.WorkloadRow{{Name: "deadline-evidence-workload"}}, nil + } + + req := httptest.NewRequest(http.MethodGet, "/demo", nil) + res := httptest.NewRecorder() + done := make(chan struct{}) + started := time.Now() + go func() { + server.handleDemo(res, req) + close(done) + }() + + waitForDemoSignal(t, blockedStarted, done, "blocked source did not start") + select { + case <-done: + case <-time.After(250 * time.Millisecond): + close(releaseBlocked) + <-done + t.Fatal("handleDemo waited for a source that ignored cancellation") + } + close(releaseBlocked) + + elapsed := time.Since(started) + if elapsed < pollTimeout { + t.Fatalf("handleDemo returned in %v, before poll timeout %v", elapsed, pollTimeout) + } + if elapsed >= 200*time.Millisecond { + t.Fatalf("handleDemo returned in %v, want deadline-bounded response", elapsed) + } + + body := res.Body.String() + assertEvidenceSourceState(t, body, "workloads", "LIVE") + assertEvidenceSourceContains(t, body, "workloads", "deadline-evidence-workload") + assertEvidenceSourceState(t, body, "runtime-pods", "UNAVAILABLE") +} + +func TestHandleDemoTemplateFailureDoesNotCommitPartialBody(t *testing.T) { + templateFS := fstest.MapFS{ + "demo.html": { + Data: []byte(`{{define "demo.html"}}partial demo body{{.Missing}}{{end}}`), + }, + "partials/status.html": { + Data: []byte(`{{define "status.html"}}{{end}}`), + }, + } + server, err := NewServer(nil, nil, templateFS) + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/demo", nil) + res := httptest.NewRecorder() + server.handleDemo(res, req) + + if res.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", res.Code, http.StatusInternalServerError) + } + if body := res.Body.String(); strings.Contains(body, "partial demo body") { + t.Fatalf("error response contains partial demo body: %q", body) + } +} + +func TestHandleLoginRendersClawdlinuxEvidenceConsoleBranding(t *testing.T) { + server, err := NewServer(nil, nil, TemplatesFS()) + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/auth/login", nil) + res := httptest.NewRecorder() + + server.handleLogin(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", res.Code, http.StatusOK) + } + + body := res.Body.String() + for _, want := range []string{ + `href="/theme/clawdlinux-theme.css"`, + `href="/static/style.css"`, + `src="/theme/clawdlinux-wordmark.svg"`, + `alt="Clawdlinux"`, + "Governance evidence console", + `method="POST" action="/auth/login"`, + `