Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/runner-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Claude Code Runner Tests

on:
pull_request:
paths:
- 'components/runners/claude-code-runner/**'
- '.github/workflows/runner-tests.yml'
push:
branches: [main]
paths:
- 'components/runners/claude-code-runner/**'
- '.github/workflows/runner-tests.yml'

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: components/runners/claude-code-runner

steps:
- name: Checkout code
uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-asyncio pytest-cov

- name: Run unit tests for observability and security_utils
run: |
# Only run standalone unit tests that don't require runner_shell runtime
# (test_model_mapping.py and test_wrapper_vertex.py require full runtime environment)
pytest tests/test_observability.py tests/test_security_utils.py -v --tb=short --color=yes

- name: Run tests with coverage
run: |
pytest tests/test_observability.py tests/test_security_utils.py --cov=observability --cov=security_utils --cov-report=term-missing --cov-report=xml

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./components/runners/claude-code-runner/coverage.xml
flags: runner
name: claude-code-runner
fail_ci_if_error: false
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,7 @@ e2e/.env.test
e2e/node_modules/
e2e/cypress/screenshots/
e2e/cypress/videos/

# Langfuse secrets and deployment credentials
e2e/.env.langfuse
e2e/langfuse/.env.langfuse-keys
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help setup-env build-all build-frontend build-backend build-operator build-runner deploy clean dev-frontend dev-backend lint test registry-login push-all dev-start dev-stop dev-test dev-logs-operator dev-restart-operator dev-operator-status dev-test-operator e2e-test e2e-setup e2e-clean
.PHONY: help setup-env build-all build-frontend build-backend build-operator build-runner deploy clean dev-frontend dev-backend lint test registry-login push-all dev-start dev-stop dev-test dev-logs-operator dev-restart-operator dev-operator-status dev-test-operator e2e-test e2e-setup e2e-clean deploy-langfuse-openshift

# Default target
help: ## Show this help message
Expand Down Expand Up @@ -165,3 +165,7 @@ e2e-setup: ## Install e2e test dependencies
e2e-clean: ## Clean up e2e test environment
@echo "Cleaning up e2e environment..."
cd e2e && CONTAINER_ENGINE=$(CONTAINER_ENGINE) ./scripts/cleanup.sh

deploy-langfuse-openshift: ## Deploy Langfuse to OpenShift/ROSA cluster
@echo "Deploying Langfuse to OpenShift cluster..."
@cd e2e && ./scripts/deploy-langfuse-openshift.sh
31 changes: 31 additions & 0 deletions components/backend/handlers/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"log"
"net/http"
"regexp"
"strings"
"time"

Expand All @@ -30,6 +31,25 @@ var (
StringPtr = func(s string) *string { return &s }
)

// Kubernetes DNS-1123 label validation (namespace, service account names)
// Must be lowercase alphanumeric or '-', max 63 chars, start/end with alphanumeric
// Regex allows single char (e.g., "a") or multi-char with optional middle section
var kubernetesNameRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)

// isValidKubernetesName validates that a string is a valid Kubernetes DNS-1123 label
// Returns false if:
// - name is empty (prevents empty string injection)
// - name exceeds 63 characters
// - name contains invalid characters (not lowercase alphanumeric or '-')
// - name starts or ends with '-' (enforced by regex)
func isValidKubernetesName(name string) bool {
// Explicit length check: reject empty strings and names > 63 chars
if len(name) == 0 || len(name) > 63 {
return false
}
return kubernetesNameRegex.MatchString(name)
}

// ContentListItem represents a content list item for file browsing
type ContentListItem struct {
Name string `json:"name"`
Expand Down Expand Up @@ -252,6 +272,17 @@ func ValidateProjectContext() gin.HandlerFunc {
return
}

// Validate namespace name to prevent injection attacks
// Kubernetes namespace names must be valid DNS-1123 labels:
// - lowercase alphanumeric plus '-'
// - max 63 characters
// - start and end with alphanumeric
if !isValidKubernetesName(projectHeader) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name format"})
c.Abort()
return
}

