Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
f9948f8
feat(public-api): complete session API surface for mcp-acp integration
adalton Mar 4, 2026
2c1d28b
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 4, 2026
080b205
style: apply gofmt formatting
adalton Mar 4, 2026
e29b679
fix: address review feedback — unstructured helpers, bind error, quer…
adalton Mar 4, 2026
44f9dc9
fix: filter kubectl/meta K8s prefixes, align auth pattern, refine com…
adalton Mar 5, 2026
2408a9b
Merge branch 'main' into andalton/mcp-update
jeremyeder Mar 7, 2026
b73fded
fix: sanitize sessionName param in logs/metrics handlers
adalton Mar 9, 2026
5df0c94
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 9, 2026
0f39434
Merge branch 'andalton/mcp-update' of github.com:ambient-code/platfor…
adalton Mar 9, 2026
11813e0
fix: stream logs in public-api proxy, clarify SanitizeForLog usage
adalton Mar 9, 2026
44a8a09
fix: handle K8s 403, stream transcript, align update status codes, ad…
adalton Mar 9, 2026
a4df45e
fix: add project/session context to error log messages
adalton Mar 9, 2026
ee74c91
fix: verify session CR before log retrieval, fix duration on bad time…
adalton Mar 9, 2026
ca25630
fix: validate query params before session CR check in GetSessionLogs
adalton Mar 9, 2026
4e29939
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 9, 2026
e9c26ef
Merge branch 'main' into andalton/mcp-update
adalton Mar 13, 2026
b6ecde0
fix: address code review feedback on public-api session endpoints
adalton Mar 13, 2026
e912364
Merge branch 'main' into andalton/mcp-update
adalton Mar 13, 2026
dad993e
Merge branch 'main' into andalton/mcp-update
adalton Mar 13, 2026
55f1123
Merge branch 'main' into andalton/mcp-update
adalton Mar 15, 2026
47b5bc3
Merge branch 'main' into andalton/mcp-update
adalton Mar 16, 2026
0fdcc01
Merge branch 'main' into andalton/mcp-update
adalton Mar 18, 2026
77fe084
fix: validate annotation value types in PatchSession to prevent K8s 5…
adalton Mar 18, 2026
b1bacbb
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 18, 2026
115b0c9
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 18, 2026
d3cdcf7
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 19, 2026
9bd300d
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 19, 2026
672676b
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 20, 2026
153b11f
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 23, 2026
818b330
Merge branch 'main' into andalton/mcp-update
adalton Mar 24, 2026
7c545b1
Merge branch 'main' into andalton/mcp-update
adalton Mar 24, 2026
bff0f6d
Merge branch 'main' into andalton/mcp-update
adalton Mar 25, 2026
05a3066
Merge branch 'main' into andalton/mcp-update
adalton Mar 25, 2026
003a4bf
Merge branch 'main' into andalton/mcp-update
adalton Mar 25, 2026
f174841
Merge branch 'main' into andalton/mcp-update
adalton Mar 26, 2026
d8d249c
Merge remote-tracking branch 'origin/andalton/mcp-update' into andalt…
adalton Mar 26, 2026
235c98b
Merge branch 'main' into andalton/mcp-update
adalton Mar 26, 2026
8ed4ce3
Merge branch 'main' into andalton/mcp-update
adalton Mar 27, 2026
67fc5af
Merge branch 'main' into andalton/mcp-update
adalton Mar 27, 2026
00b2cf4
Merge branch 'main' into andalton/mcp-update
adalton Mar 27, 2026
aa998bf
Merge branch 'main' into andalton/mcp-update
adalton Mar 30, 2026
58e50eb
Merge branch 'main' into andalton/mcp-update
adalton Mar 31, 2026
3be43cd
Merge branch 'main' into andalton/mcp-update
adalton Apr 1, 2026
a407d68
Merge branch 'main' into andalton/mcp-update
adalton Apr 2, 2026
9a300d9
Merge branch 'main' into andalton/mcp-update
adalton Apr 2, 2026
4d6f152
Merge branch 'main' into andalton/mcp-update
adalton Apr 2, 2026
6cefd7f
Merge branch 'main' into andalton/mcp-update
adalton Apr 3, 2026
667f9b5
Merge branch 'main' into andalton/mcp-update
adalton Apr 6, 2026
385732c
Merge branch 'main' into andalton/mcp-update
adalton Apr 6, 2026
3f4170e
Merge branch 'main' into andalton/mcp-update
adalton Apr 7, 2026
f9723e9
Merge branch 'main' into andalton/mcp-update
adalton Apr 7, 2026
ebd1617
Merge branch 'main' into andalton/mcp-update
adalton Apr 7, 2026
648526d
Merge branch 'main' into andalton/mcp-update
adalton Apr 8, 2026
0a2dd86
Merge branch 'main' into andalton/mcp-update
adalton Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/backend/handlers/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,15 @@ 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)

