From f9948f8001cbd2592cb6621a212d5a0ff76117ae Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Wed, 4 Mar 2026 16:59:20 -0500 Subject: [PATCH 01/12] feat(public-api): complete session API surface for mcp-acp integration Add PATCH, logs, transcript, and metrics endpoints to the public API so mcp-acp can operate end-to-end with API keys. Enrich SessionResponse DTO with displayName, repos, labels, and timeout fields. Backend changes: - Add GET /api/.../logs (streaming with 10MB cap, pod log via K8s API) - Add GET /api/.../metrics (session CR status + usage annotations) - Fix PatchSession to delete annotations set to nil instead of storing empty strings Public API changes: - Add PATCH /v1/sessions/:id with smart routing (start/stop/update/labels) - Add GET /v1/sessions/:id/{logs,transcript,metrics} proxy handlers - Forward labelSelector query params in ListSessions - Forward displayName in CreateSession - Validate label keys against reserved internal prefixes on write - Fix labels-to-annotations mapping on create path for write/read symmetry - Label PATCH returns full session DTO (follow-up GET for consistency) Other: - Fix setup-vertex-kind.sh to restart backend-api after ConfigMap patch - Add IMAGE_PUBLIC_API substitution in e2e deploy script Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions.go | 6 +- components/backend/handlers/sessions_logs.go | 93 +++++ .../backend/handlers/sessions_metrics.go | 107 ++++++ .../backend/handlers/sessions_sub_test.go | 341 ++++++++++++++++++ components/backend/routes.go | 2 + .../public-api/handlers/integration_test.go | 10 +- components/public-api/handlers/sessions.go | 139 ++++++- .../public-api/handlers/sessions_patch.go | 247 +++++++++++++ .../handlers/sessions_patch_test.go | 269 ++++++++++++++ .../public-api/handlers/sessions_sub.go | 142 ++++++++ .../public-api/handlers/sessions_sub_test.go | 320 ++++++++++++++++ .../public-api/handlers/sessions_test.go | 110 +++++- components/public-api/main.go | 12 +- components/public-api/types/dto.go | 38 +- e2e/scripts/deploy.sh | 4 +- scripts/setup-vertex-kind.sh | 7 +- 16 files changed, 1810 insertions(+), 37 deletions(-) create mode 100644 components/backend/handlers/sessions_logs.go create mode 100644 components/backend/handlers/sessions_metrics.go create mode 100644 components/backend/handlers/sessions_sub_test.go create mode 100644 components/public-api/handlers/sessions_patch.go create mode 100644 components/public-api/handlers/sessions_patch_test.go create mode 100644 components/public-api/handlers/sessions_sub.go create mode 100644 components/public-api/handlers/sessions_sub_test.go diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 2e3ee5611..b9101f383 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -989,7 +989,11 @@ func PatchSession(c *gin.Context) { anns = map[string]interface{}{} } for k, v := range annsPatch { - anns[k] = v + if v == nil { + delete(anns, k) + } else { + anns[k] = v + } } _ = unstructured.SetNestedMap(metadata, anns, "annotations") _ = unstructured.SetNestedMap(item.Object, metadata, "metadata") diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go new file mode 100644 index 000000000..deee2af00 --- /dev/null +++ b/components/backend/handlers/sessions_logs.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" +) + +const ( + defaultTailLines = int64(1000) + maxTailLines = int64(10000) + maxLogBytes = 10 * 1024 * 1024 // 10MB cap on log response size +) + +// GetSessionLogs returns container logs for the session's runner pod. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/logs +// +// Query params: +// - tailLines: number of lines from the end (default 1000, max 10000) +// - container: specific container name (optional) +func GetSessionLogs(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + k8sClt, _ := GetK8sClientsForRequest(c) + if k8sClt == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // Parse tailLines query param + tailLines := defaultTailLines + if tl := c.Query("tailLines"); tl != "" { + parsed, err := strconv.ParseInt(tl, 10, 64) + if err != nil || parsed < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "tailLines must be a positive integer"}) + return + } + if parsed > maxTailLines { + parsed = maxTailLines + } + tailLines = parsed + } + + container := c.Query("container") + + // Pod naming convention: {sessionName}-runner + // Must match operator pod creation in internal/controller/reconcile_phases.go + podName := fmt.Sprintf("%s-runner", sessionName) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + logOpts := &corev1.PodLogOptions{ + TailLines: &tailLines, + } + if container != "" { + logOpts.Container = container + } + + logReq := k8sClt.CoreV1().Pods(project).GetLogs(podName, logOpts) + logStream, err := logReq.Stream(ctx) + if err != nil { + if errors.IsNotFound(err) { + // Pod doesn't exist (not yet created or already cleaned up) — return empty 200 + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte("")) + return + } + log.Printf("GetSessionLogs: failed to get logs for pod %s: %v", podName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"}) + return + } + defer logStream.Close() + + // Stream logs directly to the client with a size cap to prevent OOM + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, io.LimitReader(logStream, maxLogBytes)); err != nil { + log.Printf("GetSessionLogs: error streaming logs for pod %s: %v", podName, err) + } +} diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go new file mode 100644 index 000000000..55842d56d --- /dev/null +++ b/components/backend/handlers/sessions_metrics.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetSessionMetrics returns usage metrics extracted from the session CR status. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/metrics +func GetSessionMetrics(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + _, k8sDyn := GetK8sClientsForRequest(c) + if k8sDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + item, err := k8sDyn.Resource(gvr).Namespace(project).Get(ctx, sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("GetSessionMetrics: failed to get session %s: %v", sessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + metrics := gin.H{ + "sessionId": sessionName, + } + + // Extract timing info from status + if status, ok := item.Object["status"].(map[string]interface{}); ok { + if phase, ok := status["phase"].(string); ok { + metrics["phase"] = phase + } + if startTime, ok := status["startTime"].(string); ok { + metrics["startTime"] = startTime + + // Calculate duration if possible + start, err := time.Parse(time.RFC3339, startTime) + if err == nil { + var end time.Time + if completionTime, ok := status["completionTime"].(string); ok && completionTime != "" { + end, err = time.Parse(time.RFC3339, completionTime) + if err != nil { + end = time.Now() + } + metrics["completionTime"] = completionTime + } else { + end = time.Now() + } + metrics["durationSeconds"] = int(end.Sub(start).Seconds()) + } + } + if sdkRestartCount, ok := status["sdkRestartCount"].(float64); ok { + metrics["restartCount"] = int(sdkRestartCount) + } + } + + // Extract timeout from spec + if spec, ok := item.Object["spec"].(map[string]interface{}); ok { + if timeout, ok := spec["timeout"].(float64); ok { + metrics["timeoutSeconds"] = int(timeout) + } + } + + // Extract any usage annotations (token counts, tool calls, etc.) + annotations := item.GetAnnotations() + usage := gin.H{} + for k, v := range annotations { + // Look for usage-related annotations + switch k { + case "ambient-code.io/input-tokens": + usage["inputTokens"] = v + case "ambient-code.io/output-tokens": + usage["outputTokens"] = v + case "ambient-code.io/total-cost": + usage["totalCost"] = v + case "ambient-code.io/tool-calls": + usage["toolCalls"] = v + } + } + if len(usage) > 0 { + metrics["usage"] = usage + } + + c.JSON(http.StatusOK, metrics) +} diff --git a/components/backend/handlers/sessions_sub_test.go b/components/backend/handlers/sessions_sub_test.go new file mode 100644 index 000000000..81511d1ae --- /dev/null +++ b/components/backend/handlers/sessions_sub_test.go @@ -0,0 +1,341 @@ +//go:build test + +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "ambient-code-backend/tests/config" + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var _ = Describe("Session Sub-Resource Handlers", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelSessions), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + ctx context.Context + testNamespace string + sessionGVR schema.GroupVersionResource + randomName string + testToken string + ) + + BeforeEach(func() { + logger.Log("Setting up Session Sub-Resource Handler test") + + httpUtils = test_utils.NewHTTPTestUtils() + k8sUtils = test_utils.NewK8sTestUtils(false, *config.TestNamespace) + ctx = context.Background() + randomName = strconv.FormatInt(time.Now().UnixNano(), 10) + testNamespace = "test-sub-" + randomName + + sessionGVR = schema.GroupVersionResource{ + Group: "vteam.ambient-code", + Version: "v1alpha1", + Resource: "agenticsessions", + } + + SetupHandlerDependencies(k8sUtils) + + _, err := k8sUtils.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{Name: testNamespace}, + }, v1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + + _, err = k8sUtils.CreateTestRole(ctx, testNamespace, "test-full-access-role", []string{"get", "list", "create", "update", "delete", "patch"}, "*", "") + Expect(err).NotTo(HaveOccurred()) + + token, _, err := httpUtils.SetValidTestToken( + k8sUtils, + testNamespace, + []string{"get", "list", "create", "update", "delete", "patch"}, + "*", + "", + "test-full-access-role", + ) + Expect(err).NotTo(HaveOccurred()) + testToken = token + }) + + AfterEach(func() { + if k8sUtils != nil && testNamespace != "" { + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(ctx, testNamespace, v1.DeleteOptions{}) + } + }) + + Describe("GetSessionMetrics", func() { + Context("When session exists with status fields", func() { + BeforeEach(func() { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName("metrics-session-"+randomName) + session.SetNamespace(testNamespace) + session.SetAnnotations(map[string]string{ + "ambient-code.io/input-tokens": "1500", + "ambient-code.io/output-tokens": "3200", + "ambient-code.io/total-cost": "0.05", + "ambient-code.io/tool-calls": "12", + }) + + _ = unstructured.SetNestedField(session.Object, "Running", "status", "phase") + _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:00:00Z", "status", "startTime") + _ = unstructured.SetNestedField(session.Object, float64(300), "spec", "timeout") + + _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( + ctx, session, v1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + logger.Log("Created metrics test session with annotations") + }) + + It("Should return metrics with usage data", func() { + sessionName := "metrics-session-" + randomName + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", testNamespace, sessionName) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: sessionName}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("sessionId")) + Expect(response["sessionId"]).To(Equal(sessionName)) + Expect(response).To(HaveKey("phase")) + Expect(response["phase"]).To(Equal("Running")) + Expect(response).To(HaveKey("startTime")) + Expect(response).To(HaveKey("durationSeconds")) + Expect(response).To(HaveKey("timeoutSeconds")) + Expect(response["timeoutSeconds"]).To(BeNumerically("==", 300)) + + // Check usage annotations + Expect(response).To(HaveKey("usage")) + usage, ok := response["usage"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "usage should be a map") + Expect(usage["inputTokens"]).To(Equal("1500")) + Expect(usage["outputTokens"]).To(Equal("3200")) + Expect(usage["totalCost"]).To(Equal("0.05")) + Expect(usage["toolCalls"]).To(Equal("12")) + + logger.Log("Metrics with usage data returned successfully") + }) + }) + + Context("When session exists with completion time", func() { + BeforeEach(func() { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName("completed-session-"+randomName) + session.SetNamespace(testNamespace) + + _ = unstructured.SetNestedField(session.Object, "Completed", "status", "phase") + _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:00:00Z", "status", "startTime") + _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:05:00Z", "status", "completionTime") + + _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( + ctx, session, v1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + logger.Log("Created completed test session") + }) + + It("Should calculate duration from start and completion times", func() { + sessionName := "completed-session-" + randomName + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", testNamespace, sessionName) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: sessionName}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["durationSeconds"]).To(BeNumerically("==", 300), "5 minutes = 300 seconds") + Expect(response).To(HaveKey("completionTime")) + + logger.Log("Duration calculated correctly for completed session") + }) + }) + + Context("When session does not exist", func() { + It("Should return 404 Not Found", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/non-existent/metrics", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "non-existent"}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusNotFound) + httpUtils.AssertErrorMessage("Session not found") + + logger.Log("404 returned for non-existent session metrics") + }) + }) + + Context("When session has no usage annotations", func() { + BeforeEach(func() { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName("no-usage-session-"+randomName) + session.SetNamespace(testNamespace) + + _ = unstructured.SetNestedField(session.Object, "Pending", "status", "phase") + + _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( + ctx, session, v1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should return metrics without usage field", func() { + sessionName := "no-usage-session-" + randomName + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", testNamespace, sessionName) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: sessionName}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("sessionId")) + Expect(response).NotTo(HaveKey("usage"), "Should not include usage when no usage annotations exist") + + logger.Log("Metrics without usage returned correctly") + }) + }) + + Context("When no auth token is provided", func() { + It("Should return 401 Unauthorized", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/metrics", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + // Deliberately NOT setting auth header + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + logger.Log("401 returned for unauthenticated metrics request") + }) + }) + }) + + Describe("GetSessionLogs", func() { + // Note: GetSessionLogs calls k8sClt.CoreV1().Pods().GetLogs() which requires + // a real or fake pod. The fake k8s client doesn't implement pod log streaming, + // so we test the input validation and auth paths here. Integration tests + // cover the full streaming path. + + Context("When no auth token is provided", func() { + It("Should return 401 Unauthorized", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + logger.Log("401 returned for unauthenticated logs request") + }) + }) + + Context("When tailLines parameter is invalid", func() { + It("Should return 400 for non-numeric tailLines", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs?tailLines=abc", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("tailLines must be a positive integer") + + logger.Log("400 returned for non-numeric tailLines") + }) + + It("Should return 400 for negative tailLines", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs?tailLines=-5", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("tailLines must be a positive integer") + + logger.Log("400 returned for negative tailLines") + }) + + It("Should return 400 for zero tailLines", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs?tailLines=0", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("tailLines must be a positive integer") + + logger.Log("400 returned for zero tailLines") + }) + }) + }) +}) diff --git a/components/backend/routes.go b/components/backend/routes.go index d9ec39711..6b3b1b530 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -48,6 +48,8 @@ func registerRoutes(r *gin.Engine) { // Removed: git/pull, git/push, git/synchronize, git/create-branch, git/list-branches - agent handles all git operations projectGroup.GET("/agentic-sessions/:sessionName/git/list-branches", handlers.GitListBranchesSession) projectGroup.GET("/agentic-sessions/:sessionName/pod-events", handlers.GetSessionPodEvents) + projectGroup.GET("/agentic-sessions/:sessionName/logs", handlers.GetSessionLogs) + projectGroup.GET("/agentic-sessions/:sessionName/metrics", handlers.GetSessionMetrics) projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow) projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata) projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo) diff --git a/components/public-api/handlers/integration_test.go b/components/public-api/handlers/integration_test.go index 5f1485dd4..bb7dd75dd 100644 --- a/components/public-api/handlers/integration_test.go +++ b/components/public-api/handlers/integration_test.go @@ -26,7 +26,11 @@ func setupTestRouter() *gin.Engine { v1.GET("/sessions", ListSessions) v1.POST("/sessions", CreateSession) v1.GET("/sessions/:id", GetSession) + v1.PATCH("/sessions/:id", PatchSession) v1.DELETE("/sessions/:id", DeleteSession) + v1.GET("/sessions/:id/logs", GetSessionLogs) + v1.GET("/sessions/:id/transcript", GetSessionTranscript) + v1.GET("/sessions/:id/metrics", GetSessionMetrics) } return r @@ -108,9 +112,9 @@ func TestE2E_CreateSession(t *testing.T) { t.Errorf("Expected status 201, got %d: %s", w.Code, w.Body.String()) } - // Verify request body was transformed correctly - if !strings.Contains(requestBody, "prompt") { - t.Errorf("Expected request body to contain 'prompt', got %s", requestBody) + // Verify request body was transformed correctly (task maps to initialPrompt for backend) + if !strings.Contains(requestBody, "initialPrompt") { + t.Errorf("Expected request body to contain 'initialPrompt', got %s", requestBody) } } diff --git a/components/public-api/handlers/sessions.go b/components/public-api/handlers/sessions.go index 758914e18..8b544abb1 100644 --- a/components/public-api/handlers/sessions.go +++ b/components/public-api/handlers/sessions.go @@ -6,6 +6,7 @@ import ( "io" "log" "net/http" + "strings" "ambient-code-public-api/types" @@ -21,6 +22,11 @@ func ListSessions(c *gin.Context) { } path := fmt.Sprintf("/api/projects/%s/agentic-sessions", project) + // Forward query parameters (e.g., labelSelector) + if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { + path = path + "?" + rawQuery + } + resp, err := ProxyRequest(c, http.MethodGet, path, nil) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) @@ -123,20 +129,42 @@ func CreateSession(c *gin.Context) { // Transform to backend format backendReq := map[string]interface{}{ - "prompt": req.Task, + "initialPrompt": req.Task, } if req.Model != "" { - backendReq["model"] = req.Model + backendReq["llmSettings"] = map[string]interface{}{ + "model": req.Model, + } + } + if req.DisplayName != "" { + backendReq["displayName"] = req.DisplayName + } + if req.Timeout != nil { + backendReq["timeout"] = *req.Timeout + } + if len(req.Labels) > 0 { + keys := make([]string, 0, len(req.Labels)) + for k := range req.Labels { + keys = append(keys, k) + } + if key, prefix, ok := validateLabelKeys(keys); !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("label key %q uses reserved prefix %q", key, prefix)}) + return + } + // Send as annotations (not labels) so the write/read paths are symmetric. + // The read path extracts user labels from metadata.annotations. + backendReq["annotations"] = req.Labels } if len(req.Repos) > 0 { repos := make([]map[string]interface{}, len(req.Repos)) for i, r := range req.Repos { - repos[i] = map[string]interface{}{ - "input": map[string]interface{}{ - "url": r.URL, - "branch": r.Branch, - }, + repo := map[string]interface{}{ + "url": r.URL, + } + if r.Branch != "" { + repo["branch"] = r.Branch } + repos[i] = repo } backendReq["repos"] = repos } @@ -247,6 +275,12 @@ func transformSession(data map[string]interface{}) types.SessionResponse { if creationTimestamp, ok := metadata["creationTimestamp"].(string); ok { session.CreatedAt = creationTimestamp } + if annotationsRaw, ok := metadata["annotations"].(map[string]interface{}); ok { + labels := filterUserLabels(annotationsRaw) + if len(labels) > 0 { + session.Labels = labels + } + } } // If no metadata, try top-level name (list response format) @@ -258,12 +292,29 @@ func transformSession(data map[string]interface{}) types.SessionResponse { // Extract spec if spec, ok := data["spec"].(map[string]interface{}); ok { - if prompt, ok := spec["prompt"].(string); ok { + if prompt, ok := spec["initialPrompt"].(string); ok { + session.Task = prompt + } + if prompt, ok := spec["prompt"].(string); ok && session.Task == "" { session.Task = prompt } if model, ok := spec["model"].(string); ok { session.Model = model } + if llm, ok := spec["llmSettings"].(map[string]interface{}); ok { + if model, ok := llm["model"].(string); ok && session.Model == "" { + session.Model = model + } + } + if displayName, ok := spec["displayName"].(string); ok { + session.DisplayName = displayName + } + if timeout, ok := spec["timeout"].(float64); ok { + session.Timeout = int(timeout) + } + if reposRaw, ok := spec["repos"].([]interface{}); ok { + session.Repos = extractRepos(reposRaw) + } } // Extract status @@ -290,6 +341,78 @@ func transformSession(data map[string]interface{}) types.SessionResponse { return session } +// internalLabelPrefixes are K8s/system label prefixes that should not be exposed to users +var internalLabelPrefixes = []string{ + "app.kubernetes.io/", + "vteam.ambient-code/", + "ambient-code.io/", +} + +// validateLabelKeys checks that no label keys use reserved internal prefixes +func validateLabelKeys(keys []string) (string, string, bool) { + for _, k := range keys { + for _, prefix := range internalLabelPrefixes { + if strings.HasPrefix(k, prefix) { + return k, prefix, false + } + } + } + return "", "", true +} + +// filterUserLabels returns only user-defined labels, stripping internal/system labels +func filterUserLabels(labelsRaw map[string]interface{}) map[string]string { + labels := make(map[string]string) + for k, v := range labelsRaw { + internal := false + for _, prefix := range internalLabelPrefixes { + if strings.HasPrefix(k, prefix) { + internal = true + break + } + } + if !internal { + if s, ok := v.(string); ok { + labels[k] = s + } + } + } + return labels +} + +// extractRepos converts the repos array from the backend response to typed Repo objects +func extractRepos(reposRaw []interface{}) []types.Repo { + repos := make([]types.Repo, 0, len(reposRaw)) + for _, r := range reposRaw { + repoMap, ok := r.(map[string]interface{}) + if !ok { + continue + } + + repo := types.Repo{} + + // Handle both flat format (url/branch at top level) and nested format (input.url/input.branch) + if url, ok := repoMap["url"].(string); ok { + repo.URL = url + } + if branch, ok := repoMap["branch"].(string); ok { + repo.Branch = branch + } + if input, ok := repoMap["input"].(map[string]interface{}); ok { + if url, ok := input["url"].(string); ok { + repo.URL = url + } + if branch, ok := input["branch"].(string); ok { + repo.Branch = branch + } + } + if repo.URL != "" { + repos = append(repos, repo) + } + } + return repos +} + // normalizePhase converts K8s phase to simplified status func normalizePhase(phase string) string { switch phase { diff --git a/components/public-api/handlers/sessions_patch.go b/components/public-api/handlers/sessions_patch.go new file mode 100644 index 000000000..3d19e8a8a --- /dev/null +++ b/components/public-api/handlers/sessions_patch.go @@ -0,0 +1,247 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "ambient-code-public-api/types" + + "github.com/gin-gonic/gin" +) + +// PatchSession handles PATCH /v1/sessions/:id +// It inspects the request body and routes to the correct backend endpoint: +// - stopped: false → POST /start (resume session) +// - stopped: true → POST /stop (stop session) +// - displayName/timeout → PUT (update session spec) +// - labels/removeLabels → PATCH (update annotations) +func PatchSession(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + var req types.PatchSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Classify the request into categories + hasStopped := req.Stopped != nil + hasUpdate := req.DisplayName != nil || req.Timeout != nil + hasLabels := len(req.Labels) > 0 || len(req.RemoveLabels) > 0 + + // Count how many categories are present + categories := 0 + if hasStopped { + categories++ + } + if hasUpdate { + categories++ + } + if hasLabels { + categories++ + } + + if categories == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Request body must contain at least one of: stopped, displayName, timeout, labels, removeLabels"}) + return + } + if categories > 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot mix stopped, spec updates (displayName/timeout), and label changes in the same request"}) + return + } + + basePath := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s", project, sessionID) + + switch { + case hasStopped: + patchSessionStartStop(c, basePath, *req.Stopped) + case hasUpdate: + patchSessionUpdate(c, basePath, req) + case hasLabels: + patchSessionLabels(c, basePath, req) + } +} + +// patchSessionStartStop routes to the backend start or stop endpoint +func patchSessionStartStop(c *gin.Context, basePath string, stopped bool) { + var path string + if stopped { + path = basePath + "/stop" + } else { + path = basePath + "/start" + } + + resp, err := ProxyRequest(c, http.MethodPost, path, nil) + if err != nil { + log.Printf("Backend request failed for start/stop: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Parse and transform the response + var backendResp map[string]interface{} + if err := json.Unmarshal(body, &backendResp); err != nil { + log.Printf("Failed to parse backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, transformSession(backendResp)) +} + +// patchSessionUpdate routes to the backend PUT (UpdateSession) endpoint +func patchSessionUpdate(c *gin.Context, basePath string, req types.PatchSessionRequest) { + // Transform to backend UpdateAgenticSessionRequest format + backendReq := map[string]interface{}{} + if req.DisplayName != nil { + backendReq["displayName"] = *req.DisplayName + } + if req.Timeout != nil { + backendReq["timeout"] = *req.Timeout + } + + reqBody, err := json.Marshal(backendReq) + if err != nil { + log.Printf("Failed to marshal update request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + resp, err := ProxyRequest(c, http.MethodPut, basePath, reqBody) + if err != nil { + log.Printf("Backend request failed for update: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + var backendResp map[string]interface{} + if err := json.Unmarshal(body, &backendResp); err != nil { + log.Printf("Failed to parse backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, transformSession(backendResp)) +} + +// patchSessionLabels routes to the backend PATCH endpoint for annotation changes +func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionRequest) { + // Validate label keys don't use reserved prefixes + allKeys := make([]string, 0, len(req.Labels)+len(req.RemoveLabels)) + for k := range req.Labels { + allKeys = append(allKeys, k) + } + allKeys = append(allKeys, req.RemoveLabels...) + if key, prefix, ok := validateLabelKeys(allKeys); !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("label key %q uses reserved prefix %q", key, prefix)}) + return + } + + // Transform labels to backend annotation format: + // {"metadata": {"annotations": {"key": "value"}}} for adds + // {"metadata": {"annotations": {"key": null}}} for removes + annotations := map[string]interface{}{} + + for k, v := range req.Labels { + annotations[k] = v + } + for _, k := range req.RemoveLabels { + annotations[k] = nil + } + + backendReq := map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": annotations, + }, + } + + reqBody, err := json.Marshal(backendReq) + if err != nil { + log.Printf("Failed to marshal label patch request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + resp, err := ProxyRequest(c, http.MethodPatch, basePath, reqBody) + if err != nil { + log.Printf("Backend request failed for label patch: %v", err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Follow-up GET to return full session DTO (consistent with other PATCH responses) + getResp, err := ProxyRequest(c, http.MethodGet, basePath, nil) + if err != nil { + // PATCH succeeded but GET failed — return success with minimal info + log.Printf("Label patch succeeded but follow-up GET failed: %v", err) + c.JSON(http.StatusOK, gin.H{"message": "Labels updated"}) + return + } + defer getResp.Body.Close() + + getBody, err := io.ReadAll(getResp.Body) + if err != nil || getResp.StatusCode != http.StatusOK { + log.Printf("Label patch succeeded but follow-up GET returned unexpected result") + c.JSON(http.StatusOK, gin.H{"message": "Labels updated"}) + return + } + + var backendResp map[string]interface{} + if err := json.Unmarshal(getBody, &backendResp); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "Labels updated"}) + return + } + + c.JSON(http.StatusOK, transformSession(backendResp)) +} diff --git a/components/public-api/handlers/sessions_patch_test.go b/components/public-api/handlers/sessions_patch_test.go new file mode 100644 index 000000000..486700742 --- /dev/null +++ b/components/public-api/handlers/sessions_patch_test.go @@ -0,0 +1,269 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestE2E_PatchSession_Stop(t *testing.T) { + methodReceived := "" + pathReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methodReceived = r.Method + pathReceived = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + "creationTimestamp": "2026-01-29T10:00:00Z", + }, + "spec": map[string]interface{}{ + "initialPrompt": "Fix the bug", + }, + "status": map[string]interface{}{ + "phase": "Completed", + }, + }) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"stopped": true}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if methodReceived != http.MethodPost { + t.Errorf("Expected POST to backend, got %s", methodReceived) + } + if !strings.HasSuffix(pathReceived, "/stop") { + t.Errorf("Expected path ending in /stop, got %s", pathReceived) + } +} + +func TestE2E_PatchSession_Start(t *testing.T) { + pathReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pathReceived = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + }, + "status": map[string]interface{}{ + "phase": "Running", + }, + }) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"stopped": false}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + if !strings.HasSuffix(pathReceived, "/start") { + t.Errorf("Expected path ending in /start, got %s", pathReceived) + } +} + +func TestE2E_PatchSession_Update(t *testing.T) { + var receivedBody map[string]interface{} + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("Expected PUT, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + }, + "spec": map[string]interface{}{ + "displayName": "New Name", + "timeout": float64(900), + }, + "status": map[string]interface{}{ + "phase": "Pending", + }, + }) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"displayName": "New Name", "timeout": 900}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the backend received the correct fields + if receivedBody["displayName"] != "New Name" { + t.Errorf("Expected displayName 'New Name', got %v", receivedBody["displayName"]) + } + if receivedBody["timeout"] != float64(900) { + t.Errorf("Expected timeout 900, got %v", receivedBody["timeout"]) + } +} + +func TestE2E_PatchSession_Labels(t *testing.T) { + var receivedBody map[string]interface{} + patchReceived := false + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPatch { + patchReceived = true + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Session patched successfully", + "annotations": map[string]string{"env": "prod"}, + }) + } else if r.Method == http.MethodGet { + // Follow-up GET to return full session DTO + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + "creationTimestamp": "2026-03-04T10:00:00Z", + "annotations": map[string]interface{}{"env": "prod"}, + }, + "spec": map[string]interface{}{"initialPrompt": "test"}, + "status": map[string]interface{}{"phase": "Running"}, + }) + } + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"labels": {"env": "prod"}, "removeLabels": ["old-label"]}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if !patchReceived { + t.Errorf("Expected PATCH to be sent to backend") + } + + // Verify transformation to annotation format + metadata, ok := receivedBody["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("Expected metadata in request body") + } + annotations, ok := metadata["annotations"].(map[string]interface{}) + if !ok { + t.Fatal("Expected annotations in metadata") + } + if annotations["env"] != "prod" { + t.Errorf("Expected annotation env=prod, got %v", annotations["env"]) + } + if annotations["old-label"] != nil { + t.Errorf("Expected annotation old-label=nil, got %v", annotations["old-label"]) + } + + // Verify response is a full session DTO (from follow-up GET) + var respBody map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &respBody) + if respBody["id"] != "session-123" { + t.Errorf("Expected session id in response, got %v", respBody["id"]) + } +} + +func TestE2E_PatchSession_ReservedLabelPrefix(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"labels": {"ambient-code.io/evil": "injected"}}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for reserved label prefix, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !strings.Contains(body, "reserved prefix") { + t.Errorf("Expected error about reserved prefix, got: %s", body) + } +} + +func TestE2E_PatchSession_EmptyBody(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for empty body, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestE2E_PatchSession_MixedCategories(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"stopped": true, "displayName": "New Name"}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for mixed categories, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/components/public-api/handlers/sessions_sub.go b/components/public-api/handlers/sessions_sub.go new file mode 100644 index 000000000..47cd5d913 --- /dev/null +++ b/components/public-api/handlers/sessions_sub.go @@ -0,0 +1,142 @@ +package handlers + +import ( + "fmt" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetSessionTranscript handles GET /v1/sessions/:id/transcript +// Proxies to backend GET /api/projects/{p}/agentic-sessions/{s}/export +func GetSessionTranscript(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/export", project, sessionID) + + // Forward format query param if present + if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { + path = path + "?" + rawQuery + } + + resp, err := ProxyRequest(c, http.MethodGet, path, nil) + if err != nil { + log.Printf("Backend request failed for transcript %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Pass through the response as-is (JSON export format) + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(http.StatusOK, contentType, body) +} + +// GetSessionLogs handles GET /v1/sessions/:id/logs +// Proxies to backend GET /api/projects/{p}/agentic-sessions/{s}/logs +func GetSessionLogs(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/logs", project, sessionID) + + // Forward query params (tailLines, container) + if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { + path = path + "?" + rawQuery + } + + resp, err := ProxyRequest(c, http.MethodGet, path, nil) + if err != nil { + log.Printf("Backend request failed for logs %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + c.Data(http.StatusOK, "text/plain; charset=utf-8", body) +} + +// GetSessionMetrics handles GET /v1/sessions/:id/metrics +// Proxies to backend GET /api/projects/{p}/agentic-sessions/{s}/metrics +func GetSessionMetrics(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", project, sessionID) + + resp, err := ProxyRequest(c, http.MethodGet, path, nil) + if err != nil { + log.Printf("Backend request failed for metrics %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + c.Data(http.StatusOK, "application/json", body) +} diff --git a/components/public-api/handlers/sessions_sub_test.go b/components/public-api/handlers/sessions_sub_test.go new file mode 100644 index 000000000..b908d1056 --- /dev/null +++ b/components/public-api/handlers/sessions_sub_test.go @@ -0,0 +1,320 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestE2E_GetSessionTranscript(t *testing.T) { + exportData := map[string]interface{}{ + "sessionId": "session-123", + "projectName": "test-project", + "aguiEvents": []interface{}{}, + } + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/export") { + t.Errorf("Expected path to contain /export, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(exportData) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/transcript", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Expected JSON response: %v", err) + } + if resp["sessionId"] != "session-123" { + t.Errorf("Expected sessionId session-123, got %v", resp["sessionId"]) + } +} + +func TestE2E_GetSessionLogs(t *testing.T) { + logOutput := "2026-01-29T10:00:00Z Starting session\n2026-01-29T10:00:01Z Running task\n" + tailLinesReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/logs") { + t.Errorf("Expected path to contain /logs, got %s", r.URL.Path) + } + tailLinesReceived = r.URL.Query().Get("tailLines") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(logOutput)) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/logs?tailLines=500", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if tailLinesReceived != "500" { + t.Errorf("Expected tailLines=500 forwarded, got %q", tailLinesReceived) + } + + if !strings.Contains(w.Body.String(), "Starting session") { + t.Errorf("Expected log output in response, got %s", w.Body.String()) + } +} + +func TestE2E_GetSessionMetrics(t *testing.T) { + metricsData := map[string]interface{}{ + "sessionId": "session-123", + "phase": "Running", + "durationSeconds": 120, + } + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/metrics") { + t.Errorf("Expected path to contain /metrics, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(metricsData) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/metrics", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Expected JSON response: %v", err) + } + if resp["sessionId"] != "session-123" { + t.Errorf("Expected sessionId session-123, got %v", resp["sessionId"]) + } +} + +func TestE2E_GetSessionLogs_InvalidSessionID(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/INVALID_ID/logs", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid session ID, got %d", w.Code) + } +} + +func TestE2E_GetSessionTranscript_BackendError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Session not found"}) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/transcript", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestE2E_GetSessionLogs_QueryParamForwarding(t *testing.T) { + queryReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queryReceived = r.URL.RawQuery + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/logs?tailLines=100&container=runner", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if !strings.Contains(queryReceived, "tailLines=100") { + t.Errorf("Expected tailLines forwarded, got query: %s", queryReceived) + } + if !strings.Contains(queryReceived, "container=runner") { + t.Errorf("Expected container forwarded, got query: %s", queryReceived) + } +} + +func TestE2E_ListSessions_LabelSelector(t *testing.T) { + queryReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queryReceived = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}}) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions?labelSelector=env%3Dprod", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if !strings.Contains(queryReceived, "labelSelector=env%3Dprod") { + t.Errorf("Expected labelSelector forwarded, got query: %s", queryReceived) + } +} + +func TestFilterUserLabels(t *testing.T) { + input := map[string]interface{}{ + "env": "staging", + "team": "platform", + "app.kubernetes.io/managed-by": "helm", + "vteam.ambient-code/session": "abc", + "ambient-code.io/runner-sa": "my-sa", + "not-a-string": 12345, + } + + result := filterUserLabels(input) + + if result["env"] != "staging" { + t.Errorf("Expected env=staging, got %q", result["env"]) + } + if result["team"] != "platform" { + t.Errorf("Expected team=platform, got %q", result["team"]) + } + if _, ok := result["app.kubernetes.io/managed-by"]; ok { + t.Error("Expected K8s label to be filtered") + } + if _, ok := result["vteam.ambient-code/session"]; ok { + t.Error("Expected vteam label to be filtered") + } + if _, ok := result["ambient-code.io/runner-sa"]; ok { + t.Error("Expected ambient-code label to be filtered") + } + if _, ok := result["not-a-string"]; ok { + t.Error("Expected non-string value to be filtered") + } +} + +func TestExtractRepos(t *testing.T) { + tests := []struct { + name string + input []interface{} + expected int + firstURL string + }{ + { + name: "Flat format", + input: []interface{}{ + map[string]interface{}{ + "url": "https://github.com/org/repo", + "branch": "main", + }, + }, + expected: 1, + firstURL: "https://github.com/org/repo", + }, + { + name: "Nested input format", + input: []interface{}{ + map[string]interface{}{ + "input": map[string]interface{}{ + "url": "https://github.com/org/repo2", + "branch": "dev", + }, + }, + }, + expected: 1, + firstURL: "https://github.com/org/repo2", + }, + { + name: "Empty repos", + input: []interface{}{}, + expected: 0, + }, + { + name: "Invalid entry skipped", + input: []interface{}{ + "not-a-map", + map[string]interface{}{ + "url": "https://github.com/org/valid", + }, + }, + expected: 1, + firstURL: "https://github.com/org/valid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractRepos(tt.input) + if len(result) != tt.expected { + t.Errorf("Expected %d repos, got %d", tt.expected, len(result)) + } + if tt.expected > 0 && result[0].URL != tt.firstURL { + t.Errorf("Expected first URL %q, got %q", tt.firstURL, result[0].URL) + } + }) + } +} diff --git a/components/public-api/handlers/sessions_test.go b/components/public-api/handlers/sessions_test.go index 43e7ff920..d48a6d17a 100644 --- a/components/public-api/handlers/sessions_test.go +++ b/components/public-api/handlers/sessions_test.go @@ -21,10 +21,24 @@ func TestTransformSession(t *testing.T) { "metadata": map[string]interface{}{ "name": "session-123", "creationTimestamp": "2026-01-29T10:00:00Z", + "annotations": map[string]interface{}{ + "env": "staging", + "app.kubernetes.io/foo": "bar", // should be filtered + }, }, "spec": map[string]interface{}{ - "prompt": "Fix the bug", - "model": "claude-sonnet-4", + "initialPrompt": "Fix the bug", + "displayName": "Bug Fix Session", + "timeout": float64(600), + "llmSettings": map[string]interface{}{ + "model": "claude-sonnet-4", + }, + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/org/repo", + "branch": "main", + }, + }, }, "status": map[string]interface{}{ "phase": "Running", @@ -32,11 +46,15 @@ func TestTransformSession(t *testing.T) { }, }, expected: types.SessionResponse{ - ID: "session-123", - Status: "running", - Task: "Fix the bug", - Model: "claude-sonnet-4", - CreatedAt: "2026-01-29T10:00:00Z", + ID: "session-123", + Status: "running", + Task: "Fix the bug", + Model: "claude-sonnet-4", + DisplayName: "Bug Fix Session", + Timeout: 600, + CreatedAt: "2026-01-29T10:00:00Z", + Repos: []types.Repo{{URL: "https://github.com/org/repo", Branch: "main"}}, + Labels: map[string]string{"env": "staging"}, }, }, { @@ -129,6 +147,9 @@ func TestTransformSession(t *testing.T) { if result.Model != tt.expected.Model { t.Errorf("Model = %q, want %q", result.Model, tt.expected.Model) } + if result.DisplayName != tt.expected.DisplayName { + t.Errorf("DisplayName = %q, want %q", result.DisplayName, tt.expected.DisplayName) + } if result.CreatedAt != tt.expected.CreatedAt { t.Errorf("CreatedAt = %q, want %q", result.CreatedAt, tt.expected.CreatedAt) } @@ -141,6 +162,28 @@ func TestTransformSession(t *testing.T) { if result.Error != tt.expected.Error { t.Errorf("Error = %q, want %q", result.Error, tt.expected.Error) } + if result.Timeout != tt.expected.Timeout { + t.Errorf("Timeout = %d, want %d", result.Timeout, tt.expected.Timeout) + } + if len(result.Repos) != len(tt.expected.Repos) { + t.Errorf("Repos len = %d, want %d", len(result.Repos), len(tt.expected.Repos)) + } else { + for i, r := range result.Repos { + if r.URL != tt.expected.Repos[i].URL { + t.Errorf("Repos[%d].URL = %q, want %q", i, r.URL, tt.expected.Repos[i].URL) + } + if r.Branch != tt.expected.Repos[i].Branch { + t.Errorf("Repos[%d].Branch = %q, want %q", i, r.Branch, tt.expected.Repos[i].Branch) + } + } + } + if len(tt.expected.Labels) > 0 { + for k, v := range tt.expected.Labels { + if result.Labels[k] != v { + t.Errorf("Labels[%q] = %q, want %q", k, result.Labels[k], v) + } + } + } }) } } @@ -241,6 +284,59 @@ func TestForwardErrorResponse(t *testing.T) { } } +func TestValidateLabelKeys(t *testing.T) { + tests := []struct { + name string + keys []string + wantOK bool + wantKey string + wantPrefix string + }{ + {"valid user keys", []string{"env", "team", "custom-label"}, true, "", ""}, + {"app.kubernetes.io prefix rejected", []string{"app.kubernetes.io/name"}, false, "app.kubernetes.io/name", "app.kubernetes.io/"}, + {"vteam.ambient-code prefix rejected", []string{"vteam.ambient-code/session"}, false, "vteam.ambient-code/session", "vteam.ambient-code/"}, + {"ambient-code.io prefix rejected", []string{"ambient-code.io/runner-sa"}, false, "ambient-code.io/runner-sa", "ambient-code.io/"}, + {"mixed valid and reserved", []string{"env", "ambient-code.io/evil"}, false, "ambient-code.io/evil", "ambient-code.io/"}, + {"empty keys list", []string{}, true, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, prefix, ok := validateLabelKeys(tt.keys) + if ok != tt.wantOK { + t.Errorf("validateLabelKeys() ok = %v, want %v", ok, tt.wantOK) + } + if key != tt.wantKey { + t.Errorf("validateLabelKeys() key = %q, want %q", key, tt.wantKey) + } + if prefix != tt.wantPrefix { + t.Errorf("validateLabelKeys() prefix = %q, want %q", prefix, tt.wantPrefix) + } + }) + } +} + +func TestFilterUserLabels_EmptyValues(t *testing.T) { + input := map[string]interface{}{ + "tag": "", + "env": "prod", + "app.kubernetes.io/foo": "filtered", + } + result := filterUserLabels(input) + + if val, exists := result["tag"]; !exists { + t.Error("expected empty-string label key to be present in result") + } else if val != "" { + t.Errorf("expected empty-string label value, got %q", val) + } + if result["env"] != "prod" { + t.Errorf("expected env=prod, got %q", result["env"]) + } + if _, exists := result["app.kubernetes.io/foo"]; exists { + t.Error("expected internal label to be filtered out") + } +} + func TestTransformSession_TypeSafety(t *testing.T) { // Test that transformSession handles incorrect types gracefully tests := []struct { diff --git a/components/public-api/main.go b/components/public-api/main.go index fb20ac418..887a8aa95 100644 --- a/components/public-api/main.go +++ b/components/public-api/main.go @@ -91,11 +91,17 @@ func main() { v1 := r.Group("/v1") v1.Use(handlers.AuthMiddleware()) { - // Sessions + // Sessions CRUD v1.GET("/sessions", handlers.ListSessions) v1.POST("/sessions", handlers.CreateSession) v1.GET("/sessions/:id", handlers.GetSession) + v1.PATCH("/sessions/:id", handlers.PatchSession) v1.DELETE("/sessions/:id", handlers.DeleteSession) + + // Session sub-resources + v1.GET("/sessions/:id/logs", handlers.GetSessionLogs) + v1.GET("/sessions/:id/transcript", handlers.GetSessionTranscript) + v1.GET("/sessions/:id/metrics", handlers.GetSessionMetrics) } // Get port from environment or default to 8081 @@ -143,8 +149,8 @@ func getAllowedOrigins() []string { // Default: allow common development origins return []string{ - "http://localhost:3000", // Next.js dev server - "http://localhost:8080", // Frontend in kind + "http://localhost:3000", // Next.js dev server + "http://localhost:8080", // Frontend in kind "https://*.apps-crc.testing", // CRC routes } } diff --git a/components/public-api/types/dto.go b/components/public-api/types/dto.go index 9faafa8d1..98bd3740c 100644 --- a/components/public-api/types/dto.go +++ b/components/public-api/types/dto.go @@ -2,14 +2,18 @@ package types // SessionResponse is the simplified session response for the public API type SessionResponse struct { - ID string `json:"id"` - Status string `json:"status"` // "pending", "running", "completed", "failed" - Task string `json:"task"` - Model string `json:"model,omitempty"` - CreatedAt string `json:"createdAt"` - CompletedAt string `json:"completedAt,omitempty"` - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Status string `json:"status"` // "pending", "running", "completed", "failed" + Task string `json:"task"` + Model string `json:"model,omitempty"` + DisplayName string `json:"displayName,omitempty"` + CreatedAt string `json:"createdAt"` + CompletedAt string `json:"completedAt,omitempty"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Repos []Repo `json:"repos,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Timeout int `json:"timeout,omitempty"` } // SessionListResponse is the response for listing sessions @@ -20,9 +24,21 @@ type SessionListResponse struct { // CreateSessionRequest is the request body for creating a session type CreateSessionRequest struct { - Task string `json:"task" binding:"required"` - Model string `json:"model,omitempty"` - Repos []Repo `json:"repos,omitempty"` + Task string `json:"task" binding:"required"` + Model string `json:"model,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Repos []Repo `json:"repos,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Timeout *int `json:"timeout,omitempty"` +} + +// PatchSessionRequest is the request body for patching a session +type PatchSessionRequest struct { + Stopped *bool `json:"stopped,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + RemoveLabels []string `json:"removeLabels,omitempty"` } // Repo represents a repository configuration diff --git a/e2e/scripts/deploy.sh b/e2e/scripts/deploy.sh index bf0b7a9fe..f4cde8b66 100755 --- a/e2e/scripts/deploy.sh +++ b/e2e/scripts/deploy.sh @@ -49,13 +49,14 @@ if [ -f ".env" ]; then source .env # Log image overrides - if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_STATE_SYNC:-}" ]; then + if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_STATE_SYNC:-}${IMAGE_PUBLIC_API:-}" ]; then echo " ℹ️ Image overrides from .env:" [ -n "${IMAGE_BACKEND:-}" ] && echo " Backend: ${IMAGE_BACKEND}" [ -n "${IMAGE_FRONTEND:-}" ] && echo " Frontend: ${IMAGE_FRONTEND}" [ -n "${IMAGE_OPERATOR:-}" ] && echo " Operator: ${IMAGE_OPERATOR}" [ -n "${IMAGE_RUNNER:-}" ] && echo " Runner: ${IMAGE_RUNNER}" [ -n "${IMAGE_STATE_SYNC:-}" ] && echo " State-sync: ${IMAGE_STATE_SYNC}" + [ -n "${IMAGE_PUBLIC_API:-}" ] && echo " Public-API: ${IMAGE_PUBLIC_API}" fi fi @@ -67,6 +68,7 @@ kubectl kustomize ../components/manifests/overlays/kind/ | \ sed "s|quay.io/ambient_code/vteam_operator:latest|${IMAGE_OPERATOR:-quay.io/ambient_code/vteam_operator:latest}|g" | \ sed "s|quay.io/ambient_code/vteam_claude_runner:latest|${IMAGE_RUNNER:-quay.io/ambient_code/vteam_claude_runner:latest}|g" | \ sed "s|quay.io/ambient_code/vteam_state_sync:latest|${IMAGE_STATE_SYNC:-quay.io/ambient_code/vteam_state_sync:latest}|g" | \ + sed "s|quay.io/ambient_code/vteam_public_api:latest|${IMAGE_PUBLIC_API:-quay.io/ambient_code/vteam_public_api:latest}|g" | \ kubectl apply --validate=false -f - # Inject ANTHROPIC_API_KEY if set (for agent testing) diff --git a/scripts/setup-vertex-kind.sh b/scripts/setup-vertex-kind.sh index 6ba875247..593835885 100755 --- a/scripts/setup-vertex-kind.sh +++ b/scripts/setup-vertex-kind.sh @@ -152,10 +152,11 @@ kubectl patch configmap operator-config -n "$NAMESPACE" --type merge -p "{ echo " Done" echo "" -# Step 3: Restart operator to pick up changes -echo "Step 3/3: Restarting operator to apply changes..." -kubectl rollout restart deployment agentic-operator -n "$NAMESPACE" +# Step 3: Restart operator and backend to pick up changes +echo "Step 3/3: Restarting operator and backend to apply changes..." +kubectl rollout restart deployment agentic-operator backend-api -n "$NAMESPACE" kubectl rollout status deployment agentic-operator -n "$NAMESPACE" --timeout=60s +kubectl rollout status deployment backend-api -n "$NAMESPACE" --timeout=60s echo "" echo "=== Setup Complete ===" From 080b205dad8f1b90a7f48a9da8cc07c125dfaeff Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Wed, 4 Mar 2026 17:31:35 -0500 Subject: [PATCH 02/12] style: apply gofmt formatting --- components/ambient-sdk/generator/main.go | 4 ++-- components/ambient-sdk/generator/model.go | 2 +- components/ambient-sdk/generator/parser.go | 12 ++++++------ components/ambient-sdk/go-sdk/client/client.go | 1 - components/backend/handlers/sessions_sub_test.go | 6 +++--- components/public-api/handlers/middleware.go | 7 ++++--- .../public-api/handlers/sessions_patch_test.go | 2 +- components/public-api/handlers/sessions_test.go | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/components/ambient-sdk/generator/main.go b/components/ambient-sdk/generator/main.go index 3eb9cc21a..1407a9226 100644 --- a/components/ambient-sdk/generator/main.go +++ b/components/ambient-sdk/generator/main.go @@ -299,8 +299,8 @@ func generatePython(spec *Spec, outDir string, header GeneratedHeader) error { func loadTemplate(path string) (*template.Template, error) { funcMap := template.FuncMap{ - "snakeCase": toSnakeCase, - "lower": strings.ToLower, + "snakeCase": toSnakeCase, + "lower": strings.ToLower, "title": func(s string) string { if s == "" { return s diff --git a/components/ambient-sdk/generator/model.go b/components/ambient-sdk/generator/model.go index eb65b4676..508f80014 100644 --- a/components/ambient-sdk/generator/model.go +++ b/components/ambient-sdk/generator/model.go @@ -221,7 +221,7 @@ func pluralize(name string) string { // Check for already plural words ending in settings, data, etc. if strings.HasSuffix(lower, "settings") || strings.HasSuffix(lower, "data") || - strings.HasSuffix(lower, "metadata") || strings.HasSuffix(lower, "info") { + strings.HasSuffix(lower, "metadata") || strings.HasSuffix(lower, "info") { return lower } diff --git a/components/ambient-sdk/generator/parser.go b/components/ambient-sdk/generator/parser.go index 2e5857481..1dd11c4da 100644 --- a/components/ambient-sdk/generator/parser.go +++ b/components/ambient-sdk/generator/parser.go @@ -38,16 +38,16 @@ func parseSpec(specPath string) (*Spec, error) { specDir := filepath.Dir(specPath) resourceFiles := map[string]string{ - "Session": "openapi.sessions.yaml", - "User": "openapi.users.yaml", - "Project": "openapi.projects.yaml", + "Session": "openapi.sessions.yaml", + "User": "openapi.users.yaml", + "Project": "openapi.projects.yaml", "ProjectSettings": "openapi.projectSettings.yaml", } pathSegments := map[string]string{ - "Session": "sessions", - "User": "users", - "Project": "projects", + "Session": "sessions", + "User": "users", + "Project": "projects", "ProjectSettings": "project_settings", } diff --git a/components/ambient-sdk/go-sdk/client/client.go b/components/ambient-sdk/go-sdk/client/client.go index e4783b318..7ba03b19b 100644 --- a/components/ambient-sdk/go-sdk/client/client.go +++ b/components/ambient-sdk/go-sdk/client/client.go @@ -64,7 +64,6 @@ func NewClient(baseURL, token, project string, opts ...ClientOption) (*Client, e return nil, fmt.Errorf("placeholder token is not allowed") } - if len(project) > 63 { return nil, fmt.Errorf("project name cannot exceed 63 characters") } diff --git a/components/backend/handlers/sessions_sub_test.go b/components/backend/handlers/sessions_sub_test.go index 81511d1ae..268be9f34 100644 --- a/components/backend/handlers/sessions_sub_test.go +++ b/components/backend/handlers/sessions_sub_test.go @@ -86,7 +86,7 @@ var _ = Describe("Session Sub-Resource Handlers", Label(test_constants.LabelUnit session := &unstructured.Unstructured{} session.SetAPIVersion("vteam.ambient-code/v1alpha1") session.SetKind("AgenticSession") - session.SetName("metrics-session-"+randomName) + session.SetName("metrics-session-" + randomName) session.SetNamespace(testNamespace) session.SetAnnotations(map[string]string{ "ambient-code.io/input-tokens": "1500", @@ -149,7 +149,7 @@ var _ = Describe("Session Sub-Resource Handlers", Label(test_constants.LabelUnit session := &unstructured.Unstructured{} session.SetAPIVersion("vteam.ambient-code/v1alpha1") session.SetKind("AgenticSession") - session.SetName("completed-session-"+randomName) + session.SetName("completed-session-" + randomName) session.SetNamespace(testNamespace) _ = unstructured.SetNestedField(session.Object, "Completed", "status", "phase") @@ -210,7 +210,7 @@ var _ = Describe("Session Sub-Resource Handlers", Label(test_constants.LabelUnit session := &unstructured.Unstructured{} session.SetAPIVersion("vteam.ambient-code/v1alpha1") session.SetKind("AgenticSession") - session.SetName("no-usage-session-"+randomName) + session.SetName("no-usage-session-" + randomName) session.SetNamespace(testNamespace) _ = unstructured.SetNestedField(session.Object, "Pending", "status", "phase") diff --git a/components/public-api/handlers/middleware.go b/components/public-api/handlers/middleware.go index 5b866f6e2..7175dc02c 100644 --- a/components/public-api/handlers/middleware.go +++ b/components/public-api/handlers/middleware.go @@ -109,9 +109,10 @@ func extractProjectFromToken(token string) string { // 1. PURPOSE: Used exclusively for routing (extracting project namespace from ServiceAccount tokens) // 2. NO TRUST: The extracted value is NEVER used for authorization decisions // 3. BACKEND VALIDATES: The Go backend performs FULL token validation including: -// - Signature verification against K8s API server public keys -// - Expiration checking -// - RBAC enforcement via SelfSubjectAccessReview +// - Signature verification against K8s API server public keys +// - Expiration checking +// - RBAC enforcement via SelfSubjectAccessReview +// // 4. FAIL-SAFE: If the token is invalid/forged, the backend rejects it with 401/403 // // DO NOT use this function's output for: diff --git a/components/public-api/handlers/sessions_patch_test.go b/components/public-api/handlers/sessions_patch_test.go index 486700742..405b03515 100644 --- a/components/public-api/handlers/sessions_patch_test.go +++ b/components/public-api/handlers/sessions_patch_test.go @@ -164,7 +164,7 @@ func TestE2E_PatchSession_Labels(t *testing.T) { "metadata": map[string]interface{}{ "name": "session-123", "creationTimestamp": "2026-03-04T10:00:00Z", - "annotations": map[string]interface{}{"env": "prod"}, + "annotations": map[string]interface{}{"env": "prod"}, }, "spec": map[string]interface{}{"initialPrompt": "test"}, "status": map[string]interface{}{"phase": "Running"}, diff --git a/components/public-api/handlers/sessions_test.go b/components/public-api/handlers/sessions_test.go index d48a6d17a..e286e5e84 100644 --- a/components/public-api/handlers/sessions_test.go +++ b/components/public-api/handlers/sessions_test.go @@ -22,7 +22,7 @@ func TestTransformSession(t *testing.T) { "name": "session-123", "creationTimestamp": "2026-01-29T10:00:00Z", "annotations": map[string]interface{}{ - "env": "staging", + "env": "staging", "app.kubernetes.io/foo": "bar", // should be filtered }, }, From e29b67906fc70882454f79d57fed658ba2fe699b Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Wed, 4 Mar 2026 17:49:45 -0500 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20unstructured=20helpers,=20bind=20error,=20query=20c?= =?UTF-8?q?omments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use unstructured.NestedString/NestedFloat64 in sessions_metrics.go instead of direct type assertions - Replace raw JSON bind error with generic "Invalid request body" in PatchSession - Add safety comments explaining why raw query forwarding is safe in ListSessions, GetSessionTranscript, and GetSessionLogs Co-Authored-By: Claude Opus 4.6 --- .../backend/handlers/sessions_metrics.go | 49 +++++++++---------- components/public-api/handlers/sessions.go | 4 +- .../public-api/handlers/sessions_patch.go | 2 +- .../public-api/handlers/sessions_sub.go | 8 ++- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index 55842d56d..75aa8d603 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // GetSessionMetrics returns usage metrics extracted from the session CR status. @@ -47,40 +48,36 @@ func GetSessionMetrics(c *gin.Context) { "sessionId": sessionName, } - // Extract timing info from status - if status, ok := item.Object["status"].(map[string]interface{}); ok { - if phase, ok := status["phase"].(string); ok { - metrics["phase"] = phase - } - if startTime, ok := status["startTime"].(string); ok { - metrics["startTime"] = startTime + // Extract timing info from status using unstructured helpers + if phase, ok, _ := unstructured.NestedString(item.Object, "status", "phase"); ok { + metrics["phase"] = phase + } + if startTime, ok, _ := unstructured.NestedString(item.Object, "status", "startTime"); ok { + metrics["startTime"] = startTime - // Calculate duration if possible - start, err := time.Parse(time.RFC3339, startTime) - if err == nil { - var end time.Time - if completionTime, ok := status["completionTime"].(string); ok && completionTime != "" { - end, err = time.Parse(time.RFC3339, completionTime) - if err != nil { - end = time.Now() - } - metrics["completionTime"] = completionTime - } else { + // Calculate duration if possible + start, err := time.Parse(time.RFC3339, startTime) + if err == nil { + var end time.Time + if completionTime, ok, _ := unstructured.NestedString(item.Object, "status", "completionTime"); ok && completionTime != "" { + end, err = time.Parse(time.RFC3339, completionTime) + if err != nil { end = time.Now() } - metrics["durationSeconds"] = int(end.Sub(start).Seconds()) + metrics["completionTime"] = completionTime + } else { + end = time.Now() } - } - if sdkRestartCount, ok := status["sdkRestartCount"].(float64); ok { - metrics["restartCount"] = int(sdkRestartCount) + metrics["durationSeconds"] = int(end.Sub(start).Seconds()) } } + if sdkRestartCount, ok, _ := unstructured.NestedFloat64(item.Object, "status", "sdkRestartCount"); ok { + metrics["restartCount"] = int(sdkRestartCount) + } // Extract timeout from spec - if spec, ok := item.Object["spec"].(map[string]interface{}); ok { - if timeout, ok := spec["timeout"].(float64); ok { - metrics["timeoutSeconds"] = int(timeout) - } + if timeout, ok, _ := unstructured.NestedFloat64(item.Object, "spec", "timeout"); ok { + metrics["timeoutSeconds"] = int(timeout) } // Extract any usage annotations (token counts, tool calls, etc.) diff --git a/components/public-api/handlers/sessions.go b/components/public-api/handlers/sessions.go index 8b544abb1..c412dc7dd 100644 --- a/components/public-api/handlers/sessions.go +++ b/components/public-api/handlers/sessions.go @@ -22,7 +22,9 @@ func ListSessions(c *gin.Context) { } path := fmt.Sprintf("/api/projects/%s/agentic-sessions", project) - // Forward query parameters (e.g., labelSelector) + // Forward query parameters (e.g., labelSelector) to the backend as-is. + // Safe: the backend ignores unknown params and enforces RBAC via the + // user's own token, so no privilege escalation is possible. if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { path = path + "?" + rawQuery } diff --git a/components/public-api/handlers/sessions_patch.go b/components/public-api/handlers/sessions_patch.go index 3d19e8a8a..95460df88 100644 --- a/components/public-api/handlers/sessions_patch.go +++ b/components/public-api/handlers/sessions_patch.go @@ -32,7 +32,7 @@ func PatchSession(c *gin.Context) { var req types.PatchSessionRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } diff --git a/components/public-api/handlers/sessions_sub.go b/components/public-api/handlers/sessions_sub.go index 47cd5d913..22ad70e19 100644 --- a/components/public-api/handlers/sessions_sub.go +++ b/components/public-api/handlers/sessions_sub.go @@ -25,7 +25,9 @@ func GetSessionTranscript(c *gin.Context) { path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/export", project, sessionID) - // Forward format query param if present + // Forward query params (e.g., format) to the backend as-is. + // Safe: the backend ignores unknown params and enforces RBAC via the + // user's own token, so no privilege escalation is possible. if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { path = path + "?" + rawQuery } @@ -74,7 +76,9 @@ func GetSessionLogs(c *gin.Context) { path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/logs", project, sessionID) - // Forward query params (tailLines, container) + // Forward query params (tailLines, container) to the backend as-is. + // Safe: the backend ignores unknown params and enforces RBAC via the + // user's own token, so no privilege escalation is possible. if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { path = path + "?" + rawQuery } From 44f9dc9cb0ae971062e625adc3a2fee29c844802 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Wed, 4 Mar 2026 19:16:27 -0500 Subject: [PATCH 04/12] fix: filter kubectl/meta K8s prefixes, align auth pattern, refine comments - Add kubectl.kubernetes.io/ and meta.kubernetes.io/ to internalLabelPrefixes to prevent leaking last-applied-configuration - Align sessions_metrics.go auth check to use k8sClt for consistency with sessions_logs.go - Refine query forwarding comments to note backend validates params Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_metrics.go | 4 ++-- components/public-api/handlers/sessions.go | 7 ++++--- components/public-api/handlers/sessions_sub.go | 10 ++++------ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index 75aa8d603..ada8dfb5b 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -21,8 +21,8 @@ func GetSessionMetrics(c *gin.Context) { } sessionName := c.Param("sessionName") - _, k8sDyn := GetK8sClientsForRequest(c) - if k8sDyn == nil { + k8sClt, k8sDyn := GetK8sClientsForRequest(c) + if k8sClt == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) c.Abort() return diff --git a/components/public-api/handlers/sessions.go b/components/public-api/handlers/sessions.go index c412dc7dd..a17a13f8a 100644 --- a/components/public-api/handlers/sessions.go +++ b/components/public-api/handlers/sessions.go @@ -22,9 +22,8 @@ func ListSessions(c *gin.Context) { } path := fmt.Sprintf("/api/projects/%s/agentic-sessions", project) - // Forward query parameters (e.g., labelSelector) to the backend as-is. - // Safe: the backend ignores unknown params and enforces RBAC via the - // user's own token, so no privilege escalation is possible. + // Forward query parameters (e.g., labelSelector) verbatim to the backend. + // The backend enforces its own validation and RBAC via the user's token. if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { path = path + "?" + rawQuery } @@ -346,6 +345,8 @@ func transformSession(data map[string]interface{}) types.SessionResponse { // internalLabelPrefixes are K8s/system label prefixes that should not be exposed to users var internalLabelPrefixes = []string{ "app.kubernetes.io/", + "kubectl.kubernetes.io/", + "meta.kubernetes.io/", "vteam.ambient-code/", "ambient-code.io/", } diff --git a/components/public-api/handlers/sessions_sub.go b/components/public-api/handlers/sessions_sub.go index 22ad70e19..2b62ba8e8 100644 --- a/components/public-api/handlers/sessions_sub.go +++ b/components/public-api/handlers/sessions_sub.go @@ -25,9 +25,8 @@ func GetSessionTranscript(c *gin.Context) { path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/export", project, sessionID) - // Forward query params (e.g., format) to the backend as-is. - // Safe: the backend ignores unknown params and enforces RBAC via the - // user's own token, so no privilege escalation is possible. + // Forward query params (e.g., format) verbatim to the backend. + // The backend enforces its own validation and RBAC via the user's token. if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { path = path + "?" + rawQuery } @@ -76,9 +75,8 @@ func GetSessionLogs(c *gin.Context) { path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/logs", project, sessionID) - // Forward query params (tailLines, container) to the backend as-is. - // Safe: the backend ignores unknown params and enforces RBAC via the - // user's own token, so no privilege escalation is possible. + // Forward query params (tailLines, container) verbatim to the backend. + // The backend enforces its own validation and RBAC via the user's token. if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { path = path + "?" + rawQuery } From b73fdedfab5e63646bc49469618f0ae1f97ec3f1 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Mon, 9 Mar 2026 11:31:47 -0400 Subject: [PATCH 05/12] fix: sanitize sessionName param in logs/metrics handlers Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_logs.go | 2 +- components/backend/handlers/sessions_metrics.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index deee2af00..a4a5ca019 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -31,7 +31,7 @@ func GetSessionLogs(c *gin.Context) { if project == "" { project = c.Param("projectName") } - sessionName := c.Param("sessionName") + sessionName := SanitizeForLog(c.Param("sessionName")) k8sClt, _ := GetK8sClientsForRequest(c) if k8sClt == nil { diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index ada8dfb5b..ae586be7e 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -19,7 +19,7 @@ func GetSessionMetrics(c *gin.Context) { if project == "" { project = c.Param("projectName") } - sessionName := c.Param("sessionName") + sessionName := SanitizeForLog(c.Param("sessionName")) k8sClt, k8sDyn := GetK8sClientsForRequest(c) if k8sClt == nil { From 11813e063fcb76eb3885938896f26f4758e8d797 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Mon, 9 Mar 2026 11:47:36 -0400 Subject: [PATCH 06/12] fix: stream logs in public-api proxy, clarify SanitizeForLog usage Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_logs.go | 2 ++ .../backend/handlers/sessions_metrics.go | 2 ++ .../public-api/handlers/sessions_sub.go | 22 ++++++++++++------- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index a4a5ca019..7e2608c2c 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -31,6 +31,8 @@ func GetSessionLogs(c *gin.Context) { if project == "" { project = c.Param("projectName") } + // SanitizeForLog strips control characters for log-injection safety. + // Safe to reuse as K8s lookup key — K8s names cannot contain control characters. sessionName := SanitizeForLog(c.Param("sessionName")) k8sClt, _ := GetK8sClientsForRequest(c) diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index ae586be7e..5660fcd68 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -19,6 +19,8 @@ func GetSessionMetrics(c *gin.Context) { if project == "" { project = c.Param("projectName") } + // SanitizeForLog strips control characters for log-injection safety. + // Safe to reuse as K8s lookup key — K8s names cannot contain control characters. sessionName := SanitizeForLog(c.Param("sessionName")) k8sClt, k8sDyn := GetK8sClientsForRequest(c) diff --git a/components/public-api/handlers/sessions_sub.go b/components/public-api/handlers/sessions_sub.go index 2b62ba8e8..2c7cb177f 100644 --- a/components/public-api/handlers/sessions_sub.go +++ b/components/public-api/handlers/sessions_sub.go @@ -89,19 +89,25 @@ func GetSessionLogs(c *gin.Context) { } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Printf("Failed to read backend response: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) - return - } - + // For non-OK responses, buffer to forward the error body if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend error response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } forwardErrorResponse(c, resp.StatusCode, body) return } - c.Data(http.StatusOK, "text/plain; charset=utf-8", body) + // Stream the log response directly to the client to avoid buffering up to + // 10 MB (the backend's LimitReader cap) per concurrent request. + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, resp.Body); err != nil { + log.Printf("GetSessionLogs: error streaming backend response for %s: %v", sessionID, err) + } } // GetSessionMetrics handles GET /v1/sessions/:id/metrics From 44a8a09f292f72b073e89d06c164adfefe7e75d2 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Mon, 9 Mar 2026 12:02:17 -0400 Subject: [PATCH 07/12] fix: handle K8s 403, stream transcript, align update status codes, add session ID to label fallback Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_logs.go | 4 ++++ .../backend/handlers/sessions_metrics.go | 4 ++++ .../public-api/handlers/sessions_patch.go | 8 +++---- .../public-api/handlers/sessions_sub.go | 22 +++++++++++-------- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index 7e2608c2c..d1eced9fe 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -80,6 +80,10 @@ func GetSessionLogs(c *gin.Context) { c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte("")) return } + if errors.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } log.Printf("GetSessionLogs: failed to get logs for pod %s: %v", podName, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"}) return diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index 5660fcd68..923dc6bd0 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -41,6 +41,10 @@ func GetSessionMetrics(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) return } + if errors.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } log.Printf("GetSessionMetrics: failed to get session %s: %v", sessionName, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) return diff --git a/components/public-api/handlers/sessions_patch.go b/components/public-api/handlers/sessions_patch.go index 95460df88..d810cb285 100644 --- a/components/public-api/handlers/sessions_patch.go +++ b/components/public-api/handlers/sessions_patch.go @@ -147,7 +147,7 @@ func patchSessionUpdate(c *gin.Context, basePath string, req types.PatchSessionR return } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { forwardErrorResponse(c, resp.StatusCode, body) return } @@ -225,7 +225,7 @@ func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionR if err != nil { // PATCH succeeded but GET failed — return success with minimal info log.Printf("Label patch succeeded but follow-up GET failed: %v", err) - c.JSON(http.StatusOK, gin.H{"message": "Labels updated"}) + c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) return } defer getResp.Body.Close() @@ -233,13 +233,13 @@ func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionR getBody, err := io.ReadAll(getResp.Body) if err != nil || getResp.StatusCode != http.StatusOK { log.Printf("Label patch succeeded but follow-up GET returned unexpected result") - c.JSON(http.StatusOK, gin.H{"message": "Labels updated"}) + c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) return } var backendResp map[string]interface{} if err := json.Unmarshal(getBody, &backendResp); err != nil { - c.JSON(http.StatusOK, gin.H{"message": "Labels updated"}) + c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) return } diff --git a/components/public-api/handlers/sessions_sub.go b/components/public-api/handlers/sessions_sub.go index 2c7cb177f..8aaa64f9d 100644 --- a/components/public-api/handlers/sessions_sub.go +++ b/components/public-api/handlers/sessions_sub.go @@ -39,24 +39,28 @@ func GetSessionTranscript(c *gin.Context) { } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Printf("Failed to read backend response: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) - return - } - + // For non-OK responses, buffer to forward the error body if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend error response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } forwardErrorResponse(c, resp.StatusCode, body) return } - // Pass through the response as-is (JSON export format) + // Stream the transcript response directly to the client contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/json" } - c.Data(http.StatusOK, contentType, body) + c.Header("Content-Type", contentType) + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, resp.Body); err != nil { + log.Printf("GetSessionTranscript: error streaming backend response for %s: %v", sessionID, err) + } } // GetSessionLogs handles GET /v1/sessions/:id/logs From a4df45ec8e36b12a4636349192c174489c2a473d Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Mon, 9 Mar 2026 13:09:58 -0400 Subject: [PATCH 08/12] fix: add project/session context to error log messages Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_logs.go | 4 ++-- components/backend/handlers/sessions_metrics.go | 2 +- components/public-api/handlers/sessions_patch.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index d1eced9fe..14d7455e4 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -84,7 +84,7 @@ func GetSessionLogs(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } - log.Printf("GetSessionLogs: failed to get logs for pod %s: %v", podName, err) + log.Printf("GetSessionLogs: failed to get logs for pod %s in project %s: %v", podName, project, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"}) return } @@ -94,6 +94,6 @@ func GetSessionLogs(c *gin.Context) { c.Header("Content-Type", "text/plain; charset=utf-8") c.Status(http.StatusOK) if _, err := io.Copy(c.Writer, io.LimitReader(logStream, maxLogBytes)); err != nil { - log.Printf("GetSessionLogs: error streaming logs for pod %s: %v", podName, err) + log.Printf("GetSessionLogs: error streaming logs for pod %s in project %s: %v", podName, project, err) } } diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index 923dc6bd0..28c6caca2 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -45,7 +45,7 @@ func GetSessionMetrics(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } - log.Printf("GetSessionMetrics: failed to get session %s: %v", sessionName, err) + log.Printf("GetSessionMetrics: failed to get session %s/%s: %v", project, sessionName, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) return } diff --git a/components/public-api/handlers/sessions_patch.go b/components/public-api/handlers/sessions_patch.go index d810cb285..ed4f70d0b 100644 --- a/components/public-api/handlers/sessions_patch.go +++ b/components/public-api/handlers/sessions_patch.go @@ -232,7 +232,7 @@ func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionR getBody, err := io.ReadAll(getResp.Body) if err != nil || getResp.StatusCode != http.StatusOK { - log.Printf("Label patch succeeded but follow-up GET returned unexpected result") + log.Printf("Label patch succeeded but follow-up GET returned unexpected result for session %s", c.Param("id")) c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) return } From ee74c9120b2c9d658769647ec945bca708617ed5 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Mon, 9 Mar 2026 14:10:44 -0400 Subject: [PATCH 09/12] fix: verify session CR before log retrieval, fix duration on bad timestamp, add audit logging - GetSessionLogs now checks session CR exists before pod log access (returns 404 for unknown sessions) - Skip durationSeconds/completionTime when completionTime fails RFC3339 parse - Log IsForbidden attempts in both backend handlers for security auditing - Add sessionID context to all patch error log messages - Document follow-up GET race window in patchSessionLabels Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_logs.go | 28 ++++++++++++++++--- .../backend/handlers/sessions_metrics.go | 11 ++++++-- .../public-api/handlers/sessions_patch.go | 10 ++++--- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index 14d7455e4..cd5b63f70 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -35,13 +36,34 @@ func GetSessionLogs(c *gin.Context) { // Safe to reuse as K8s lookup key — K8s names cannot contain control characters. sessionName := SanitizeForLog(c.Param("sessionName")) - k8sClt, _ := GetK8sClientsForRequest(c) + k8sClt, k8sDyn := GetK8sClientsForRequest(c) if k8sClt == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) c.Abort() return } + // Verify the session CR exists before attempting pod log retrieval + gvr := GetAgenticSessionV1Alpha1Resource() + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + _, err := k8sDyn.Resource(gvr).Namespace(project).Get(ctx, sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + if errors.IsForbidden(err) { + log.Printf("GetSessionLogs: access denied for session %s/%s", project, sessionName) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + log.Printf("GetSessionLogs: failed to verify session %s/%s: %v", project, sessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify session"}) + return + } + // Parse tailLines query param tailLines := defaultTailLines if tl := c.Query("tailLines"); tl != "" { @@ -62,9 +84,6 @@ func GetSessionLogs(c *gin.Context) { // Must match operator pod creation in internal/controller/reconcile_phases.go podName := fmt.Sprintf("%s-runner", sessionName) - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() - logOpts := &corev1.PodLogOptions{ TailLines: &tailLines, } @@ -81,6 +100,7 @@ func GetSessionLogs(c *gin.Context) { return } if errors.IsForbidden(err) { + log.Printf("GetSessionLogs: access denied for pod %s in project %s", podName, project) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index 28c6caca2..6d0caeae9 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -42,6 +42,7 @@ func GetSessionMetrics(c *gin.Context) { return } if errors.IsForbidden(err) { + log.Printf("GetSessionMetrics: access denied for session %s/%s", project, sessionName) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -68,13 +69,17 @@ func GetSessionMetrics(c *gin.Context) { if completionTime, ok, _ := unstructured.NestedString(item.Object, "status", "completionTime"); ok && completionTime != "" { end, err = time.Parse(time.RFC3339, completionTime) if err != nil { - end = time.Now() + // Malformed completionTime — skip both fields to avoid misleading data + end = time.Time{} + } else { + metrics["completionTime"] = completionTime } - metrics["completionTime"] = completionTime } else { end = time.Now() } - metrics["durationSeconds"] = int(end.Sub(start).Seconds()) + if !end.IsZero() { + metrics["durationSeconds"] = int(end.Sub(start).Seconds()) + } } } if sdkRestartCount, ok, _ := unstructured.NestedFloat64(item.Object, "status", "sdkRestartCount"); ok { diff --git a/components/public-api/handlers/sessions_patch.go b/components/public-api/handlers/sessions_patch.go index ed4f70d0b..cb2fde76b 100644 --- a/components/public-api/handlers/sessions_patch.go +++ b/components/public-api/handlers/sessions_patch.go @@ -85,7 +85,7 @@ func patchSessionStartStop(c *gin.Context, basePath string, stopped bool) { resp, err := ProxyRequest(c, http.MethodPost, path, nil) if err != nil { - log.Printf("Backend request failed for start/stop: %v", err) + log.Printf("Backend request failed for start/stop session %s: %v", c.Param("id"), err) c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) return } @@ -134,7 +134,7 @@ func patchSessionUpdate(c *gin.Context, basePath string, req types.PatchSessionR resp, err := ProxyRequest(c, http.MethodPut, basePath, reqBody) if err != nil { - log.Printf("Backend request failed for update: %v", err) + log.Printf("Backend request failed for update session %s: %v", c.Param("id"), err) c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) return } @@ -202,7 +202,7 @@ func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionR resp, err := ProxyRequest(c, http.MethodPatch, basePath, reqBody) if err != nil { - log.Printf("Backend request failed for label patch: %v", err) + log.Printf("Backend request failed for label patch session %s: %v", c.Param("id"), err) c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) return } @@ -220,7 +220,9 @@ func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionR return } - // Follow-up GET to return full session DTO (consistent with other PATCH responses) + // Follow-up GET to return full session DTO (consistent with other PATCH responses). + // Note: the resource could be modified or deleted between the PATCH and GET; + // the fallback below handles this race safely by returning minimal success info. getResp, err := ProxyRequest(c, http.MethodGet, basePath, nil) if err != nil { // PATCH succeeded but GET failed — return success with minimal info From ca25630a5ef10f42584c22a49e1f016186613454 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Mon, 9 Mar 2026 14:26:36 -0400 Subject: [PATCH 10/12] fix: validate query params before session CR check in GetSessionLogs Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions_logs.go | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index cd5b63f70..8d1a1907c 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -36,6 +36,22 @@ func GetSessionLogs(c *gin.Context) { // Safe to reuse as K8s lookup key — K8s names cannot contain control characters. sessionName := SanitizeForLog(c.Param("sessionName")) + // Validate query params before any K8s calls + tailLines := defaultTailLines + if tl := c.Query("tailLines"); tl != "" { + parsed, err := strconv.ParseInt(tl, 10, 64) + if err != nil || parsed < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "tailLines must be a positive integer"}) + return + } + if parsed > maxTailLines { + parsed = maxTailLines + } + tailLines = parsed + } + + container := c.Query("container") + k8sClt, k8sDyn := GetK8sClientsForRequest(c) if k8sClt == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) @@ -64,22 +80,6 @@ func GetSessionLogs(c *gin.Context) { return } - // Parse tailLines query param - tailLines := defaultTailLines - if tl := c.Query("tailLines"); tl != "" { - parsed, err := strconv.ParseInt(tl, 10, 64) - if err != nil || parsed < 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "tailLines must be a positive integer"}) - return - } - if parsed > maxTailLines { - parsed = maxTailLines - } - tailLines = parsed - } - - container := c.Query("container") - // Pod naming convention: {sessionName}-runner // Must match operator pod creation in internal/controller/reconcile_phases.go podName := fmt.Sprintf("%s-runner", sessionName) From b6ecde047bd9ebad0eb8981e122ec1ace34698ae Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Fri, 13 Mar 2026 09:41:27 -0400 Subject: [PATCH 11/12] fix: address code review feedback on public-api session endpoints - Use isValidKubernetesName() for session name validation instead of SanitizeForLog(); keep sanitized copy only for log.Printf calls - Change NestedFloat64 to NestedInt64 for integer-backed fields (timeout, sdkRestartCount) in sessions_metrics.go - Move t.Errorf out of HTTP handler goroutine in patch test - Add label count assertion in transformSession test - Change SessionResponse.Timeout from int to *int so timeout=0 is not swallowed by omitempty - Add IMAGE_STATE_SYNC to imagePullPolicy override condition in deploy.sh - Fix pre-existing ExtractServiceAccountFromAuth test to use X-Remote-User header (broken by f00505a refactor) Co-Authored-By: Claude Opus 4.6 --- .../backend/handlers/middleware_test.go | 15 +++------------ components/backend/handlers/sessions_logs.go | 19 +++++++++++-------- .../backend/handlers/sessions_metrics.go | 17 ++++++++++------- .../backend/handlers/sessions_sub_test.go | 2 +- components/public-api/handlers/sessions.go | 3 ++- .../handlers/sessions_patch_test.go | 9 ++++++--- .../public-api/handlers/sessions_test.go | 11 ++++++++--- components/public-api/types/dto.go | 2 +- e2e/scripts/deploy.sh | 2 +- 9 files changed, 43 insertions(+), 37 deletions(-) diff --git a/components/backend/handlers/middleware_test.go b/components/backend/handlers/middleware_test.go index 1932eafa4..001c1edf2 100644 --- a/components/backend/handlers/middleware_test.go +++ b/components/backend/handlers/middleware_test.go @@ -267,21 +267,12 @@ var _ = Describe("Middleware Handlers", Label(test_constants.LabelUnit, test_con }) Describe("ExtractServiceAccountFromAuth", func() { - It("Should extract service account from token review", func() { + It("Should extract service account from X-Remote-User header", func() { context := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/sessions", nil) - - _, _, err := httpUtils.SetValidTestToken( - k8sUtils, - "test-project", - []string{"get", "list"}, - "agenticsessions", - "test-sa", - "test-agenticsessions-read-role", - ) - Expect(err).NotTo(HaveOccurred()) + context.Request.Header.Set("X-Remote-User", "system:serviceaccount:test-project:test-sa") namespace, serviceAccount, found := ExtractServiceAccountFromAuth(context) - Expect(found).To(BeTrue(), "Should find service account from token") + Expect(found).To(BeTrue(), "Should find service account from X-Remote-User header") Expect(namespace).To(Equal("test-project")) Expect(serviceAccount).To(Equal("test-sa")) logger.Log("Extracted service account: %s/%s", namespace, serviceAccount) diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go index 8d1a1907c..5fe460711 100644 --- a/components/backend/handlers/sessions_logs.go +++ b/components/backend/handlers/sessions_logs.go @@ -32,9 +32,12 @@ func GetSessionLogs(c *gin.Context) { if project == "" { project = c.Param("projectName") } - // SanitizeForLog strips control characters for log-injection safety. - // Safe to reuse as K8s lookup key — K8s names cannot contain control characters. - sessionName := SanitizeForLog(c.Param("sessionName")) + sessionName := c.Param("sessionName") + if !isValidKubernetesName(sessionName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session name format"}) + return + } + safeSessionName := SanitizeForLog(sessionName) // Validate query params before any K8s calls tailLines := defaultTailLines @@ -71,11 +74,11 @@ func GetSessionLogs(c *gin.Context) { return } if errors.IsForbidden(err) { - log.Printf("GetSessionLogs: access denied for session %s/%s", project, sessionName) + log.Printf("GetSessionLogs: access denied for session %s/%s", project, safeSessionName) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } - log.Printf("GetSessionLogs: failed to verify session %s/%s: %v", project, sessionName, err) + log.Printf("GetSessionLogs: failed to verify session %s/%s: %v", project, safeSessionName, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify session"}) return } @@ -100,11 +103,11 @@ func GetSessionLogs(c *gin.Context) { return } if errors.IsForbidden(err) { - log.Printf("GetSessionLogs: access denied for pod %s in project %s", podName, project) + log.Printf("GetSessionLogs: access denied for pod %s in project %s", safeSessionName, project) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } - log.Printf("GetSessionLogs: failed to get logs for pod %s in project %s: %v", podName, project, err) + log.Printf("GetSessionLogs: failed to get logs for pod %s in project %s: %v", safeSessionName, project, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"}) return } @@ -114,6 +117,6 @@ func GetSessionLogs(c *gin.Context) { c.Header("Content-Type", "text/plain; charset=utf-8") c.Status(http.StatusOK) if _, err := io.Copy(c.Writer, io.LimitReader(logStream, maxLogBytes)); err != nil { - log.Printf("GetSessionLogs: error streaming logs for pod %s in project %s: %v", podName, project, err) + log.Printf("GetSessionLogs: error streaming logs for pod %s in project %s: %v", safeSessionName, project, err) } } diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go index 6d0caeae9..0458b8f4b 100644 --- a/components/backend/handlers/sessions_metrics.go +++ b/components/backend/handlers/sessions_metrics.go @@ -19,9 +19,12 @@ func GetSessionMetrics(c *gin.Context) { if project == "" { project = c.Param("projectName") } - // SanitizeForLog strips control characters for log-injection safety. - // Safe to reuse as K8s lookup key — K8s names cannot contain control characters. - sessionName := SanitizeForLog(c.Param("sessionName")) + sessionName := c.Param("sessionName") + if !isValidKubernetesName(sessionName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session name format"}) + return + } + safeSessionName := SanitizeForLog(sessionName) k8sClt, k8sDyn := GetK8sClientsForRequest(c) if k8sClt == nil { @@ -42,11 +45,11 @@ func GetSessionMetrics(c *gin.Context) { return } if errors.IsForbidden(err) { - log.Printf("GetSessionMetrics: access denied for session %s/%s", project, sessionName) + log.Printf("GetSessionMetrics: access denied for session %s/%s", project, safeSessionName) c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } - log.Printf("GetSessionMetrics: failed to get session %s/%s: %v", project, sessionName, err) + log.Printf("GetSessionMetrics: failed to get session %s/%s: %v", project, safeSessionName, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) return } @@ -82,12 +85,12 @@ func GetSessionMetrics(c *gin.Context) { } } } - if sdkRestartCount, ok, _ := unstructured.NestedFloat64(item.Object, "status", "sdkRestartCount"); ok { + if sdkRestartCount, ok, _ := unstructured.NestedInt64(item.Object, "status", "sdkRestartCount"); ok { metrics["restartCount"] = int(sdkRestartCount) } // Extract timeout from spec - if timeout, ok, _ := unstructured.NestedFloat64(item.Object, "spec", "timeout"); ok { + if timeout, ok, _ := unstructured.NestedInt64(item.Object, "spec", "timeout"); ok { metrics["timeoutSeconds"] = int(timeout) } diff --git a/components/backend/handlers/sessions_sub_test.go b/components/backend/handlers/sessions_sub_test.go index 268be9f34..75523706d 100644 --- a/components/backend/handlers/sessions_sub_test.go +++ b/components/backend/handlers/sessions_sub_test.go @@ -97,7 +97,7 @@ var _ = Describe("Session Sub-Resource Handlers", Label(test_constants.LabelUnit _ = unstructured.SetNestedField(session.Object, "Running", "status", "phase") _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:00:00Z", "status", "startTime") - _ = unstructured.SetNestedField(session.Object, float64(300), "spec", "timeout") + _ = unstructured.SetNestedField(session.Object, int64(300), "spec", "timeout") _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( ctx, session, v1.CreateOptions{}) diff --git a/components/public-api/handlers/sessions.go b/components/public-api/handlers/sessions.go index a17a13f8a..5cac2d166 100644 --- a/components/public-api/handlers/sessions.go +++ b/components/public-api/handlers/sessions.go @@ -311,7 +311,8 @@ func transformSession(data map[string]interface{}) types.SessionResponse { session.DisplayName = displayName } if timeout, ok := spec["timeout"].(float64); ok { - session.Timeout = int(timeout) + t := int(timeout) + session.Timeout = &t } if reposRaw, ok := spec["repos"].([]interface{}); ok { session.Repos = extractRepos(reposRaw) diff --git a/components/public-api/handlers/sessions_patch_test.go b/components/public-api/handlers/sessions_patch_test.go index 405b03515..b28687977 100644 --- a/components/public-api/handlers/sessions_patch_test.go +++ b/components/public-api/handlers/sessions_patch_test.go @@ -95,11 +95,10 @@ func TestE2E_PatchSession_Start(t *testing.T) { } func TestE2E_PatchSession_Update(t *testing.T) { + methodReceived := "" var receivedBody map[string]interface{} backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - t.Errorf("Expected PUT, got %s", r.Method) - } + methodReceived = r.Method json.NewDecoder(r.Body).Decode(&receivedBody) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -135,6 +134,10 @@ func TestE2E_PatchSession_Update(t *testing.T) { t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) } + if methodReceived != http.MethodPut { + t.Errorf("Expected PUT, got %s", methodReceived) + } + // Verify the backend received the correct fields if receivedBody["displayName"] != "New Name" { t.Errorf("Expected displayName 'New Name', got %v", receivedBody["displayName"]) diff --git a/components/public-api/handlers/sessions_test.go b/components/public-api/handlers/sessions_test.go index e286e5e84..e07ed63ec 100644 --- a/components/public-api/handlers/sessions_test.go +++ b/components/public-api/handlers/sessions_test.go @@ -9,6 +9,8 @@ import ( "github.com/gin-gonic/gin" ) +func intPtr(v int) *int { return &v } + func TestTransformSession(t *testing.T) { tests := []struct { name string @@ -51,7 +53,7 @@ func TestTransformSession(t *testing.T) { Task: "Fix the bug", Model: "claude-sonnet-4", DisplayName: "Bug Fix Session", - Timeout: 600, + Timeout: intPtr(600), CreatedAt: "2026-01-29T10:00:00Z", Repos: []types.Repo{{URL: "https://github.com/org/repo", Branch: "main"}}, Labels: map[string]string{"env": "staging"}, @@ -162,8 +164,8 @@ func TestTransformSession(t *testing.T) { if result.Error != tt.expected.Error { t.Errorf("Error = %q, want %q", result.Error, tt.expected.Error) } - if result.Timeout != tt.expected.Timeout { - t.Errorf("Timeout = %d, want %d", result.Timeout, tt.expected.Timeout) + if (result.Timeout == nil) != (tt.expected.Timeout == nil) || (result.Timeout != nil && tt.expected.Timeout != nil && *result.Timeout != *tt.expected.Timeout) { + t.Errorf("Timeout = %v, want %v", result.Timeout, tt.expected.Timeout) } if len(result.Repos) != len(tt.expected.Repos) { t.Errorf("Repos len = %d, want %d", len(result.Repos), len(tt.expected.Repos)) @@ -178,6 +180,9 @@ func TestTransformSession(t *testing.T) { } } if len(tt.expected.Labels) > 0 { + if len(result.Labels) != len(tt.expected.Labels) { + t.Errorf("Labels count = %d, want %d", len(result.Labels), len(tt.expected.Labels)) + } for k, v := range tt.expected.Labels { if result.Labels[k] != v { t.Errorf("Labels[%q] = %q, want %q", k, result.Labels[k], v) diff --git a/components/public-api/types/dto.go b/components/public-api/types/dto.go index 98bd3740c..d83517661 100644 --- a/components/public-api/types/dto.go +++ b/components/public-api/types/dto.go @@ -13,7 +13,7 @@ type SessionResponse struct { Error string `json:"error,omitempty"` Repos []Repo `json:"repos,omitempty"` Labels map[string]string `json:"labels,omitempty"` - Timeout int `json:"timeout,omitempty"` + Timeout *int `json:"timeout,omitempty"` } // SessionListResponse is the response for listing sessions diff --git a/e2e/scripts/deploy.sh b/e2e/scripts/deploy.sh index 5d0404cf2..f795c7f85 100755 --- a/e2e/scripts/deploy.sh +++ b/e2e/scripts/deploy.sh @@ -72,7 +72,7 @@ kubectl kustomize ../components/manifests/overlays/kind/ | \ sed "s|quay.io/ambient_code/vteam_claude_runner:latest|${IMAGE_RUNNER:-quay.io/ambient_code/vteam_claude_runner:latest}|g" | \ sed "s|quay.io/ambient_code/vteam_state_sync:latest|${IMAGE_STATE_SYNC:-quay.io/ambient_code/vteam_state_sync:latest}|g" | \ sed "s|quay.io/ambient_code/vteam_public_api:latest|${IMAGE_PUBLIC_API:-quay.io/ambient_code/vteam_public_api:latest}|g" | \ - if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_PUBLIC_API:-}" ]; then + if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_STATE_SYNC:-}${IMAGE_PUBLIC_API:-}" ]; then sed "s|imagePullPolicy: Always|imagePullPolicy: IfNotPresent|g" else cat From 77fe084aa89a98067b0608d55b5e2d52a5b04c4e Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Wed, 18 Mar 2026 11:59:07 -0400 Subject: [PATCH 12/12] fix: validate annotation value types in PatchSession to prevent K8s 500 errors Kubernetes annotations must be map[string]string, but the PATCH handler accepted arbitrary JSON types (numbers, booleans, objects, arrays) and passed them through to the K8s API, which rejected them with a 500. Use a type switch to validate each value: allow string and nil (delete), reject all other types with a 400 Bad Request. Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 4354fab13..bbe8fb244 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1118,10 +1118,16 @@ func PatchSession(c *gin.Context) { anns = map[string]interface{}{} } for k, v := range annsPatch { - if v == nil { + switch vv := v.(type) { + case nil: delete(anns, k) - } else { - anns[k] = v + case string: + anns[k] = vv + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Invalid annotation value for key %q: must be string or null", k), + }) + return } } _ = unstructured.SetNestedMap(metadata, anns, "annotations")