Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 15 additions & 12 deletions components/backend/git/operations.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ type DiffSummary struct {
// 1. User's Personal Access Token (cluster-level, highest priority)
// 2. GitHub App installation token (cluster-level)
// 3. Project-level GITHUB_TOKEN (legacy fallback)
func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynClient dynamic.Interface, project, userID string) (string, error) {
func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynClient dynamic.Interface, project, userID string) (string, time.Time, error) {
// Priority 1: Check for user's GitHub PAT (cluster-level)
if GetGitHubPATCredentials != nil {
patCreds, err := GetGitHubPATCredentials(ctx, userID)
Expand All @@ -66,7 +66,8 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli
token := pat.GetToken()
if token != "" {
log.Printf("Using GitHub PAT for user %s (overrides GitHub App)", userID)
return token, nil
// PATs don't expire on a short schedule; return zero time
return token, time.Time{}, nil
}
}
}
Expand All @@ -88,10 +89,10 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli

if inst, ok := installation.(githubInstallation); ok {
if mgr, ok := GitHubTokenManager.(tokenManager); ok {
token, _, err := mgr.MintInstallationTokenForHost(ctx, inst.GetInstallationID(), inst.GetHost())
token, expiresAt, err := mgr.MintInstallationTokenForHost(ctx, inst.GetInstallationID(), inst.GetHost())
if err == nil && token != "" {
log.Printf("Using GitHub App token for user %s", userID)
return token, nil
log.Printf("Using GitHub App token for user %s (expires %s)", userID, expiresAt.Format(time.RFC3339))
return token, expiresAt, nil
}
log.Printf("Failed to mint GitHub App token for user %s: %v", userID, err)
}
Expand All @@ -102,7 +103,7 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli
// Priority 3: Fall back to project integration secret GITHUB_TOKEN (legacy, deprecated)
if k8sClient == nil {
log.Printf("Cannot read integration secret: k8s client is nil")
return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
}

const secretName = "ambient-non-vertex-integrations"
Expand All @@ -112,29 +113,30 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli
secret, err := k8sClient.CoreV1().Secrets(project).Get(ctx, secretName, v1.GetOptions{})
if err != nil {
log.Printf("Failed to get integration secret %s/%s: %v", project, secretName, err)
return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
}

if secret.Data == nil {
log.Printf("Secret %s/%s exists but Data is nil", project, secretName)
return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
}

token, ok := secret.Data["GITHUB_TOKEN"]
if !ok {
log.Printf("Secret %s/%s exists but has no GITHUB_TOKEN key (available keys: %v)", project, secretName, getSecretKeys(secret.Data))
return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
}

if len(token) == 0 {
log.Printf("Secret %s/%s has GITHUB_TOKEN key but value is empty", project, secretName)
return "", fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
return "", time.Time{}, fmt.Errorf("no GitHub credentials available. Connect GitHub on the Integrations page")
}

// Trim whitespace and newlines from token (common issue when copying from web UI)
cleanToken := strings.TrimSpace(string(token))
log.Printf("Using GITHUB_TOKEN from integration secret %s/%s (length=%d, legacy fallback)", project, secretName, len(cleanToken))
return cleanToken, nil
// Legacy PATs don't have known expiry; return zero time
return cleanToken, time.Time{}, nil
}

// GetGitLabToken retrieves a GitLab Personal Access Token for a user
Expand Down Expand Up @@ -197,7 +199,8 @@ func GetGitToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynClient

switch provider {
case types.ProviderGitHub:
return GetGitHubToken(ctx, k8sClient, dynClient, project, userID)
token, _, err := GetGitHubToken(ctx, k8sClient, dynClient, project, userID)
return token, err
case types.ProviderGitLab:
return GetGitLabToken(ctx, k8sClient, project, userID)
default:
Expand Down
8 changes: 5 additions & 3 deletions components/backend/handlers/github_auth.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ var (
)

// WrapGitHubTokenForRepo wraps git.GetGitHubToken to accept kubernetes.Interface instead of *kubernetes.Clientset
// This allows dependency injection while maintaining compatibility with git.GetGitHubToken
func WrapGitHubTokenForRepo(originalFunc func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, error)) func(context.Context, kubernetes.Interface, dynamic.Interface, string, string) (string, error) {
// This allows dependency injection while maintaining compatibility with git.GetGitHubToken.
// The expiresAt return value is discarded since callers only need the token string.
func WrapGitHubTokenForRepo(originalFunc func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, time.Time, error)) func(context.Context, kubernetes.Interface, dynamic.Interface, string, string) (string, error) {
return func(ctx context.Context, k8s kubernetes.Interface, dyn dynamic.Interface, project, userID string) (string, error) {
// Type assert to *kubernetes.Clientset for git.GetGitHubToken
var k8sClient *kubernetes.Clientset
Expand All @@ -55,7 +56,8 @@ func WrapGitHubTokenForRepo(originalFunc func(context.Context, *kubernetes.Clien
return "", fmt.Errorf("kubernetes client is not a *Clientset (got %T)", k8s)
}
}
return originalFunc(ctx, k8sClient, dyn, project, userID)
token, _, err := originalFunc(ctx, k8sClient, dyn, project, userID)
return token, err
}
}

