diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78bb11f..7d71c95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@
---
+## [v0.50.3] Onboarding completes gracefully for pre-configured providers (PR #323, fixes #322)
+
+- **OAuth/CLI-configured providers no longer blocked by onboarding** (closes #322): Users with providers already set up via the CLI (`openai-codex`, `copilot`, `nous`, etc.) hit `Unsupported provider for WebUI onboarding` when clicking "Open Hermes" on the finish page. The wizard now marks onboarding complete and lets them through — the agent setup is already done, no wizard steps needed.
+ - 5 new tests in `tests/test_sprint34.py`; 758 tests total (up from 753)
+
## [v0.50.2] Workspace panel state persists across refreshes
- **Workspace panel open/closed persists** (localStorage key `hermes-webui-workspace-panel`): Once you open the workspace/files pane, it stays open after a page refresh. Closing it explicitly saves the closed state, which also survives a refresh. The restore happens in the boot sequence before the first render, so there is no flash of the wrong state. Works for both desktop and mobile.
diff --git a/api/onboarding.py b/api/onboarding.py
index 3139b1a..be9ddf7 100644
--- a/api/onboarding.py
+++ b/api/onboarding.py
@@ -417,7 +417,11 @@ def apply_onboarding_setup(body: dict) -> dict:
base_url = _normalize_base_url(str(body.get("base_url") or ""))
if provider not in _SUPPORTED_PROVIDER_SETUPS:
- raise ValueError("Unsupported provider for WebUI onboarding.")
+ # Unsupported providers (openai-codex, copilot, nous, etc.) are already
+ # configured via the CLI. Just mark onboarding as complete and let the
+ # user through — the agent is already set up, no further setup needed.
+ save_settings({"onboarding_completed": True})
+ return get_onboarding_status()
if not model:
raise ValueError("model is required")
diff --git a/static/index.html b/static/index.html
index 4b1b941..dec6669 100644
--- a/static/index.html
+++ b/static/index.html
@@ -526,7 +526,7 @@
System
Instance version and access controls.
- v0.50.2
+ v0.50.3
diff --git a/tests/test_sprint34.py b/tests/test_sprint34.py
index f0a39b5..4001ff5 100644
--- a/tests/test_sprint34.py
+++ b/tests/test_sprint34.py
@@ -242,3 +242,59 @@ def test_control_center_tab_highlight_on_open():
"""Opening the control center must use settings-tabs for section navigation."""
css = open('static/style.css').read()
assert 'settings-tabs' in css, 'settings-tabs CSS class for control center tabs missing from style.css'
+
+
+# ── apply_onboarding_setup: unsupported/OAuth providers complete gracefully ──
+
+class TestApplyOnboardingSetupUnsupportedProvider:
+ """PR #323 / Issue #322: apply_onboarding_setup must not raise ValueError for
+ providers already configured via CLI (openai-codex, copilot, nous, etc.).
+ Instead it marks onboarding complete and returns current status.
+ """
+
+ def _call(self, provider: str) -> dict:
+ import sys, pathlib, unittest.mock, tempfile, os
+ repo = pathlib.Path(__file__).parent.parent
+ if str(repo) not in sys.path:
+ sys.path.insert(0, str(repo))
+
+ from api.onboarding import apply_onboarding_setup
+
+ with tempfile.TemporaryDirectory() as tmp:
+ with unittest.mock.patch("api.onboarding._get_active_hermes_home",
+ return_value=pathlib.Path(tmp)), \
+ unittest.mock.patch("api.onboarding._get_config_path",
+ return_value=pathlib.Path(tmp) / "config.yaml"), \
+ unittest.mock.patch("api.onboarding.save_settings") as mock_save, \
+ unittest.mock.patch("api.onboarding.get_onboarding_status",
+ return_value={"completed": True, "system": {}}):
+ result = apply_onboarding_setup({"provider": provider, "model": "", "api_key": ""})
+ return result, mock_save
+
+ def test_openai_codex_does_not_raise(self):
+ """apply_onboarding_setup with openai-codex must not raise ValueError."""
+ result, _ = self._call("openai-codex")
+ assert result is not None
+
+ def test_copilot_does_not_raise(self):
+ """apply_onboarding_setup with copilot must not raise ValueError."""
+ result, _ = self._call("copilot")
+ assert result is not None
+
+ def test_nous_does_not_raise(self):
+ """apply_onboarding_setup with nous must not raise ValueError."""
+ result, _ = self._call("nous")
+ assert result is not None
+
+ def test_unsupported_provider_marks_onboarding_complete(self):
+ """apply_onboarding_setup with an unsupported provider must save onboarding_completed=True."""
+ _, mock_save = self._call("openai-codex")
+ calls = [str(c) for c in mock_save.call_args_list]
+ assert any("onboarding_completed" in c for c in calls), \
+ "save_settings must be called with onboarding_completed=True for unsupported providers"
+
+ def test_unsupported_provider_returns_status_dict(self):
+ """apply_onboarding_setup with an unsupported provider must return a status dict (not raise)."""
+ result, _ = self._call("openai-codex")
+ assert isinstance(result, dict), \
+ "apply_onboarding_setup must return a dict for unsupported providers, not raise"