From 4aa701fcb27b3b7202ffc622114ff67ee346ae37 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:18:48 +0200 Subject: [PATCH 01/18] Unify OAuth account management surfaces Add a shared provider-registry-backed OAuth status summary for the status API and discovery cards. Redesign the OAuth settings modal around provider rows, inline usage, provider details, and account-backed model slots while keeping legacy Codex compatibility fields intact. --- .../python/banners/10_discovery_cards.py | 103 ++-- .../welcome-actions-end/discovery-cards.html | 65 ++- plugins/_oauth/AGENTS.md | 3 + plugins/_oauth/README.md | 4 + plugins/_oauth/api/status.py | 29 +- plugins/_oauth/helpers/summary.py | 128 +++++ plugins/_oauth/webui/config.html | 511 ++++++++++-------- plugins/_oauth/webui/oauth-config-store.js | 90 +++ tests/test_oauth_providers.py | 33 ++ tests/test_oauth_static.py | 51 +- 10 files changed, 716 insertions(+), 301 deletions(-) create mode 100644 plugins/_oauth/helpers/summary.py diff --git a/plugins/_discovery/extensions/python/banners/10_discovery_cards.py b/plugins/_discovery/extensions/python/banners/10_discovery_cards.py index b9e581899..c0bc5cdee 100644 --- a/plugins/_discovery/extensions/python/banners/10_discovery_cards.py +++ b/plugins/_discovery/extensions/python/banners/10_discovery_cards.py @@ -5,42 +5,55 @@ from helpers import plugins class DiscoveryCardsExtension(Extension): """Injects discovery cards into the banners list.""" - def _codex_oauth_status(self) -> dict: + def _oauth_summary(self) -> dict: try: - from plugins._oauth.helpers import codex + from plugins._oauth.helpers.providers import provider_registry + from plugins._oauth.helpers.route_bootstrap import is_installed + from plugins._oauth.helpers.summary import build_oauth_status_summary - status = codex.status() - return status if isinstance(status, dict) else {} + return build_oauth_status_summary( + provider_registry=provider_registry, + routes_installed=is_installed, + ) except Exception: return {} - def _codex_oauth_usage_windows(self, status: dict) -> list[dict]: - usage = status.get("usage") if isinstance(status, dict) else {} - if not isinstance(usage, dict) or not usage.get("available"): - return [] - + def _oauth_usage_windows(self, summary: dict) -> list[dict]: windows: list[dict] = [] - for key, title in (("primary", "Session"), ("secondary", "Week")): - window = usage.get(key) - if not isinstance(window, dict): + accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {} + connected = accounts.get("connected", []) if isinstance(accounts, dict) else [] + for account in connected: + if not isinstance(account, dict): continue - remaining = window.get("remaining_percent") - used = window.get("used_percent") - if remaining is None and used is not None: - try: - remaining = max(0, min(100, 100 - float(used))) - except (TypeError, ValueError): - remaining = None - if remaining is None: + provider_name = account.get("short_name") or account.get("display_name") or "Account" + for window in account.get("usage_windows") or []: + if not isinstance(window, dict): + continue + windows.append({ + **window, + "key": f'{account.get("provider_id", "oauth")}-{window.get("key", "usage")}', + "title": f'{provider_name} {window.get("title") or "Usage"}', + }) + return windows[:4] + + def _oauth_account_chips(self, summary: dict) -> list[dict]: + accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {} + if not isinstance(accounts, dict): + return [] + connected = accounts.get("connected") or [] + available = accounts.get("available") or [] + source = connected if connected else available + chips = [] + for account in source[:4]: + if not isinstance(account, dict): continue - windows.append({ - "key": key, - "title": title, - "label": window.get("label") or "", - "remaining_percent": remaining, - "reset_at": window.get("reset_at") or 0, + chips.append({ + "provider_id": account.get("provider_id") or "", + "label": account.get("short_name") or account.get("display_name") or account.get("provider_id"), + "detail": account.get("account_label") if account.get("connected") else "Available", + "connected": bool(account.get("connected")), }) - return windows + return chips async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs): # Optional logic: only show specific cards if plugins aren't already configured. @@ -49,8 +62,10 @@ class DiscoveryCardsExtension(Extension): telegram_config = plugins.get_plugin_config("_telegram_integration") or {} email_config = plugins.get_plugin_config("_email_integration") or {} whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {} - codex_oauth_status = self._codex_oauth_status() - codex_oauth_connected = bool(codex_oauth_status.get("connected")) + oauth_summary = self._oauth_summary() + oauth_accounts = oauth_summary.get("oauth_accounts") if isinstance(oauth_summary, dict) else {} + connected_count = int(oauth_accounts.get("connected_count") or 0) if isinstance(oauth_accounts, dict) else 0 + total_count = int(oauth_accounts.get("total_count") or 0) if isinstance(oauth_accounts, dict) else 0 # 1. Plugin Hub Hero banners.append({ @@ -117,25 +132,23 @@ class DiscoveryCardsExtension(Extension): "show_in_onboarding": True }) - # 5. Codex/ChatGPT OAuth - codex_oauth_card = { - "id": "discovery-codex-oauth", + # 5. OAuth account providers + oauth_card = { + "id": "discovery-oauth-accounts", "type": "hero", "placement": "after-features", - "title": "Connect ChatGPT/Codex" if not codex_oauth_connected else "ChatGPT/Codex Connected", - "description": "Link your account through the OAuth plugin to unlock account-backed Codex models locally." - if not codex_oauth_connected - else "Manage your OAuth connection and account-backed Codex models.", - "thumbnail": "/plugins/_discovery/webui/assets/hero-openai-oauth.png", - "icon": "key", - "cta_text": "Connect Account" if not codex_oauth_connected else "Manage OAuth", + "title": "Your AI accounts", + "description": "Link account-backed providers such as ChatGPT/Codex, GitHub Copilot, Gemini API, and xAI Grok." + if not connected_count + else "", + "cta_text": "Connect accounts" if not connected_count else "Manage accounts", "cta_action": "open-plugin-config:_oauth", "dismissible": True, "priority": 40, - "show_in_onboarding": True + "show_in_onboarding": True, + "account_chips": self._oauth_account_chips(oauth_summary), } - if codex_oauth_connected: - usage_windows = self._codex_oauth_usage_windows(codex_oauth_status) - if usage_windows: - codex_oauth_card["usage_windows"] = usage_windows - banners.append(codex_oauth_card) + usage_windows = self._oauth_usage_windows(oauth_summary) + if usage_windows: + oauth_card["usage_windows"] = usage_windows + banners.append(oauth_card) diff --git a/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html b/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html index 72c982bf7..21b3362e6 100644 --- a/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html +++ b/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html @@ -28,7 +28,15 @@

-

+

+
+ +
- -
@@ -375,8 +349,7 @@ color: var(--color-text); } - .oauth-provider-list, - .oauth-provider-detail { + .oauth-provider-list { display: grid; gap: 12px; padding: 0; @@ -477,11 +450,11 @@ padding: 4px 0 0; } - .oauth-provider-detail-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + .oauth-provider-row-detail { + display: grid; + grid-column: 1 / -1; + min-width: 0; + padding-top: 2px; } .oauth-detail-grid { @@ -1154,6 +1127,7 @@ @media (max-width: 720px) { .oauth-device, + .oauth-provider-row-detail, .oauth-provider-usage, .oauth-status-row, .oauth-model-grid, @@ -1162,15 +1136,10 @@ .oauth-details div, .oauth-manual-callback, .oauth-auth-attempt, - .oauth-provider-row, - .oauth-provider-detail-head { + .oauth-provider-row { grid-template-columns: 1fr; } - .oauth-provider-detail-head { - display: grid; - } - .oauth-section-head { flex-direction: column; } diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js index 5e2b45695..9b5279d54 100644 --- a/plugins/_oauth/webui/oauth-config-store.js +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -299,6 +299,34 @@ export const store = createStore("oauthConfig", { return status.account_label || status.email || "Connected"; }, + providerDevice(providerId) { + return this.devices[String(providerId || "")] || null; + }, + + providerShowSetupFields(providerId) { + if (this.providerConnected(providerId)) return false; + if (this.providerDevice(providerId)) return false; + return this.selectedProviderId === providerId; + }, + + providerShowNote(providerId) { + if (this.providerConnected(providerId)) return false; + const status = this.providerStatus(providerId); + return this.selectedProviderId === providerId && Boolean(status.warning || status.note); + }, + + providerDetailOpen(providerId) { + if (!this.isOauthProvider(providerId) || this.providerConnected(providerId)) return false; + if (this.providerDevice(providerId)) return true; + const status = this.providerStatus(providerId); + return this.selectedProviderId === providerId && Boolean( + status.supports_enterprise_domain + || status.supports_oauth_client_config + || status.warning + || status.note + ); + }, + providerReadinessLabel(providerId) { const status = this.providerStatus(providerId); if (this.loadingStatus) return "Checking"; diff --git a/tests/test_oauth_static.py b/tests/test_oauth_static.py index 0cdc2d394..fe9eb0b7d 100644 --- a/tests/test_oauth_static.py +++ b/tests/test_oauth_static.py @@ -52,9 +52,14 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy(): assert "supports_quota_project" in config_html + store_js assert "OAuth client ID" in config_html assert "quota_project_id" in config_html + store_js - assert "submitManualCallback($store.oauthConfig.selectedProviderId)" in config_html - assert "cancelConnect($store.oauthConfig.selectedProviderId)" in config_html + assert "providerDetailOpen(card.provider_id)" in config_html + store_js + assert "providerDevice(card.provider_id)?.user_code" in config_html + assert "submitManualCallback(card.provider_id)" in config_html + assert "cancelConnect(card.provider_id)" in config_html + assert "selectedProvider()" not in config_html assert "oauth-auth-attempt" in config_html + assert "oauth-provider-row-detail" in config_html + assert "oauth-provider-detail" not in config_html assert "oauth-detail-metrics" not in config_html assert "Codex/ChatGPT Account" not in config_html + store_js assert "activeModelsDescription()" in config_html + store_js @@ -88,7 +93,8 @@ def test_oauth_available_models_list_sits_above_advanced_without_borders(): assert config_html.index("

Providers

") < config_html.index("Advanced") assert config_html.index("Available models") < config_html.index("Advanced") assert ".oauth-models-panel {\n display: grid;\n gap: 10px;\n padding: 0;\n border: 0;\n }" in config_html - assert ".oauth-provider-list,\n .oauth-provider-detail {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;" in config_html + assert ".oauth-provider-list {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;" in config_html + assert ".oauth-provider-row-detail {\n display: grid;\n grid-column: 1 / -1;" in config_html assert ".oauth-advanced {\n border: 0;\n border-radius: 0;\n padding: 0;\n }" in config_html model_chip_rule = config_html.split(".oauth-models span {", 1)[1].split("}", 1)[0] assert "border:" not in model_chip_rule From 85e28d0799b3aa4c9573b22606dce7a44a4b0a3d Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:52:58 +0200 Subject: [PATCH 14/18] Honor GitHub device-flow polling intervals Update the OAuth settings poller to wait for the provider interval, carry slow_down interval updates forward, and respect provider expiration times so GitHub Copilot device-code auth can complete after browser authorization. Dispatch the legacy device-login endpoint by provider_id and add regressions plus DOX guidance for provider-aware device polling. --- plugins/_oauth/AGENTS.md | 1 + plugins/_oauth/api/start_device_login.py | 26 ++++++++++- plugins/_oauth/webui/oauth-config-store.js | 42 ++++++++++++++--- tests/test_oauth_providers.py | 52 +++++++++++++++++++++- tests/test_oauth_static.py | 15 +++++++ 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/plugins/_oauth/AGENTS.md b/plugins/_oauth/AGENTS.md index 02f807262..1bbf87f9e 100644 --- a/plugins/_oauth/AGENTS.md +++ b/plugins/_oauth/AGENTS.md @@ -26,6 +26,7 @@ - Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs. - OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state. - OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers. +- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down. - OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`. - `helpers/providers/registry.py` is the source of truth for connectable OAuth providers. - OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected. diff --git a/plugins/_oauth/api/start_device_login.py b/plugins/_oauth/api/start_device_login.py index 04c9e4ab9..c93fb199b 100644 --- a/plugins/_oauth/api/start_device_login.py +++ b/plugins/_oauth/api/start_device_login.py @@ -6,4 +6,28 @@ from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider class StartDeviceLogin(ApiHandler): async def process(self, input: dict, request: Request) -> dict: - return get_provider(CODEX_PROVIDER_ID).start_login(input, request).to_dict() + raw_provider_id = _provider_id(input) + try: + return get_provider(raw_provider_id).start_login(input, request).to_dict() + except Exception as exc: + return { + "ok": False, + "provider_id": _provider_id_label(raw_provider_id), + "error": str(exc), + } + + +def _provider_id(input: dict) -> object: + if "provider_id" not in input or input.get("provider_id") is None: + return CODEX_PROVIDER_ID + value = input.get("provider_id") + if isinstance(value, str) and not value.strip(): + return CODEX_PROVIDER_ID + return value + + +def _provider_id_label(value: object) -> str: + if value is None: + return CODEX_PROVIDER_ID + text = str(value).strip() + return text or CODEX_PROVIDER_ID diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js index 9b5279d54..6e2b2201d 100644 --- a/plugins/_oauth/webui/oauth-config-store.js +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -758,9 +758,23 @@ export const store = createStore("oauthConfig", { startPolling(providerId = CODEX_PROVIDER) { this.stopPolling(providerId); this.pollStartedAt = { ...this.pollStartedAt, [providerId]: Date.now() }; + const clearTimer = () => { + if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]); + const timers = { ...this.pollTimers }; + delete timers[providerId]; + this.pollTimers = timers; + if (providerId === CODEX_PROVIDER) this.pollTimer = null; + }; + const schedule = (delayMs) => { + clearTimer(); + this.pollTimers = { ...this.pollTimers, [providerId]: window.setTimeout(tick, delayMs) }; + if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId]; + }; const tick = async () => { + clearTimer(); const device = this.devices[providerId]; if (!device?.attempt_id) return; + let nextDelay = Math.max(1500, Number(device.interval || 5) * 1000); try { const response = await callJsonApi(POLL_LOGIN_API, { provider_id: providerId, @@ -782,6 +796,16 @@ export const store = createStore("oauthConfig", { void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections"); return; } + if (response.interval || response.expires_at) { + const updatedDevice = { + ...device, + interval: response.interval || device.interval, + expires_at: response.expires_at || device.expires_at, + }; + this.devices = { ...this.devices, [providerId]: updatedDevice }; + if (providerId === CODEX_PROVIDER) this.device = updatedDevice; + nextDelay = Math.max(1500, Number(updatedDevice.interval || 5) * 1000); + } } catch (error) { if (this.connectingProvider === providerId) this.connectingProvider = ""; this.connecting = Boolean(this.connectingProvider); @@ -789,17 +813,21 @@ export const store = createStore("oauthConfig", { void toastFrontendError(messageOf(error), "OAuth Connections"); return; } - if (Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS) { + const expiresAt = Number(this.devices[providerId]?.expires_at || 0); + const timedOut = expiresAt > 0 + ? Date.now() / 1000 > expiresAt + : Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS; + if (timedOut) { if (this.connectingProvider === providerId) this.connectingProvider = ""; this.connecting = Boolean(this.connectingProvider); this.clearProviderDevice(providerId); this.stopPolling(providerId); + return; } + schedule(nextDelay); }; - const delay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000); - this.pollTimers = { ...this.pollTimers, [providerId]: window.setInterval(tick, delay) }; - if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId]; - void tick(); + const initialDelay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000); + schedule(initialDelay); }, pollProvider(providerId = CODEX_PROVIDER) { @@ -834,7 +862,7 @@ export const store = createStore("oauthConfig", { stopPolling(providerId = "") { if (providerId) { - if (this.pollTimers[providerId]) window.clearInterval(this.pollTimers[providerId]); + if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]); const timers = { ...this.pollTimers }; delete timers[providerId]; this.pollTimers = timers; @@ -846,7 +874,7 @@ export const store = createStore("oauthConfig", { } for (const timer of Object.values(this.pollTimers || {})) { - if (timer) window.clearInterval(timer); + if (timer) window.clearTimeout(timer); } this.pollTimers = {}; this.pollStartedAt = {}; diff --git a/tests/test_oauth_providers.py b/tests/test_oauth_providers.py index baf1da369..155c2bcba 100644 --- a/tests/test_oauth_providers.py +++ b/tests/test_oauth_providers.py @@ -540,7 +540,7 @@ def test_manual_callback_dispatches_to_xai_provider_without_active_attempt(): assert "no active xai grok sign-in attempt" in response["error"].lower() -def test_start_device_login_wrapper_calls_codex_provider(monkeypatch): +def test_start_device_login_wrapper_defaults_to_codex_provider(monkeypatch): calls = [] class FakeProvider: @@ -576,6 +576,56 @@ def test_start_device_login_wrapper_calls_codex_provider(monkeypatch): assert response["attempt_id"] == "attempt-1" +def test_start_device_login_with_provider_id_calls_github_provider(monkeypatch): + calls = [] + + class FakeProvider: + def start_login(self, input, request): + calls.append((input, request)) + return LoginStartResult( + ok=True, + provider_id=GITHUB_COPILOT_PROVIDER_ID, + flow="device_code", + attempt_id="github-attempt-1", + verification_url="https://github.com/login/device", + user_code="1234-5678", + ) + + monkeypatch.setattr( + start_device_login_api, + "get_provider", + lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(), + ) + + request = FakeRequest() + payload = {"provider_id": GITHUB_COPILOT_PROVIDER_ID, "enterprise_domain": ""} + response = asyncio.run( + start_device_login_api.StartDeviceLogin(None, None).process(payload, request) + ) + + assert calls[0] == ("provider_id", GITHUB_COPILOT_PROVIDER_ID) + assert calls[1] == (payload, request) + assert response["ok"] is True + assert response["provider_id"] == GITHUB_COPILOT_PROVIDER_ID + assert response["flow"] == "device_code" + assert response["attempt_id"] == "github-attempt-1" + assert response["verification_url"] == "https://github.com/login/device" + assert response["user_code"] == "1234-5678" + + +def test_start_device_login_unknown_provider_returns_structured_error(): + response = asyncio.run( + start_device_login_api.StartDeviceLogin(None, None).process( + {"provider_id": "missing"}, + FakeRequest(), + ) + ) + + assert response["ok"] is False + assert response["provider_id"] == "missing" + assert "Unknown OAuth provider" in response["error"] + + def test_poll_device_login_wrapper_calls_codex_provider(monkeypatch): calls = [] diff --git a/tests/test_oauth_static.py b/tests/test_oauth_static.py index fe9eb0b7d..155681e68 100644 --- a/tests/test_oauth_static.py +++ b/tests/test_oauth_static.py @@ -126,6 +126,21 @@ def test_browser_callback_completion_is_observed_from_modal(): assert "this.providerConnected(providerId)" in store_js +def test_device_polling_honors_provider_interval_updates(): + store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8") + + start_polling = store_js.split("startPolling(providerId = CODEX_PROVIDER)", 1)[1].split( + "pollProvider(providerId = CODEX_PROVIDER)", + 1, + )[0] + assert "window.setTimeout(tick, delayMs)" in start_polling + assert "window.setInterval(tick" not in start_polling + assert "void tick();" not in start_polling + assert "interval: response.interval || device.interval" in start_polling + assert "expires_at: response.expires_at || device.expires_at" in start_polling + assert "Date.now() / 1000 > expiresAt" in start_polling + + def test_usage_plan_catalog_stays_backend_only_on_oauth_settings_page(): config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8") store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8") From ca4efe6e6ac27905482f2995264c0bd5c4305832 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:52:49 +0200 Subject: [PATCH 15/18] Fix Tailscale Remote Control CSRF origins Normalize active Remote Control URLs to same-origin values before adding them to CSRF allowlists, so Tailscale Funnel URLs with paths or trailing slashes can bootstrap tokens correctly. Allow WebSocket origin validation to trust only the currently active Remote Control origin, including Docker split-process tunnel service URLs, while preserving rejection for unrelated external origins. Add focused regression coverage for active Tailscale-style origins, tunnel-service origin lookup, and negative cross-origin cases; keep run_ui decorator re-exports compatible with existing CSRF tests. --- api/csrf_token.py | 14 ++- helpers/tunnel_origins.py | 107 +++++++++++++++++++++ helpers/ws.py | 8 +- run_ui.py | 1 + tests/test_csrf_tunnel_origins.py | 153 ++++++++++++++++++++++++++++++ tests/test_ws_csrf.py | 46 +++++++++ 6 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 helpers/tunnel_origins.py create mode 100644 tests/test_csrf_tunnel_origins.py diff --git a/api/csrf_token.py b/api/csrf_token.py index 5f4ba13d5..6cbd355c0 100644 --- a/api/csrf_token.py +++ b/api/csrf_token.py @@ -1,5 +1,4 @@ import secrets -from urllib.parse import urlparse from helpers.api import ( ApiHandler, Input, @@ -9,6 +8,7 @@ from helpers.api import ( session, ) from helpers import runtime, dotenv, login +from helpers.tunnel_origins import origin_from_url import fnmatch ALLOWED_ORIGINS_KEY = "ALLOWED_ORIGINS" @@ -82,11 +82,7 @@ class GetCsrfToken(ApiHandler): ) if not r: return None - # parse and normalize - p = urlparse(r) - if not p.scheme or not p.hostname: - return None - return f"{p.scheme}://{p.hostname}" + (f":{p.port}" if p.port else "") + return origin_from_url(r) async def get_allowed_origins(self) -> list[str]: # get the allowed origins from the environment @@ -107,8 +103,10 @@ class GetCsrfToken(ApiHandler): from api.tunnel_proxy import process as tunnel_api_process tunnel = await tunnel_api_process({"action": "get"}) - if tunnel and isinstance(tunnel, dict) and tunnel["success"]: - allowed_origins.append(tunnel["tunnel_url"]) + if tunnel and isinstance(tunnel, dict) and tunnel.get("success"): + tunnel_origin = origin_from_url(tunnel.get("tunnel_url")) + if tunnel_origin: + allowed_origins.append(tunnel_origin) except Exception: pass diff --git a/helpers/tunnel_origins.py b/helpers/tunnel_origins.py new file mode 100644 index 000000000..c24d8e997 --- /dev/null +++ b/helpers/tunnel_origins.py @@ -0,0 +1,107 @@ +import json +import urllib.request +from urllib.parse import urlparse + + +_DEFAULT_PORTS = { + "http": 80, + "https": 443, + "ws": 80, + "wss": 443, +} + + +def origin_from_url(value): + """Normalize a URL or Origin header to scheme://host[:port].""" + if not isinstance(value, str) or not value.strip(): + return None + parsed = urlparse(value.strip()) + if not parsed.scheme or not parsed.hostname: + return None + + scheme = parsed.scheme.lower() + host = parsed.hostname.lower() + try: + port = parsed.port + except ValueError: + return None + + origin = f"{scheme}://{host}" + if port and port != _DEFAULT_PORTS.get(scheme): + origin += f":{port}" + return origin + + +def origin_key(value): + """Return a comparable same-origin tuple including default ports.""" + origin = origin_from_url(value) + if not origin: + return None + parsed = urlparse(origin) + try: + port = parsed.port or _DEFAULT_PORTS.get(parsed.scheme) + except ValueError: + return None + if not parsed.scheme or not parsed.hostname or port is None: + return None + return parsed.scheme, parsed.hostname.lower(), int(port) + + +def get_active_tunnel_origins(): + """Return normalized origins for currently active Remote Control URLs.""" + origins = [] + + try: + from helpers.tunnel_manager import TunnelManager + + tunnel_url = TunnelManager.get_instance().get_tunnel_url() + _append_origin(origins, tunnel_url) + except Exception: + pass + + try: + _append_origin(origins, _get_tunnel_service_url()) + except Exception: + pass + + return origins + + +def _append_origin(origins, url): + origin = origin_from_url(url) + if origin and origin not in origins: + origins.append(origin) + + +def _get_tunnel_service_url(): + try: + from helpers import dotenv, runtime + + should_query_service = bool( + runtime.is_dockerized() + or runtime.get_arg("tunnel_api_port") + or dotenv.get_dotenv_value("TUNNEL_API_PORT") + ) + if not should_query_service: + return None + + port = runtime.get_tunnel_api_port() + except Exception: + return None + + body = json.dumps({"action": "get"}).encode("utf-8") + request = urllib.request.Request( + f"http://localhost:{port}/", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=0.35) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + except Exception: + return None + + if isinstance(payload, dict) and payload.get("success"): + return payload.get("tunnel_url") + return None diff --git a/helpers/ws.py b/helpers/ws.py index 1150aa8dc..5ca1cc79c 100644 --- a/helpers/ws.py +++ b/helpers/ws.py @@ -13,6 +13,7 @@ from flask import Flask, session, request from helpers import files, cache from helpers.print_style import PrintStyle from helpers.errors import format_error +from helpers.tunnel_origins import get_active_tunnel_origins, origin_key if TYPE_CHECKING: from helpers.ws_manager import WsManager @@ -148,6 +149,11 @@ def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]: if origin_host == host and origin_port == port: return True, None + request_origin_key = (origin_parsed.scheme, origin_host, int(origin_port)) + for active_origin in get_active_tunnel_origins(): + if origin_key(active_origin) == request_origin_key: + return True, None + if origin_host not in {host for host, _ in candidates}: return False, "origin_host_mismatch" return False, "origin_port_mismatch" @@ -648,4 +654,4 @@ def _error_response(code: str, message: str, "ok": False, "error": {"code": code, "error": message}, }], - } \ No newline at end of file + } diff --git a/run_ui.py b/run_ui.py index 4edf94b8b..8346d1b47 100644 --- a/run_ui.py +++ b/run_ui.py @@ -1,5 +1,6 @@ import initialize from helpers import dotenv, extension, runtime +from helpers.api import csrf_protect, requires_auth from helpers.print_style import PrintStyle from helpers.server_startup import run_uvicorn_with_retries from helpers.ui_server import UiServerRuntime, configure_process_environment diff --git a/tests/test_csrf_tunnel_origins.py b/tests/test_csrf_tunnel_origins.py new file mode 100644 index 000000000..b2c0af92d --- /dev/null +++ b/tests/test_csrf_tunnel_origins.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest +from flask import Flask + + +@pytest.mark.asyncio +async def test_csrf_token_allows_normalized_active_tailscale_origin(monkeypatch): + import api.csrf_token as csrf_module + import api.tunnel_proxy as tunnel_proxy + + handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None) + request = SimpleNamespace( + headers={"Origin": "https://agent-zero.tailabc.ts.net"}, + environ={}, + referrer=None, + ) + + monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False) + monkeypatch.setattr( + csrf_module.dotenv, + "get_dotenv_value", + lambda key: "http://localhost:32080" + if key == csrf_module.ALLOWED_ORIGINS_KEY + else "", + ) + + async def fake_tunnel_process(input_data): + return { + "success": True, + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/", + "is_running": True, + } + + monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process) + + origin_check = await handler.check_allowed_origin(request) + + assert origin_check["ok"] is True + assert "https://agent-zero.tailabc.ts.net" in origin_check["allowed_origins"] + + +@pytest.mark.asyncio +async def test_csrf_token_rejects_unrelated_origin_with_active_tunnel(monkeypatch): + import api.csrf_token as csrf_module + import api.tunnel_proxy as tunnel_proxy + + handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None) + request = SimpleNamespace( + headers={"Origin": "https://evil.example"}, + environ={}, + referrer=None, + ) + + monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False) + monkeypatch.setattr( + csrf_module.dotenv, + "get_dotenv_value", + lambda key: "http://localhost:32080" + if key == csrf_module.ALLOWED_ORIGINS_KEY + else "", + ) + + async def fake_tunnel_process(input_data): + return { + "success": True, + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/", + "is_running": True, + } + + monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process) + + origin_check = await handler.check_allowed_origin(request) + + assert origin_check["ok"] is False + + +def test_active_tunnel_origins_include_docker_tunnel_service_url(monkeypatch): + import helpers.tunnel_origins as tunnel_origins + + monkeypatch.setattr( + tunnel_origins, + "_get_tunnel_service_url", + lambda: "https://agent-zero.tailabc.ts.net/funnel-ready/", + ) + + assert ( + "https://agent-zero.tailabc.ts.net" + in tunnel_origins.get_active_tunnel_origins() + ) + + +def test_tunnel_service_url_uses_short_local_get_request(monkeypatch): + from helpers import dotenv, runtime + import helpers.tunnel_origins as tunnel_origins + + captured = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def read(self): + return json.dumps({ + "success": True, + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/", + }).encode("utf-8") + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["body"] = request.data + captured["method"] = request.get_method() + captured["timeout"] = timeout + return FakeResponse() + + monkeypatch.setattr( + runtime, + "is_dockerized", + lambda: True, + ) + monkeypatch.setattr( + runtime, + "get_arg", + lambda name: None, + ) + monkeypatch.setattr( + runtime, + "get_tunnel_api_port", + lambda: 55520, + ) + monkeypatch.setattr( + dotenv, + "get_dotenv_value", + lambda key: "", + ) + monkeypatch.setattr(tunnel_origins.urllib.request, "urlopen", fake_urlopen) + + assert ( + tunnel_origins._get_tunnel_service_url() + == "https://agent-zero.tailabc.ts.net/funnel-ready/" + ) + assert captured == { + "url": "http://localhost:55520/", + "body": b'{"action": "get"}', + "method": "POST", + "timeout": 0.35, + } diff --git a/tests/test_ws_csrf.py b/tests/test_ws_csrf.py index 48e7e8230..a5fb7ba70 100644 --- a/tests/test_ws_csrf.py +++ b/tests/test_ws_csrf.py @@ -49,3 +49,49 @@ def test_validate_ws_origin_rejects_cross_origin(): ) assert ok is False assert reason == "origin_host_mismatch" + + +def test_validate_ws_origin_allows_active_tunnel_origin_with_local_upstream_host( + monkeypatch, +): + import helpers.ws as ws + + monkeypatch.setattr( + ws, + "get_active_tunnel_origins", + lambda: ["https://agent-zero.tailabc.ts.net"], + raising=False, + ) + + ok, reason = validate_ws_origin( + { + "HTTP_ORIGIN": "https://agent-zero.tailabc.ts.net", + "HTTP_HOST": "127.0.0.1:80", + } + ) + + assert ok is True + assert reason is None + + +def test_validate_ws_origin_rejects_unrelated_origin_with_active_tunnel( + monkeypatch, +): + import helpers.ws as ws + + monkeypatch.setattr( + ws, + "get_active_tunnel_origins", + lambda: ["https://agent-zero.tailabc.ts.net"], + raising=False, + ) + + ok, reason = validate_ws_origin( + { + "HTTP_ORIGIN": "https://evil.example", + "HTTP_HOST": "127.0.0.1:80", + } + ) + + assert ok is False + assert reason == "origin_host_mismatch" From 16f724226a10f94e60f8446ed34b6de8598cd522 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:16:39 +0200 Subject: [PATCH 16/18] Fix Editor manual open behavior Open the Editor file browser from the active project context before falling back to the configured workdir. Keep manual Editor launches on the empty start page instead of auto-creating blank Markdown files, while preserving explicit Markdown creation from the empty state. Add static regression coverage for both behaviors. --- plugins/_editor/webui/editor-store.js | 25 +++++++++---------------- tests/test_office_canvas_setup.py | 21 +++++++++++++++++++-- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/plugins/_editor/webui/editor-store.js b/plugins/_editor/webui/editor-store.js index e0e3c3682..c4293320a 100644 --- a/plugins/_editor/webui/editor-store.js +++ b/plugins/_editor/webui/editor-store.js @@ -225,7 +225,6 @@ const model = { _previewEnhanceTimer: null, _staticHighlightPromise: null, _pendingPreviewFragment: "", - _initialCreatePromise: null, async init() { if (this._initialized) return; @@ -244,7 +243,6 @@ const model = { this._mode = options?.mode === "canvas" ? "canvas" : "modal"; if (this._mode === "modal") { this.setupMarkdownModal(element); - await this.ensureInitialMarkdownFile(); } this.scheduleSourceEditorInit(); }, @@ -261,7 +259,6 @@ const model = { }); return; } - await this.ensureInitialMarkdownFile(); }, beforeHostHidden() { @@ -848,24 +845,20 @@ const model = { }); }, - async ensureInitialMarkdownFile() { - if (this.session || this.visibleTabs().length > 0 || this.loading) return null; - if (!this._root || this._initialCreatePromise) return this._initialCreatePromise; - this._initialCreatePromise = this.create("document", "md").finally(() => { - this._initialCreatePromise = null; - }); - return await this._initialCreatePromise; - }, - async openFileBrowser() { let workdirPath = "/a0/usr/workdir"; try { - const response = await callJsonApi("settings_get", null); - workdirPath = response?.settings?.workdir_path || workdirPath; + const home = await callEditor("home"); + if (home?.path) { + workdirPath = home.path; + } else { + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; + } } catch { try { - const home = await callEditor("home"); - workdirPath = home?.path || workdirPath; + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; } catch { // The file browser can still open with the static fallback. } diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index 28935ba67..b4c398a80 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -140,10 +140,13 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state() assert "editor-preview-title" in editor_web_panel assert "editor-preview-page-editor" in editor_web_panel assert "editor-table-wrap" in editor_web_panel + assert "editor-empty" in editor_web_panel + assert "runNewMenuAction('open')" in editor_web_panel + assert "runNewMenuAction('markdown')" in editor_web_panel assert "closeAllFiles" in editor_store assert "confirmPendingClose" in editor_store - assert "ensureInitialMarkdownFile" in editor_store - assert "await this.ensureInitialMarkdownFile();" in editor_store + assert "ensureInitialMarkdownFile" not in editor_store + assert "_initialCreatePromise" not in editor_store assert "startPreviewEdit" in editor_store assert "applyPreviewEdit" in editor_store assert "previewEditDirty" in editor_store @@ -502,6 +505,20 @@ def test_editor_plugin_owns_markdown_sessions_and_active_context_extras(): assert "syncTextEditorResultsIntoOpenEditor" in editor_result_sync +def test_editor_open_file_browser_prefers_context_home_before_workdir_fallback(): + editor_store = read("plugins", "_editor", "webui", "editor-store.js") + start = editor_store.index("async openFileBrowser()") + end = editor_store.index("\n async openPath", start) + open_file_browser = editor_store[start:end] + + home_lookup = open_file_browser.index('const home = await callEditor("home");') + settings_fallback = open_file_browser.index('const response = await callJsonApi("settings_get", null);') + + assert home_lookup < settings_fallback + assert "workdirPath = home.path;" in open_file_browser + assert "workdirPath = response?.settings?.workdir_path || workdirPath;" in open_file_browser + + def test_office_and_desktop_skills_are_rehomed_and_renamed(): office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills" desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills" From 8c6157301926495ed4319776ef03a28505269016 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:21:23 +0200 Subject: [PATCH 17/18] Polish Editor toolbar actions Move the Editor preview/source toggle into the left-side toolbar cluster so mode switching sits with editing controls. Add a dedicated right-side Save button and remove Save from the overflow menu, leaving the menu for rename and close actions. Cover the toolbar placement with a static regression test. --- plugins/_editor/webui/editor-panel.html | 26 ++++++++++++++++--------- tests/test_office_canvas_setup.py | 25 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/plugins/_editor/webui/editor-panel.html b/plugins/_editor/webui/editor-panel.html index 318aed2e5..310a8f7a3 100644 --- a/plugins/_editor/webui/editor-panel.html +++ b/plugins/_editor/webui/editor-panel.html @@ -79,6 +79,16 @@