diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 0732cc67d..891d0cfbc 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -511,17 +511,12 @@ func ContentWorkflowMetadata(c *gin.Context) { displayName = commandName } - // Extract short command (last segment after final dot) - shortCommand := commandName - if lastDot := strings.LastIndex(commandName, "."); lastDot != -1 { - shortCommand = commandName[lastDot+1:] - } - + // Use full command name as slash command (e.g., /speckit.rfe.start) commands = append(commands, map[string]interface{}{ "id": commandName, "name": displayName, "description": metadata["description"], - "slashCommand": "/" + shortCommand, + "slashCommand": "/" + commandName, "icon": metadata["icon"], }) } @@ -648,9 +643,9 @@ func parseAmbientConfig(workflowDir string) *AmbientConfig { // findActiveWorkflowDir finds the active workflow directory for a session func findActiveWorkflowDir(sessionName string) string { - // Workflows are stored at {StateBaseDir}/sessions/{session-name}/workspace/workflows/{workflow-name} - // The runner creates this nested structure - workflowsBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace", "workflows") + // Workflows are stored at {StateBaseDir}/workflows/{workflow-name} + // The runner clones workflows to /workspace/workflows/ at runtime + workflowsBase := filepath.Join(StateBaseDir, "workflows") entries, err := os.ReadDir(workflowsBase) if err != nil { diff --git a/components/backend/handlers/content_test.go b/components/backend/handlers/content_test.go index 6200e77d5..285ee60dc 100644 --- a/components/backend/handlers/content_test.go +++ b/components/backend/handlers/content_test.go @@ -830,12 +830,13 @@ var _ = Describe("Content Handler", Label(test_constants.LabelUnit, test_constan }) It("Should parse workflow metadata when available", func() { - // Create test workflow structure - sessionDir := filepath.Join(tempStateDir, "sessions", "test-session", "workspace", "workflows", "test-workflow") - claudeDir := filepath.Join(sessionDir, ".claude") + // Create test workflow structure at StateBaseDir/workflows/{workflow-name} + // findActiveWorkflowDir looks in StateBaseDir/workflows/ for directories with .claude subdirectory + workflowDir := filepath.Join(tempStateDir, "workflows", "test-workflow") + claudeDir := filepath.Join(workflowDir, ".claude") commandsDir := filepath.Join(claudeDir, "commands") agentsDir := filepath.Join(claudeDir, "agents") - ambientDir := filepath.Join(sessionDir, ".ambient") + ambientDir := filepath.Join(workflowDir, ".ambient") err := os.MkdirAll(commandsDir, 0755) Expect(err).NotTo(HaveOccurred()) @@ -917,7 +918,7 @@ This is a test agent. slashCommandInterface, exists := command["slashCommand"] Expect(exists).To(BeTrue(), "Command should contain 'slashCommand' field") - Expect(slashCommandInterface).To(Equal("/command")) + Expect(slashCommandInterface).To(Equal("/test.command")) iconInterface, exists := command["icon"] Expect(exists).To(BeTrue(), "Command should contain 'icon' field") diff --git a/components/backend/handlers/oauth.go b/components/backend/handlers/oauth.go index c4864345e..2975a4909 100644 --- a/components/backend/handlers/oauth.go +++ b/components/backend/handlers/oauth.go @@ -298,7 +298,36 @@ func HandleOAuth2Callback(c *gin.Context) { callbackData.ExpiresIn = tokenData.ExpiresIn callbackData.TokenType = tokenData.TokenType - // Parse and validate session context from signed state parameter + // Try to parse state as new format (map) or legacy format (OAuthStateData struct) + // New cluster-level OAuth uses map with "cluster":true flag + var stateMap map[string]interface{} + stateBytes, err := base64.URLEncoding.DecodeString(strings.Split(state, ".")[0]) + if err == nil { + if jsonErr := json.Unmarshal(stateBytes, &stateMap); jsonErr == nil { + // Check if this is cluster-level OAuth + if isCluster, ok := stateMap["cluster"].(bool); ok && isCluster { + log.Printf("Detected cluster-level OAuth flow") + + // Handle cluster-level Google OAuth + if err := HandleGoogleOAuthCallback(c.Request.Context(), code, stateMap); err != nil { + log.Printf("Cluster-level OAuth failed: %v", err) + // Return generic error to client, details logged server-side only + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte( + "

Authorization Error

Failed to connect Google Drive. Please try again.

You can close this window.

", + )) + return + } + + // Success + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte( + "

Authorization Successful!

Google Drive is now connected!

All your sessions will have access to Google Drive.

You can close this window.