// ExtractServiceAccountFromAuth reads the X-Remote-User header
// (set by OpenShift OAuth proxy) to identify service accounts
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)
Expand Down
6 changes: 5 additions & 1 deletion components/backend/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current PatchSession behavior writes arbitrary annotation value types and lacks type validation.

rg -n -C4 'if annsPatch, ok := metaPatch\["annotations"\]\.\(map\[string\]interface\{\}\); ok \{|for k, v := range annsPatch|anns\[k\] = v|v == nil' components/backend/handlers/sessions.go

# Check whether tests cover invalid annotation value types for PatchSession.
rg -n -C3 --type go 'PatchSession|annotations|null|Invalid request body|must be string'

Repository: ambient-code/platform

Length of output: 50378


🏁 Script executed:

# Check what happens after annotations are set - look for error handling
rg -A10 'SetNestedMap.*annotations' components/backend/handlers/sessions.go

# Search for any validation of annotation values in PatchSession or related code
rg -B5 -A5 'annotation.*string|string.*annotation' components/backend/handlers/sessions.go | head -50

# Check the types to confirm Annotations is defined as map[string]string
rg -A3 'type.*Session.*struct' components/backend/types/session.go

Repository: ambient-code/platform

Length of output: 4168


🏁 Script executed:

# Look for tests that might be patching annotations with non-string values
rg -n 'annotations.*:.*[0-9]|annotations.*:.*true|annotations.*:.*false|annotations.*:.*{' components/public-api/handlers/sessions_patch_test.go components/backend/handlers/sessions_test.go

# Check if there's any type assertion or validation before annotation assignment
rg -B3 -A3 'for k, v := range annsPatch' components/backend/handlers/sessions.go

Repository: ambient-code/platform

Length of output: 465


🏁 Script executed:

# Verify Kubernetes annotation constraints - check if there's validation elsewhere
rg -n 'annotation.*string|string.*annotation|map\[string\]string' components/backend/handlers/sessions.go | head -20

# Check what unstructured.SetNestedMap does with non-string values in comments or tests
rg -i 'SetNestedMap.*annotation|annotation.*SetNestedMap' --type go

Repository: ambient-code/platform

Length of output: 1098


🏁 Script executed:

# Check if errors from SetNestedMap are actually ignored
rg -B5 'unstructured\.SetNestedMap.*annotations' components/backend/handlers/sessions.go | grep -A5 'SetNestedMap'

# Look at error handling around the Update call to see if annotation errors propagate
rg -B2 -A8 'k8sDyn\.Resource.*Update.*item' components/backend/handlers/sessions.go | head -40

# Search for how the backend type Annotations (map[string]string) is used vs actual annotation storage
rg -n 'Annotations.*map\[string\]string' components/backend/types/session.go

# Check if there are any tests for patching with invalid annotation types
rg -A20 'TestE2E_PatchSession|func.*Patch.*Session' components/public-api/handlers/sessions_patch_test.go | grep -i 'annotation\|invalid\|string\|number\|bool'

Repository: ambient-code/platform

Length of output: 3318


🏁 Script executed:

# Look at the session DTO in public-api to see type consistency
rg -B2 -A2 'Annotations' components/public-api/types/dto.go

# Check if validation happens when binding the PATCH request
rg -B10 -A5 'ShouldBindJSON.*patch\|patch.*ShouldBindJSON' components/backend/handlers/sessions.go

# See if empty string annotations are tested anywhere
rg -n '"".*annotation|annotation.*""' components/public-api/handlers/sessions_patch_test.go components/backend/handlers/sessions_test.go

Repository: ambient-code/platform

Length of output: 47


🏁 Script executed:

# Check Kubernetes unstructured documentation and usage to understand what happens with non-string values
rg -C3 'unstructured\.' components/backend/handlers/sessions.go | head -60

Repository: ambient-code/platform

Length of output: 2077


🏁 Script executed:

# Check if there are any integration tests or actual test data with non-string annotation values
rg -n 'annotations.*:' components/public-api/handlers/sessions_patch_test.go -A2 -B2

# Specifically look for what annotation values are being tested
rg '"annotations"' components/public-api/handlers/sessions_patch_test.go -A5 -B2

Repository: ambient-code/platform

Length of output: 1655


🏁 Script executed:

# Check what the backend returns in PatchSession response - verify if it successfully reflects non-string values
rg -A3 'PatchSession.*200\|StatusOK.*annotation' components/public-api/handlers/sessions_patch_test.go

# Look at the full PatchSession test to understand what values are tested
sed -n '150,222p' components/public-api/handlers/sessions_patch_test.go

Repository: ambient-code/platform

Length of output: 2638


🏁 Script executed:

# Check if there's any documentation or handling of what types annotations should accept
rg -i 'kubernetes.*annotation|annotation.*kubernetes|string.*only|must be string' --type go | head -20

Repository: ambient-code/platform

Length of output: 741


🏁 Script executed:

# See if the backend handler has any imports that might indicate type validation
rg -n 'import\|^import' components/backend/handlers/sessions.go | head -10

Repository: ambient-code/platform

Length of output: 47


Validate annotation values before assignment to avoid 500s on invalid PATCH payloads.

At line 1124, anns[k] = v accepts arbitrary JSON types without validation. Kubernetes annotations must be strings; non-string values (number, boolean, object, array) are rejected by the Kubernetes API during the subsequent Update call and currently surface as 500 errors instead of 400. Additionally, empty-string annotation values are not handled and persist rather than being deleted, contrary to Kubernetes semantics.

💡 Suggested fix
 			for k, v := range annsPatch {
-				if v == nil {
-					delete(anns, k)
-				} else {
-					anns[k] = v
-				}
+				switch vv := v.(type) {
+				case nil:
+					delete(anns, k)
+				case string:
+					// Treat empty string as delete to avoid retaining "removed" annotations.
+					if strings.TrimSpace(vv) == "" {
+						delete(anns, k)
+						continue
+					}
+					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
+				}
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if v == nil {
delete(anns, k)
} else {
anns[k] = v
}
for k, v := range annsPatch {
switch vv := v.(type) {
case nil:
delete(anns, k)
case string:
// Treat empty string as delete to avoid retaining "removed" annotations.
if strings.TrimSpace(vv) == "" {
delete(anns, k)
continue
}
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
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/backend/handlers/sessions.go` around lines 1121 - 1125, The
current annotation assignment (anns[k] = v) accepts arbitrary JSON types and
empty strings which cause Kubernetes API errors; update the session PATCH
handler to validate each annotation value before assignment: ensure v is a
string type, treat empty-string ("") as nil/delete (call delete(anns, k)), and
if v is a non-string return a 400 Bad Request with a clear validation error;
keep using the same variables (anns, k, v) and ensure the validated string value
is assigned to anns[k] only after these checks so the subsequent Kubernetes
Update call never receives non-string annotation values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 77fe084. Added a type switch to validate annotation values before assignment:

  • nil → deletes the annotation key (existing behavior)
  • string → assigns the value
  • anything else → returns 400 Bad Request with a descriptive error

This prevents non-string JSON types (numbers, booleans, objects, arrays) from reaching the Kubernetes API and causing 500 errors.

Not adopted: the suggestion to treat empty strings as deletes. Empty-string annotations are valid in Kubernetes (kubectl annotate ... key="" is allowed), and silently converting them to deletes could break callers who intentionally set empty values. If a caller wants to remove an annotation, they should send null.

}
_ = unstructured.SetNestedMap(metadata, anns, "annotations")
_ = unstructured.SetNestedMap(item.Object, metadata, "metadata")
Expand Down
122 changes: 122 additions & 0 deletions components/backend/handlers/sessions_logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

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")
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
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"})
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, safeSessionName)
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
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
}

// Pod naming convention: {sessionName}-runner
// Must match operator pod creation in internal/controller/reconcile_phases.go
podName := fmt.Sprintf("%s-runner", sessionName)

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
}
if errors.IsForbidden(err) {
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", safeSessionName, project, 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 in project %s: %v", safeSessionName, project, err)
}
}
118 changes: 118 additions & 0 deletions components/backend/handlers/sessions_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// 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")
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 {
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
}
if errors.IsForbidden(err) {
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, safeSessionName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"})
return
}

metrics := gin.H{
"sessionId": sessionName,
}

// 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, _ := unstructured.NestedString(item.Object, "status", "completionTime"); ok && completionTime != "" {
end, err = time.Parse(time.RFC3339, completionTime)
if err != nil {
// Malformed completionTime — skip both fields to avoid misleading data
end = time.Time{}
} else {
metrics["completionTime"] = completionTime
}
} else {
end = time.Now()
}
if !end.IsZero() {
metrics["durationSeconds"] = int(end.Sub(start).Seconds())
}
}
}
if sdkRestartCount, ok, _ := unstructured.NestedInt64(item.Object, "status", "sdkRestartCount"); ok {
metrics["restartCount"] = int(sdkRestartCount)
}

// Extract timeout from spec
if timeout, ok, _ := unstructured.NestedInt64(item.Object, "spec", "timeout"); 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)
}
Loading
Loading