Fix/onboard oauth ux (#38)

* "fix(onboard): guide ccproxy install and auth in OAuth flow

  - Always show API Key / OAuth choice; prompt to install evoscientist[oauth]
    when ccproxy missing (mirrors iMessage imsg install UX)
  - Add _ccproxy_exe() helper: checks PATH then env bin dir (fixes conda envs
    where shutil.which may not find newly installed binaries)
  - Fix check_ccproxy_auth() false positive: ccproxy auth status exits 0 even
    when not authenticated; detect via output content + filter structlog noise
  - Silent install/login subprocesses; show browser URL as fallback
  - Reset anthropic/openai auth_mode to api_key when switching to non-Anthropic/
    OpenAI provider, preventing stale oauth config from triggering ccproxy error

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"

* fix(onboard): update OAuth support details in README and zh-CN translation
This commit is contained in:
Xi Zhang
2026-03-16 21:22:20 +01:00
committed by GitHub
parent 4b648b3413
commit e515f69bdc
6 changed files with 165 additions and 43 deletions
+42 -12
View File
@@ -30,9 +30,26 @@ _DEFAULT_PORT = 8000
# ============================================================================= # =============================================================================
def _ccproxy_exe() -> str | None:
"""Return the path to the ccproxy binary, or None if not found.
Checks PATH first, then the current Python environment's bin directory
(handles conda envs where newly installed binaries may not be visible
to shutil.which immediately after pip install).
"""
found = shutil.which("ccproxy")
if found:
return found
import sys as _sys
candidate = os.path.join(os.path.dirname(_sys.executable), "ccproxy")
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def is_ccproxy_available() -> bool: def is_ccproxy_available() -> bool:
"""Check whether the ``ccproxy`` CLI binary is on PATH.""" """Check whether the ``ccproxy`` CLI binary is available."""
return shutil.which("ccproxy") is not None return _ccproxy_exe() is not None
def _summarize_auth_output(raw: str) -> str: def _summarize_auth_output(raw: str) -> str:
@@ -77,23 +94,35 @@ def check_ccproxy_auth(provider: str = "claude_api") -> tuple[bool, str]:
(is_valid, message) tuple. (is_valid, message) tuple.
""" """
try: try:
exe = _ccproxy_exe() or "ccproxy"
result = subprocess.run( result = subprocess.run(
["ccproxy", "auth", "status", provider], [exe, "auth", "status", provider],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
) )
# ccproxy auth status exits 0 when authed
if result.returncode == 0:
summary = _summarize_auth_output(result.stdout)
return True, summary or "Authenticated"
# On failure, include stderr for diagnostics
raw = (result.stdout + result.stderr).strip()
# Strip ANSI escapes for cleaner error messages
import re as _re import re as _re
raw = (result.stdout + result.stderr).strip()
clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw) clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw)
return False, clean or "Not authenticated"
# Filter out structlog warning/noise lines, keep only status lines
status_lines = [
line for line in clean.splitlines()
if line.strip()
and not _re.match(r"\d{4}-\d{2}-\d{2}", line.strip())
and "warning" not in line.lower()
and "plugin" not in line.lower()
]
status_msg = " ".join(status_lines).strip()
# ccproxy auth status may exit 0 even when not authenticated —
# detect failure by checking output content
if result.returncode != 0 or "not authenticated" in clean.lower():
return False, status_msg or "Not authenticated"
summary = _summarize_auth_output(result.stdout)
return True, summary or "Authenticated"
except FileNotFoundError: except FileNotFoundError:
return False, "ccproxy not found" return False, "ccproxy not found"
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -131,8 +160,9 @@ def start_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen:
RuntimeError: If ccproxy fails to become healthy within 10 seconds. RuntimeError: If ccproxy fails to become healthy within 10 seconds.
FileNotFoundError: If ccproxy binary is not found. FileNotFoundError: If ccproxy binary is not found.
""" """
exe = _ccproxy_exe() or "ccproxy"
proc = subprocess.Popen( proc = subprocess.Popen(
["ccproxy", "serve", "--port", str(port)], [exe, "serve", "--port", str(port)],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
+108 -20
View File
@@ -768,19 +768,16 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
Returns: Returns:
Selected auth mode: "api_key", "oauth", or "auto". Selected auth mode: "api_key", "oauth", or "auto".
""" """
from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth
if not is_ccproxy_available(): ccproxy_available = is_ccproxy_available()
console.print(
" [dim]OAuth via ccproxy not available. "
'Install with: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
choices = [ choices = [
Choice(title="API Key (direct Anthropic access)", value="api_key"), Choice(title="API Key (direct Anthropic access)", value="api_key"),
Choice( Choice(
title="Claude Code OAuth (via ccproxy — no API key needed)", value="oauth" title="Claude Code OAuth (via ccproxy — no API key needed)"
+ ("" if ccproxy_available else " [requires: pip install evoscientist[oauth]]"),
value="oauth",
), ),
] ]
@@ -800,6 +797,30 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if auth_mode is None: if auth_mode is None:
raise KeyboardInterrupt() raise KeyboardInterrupt()
if auth_mode == "oauth" and not ccproxy_available:
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
console.print()
install = questionary.confirm(
'Install ccproxy now? (pip install "evoscientist[oauth]")',
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if install:
console.print()
if _install_ccproxy():
console.print(" [green]✓ ccproxy installed successfully.[/green]")
else:
console.print(" [yellow]Falling back to API key mode.[/yellow]")
return "api_key"
else:
console.print(
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
# If OAuth selected, check auth status and offer login # If OAuth selected, check auth status and offer login
if auth_mode in ("oauth", "auto"): if auth_mode in ("oauth", "auto"):
authed, msg = check_ccproxy_auth() authed, msg = check_ccproxy_auth()
@@ -816,10 +837,17 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if login: if login:
console.print(" [dim]Opening browser for authentication...[/dim]") console.print(" [dim]Opening browser for authentication...[/dim]")
try: try:
subprocess.run( proc = subprocess.run(
["ccproxy", "auth", "login", "claude_api"], [_ccproxy_exe() or "ccproxy", "auth", "login", "claude_api"],
capture_output=True,
text=True,
timeout=120, timeout=120,
) )
# Show browser URL in case browser didn't open automatically
for line in proc.stdout.splitlines():
if line.strip().startswith("https://"):
console.print(f" [dim]Visit: {line.strip()}[/dim]")
break
authed, msg = check_ccproxy_auth() authed, msg = check_ccproxy_auth()
if authed: if authed:
console.print(f" [green]✓ OAuth: {msg}[/green]") console.print(f" [green]✓ OAuth: {msg}[/green]")
@@ -842,19 +870,16 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
Returns: Returns:
Selected auth mode: "api_key" or "oauth". Selected auth mode: "api_key" or "oauth".
""" """
from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth
if not is_ccproxy_available(): ccproxy_available = is_ccproxy_available()
console.print(
" [dim]OAuth via ccproxy not available. "
'Install with: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
choices = [ choices = [
Choice(title="API Key (direct OpenAI access)", value="api_key"), Choice(title="API Key (direct OpenAI access)", value="api_key"),
Choice( Choice(
title="Codex OAuth (via ccproxy — no API key needed)", value="oauth" title="Codex OAuth (via ccproxy — no API key needed)"
+ ("" if ccproxy_available else " [requires: pip install evoscientist[oauth]]"),
value="oauth",
), ),
] ]
@@ -874,6 +899,30 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
if auth_mode is None: if auth_mode is None:
raise KeyboardInterrupt() raise KeyboardInterrupt()
if auth_mode == "oauth" and not ccproxy_available:
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
console.print()
install = questionary.confirm(
'Install ccproxy now? (pip install "evoscientist[oauth]")',
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if install:
console.print()
if _install_ccproxy():
console.print(" [green]✓ ccproxy installed successfully.[/green]")
else:
console.print(" [yellow]Falling back to API key mode.[/yellow]")
return "api_key"
else:
console.print(
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
# If OAuth selected, check auth status and offer login # If OAuth selected, check auth status and offer login
if auth_mode == "oauth": if auth_mode == "oauth":
authed, msg = check_ccproxy_auth("codex") authed, msg = check_ccproxy_auth("codex")
@@ -890,10 +939,17 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
if login: if login:
console.print(" [dim]Opening browser for authentication...[/dim]") console.print(" [dim]Opening browser for authentication...[/dim]")
try: try:
subprocess.run( proc = subprocess.run(
["ccproxy", "auth", "login", "codex"], [_ccproxy_exe() or "ccproxy", "auth", "login", "codex"],
capture_output=True,
text=True,
timeout=120, timeout=120,
) )
# Show browser URL in case browser didn't open automatically
for line in proc.stdout.splitlines():
if line.strip().startswith("https://"):
console.print(f" [dim]Visit: {line.strip()}[/dim]")
break
authed, msg = check_ccproxy_auth("codex") authed, msg = check_ccproxy_auth("codex")
if authed: if authed:
console.print(f" [green]✓ Codex OAuth: {msg}[/green]") console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
@@ -1715,6 +1771,33 @@ def validate_imessage() -> tuple[bool, str]:
return True, f"imsg{version_str} at {cli_path}" return True, f"imsg{version_str} at {cli_path}"
def _install_ccproxy() -> bool:
"""Run pip install for ccproxy (evoscientist[oauth]).
Returns:
True if installation succeeded and ccproxy is available.
"""
from ..ccproxy_manager import is_ccproxy_available
try:
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "evoscientist[oauth]"],
capture_output=True,
text=True,
timeout=120,
)
if proc.returncode != 0:
console.print(f" [red]✗ Installation failed:[/red]\n{proc.stderr.strip()}")
return False
return is_ccproxy_available()
except subprocess.TimeoutExpired:
console.print(" [red]✗ Installation timed out.[/red]")
return False
except Exception as e:
console.print(f" [red]✗ Installation failed: {e}[/red]")
return False
def _install_imsg() -> bool: def _install_imsg() -> bool:
"""Run brew install for imsg CLI. """Run brew install for imsg CLI.
@@ -2353,6 +2436,11 @@ def run_onboard(skip_validation: bool = False) -> bool:
elif provider == "openai": elif provider == "openai":
auth_mode = _step_openai_auth_mode(config) auth_mode = _step_openai_auth_mode(config)
config.openai_auth_mode = auth_mode config.openai_auth_mode = auth_mode
else:
# Non-Anthropic/OpenAI provider: reset OAuth modes to avoid
# stale oauth config triggering ccproxy requirement on startup
config.anthropic_auth_mode = "api_key"
config.openai_auth_mode = "api_key"
# Step 2c: Provider API Key (skip for Ollama — no key needed, # Step 2c: Provider API Key (skip for Ollama — no key needed,
# and for Anthropic/OpenAI pure OAuth — key provided by ccproxy) # and for Anthropic/OpenAI pure OAuth — key provided by ccproxy)
+3 -5
View File
@@ -220,9 +220,7 @@ def _apply_auto_config(
base_url = os.environ.get("OPENAI_BASE_URL", "") base_url = os.environ.get("OPENAI_BASE_URL", "")
_is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url
if _is_openai_proxy: if _is_openai_proxy:
# ccproxy forces store=False. Setting `reasoning` triggers # Skip reasoning kwarg for ccproxy — not needed and may cause issues.
# langchain-openai's Responses API path, which produces
# rs_ summary items that 404 on multi-turn. Skip entirely.
pass pass
else: else:
kwargs["reasoning"] = {"effort": "high", "summary": "auto"} kwargs["reasoning"] = {"effort": "high", "summary": "auto"}
@@ -308,8 +306,8 @@ def get_chat_model(
kwargs["base_url"] = base_url kwargs["base_url"] = base_url
_is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url
if _is_openai_proxy: if _is_openai_proxy:
kwargs.setdefault("streaming", False) # ccproxy streaming incompatible kwargs.setdefault("streaming", False) # ccproxy streaming format incompatible with langchain-openai
kwargs.setdefault("use_responses_api", False) # force Chat Completions kwargs.setdefault("use_responses_api", True) # ccproxy Chat Completions does not support tool calling; Responses API does
api_key = os.environ.get("OPENAI_API_KEY", "") api_key = os.environ.get("OPENAI_API_KEY", "")
if api_key: if api_key:
kwargs["api_key"] = api_key kwargs["api_key"] = api_key
+3 -2
View File
@@ -207,7 +207,8 @@ EvoSci onboard
``` ```
> [!TIP] > [!TIP]
> It walks you through provider selection, key validation, model choice, and workspace mode. > It walks you through provider selection, key validation, model choice, and workspace mode.
> Supports OAuth sign-in for [Claude Code](https://claude.com/product/claude-code) and [Codex ClI](https://developers.openai.com/codex/cli/) users — no API key needed.
![onboard](.github/assets/EvoScientist_onboard.png) ![onboard](.github/assets/EvoScientist_onboard.png)
@@ -413,7 +414,7 @@ Coming soon:
- [x] 👋 Human-in-the-loop action approval - [x] 👋 Human-in-the-loop action approval
- [x] 🦾 Agent-initiated human clarification - [x] 🦾 Agent-initiated human clarification
- [x] 📑 Technical report on the way - [x] 📑 Technical report on the way
- [ ] 🔐 OAuth sign-in (Anthropic, OpenAI, etc.) - [x] 🔐 OAuth sign-in (Anthropic, OpenAI, etc.)
- [ ] 📺 Web app with workspace UI - [ ] 📺 Web app with workspace UI
- [ ] 📹 Demo and tutorial in the works - [ ] 📹 Demo and tutorial in the works
- [ ] 📊 Benchmark suite to be released - [ ] 📊 Benchmark suite to be released
+2 -1
View File
@@ -217,6 +217,7 @@ EvoSci onboard
> [!TIP] > [!TIP]
> 向导将引导你完成供应商选择、密钥验证、模型选择和工作区模式设置。 > 向导将引导你完成供应商选择、密钥验证、模型选择和工作区模式设置。
> 支持 [Claude Code](https://claude.com/product/claude-code) 和 [Codex CLI](https://developers.openai.com/codex/cli/) 用户通过 OAuth 直连——无需 API Key。
![onboard](.github/assets/EvoScientist_onboard.png) ![onboard](.github/assets/EvoScientist_onboard.png)
@@ -422,7 +423,7 @@ channel_enabled: "telegram,slack,feishu,qq"
- [x] 👋 Human-in-the-loop 操作审批 - [x] 👋 Human-in-the-loop 操作审批
- [x] 🦾 智能体主动向人类澄清确认 - [x] 🦾 智能体主动向人类澄清确认
- [x] 📑 技术报告已发布 - [x] 📑 技术报告已发布
- [ ] 🔐 OAuth 登录(Anthropic、OpenAI 等) - [x] 🔐 OAuth 登录(Anthropic、OpenAI 等)
- [ ] 📺 带工作区的 Web 应用界面 - [ ] 📺 带工作区的 Web 应用界面
- [ ] 📹 Demo 与教程正在制作中 - [ ] 📹 Demo 与教程正在制作中
- [ ] 📊 基准测试套件即将推出 - [ ] 📊 基准测试套件即将推出
+7 -3
View File
@@ -29,8 +29,10 @@ class TestIsCcproxyAvailable:
assert is_ccproxy_available() is True assert is_ccproxy_available() is True
mock_which.assert_called_once_with("ccproxy") mock_which.assert_called_once_with("ccproxy")
@patch("os.access", return_value=False)
@patch("os.path.isfile", return_value=False)
@patch("shutil.which", return_value=None) @patch("shutil.which", return_value=None)
def test_not_found(self, mock_which): def test_not_found(self, mock_which, mock_isfile, mock_access):
assert is_ccproxy_available() is False assert is_ccproxy_available() is False
@@ -49,7 +51,8 @@ class TestCheckCcproxyAuth:
assert valid is True assert valid is True
assert "Authenticated" in msg assert "Authenticated" in msg
mock_run.assert_called_once() mock_run.assert_called_once()
assert mock_run.call_args[0][0] == ["ccproxy", "auth", "status", "claude_api"] cmd = mock_run.call_args[0][0]
assert cmd[1:] == ["auth", "status", "claude_api"]
@patch("subprocess.run") @patch("subprocess.run")
def test_valid_auth_codex(self, mock_run): def test_valid_auth_codex(self, mock_run):
@@ -58,7 +61,8 @@ class TestCheckCcproxyAuth:
) )
valid, msg = check_ccproxy_auth("codex") valid, msg = check_ccproxy_auth("codex")
assert valid is True assert valid is True
assert mock_run.call_args[0][0] == ["ccproxy", "auth", "status", "codex"] cmd = mock_run.call_args[0][0]
assert cmd[1:] == ["auth", "status", "codex"]
@patch("subprocess.run") @patch("subprocess.run")
def test_invalid_auth(self, mock_run): def test_invalid_auth(self, mock_run):