", + )) + return + } + } + } + + // Fallback to legacy session-specific OAuth stateData, err := validateAndParseOAuthState(state) if err != nil { log.Printf("ERROR: State validation failed: %v (possible CSRF attack or tampering)", err) @@ -309,7 +338,7 @@ func HandleOAuth2Callback(c *gin.Context) { return } - // Store credentials in Kubernetes Secret in the project namespace + // Store credentials in Kubernetes Secret in the project namespace (legacy session-specific) if stateData.ProjectName != "" && stateData.SessionName != "" { err := storeCredentialsInSecret( c.Request.Context(), @@ -753,3 +782,390 @@ func storeCredentialsInSecret(ctx context.Context, projectName, sessionName, pro return nil } + +// ============================================================================ +// Cluster-Level Google OAuth (User-Scoped, Not Session-Specific) +// ============================================================================ + +// GoogleOAuthCredentials represents cluster-level Google OAuth credentials for a user +type GoogleOAuthCredentials struct { + UserID string `json:"userId"` + Email string `json:"email,omitempty"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Scopes []string `json:"scopes"` + ExpiresAt time.Time `json:"expiresAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// isValidUserID validates userID for use as a Kubernetes Secret data key +// Keys must be valid DNS subdomain names (RFC 1123) and reasonable length +func isValidUserID(userID string) bool { + if userID == "" || len(userID) > 253 { + return false + } + // Reject path traversal and invalid characters for Secret data keys + for _, ch := range userID { + if ch == '/' || ch == '\\' || ch == '\x00' { + return false + } + } + return true +} + +// GetGoogleOAuthURLGlobal handles POST /api/auth/google/connect +// Returns OAuth URL for cluster-level Google authentication +func GetGoogleOAuthURLGlobal(c *gin.Context) { + // Verify user has valid K8s token (follows RBAC pattern) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + // Verify user is authenticated and userID is valid + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + if !isValidUserID(userID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"}) + return + } + + // Get OAuth provider config + provider, err := getOAuthProvider("google") + if err != nil { + log.Printf("Failed to get OAuth provider: %v", err) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Google OAuth not configured"}) + return + } + + // Build state with user context only (no session/project) + stateData := map[string]interface{}{ + "provider": "google", + "userID": userID, + "timestamp": time.Now().Unix(), + "cluster": true, // Flag to indicate cluster-level OAuth + } + + // Serialize state to JSON + stateJSON, err := json.Marshal(stateData) + if err != nil { + log.Printf("Failed to marshal state: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate OAuth state"}) + return + } + + // Get HMAC secret from environment + secret := os.Getenv("OAUTH_STATE_SECRET") + if secret == "" { + log.Printf("OAUTH_STATE_SECRET not configured") + c.JSON(http.StatusInternalServerError, gin.H{"error": "OAuth state validation not configured"}) + return + } + + // Generate HMAC signature + h := hmac.New(sha256.New, []byte(secret)) + h.Write(stateJSON) + signature := h.Sum(nil) + + // Combine: base64(json) + "." + base64(signature) + stateToken := base64.URLEncoding.EncodeToString(stateJSON) + "." + base64.URLEncoding.EncodeToString(signature) + + // Get backend URL for redirect URI + backendURL := os.Getenv("BACKEND_URL") + if backendURL == "" { + backendURL = "http://localhost:8080" + } + redirectURI := fmt.Sprintf("%s/oauth2callback", backendURL) + + // Build OAuth URL + authURL := fmt.Sprintf( + "https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&access_type=offline&state=%s&prompt=consent", + provider.ClientID, + redirectURI, + strings.Join(provider.Scopes, " "), + stateToken, + ) + + log.Printf("Generated cluster-level Google OAuth URL for user %s", userID) + + c.JSON(http.StatusOK, gin.H{ + "url": authURL, + "state": stateToken, + }) +} + +// HandleGoogleOAuthCallback handles the OAuth callback for cluster-level Google auth +// This is called via the generic /oauth2callback endpoint when state contains "cluster":true +func HandleGoogleOAuthCallback(ctx context.Context, code string, stateData map[string]interface{}) error { + userID, _ := stateData["userID"].(string) + if userID == "" { + return fmt.Errorf("missing userID in state") + } + + // Get OAuth provider config + provider, err := getOAuthProvider("google") + if err != nil { + return fmt.Errorf("failed to get OAuth provider: %w", err) + } + + // Get backend URL for redirect URI + backendURL := os.Getenv("BACKEND_URL") + if backendURL == "" { + backendURL = "http://localhost:8080" + } + redirectURI := fmt.Sprintf("%s/oauth2callback", backendURL) + + // Exchange code for tokens + tokenData, err := exchangeOAuthCode(ctx, provider, code, redirectURI) + if err != nil { + return fmt.Errorf("failed to exchange code: %w", err) + } + + // Get user's email from Google + userEmail, err := getGoogleUserEmail(ctx, tokenData.AccessToken) + if err != nil { + log.Printf("Warning: failed to get user email: %v", err) + userEmail = "" // Non-fatal + } + + // Store credentials in cluster-level ConfigMap + credentials := GoogleOAuthCredentials{ + UserID: userID, + Email: userEmail, + AccessToken: tokenData.AccessToken, + RefreshToken: tokenData.RefreshToken, + Scopes: provider.Scopes, + ExpiresAt: time.Now().Add(time.Duration(tokenData.ExpiresIn) * time.Second), + UpdatedAt: time.Now(), + } + + if err := storeGoogleCredentials(ctx, &credentials); err != nil { + return fmt.Errorf("failed to store credentials: %w", err) + } + + log.Printf("✓ Stored cluster-level Google OAuth credentials for user %s", userID) + return nil +} + +// storeGoogleCredentials stores Google OAuth credentials in cluster-level Secret +func storeGoogleCredentials(ctx context.Context, creds *GoogleOAuthCredentials) error { + if creds == nil || creds.UserID == "" { + return fmt.Errorf("invalid credentials payload") + } + + const secretName = "google-oauth-credentials" + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + // Create Secret + secret = &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: secretName, + Namespace: Namespace, + Labels: map[string]string{ + "app": "ambient-code", + "ambient-code.io/oauth": "true", + "ambient-code.io/oauth-provider": "google", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + if _, cerr := K8sClient.CoreV1().Secrets(Namespace).Create(ctx, secret, v1.CreateOptions{}); cerr != nil && !errors.IsAlreadyExists(cerr) { + return fmt.Errorf("failed to create Secret: %w", cerr) + } + // Fetch again to get resourceVersion + secret, err = K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to fetch Secret after create: %w", err) + } + } else { + return fmt.Errorf("failed to get Secret: %w", err) + } + } + + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + b, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + secret.Data[creds.UserID] = b + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue // retry + } + return fmt.Errorf("failed to update Secret: %w", uerr) + } + return nil + } + return fmt.Errorf("failed to update Secret after retries") +} + +// GetGoogleCredentials retrieves cluster-level Google OAuth credentials for a user +func GetGoogleCredentials(ctx context.Context, userID string) (*GoogleOAuthCredentials, error) { + const secretName = "google-oauth-credentials" + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil // No credentials stored yet + } + return nil, fmt.Errorf("failed to get Secret: %w", err) + } + + if secret.Data == nil || len(secret.Data[userID]) == 0 { + return nil, nil // User hasn't connected yet + } + + var creds GoogleOAuthCredentials + if err := json.Unmarshal(secret.Data[userID], &creds); err != nil { + return nil, fmt.Errorf("failed to unmarshal credentials: %w", err) + } + + return &creds, nil +} + +// GetGoogleOAuthStatusGlobal handles GET /api/auth/google/status +// Returns connection status for current user +func GetGoogleOAuthStatusGlobal(c *gin.Context) { + // Verify user has valid K8s token (follows RBAC pattern) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + if !isValidUserID(userID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"}) + return + } + + creds, err := GetGoogleCredentials(c.Request.Context(), userID) + if err != nil { + log.Printf("Failed to get Google credentials for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check connection status"}) + return + } + + if creds == nil { + c.JSON(http.StatusOK, gin.H{ + "connected": false, + }) + return + } + + // Check if token is expired + isExpired := time.Now().After(creds.ExpiresAt) + + c.JSON(http.StatusOK, gin.H{ + "connected": true, + "email": creds.Email, + "expiresAt": creds.ExpiresAt.Format(time.RFC3339), + "expired": isExpired, + }) +} + +// DisconnectGoogleOAuthGlobal handles POST /api/auth/google/disconnect +// Removes user's Google OAuth credentials from cluster storage +func DisconnectGoogleOAuthGlobal(c *gin.Context) { + // Verify user has valid K8s token (follows RBAC pattern) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + if !isValidUserID(userID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"}) + return + } + + const secretName = "google-oauth-credentials" + ctx := c.Request.Context() + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + // Already disconnected + c.JSON(http.StatusOK, gin.H{"message": "Google Drive disconnected"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to access credentials"}) + return + } + + if secret.Data == nil || len(secret.Data[userID]) == 0 { + // Already disconnected + c.JSON(http.StatusOK, gin.H{"message": "Google Drive disconnected"}) + return + } + + delete(secret.Data, userID) + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue // retry + } + log.Printf("Failed to update Secret: %v", uerr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect"}) + return + } + + log.Printf("✓ Removed Google OAuth credentials for user %s", userID) + c.JSON(http.StatusOK, gin.H{"message": "Google Drive disconnected successfully"}) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect after retries"}) +} + +// getGoogleUserEmail fetches the user's email from Google using the access token +func getGoogleUserEmail(ctx context.Context, accessToken string) (string, error) { + // Create request with context for timeout/cancellation support + req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Use client with timeout instead of DefaultClient + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var userInfo struct { + Email string `json:"email"` + } + if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { + return "", err + } + + return userInfo.Email, nil +} diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 276d0b019..47b0b38e6 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1192,6 +1192,51 @@ func SelectWorkflow(c *gin.Context) { return } + // Build workflow config + branch := req.Branch + if branch == "" { + branch = "main" + } + + // Call runner to clone and activate the workflow (if session is running) + status, _ := item.Object["status"].(map[string]interface{}) + phase, _ := status["phase"].(string) + if phase == "Running" { + runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8001/workflow", sessionName, project) + runnerReq := map[string]string{ + "gitUrl": req.GitURL, + "branch": branch, + "path": req.Path, + } + reqBody, _ := json.Marshal(runnerReq) + + log.Printf("Calling runner to activate workflow: %s@%s (path: %s) -> %s", req.GitURL, branch, req.Path, runnerURL) + httpReq, err := http.NewRequestWithContext(c.Request.Context(), "POST", runnerURL, bytes.NewReader(reqBody)) + if err != nil { + log.Printf("Failed to create runner request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create runner request"}) + return + } + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 120 * time.Second} // Allow time for clone + resp, err := client.Do(httpReq) + if err != nil { + log.Printf("Failed to call runner to activate workflow: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to activate workflow (runner not reachable)"}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("Runner failed to activate workflow (status %d): %s", resp.StatusCode, string(body)) + c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Failed to activate workflow: %s", string(body))}) + return + } + log.Printf("Runner successfully activated workflow %s@%s for session %s", req.GitURL, branch, sessionName) + } + // Update activeWorkflow in spec spec, ok := item.Object["spec"].(map[string]interface{}) if !ok { @@ -1202,11 +1247,7 @@ func SelectWorkflow(c *gin.Context) { // Set activeWorkflow workflowMap := map[string]interface{}{ "gitUrl": req.GitURL, - } - if req.Branch != "" { - workflowMap["branch"] = req.Branch - } else { - workflowMap["branch"] = "main" + "branch": branch, } if req.Path != "" { workflowMap["path"] = req.Path @@ -1221,7 +1262,7 @@ func SelectWorkflow(c *gin.Context) { return } - log.Printf("Workflow updated for session %s: %s@%s", sessionName, req.GitURL, workflowMap["branch"]) + log.Printf("Workflow updated for session %s: %s@%s", sessionName, req.GitURL, branch) // Respond with updated session summary session := types.AgenticSession{ @@ -1694,7 +1735,14 @@ func ListOOTBWorkflows(c *gin.Context) { return } ootbCache.mu.RUnlock() - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to discover OOTB workflows"}) + // Include more context in error message for debugging + errMsg := "Failed to discover OOTB workflows" + if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "rate limit") { + errMsg = "Failed to discover OOTB workflows: GitHub rate limit exceeded. Try again later or configure a GitHub token in project settings." + } else if strings.Contains(err.Error(), "404") { + errMsg = "Failed to discover OOTB workflows: Repository or path not found" + } + c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) return } diff --git a/components/backend/routes.go b/components/backend/routes.go index 539ca4ea5..b6dd5876c 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -90,6 +90,9 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/agentic-sessions/:sessionName/agui/history", websocket.HandleAGUIHistory) projectGroup.GET("/agentic-sessions/:sessionName/agui/runs", websocket.HandleAGUIRuns) + // MCP status endpoint + projectGroup.GET("/agentic-sessions/:sessionName/mcp/status", websocket.HandleMCPStatus) + // Session export projectGroup.GET("/agentic-sessions/:sessionName/export", websocket.HandleExportSession) @@ -118,6 +121,11 @@ func registerRoutes(r *gin.Engine) { api.POST("/auth/github/disconnect", handlers.DisconnectGitHubGlobal) api.GET("/auth/github/user/callback", handlers.HandleGitHubUserOAuthCallback) + // Cluster-level Google OAuth (similar to GitHub App pattern) + api.POST("/auth/google/connect", handlers.GetGoogleOAuthURLGlobal) + api.GET("/auth/google/status", handlers.GetGoogleOAuthStatusGlobal) + api.POST("/auth/google/disconnect", handlers.DisconnectGoogleOAuthGlobal) + // Cluster info endpoint (public, no auth required) api.GET("/cluster-info", handlers.GetClusterInfo) diff --git a/components/backend/websocket/agui_proxy.go b/components/backend/websocket/agui_proxy.go index 36a32f5ad..7c2ec931b 100644 --- a/components/backend/websocket/agui_proxy.go +++ b/components/backend/websocket/agui_proxy.go @@ -406,6 +406,88 @@ func HandleAGUIInterrupt(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Interrupt signal sent"}) } +// HandleMCPStatus proxies MCP status requests to runner +// GET /api/projects/:projectName/agentic-sessions/:sessionName/mcp/status +func HandleMCPStatus(c *gin.Context) { + projectName := c.Param("projectName") + sessionName := c.Param("sessionName") + + // SECURITY: Authenticate user and get user-scoped K8s client + reqK8s, _ := handlers.GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // SECURITY: Verify user has permission to read this session + ctx := context.Background() + ssar := &authv1.SelfSubjectAccessReview{ + Spec: authv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authv1.ResourceAttributes{ + Group: "vteam.ambient-code", + Resource: "agenticsessions", + Verb: "get", + Namespace: projectName, + Name: sessionName, + }, + }, + } + res, err := reqK8s.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, metav1.CreateOptions{}) + if err != nil || !res.Status.Allowed { + log.Printf("MCP Status: User not authorized to read session %s/%s", projectName, sessionName) + c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"}) + c.Abort() + return + } + + // Get runner endpoint + runnerURL, err := getRunnerEndpoint(projectName, sessionName) + if err != nil { + log.Printf("MCP Status: Failed to get runner endpoint: %v", err) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Runner not available"}) + return + } + + mcpStatusURL := strings.TrimSuffix(runnerURL, "/") + "/mcp/status" + log.Printf("MCP Status: Forwarding to runner: %s", mcpStatusURL) + + // GET from runner's MCP status endpoint + req, err := http.NewRequest("GET", mcpStatusURL, nil) + if err != nil { + log.Printf("MCP Status: Failed to create request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("MCP Status: Request failed: %v", err) + // Runner might not be running yet - return empty list + c.JSON(http.StatusOK, gin.H{"servers": []interface{}{}, "totalCount": 0}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("MCP Status: Runner returned %d: %s", resp.StatusCode, string(body)) + c.JSON(http.StatusOK, gin.H{"servers": []interface{}{}, "totalCount": 0}) + return + } + + // Forward runner response to client + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + log.Printf("MCP Status: Failed to decode response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse runner response"}) + return + } + + c.JSON(http.StatusOK, result) +} + // getRunnerEndpoint returns the AG-UI server endpoint for a session // The operator creates a Service named "session-{sessionName}" in the project namespace func getRunnerEndpoint(projectName, sessionName string) (string, error) { diff --git a/components/frontend/src/app/api/auth/google/connect/route.ts b/components/frontend/src/app/api/auth/google/connect/route.ts new file mode 100644 index 000000000..abede60c9 --- /dev/null +++ b/components/frontend/src/app/api/auth/google/connect/route.ts @@ -0,0 +1,48 @@ +/** + * Google OAuth Connect API Route + * POST /api/auth/google/connect + * Returns OAuth URL for cluster-level Google authentication + */ + +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + // Build auth headers from the incoming request + const headers = await buildForwardHeadersAsync(request) + + // Build backend URL + const backendUrl = `${BACKEND_URL}/auth/google/connect` + + try { + const response = await fetch(backendUrl, { + method: 'POST', + headers, + }) + + if (!response.ok) { + const errorText = await response.text() + return new Response(JSON.stringify({ error: errorText }), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const data = await response.json() + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('Google OAuth connect proxy error:', error) + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to get OAuth URL', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +} + diff --git a/components/frontend/src/app/api/auth/google/disconnect/route.ts b/components/frontend/src/app/api/auth/google/disconnect/route.ts new file mode 100644 index 000000000..772da54d5 --- /dev/null +++ b/components/frontend/src/app/api/auth/google/disconnect/route.ts @@ -0,0 +1,48 @@ +/** + * Google OAuth Disconnect API Route + * POST /api/auth/google/disconnect + * Disconnects Google OAuth for current user + */ + +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + // Build auth headers from the incoming request + const headers = await buildForwardHeadersAsync(request) + + // Build backend URL + const backendUrl = `${BACKEND_URL}/auth/google/disconnect` + + try { + const response = await fetch(backendUrl, { + method: 'POST', + headers, + }) + + if (!response.ok) { + const errorText = await response.text() + return new Response(JSON.stringify({ error: errorText }), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const data = await response.json() + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('Google OAuth disconnect proxy error:', error) + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to disconnect', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +} + diff --git a/components/frontend/src/app/api/auth/google/status/route.ts b/components/frontend/src/app/api/auth/google/status/route.ts new file mode 100644 index 000000000..891fb17f3 --- /dev/null +++ b/components/frontend/src/app/api/auth/google/status/route.ts @@ -0,0 +1,48 @@ +/** + * Google OAuth Status API Route + * GET /api/auth/google/status + * Returns connection status for current user + */ + +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + // Build auth headers from the incoming request + const headers = await buildForwardHeadersAsync(request) + + // Build backend URL + const backendUrl = `${BACKEND_URL}/auth/google/status` + + try { + const response = await fetch(backendUrl, { + method: 'GET', + headers, + }) + + if (!response.ok) { + const errorText = await response.text() + return new Response(JSON.stringify({ error: errorText }), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const data = await response.json() + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('Google OAuth status proxy error:', error) + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to get status', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +} + diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/mcp/status/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/mcp/status/route.ts new file mode 100644 index 000000000..93fc1d4f4 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/mcp/status/route.ts @@ -0,0 +1,55 @@ +/** + * MCP Status API Route + * GET /api/projects/:name/agentic-sessions/:sessionName/mcp/status + * Proxies to backend which proxies to runner + */ + +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string; sessionName: string }> }, +) { + const { name, sessionName } = await params + + // Build auth headers from the incoming request + const headers = await buildForwardHeadersAsync(request) + + // Build backend URL + const backendUrl = `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/mcp/status` + + try { + const response = await fetch(backendUrl, { + method: 'GET', + headers, + }) + + if (!response.ok) { + const errorText = await response.text() + return new Response(JSON.stringify({ error: errorText }), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const data = await response.json() + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('MCP status proxy error:', error) + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to fetch MCP status', + servers: [], + totalCount: 0, + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +} + diff --git a/components/frontend/src/app/api/workflows/ootb/route.ts b/components/frontend/src/app/api/workflows/ootb/route.ts index 41c463a95..8affdb55e 100644 --- a/components/frontend/src/app/api/workflows/ootb/route.ts +++ b/components/frontend/src/app/api/workflows/ootb/route.ts @@ -1,13 +1,27 @@ import { BACKEND_URL } from "@/lib/config"; +import { NextRequest } from "next/server"; -export async function GET() { +export async function GET(request: NextRequest) { try { - // No auth required for public OOTB workflows endpoint - const response = await fetch(`${BACKEND_URL}/workflows/ootb`, { + // Forward query parameters to backend (e.g., project param for GitHub token lookup) + const searchParams = request.nextUrl.searchParams; + const queryString = searchParams.toString(); + const url = queryString + ? `${BACKEND_URL}/workflows/ootb?${queryString}` + : `${BACKEND_URL}/workflows/ootb`; + + // Forward authorization header if present (enables GitHub token lookup for better rate limits) + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + const authHeader = request.headers.get("Authorization"); + if (authHeader) { + headers["Authorization"] = authHeader; + } + + const response = await fetch(url, { method: 'GET', - headers: { - "Content-Type": "application/json", - }, + headers, }); // Forward the response from backend diff --git a/components/frontend/src/app/integrations/IntegrationsClient.tsx b/components/frontend/src/app/integrations/IntegrationsClient.tsx index 8c21d1bf7..7893c568a 100644 --- a/components/frontend/src/app/integrations/IntegrationsClient.tsx +++ b/components/frontend/src/app/integrations/IntegrationsClient.tsx @@ -2,6 +2,7 @@ import React from 'react' import { GitHubConnectionCard } from '@/components/github-connection-card' +import { GoogleDriveConnectionCard } from '@/components/google-drive-connection-card' import { PageHeader } from '@/components/page-header' type Props = { appSlug?: string } @@ -24,6 +25,7 @@ export default function IntegrationsClient({ appSlug }: Props) {
+
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx index e161272f5..c34ec6514 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx @@ -1,14 +1,13 @@ 'use client' -import { useState } from 'react' -import { Plug, Check, Loader2 } from 'lucide-react' +import { Plug, CheckCircle2, XCircle, AlertCircle } from 'lucide-react' import { AccordionItem, AccordionTrigger, AccordionContent, } from '@/components/ui/accordion' -import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { useMcpStatus } from '@/services/queries/use-mcp' type McpIntegrationsAccordionProps = { projectName: string @@ -19,56 +18,50 @@ export function McpIntegrationsAccordion({ projectName, sessionName, }: McpIntegrationsAccordionProps) { - const [googleConnected, setGoogleConnected] = useState(false) - const [connecting, setConnecting] = useState(false) - - const handleConnectGoogle = async () => { - setConnecting(true) - - try { - // Call backend to get OAuth URL - const response = await fetch( - `/api/projects/${projectName}/agentic-sessions/${sessionName}/oauth/google/url` - ) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to get OAuth URL') - } - - const data = await response.json() - const authUrl = data.url - - // Open OAuth flow in popup window - const width = 600 - const height = 700 - const left = window.screen.width / 2 - width / 2 - const top = window.screen.height / 2 - height / 2 - - const popup = window.open( - authUrl, - 'Google OAuth', - `width=${width},height=${height},left=${left},top=${top}` - ) - - // Poll for popup close (credentials will be stored server-side) - const pollTimer = setInterval(() => { - if (popup?.closed) { - clearInterval(pollTimer) - setConnecting(false) - // TODO: Check if credentials were successfully stored - setGoogleConnected(true) - } - }, 500) - } catch (error) { - console.error('Failed to initiate Google OAuth:', error) - setConnecting(false) + // Fetch real MCP status from runner + const { data: mcpStatus } = useMcpStatus(projectName, sessionName) + const mcpServers = mcpStatus?.servers || [] + const getStatusIcon = (status: 'configured' | 'connected' | 'disconnected' | 'error') => { + switch (status) { + case 'configured': + case 'connected': + return + case 'error': + return + case 'disconnected': + default: + return } } - const handleDisconnectGoogle = () => { - // TODO: Implement disconnect - remove credentials from session - setGoogleConnected(false) + const getStatusBadge = (status: 'configured' | 'connected' | 'disconnected' | 'error') => { + switch (status) { + case 'configured': + return ( + + Configured + + ) + case 'connected': + return ( + + Connected + + ) + case 'error': + return ( + + Error + + ) + case 'disconnected': + default: + return ( + + Disconnected + + ) + } } return ( @@ -76,83 +69,43 @@ export function McpIntegrationsAccordion({
- MCP Integrations + MCP Server Status
-
- {/* Google Drive Integration */} -
-
-
- -
-
-
-

Google Drive

- {googleConnected && ( - - - Connected - - )} +
+ {mcpServers.length > 0 ? ( + mcpServers.map((server) => ( +
+
+
+ {getStatusIcon(server.status)} +
+
+

{server.displayName}

+

+ {server.name} +

+
+
+
+ {getStatusBadge(server.status)}
-

- Access Drive files in this session -

+ )) + ) : ( +
+

+ No MCP servers configured for this session +

+

+ Configure MCP servers in your workflow or project settings +

-
- {googleConnected ? ( - - ) : ( - - )} -
-
- - {/* Placeholder for future MCP integrations */} -

- More integrations coming soon... -

+ )}
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 2617325b9..d1ff4099f 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -195,6 +195,9 @@ export default function ProjectSessionDetailPage({ const deleteMutation = useDeleteSession(); const continueMutation = useContinueSession(); + // Extract phase for sidebar state management + const phase = session?.status?.phase || "Pending"; + // AG-UI streaming hook - replaces useSessionMessages and useSendChatMessage // Note: autoConnect is intentionally false to avoid SSR hydration mismatch // Connection is triggered manually in useEffect after client hydration @@ -1385,8 +1388,8 @@ export default function ProjectSessionDetailPage({
- {/* Mobile: Options menu button (below header border) - only show when session is running */} - {session?.status?.phase === "Running" && ( + {/* Mobile: Options menu button (below header border) - always show */} + {session && (
+
+ )} +
+
+ )} + {/* Mobile close button */}
-
+
- diff --git a/components/frontend/src/app/projects/[name]/sessions/new/model-configuration.tsx b/components/frontend/src/app/projects/[name]/sessions/new/model-configuration.tsx deleted file mode 100644 index a4e442135..000000000 --- a/components/frontend/src/app/projects/[name]/sessions/new/model-configuration.tsx +++ /dev/null @@ -1,119 +0,0 @@ -"use client"; - -import { Control } from "react-hook-form"; -import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; - -const models = [ - { value: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, - { value: "claude-opus-4-5", label: "Claude Opus 4.5" }, - { value: "claude-opus-4-1", label: "Claude Opus 4.1" }, - { value: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, -]; - -type ModelConfigurationProps = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - control: Control; -}; - -export function ModelConfiguration({ control }: ModelConfigurationProps) { - return ( -
-
- ( - - Model - - - - )} - /> - - ( - - Temperature - - field.onChange(parseFloat(e.target.value))} - /> - - Controls randomness (0.0 - 2.0) - - - )} - /> -
- -
- ( - - Max Output Tokens - - field.onChange(parseInt(e.target.value))} - /> - - Maximum response length (100-8000) - - - )} - /> - - ( - - Timeout (seconds) - - field.onChange(parseInt(e.target.value))} - /> - - Session timeout (60-1800 seconds) - - - )} - /> -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/sessions/new/page.tsx b/components/frontend/src/app/projects/[name]/sessions/new/page.tsx deleted file mode 100644 index 147d651a6..000000000 --- a/components/frontend/src/app/projects/[name]/sessions/new/page.tsx +++ /dev/null @@ -1,290 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Loader2 } from "lucide-react"; -import { useForm, useFieldArray } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; - -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Textarea } from "@/components/ui/textarea"; -import type { CreateAgenticSessionRequest } from "@/types/agentic-session"; -import { Checkbox } from "@/components/ui/checkbox"; -import { errorToast } from "@/hooks/use-toast"; -import { Breadcrumbs } from "@/components/breadcrumbs"; -import { RepositoryDialog } from "./repository-dialog"; -import { RepositoryList } from "./repository-list"; -import { ModelConfiguration } from "./model-configuration"; -import { useCreateSession } from "@/services/queries/use-sessions"; - -const formSchema = z - .object({ - initialPrompt: z.string(), - model: z.string().min(1, "Please select a model"), - temperature: z.number().min(0).max(2), - maxTokens: z.number().min(100).max(8000), - timeout: z.number().min(60).max(1800), - interactive: z.boolean().default(false), - // Unified multi-repo array - repos: z - .array(z.object({ - url: z.string().url(), - branch: z.string().optional(), - })) - .optional() - .default([]), - // Runner behavior - autoPushOnComplete: z.boolean().default(false), - }) - .superRefine((data, ctx) => { - const isInteractive = Boolean(data.interactive); - const promptLength = (data.initialPrompt || "").trim().length; - if (!isInteractive && promptLength < 10) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["initialPrompt"], - message: "Prompt must be at least 10 characters long", - }); - } - }); - -type FormValues = z.input; - -export default function NewProjectSessionPage({ params }: { params: Promise<{ name: string }> }) { - const router = useRouter(); - const [projectName, setProjectName] = useState(""); - const [editingRepoIndex, setEditingRepoIndex] = useState(null); - const [repoDialogOpen, setRepoDialogOpen] = useState(false); - const [tempRepo, setTempRepo] = useState<{ url: string; branch?: string }>({ url: "", branch: "main" }); - - // React Query hooks - const createSessionMutation = useCreateSession(); - - useEffect(() => { - params.then(({ name }) => setProjectName(name)); - }, [params]); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - initialPrompt: "", - model: "claude-sonnet-4-5", - temperature: 0.7, - maxTokens: 4000, - timeout: 300, - interactive: false, - autoPushOnComplete: false, - repos: [], - }, - }); - - // Field arrays for multi-repo configuration - const { append: appendRepo, remove: removeRepo, update: updateRepo } = useFieldArray({ control: form.control, name: "repos" }); - - // Watch interactive to adjust prompt field hints - const isInteractive = form.watch("interactive"); - - - - - - const onSubmit = async (values: FormValues) => { - if (!projectName) return; - - const promptToSend = values.interactive && !values.initialPrompt.trim() - ? "Greet the user and briefly explain the workspace capabilities: they can select workflows, add code repositories for context, use commands, and you'll help with software engineering tasks. Keep it friendly and concise." - : values.initialPrompt; - const request: CreateAgenticSessionRequest = { - initialPrompt: promptToSend, - llmSettings: { - model: values.model, - temperature: values.temperature, - maxTokens: values.maxTokens, - }, - timeout: values.timeout, - interactive: values.interactive, - autoPushOnComplete: values.autoPushOnComplete, - }; - - // Apply labels if projectName is present - if (projectName) { - request.labels = { - ...(request.labels || {}), - project: projectName, - }; - } - - - // Multi-repo configuration (simplified format) - const repos = (values.repos || []).filter(r => r && r.url); - if (repos.length > 0) { - request.repos = repos; - } - - createSessionMutation.mutate( - { projectName, data: request }, - { - onSuccess: (session) => { - const sessionName = session.metadata.name; - router.push(`/projects/${encodeURIComponent(projectName)}/sessions/${sessionName}`); - }, - onError: (error) => { - errorToast(error.message || "Failed to create session"); - }, - } - ); - }; - - return ( -
- - - - - New Agentic Session - Create a new agentic session that will analyze a website - - -
- - ( - - - field.onChange(Boolean(v))} /> - -
- Interactive chat - - When enabled, the session runs in chat mode. You can send messages and receive streamed responses. - -
- -
- )} - /> - - {!isInteractive && ( - ( - - Agentic Prompt - -