feat(webui): support remote Codex OAuth login (#5174)

This commit is contained in:
chengyongru 2026-07-30 15:06:34 +08:00 committed by GitHub
parent e2563e2e74
commit 606ac56e8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1233 additions and 150 deletions

View File

@ -150,7 +150,7 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
| Codex login runs on a remote/headless machine | In the WebUI, open ChatGPT in your local browser; when the localhost callback page cannot load, copy the full `http://localhost:1455/auth/callback?...` URL from the address bar and paste it into the WebUI dialog. From the CLI, open the printed URL locally and paste the same callback URL back into the terminal. |
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |

View File

@ -0,0 +1,272 @@
"""WebUI adapter around oauth-cli-kit's interactive Codex login."""
# oauth-cli-kit does not publish type stubs.
# pyright: reportMissingTypeStubs=false
from __future__ import annotations
import hmac
import queue
import re
import threading
import time
from concurrent.futures import Future
from contextlib import suppress
from urllib.parse import parse_qs, urlsplit
from oauth_cli_kit import login_oauth_interactive
from oauth_cli_kit.models import OAuthToken
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
_AUTHORIZATION_URL_TIMEOUT_S = 5.0
_CALLBACK = urlsplit(OPENAI_CODEX_PROVIDER.redirect_uri)
_CALLBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
_TOKEN_EXCHANGE_STATUS = re.compile(r"Token exchange failed:\s*(\d{3})\b")
class OpenAICodexOAuthError(RuntimeError):
"""An actionable Codex OAuth failure that contains no credential material."""
class OpenAICodexOAuthInputError(OpenAICodexOAuthError):
"""A recoverable error in a callback URL pasted by the user."""
class OpenAICodexOAuthLoginFlow:
"""Expose oauth-cli-kit's blocking prompt as a two-stage WebUI flow."""
def __init__(
self,
*,
proxy: str | None,
timeout_s: float,
open_browser: bool,
) -> None:
self.authorization_url = ""
self._expected_state = ""
self._proxy = proxy
self._open_browser = open_browser
self._expires_at = time.monotonic() + timeout_s
self._callback_input: queue.Queue[str] = queue.Queue(maxsize=1)
self._result: Future[OAuthToken] = Future()
self._ready = threading.Event()
self._submission_lock = threading.Lock()
self._submitted = False
self._thread = threading.Thread(
target=self._run,
name="nanobot-openai-codex-oauth",
daemon=True,
)
@property
def expired(self) -> bool:
return time.monotonic() >= self._expires_at
@property
def remaining_seconds(self) -> int:
return max(0, int(self._expires_at - time.monotonic()))
def start(self) -> OpenAICodexOAuthLoginFlow:
self._thread.start()
wait_s = min(
_AUTHORIZATION_URL_TIMEOUT_S,
max(0.0, self._expires_at - time.monotonic()),
)
if not self._ready.wait(wait_s):
error = OpenAICodexOAuthError(
"OpenAI Codex sign-in could not create an authorization URL."
)
self._fail(error)
raise error
if self._result.done():
self._result.result()
if self.authorization_url:
return self
error = OpenAICodexOAuthError(
"OpenAI Codex sign-in returned no authorization URL."
)
self._fail(error)
raise error
def complete(self, callback_url: str | None = None) -> OAuthToken | None:
"""Submit a full callback URL, or return ``None`` while waiting for one."""
if self._result.done():
return self._result.result()
if self.expired:
error = OpenAICodexOAuthError(
"OpenAI Codex sign-in expired. Start a new sign-in flow."
)
self._fail(error)
raise error
if callback_url is None:
return None
callback_state, authorization_failed = _validate_callback_url(callback_url)
if not hmac.compare_digest(callback_state, self._expected_state):
raise OpenAICodexOAuthInputError(
"The callback URL does not belong to this sign-in flow. Copy the latest URL."
)
if authorization_failed:
error = OpenAICodexOAuthError(
"OpenAI Codex sign-in was not completed by the authorization server."
)
self._fail(error)
raise error
with self._submission_lock:
if self._submitted:
return None
self._submitted = True
try:
self._callback_input.put_nowait(callback_url.strip())
except queue.Full:
return None
return self._result.result() if self._result.done() else None
def cancel(self) -> None:
"""Unblock an abandoned interactive login."""
self._fail(OpenAICodexOAuthError("OpenAI Codex sign-in was cancelled."))
if threading.current_thread() is not self._thread:
self._thread.join(timeout=0.5)
def _run(self) -> None:
try:
token = login_oauth_interactive(
print_fn=self._capture_output,
prompt_fn=self._prompt_for_callback,
provider=OPENAI_CODEX_PROVIDER,
proxy=self._proxy,
open_browser=self._open_browser,
)
except Exception as exc:
with suppress(Exception):
self._result.set_exception(_safe_login_error(exc))
else:
with suppress(Exception):
self._result.set_result(token)
finally:
self._ready.set()
def _capture_output(self, message: str) -> None:
raw = str(message)
start = raw.find(OPENAI_CODEX_PROVIDER.authorize_url)
if start < 0:
return
candidate = raw[start:].split(maxsplit=1)[0]
state = _first(parse_qs(urlsplit(candidate).query), "state")
if not state:
return
self.authorization_url = candidate
self._expected_state = state
self._ready.set()
def _prompt_for_callback(self, _prompt: str) -> str:
remaining = max(0.0, self._expires_at - time.monotonic())
try:
value = self._callback_input.get(timeout=remaining)
except queue.Empty as exc:
raise OpenAICodexOAuthError(
"OpenAI Codex sign-in expired. Start a new sign-in flow."
) from exc
if not value:
error = self._result.exception() if self._result.done() else None
if error is not None:
raise error
raise OpenAICodexOAuthError("OpenAI Codex sign-in was cancelled.")
return value
def _fail(self, error: OpenAICodexOAuthError) -> None:
try:
self._result.set_exception(error)
except Exception:
pass
else:
with suppress(queue.Full):
self._callback_input.put_nowait("")
self._ready.set()
def start_openai_codex_oauth_login(
*,
proxy: str | None = None,
timeout_s: float = 600,
open_browser: bool = True,
) -> OpenAICodexOAuthLoginFlow:
"""Start a non-blocking wrapper around oauth-cli-kit's Codex login."""
return OpenAICodexOAuthLoginFlow(
proxy=proxy,
timeout_s=timeout_s,
open_browser=open_browser,
).start()
def complete_openai_codex_oauth_login(
flow: OpenAICodexOAuthLoginFlow,
callback_url: str | None = None,
) -> OAuthToken | None:
"""Complete a pending Codex login from a full callback URL."""
return flow.complete(callback_url)
def _validate_callback_url(raw: str) -> tuple[str, bool]:
value = raw.strip()
if not value:
raise OpenAICodexOAuthInputError("Paste the full callback URL from your browser.")
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise OpenAICodexOAuthInputError(
"The callback URL is invalid. Copy the full URL from your browser's address bar."
) from exc
if (
parsed.scheme != _CALLBACK.scheme
or parsed.hostname not in _CALLBACK_HOSTS
or port != _CALLBACK.port
or parsed.path != _CALLBACK.path
or parsed.username is not None
or parsed.password is not None
):
raise OpenAICodexOAuthInputError(
f"Paste the full callback URL from your browser ({OPENAI_CODEX_PROVIDER.redirect_uri}?...)."
)
params = parse_qs(parsed.query)
code = _first(params, "code")
state = _first(params, "state")
error = _first(params, "error")
if not state:
raise OpenAICodexOAuthInputError(
"The callback URL is missing OAuth state. Copy the entire browser address."
)
if not code and not error:
raise OpenAICodexOAuthInputError(
"The callback URL has no authorization result. Finish signing in, then copy it again."
)
return state, error is not None
def _safe_login_error(exc: Exception) -> OpenAICodexOAuthError:
if isinstance(exc, OpenAICodexOAuthError):
return exc
message = str(exc).strip()
if message == "State validation failed.":
return OpenAICodexOAuthError(
"OpenAI Codex sign-in failed because the OAuth state did not match."
)
if message == "Authorization code not found.":
return OpenAICodexOAuthError(
"OpenAI Codex sign-in returned no authorization code."
)
status = _TOKEN_EXCHANGE_STATUS.search(message)
if status:
return OpenAICodexOAuthError(
f"OpenAI Codex OAuth token exchange failed with HTTP {status.group(1)}."
)
return OpenAICodexOAuthError(
f"OpenAI Codex sign-in failed ({type(exc).__name__})."
)
def _first(params: dict[str, list[str]], key: str) -> str | None:
values = params.get(key)
return values[0] if values else None

View File

@ -131,10 +131,10 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
}
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
_XAI_WEBUI_OAUTH_TIMEOUT_S = 600
_XAI_WEBUI_OAUTH_MAX_FLOWS = 8
_xai_webui_oauth_flows: dict[str, Any] = {}
_xai_webui_oauth_flows_lock = threading.Lock()
_WEBUI_OAUTH_TIMEOUT_S = 600
_WEBUI_OAUTH_MAX_FLOWS = 8
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
_webui_oauth_flows_lock = threading.Lock()
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@ -1810,7 +1810,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
if spec.name == "openai_codex":
try:
from oauth_cli_kit import get_token, login_oauth_interactive
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
except ImportError:
raise WebUISettingsError(
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
@ -1820,19 +1820,30 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
except ValueError as e:
raise WebUISettingsError(str(e), status=400) from e
token = None
with suppress(Exception):
token = get_token(proxy=proxy)
if not (token and token.access):
messages: list[str] = []
token = login_oauth_interactive(
print_fn=lambda message: messages.append(str(message)),
prompt_fn=lambda _prompt: "",
remote_browser_value = _query_first(query, "remote_browser")
remote_browser = (
_parse_bool(remote_browser_value, "remote_browser")
if remote_browser_value is not None
else False
)
try:
flow = start_openai_codex_oauth_login(
proxy=proxy,
timeout_s=_WEBUI_OAUTH_TIMEOUT_S,
open_browser=not remote_browser,
)
if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload()
except Exception as e:
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
flow_id = secrets.token_urlsafe(24)
_register_webui_oauth_flow(spec.name, flow_id, flow)
return {
"status": "authorization_required",
"provider": spec.name,
"flow_id": flow_id,
"authorization_url": flow.authorization_url,
"expires_in": flow.remaining_seconds,
"completion_input": "callback_url",
}
if spec.name == "github_copilot":
try:
@ -1862,18 +1873,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
try:
flow = start_xai_oauth_login(
proxy=proxy,
timeout_s=_XAI_WEBUI_OAUTH_TIMEOUT_S,
timeout_s=_WEBUI_OAUTH_TIMEOUT_S,
)
except Exception as e:
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
flow_id = secrets.token_urlsafe(24)
_register_xai_webui_oauth_flow(flow_id, flow)
_register_webui_oauth_flow(spec.name, flow_id, flow)
return {
"status": "authorization_required",
"provider": spec.name,
"flow_id": flow_id,
"authorization_url": flow.authorization_url,
"expires_in": flow.remaining_seconds,
"completion_input": "authorization_code",
}
raise WebUISettingsError("OAuth login is not supported for this provider")
@ -1881,34 +1893,47 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
def complete_oauth_provider(
query: QueryParams,
authorization_code: str | None = None,
authorization_response: str | None = None,
) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
flow_id = (_query_first(query, "flow_id") or "").strip()
spec = find_by_name(provider_name)
if spec is None or spec.name != "xai_grok":
if spec is None or spec.name not in {"openai_codex", "xai_grok"}:
raise WebUISettingsError("OAuth completion is not supported for this provider")
if not flow_id:
raise WebUISettingsError("flow_id is required")
flow = _get_xai_webui_oauth_flow(flow_id)
flow = _get_webui_oauth_flow(spec.name, flow_id)
if flow is None:
raise WebUISettingsError("xAI sign-in expired. Start again.", status=410)
from nanobot.providers.xai_oauth import complete_xai_oauth_login
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
try:
token = complete_xai_oauth_login(flow, authorization_code)
if spec.name == "openai_codex":
from nanobot.providers.openai_codex_oauth import (
OpenAICodexOAuthInputError,
complete_openai_codex_oauth_login,
)
try:
token = complete_openai_codex_oauth_login(flow, authorization_response)
except OpenAICodexOAuthInputError as e:
raise WebUISettingsError(str(e), status=400) from e
else:
from nanobot.providers.xai_oauth import complete_xai_oauth_login
token = complete_xai_oauth_login(flow, authorization_response)
except WebUISettingsError:
raise
except Exception as e:
_remove_xai_webui_oauth_flow(flow_id, flow)
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
_remove_webui_oauth_flow(spec.name, flow_id, flow)
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
if token is None:
return {
"status": "pending",
"provider": spec.name,
"flow_id": flow_id,
}
_remove_xai_webui_oauth_flow(flow_id, flow, cancel=False)
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
if not token.access:
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload()
@ -1930,6 +1955,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
raise WebUISettingsError(
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
) from None
_clear_webui_oauth_flows(spec.name)
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
elif spec.name == "github_copilot":
try:
@ -1942,7 +1968,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
elif spec.name == "xai_grok":
from nanobot.providers.xai_oauth import logout_xai_oauth
_clear_xai_webui_oauth_flows()
_clear_webui_oauth_flows(spec.name)
logout_xai_oauth()
return settings_payload()
else:
@ -1954,47 +1980,60 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
return settings_payload()
def _register_xai_webui_oauth_flow(flow_id: str, flow: Any) -> None:
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
discarded: list[Any] = []
with _xai_webui_oauth_flows_lock:
for existing_id, existing in list(_xai_webui_oauth_flows.items()):
with _webui_oauth_flows_lock:
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
if existing.expired:
discarded.append(_xai_webui_oauth_flows.pop(existing_id))
while len(_xai_webui_oauth_flows) >= _XAI_WEBUI_OAUTH_MAX_FLOWS:
oldest_id = next(iter(_xai_webui_oauth_flows))
discarded.append(_xai_webui_oauth_flows.pop(oldest_id))
_xai_webui_oauth_flows[flow_id] = flow
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
oldest_id = next(iter(_webui_oauth_flows))
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
_webui_oauth_flows[flow_id] = (provider_name, flow)
for existing in discarded:
existing.cancel()
def _get_xai_webui_oauth_flow(flow_id: str) -> Any | None:
with _xai_webui_oauth_flows_lock:
flow = _xai_webui_oauth_flows.get(flow_id)
if flow is None or not flow.expired:
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
with _webui_oauth_flows_lock:
registered = _webui_oauth_flows.get(flow_id)
if registered is None or registered[0] != provider_name:
return None
flow = registered[1]
if not flow.expired:
return flow
_xai_webui_oauth_flows.pop(flow_id, None)
_webui_oauth_flows.pop(flow_id, None)
flow.cancel()
return None
def _remove_xai_webui_oauth_flow(
def _remove_webui_oauth_flow(
provider_name: str,
flow_id: str,
flow: Any,
*,
cancel: bool = True,
) -> None:
with _xai_webui_oauth_flows_lock:
if _xai_webui_oauth_flows.get(flow_id) is flow:
_xai_webui_oauth_flows.pop(flow_id)
with _webui_oauth_flows_lock:
registered = _webui_oauth_flows.get(flow_id)
if (
registered is not None
and registered[0] == provider_name
and registered[1] is flow
):
_webui_oauth_flows.pop(flow_id)
if cancel:
flow.cancel()
def _clear_xai_webui_oauth_flows() -> None:
with _xai_webui_oauth_flows_lock:
flows = list(_xai_webui_oauth_flows.values())
_xai_webui_oauth_flows.clear()
def _clear_webui_oauth_flows(provider_name: str) -> None:
with _webui_oauth_flows_lock:
flow_ids = [
flow_id
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
if registered_provider == provider_name
]
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
for flow in flows:
flow.cancel()

View File

@ -85,7 +85,8 @@ _CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
_OAUTH_CODE_HEADER_MAX_BYTES = 8 * 1024
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
_SKIP_FIELD = object()
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
@ -471,16 +472,22 @@ class WebUISettingsRouter:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
elif action == "complete":
authorization_code = case_insensitive_header(
authorization_response = case_insensitive_header(
request.headers,
_OAUTH_CALLBACK_HEADER,
) or case_insensitive_header(
request.headers,
_OAUTH_CODE_HEADER,
)
if len(authorization_code.encode("utf-8")) > _OAUTH_CODE_HEADER_MAX_BYTES:
raise WebUISettingsError("OAuth authorization code is too large")
if (
len(authorization_response.encode("utf-8"))
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
):
raise WebUISettingsError("OAuth authorization response is too large")
payload = await asyncio.to_thread(
complete_oauth_provider,
query,
authorization_code or None,
authorization_response or None,
)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)

View File

@ -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()

View File

@ -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] = []

View File

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

View File

@ -661,11 +661,12 @@ export function SettingsView({
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [xaiOAuthFlow, setXaiOAuthFlow] =
const [providerOAuthFlow, setProviderOAuthFlow] =
useState<ProviderOAuthAuthorizationRequired | null>(null);
const xaiOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
const [xaiOAuthCode, setXaiOAuthCode] = useState("");
const [xaiOAuthCompleting, setXaiOAuthCompleting] = useState(false);
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
@ -765,37 +766,44 @@ export function SettingsView({
[onSettingsChange],
);
const closeXaiOAuthFlow = useCallback(() => {
xaiOAuthFlowRef.current = null;
setXaiOAuthFlow(null);
setXaiOAuthCode("");
setXaiOAuthCompleting(false);
const closeProviderOAuthFlow = useCallback(() => {
providerOAuthFlowRef.current = null;
setProviderOAuthFlow(null);
setProviderOAuthResponse("");
setProviderOAuthCompleting(false);
setProviderOAuthDialogError(null);
}, []);
useEffect(() => {
if (!xaiOAuthFlow) return;
if (!providerOAuthFlow) return;
let cancelled = false;
let timer: number | null = null;
const poll = async () => {
try {
const payload = await completeProviderOAuth(
getToken(),
xaiOAuthFlow.provider,
xaiOAuthFlow.flow_id,
providerOAuthFlow.provider,
providerOAuthFlow.flow_id,
);
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
if (
cancelled
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
) return;
if (isProviderOAuthPending(payload)) {
timer = window.setTimeout(() => void poll(), 1000);
return;
}
applyPayload(payload);
setExpandedProvider(xaiOAuthFlow.provider);
setExpandedProvider(providerOAuthFlow.provider);
setError(null);
closeXaiOAuthFlow();
closeProviderOAuthFlow();
} catch (err) {
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
if (
cancelled
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
) return;
setError((err as Error).message);
closeXaiOAuthFlow();
closeProviderOAuthFlow();
}
};
timer = window.setTimeout(() => void poll(), 1000);
@ -803,7 +811,7 @@ export function SettingsView({
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [applyPayload, closeXaiOAuthFlow, getToken, xaiOAuthFlow]);
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
@ -1612,7 +1620,11 @@ export function SettingsView({
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
if (providerSaving) return;
let popup: Window | null = null;
if (action === "login" && providerName === "xai_grok" && !remoteBrowserAccess) {
if (
action === "login"
&& providerName === "xai_grok"
&& !remoteBrowserAccess
) {
try {
popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
@ -1624,7 +1636,12 @@ export function SettingsView({
try {
const payload =
action === "login"
? await loginProviderOAuth(token, providerName)
? await loginProviderOAuth(
token,
providerName,
"",
providerName === "openai_codex" && remoteBrowserAccess,
)
: await logoutProviderOAuth(token, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
@ -1632,15 +1649,16 @@ export function SettingsView({
} catch {
// The dialog keeps the authorization link available when the popup was closed.
}
xaiOAuthFlowRef.current = payload;
setXaiOAuthFlow(payload);
setXaiOAuthCode("");
providerOAuthFlowRef.current = payload;
setProviderOAuthFlow(payload);
setProviderOAuthResponse("");
setProviderOAuthDialogError(null);
setExpandedProvider(providerName);
setError(null);
return;
}
popup?.close();
closeXaiOAuthFlow();
closeProviderOAuthFlow();
applyPayload(payload);
setExpandedProvider(providerName);
setError(null);
@ -1652,31 +1670,31 @@ export function SettingsView({
}
};
const completeXaiOAuth = async () => {
const flow = xaiOAuthFlowRef.current;
const authorizationCode = xaiOAuthCode.trim();
if (!flow || !authorizationCode || xaiOAuthCompleting) return;
setXaiOAuthCompleting(true);
const completeProviderOAuthResponse = async () => {
const flow = providerOAuthFlowRef.current;
const authorizationResponse = providerOAuthResponse.trim();
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
setProviderOAuthCompleting(true);
setProviderOAuthDialogError(null);
try {
const payload = await completeProviderOAuth(
token,
flow.provider,
flow.flow_id,
authorizationCode,
authorizationResponse,
);
if (xaiOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
if (isProviderOAuthPending(payload)) return;
applyPayload(payload);
setExpandedProvider(flow.provider);
setError(null);
closeXaiOAuthFlow();
closeProviderOAuthFlow();
} catch (err) {
if (xaiOAuthFlowRef.current?.flow_id === flow.flow_id) {
setError((err as Error).message);
closeXaiOAuthFlow();
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
setProviderOAuthDialogError((err as Error).message);
}
} finally {
setXaiOAuthCompleting(false);
setProviderOAuthCompleting(false);
}
};
@ -2317,19 +2335,33 @@ export function SettingsView({
onConfirm={handleDeleteModelConfiguration}
/>
<XaiOAuthLoginDialog
flow={xaiOAuthFlow}
authorizationCode={xaiOAuthCode}
completing={xaiOAuthCompleting}
<ProviderOAuthLoginDialog
flow={providerOAuthFlow}
providerLabel={
providerOAuthFlow
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
?.label ?? providerOAuthFlow.provider
: ""
}
authorizationResponse={providerOAuthResponse}
completing={providerOAuthCompleting}
error={providerOAuthDialogError}
remoteBrowserAccess={remoteBrowserAccess}
onAuthorizationCodeChange={setXaiOAuthCode}
onAuthorizationResponseChange={(value) => {
setProviderOAuthResponse(value);
setProviderOAuthDialogError(null);
}}
onOpenAuthorization={() => {
if (!xaiOAuthFlow) return;
const opened = window.open(xaiOAuthFlow.authorization_url, "_blank", "noopener,noreferrer");
if (!providerOAuthFlow) return;
const opened = window.open(
providerOAuthFlow.authorization_url,
"_blank",
"noopener,noreferrer",
);
if (opened) opened.opener = null;
}}
onComplete={() => void completeXaiOAuth()}
onClose={closeXaiOAuthFlow}
onComplete={() => void completeProviderOAuthResponse()}
onClose={closeProviderOAuthFlow}
/>
<NanobotFeatureInstallDialog
@ -2980,26 +3012,35 @@ function AppearanceSettings({
);
}
function XaiOAuthLoginDialog({
function ProviderOAuthLoginDialog({
flow,
authorizationCode,
providerLabel,
authorizationResponse,
completing,
error,
remoteBrowserAccess,
onAuthorizationCodeChange,
onAuthorizationResponseChange,
onOpenAuthorization,
onComplete,
onClose,
}: {
flow: ProviderOAuthAuthorizationRequired | null;
authorizationCode: string;
providerLabel: string;
authorizationResponse: string;
completing: boolean;
error: string | null;
remoteBrowserAccess: boolean;
onAuthorizationCodeChange: (value: string) => void;
onAuthorizationResponseChange: (value: string) => void;
onOpenAuthorization: () => void;
onComplete: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const expectsCallbackUrl = flow?.completion_input === "callback_url";
const inputId = expectsCallbackUrl ? "provider-oauth-callback" : "provider-oauth-code";
const inputLabel = expectsCallbackUrl
? t("settings.oauth.callbackUrl")
: t("settings.oauth.authorizationCode");
return (
<Dialog
@ -3017,36 +3058,75 @@ function XaiOAuthLoginDialog({
}}
>
<DialogHeader>
<DialogTitle>xAI Grok</DialogTitle>
<DialogTitle>{providerLabel}</DialogTitle>
<DialogDescription>
{remoteBrowserAccess
? t("settings.oauth.remoteCodeHelp")
: t("settings.oauth.localCodeHelp")}
{expectsCallbackUrl
? remoteBrowserAccess
? t("settings.oauth.remoteCallbackHelp")
: t("settings.oauth.localCallbackHelp")
: remoteBrowserAccess
? t("settings.oauth.remoteCodeHelp")
: t("settings.oauth.localCodeHelp")}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-[14px] border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
{expectsCallbackUrl && remoteBrowserAccess ? (
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
)}
<span>
{expectsCallbackUrl && remoteBrowserAccess
? t("settings.oauth.pasteCallbackToContinue")
: t("settings.oauth.waitingForCallback")}
</span>
</div>
<div className="space-y-2">
<label
htmlFor="xai-oauth-code"
htmlFor={inputId}
className="block text-xs font-medium text-foreground"
>
{t("settings.oauth.authorizationCode")}
{inputLabel}
</label>
<Input
id="xai-oauth-code"
value={authorizationCode}
onChange={(event) => onAuthorizationCodeChange(event.target.value)}
placeholder={t("settings.oauth.authorizationCode")}
aria-label={t("settings.oauth.authorizationCode")}
autoComplete="off"
spellCheck={false}
/>
{expectsCallbackUrl ? (
<Textarea
id={inputId}
value={authorizationResponse}
onChange={(event) => onAuthorizationResponseChange(event.target.value)}
placeholder={t("settings.oauth.callbackUrlPlaceholder")}
aria-label={inputLabel}
autoComplete="off"
spellCheck={false}
className="min-h-[88px] resize-none break-all font-mono text-[12px] leading-5"
/>
) : (
<Input
id={inputId}
value={authorizationResponse}
onChange={(event) => onAuthorizationResponseChange(event.target.value)}
placeholder={inputLabel}
aria-label={inputLabel}
autoComplete="off"
spellCheck={false}
/>
)}
</div>
{error ? (
<p
role="alert"
className="rounded-[14px] border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
>
{error}
</p>
) : null}
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
{t("settings.oauth.signIn")}
{expectsCallbackUrl
? t("settings.oauth.openChatGPT")
: t("settings.oauth.signIn")}
</Button>
<Button type="submit" disabled={!authorizationCode.trim() || completing}>
<Button type="submit" disabled={!authorizationResponse.trim() || completing}>
{completing ? t("settings.oauth.signingIn") : t("settings.oauth.finishSignIn")}
</Button>
</DialogFooter>
@ -4255,7 +4335,12 @@ function ProvidersSettings({
account: provider.oauth_account || provider.label,
defaultValue: "Signed in as {{account}}",
})
: provider.name === "xai_grok" && remoteBrowserAccess
: provider.name === "openai_codex" && remoteBrowserAccess
? tx(
"settings.oauth.codexRemoteSignInHelp",
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
)
: provider.name === "xai_grok" && remoteBrowserAccess
? tx(
"settings.oauth.remoteSignInHelp",
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",

View File

@ -773,6 +773,7 @@
"signedInAs": "Signed in as {{account}}",
"signInHelp": "Sign in from this device; no API key is stored in config.",
"remoteSignInHelp": "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
"codexRemoteSignInHelp": "Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
"signInRequired": "Sign in required",
"signInBeforeSaving": "Sign in before saving this provider in the preset.",
"signedIn": "Signed in",
@ -782,7 +783,14 @@
"saveProxy": "Save proxy",
"localCodeHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
"remoteCodeHelp": "Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
"localCallbackHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
"remoteCallbackHelp": "Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
"authorizationCode": "Authorization code",
"callbackUrl": "Full callback URL",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "Open ChatGPT",
"pasteCallbackToContinue": "Paste the callback URL to continue.",
"waitingForCallback": "Waiting for the browser callback…",
"finishSignIn": "Finish sign-in"
},
"skills": {

View File

@ -760,6 +760,7 @@
"signedInAs": "Sesión iniciada como {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora y luego pega el código de autorización que se muestra tras iniciar sesión.",
"codexRemoteSignInHelp": "Inicia sesión en este navegador y pega en nanobot la URL completa de devolución de localhost.",
"signInRequired": "Inicio de sesión requerido",
"signInBeforeSaving": "Inicia sesión en este proveedor antes de guardar el preajuste.",
"signedIn": "Sesión iniciada",
@ -769,7 +770,14 @@
"saveProxy": "Guardar proxy",
"localCodeHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, pega el código de autorización abajo.",
"remoteCodeHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora. Después de iniciar sesión, pega abajo el código de autorización que muestra xAI.",
"localCallbackHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, copia de la barra de direcciones la URL completa de devolución de localhost y pégala abajo.",
"remoteCallbackHelp": "Abre ChatGPT en este navegador y completa el inicio de sesión. Cuando la página de localhost no cargue, copia la URL completa de la barra de direcciones y pégala abajo.",
"authorizationCode": "Código de autorización",
"callbackUrl": "URL completa de devolución",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "Abrir ChatGPT",
"pasteCallbackToContinue": "Pega la URL de devolución para continuar.",
"waitingForCallback": "Esperando la devolución del navegador…",
"finishSignIn": "Completar inicio de sesión"
},
"skills": {

View File

@ -759,6 +759,7 @@
"signedInAs": "Connecté en tant que {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code dautorisation affiché après la connexion.",
"codexRemoteSignInHelp": "Connectez-vous dans ce navigateur, puis recollez dans nanobot lURL complète de rappel localhost.",
"signInRequired": "Connexion requise",
"signInBeforeSaving": "Connectez-vous à ce fournisseur avant denregistrer le préréglage.",
"signedIn": "Connecté",
@ -768,7 +769,14 @@
"saveProxy": "Enregistrer le proxy",
"localCodeHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, collez le code dautorisation ci-dessous.",
"remoteCodeHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur. Après la connexion, collez ci-dessous le code dautorisation affiché par xAI.",
"localCallbackHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, copiez lURL complète de rappel localhost depuis la barre dadresse et collez-la ci-dessous.",
"remoteCallbackHelp": "Ouvrez ChatGPT dans ce navigateur et terminez la connexion. Lorsque la page localhost ne se charge pas, copiez lURL complète de la barre dadresse et collez-la ci-dessous.",
"authorizationCode": "Code dautorisation",
"callbackUrl": "URL complète de rappel",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "Ouvrir ChatGPT",
"pasteCallbackToContinue": "Collez lURL de rappel pour continuer.",
"waitingForCallback": "En attente du rappel du navigateur…",
"finishSignIn": "Terminer la connexion"
},
"skills": {

View File

@ -759,6 +759,7 @@
"signedInAs": "Masuk sebagai {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
"codexRemoteSignInHelp": "Masuk melalui browser ini, lalu tempel URL callback localhost lengkap kembali ke nanobot.",
"signInRequired": "Perlu masuk",
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan preset.",
"signedIn": "Sudah masuk",
@ -768,7 +769,14 @@
"saveProxy": "Simpan proksi",
"localCodeHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, tempel kode otorisasi di bawah.",
"remoteCodeHelp": "Pilih Masuk untuk membuka xAI di komputer Anda. Setelah masuk, tempel kode otorisasi yang ditampilkan xAI di bawah.",
"localCallbackHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, salin URL callback localhost lengkap dari bilah alamat dan tempel di bawah.",
"remoteCallbackHelp": "Buka ChatGPT di browser ini dan selesaikan proses masuk. Saat halaman localhost gagal dimuat, salin URL lengkap dari bilah alamat dan tempel di bawah.",
"authorizationCode": "Kode otorisasi",
"callbackUrl": "URL callback lengkap",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "Buka ChatGPT",
"pasteCallbackToContinue": "Tempel URL callback untuk melanjutkan.",
"waitingForCallback": "Menunggu callback browser…",
"finishSignIn": "Selesaikan masuk"
},
"skills": {

View File

@ -759,6 +759,7 @@
"signedInAs": "{{account}} としてサインイン済み",
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
"codexRemoteSignInHelp": "このブラウザーでサインインし、localhost の完全なコールバック URL を nanobot に貼り付けてください。",
"signInRequired": "サインインが必要です",
"signInBeforeSaving": "プリセットを保存する前に、このプロバイダーへサインインしてください。",
"signedIn": "サインイン済み",
@ -768,7 +769,14 @@
"saveProxy": "プロキシを保存",
"localCodeHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、認証コードを下に貼り付けてください。",
"remoteCodeHelp": "「サインイン」を選択して自分のコンピューターで xAI を開いてください。サインイン後、xAI に表示された認証コードを下に貼り付けてください。",
"localCallbackHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、アドレスバーから localhost の完全なコールバック URL をコピーして下に貼り付けてください。",
"remoteCallbackHelp": "このブラウザーで ChatGPT を開いてサインインを完了してください。localhost ページを開けない場合は、アドレスバーの完全な URL をコピーして下に貼り付けてください。",
"authorizationCode": "認証コード",
"callbackUrl": "完全なコールバック URL",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "ChatGPT を開く",
"pasteCallbackToContinue": "続行するにはコールバック URL を貼り付けてください。",
"waitingForCallback": "ブラウザーのコールバックを待機中…",
"finishSignIn": "サインインを完了"
},
"skills": {

View File

@ -759,6 +759,7 @@
"signedInAs": "{{account}}로 로그인됨",
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
"codexRemoteSignInHelp": "이 브라우저에서 로그인한 다음 전체 localhost 콜백 URL을 nanobot에 붙여 넣으세요.",
"signInRequired": "로그인이 필요합니다",
"signInBeforeSaving": "프리셋을 저장하기 전에 이 제공자에 로그인하세요.",
"signedIn": "로그인됨",
@ -768,7 +769,14 @@
"saveProxy": "프록시 저장",
"localCodeHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 인증 코드를 아래에 붙여 넣으세요.",
"remoteCodeHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 여세요. 로그인 후 xAI에 표시된 인증 코드를 아래에 붙여 넣으세요.",
"localCallbackHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 주소 표시줄에서 전체 localhost 콜백 URL을 복사해 아래에 붙여 넣으세요.",
"remoteCallbackHelp": "이 브라우저에서 ChatGPT를 열고 로그인을 완료하세요. localhost 페이지가 열리지 않으면 주소 표시줄의 전체 URL을 복사해 아래에 붙여 넣으세요.",
"authorizationCode": "인증 코드",
"callbackUrl": "전체 콜백 URL",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "ChatGPT 열기",
"pasteCallbackToContinue": "계속하려면 콜백 URL을 붙여 넣으세요.",
"waitingForCallback": "브라우저 콜백 대기 중…",
"finishSignIn": "로그인 완료"
},
"skills": {

View File

@ -773,6 +773,7 @@
"signedInAs": "Conectado como {{account}}",
"signInHelp": "Entre por este dispositivo; nenhuma chave de API é armazenada em config.",
"remoteSignInHelp": "Selecione Entrar para abrir a xAI no seu computador e depois cole o código de autorização exibido após o login.",
"codexRemoteSignInHelp": "Entre por este navegador e cole no nanobot a URL completa de callback do localhost.",
"signInRequired": "Login necessário",
"signInBeforeSaving": "Entre neste provedor antes de salvar a predefinição.",
"signedIn": "Conectado",
@ -782,7 +783,14 @@
"saveProxy": "Salvar proxy",
"localCodeHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, cole o código de autorização abaixo.",
"remoteCodeHelp": "Selecione Entrar para abrir a xAI no seu computador. Após o login, cole abaixo o código de autorização exibido pela xAI.",
"localCallbackHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, copie da barra de endereço a URL completa de callback do localhost e cole abaixo.",
"remoteCallbackHelp": "Abra o ChatGPT neste navegador e conclua o login. Quando a página localhost não carregar, copie a URL completa da barra de endereço e cole abaixo.",
"authorizationCode": "Código de autorização",
"callbackUrl": "URL completa de callback",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "Abrir ChatGPT",
"pasteCallbackToContinue": "Cole a URL de callback para continuar.",
"waitingForCallback": "Aguardando o callback do navegador…",
"finishSignIn": "Concluir login"
},
"skills": {

View File

@ -759,6 +759,7 @@
"signedInAs": "Đã đăng nhập bằng {{account}}",
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
"codexRemoteSignInHelp": "Đăng nhập trong trình duyệt này, sau đó dán lại URL callback localhost đầy đủ vào nanobot.",
"signInRequired": "Cần đăng nhập",
"signInBeforeSaving": "Hãy đăng nhập nhà cung cấp này trước khi lưu cấu hình đặt trước.",
"signedIn": "Đã đăng nhập",
@ -768,7 +769,14 @@
"saveProxy": "Lưu proxy",
"localCodeHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy dán mã ủy quyền bên dưới.",
"remoteCodeHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn. Sau khi đăng nhập, hãy dán mã ủy quyền do xAI hiển thị bên dưới.",
"localCallbackHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy sao chép URL callback localhost đầy đủ từ thanh địa chỉ và dán vào bên dưới.",
"remoteCallbackHelp": "Mở ChatGPT trong trình duyệt này và hoàn tất đăng nhập. Khi trang localhost không tải được, hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào bên dưới.",
"authorizationCode": "Mã ủy quyền",
"callbackUrl": "URL callback đầy đủ",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "Mở ChatGPT",
"pasteCallbackToContinue": "Dán URL callback để tiếp tục.",
"waitingForCallback": "Đang chờ callback từ trình duyệt…",
"finishSignIn": "Hoàn tất đăng nhập"
},
"skills": {

View File

@ -773,6 +773,7 @@
"signedInAs": "已登录为 {{account}}",
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI完成登录后粘贴页面显示的授权码。",
"codexRemoteSignInHelp": "在此浏览器中登录,然后将完整的 localhost 回调 URL 粘贴回 nanobot。",
"signInRequired": "需要登录",
"signInBeforeSaving": "请先登录此提供商,再保存模型预设。",
"signedIn": "已登录",
@ -782,7 +783,14 @@
"saveProxy": "保存代理",
"localCodeHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请将授权码粘贴到下方。",
"remoteCodeHelp": "点击“登录”在你的电脑上打开 xAI。完成登录后请将 xAI 显示的授权码粘贴到下方。",
"localCallbackHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请复制地址栏中的完整 localhost 回调 URL 并粘贴到下方。",
"remoteCallbackHelp": "在此浏览器中打开 ChatGPT 并完成登录。当 localhost 页面无法打开时,请复制地址栏中的完整 URL 并粘贴到下方。",
"authorizationCode": "授权码",
"callbackUrl": "完整回调 URL",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "打开 ChatGPT",
"pasteCallbackToContinue": "粘贴回调 URL 以继续。",
"waitingForCallback": "正在等待浏览器回调…",
"finishSignIn": "完成登录"
},
"skills": {

View File

@ -759,6 +759,7 @@
"signedInAs": "已使用 {{account}} 登入",
"signInHelp": "請從這臺裝置登入;系統不會將 API 金鑰儲存在設定中。",
"remoteSignInHelp": "點擊「登入」在你的電腦上開啟 xAI完成登入後貼上頁面顯示的授權碼。",
"codexRemoteSignInHelp": "請在此瀏覽器中登入,然後將完整的 localhost 回呼 URL 貼回 nanobot。",
"signInRequired": "需要登入",
"signInBeforeSaving": "請先登入此供應商,再儲存模型預設。",
"signedIn": "已登入",
@ -768,7 +769,14 @@
"saveProxy": "儲存代理",
"localCodeHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請將授權碼貼到下方。",
"remoteCodeHelp": "點擊「登入」在你的電腦上開啟 xAI。完成登入後請將 xAI 顯示的授權碼貼到下方。",
"localCallbackHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請複製網址列中的完整 localhost 回呼 URL 並貼到下方。",
"remoteCallbackHelp": "請在此瀏覽器中開啟 ChatGPT 並完成登入。當 localhost 頁面無法開啟時,請複製網址列中的完整 URL 並貼到下方。",
"authorizationCode": "授權碼",
"callbackUrl": "完整回呼 URL",
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
"openChatGPT": "開啟 ChatGPT",
"pasteCallbackToContinue": "貼上回呼 URL 以繼續。",
"waitingForCallback": "正在等待瀏覽器回呼…",
"finishSignIn": "完成登入"
},
"skills": {

View File

@ -61,6 +61,7 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback";
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
export class ApiError extends Error {
@ -992,9 +993,11 @@ export async function loginProviderOAuth(
token: string,
provider: string,
base: string = "",
remoteBrowserAccess: boolean = false,
): Promise<ProviderOAuthLoginResult> {
const query = new URLSearchParams();
query.set("provider", provider);
if (remoteBrowserAccess) query.set("remote_browser", "true");
return request<ProviderOAuthLoginResult>(
`${base}/api/settings/provider/oauth-login?${query}`,
token,
@ -1006,13 +1009,18 @@ export async function completeProviderOAuth(
token: string,
provider: string,
flowId: string,
authorizationCode?: string,
authorizationResponse?: string,
base: string = "",
): Promise<ProviderOAuthCompletionResult> {
const query = new URLSearchParams();
query.set("provider", provider);
query.set("flow_id", flowId);
const headers = authorizationCode ? { [OAUTH_CODE_HEADER]: authorizationCode } : undefined;
const responseHeader = provider === "openai_codex"
? OAUTH_CALLBACK_HEADER
: OAUTH_CODE_HEADER;
const headers = authorizationResponse
? { [responseHeader]: authorizationResponse }
: undefined;
return request<ProviderOAuthCompletionResult>(
`${base}/api/settings/provider/oauth-login/complete?${query}`,
token,

View File

@ -458,6 +458,7 @@ export interface ProviderOAuthAuthorizationRequired {
flow_id: string;
authorization_url: string;
expires_in: number;
completion_input?: "authorization_code" | "callback_url";
}
export interface ProviderOAuthPending {

View File

@ -651,6 +651,14 @@ describe("webui API helpers", () => {
}),
);
await loginProviderOAuth("tok", "openai_codex", "", true);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await completeProviderOAuth("tok", "xai_grok", "flow-123");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
@ -675,6 +683,23 @@ describe("webui API helpers", () => {
}),
);
await completeProviderOAuth(
"tok",
"openai_codex",
"flow-codex",
"http://localhost:1455/auth/callback?code=secret&state=test",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Callback":
"http://localhost:1455/auth/callback?code=secret&state=test",
},
}),
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",

View File

@ -2753,6 +2753,219 @@ describe("SettingsView Apps catalog", () => {
}
});
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex-local",
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
expires_in: 600,
completion_input: "callback_url",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/provider/oauth-login?provider=openai_codex") {
return jsonResponse(authorization);
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex-local"
) {
expect(init?.headers).not.toHaveProperty("X-Nanobot-OAuth-Callback");
return jsonResponse(signedIn);
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.fn();
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
expect(
within(dialog).queryByText("Paste the callback URL to continue."),
).not.toBeInTheDocument();
expect(
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
).toBeInTheDocument();
});
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex",
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
expires_in: 600,
completion_input: "callback_url",
};
const callbackUrl =
"http://localhost:1455/auth/callback?code=secret&state=test";
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (
url ===
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true"
) {
return jsonResponse(authorization);
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex"
) {
const headers = init?.headers as Record<string, string>;
if (headers?.["X-Nanobot-OAuth-Callback"]) {
expect(headers["X-Nanobot-OAuth-Callback"]).toBe(callbackUrl);
return jsonResponse(signedIn);
}
return jsonResponse({
status: "pending",
provider: "openai_codex",
flow_id: "flow-codex",
});
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
expect(
screen.getByText(
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
const callbackInput = within(dialog).getByRole("textbox", {
name: "Full callback URL",
});
expect(callbackInput).toHaveAttribute(
"placeholder",
"http://localhost:1455/auth/callback?code=…&state=…",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
expect.objectContaining({
headers: expect.objectContaining({
"X-Nanobot-OAuth-Callback": callbackUrl,
}),
}),
),
);
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [