Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion components/ambient-api-server/secrets/db.port
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5432
5432
10 changes: 5 additions & 5 deletions components/ambient-cli/cmd/acpctl/project/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
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
32 changes: 22 additions & 10 deletions components/runners/ambient-runner/ambient_runner/bridge.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading