Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
25 changes: 0 additions & 25 deletions components/backend/github/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"

"github.com/golang-jwt/jwt/v5"
Expand All @@ -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
Expand Down Expand Up @@ -62,8 +54,6 @@ func NewTokenManager() (*TokenManager, error) {
return &TokenManager{
AppID: appID,
PrivateKey: privateKey,
cacheMu: &sync.Mutex{},
cache: map[int64]cachedInstallationToken{},
}, nil
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ async def _setup_platform(self) -> None:
log_auth_status,
)

mcp_servers = build_mcp_servers(self._context, cwd_path, self._obs)
mcp_servers = build_mcp_servers(
self._context, cwd_path, self._obs, bridge_ref=self
)
log_auth_status(mcp_servers)
allowed_tools = build_allowed_tools(mcp_servers)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,19 @@
]


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,
bridge_ref: Any = None,
) -> dict:
"""Build the full MCP server config dict including platform tools.

Args:
context: Runner context.
cwd_path: Working directory (used to find rubric files).
obs: Optional ObservabilityManager (passed to rubric tool).
bridge_ref: Reference to the bridge instance (deferred adapter access).

Returns:
Dict of MCP server name -> server config.
Expand All @@ -56,7 +62,7 @@ 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)
restart_tool = create_restart_session_tool(bridge_ref, 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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@
# ------------------------------------------------------------------


def create_restart_session_tool(adapter_ref, sdk_tool_decorator):
def create_restart_session_tool(bridge_ref, sdk_tool_decorator):
"""Create the restart_session MCP tool.

Args:
adapter_ref: Reference to the ClaudeCodeAdapter instance
(used to set _restart_requested flag).
bridge_ref: Reference to the bridge instance whose ``_adapter``
attribute is resolved at call time (may be ``None`` during
MCP setup).
sdk_tool_decorator: The ``tool`` decorator from ``claude_agent_sdk``.

Returns:
Expand All @@ -48,7 +49,19 @@ def create_restart_session_tool(adapter_ref, sdk_tool_decorator):
)
async def restart_session_tool(args: dict) -> dict:
"""Tool that allows Claude to request a session restart."""
adapter_ref._restart_requested = True
adapter = getattr(bridge_ref, "_adapter", None) if bridge_ref else None
if adapter is None:
logger.warning("restart_session called but adapter not ready")
return {
"content": [
{
"type": "text",
"text": "Session not ready — cannot restart yet. Try again shortly.",
}
],
"isError": True,
}
adapter._restart_requested = True
logger.info("Session restart requested by Claude via MCP tool")
return {
"content": [
Expand Down
Loading
Loading