Fix deployment save auth recovery (#632)

The github auth flow was lost/overlooked during the refactor to use
$EDITOR. This restores the functionality, waiting for auth and opening a
browser.
This commit is contained in:
Adrian Lyjak
2026-05-11 08:47:49 -04:00
committed by GitHub
parent 7a0b62e6d4
commit 532a6fa240
6 changed files with 645 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"llamactl": patch
---
Fix inline auth recovery while saving deployments.
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, AsyncGenerator
import click
from llama_agents.cli.env_settings import LlamactlEnvSettings, read_env_settings
from llama_agents.cli.interactive import is_interactive_session
if TYPE_CHECKING:
from llama_agents.core.client.manage_client import ControlPlaneClient, ProjectClient
@@ -157,6 +158,21 @@ def get_project_client(project_id_override: str | None = None) -> ProjectClient:
context.auth_middleware,
)
if is_interactive_session():
# Deferred: auth command imports browser/prompt dependencies and also
# depends on client helpers for project discovery.
from llama_agents.cli.commands.auth import validate_authenticated_profile
validate_authenticated_profile()
context = _auth_context_or_none(project_id_override)
if context is not None:
return ProjectClient(
context.base_url,
context.project_id,
context.api_key,
context.auth_middleware,
)
from llama_agents.cli.config.env_service import service
auth_svc = service.current_auth_service()
@@ -31,6 +31,7 @@ from llama_agents.core.schema.deployments import (
DeploymentResponse,
DeploymentUpdate,
)
from llama_agents.core.schema.git_validation import RepositoryValidationResponse
from pydantic import ValidationError
from ..app import app
@@ -204,6 +205,210 @@ def _repository_error_path(
return ()
def _github_app_access_url(vr: RepositoryValidationResponse) -> str | None:
return vr.github_app_settings_url or vr.github_app_installation_url
def _is_github_app_connect_url(url: str | None) -> bool:
return (
url is not None
and "/api/internal/external-credentials/github-app/connect" in url
)
def _github_app_authorization_url(vr: RepositoryValidationResponse) -> str | None:
if vr.github_app_authorization_url:
return vr.github_app_authorization_url
if _is_github_app_connect_url(vr.github_app_installation_url):
return vr.github_app_installation_url
return None
def _github_app_recovery_url(vr: RepositoryValidationResponse) -> str | None:
return _github_app_authorization_url(vr) or _github_app_access_url(vr)
def _repository_validation_error_message(vr: RepositoryValidationResponse) -> str:
message = vr.message
authorization_url = _github_app_authorization_url(vr)
if authorization_url:
message += f"\n\nConnect GitHub: {authorization_url}"
else:
url = _github_app_access_url(vr)
if url:
message += f"\n\nInstall the GitHub App: {url}"
return message
class _GitHubCallbackServer:
"""Local callback server for GitHub OAuth/App redirects."""
def __init__(self, port: int = 41010) -> None:
self.port = port
self.callback_received = asyncio.Event()
self.app: Any | None = None
self.runner: Any | None = None
self.site: Any | None = None
async def start(self) -> None:
# Deferred: aiohttp is only needed for the browser callback path.
from aiohttp.web_app import Application
from aiohttp.web_response import Response
from aiohttp.web_runner import AppRunner, TCPSite
async def _handle_callback(_: Any) -> Response:
self.callback_received.set()
return Response(text=self._success_html(), content_type="text/html")
self.app = Application()
self.app.router.add_get("/", _handle_callback)
self.app.router.add_get("/{path:.*}", _handle_callback)
self.runner = AppRunner(self.app, logger=None)
await self.runner.setup()
self.site = TCPSite(self.runner, "localhost", self.port)
await self.site.start()
async def wait(self, timeout: float) -> None:
try:
await asyncio.wait_for(self.callback_received.wait(), timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"GitHub callback timed out after {timeout} seconds")
async def stop(self) -> None:
if self.site is not None:
await self.site.stop()
self.site = None
if self.runner is not None:
await self.runner.cleanup()
self.runner = None
self.app = None
self.callback_received.clear()
def _success_html(self) -> str:
return (
"<!DOCTYPE html>"
"<html>"
"<head><title>llamactl - Authentication Complete</title></head>"
"<body>"
"<h1>Authentication complete</h1>"
"<p>Return to your terminal to continue.</p>"
"</body>"
"</html>"
)
async def _wait_for_callback_or_interval(
server: _GitHubCallbackServer,
interval: int,
) -> bool:
callback_task = asyncio.create_task(server.wait(timeout=300))
sleep_task = asyncio.create_task(asyncio.sleep(interval))
done, pending = await asyncio.wait(
{callback_task, sleep_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in done:
task.result()
return callback_task in done
async def _open_github_url_and_poll_access(
client: Any,
*,
url: str,
wait_message: str,
repo_url: str,
deployment_id: str | None,
pat: str | None,
) -> RepositoryValidationResponse:
# Deferred: browser launch support is only needed on this auth recovery path.
import webbrowser
server = _GitHubCallbackServer()
await server.start()
try:
click.echo(f"Open this URL: {url}", err=True)
webbrowser.open(url)
interval = 10
elapsed = 0
while True:
callback_received = await _wait_for_callback_or_interval(server, interval)
if not callback_received:
elapsed += interval
click.echo(
f"\r{wait_message}... ({elapsed}s)",
nl=False,
err=True,
)
vr = await client.validate_repository(
repo_url=repo_url,
deployment_id=deployment_id,
pat=pat,
)
if vr.accessible:
click.echo(err=True)
return vr
if callback_received:
return vr
finally:
await server.stop()
async def _resolve_github_app_access(
client: Any,
vr: RepositoryValidationResponse,
repo_url: str,
deployment_id: str | None,
pat: str | None,
) -> RepositoryValidationResponse:
"""Open GitHub App access URL and poll until repo validation succeeds."""
authorization_url = _github_app_authorization_url(vr)
install_url = _github_app_access_url(vr)
if authorization_url == install_url:
install_url = None
if authorization_url is None and install_url is None:
return vr
app_name = vr.github_app_name or "configured"
click.echo(
f"GitHub App '{app_name}' does not have access to this repository.",
err=True,
)
if authorization_url is not None:
click.echo("Opening browser to connect GitHub...", err=True)
vr = await _open_github_url_and_poll_access(
client,
url=authorization_url,
wait_message="waiting for GitHub authorization",
repo_url=repo_url,
deployment_id=deployment_id,
pat=pat,
)
if vr.accessible:
return vr
install_url = _github_app_access_url(vr)
if install_url is None:
return vr
click.echo(
"Opening browser to install the GitHub App... (press Ctrl+C to cancel)",
err=True,
)
return await _open_github_url_and_poll_access(
client,
url=install_url,
wait_message="waiting for GitHub App installation",
repo_url=repo_url,
deployment_id=deployment_id,
pat=pat,
)
def _read_apply_input(filename: str) -> str:
if filename == "-":
return click.get_text_stream("stdin").read()
@@ -244,7 +449,9 @@ def _raise_apply_yaml_click_error(exc: ApplyYamlError) -> NoReturn:
def _new_deployment_template_yaml(
*, action_hint: str = "Edit, then run: llamactl deployments apply -f <file>"
*,
action_hint: str = "Edit, then run: llamactl deployments apply -f <file>",
logged_in_email: str | None = None,
) -> str:
ctx = gather_local_context()
@@ -272,7 +479,10 @@ def _new_deployment_template_yaml(
display = DeploymentDisplay(name=None, generate_name=preferred_name, spec=spec)
head: list[str] = [f"WARNING: {warning}" for warning in ctx.warnings]
head: list[str] = []
if logged_in_email:
head.append(f"Logged in as {logged_in_email}")
head.extend(f"WARNING: {warning}" for warning in ctx.warnings)
if ctx.warnings:
head.append("")
head.append(action_hint)
@@ -652,9 +862,17 @@ def create_deployment(
if _requires_file_for_editor():
raise click.ClickException("pass -f <file> for non-interactive create")
# Deferred: auth command imports browser/prompt dependencies and client.py
# imports this command module indirectly during inline auth.
from llama_agents.cli.commands.auth import validate_authenticated_profile
profile = validate_authenticated_profile()
logged_in_email = profile.device_oidc.email if profile.device_oidc else None
_edit_deployment_yaml_loop(
initial_yaml=_new_deployment_template_yaml(
action_hint="Edit, save, and close to create the deployment"
action_hint="Edit, save, and close to create the deployment",
logged_in_email=logged_in_email,
),
project=project,
no_push=no_push,
@@ -904,8 +1122,18 @@ async def _execute_deployment_operation(
pat=display.spec.personal_access_token,
)
if not vr.accessible:
if _github_app_recovery_url(vr) and is_interactive_session():
vr = await _resolve_github_app_access(
client,
vr,
repo_url,
existing.id if existing else None,
display.spec.personal_access_token,
)
if not vr.accessible:
message = _repository_validation_error_message(vr)
raise RepositoryValidationError(
vr.message, _repository_error_path(vr.message, display)
message, _repository_error_path(message, display)
)
# Push ordering matrix:
+31 -1
View File
@@ -99,8 +99,9 @@ def test_client_requires_profile_with_project() -> None:
_close_client(client)
def test_client_requires_valid_profile() -> None:
def test_client_requires_valid_profile(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that client fails when no profile is configured"""
monkeypatch.setattr(client_module, "is_interactive_session", lambda: False)
with patch("llama_agents.cli.config.env_service.service") as mock_service:
mock_auth_svc = MagicMock()
mock_auth_svc.get_current_profile.return_value = None
@@ -109,6 +110,34 @@ def test_client_requires_valid_profile() -> None:
get_project_client()
def test_interactive_project_client_authenticates_and_retries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _profile(project_id="authed-project", api_key="authed-key")
monkeypatch.setattr(client_module, "is_interactive_session", lambda: True)
with (
patch("llama_agents.cli.config.env_service.service") as mock_service,
patch(
"llama_agents.cli.commands.auth.validate_authenticated_profile",
return_value=profile,
) as validate_authenticated_profile,
):
mock_auth_svc = MagicMock()
mock_auth_svc.get_current_profile.side_effect = [None, profile]
mock_auth_svc.auth_middleware.return_value = None
mock_service.current_auth_service.return_value = mock_auth_svc
client = get_project_client()
try:
validate_authenticated_profile.assert_called_once_with()
assert client.base_url == "http://test:8011"
assert client.project_id == "authed-project"
assert client.api_key == "authed-key"
finally:
_close_client(client)
def test_env_var_project_client_uses_default_base_url_and_api_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -198,6 +227,7 @@ def test_incomplete_env_var_project_client_without_profile_warns_and_exits(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
set_llama_cloud_env(monkeypatch, api_key="env-api-key")
monkeypatch.setattr(client_module, "is_interactive_session", lambda: False)
_set_current_profile(monkeypatch, None)
with pytest.raises(click.ClickException, match="No profile configured"):
@@ -4,6 +4,7 @@
from __future__ import annotations
import asyncio
import subprocess
import textwrap
from collections.abc import Generator
@@ -24,6 +25,7 @@ from conftest import (
set_llama_cloud_env,
)
from llama_agents.cli.app import app
from llama_agents.cli.commands import deployment as deployment_cmd
from llama_agents.core.schema.deployments import DeploymentResponse
from llama_agents.core.schema.git_validation import RepositoryValidationResponse
@@ -137,6 +139,43 @@ MINIMAL_UPDATE_YAML = textwrap.dedent("""\
git_ref: v2
""")
GITHUB_APP_INSTALL_URL = (
"https://github.com/apps/llamaindex/installations/new/permissions?target_id=42"
)
GITHUB_APP_SETTINGS_URL = "https://github.com/settings/installations/42"
GITHUB_APP_AUTHORIZATION_URL = (
"https://api.example.test/api/internal/external-credentials/github-app/connect"
)
def _repository_validation_response(
*,
accessible: bool,
message: str,
github_app_installation_url: str | None = None,
github_app_settings_url: str | None = None,
github_app_authorization_url: str | None = None,
) -> RepositoryValidationResponse:
return RepositoryValidationResponse(
accessible=accessible,
message=message,
github_app_name="llamaindex",
github_app_installation_url=github_app_installation_url,
github_app_settings_url=github_app_settings_url,
github_app_authorization_url=github_app_authorization_url,
)
class _FakeGitHubCallbackServer:
async def start(self) -> None:
pass
async def wait(self, timeout: float) -> None:
pass
async def stop(self) -> None:
pass
def test_apply_creates_when_not_found(patched_auth: Any, tmp_path: Any) -> None:
runner = CliRunner()
@@ -418,6 +457,255 @@ def test_apply_validate_repository_blocks_create(
client.create_deployment.assert_not_called()
def test_apply_interactive_inaccessible_github_repo_installs_app_and_retries(
patched_auth: Any, tmp_path: Any
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text(MINIMAL_CREATE_YAML)
client = _apply_client_mock(created=make_deployment("new-app"))
client.validate_repository = AsyncMock(
side_effect=[
_repository_validation_response(
accessible=False,
message="GitHub App does not have access",
github_app_installation_url=GITHUB_APP_INSTALL_URL,
),
_repository_validation_response(accessible=True, message="ok"),
]
)
with (
patch_project_client(client),
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
patch("webbrowser.open") as mock_open,
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
patch(
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
) as mock_wait,
):
mock_wait.return_value = False
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
assert result.exit_code == 0, result.output
mock_open.assert_called_once_with(GITHUB_APP_INSTALL_URL)
assert f"Open this URL: {GITHUB_APP_INSTALL_URL}" in result.output
mock_wait.assert_awaited_once()
assert client.validate_repository.await_count == 2
client.create_deployment.assert_called_once()
assert "created new-app" in result.output
def test_apply_non_interactive_inaccessible_github_repo_error_includes_install_url(
patched_auth: Any, tmp_path: Any
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text(MINIMAL_CREATE_YAML)
client = _apply_client_mock()
client.validate_repository = AsyncMock(
return_value=_repository_validation_response(
accessible=False,
message="GitHub App does not have access",
github_app_installation_url=GITHUB_APP_INSTALL_URL,
)
)
with (
patch_project_client(client),
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=False),
):
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
assert result.exit_code != 0
assert "GitHub App does not have access" in result.output
assert GITHUB_APP_INSTALL_URL in result.output
client.create_deployment.assert_not_called()
def test_apply_inaccessible_github_repo_without_install_url_preserves_error(
patched_auth: Any, tmp_path: Any
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text(MINIMAL_CREATE_YAML)
client = _apply_client_mock()
client.validate_repository = AsyncMock(
return_value=_repository_validation_response(
accessible=False,
message="repo not found",
)
)
with (
patch_project_client(client),
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
patch("webbrowser.open") as mock_open,
):
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
assert result.exit_code != 0
assert "repo not found" in result.output
assert "Install the GitHub App" not in result.output
mock_open.assert_not_called()
client.create_deployment.assert_not_called()
def test_apply_interactive_github_app_settings_url_preferred_over_install_url(
patched_auth: Any, tmp_path: Any
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text(MINIMAL_CREATE_YAML)
client = _apply_client_mock(created=make_deployment("new-app"))
client.validate_repository = AsyncMock(
side_effect=[
_repository_validation_response(
accessible=False,
message="GitHub App does not have access",
github_app_installation_url=GITHUB_APP_INSTALL_URL,
github_app_settings_url=GITHUB_APP_SETTINGS_URL,
),
_repository_validation_response(accessible=True, message="ok"),
]
)
with (
patch_project_client(client),
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
patch("webbrowser.open") as mock_open,
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
patch(
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
) as mock_wait,
):
mock_wait.return_value = False
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
assert result.exit_code == 0, result.output
mock_open.assert_called_once_with(GITHUB_APP_SETTINGS_URL)
client.create_deployment.assert_called_once()
def test_apply_interactive_github_authorization_runs_before_install(
patched_auth: Any, tmp_path: Any
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text(MINIMAL_CREATE_YAML)
client = _apply_client_mock(created=make_deployment("new-app"))
client.validate_repository = AsyncMock(
side_effect=[
_repository_validation_response(
accessible=False,
message="GitHub user authorization required",
github_app_authorization_url=GITHUB_APP_AUTHORIZATION_URL,
),
_repository_validation_response(
accessible=False,
message="GitHub App does not have access",
github_app_installation_url=GITHUB_APP_INSTALL_URL,
),
_repository_validation_response(accessible=True, message="ok"),
]
)
with (
patch_project_client(client),
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
patch("webbrowser.open") as mock_open,
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
patch(
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
) as mock_wait,
):
mock_wait.side_effect = [True, False]
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
assert result.exit_code == 0, result.output
assert [call.args[0] for call in mock_open.call_args_list] == [
GITHUB_APP_AUTHORIZATION_URL,
GITHUB_APP_INSTALL_URL,
]
assert f"Open this URL: {GITHUB_APP_AUTHORIZATION_URL}" in result.output
assert f"Open this URL: {GITHUB_APP_INSTALL_URL}" in result.output
assert client.validate_repository.await_count == 3
client.create_deployment.assert_called_once()
def test_apply_interactive_legacy_connect_url_treated_as_authorization(
patched_auth: Any, tmp_path: Any
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text(MINIMAL_CREATE_YAML)
client = _apply_client_mock(created=make_deployment("new-app"))
client.validate_repository = AsyncMock(
side_effect=[
_repository_validation_response(
accessible=False,
message="GitHub user authorization required",
github_app_installation_url=GITHUB_APP_AUTHORIZATION_URL,
),
_repository_validation_response(accessible=True, message="ok"),
]
)
with (
patch_project_client(client),
patch(f"{_DEPLOY_CMD}.is_interactive_session", return_value=True),
patch("webbrowser.open") as mock_open,
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
patch(
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
) as mock_wait,
):
mock_wait.return_value = True
result = runner.invoke(app, ["deployments", "apply", "-f", str(f)])
assert result.exit_code == 0, result.output
mock_open.assert_called_once_with(GITHUB_APP_AUTHORIZATION_URL)
assert "Opening browser to connect GitHub" in result.output
assert "Opening browser to install the GitHub App" not in result.output
client.create_deployment.assert_called_once()
async def test_github_app_poll_cancelled_error_exits() -> None:
client = MagicMock()
client.validate_repository = AsyncMock()
vr = _repository_validation_response(
accessible=False,
message="GitHub App does not have access",
github_app_installation_url=GITHUB_APP_INSTALL_URL,
)
with (
pytest.raises(asyncio.CancelledError),
patch("webbrowser.open") as mock_open,
patch(f"{_DEPLOY_CMD}._GitHubCallbackServer", _FakeGitHubCallbackServer),
patch(
f"{_DEPLOY_CMD}._wait_for_callback_or_interval", new_callable=AsyncMock
) as mock_wait,
):
mock_wait.side_effect = asyncio.CancelledError
await deployment_cmd._resolve_github_app_access(
client,
vr,
"https://github.com/example/repo",
None,
None,
)
mock_open.assert_called_once_with(GITHUB_APP_INSTALL_URL)
client.validate_repository.assert_not_called()
def test_apply_validates_payload_before_repository(
patched_auth: Any, tmp_path: Any
) -> None:
@@ -9,6 +9,7 @@ import textwrap
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -156,6 +157,50 @@ def test_create_opens_editor_with_template_and_applies_saved_yaml(
assert "created editor-app" in result.output
def test_create_preflights_auth_and_shows_logged_in_email(
patched_auth: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_local_context(monkeypatch)
runner = CliRunner()
client = _editor_client_mock(created=make_deployment("editor-app"))
saved_text = textwrap.dedent("""\
generate_name: Editor App
spec:
repo_url: https://github.com/example/repo
""")
order: list[str] = []
profile = SimpleNamespace(
device_oidc=SimpleNamespace(email="user@example.com"),
)
def _validate_profile() -> SimpleNamespace:
order.append("auth")
return profile
def _open_editor(text: str) -> str:
order.append("editor")
assert "## Logged in as user@example.com" in text
return saved_text
with (
patch_project_client(client),
patch(
"llama_agents.cli.commands.auth.validate_authenticated_profile",
side_effect=_validate_profile,
) as validate_authenticated_profile,
patch(
f"{_DEPLOY_CMD}._open_deployment_yaml_editor",
side_effect=_open_editor,
),
):
result = runner.invoke(app, ["deployments", "create"])
assert result.exit_code == 0, result.output
assert order == ["auth", "editor"]
validate_authenticated_profile.assert_called_once_with()
client.create_deployment.assert_called_once()
def test_create_file_applies_without_editor_and_threads_project_no_push(
patched_auth: Any, tmp_path: Path
) -> None:
@@ -191,6 +236,34 @@ def test_create_file_applies_without_editor_and_threads_project_no_push(
client.create_deployment.assert_called_once()
def test_create_file_without_profile_raises_auth_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
runner = CliRunner()
f = tmp_path / "deploy.yaml"
f.write_text("generate_name: File App\nspec:\n repo_url: ''\n")
mock_auth_svc = MagicMock()
mock_auth_svc.get_current_profile.return_value = None
mock_auth_svc.list_profiles.return_value = []
mock_auth_svc.env = SimpleNamespace(requires_auth=True)
mock_service = MagicMock()
mock_service.current_auth_service.return_value = mock_auth_svc
with (
patch("llama_agents.cli.config.env_service.service", mock_service),
patch("llama_agents.cli.client.is_interactive_session", return_value=False),
patch(_INTERACTIVE_PATCH, return_value=False),
):
result = runner.invoke(app, ["deployments", "create", "-f", str(f)])
assert result.exit_code != 0
assert "No profile configured. To get started, run: llamactl auth login" in (
result.output
)
def test_create_file_uses_create_intent_not_upsert(
patched_auth: Any, tmp_path: Path
) -> None: