mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
feat(webui): support remote Codex OAuth login (#5174)
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
import pytest
|
||||
from oauth_cli_kit.models import OAuthToken
|
||||
|
||||
import nanobot.providers.openai_codex_oauth as codex_oauth
|
||||
from nanobot.providers.openai_codex_oauth import (
|
||||
OpenAICodexOAuthError,
|
||||
OpenAICodexOAuthInputError,
|
||||
complete_openai_codex_oauth_login,
|
||||
start_openai_codex_oauth_login,
|
||||
)
|
||||
|
||||
|
||||
def _authorization_url(state: str = "expected-state") -> str:
|
||||
return f"{codex_oauth.OPENAI_CODEX_PROVIDER.authorize_url}?{urlencode({'state': state})}"
|
||||
|
||||
|
||||
def _wait_for_completion(flow) -> OAuthToken:
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
token = complete_openai_codex_oauth_login(flow)
|
||||
if token is not None:
|
||||
return token
|
||||
time.sleep(0.01)
|
||||
pytest.fail("OAuth flow did not finish")
|
||||
|
||||
|
||||
def _fake_interactive_login(
|
||||
captured: dict[str, object],
|
||||
*,
|
||||
error: Exception | None = None,
|
||||
) -> Callable[..., OAuthToken]:
|
||||
def login(
|
||||
*,
|
||||
print_fn,
|
||||
prompt_fn,
|
||||
provider,
|
||||
proxy,
|
||||
open_browser,
|
||||
) -> OAuthToken:
|
||||
captured.update(
|
||||
provider=provider,
|
||||
proxy=proxy,
|
||||
open_browser=open_browser,
|
||||
)
|
||||
print_fn("Open this URL:")
|
||||
print_fn(_authorization_url())
|
||||
if not open_browser:
|
||||
captured["callback_url"] = prompt_fn("Paste callback URL")
|
||||
if error is not None:
|
||||
raise error
|
||||
return OAuthToken(
|
||||
access="access-token",
|
||||
refresh="refresh-token",
|
||||
expires=2_000_000_000_000,
|
||||
account_id="acct-test",
|
||||
)
|
||||
|
||||
return login
|
||||
|
||||
|
||||
def test_authorization_url_comes_from_oauth_cli_kit() -> None:
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=2,
|
||||
open_browser=False,
|
||||
)
|
||||
try:
|
||||
params = parse_qs(urlsplit(flow.authorization_url).query)
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["client_id"] == [codex_oauth.OPENAI_CODEX_PROVIDER.client_id]
|
||||
assert params["redirect_uri"] == [codex_oauth.OPENAI_CODEX_PROVIDER.redirect_uri]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
assert params["code_challenge"]
|
||||
assert params["state"]
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def test_local_flow_delegates_browser_and_callback_to_public_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(timeout_s=5)
|
||||
|
||||
try:
|
||||
token = _wait_for_completion(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert token.account_id == "acct-test"
|
||||
assert captured == {
|
||||
"provider": codex_oauth.OPENAI_CODEX_PROVIDER,
|
||||
"proxy": None,
|
||||
"open_browser": True,
|
||||
}
|
||||
|
||||
|
||||
def test_remote_flow_delegates_pasted_callback_to_public_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
proxy="http://127.0.0.1:7890",
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"code": "authorization-code", "state": "expected-state"})
|
||||
)
|
||||
try:
|
||||
assert complete_openai_codex_oauth_login(flow) is None
|
||||
with pytest.raises(OpenAICodexOAuthInputError, match="full callback URL"):
|
||||
complete_openai_codex_oauth_login(flow, "authorization-code")
|
||||
token = complete_openai_codex_oauth_login(flow, callback_url)
|
||||
if token is None:
|
||||
token = _wait_for_completion(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert token is not None
|
||||
assert token.account_id == "acct-test"
|
||||
assert captured == {
|
||||
"provider": codex_oauth.OPENAI_CODEX_PROVIDER,
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"open_browser": False,
|
||||
"callback_url": callback_url,
|
||||
}
|
||||
|
||||
|
||||
def test_remote_flow_rejects_callback_from_another_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"code": "authorization-code", "state": "wrong-state"})
|
||||
)
|
||||
try:
|
||||
with pytest.raises(OpenAICodexOAuthInputError, match="does not belong"):
|
||||
complete_openai_codex_oauth_login(flow, callback_url)
|
||||
assert "callback_url" not in captured
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def test_remote_flow_reports_authorization_denial_without_exchanging_code(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"error": "access_denied", "state": "expected-state"})
|
||||
)
|
||||
try:
|
||||
with pytest.raises(OpenAICodexOAuthError, match="authorization server"):
|
||||
complete_openai_codex_oauth_login(flow, callback_url)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert "callback_url" not in captured
|
||||
|
||||
|
||||
def test_dependency_error_is_bounded_and_does_not_expose_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(
|
||||
captured,
|
||||
error=RuntimeError("Token exchange failed: 400 secret-code upstream-body"),
|
||||
),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"code": "secret-code", "state": "expected-state"})
|
||||
)
|
||||
try:
|
||||
with pytest.raises(OpenAICodexOAuthError) as exc:
|
||||
token = complete_openai_codex_oauth_login(flow, callback_url)
|
||||
if token is None:
|
||||
_wait_for_completion(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert str(exc.value) == "OpenAI Codex OAuth token exchange failed with HTTP 400."
|
||||
assert "secret-code" not in str(exc.value)
|
||||
|
||||
|
||||
def test_remote_flow_expires_while_waiting_for_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login({}),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=0.05,
|
||||
open_browser=False,
|
||||
)
|
||||
try:
|
||||
time.sleep(0.08)
|
||||
with pytest.raises(OpenAICodexOAuthError, match="expired"):
|
||||
complete_openai_codex_oauth_login(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_clear_webui_oauth_flows,
|
||||
_docs_version,
|
||||
_model_catalog_kind,
|
||||
_oauth_provider_status,
|
||||
@@ -1454,25 +1455,114 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||
)
|
||||
monkeypatch.setenv("CODEX_PROXY_TEST", proxy)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
import oauth_cli_kit
|
||||
class FakeFlow:
|
||||
authorization_url = "https://auth.openai.com/oauth/authorize?state=test"
|
||||
remaining_seconds = 600
|
||||
expired = False
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
def cancel(self) -> None:
|
||||
captured["cancelled"] = True
|
||||
|
||||
def fake_get_token(*, proxy=None):
|
||||
captured["get_proxy"] = proxy
|
||||
raise RuntimeError("no-token")
|
||||
def fake_start(*, proxy=None, timeout_s=None, open_browser=None):
|
||||
captured.update(
|
||||
proxy=proxy,
|
||||
timeout_s=timeout_s,
|
||||
open_browser=open_browser,
|
||||
)
|
||||
return FakeFlow()
|
||||
|
||||
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
||||
captured["login_proxy"] = proxy
|
||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_oauth.start_openai_codex_oauth_login",
|
||||
fake_start,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
||||
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
|
||||
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
||||
|
||||
login_oauth_provider({"provider": ["openai-codex"]})
|
||||
assert captured == {
|
||||
"proxy": proxy,
|
||||
"timeout_s": 600,
|
||||
"open_browser": True,
|
||||
}
|
||||
assert payload["status"] == "authorization_required"
|
||||
assert payload["provider"] == "openai_codex"
|
||||
assert payload["authorization_url"] == FakeFlow.authorization_url
|
||||
assert payload["completion_input"] == "callback_url"
|
||||
|
||||
assert captured == {"get_proxy": proxy, "login_proxy": proxy}
|
||||
callbacks: list[str | None] = []
|
||||
|
||||
def fake_complete(_flow, callback):
|
||||
callbacks.append(callback)
|
||||
if callback is None:
|
||||
return None
|
||||
return SimpleNamespace(access="access-token")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_oauth.complete_openai_codex_oauth_login",
|
||||
fake_complete,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api.settings_payload",
|
||||
lambda: {"settings": "ready"},
|
||||
)
|
||||
|
||||
pending = complete_oauth_provider(
|
||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||
)
|
||||
completed = complete_oauth_provider(
|
||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
)
|
||||
|
||||
assert pending == {
|
||||
"status": "pending",
|
||||
"provider": "openai_codex",
|
||||
"flow_id": payload["flow_id"],
|
||||
}
|
||||
assert completed == {"settings": "ready"}
|
||||
assert callbacks == [
|
||||
None,
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
]
|
||||
|
||||
|
||||
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeFlow:
|
||||
authorization_url = "https://auth.openai.com/oauth/authorize?state=test"
|
||||
remaining_seconds = 600
|
||||
expired = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
captured["cancelled"] = True
|
||||
|
||||
def fake_start(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return FakeFlow()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_oauth.start_openai_codex_oauth_login",
|
||||
fake_start,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = login_oauth_provider(
|
||||
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
||||
)
|
||||
finally:
|
||||
_clear_webui_oauth_flows("openai_codex")
|
||||
|
||||
assert payload["completion_input"] == "callback_url"
|
||||
assert captured["open_browser"] is False
|
||||
assert captured["cancelled"] is True
|
||||
|
||||
|
||||
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
@@ -1481,7 +1571,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "oauth_cli_kit":
|
||||
if name == "nanobot.providers.openai_codex_oauth":
|
||||
raise ImportError("missing")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
@@ -1542,6 +1632,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||
assert payload["status"] == "authorization_required"
|
||||
assert payload["provider"] == "xai_grok"
|
||||
assert payload["authorization_url"] == FakeFlow.authorization_url
|
||||
assert payload["completion_input"] == "authorization_code"
|
||||
assert payload["flow_id"]
|
||||
|
||||
callbacks: list[str | None] = []
|
||||
|
||||
@@ -27,15 +27,31 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "header_name", "authorization_response"),
|
||||
[
|
||||
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
||||
(
|
||||
"openai_codex",
|
||||
"X-Nanobot-OAuth-Callback",
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) -> None:
|
||||
async def test_oauth_completion_reads_private_response_header(
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
header_name: str,
|
||||
authorization_response: str,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def complete(query, authorization_code=None):
|
||||
captured.update(query=query, authorization_code=authorization_code)
|
||||
def complete(query, authorization_response=None):
|
||||
captured.update(query=query, authorization_response=authorization_response)
|
||||
return {
|
||||
"status": "pending",
|
||||
"provider": "xai_grok",
|
||||
"provider": provider,
|
||||
"flow_id": "flow-123",
|
||||
}
|
||||
|
||||
@@ -44,13 +60,13 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch)
|
||||
request = SimpleNamespace(
|
||||
path=(
|
||||
"/api/settings/provider/oauth-login/complete"
|
||||
"?provider=xai_grok&flow_id=flow-123"
|
||||
f"?provider={provider}&flow_id=flow-123"
|
||||
),
|
||||
headers=Headers(
|
||||
[
|
||||
(
|
||||
"X-Nanobot-OAuth-Code",
|
||||
"secret",
|
||||
header_name,
|
||||
authorization_response,
|
||||
)
|
||||
]
|
||||
),
|
||||
@@ -66,14 +82,14 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch)
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {
|
||||
"status": "pending",
|
||||
"provider": "xai_grok",
|
||||
"provider": provider,
|
||||
"flow_id": "flow-123",
|
||||
}
|
||||
assert captured == {
|
||||
"query": {"provider": ["xai_grok"], "flow_id": ["flow-123"]},
|
||||
"authorization_code": "secret",
|
||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||
"authorization_response": authorization_response,
|
||||
}
|
||||
assert "secret" not in request.path
|
||||
assert authorization_response not in request.path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
Reference in New Issue
Block a user