Expand Down
2 changes: 1 addition & 1 deletion components/backend/handlers/operations_test.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ var _ = Describe("Git Operations", Label(test_constants.LabelUnit, test_constant
// Act
k8sClient := k8sUtils.K8sClient
clientset, _ := k8sClient.(*kubernetes.Clientset)
token, err := git.GetGitHubToken(ctx, clientset, k8sUtils.DynamicClient, projectName, userID)
token, _, err := git.GetGitHubToken(ctx, clientset, k8sUtils.DynamicClient, projectName, userID)

// Assert - function should return error for missing/invalid setup
Expect(err).To(HaveOccurred(), "Should return error for missing token/secret")
Expand Down
10 changes: 7 additions & 3 deletions components/backend/handlers/runtime_credentials.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func GetGitHubTokenForSession(c *gin.Context) {
return
}

token, err := git.GetGitHubToken(c.Request.Context(), k8sClientset, DynamicClient, project, userID)
token, expiresAt, err := git.GetGitHubToken(c.Request.Context(), k8sClientset, DynamicClient, project, userID)
if err != nil {
log.Printf("Failed to get GitHub token for user %s: %v", userID, err)
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
Expand All @@ -92,12 +92,16 @@ func GetGitHubTokenForSession(c *gin.Context) {
log.Printf("Returning GitHub credentials with identity for session %s/%s", project, session)
}

c.JSON(http.StatusOK, gin.H{
resp := gin.H{
"token": token,
"userName": userName,
"email": userEmail,
"provider": "github",
})
}
if !expiresAt.IsZero() {
resp["expiresAt"] = expiresAt.Format(time.RFC3339)
}
c.JSON(http.StatusOK, resp)
}

// GetGoogleCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/google
Expand Down
24 changes: 17 additions & 7 deletions components/runners/ambient-runner/ambient_runner/bridge.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,21 @@ def _async_safe_manager_shutdown(manager: Any) -> None:
loop = asyncio.get_running_loop()
task = loop.create_task(manager.shutdown())
task.add_done_callback(
lambda f: _bridge_logger.warning(
"mark_dirty: session_manager shutdown error: %s", f.exception()
lambda f: (
_bridge_logger.warning(
"mark_dirty: session_manager shutdown error: %s", f.exception()
)
if f.exception()
else None
)
if f.exception()
else None
)
except RuntimeError:
try:
asyncio.run(manager.shutdown())
except Exception as exc:
_bridge_logger.warning("mark_dirty: session_manager shutdown error: %s", exc)
_bridge_logger.warning(
"mark_dirty: session_manager shutdown error: %s", exc
)


@dataclass
Expand Down Expand Up @@ -151,12 +155,18 @@ def set_context(self, context: RunnerContext) -> None:
self._context = context

async def _refresh_credentials_if_stale(self) -> None:
"""Refresh platform credentials if the refresh interval has elapsed.
"""Refresh platform credentials if the refresh interval has elapsed
or if the GitHub token is expiring soon.

Call this at the start of each ``run()`` to keep tokens fresh.
"""
now = time.monotonic()
if now - self._last_creds_refresh > CREDS_REFRESH_INTERVAL_SEC:
needs_refresh = now - self._last_creds_refresh > CREDS_REFRESH_INTERVAL_SEC
if not needs_refresh:
from ambient_runner.platform.auth import github_token_expiring_soon

needs_refresh = github_token_expiring_soon()
if needs_refresh:
from ambient_runner.platform.auth import populate_runtime_credentials

await populate_runtime_credentials(self._context)
Expand Down
31 changes: 28 additions & 3 deletions components/runners/ambient-runner/ambient_runner/platform/auth.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import logging
import os
import re
import time
from datetime import datetime
from pathlib import Path
from urllib import request as _urllib_request
from urllib.parse import urlparse
Expand All @@ -21,6 +23,12 @@
# Placeholder email used by the platform when no real email is available.
_PLACEHOLDER_EMAIL = "user@example.com"

# Tracks credential expiry timestamps (epoch seconds) by provider name.
_credential_expiry: dict[str, float] = {}

# How many seconds before expiry to trigger a proactive refresh.
_EXPIRY_BUFFER_SEC = 5 * 60


# ---------------------------------------------------------------------------
# Vertex AI credential validation (shared across all bridges)
Expand Down Expand Up @@ -127,14 +135,24 @@ def _do_req():
async def fetch_github_credentials(context: RunnerContext) -> dict:
"""Fetch GitHub credentials from backend API (always fresh — PAT or minted App token).

Returns dict with: token, userName, email, provider
Returns dict with: token, userName, email, provider, and optionally expiresAt
"""
data = await _fetch_credential(context, "github")
if data.get("token"):
logger.info(
f"Using fresh GitHub credentials from backend "
f"(user: {data.get('userName', 'unknown')}, hasEmail: {bool(data.get('email'))})"
)
if data.get("expiresAt"):
try:
exp_dt = datetime.fromisoformat(data["expiresAt"])
_credential_expiry["github"] = exp_dt.timestamp()
logger.info(f"GitHub token expires at {data['expiresAt']}")
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse GitHub expiresAt: {e}")
else:
# PAT or legacy token without expiry — clear any stale tracking
_credential_expiry.pop("github", None)
return data


Expand All @@ -144,6 +162,14 @@ async def fetch_github_token(context: RunnerContext) -> str:
return data.get("token", "")


def github_token_expiring_soon() -> bool:
"""Return True if the cached GitHub token will expire within the buffer window."""
expiry = _credential_expiry.get("github")
if not expiry:
return False
return time.time() > expiry - _EXPIRY_BUFFER_SEC


async def fetch_google_credentials(context: RunnerContext) -> dict:
"""Fetch Google OAuth credentials from backend API."""
data = await _fetch_credential(context, "google")
Expand Down Expand Up @@ -329,8 +355,7 @@ async def populate_mcp_server_credentials(context: RunnerContext) -> None:

# Check if any env value references ${MCP_*} pattern
needs_creds = any(
isinstance(v, str) and mcp_env_pattern.search(v)
for v in env_block.values()
isinstance(v, str) and mcp_env_pattern.search(v) for v in env_block.values()
)
if not needs_creds:
continue
Expand Down
Loading