Skip to content
Open
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
6 changes: 6 additions & 0 deletions components/backend/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ func registerRoutes(r *gin.Engine) {

api.POST("/projects/:projectName/agentic-sessions/:sessionName/github/token", handlers.MintSessionGitHubToken)

// Runner-accessible feedback endpoint — accepts service account tokens
// (BOT_TOKEN) so that in-session workflows (e.g. Amber) can submit
// feedback without requiring a user OAuth token. The handler performs
// its own auth via GetK8sClientsForRequest + SSAR check.
api.POST("/projects/:projectName/agentic-sessions/:sessionName/runner/feedback", websocket.HandleAGUIFeedback)

projectGroup := api.Group("/projects/:projectName", handlers.ValidateProjectContext())
{
projectGroup.GET("/models", handlers.ListModelsForProject)
Expand Down
17 changes: 12 additions & 5 deletions components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,18 @@ async def _initialize_run(

await self._ensure_ready()

# Fresh credentials for this user on every run
clear_runtime_credentials()
await populate_runtime_credentials(self._context)
await populate_mcp_server_credentials(self._context)
self._last_creds_refresh = time.monotonic()
# Fresh credentials for this user on every run.
# On first run, _setup_platform() already populated credentials and
# built MCP servers with the correct env vars — skip the redundant
# clear-then-repopulate cycle to avoid briefly removing env vars
# (like USER_GOOGLE_EMAIL) that MCP servers depend on.
if self._first_run:
logger.info("First run: using credentials from _setup_platform()")
else:
clear_runtime_credentials()
await populate_runtime_credentials(self._context)
await populate_mcp_server_credentials(self._context)
self._last_creds_refresh = time.monotonic()

# If the caller changed, destroy the worker and rebuild MCP servers +
# adapter so the new ClaudeSDKClient gets fresh mcp_servers config.
Expand Down
41 changes: 37 additions & 4 deletions components/runners/ambient-runner/ambient_runner/bridges/claude/tools.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,30 @@
logger = logging.getLogger(__name__)


# ------------------------------------------------------------------
# Credential refresh helpers
# ------------------------------------------------------------------


def _check_mcp_auth_after_refresh() -> str:
"""Check MCP server auth status after a credential refresh.

Returns a diagnostic string with any warnings about MCP servers
that may not be able to use the refreshed credentials. Empty
string means all known servers look healthy.
"""
from ambient_runner.bridges.claude.mcp import check_mcp_authentication

warnings = []
for server_name in ("google-workspace", "mcp-atlassian"):
is_auth, msg = check_mcp_authentication(server_name)
if is_auth is False:
warnings.append(f"{server_name}: {msg}")
elif is_auth is None and msg:
warnings.append(f"{server_name}: {msg}")
return "; ".join(warnings)


# ------------------------------------------------------------------
# Credential refresh tool
# ------------------------------------------------------------------
Expand Down Expand Up @@ -70,14 +94,23 @@ async def refresh_credentials_tool(args: dict) -> dict:

integrations = get_active_integrations()
summary = ", ".join(integrations) if integrations else "none detected"

# Verify MCP server auth status after refresh to detect
# false positives (credentials written but MCP server can't use them).
diagnostics = _check_mcp_auth_after_refresh()

parts = [
f"Credentials refreshed successfully. "
f"Active integrations: {summary}.",
]
if diagnostics:
parts.append(f"MCP diagnostics: {diagnostics}")

return {
"content": [
{
"type": "text",
"text": (
f"Credentials refreshed successfully. "
f"Active integrations: {summary}."
),
"text": "\n".join(parts),
}
]
}
Expand Down
19 changes: 7 additions & 12 deletions components/runners/ambient-runner/ambient_runner/platform/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,18 +455,13 @@ def clear_runtime_credentials() -> None:
except OSError as e:
logger.warning(f"Failed to remove token file {token_file}: {e}")

# Remove Google Workspace credential file if present (uses same hardcoded path as populate_runtime_credentials)
google_cred_file = _GOOGLE_WORKSPACE_CREDS_FILE
if google_cred_file.exists():
try:
google_cred_file.unlink()
cleared.append("google_workspace_credentials_file")
# Clean up empty parent dirs
cred_dir = google_cred_file.parent
if cred_dir.exists() and not any(cred_dir.iterdir()):
cred_dir.rmdir()
except OSError as e:
logger.warning(f"Failed to remove Google credential file: {e}")
# NOTE: Google Workspace credential file is intentionally NOT deleted here.
# The workspace-mcp process runs as a long-lived child process of the Claude
# CLI and reads credentials from this file. Deleting it between turns causes
# workspace-mcp to lose its credentials and fall back to initiating a new
# OAuth flow (with an inaccessible localhost:8000 callback URL).
# The file is overwritten with fresh credentials at the start of each run
# by populate_runtime_credentials(), so staleness is not a concern.

if cleared:
logger.info(f"Cleared credentials: {', '.join(cleared)}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_GH_WRAPPER_PATH,
_GITHUB_TOKEN_FILE,
_GITLAB_TOKEN_FILE,
_GOOGLE_WORKSPACE_CREDS_FILE,
_fetch_credential,
clear_runtime_credentials,
install_gh_wrapper,
Expand Down Expand Up @@ -143,6 +144,27 @@ def test_does_not_crash_when_vars_absent(self):
# Should not raise
clear_runtime_credentials()

def test_preserves_google_credentials_file(self, tmp_path, monkeypatch):
"""clear_runtime_credentials must NOT delete the Google credentials file.

The workspace-mcp process reads credentials from this file. Deleting it
between turns causes workspace-mcp to fall back to an inaccessible
localhost OAuth flow (issue #1222).
"""
fake_cred_file = tmp_path / "credentials.json"
fake_cred_file.write_text('{"token": "test-access-token"}')
monkeypatch.setattr(
"ambient_runner.platform.auth._GOOGLE_WORKSPACE_CREDS_FILE",
fake_cred_file,
)

clear_runtime_credentials()

assert fake_cred_file.exists(), (
"Google credentials file must NOT be deleted — workspace-mcp needs it"
)
assert fake_cred_file.read_text() == '{"token": "test-access-token"}'

def test_does_not_clear_unrelated_vars(self):
try:
os.environ["PATH_BACKUP_TEST"] = "keep-me"
Expand Down Expand Up @@ -709,12 +731,47 @@ async def test_returns_success_on_successful_refresh(self):
"ambient_runner.platform.utils.get_active_integrations",
return_value=["github", "jira"],
),
patch(
"ambient_runner.bridges.claude.tools._check_mcp_auth_after_refresh",
return_value="",
),
):
result = await tool_fn({})

assert result.get("isError") is None or result.get("isError") is False
assert "successfully" in result["content"][0]["text"].lower()

@pytest.mark.asyncio
async def test_includes_mcp_diagnostics_on_auth_warning(self):
"""refresh_credentials_tool includes MCP diagnostic warnings when auth issues are detected."""
from ambient_runner.bridges.claude.tools import create_refresh_credentials_tool

mock_context = MagicMock()
tool_fn = create_refresh_credentials_tool(
mock_context, self._make_tool_decorator()
)

with (
patch(
"ambient_runner.platform.auth.populate_runtime_credentials",
new_callable=AsyncMock,
),
patch(
"ambient_runner.platform.utils.get_active_integrations",
return_value=["github", "google"],
),
patch(
"ambient_runner.bridges.claude.tools._check_mcp_auth_after_refresh",
return_value="google-workspace: Google OAuth token expired - re-authenticate",
),
):
result = await tool_fn({})

text = result["content"][0]["text"]
assert "successfully" in text.lower()
assert "MCP diagnostics:" in text
assert "google-workspace" in text


# ---------------------------------------------------------------------------
# gh CLI wrapper — ensures gh picks up refreshed tokens (issue #1135)
Expand Down
Loading