chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user