4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
304 lines
10 KiB
Python
304 lines
10 KiB
Python
"""First-launch GitHub login gate.
|
|
|
|
On the first interactive launch of ``opensre`` (all platforms), the user is
|
|
prompted to sign in to GitHub via device flow unless they skip, are in CI/CD, a
|
|
test harness, or a non-interactive session. The sign-in runs the hosted GitHub
|
|
MCP setup, persists the integration, and propagates the authenticated GitHub
|
|
username to PostHog.
|
|
|
|
Escape hatch: ``OPENSRE_SKIP_GITHUB_LOGIN=1`` bypasses the gate so a GitHub
|
|
outage or a disabled device flow can never permanently lock anyone out. The gate
|
|
is also auto-bypassed in CI/test environments and when stdin is not a TTY.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
from rich.console import Console
|
|
from rich.markup import escape
|
|
|
|
from config.repl_config import read_github_login_deferred, write_github_login_deferred
|
|
from platform.analytics.cli import capture_github_login_completed
|
|
from platform.analytics.source import is_test_run
|
|
from platform.terminal.theme import DEVICE_CODE
|
|
from surfaces.interactive_shell.ui import repl_tty_interactive
|
|
|
|
_SKIP_ENV_VAR = "OPENSRE_SKIP_GITHUB_LOGIN"
|
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
_SIGN_IN_CHOICE = "sign_in"
|
|
_SKIP_CHOICE = "skip"
|
|
|
|
|
|
def _skip_requested() -> bool:
|
|
return os.getenv(_SKIP_ENV_VAR, "").strip().lower() in _TRUTHY
|
|
|
|
|
|
def _github_login_explicitly_bypassed() -> bool:
|
|
"""Cheap check for contexts where gate errors should not block startup."""
|
|
if _skip_requested():
|
|
return True
|
|
if os.getenv("OPENSRE_INVESTIGATION_SOURCE", "").strip().lower() == "test":
|
|
return True
|
|
if os.getenv("OPENSRE_IS_TEST", "0").strip() == "1":
|
|
return True
|
|
if os.getenv("PYTEST_CURRENT_TEST"):
|
|
return True
|
|
if os.getenv("GITHUB_ACTIONS", "").strip().lower() == "true":
|
|
return True
|
|
ci_value = os.getenv("CI", "").strip().lower()
|
|
if ci_value in {"1", "true", "yes"}:
|
|
return True
|
|
try:
|
|
return not sys.stdin.isatty()
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def _github_already_configured() -> bool:
|
|
from integrations.github.mcp import github_integration_is_configured
|
|
|
|
return github_integration_is_configured()
|
|
|
|
|
|
def should_require_github_login() -> bool:
|
|
"""Return True when the first-launch GitHub login prompt must run now."""
|
|
if _skip_requested():
|
|
return False
|
|
if read_github_login_deferred():
|
|
return False
|
|
if is_test_run():
|
|
return False
|
|
if not repl_tty_interactive():
|
|
return False
|
|
# GitHub being configured is the authoritative bypass. We intentionally do
|
|
# NOT consult a first-launch "completion" marker here: a stale marker must
|
|
# never let the REPL start once the GitHub integration has been removed
|
|
# (e.g. via ``/integrations remove github``). Re-checking the store is cheap,
|
|
# so the gate always re-runs when GitHub is not currently configured.
|
|
return not _github_already_configured()
|
|
|
|
|
|
def clear_github_login_deferral() -> None:
|
|
"""Clear a saved skip so removing GitHub can re-prompt on the next launch."""
|
|
if not read_github_login_deferred():
|
|
return
|
|
write_github_login_deferred(False)
|
|
|
|
|
|
def _propagate_username(username: str) -> None:
|
|
if not username:
|
|
return
|
|
# ``authenticate_and_configure_github`` already calls identify_github_username;
|
|
# only emit the one-time login lifecycle event here.
|
|
capture_github_login_completed(username)
|
|
|
|
|
|
def _print_intro(console: Console) -> None:
|
|
console.print()
|
|
console.print("[bold]Connect GitHub to get started[/bold]")
|
|
console.print(
|
|
"OpenSRE needs read access to your GitHub repositories to investigate "
|
|
"incidents against your source. Sign in once with your browser."
|
|
)
|
|
console.print(
|
|
"[dim](Escape to skip for now, or set "
|
|
f"{_SKIP_ENV_VAR}=1 if GitHub sign-in is unavailable.)[/dim]"
|
|
)
|
|
|
|
|
|
def _show_device_code(console: Console, code: object) -> None:
|
|
from integrations.github.mcp_oauth import GitHubDeviceCode
|
|
|
|
if not isinstance(code, GitHubDeviceCode):
|
|
return
|
|
user_code = escape(code.user_code)
|
|
console.print()
|
|
console.print(f" 1. Your browser will open [underline]{code.verification_uri}[/underline]")
|
|
console.print(" (if it doesn't open automatically, visit that URL yourself).")
|
|
console.print(f" 2. Enter this one-time code when GitHub asks: [{DEVICE_CODE}]{user_code}[/]")
|
|
console.print(" 3. Approve the request for OpenSRE.")
|
|
console.print()
|
|
console.print(
|
|
" [dim]Waiting for you to approve in the browser… (Escape or Ctrl-C to skip)[/dim]"
|
|
)
|
|
|
|
|
|
def _print_skip_guidance(console: Console) -> None:
|
|
console.print()
|
|
console.print(
|
|
"[dim]Skipped GitHub sign-in. Connect later with "
|
|
"[bold]/integrations setup[/bold] or [bold]/mcp connect github[/bold].[/dim]"
|
|
)
|
|
|
|
|
|
def _defer_github_login() -> None:
|
|
write_github_login_deferred(True)
|
|
|
|
|
|
def _sleep_until_or_cancel(seconds: float) -> None:
|
|
"""Sleep up to ``seconds``, raising ``KeyboardInterrupt`` when the user skips."""
|
|
if seconds <= 0 or not sys.stdin.isatty():
|
|
time.sleep(seconds)
|
|
return
|
|
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
from surfaces.interactive_shell.ui.components.key_reader import read_key_windows
|
|
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
if msvcrt.kbhit() and read_key_windows() == "cancel": # type: ignore[attr-defined]
|
|
raise KeyboardInterrupt
|
|
time.sleep(0.05)
|
|
return
|
|
|
|
import select
|
|
import termios
|
|
import tty
|
|
|
|
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix
|
|
|
|
fd = sys.stdin.fileno()
|
|
old_attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
|
|
try:
|
|
tty.setraw(fd) # type: ignore[attr-defined]
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return
|
|
ready, _, _ = select.select([fd], [], [], min(remaining, 0.15))
|
|
if not ready:
|
|
continue
|
|
if read_key_unix() == "cancel":
|
|
raise KeyboardInterrupt
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) # type: ignore[attr-defined]
|
|
|
|
|
|
def _offer_github_login(_console: Console) -> bool:
|
|
"""Return True when the user wants to start browser sign-in."""
|
|
import questionary
|
|
|
|
try:
|
|
choice = questionary.select(
|
|
"Connect GitHub now?",
|
|
choices=[
|
|
questionary.Choice(
|
|
"Sign in with GitHub (opens browser)",
|
|
value=_SIGN_IN_CHOICE,
|
|
),
|
|
questionary.Choice("Skip for now", value=_SKIP_CHOICE),
|
|
],
|
|
default=_SIGN_IN_CHOICE,
|
|
).ask()
|
|
except (EOFError, KeyboardInterrupt):
|
|
return False
|
|
if choice is None:
|
|
return False
|
|
return bool(choice == _SIGN_IN_CHOICE)
|
|
|
|
|
|
def _ask_retry(_console: Console) -> bool:
|
|
import questionary
|
|
|
|
try:
|
|
answer = questionary.confirm("Try GitHub sign-in again?", default=True).ask()
|
|
except (EOFError, KeyboardInterrupt):
|
|
return False
|
|
if answer is None:
|
|
return False
|
|
return bool(answer)
|
|
|
|
|
|
def _attempt_login(console: Console) -> str:
|
|
"""Run one login attempt. Returns ``"success"``, ``"failed"``, or ``"skipped"``."""
|
|
from integrations.github.login import authenticate_and_configure_github
|
|
from integrations.github.mcp_oauth import GitHubDeviceFlowError
|
|
|
|
try:
|
|
result = authenticate_and_configure_github(
|
|
on_prompt=lambda code: _show_device_code(console, code),
|
|
poll_sleep=_sleep_until_or_cancel,
|
|
)
|
|
except (EOFError, KeyboardInterrupt):
|
|
console.print("\nSkipped GitHub sign-in.")
|
|
return "skipped"
|
|
except GitHubDeviceFlowError as err:
|
|
console.print(f"[yellow]GitHub sign-in is unavailable:[/yellow] {err}")
|
|
return "failed"
|
|
except Exception as err: # network/transport issues
|
|
console.print(f"[yellow]GitHub sign-in failed:[/yellow] {err}")
|
|
return "failed"
|
|
|
|
if result.ok:
|
|
clear_github_login_deferral()
|
|
# Persisting the GitHub integration (done inside
|
|
# ``authenticate_and_configure_github``) is what suppresses the gate on
|
|
# subsequent launches — there is no separate completion marker to write.
|
|
_propagate_username(result.username)
|
|
who = f"@{result.username}" if result.username else "your GitHub account"
|
|
console.print(f"[bold]Connected.[/bold] Signed in as {who}.")
|
|
return "success"
|
|
|
|
console.print(f"[yellow]Could not verify GitHub access:[/yellow] {result.detail}")
|
|
return "failed"
|
|
|
|
|
|
def require_github_login_on_first_launch(console: Console | None = None) -> bool:
|
|
"""Run the first-launch GitHub login prompt.
|
|
|
|
Returns True when the caller should proceed into the REPL (login succeeded or
|
|
the user skipped), and False only when startup must abort.
|
|
"""
|
|
con = console or Console(highlight=False)
|
|
_print_intro(con)
|
|
if not _offer_github_login(con):
|
|
_defer_github_login()
|
|
_print_skip_guidance(con)
|
|
return True
|
|
|
|
while True:
|
|
outcome = _attempt_login(con)
|
|
if outcome == "success":
|
|
return True
|
|
if outcome == "skipped":
|
|
_defer_github_login()
|
|
_print_skip_guidance(con)
|
|
return True
|
|
if not _ask_retry(con):
|
|
_defer_github_login()
|
|
_print_skip_guidance(con)
|
|
return True
|
|
|
|
|
|
def require_startup_github_login(console: Console) -> bool:
|
|
"""Return True when startup may proceed past the GitHub login gate.
|
|
|
|
On an unexpected gate error we deliberately do NOT fail open into the REPL:
|
|
that would let a gate bug silently skip sign-in. Instead we only allow
|
|
startup when an explicit, documented bypass applies.
|
|
"""
|
|
try:
|
|
if not should_require_github_login():
|
|
return True
|
|
return require_github_login_on_first_launch(console)
|
|
except Exception:
|
|
logging.getLogger(__name__).warning(
|
|
"First-launch GitHub login gate failed.",
|
|
exc_info=True,
|
|
)
|
|
if _github_login_explicitly_bypassed():
|
|
return True
|
|
console.print(
|
|
"GitHub sign-in could not run. "
|
|
f"Set [bold]{_SKIP_ENV_VAR}=1[/bold] to bypass this, then relaunch "
|
|
"[bold]opensre[/bold]."
|
|
)
|
|
return False
|