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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions components/public-api/handlers/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func setupTestRouter() *gin.Engine {
v1.POST("/sessions", CreateSession)
v1.GET("/sessions/:id", GetSession)
v1.DELETE("/sessions/:id", DeleteSession)
v1.POST("/sessions/:id/runs", CreateSessionRun)
}

return r
Expand Down Expand Up @@ -238,6 +239,156 @@ func TestE2E_ProjectMismatchAttack(t *testing.T) {
}
}

func TestE2E_CreateSessionRun(t *testing.T) {
tests := []struct {
name string
sessionID string
requestBody string
backendStatus int
backendResp string
expectedStatus int
expectRunID bool
}{
{
name: "Successful run creation",
sessionID: "test-session",
requestBody: `{"prompt": "Fix the authentication bug"}`,
backendStatus: http.StatusOK,
backendResp: `{"runId": "run-abc123", "threadId": "test-session"}`,
expectedStatus: http.StatusOK,
expectRunID: true,
},
{
name: "Missing prompt returns 400",
sessionID: "test-session",
requestBody: `{}`,
backendStatus: http.StatusOK,
backendResp: `{}`,
expectedStatus: http.StatusBadRequest,
expectRunID: false,
},
{
name: "Backend error forwarded",
sessionID: "test-session",
requestBody: `{"prompt": "Do something"}`,
backendStatus: http.StatusBadRequest,
backendResp: `{"error": "Session not running"}`,
expectedStatus: http.StatusBadRequest,
expectRunID: false,
},
{
name: "Invalid session ID returns 400",
sessionID: "INVALID-SESSION",
requestBody: `{"prompt": "Do something"}`,
backendStatus: http.StatusOK,
backendResp: `{}`,
expectedStatus: http.StatusBadRequest,
expectRunID: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify path contains agui/run for valid requests
if tt.expectedStatus != http.StatusBadRequest || tt.backendStatus != http.StatusOK {
if !strings.Contains(r.URL.Path, "agui/run") {
t.Errorf("Expected path to contain agui/run, got %s", r.URL.Path)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.backendStatus)
w.Write([]byte(tt.backendResp))
}))
defer backend.Close()

originalURL := BackendURL
BackendURL = backend.URL
defer func() { BackendURL = originalURL }()

router := setupTestRouter()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/sessions/"+tt.sessionID+"/runs",
strings.NewReader(tt.requestBody))
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 != tt.expectedStatus {
t.Errorf("Expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String())
}

if tt.expectRunID {
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp["runId"] == "" {
t.Errorf("Expected runId in response, got %v", resp)
}
if resp["threadId"] == "" {
t.Errorf("Expected threadId in response, got %v", resp)
}
}
})
}
}

func TestE2E_CreateSessionRun_MessageFormat(t *testing.T) {
// Verify the AG-UI message format sent to the backend
var capturedBody map[string]interface{}

backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil {
t.Errorf("Failed to decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"runId": "run-xyz", "threadId": "my-session"}`))
}))
defer backend.Close()

originalURL := BackendURL
BackendURL = backend.URL
defer func() { BackendURL = originalURL }()

router := setupTestRouter()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/sessions/my-session/runs",
strings.NewReader(`{"prompt": "Add unit tests"}`))
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.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String())
}

// Verify AG-UI format: threadId and messages array
if capturedBody["threadId"] != "my-session" {
t.Errorf("Expected threadId=my-session, got %v", capturedBody["threadId"])
}
messages, ok := capturedBody["messages"].([]interface{})
if !ok || len(messages) != 1 {
t.Fatalf("Expected 1 message in AG-UI format, got %v", capturedBody["messages"])
}
msg, ok := messages[0].(map[string]interface{})
if !ok {
t.Fatalf("Expected message to be an object, got %T", messages[0])
}
if msg["role"] != "user" {
t.Errorf("Expected role=user, got %v", msg["role"])
}
if msg["content"] != "Add unit tests" {
t.Errorf("Expected content='Add unit tests', got %v", msg["content"])
}
if msg["id"] == "" {
t.Error("Expected non-empty message id")
}
}

func TestE2E_DeleteSession(t *testing.T) {
deleted := false
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
7 changes: 4 additions & 3 deletions components/public-api/handlers/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions components/public-api/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"log"
"net/http"
"time"

"ambient-code-public-api/types"

Expand Down Expand Up @@ -221,6 +222,79 @@ func DeleteSession(c *gin.Context) {
forwardErrorResponse(c, resp.StatusCode, body)
}

// CreateSessionRun handles POST /v1/sessions/:id/runs
// Delivers a prompt to an active session via the AG-UI run endpoint.
func CreateSessionRun(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.CreateRunRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// Build AG-UI RunAgentInput from the simple prompt.
// The message ID uses the session ID and current timestamp for uniqueness.
msgID := fmt.Sprintf("msg-%s-%d", sessionID, time.Now().UnixNano())
backendReq := map[string]interface{}{
"threadId": sessionID,
"messages": []map[string]interface{}{
{
"id": msgID,
"role": "user",
"content": req.Prompt,
},
},
}

reqBody, err := json.Marshal(backendReq)
if err != nil {
log.Printf("Failed to marshal run request for session %s: %v", sessionID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}

path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/agui/run", project, sessionID)

resp, err := ProxyRequest(c, http.MethodPost, path, reqBody)
if err != nil {
log.Printf("Backend request failed for session run %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 for session run %s: %v", sessionID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}

if resp.StatusCode != http.StatusOK {
forwardErrorResponse(c, resp.StatusCode, body)
return
}

var runResp types.RunResponse
if err := json.Unmarshal(body, &runResp); err != nil {
log.Printf("Failed to parse backend run response for session %s: %v", sessionID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}

c.JSON(http.StatusOK, runResp)
}

// forwardErrorResponse forwards backend error with consistent JSON format
func forwardErrorResponse(c *gin.Context, statusCode int, body []byte) {
// Try to parse as JSON error response
Expand Down
7 changes: 5 additions & 2 deletions components/public-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ func main() {
v1.POST("/sessions", handlers.CreateSession)
v1.GET("/sessions/:id", handlers.GetSession)
v1.DELETE("/sessions/:id", handlers.DeleteSession)

// Session runs (AG-UI prompt delivery)
v1.POST("/sessions/:id/runs", handlers.CreateSessionRun)
}

// Get port from environment or default to 8081
Expand Down Expand Up @@ -143,8 +146,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
}
}
Expand Down
11 changes: 11 additions & 0 deletions components/public-api/types/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,14 @@ type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}

// CreateRunRequest is the request body for delivering a prompt to an active session
type CreateRunRequest struct {
Prompt string `json:"prompt" binding:"required"`
}

// RunResponse is the response after starting a run
type RunResponse struct {
RunID string `json:"runId"`
ThreadID string `json:"threadId"`
}