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 && (