diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 35485d866..29b1df9fe 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest needs: [detect-changes] if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' - + timeout-minutes: 25 steps: diff --git a/components/ambient-api-server/secrets/db.port b/components/ambient-api-server/secrets/db.port index fbdcedfb7..38627a6f0 100644 --- a/components/ambient-api-server/secrets/db.port +++ b/components/ambient-api-server/secrets/db.port @@ -1 +1 @@ -5432 \ No newline at end of file +5432 diff --git a/components/ambient-cli/cmd/acpctl/project/cmd.go b/components/ambient-cli/cmd/acpctl/project/cmd.go index c8388a4df..910c4c267 100644 --- a/components/ambient-cli/cmd/acpctl/project/cmd.go +++ b/components/ambient-cli/cmd/acpctl/project/cmd.go @@ -24,13 +24,13 @@ var Cmd = &cobra.Command{ Long: `Manage projects in the Ambient Code Platform.`, Example: ` # Set current project context (shorthand) acpctl project my-project - - # Set current project context (explicit) + + # Set current project context (explicit) acpctl project set my-project - - # Get current project context + + # Get current project context acpctl project current - + # List all projects acpctl project list`, Args: cobra.MaximumNArgs(1), diff --git a/components/backend/git/operations.go b/components/backend/git/operations.go old mode 100644 new mode 100755 index 85a08649d..956da3f5d --- a/components/backend/git/operations.go +++ b/components/backend/git/operations.go @@ -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) @@ -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 } } } @@ -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) } @@ -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" @@ -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 @@ -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: diff --git a/components/backend/github/token.go b/components/backend/github/token.go index e31b37584..28ac35e58 100644 --- a/components/backend/github/token.go +++ b/components/backend/github/token.go @@ -13,7 +13,6 @@ import ( "net/http" "os" "strings" - "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -23,13 +22,6 @@ import ( type TokenManager struct { AppID string PrivateKey *rsa.PrivateKey - cacheMu *sync.Mutex - cache map[int64]cachedInstallationToken -} - -type cachedInstallationToken struct { - token string - expiresAt time.Time } // NewTokenManager creates a new token manager @@ -62,8 +54,6 @@ func NewTokenManager() (*TokenManager, error) { return &TokenManager{ AppID: appID, PrivateKey: privateKey, - cacheMu: &sync.Mutex{}, - cache: map[int64]cachedInstallationToken{}, }, nil } @@ -117,18 +107,6 @@ func (m *TokenManager) MintInstallationTokenForHost(ctx context.Context, install if m == nil { return "", time.Time{}, fmt.Errorf("GitHub App not configured") } - // Serve from cache if still valid (>3 minutes left) - m.cacheMu.Lock() - if entry, ok := m.cache[installationID]; ok { - if time.Until(entry.expiresAt) > 3*time.Minute { - token := entry.token - exp := entry.expiresAt - m.cacheMu.Unlock() - return token, exp, nil - } - } - m.cacheMu.Unlock() - jwtToken, err := m.GenerateJWT() if err != nil { return "", time.Time{}, fmt.Errorf("failed to generate JWT: %w", err) @@ -163,9 +141,6 @@ func (m *TokenManager) MintInstallationTokenForHost(ctx context.Context, install if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return "", time.Time{}, fmt.Errorf("failed to parse token response: %w", err) } - m.cacheMu.Lock() - m.cache[installationID] = cachedInstallationToken{token: parsed.Token, expiresAt: parsed.ExpiresAt} - m.cacheMu.Unlock() return parsed.Token, parsed.ExpiresAt, nil } diff --git a/components/backend/handlers/github_auth.go b/components/backend/handlers/github_auth.go old mode 100644 new mode 100755 index 7a72c3e88..0eab59362 --- a/components/backend/handlers/github_auth.go +++ b/components/backend/handlers/github_auth.go @@ -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 @@ -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 } } diff --git a/components/backend/handlers/operations_test.go b/components/backend/handlers/operations_test.go old mode 100644 new mode 100755 index 20167864a..1e87974fd --- a/components/backend/handlers/operations_test.go +++ b/components/backend/handlers/operations_test.go @@ -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") diff --git a/components/backend/handlers/runtime_credentials.go b/components/backend/handlers/runtime_credentials.go old mode 100644 new mode 100755 index 8f4db2a33..12768b853 --- a/components/backend/handlers/runtime_credentials.go +++ b/components/backend/handlers/runtime_credentials.go @@ -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()}) @@ -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 diff --git a/components/runners/ambient-runner/ambient_runner/bridge.py b/components/runners/ambient-runner/ambient_runner/bridge.py old mode 100644 new mode 100755 index 4a5762473..6dc90f609 --- a/components/runners/ambient-runner/ambient_runner/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridge.py @@ -55,18 +55,24 @@ def _async_safe_manager_shutdown(manager: Any) -> None: try: 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() - ) - if f.exception() - else None - ) + + def _log_shutdown_error(f: asyncio.Future) -> None: + if f.cancelled(): + return + exc = f.exception() + if exc is not None: + _bridge_logger.warning( + "mark_dirty: session_manager shutdown error: %s", exc + ) + + task.add_done_callback(_log_shutdown_error) 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 @@ -151,12 +157,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) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index 431645cde..0a236acdb 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -2,7 +2,7 @@ Claude-specific MCP server building and authentication checks. Assembles the full MCP server dict (external servers from .mcp.json + -platform tools like restart_session and rubric evaluation) and provides +platform tools like refresh_credentials and rubric evaluation) and provides a pre-flight auth check that logs status without emitting events. """ @@ -30,7 +30,11 @@ ] -def build_mcp_servers(context: RunnerContext, cwd_path: str, obs: Any = None) -> dict: +def build_mcp_servers( + context: RunnerContext, + cwd_path: str, + obs: Any = None, +) -> dict: """Build the full MCP server config dict including platform tools. Args: @@ -47,7 +51,6 @@ def build_mcp_servers(context: RunnerContext, cwd_path: str, obs: Any = None) -> from ambient_runner.platform.config import load_mcp_config from ambient_runner.bridges.claude.tools import ( create_refresh_credentials_tool, - create_restart_session_tool, create_rubric_mcp_tool, load_rubric_content, ) @@ -56,14 +59,13 @@ def build_mcp_servers(context: RunnerContext, cwd_path: str, obs: Any = None) -> mcp_servers = load_mcp_config(context, cwd_path) or {} # Session control tools - restart_tool = create_restart_session_tool(None, sdk_tool) refresh_creds_tool = create_refresh_credentials_tool(context, sdk_tool) session_server = create_sdk_mcp_server( - name="session", version="1.0.0", tools=[restart_tool, refresh_creds_tool] + name="session", version="1.0.0", tools=[refresh_creds_tool] ) mcp_servers["session"] = session_server logger.info( - "Added session control MCP tools (restart_session, refresh_credentials)" + "Added session control MCP tools (refresh_credentials)" ) # Rubric evaluation tool diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py index 692b70d3d..23fac4a98 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mock_client.py @@ -137,9 +137,7 @@ def _deserialize(data: dict) -> Optional[Any]: if cls in (AssistantMessage, UserMessage) and "content" in data: content = data["content"] if isinstance(content, list): - data["content"] = [ - b for b in (_deserialize_block(b) for b in content) if b - ] + data["content"] = [b for b in (_deserialize_block(b) for b in content) if b] try: return cls(**data) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py index 1e581f191..b43674d35 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/session.py @@ -100,7 +100,11 @@ async def _run(self) -> None: os.environ["ANTHROPIC_API_KEY"] = self._api_key - from ambient_runner.bridges.claude.mock_client import MOCK_API_KEY, MockClaudeSDKClient + from ambient_runner.bridges.claude.mock_client import ( + MOCK_API_KEY, + MockClaudeSDKClient, + ) + if self._api_key == MOCK_API_KEY: logger.info("[SessionWorker] Using MockClaudeSDKClient (replay mode)") client: Any = MockClaudeSDKClient(options=self._options) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py index d41561696..824069be8 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py @@ -4,7 +4,6 @@ Tools are created dynamically per-run and registered as in-process MCP servers alongside the Claude Agent SDK. -- ``restart_session`` — allows Claude to request a session restart - ``refresh_credentials`` — allows Claude to refresh auth tokens mid-run - ``evaluate_rubric`` — logs a rubric evaluation score to Langfuse """ @@ -18,52 +17,11 @@ from ambient_runner.bridge import TOOL_REFRESH_MIN_INTERVAL_SEC from ambient_runner.platform.prompts import ( REFRESH_CREDENTIALS_TOOL_DESCRIPTION, - RESTART_TOOL_DESCRIPTION, ) logger = logging.getLogger(__name__) -# ------------------------------------------------------------------ -# Session restart tool -# ------------------------------------------------------------------ - - -def create_restart_session_tool(adapter_ref, sdk_tool_decorator): - """Create the restart_session MCP tool. - - Args: - adapter_ref: Reference to the ClaudeCodeAdapter instance - (used to set _restart_requested flag). - sdk_tool_decorator: The ``tool`` decorator from ``claude_agent_sdk``. - - Returns: - Decorated async tool function. - """ - - @sdk_tool_decorator( - "restart_session", - RESTART_TOOL_DESCRIPTION, - {}, - ) - async def restart_session_tool(args: dict) -> dict: - """Tool that allows Claude to request a session restart.""" - adapter_ref._restart_requested = True - logger.info("Session restart requested by Claude via MCP tool") - return { - "content": [ - { - "type": "text", - "text": ( - "Session restart has been requested. The current run " - "will complete and a fresh session will be established. " - "Your conversation context will be preserved on disk." - ), - } - ] - } - - return restart_session_tool # ------------------------------------------------------------------ diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py index 77915f922..1f5e027d5 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/bridge.py @@ -287,7 +287,9 @@ async def _setup_platform(self) -> None: # MCP servers — write .gemini/settings.json so the CLI discovers them from ambient_runner.bridges.gemini_cli.mcp import setup_gemini_mcp - from ambient_runner.bridges.gemini_cli.system_prompt import write_gemini_system_prompt + from ambient_runner.bridges.gemini_cli.system_prompt import ( + write_gemini_system_prompt, + ) mcp_settings_path = setup_gemini_mcp(self._context, cwd_path) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py index 9ae9d2d18..2406685cc 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/feedback_server.py @@ -107,6 +107,7 @@ def _error(msg_id, code: int, message: str) -> None: # Tool handlers # --------------------------------------------------------------------------- + def _handle_evaluate_rubric(args: dict) -> dict: from ambient_runner.platform.feedback import log_rubric_score @@ -116,7 +117,9 @@ def _handle_evaluate_rubric(args: dict) -> dict: success, err = log_rubric_score(score=score, comment=comment, session_id=session_id) if success: - return {"content": [{"type": "text", "text": f"Score {score} logged to Langfuse."}]} + return { + "content": [{"type": "text", "text": f"Score {score} logged to Langfuse."}] + } return { "content": [{"type": "text", "text": f"Failed to log score: {err}"}], "isError": True, @@ -137,7 +140,9 @@ def _handle_log_correction(args: dict) -> dict: source=args.get("source", "human"), ) if success: - return {"content": [{"type": "text", "text": "Correction logged successfully."}]} + return { + "content": [{"type": "text", "text": "Correction logged successfully."}] + } return { "content": [{"type": "text", "text": f"Failed to log correction: {err}"}], "isError": True, diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py index 23c0406a9..e696f1a7f 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py @@ -214,7 +214,9 @@ def _build_system_prompt(cwd_path: str) -> str: if active_workflow_url: ambient_config = load_ambient_config(cwd_path) or {} if ambient_config.get("systemPrompt"): - sections.append(f"## Workflow Instructions\n\n{ambient_config['systemPrompt']}\n") + sections.append( + f"## Workflow Instructions\n\n{ambient_config['systemPrompt']}\n" + ) # ---- Rubric instructions (when rubric config exists) ---- rubric_config = ambient_config.get("rubric", {}) diff --git a/components/runners/ambient-runner/ambient_runner/platform/auth.py b/components/runners/ambient-runner/ambient_runner/platform/auth.py old mode 100644 new mode 100755 index 005989caf..ff4153d59 --- a/components/runners/ambient-runner/ambient_runner/platform/auth.py +++ b/components/runners/ambient-runner/ambient_runner/platform/auth.py @@ -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 @@ -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) @@ -127,7 +135,7 @@ 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"): @@ -135,6 +143,19 @@ async def fetch_github_credentials(context: RunnerContext) -> dict: 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"].replace("Z", "+00:00") + ) + _credential_expiry["github"] = exp_dt.timestamp() + logger.info(f"GitHub token expires at {data['expiresAt']}") + except (ValueError, TypeError) as e: + _credential_expiry.pop("github", None) + 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 @@ -144,6 +165,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") @@ -329,8 +358,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 diff --git a/components/runners/ambient-runner/scripts/capture-fixtures.py b/components/runners/ambient-runner/scripts/capture-fixtures.py index 310603313..d4e4d415e 100755 --- a/components/runners/ambient-runner/scripts/capture-fixtures.py +++ b/components/runners/ambient-runner/scripts/capture-fixtures.py @@ -62,7 +62,11 @@ async def capture(prompt: str, output_path: Path) -> None: # Tag content blocks with their type so they can be deserialized if type_name in ("AssistantMessage", "UserMessage") and "content" in data: content = data["content"] - if isinstance(content, list) and hasattr(msg, "content") and isinstance(msg.content, list): + if ( + isinstance(content, list) + and hasattr(msg, "content") + and isinstance(msg.content, list) + ): for block, orig in zip(content, msg.content): if isinstance(block, dict): block["_type"] = type(orig).__name__ @@ -81,13 +85,17 @@ def main() -> None: formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("prompt", help="Prompt to send to Claude (use single quotes)") - parser.add_argument("--out", help="Output JSONL path (default: fixtures/.jsonl)") + parser.add_argument( + "--out", help="Output JSONL path (default: fixtures/.jsonl)" + ) args = parser.parse_args() if args.out: output_path = Path(args.out) else: - slug = "".join(c if c.isalnum() else "-" for c in args.prompt.lower()[:30]).strip("-") + slug = "".join( + c if c.isalnum() else "-" for c in args.prompt.lower()[:30] + ).strip("-") output_path = ( Path(__file__).parent.parent / "ambient_runner/bridges/claude/fixtures" diff --git a/components/runners/ambient-runner/tests/test_bridge_claude.py b/components/runners/ambient-runner/tests/test_bridge_claude.py index 07368f721..b21eba510 100644 --- a/components/runners/ambient-runner/tests/test_bridge_claude.py +++ b/components/runners/ambient-runner/tests/test_bridge_claude.py @@ -242,6 +242,7 @@ async def test_forwards_workflow_env_vars_to_initialize(self): return_value=mock_obs_instance, ) as mock_obs_cls: from ambient_runner.bridge import setup_bridge_observability + await setup_bridge_observability(ctx, "claude-sonnet-4-5") mock_obs_cls.assert_called_once() @@ -276,6 +277,7 @@ async def test_forwards_empty_defaults_when_workflow_vars_unset(self): return_value=mock_obs_instance, ): from ambient_runner.bridge import setup_bridge_observability + await setup_bridge_observability(ctx, "claude-sonnet-4-5") call_kwargs = mock_obs_instance.initialize.call_args[1] diff --git a/components/runners/ambient-runner/tests/test_context.py b/components/runners/ambient-runner/tests/test_context.py index ad196a997..8b5e3bda1 100644 --- a/components/runners/ambient-runner/tests/test_context.py +++ b/components/runners/ambient-runner/tests/test_context.py @@ -2,7 +2,6 @@ import os -import pytest from ambient_runner.platform.context import RunnerContext diff --git a/components/runners/ambient-runner/tests/test_git_identity.py b/components/runners/ambient-runner/tests/test_git_identity.py index 6df747e56..d3101a9ea 100644 --- a/components/runners/ambient-runner/tests/test_git_identity.py +++ b/components/runners/ambient-runner/tests/test_git_identity.py @@ -129,7 +129,9 @@ async def test_fetch_github_credentials_returns_identity(self): "provider": "github", } - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = mock_response result = await fetch_github_credentials(mock_context) @@ -149,7 +151,8 @@ async def test_fetch_github_token_delegates_to_fetch_github_credentials(self): mock_context.session_id = "test-session" with patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_fetch: mock_fetch.return_value = {"token": "ghp_test_token", "userName": "Test"} @@ -189,7 +192,9 @@ async def test_fetch_gitlab_credentials_returns_identity(self): "provider": "gitlab", } - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = mock_response result = await fetch_gitlab_credentials(mock_context) @@ -210,7 +215,8 @@ async def test_fetch_gitlab_token_delegates_to_fetch_gitlab_credentials(self): mock_context.session_id = "test-session" with patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_fetch: mock_fetch.return_value = {"token": "glpat-test_token"} @@ -251,16 +257,25 @@ async def test_git_identity_from_github(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -290,16 +305,25 @@ async def test_git_identity_from_gitlab_when_no_github(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -335,16 +359,25 @@ async def test_github_takes_precedence_over_gitlab(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -367,16 +400,25 @@ async def test_defaults_when_no_credentials(self): with ( patch( - "ambient_runner.platform.auth.fetch_google_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_google_credentials", + new_callable=AsyncMock, ) as mock_google, - patch("ambient_runner.platform.auth.fetch_jira_credentials", new_callable=AsyncMock) as mock_jira, patch( - "ambient_runner.platform.auth.fetch_gitlab_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_jira_credentials", + new_callable=AsyncMock, + ) as mock_jira, + patch( + "ambient_runner.platform.auth.fetch_gitlab_credentials", + new_callable=AsyncMock, ) as mock_gitlab, patch( - "ambient_runner.platform.auth.fetch_github_credentials", new_callable=AsyncMock + "ambient_runner.platform.auth.fetch_github_credentials", + new_callable=AsyncMock, ) as mock_github, - patch("ambient_runner.platform.auth.configure_git_identity", new_callable=AsyncMock) as mock_config, + patch( + "ambient_runner.platform.auth.configure_git_identity", + new_callable=AsyncMock, + ) as mock_config, ): mock_google.return_value = {} mock_jira.return_value = {} @@ -401,7 +443,9 @@ async def test_github_provider_field(self): mock_context = MagicMock(spec=RunnerContext) mock_context.session_id = "test-session" - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = { "token": "ghp_test", "provider": "github", @@ -419,7 +463,9 @@ async def test_gitlab_provider_field(self): mock_context = MagicMock(spec=RunnerContext) mock_context.session_id = "test-session" - with patch("ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock) as mock_fetch: + with patch( + "ambient_runner.platform.auth._fetch_credential", new_callable=AsyncMock + ) as mock_fetch: mock_fetch.return_value = { "token": "glpat-test", "provider": "gitlab", diff --git a/docs/internal/developer/local-development/openshift.md b/docs/internal/developer/local-development/openshift.md index 8099ad8ee..e57b806e0 100644 --- a/docs/internal/developer/local-development/openshift.md +++ b/docs/internal/developer/local-development/openshift.md @@ -209,7 +209,7 @@ oc exec deployment/ambient-api-server-db -n ambient-code -- psql -U ambient -d a # Check control plane is connecting via TLS gRPC oc logs deployment/ambient-control-plane -n ambient-code --tail=10 | grep -i grpc -# Verify API server gRPC streams are active +# Verify API server gRPC streams are active oc logs deployment/ambient-api-server -n ambient-code --tail=20 | grep "gRPC stream started" ``` @@ -229,7 +229,7 @@ oc get route -n ambient-code **Main routes:** - **Frontend**: https://ambient-code.apps./ -- **Backend API**: https://backend-route-ambient-code.apps./ +- **Backend API**: https://backend-route-ambient-code.apps./ - **Public API**: https://public-api-route-ambient-code.apps./ - **Ambient API Server**: https://ambient-api-server-ambient-code.apps./ @@ -335,7 +335,7 @@ OAuth configuration requires cluster-admin permissions for creating the OAuthCli ## What the Deployment Provides - ✅ **Applies all CRDs** (Custom Resource Definitions) -- ✅ **Creates RBAC** roles and service accounts +- ✅ **Creates RBAC** roles and service accounts - ✅ **Deploys all components** with correct OpenShift-compatible security contexts - ✅ **Configures OAuth** integration automatically (with cluster-admin) - ✅ **Creates all routes** for external access @@ -379,4 +379,4 @@ curl -H "Authorization: Bearer $(oc whoami -t)" \ 2. Configure ANTHROPIC_API_KEY in project settings 3. Test SDKs using the commands above 4. Create your first AgenticSession via UI or SDK -5. Monitor with: `oc get pods -n ambient-code -w` \ No newline at end of file +5. Monitor with: `oc get pods -n ambient-code -w`