// Ensure the caller has at least list permission on agenticsessions in the namespace
ssar := &authv1.SelfSubjectAccessReview{
Spec: authv1.SelfSubjectAccessReviewSpec{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Example: Platform Admin Langfuse Secret
#
# This secret stores ALL Langfuse observability configuration for the platform.
# All LANGFUSE_* environment variables are configured here in one place.
#
# IMPORTANT:
# - This is a PLATFORM-ADMIN managed secret, not configured per-workspace
# - Should be created in the same namespace as the operator (typically 'ambient-code')
# - Contains both credentials (PUBLIC_KEY, SECRET_KEY) and configuration (HOST, ENABLED)
#
# How to create this secret:
#
# Option 1: Using kubectl (recommended for production):
# kubectl create secret generic ambient-admin-langfuse-secret \
# --from-literal=LANGFUSE_PUBLIC_KEY=pk-lf-YOUR-PUBLIC-KEY-HERE \
# --from-literal=LANGFUSE_SECRET_KEY=sk-lf-YOUR-SECRET-KEY-HERE \
# --from-literal=LANGFUSE_HOST=http://langfuse-web.langfuse.svc.cluster.local:3000 \
# --from-literal=LANGFUSE_ENABLED=true \
# -n ambient-code
#
# Option 2: Using this YAML file (less secure - keys visible in manifest):
# 1. Copy this file: cp ambient-admin-langfuse-secret.yaml.example ambient-admin-langfuse-secret.yaml
# 2. Replace placeholder values with your actual configuration
# 3. Apply: kubectl apply -f ambient-admin-langfuse-secret.yaml
# 4. Delete the file: rm ambient-admin-langfuse-secret.yaml # Don't commit secrets!

apiVersion: v1
kind: Secret
metadata:
name: ambient-admin-langfuse-secret
labels:
app: agentic-operator
ambient-code.io/component: observability
type: Opaque
stringData:
# Get these keys from your Langfuse instance:
# https://your-langfuse-host.com/settings (Project Settings -> API Keys)
LANGFUSE_PUBLIC_KEY: "pk-lf-YOUR-PUBLIC-KEY-HERE"
LANGFUSE_SECRET_KEY: "sk-lf-YOUR-SECRET-KEY-HERE"

# Langfuse instance URL (cluster-internal or external)
LANGFUSE_HOST: "http://langfuse-web.langfuse.svc.cluster.local:3000"

# Enable Langfuse observability for all sessions
LANGFUSE_ENABLED: "true"
26 changes: 26 additions & 0 deletions components/manifests/base/operator-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ spec:
configMapKeyRef:
name: operator-config
key: GOOGLE_APPLICATION_CREDENTIALS
# Platform-wide Langfuse observability configuration
# All LANGFUSE_* config stored in ambient-admin-langfuse-secret (platform-admin managed)
- name: LANGFUSE_ENABLED
valueFrom:
secretKeyRef:
name: ambient-admin-langfuse-secret
key: LANGFUSE_ENABLED
optional: true # Optional: defaults to false if secret doesn't exist
- name: LANGFUSE_HOST
valueFrom:
secretKeyRef:
name: ambient-admin-langfuse-secret
key: LANGFUSE_HOST
optional: true # Optional: only needed if Langfuse enabled
- name: LANGFUSE_PUBLIC_KEY
valueFrom:
secretKeyRef:
name: ambient-admin-langfuse-secret
key: LANGFUSE_PUBLIC_KEY
optional: true # Optional: only needed if Langfuse enabled
- name: LANGFUSE_SECRET_KEY
valueFrom:
secretKeyRef:
name: ambient-admin-langfuse-secret
key: LANGFUSE_SECRET_KEY
optional: true # Optional: only needed if Langfuse enabled
resources:
requests:
cpu: 50m
Expand Down
48 changes: 45 additions & 3 deletions components/operator/internal/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,9 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {

// Hardcoded secret names (convention over configuration)
const runnerSecretsName = "ambient-runner-secrets" // ANTHROPIC_API_KEY only (ignored when Vertex enabled)
const integrationSecretsName = "ambient-non-vertex-integrations" // GIT_*, JIRA_*, custom keys (optional)
const integrationSecretsName = "ambient-non-vertex-integrations" // GIT_*, JIRA_*, custom keys (optional, NO Langfuse keys)

// Check if integration secrets exist (optional)
// Check if integration secrets exist (user-provided integrations like GIT_TOKEN, JIRA_*)
integrationSecretsExist := false
if _, err := config.K8sClient.CoreV1().Secrets(sessionNamespace).Get(context.TODO(), integrationSecretsName, v1.GetOptions{}); err == nil {
integrationSecretsExist = true
Expand Down Expand Up @@ -347,6 +347,19 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
// Read autoPushOnComplete flag
autoPushOnComplete, _, _ := unstructured.NestedBool(spec, "autoPushOnComplete")

// Extract userContext for observability and auditing
userID := ""
userName := ""
if userContext, found, _ := unstructured.NestedMap(spec, "userContext"); found {
if v, ok := userContext["userId"].(string); ok {
userID = strings.TrimSpace(v)
}
if v, ok := userContext["displayName"].(string); ok {
userName = strings.TrimSpace(v)
}
}
log.Printf("Session %s initiated by user: %s (userId: %s)", name, userName, userID)

// Create the Job
job := &batchv1.Job{
ObjectMeta: v1.ObjectMeta{
Expand Down Expand Up @@ -482,6 +495,30 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
// S3 disabled; backend persists messages
}

// Add user context for observability and auditing (Langfuse userId, logs, etc.)
if userID != "" {
base = append(base, corev1.EnvVar{Name: "USER_ID", Value: userID})
}
if userName != "" {
base = append(base, corev1.EnvVar{Name: "USER_NAME", Value: userName})
}

// Platform-wide Langfuse observability configuration (injected from operator env)
// Uses explicit env vars instead of EnvFrom to prevent automatic exposure of future secret keys
// All LANGFUSE_* vars come from ambient-admin-langfuse-secret Secret (platform-admin managed)
if os.Getenv("LANGFUSE_ENABLED") != "" {
base = append(base, corev1.EnvVar{Name: "LANGFUSE_ENABLED", Value: os.Getenv("LANGFUSE_ENABLED")})
}
if os.Getenv("LANGFUSE_HOST") != "" {
base = append(base, corev1.EnvVar{Name: "LANGFUSE_HOST", Value: os.Getenv("LANGFUSE_HOST")})
}
if os.Getenv("LANGFUSE_PUBLIC_KEY") != "" {
base = append(base, corev1.EnvVar{Name: "LANGFUSE_PUBLIC_KEY", Value: os.Getenv("LANGFUSE_PUBLIC_KEY")})
}
if os.Getenv("LANGFUSE_SECRET_KEY") != "" {
base = append(base, corev1.EnvVar{Name: "LANGFUSE_SECRET_KEY", Value: os.Getenv("LANGFUSE_SECRET_KEY")})
}

// Add Vertex AI configuration only if enabled
if vertexEnabled {
base = append(base,
Expand Down Expand Up @@ -586,8 +623,9 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
}(),

// Import secrets as environment variables
// - integrationSecretsName: Only if exists (GIT_TOKEN, JIRA_*, custom keys)
// - integrationSecretsName: Only if exists (GIT_TOKEN, JIRA_*, custom keys - NO Langfuse keys)
// - runnerSecretsName: Only when Vertex disabled (ANTHROPIC_API_KEY)
// - ambient-langfuse-keys: Platform-wide Langfuse observability (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST, LANGFUSE_ENABLED)
EnvFrom: func() []corev1.EnvFromSource {
sources := []corev1.EnvFromSource{}

Expand Down Expand Up @@ -615,6 +653,10 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
log.Printf("Skipping runner secrets '%s' for session %s (Vertex enabled)", runnerSecretsName, name)
}

// Note: Platform-wide Langfuse observability keys are injected via explicit Env entries above
// (LANGFUSE_* env vars from ambient-admin-langfuse-secret Secret, platform-admin managed)
// EnvFrom is intentionally NOT used here to prevent automatic exposure of future secret keys

return sources
}(),

Expand Down
4 changes: 2 additions & 2 deletions components/runners/claude-code-runner/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y \
ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g @anthropic-ai/claude-code \
&& npm install -g @anthropic-ai/claude-code@2.0.41 \
&& rm -rf /var/lib/apt/lists/*

# Create working directory
Expand All @@ -21,7 +21,7 @@ RUN cd /app/runner-shell && pip install --no-cache-dir .
COPY claude-code-runner /app/claude-runner

# Install runner wrapper as a package (pulls dependencies like claude-agent-sdk)
RUN pip install --no-cache-dir /app/claude-runner \
RUN pip install --no-cache-dir /app/claude-runner[observability] \
&& pip install --no-cache-dir aiofiles

# Set environment variables
Expand Down
Loading
Loading