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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||

|
||||
|
||||
@@ -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 与教程正在制作中
|
||||
- [ ] 📊 基准测试套件即将推出
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user