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/api.py b/helpers/api.py index 9616528b7..351ba4716 100644 --- a/helpers/api.py +++ b/helpers/api.py @@ -1,6 +1,7 @@ from abc import abstractmethod import json import threading +from urllib.parse import urlsplit, unquote from functools import wraps from pathlib import Path from typing import Union, Dict, Any @@ -102,6 +103,44 @@ class ApiHandler: from helpers.network import is_loopback_address +def is_safe_next_url(value: str | None) -> bool: + """Return True when value is a safe same-origin redirect target.""" + if not value: + return False + if "\r" in value or "\n" in value: + return False + # Reject raw backslashes (browsers normalize `/\host` to `//host` -> external). + if "\\" in value: + return False + + # Decode percent-escapes so encoded backslashes (e.g. `%5C`) are caught too. + decoded = unquote(value) + if "\\" in decoded: + return False + + parsed = urlsplit(decoded) + if parsed.scheme or parsed.netloc: + return False + + # Require an absolute path within this origin, but reject protocol-relative URLs. + return parsed.path.startswith("/") and not parsed.path.startswith("//") + + +def get_safe_next_url(value: str | None, fallback: str | None = None) -> str | None: + """Return value if it is a safe next URL, otherwise return a safe fallback.""" + if is_safe_next_url(value): + return value + if is_safe_next_url(fallback): + return fallback + return None + + +def get_current_request_next_url() -> str: + """Return the current request path/query as a safe relative redirect target.""" + next_url = request.full_path if request.query_string else request.path + return get_safe_next_url(next_url, url_for("serve_index")) or url_for("serve_index") + + def requires_api_key(f): @wraps(f) async def decorated(*args, **kwargs): @@ -142,7 +181,7 @@ def requires_auth(f): if not user_pass_hash: return await f(*args, **kwargs) if session.get("authentication") != user_pass_hash: - return redirect(url_for("login_handler")) + return redirect(url_for("login_handler", next=get_current_request_next_url())) return await f(*args, **kwargs) return decorated diff --git a/helpers/file_browser.py b/helpers/file_browser.py index 4b3ac2746..a4a7e936f 100644 --- a/helpers/file_browser.py +++ b/helpers/file_browser.py @@ -313,6 +313,10 @@ class FileBrowser: full_path = (self.base_dir / current_path).resolve() if not str(full_path).startswith(str(self.base_dir)): raise ValueError("Invalid path") + if not full_path.exists(): + raise FileNotFoundError("Directory not found") + if not full_path.is_dir(): + raise NotADirectoryError("Path is not a directory") # Use ls command instead of os.scandir for better error handling files, folders = self._get_files_via_ls(full_path) @@ -342,7 +346,12 @@ class FileBrowser: except Exception as e: PrintStyle.error(f"Error reading directory: {e}") - return {"entries": [], "current_path": "", "parent_path": ""} + return { + "entries": [], + "current_path": current_path, + "parent_path": "", + "error": str(e), + } def get_full_path(self, file_path: str, allow_dir: bool = False) -> str: """Get full file path if it exists and is within base_dir""" diff --git a/helpers/settings.py b/helpers/settings.py index e9b707f5e..88eea749e 100644 --- a/helpers/settings.py +++ b/helpers/settings.py @@ -66,6 +66,7 @@ class Settings(TypedDict): workdir_max_folders: int workdir_max_lines: int workdir_gitignore: str + file_browser_remember_last_directory: bool api_keys: dict[str, str] @@ -506,6 +507,10 @@ def get_default_settings() -> Settings: workdir_max_folders=get_default_value("workdir_max_folders", 20), workdir_max_lines=get_default_value("workdir_max_lines", 250), workdir_gitignore=get_default_value("workdir_gitignore", gitignore), + file_browser_remember_last_directory=get_default_value( + "file_browser_remember_last_directory", + True, + ), rfc_auto_docker=get_default_value("rfc_auto_docker", True), rfc_url=get_default_value("rfc_url", "localhost"), rfc_password="", 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/ui_server.py b/helpers/ui_server.py index 0756c6e0d..6cf9b7bb9 100644 --- a/helpers/ui_server.py +++ b/helpers/ui_server.py @@ -26,7 +26,7 @@ from werkzeug.wrappers.request import Request as WerkzeugRequest import socketio # type: ignore[import-untyped] from helpers import dotenv, fasta2a_server, files, git, login, mcp_server, runtime -from helpers.api import register_api_route, requires_auth +from helpers.api import get_safe_next_url, register_api_route, requires_auth from helpers.extension import extensible from helpers.files import get_abs_path from helpers.print_style import PrintStyle @@ -200,19 +200,25 @@ class UiRouteHandlers: @extensible async def login_handler(self): error = None + fallback_url = url_for("serve_index") + next_url = get_safe_next_url( + request.form.get("next") if request.method == "POST" else request.args.get("next"), + fallback_url, + ) + if request.method == "POST": user = dotenv.get_dotenv_value("AUTH_LOGIN") password = dotenv.get_dotenv_value("AUTH_PASSWORD") if request.form["username"] == user and request.form["password"] == password: session["authentication"] = login.get_credentials_hash() - return redirect(url_for("serve_index")) + return redirect(next_url or fallback_url) else: await asyncio.sleep(1) error = "Invalid Credentials. Please try again." login_page_content = files.read_file("webui/login.html") - return render_template_string(login_page_content, error=error) + return render_template_string(login_page_content, error=error, next=next_url) @extensible async def logout_handler(self): 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/plugins/AGENTS.md b/plugins/AGENTS.md index 2a60a0c5d..92522fa0b 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -30,6 +30,7 @@ - `hooks.py` runs in the framework runtime. Explicitly target another runtime if a plugin must prepare the agent execution environment. - `execute.py` is manual user-triggered setup, maintenance, repair, migration, or refresh work; automatic framework behavior belongs in hooks or lifecycle extensions. - Plugin routes are `GET /plugins//`, `POST /api/plugins//`, and `POST /api/plugins` for management actions. +- `_a0_connector` WebSocket history replay must stay bounded: emit large chat history as paged `connector_context_snapshot` payloads, keep `last_sequence` as the Agent Zero log-output cursor, and avoid sending an entire long transcript in one frame. - Frontend plugin HTML extensions live under `extensions/webui//`, include a root Alpine scope, and use `x-move-*` directives when targeting static breakpoints. - Frontend plugin JS extensions live under `extensions/webui//` and export a default function. - Plugin UI must use the A0 notification system for errors, warnings, success, and info instead of inline success/error boxes. @@ -48,6 +49,7 @@ - Keep plugin README and docs current when user-visible plugin behavior changes. - Check configuration before injecting setup or discovery banners so configured plugins do not keep advertising setup. - Use highly unique banner IDs prefixed by plugin name. +- Browser tool prompts must preserve the existing-tab workflow: when a user refers to an already-open URL, tab, or page title, guide agents to `list` and then `set_active` or `navigate` by `browser_id` instead of blindly opening a new tab. - When preparing community plugins, keep plugin contents at the standalone repository root with `plugin.yaml`, `README.md`, and a root `LICENSE`. - Plugin Index submissions use a separate `index.yaml` under `a0-plugins/plugins//`; do not confuse it with runtime `plugin.yaml`. diff --git a/plugins/_a0_connector/api/ws_connector.py b/plugins/_a0_connector/api/ws_connector.py index 2f15c671f..0152eeb4c 100644 --- a/plugins/_a0_connector/api/ws_connector.py +++ b/plugins/_a0_connector/api/ws_connector.py @@ -62,6 +62,9 @@ WS_FEATURES = [ "connector_browser_op", ] +_SNAPSHOT_REPLAY_PAGE_SIZE = 50 +_LIVE_STREAM_PAGE_SIZE = 100 + class WsConnector(WsHandler): _streaming_tasks: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {} @@ -253,19 +256,25 @@ class WsConnector(WsHandler): ) subscribe_sid_to_context(sid, context_id) - events, last_sequence = get_context_log_entries(context_id, after=from_sequence) - await self.emit_to( + events, last_sequence = get_context_log_entries( + context_id, + after=from_sequence, + limit=_SNAPSHOT_REPLAY_PAGE_SIZE, + ) + await self._emit_context_snapshot( sid, - "connector_context_snapshot", - { - "context_id": context_id, - "events": events, - "last_sequence": last_sequence, - "message_queue": self._queue_items_for_context(context), - }, + context_id=context_id, + events=events, + last_sequence=last_sequence, + context=context, correlation_id=data.get("correlationId"), ) - self._start_streaming(sid, context_id, from_sequence=last_sequence) + self._start_streaming( + sid, + context_id, + from_sequence=last_sequence, + replay_history=True, + ) return { "context_id": context_id, @@ -349,19 +358,25 @@ class WsConnector(WsHandler): if context_id not in subscribed_contexts_for_sid(sid): subscribe_sid_to_context(sid, context_id) - events, last_sequence = get_context_log_entries(context_id, after=0) - await self.emit_to( + events, last_sequence = get_context_log_entries( + context_id, + after=0, + limit=_SNAPSHOT_REPLAY_PAGE_SIZE, + ) + await self._emit_context_snapshot( sid, - "connector_context_snapshot", - { - "context_id": context_id, - "events": events, - "last_sequence": last_sequence, - "message_queue": self._queue_items_for_context(context), - }, + context_id=context_id, + events=events, + last_sequence=last_sequence, + context=context, correlation_id=data.get("correlationId"), ) - self._start_streaming(sid, context_id, from_sequence=last_sequence) + self._start_streaming( + sid, + context_id, + from_sequence=last_sequence, + replay_history=True, + ) message_id = client_message_id or data.get("correlationId") or "" context.log.log( @@ -865,14 +880,48 @@ class WsConnector(WsHandler): f"[a0-connector] failed to emit connector_context_complete to {target_sid}: {exc}" ) - def _start_streaming(self, sid: str, context_id: str, *, from_sequence: int) -> None: + async def _emit_context_snapshot( + self, + sid: str, + *, + context_id: str, + events: list[dict[str, Any]], + last_sequence: int, + context: AgentContext | None = None, + correlation_id: str | None = None, + ) -> None: + await self.emit_to( + sid, + "connector_context_snapshot", + { + "context_id": context_id, + "events": events, + "last_sequence": last_sequence, + "message_queue": self._queue_items_for_context(context), + }, + correlation_id=correlation_id, + ) + + def _start_streaming( + self, + sid: str, + context_id: str, + *, + from_sequence: int, + replay_history: bool = False, + ) -> None: key = (sid, context_id) task = self._streaming_tasks.get(key) if task is not None and not task.done(): return task = asyncio.create_task( - self._stream_events(sid, context_id, from_sequence=from_sequence) + self._stream_events( + sid, + context_id, + from_sequence=from_sequence, + replay_history=replay_history, + ) ) self._streaming_tasks[key] = task @@ -887,14 +936,26 @@ class WsConnector(WsHandler): context_id: str, *, from_sequence: int, + replay_history: bool = False, ) -> None: # `from_sequence` is a log-output cursor (not an event sequence number). cursor = max(int(from_sequence or 0), 0) last_queue_signature, _ = self._queue_state_for_context_id(context_id) was_running = self._context_is_running(context_id) try: + if replay_history: + cursor = await self._replay_history_snapshots( + sid, + context_id, + from_sequence=cursor, + ) + while context_id in subscribed_contexts_for_sid(sid): - events, next_cursor = get_context_log_entries(context_id, after=cursor) + events, next_cursor = get_context_log_entries( + context_id, + after=cursor, + limit=_LIVE_STREAM_PAGE_SIZE, + ) for event in events: await self.emit_to(sid, "connector_context_event", event) cursor = max(cursor, int(next_cursor or cursor)) @@ -920,7 +981,7 @@ class WsConnector(WsHandler): }, ) was_running = is_running - await asyncio.sleep(0.5) + await asyncio.sleep(0 if events else 0.5) except asyncio.CancelledError: raise except Exception as exc: @@ -929,3 +990,41 @@ class WsConnector(WsHandler): ) finally: self._streaming_tasks.pop((sid, context_id), None) + + async def _replay_history_snapshots( + self, + sid: str, + context_id: str, + *, + from_sequence: int, + ) -> int: + cursor = max(int(from_sequence or 0), 0) + + while context_id in subscribed_contexts_for_sid(sid): + events, next_cursor = get_context_log_entries( + context_id, + after=cursor, + limit=_SNAPSHOT_REPLAY_PAGE_SIZE, + ) + next_cursor = max(cursor, int(next_cursor or cursor)) + if not events: + return next_cursor + + _, queue_items = self._queue_state_for_context_id(context_id) + await self.emit_to( + sid, + "connector_context_snapshot", + { + "context_id": context_id, + "events": events, + "last_sequence": next_cursor, + "message_queue": queue_items, + }, + ) + + if next_cursor == cursor: + return cursor + len(events) + cursor = next_cursor + await asyncio.sleep(0) + + return cursor diff --git a/plugins/_a0_connector/helpers/event_bridge.py b/plugins/_a0_connector/helpers/event_bridge.py index 9a35e6729..c453a6e78 100644 --- a/plugins/_a0_connector/helpers/event_bridge.py +++ b/plugins/_a0_connector/helpers/event_bridge.py @@ -79,6 +79,7 @@ def log_entry_to_connector_event( def get_context_log_entries( context_id: str, after: int = 0, + limit: int | None = None, ) -> tuple[list[dict[str, Any]], int]: """Return connector events plus the next log cursor for the context.""" try: @@ -88,7 +89,20 @@ def get_context_log_entries( if context is None: return [], 0 - log_output = context.log.output(start=max(int(after or 0), 0)) + start = max(int(after or 0), 0) + end: int | None = None + if limit is not None: + limit = max(int(limit or 0), 0) + if limit > 0: + log_lock = getattr(context.log, "_lock", None) + log_updates = getattr(context.log, "updates", None) + if log_lock is not None and isinstance(log_updates, list): + with log_lock: + end = min(start + limit, len(log_updates)) + else: + end = start + limit + + log_output = context.log.output(start=start, end=end) events = [ log_entry_to_connector_event(entry, context_id) for entry in log_output.items diff --git a/plugins/_browser/prompts/agent.system.tool.browser.md b/plugins/_browser/prompts/agent.system.tool.browser.md index 258a5233a..4ecfd4660 100644 --- a/plugins/_browser/prompts/agent.system.tool.browser.md +++ b/plugins/_browser/prompts/agent.system.tool.browser.md @@ -14,7 +14,8 @@ Actions: `open`, `list`, `state`, `set_active`, `navigate`, `back`, `forward`, ` Common args: `action`, `browser_id`, `url`, `ref`, `target_ref`, `text`, `selector`, `selectors`, `script`, `modifiers`, `keys`, `key`, `include_content`, `focus_popup`, `event_type`, `x`, `y`, `to_x`, `to_y`, `delta_x`, `delta_y`, `button`, `quality`, `full_page`, `path`, `paths`, `value`, `values`, `checked`, `width`, `height`, `calls`. Workflow: -- `open` creates a tab and returns id/state. +- `open` creates a tab and returns id/state. In host-browser mode, if the requested URL is already open, the host may reuse and activate that existing tab. +- If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id` instead of opening a new tab. - `content` returns markdown with refs like `[link 3]`, `[button 6]`, `[input text 8]`. - Interactions use refs from the latest `content` capture. - For same-page controls that are easier to identify structurally, `click`, `type`, `submit`, `type_submit`, `scroll`, `select_option`, `set_checked`, and `upload_file` may use `selector` instead of `ref`; the tool resolves the selector through `content` first. diff --git a/plugins/_desktop/plugin.yaml b/plugins/_desktop/plugin.yaml index f4f239e9a..768ac49c6 100644 --- a/plugins/_desktop/plugin.yaml +++ b/plugins/_desktop/plugin.yaml @@ -2,4 +2,4 @@ name: _desktop title: Desktop description: Owns the Agent Zero Linux Desktop runtime, Xpra/Xfce sessions, and live Desktop surface. version: 1.0.0 -always_enabled: true +always_enabled: false 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/onboarding-success-end/discovery-cards.html b/plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html index d238ef87e..fd68a9dc4 100644 --- a/plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html +++ b/plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html @@ -53,7 +53,7 @@ -