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:
"""Check whether the ``ccproxy`` CLI binary is on PATH."""
return shutil.which("ccproxy") is not None
"""Check whether the ``ccproxy`` CLI binary is available."""
return _ccproxy_exe() is not None
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.
"""
try:
exe = _ccproxy_exe() or "ccproxy"
result = subprocess.run(
["ccproxy", "auth", "status", provider],
[exe, "auth", "status", provider],
capture_output=True,
text=True,
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
raw = (result.stdout + result.stderr).strip()
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:
return False, "ccproxy not found"
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.
FileNotFoundError: If ccproxy binary is not found.
"""
exe = _ccproxy_exe() or "ccproxy"
proc = subprocess.Popen(
["ccproxy", "serve", "--port", str(port)],
[exe, "serve", "--port", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
+108 -20
View File
@@ -768,19 +768,16 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
Returns:
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():
console.print(
" [dim]OAuth via ccproxy not available. "
'Install with: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
ccproxy_available = is_ccproxy_available()
choices = [
Choice(title="API Key (direct Anthropic access)", value="api_key"),
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:
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 auth_mode in ("oauth", "auto"):
authed, msg = check_ccproxy_auth()
@@ -816,10 +837,17 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if login:
console.print(" [dim]Opening browser for authentication...[/dim]")
try:
subprocess.run(
["ccproxy", "auth", "login", "claude_api"],
proc = subprocess.run(
[_ccproxy_exe() or "ccproxy", "auth", "login", "claude_api"],
capture_output=True,
text=True,
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()
if authed:
console.print(f" [green]✓ OAuth: {msg}[/green]")
@@ -842,19 +870,16 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
Returns:
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():
console.print(
" [dim]OAuth via ccproxy not available. "
'Install with: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
ccproxy_available = is_ccproxy_available()
choices = [
Choice(title="API Key (direct OpenAI access)", value="api_key"),
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:
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 auth_mode == "oauth":
authed, msg = check_ccproxy_auth("codex")
@@ -890,10 +939,17 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
if login:
console.print(" [dim]Opening browser for authentication...[/dim]")
try:
subprocess.run(
["ccproxy", "auth", "login", "codex"],
proc = subprocess.run(
[_ccproxy_exe() or "ccproxy", "auth", "login", "codex"],
capture_output=True,
text=True,
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")
if authed:
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}"
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:
"""Run brew install for imsg CLI.
@@ -2353,6 +2436,11 @@ def run_onboard(skip_validation: bool = False) -> bool:
elif provider == "openai":
auth_mode = _step_openai_auth_mode(config)
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,
# 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", "")
_is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url
if _is_openai_proxy:
# ccproxy forces store=False. Setting `reasoning` triggers
# langchain-openai's Responses API path, which produces
# rs_ summary items that 404 on multi-turn. Skip entirely.
# Skip reasoning kwarg for ccproxy — not needed and may cause issues.
pass
else:
kwargs["reasoning"] = {"effort": "high", "summary": "auto"}
@@ -308,8 +306,8 @@ def get_chat_model(
kwargs["base_url"] = base_url
_is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url
if _is_openai_proxy:
kwargs.setdefault("streaming", False) # ccproxy streaming incompatible
kwargs.setdefault("use_responses_api", False) # force Chat Completions
kwargs.setdefault("streaming", False) # ccproxy streaming format incompatible with langchain-openai
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", "")
if api_key:
kwargs["api_key"] = api_key
+3 -2
View File
@@ -207,7 +207,8 @@ EvoSci onboard
```
> [!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)
@@ -413,7 +414,7 @@ Coming soon:
- [x] 👋 Human-in-the-loop action approval
- [x] 🦾 Agent-initiated human clarification
- [x] 📑 Technical report on the way
- [ ] 🔐 OAuth sign-in (Anthropic, OpenAI, etc.)
- [x] 🔐 OAuth sign-in (Anthropic, OpenAI, etc.)
- [ ] 📺 Web app with workspace UI
- [ ] 📹 Demo and tutorial in the works
- [ ] 📊 Benchmark suite to be released
+2 -1
View File
@@ -217,6 +217,7 @@ EvoSci onboard
> [!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)
@@ -422,7 +423,7 @@ channel_enabled: "telegram,slack,feishu,qq"
- [x] 👋 Human-in-the-loop 操作审批
- [x] 🦾 智能体主动向人类澄清确认
- [x] 📑 技术报告已发布
- [ ] 🔐 OAuth 登录(Anthropic、OpenAI 等)
- [x] 🔐 OAuth 登录(Anthropic、OpenAI 等)
- [ ] 📺 带工作区的 Web 应用界面
- [ ] 📹 Demo 与教程正在制作中
- [ ] 📊 基准测试套件即将推出
+7 -3
View File
@@ -29,8 +29,10 @@ class TestIsCcproxyAvailable:
assert is_ccproxy_available() is True
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)
def test_not_found(self, mock_which):
def test_not_found(self, mock_which, mock_isfile, mock_access):
assert is_ccproxy_available() is False
@@ -49,7 +51,8 @@ class TestCheckCcproxyAuth:
assert valid is True
assert "Authenticated" in msg
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")
def test_valid_auth_codex(self, mock_run):
@@ -58,7 +61,8 @@ class TestCheckCcproxyAuth:
)
valid, msg = check_ccproxy_auth("codex")
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")
def test_invalid_auth(self, mock_run):