Merge branch 'ready' of https://github.com/agent0ai/agent-zero into ready

This commit is contained in:
frdel 2026-06-08 12:41:57 +02:00
commit c576f67469
57 changed files with 2978 additions and 654 deletions

View file

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

View file

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

View file

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

View file

@ -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="",

107
helpers/tunnel_origins.py Normal file
View file

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

View file

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

View file

@ -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},
}],
}
}

View file

@ -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/<name>/<path>`, `POST /api/plugins/<name>/<handler>`, 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/<point>/`, include a root Alpine scope, and use `x-move-*` directives when targeting static breakpoints.
- Frontend plugin JS extensions live under `extensions/webui/<point>/` 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/<name>/`; do not confuse it with runtime `plugin.yaml`.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -53,7 +53,7 @@
</div>
<template x-for="card in $store.discoveryStore.heroCards.filter(card => card.id !== 'discovery-codex-oauth')" :key="card.id">
<template x-for="card in $store.discoveryStore.heroCards.filter(card => !['discovery-codex-oauth', 'discovery-oauth-accounts'].includes(card.id))" :key="card.id">
<article class="discovery-hero"
role="button"
tabindex="0"

View file

@ -28,7 +28,15 @@
<div class="discovery-hero-content">
<h3 class="discovery-hero-title" x-text="card.title"></h3>
<p class="discovery-hero-desc" x-text="card.description"></p>
<p class="discovery-hero-desc" x-show="card.description" x-text="card.description"></p>
<div class="discovery-account-chips" x-show="(card.account_chips || []).length">
<template x-for="chip in card.account_chips" :key="`${card.id}-${chip.provider_id}`">
<span class="discovery-account-chip" :class="{ connected: chip.connected }">
<span x-text="chip.label"></span>
<small x-show="chip.detail" x-text="chip.detail"></small>
</span>
</template>
</div>
<div class="discovery-usage" x-show="(card.usage_windows || []).length">
<template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
<div class="discovery-usage-window">
@ -54,7 +62,7 @@
</button>
</div>
<div class="discovery-hero-thumb">
<div class="discovery-hero-thumb" x-show="card.thumbnail || card.icon">
<template x-if="card.thumbnail">
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
</template>
@ -124,7 +132,15 @@
<div class="discovery-hero-content">
<h3 class="discovery-hero-title" x-text="card.title"></h3>
<p class="discovery-hero-desc" x-text="card.description"></p>
<p class="discovery-hero-desc" x-show="card.description" x-text="card.description"></p>
<div class="discovery-account-chips" x-show="(card.account_chips || []).length">
<template x-for="chip in card.account_chips" :key="`${card.id}-${chip.provider_id}`">
<span class="discovery-account-chip" :class="{ connected: chip.connected }">
<span x-text="chip.label"></span>
<small x-show="chip.detail" x-text="chip.detail"></small>
</span>
</template>
</div>
<div class="discovery-usage" x-show="(card.usage_windows || []).length">
<template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
<div class="discovery-usage-window">
@ -150,7 +166,7 @@
</button>
</div>
<div class="discovery-hero-thumb">
<div class="discovery-hero-thumb" x-show="card.thumbnail || card.icon">
<template x-if="card.thumbnail">
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
</template>
@ -243,7 +259,7 @@
}
.discovery-hero-title {
margin: 0 0 0.4rem;
margin: 0 0 var(--spacing-sm);
color: var(--color-text);
font-size: 1.08rem;
font-weight: 600;
@ -266,6 +282,45 @@
margin: -0.15rem 0 1rem;
}
.discovery-account-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
max-width: 560px;
margin: -0.25rem 0 0.95rem;
}
.discovery-account-chip {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 0.4rem;
padding: 0.28rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 28%, transparent);
color: var(--color-text);
font-size: 0.72rem;
font-weight: 750;
line-height: 1.15;
}
.discovery-account-chip.connected {
border-color: color-mix(in srgb, #35d07f 44%, var(--color-border));
background: color-mix(in srgb, #35d07f 13%, transparent);
}
.discovery-account-chip small {
overflow: hidden;
max-width: 18ch;
color: var(--color-secondary);
font-size: 0.68rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.discovery-usage-window {
display: grid;
min-width: 0;

View file

@ -8,7 +8,8 @@ timeouts and thread-safe parsers.
- **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
- **Adaptive OCR** - large text-rich PDFs skip OCR automatically to avoid pathological parse times
- **Adaptive OCR** - long PDFs skip OCR automatically to avoid pathological parse times
- **Adaptive indexing** - very large extracted documents increase chunk size to keep embedding work bounded
- **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
- **Configurable timeouts** - per-document and gather-level timeouts
- **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
@ -28,10 +29,11 @@ See default_config.yaml for all options. Key settings:
| context_intro_chunks | 2 | Leading chunks included per document for title/abstract grounding |
| chunk_size | 1000 | Text splitter chunk size |
| chunk_overlap | 100 | Text splitter overlap |
| max_index_chunks | 1200 | Maximum indexed chunks before adaptive chunk sizing, or 0 for no cap |
| search_threshold | 0.5 | Similarity search threshold |
| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
| liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job |
| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for text-rich PDFs at or above this effective page count |
| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for PDFs at or above this effective page count |
| thread_offload | true | Offload sync parsers to thread pool |
LiteParse is installed into the Agent Zero framework runtime from hooks.py during

View file

@ -13,6 +13,7 @@ parser_concurrency: 1 # max parser jobs running across all chats in this
context_intro_chunks: 2 # always include leading chunks per document for title/abstract grounding
chunk_size: 1000
chunk_overlap: 100
max_index_chunks: 1200 # adapt chunk size above this many indexed chunks
search_threshold: 0.5
search_limit: 100
max_remote_bytes: 52428800 # 50 MB
@ -29,9 +30,8 @@ liteparse_dpi: 150
liteparse_preserve_very_small_text: false
liteparse_output_format: text
liteparse_num_workers: 2 # balanced default for OCR speed without overloading shared Web UI runtime
liteparse_ocr_auto_disable: true # disable OCR automatically for large text-rich PDFs
liteparse_ocr_auto_disable: true # disable OCR automatically for long PDFs
liteparse_ocr_auto_disable_pages: 30 # OCR-on runtime climbs sharply around this page count
liteparse_ocr_auto_min_chars_per_page: 80
liteparse_ocr_auto_sample_pages: 5
pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF
thread_offload: true # offload sync parsers to thread pool

View file

@ -40,6 +40,14 @@ def _positive_int(value: Any, default: int) -> int:
return parsed if parsed > 0 else default
def _nonnegative_int(value: Any, default: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return parsed if parsed >= 0 else default
def _parser_semaphore(config: dict) -> asyncio.Semaphore:
concurrency = _positive_int(
config.get("parser_concurrency"),
@ -64,14 +72,30 @@ def _load_config(agent: Agent) -> dict:
class DocumentQueryStore:
"""FAISS Store for document query results."""
CONTEXT_DATA_KEY = "_document_query_store"
DEFAULT_CHUNK_SIZE = 1000
DEFAULT_CHUNK_OVERLAP = 100
DEFAULT_MAX_INDEX_CHUNKS = 1200
_GET_LOCK = threading.RLock()
@staticmethod
def get(agent: Agent):
@classmethod
def get(cls, agent: Agent):
if not agent or not agent.config:
raise ValueError("Agent and agent config must be provided")
return DocumentQueryStore(agent)
context = getattr(agent, "context", None)
if context is None:
return cls(agent)
with cls._GET_LOCK:
store = context.get_data(cls.CONTEXT_DATA_KEY, recursive=False)
if not isinstance(store, cls):
store = cls(agent)
context.set_data(cls.CONTEXT_DATA_KEY, store, recursive=False)
else:
store.agent = agent
store.config = _load_config(agent)
return store
def __init__(self, agent: Agent):
self.agent = agent
@ -103,12 +127,7 @@ class DocumentQueryStore:
doc_metadata = metadata or {}
doc_metadata["document_uri"] = document_uri
doc_metadata["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
chunk_size = self.config.get("chunk_size", self.DEFAULT_CHUNK_SIZE)
chunk_overlap = self.config.get("chunk_overlap", self.DEFAULT_CHUNK_OVERLAP)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size, chunk_overlap=chunk_overlap
)
chunks = text_splitter.split_text(text)
chunks = self._split_text_for_index(text)
docs = []
for i, chunk in enumerate(chunks):
chunk_metadata = doc_metadata.copy()
@ -129,6 +148,51 @@ class DocumentQueryStore:
PrintStyle.error(f"Error adding document '{document_uri}': {err_text}")
return False, []
def _split_text_for_index(self, text: str) -> list[str]:
chunk_size = _positive_int(
self.config.get("chunk_size"),
self.DEFAULT_CHUNK_SIZE,
)
chunk_overlap = min(
_nonnegative_int(
self.config.get("chunk_overlap"),
self.DEFAULT_CHUNK_OVERLAP,
),
max(0, chunk_size - 1),
)
chunks = self._split_text(text, chunk_size, chunk_overlap)
max_chunks = _nonnegative_int(
self.config.get("max_index_chunks"),
self.DEFAULT_MAX_INDEX_CHUNKS,
)
if not max_chunks or len(chunks) <= max_chunks:
return chunks
overlap_ratio = chunk_overlap / chunk_size if chunk_size else 0
overlap_ratio = max(0, min(overlap_ratio, 0.5))
target_size = max(
chunk_size + 1,
int(len(text) / max(1, max_chunks * (1 - overlap_ratio))) + 1,
)
for _ in range(8):
target_overlap = min(int(target_size * overlap_ratio), target_size - 1)
chunks = self._split_text(text, target_size, target_overlap)
if len(chunks) <= max_chunks or target_size >= len(text):
return chunks
target_size = min(len(text), int(target_size * 1.25) + 1)
return chunks
@staticmethod
def _split_text(text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
return text_splitter.split_text(text)
async def get_document(self, document_uri: str) -> Optional[Document]:
if not self.vector_db:
return None

View file

@ -14,7 +14,6 @@ from .base import BaseParser
DEFAULT_OCR_AUTO_DISABLE_PAGES = 30
DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE = 80
DEFAULT_OCR_AUTO_SAMPLE_PAGES = 5
@ -182,11 +181,7 @@ class LiteParseParser(BaseParser):
if effective_pages < auto_disable_pages:
return False
min_chars_per_page = _positive_int(
config.get("liteparse_ocr_auto_min_chars_per_page"),
DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE,
)
return (profile.text_chars / profile.sampled_pages) >= min_chars_per_page
return True
def _pdf_text_profile(file_path: str, config: dict) -> _PdfTextProfile | None:

View file

@ -15,7 +15,7 @@ from helpers.print_style import PrintStyle
_LOCK = threading.Lock()
_CHECKED = False
_PLUGIN_DIR = Path(__file__).resolve().parent
_REQUIREMENTS_FILE = _PLUGIN_DIR / "requirements.txt"
_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
def has_liteparse() -> bool:
@ -66,9 +66,10 @@ def _install_requirements() -> None:
raise RuntimeError(
"Document Query plugin requires 'uv' to install liteparse automatically"
)
if not _REQUIREMENTS_FILE.is_file():
requirement = _liteparse_requirement()
if not requirement:
raise RuntimeError(
f"Document Query requirements file not found: {_REQUIREMENTS_FILE}"
f"Document Query LiteParse requirement not found in {_ROOT_REQUIREMENTS_FILE}"
)
cmd = [
@ -77,9 +78,18 @@ def _install_requirements() -> None:
"install",
"--python",
sys.executable,
"-r",
str(_REQUIREMENTS_FILE),
requirement,
]
PrintStyle.info("Document Query: liteparse not found, installing plugin dependency")
subprocess.check_call(cmd, cwd=str(_PLUGIN_DIR))
def _liteparse_requirement() -> str:
if not _ROOT_REQUIREMENTS_FILE.is_file():
return ""
for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
requirement = line.strip()
if requirement.startswith("liteparse"):
return requirement
return ""

View file

@ -15,6 +15,7 @@
context_intro_chunks: 2,
chunk_size: 1000,
chunk_overlap: 100,
max_index_chunks: 1200,
search_threshold: 0.5,
search_limit: 100,
max_remote_bytes: 52428800,
@ -40,6 +41,7 @@
this.ensureInt('context_intro_chunks', 2, 0);
this.ensureInt('chunk_size', 1000, 100);
this.ensureInt('chunk_overlap', 100, 0);
this.ensureInt('max_index_chunks', 1200, 0);
this.ensureInt('search_limit', 100, 1);
this.ensureInt('max_remote_bytes', 52428800, 1);
this.ensureNumber('search_threshold', 0.5, 0, 1);
@ -172,6 +174,20 @@
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Max index chunks</div>
<div class="field-description">
Adapt chunk size when a parsed document would exceed this many indexed chunks. Use 0 for no cap.
</div>
</div>
<div class="field-control">
<input type="number" min="0" step="50"
@change="ensureInt('max_index_chunks', 1200, 0)"
x-model.number="config.max_index_chunks" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Search limit</div>

View file

@ -2,4 +2,4 @@ name: _editor
title: Editor
description: Native Markdown editor surface for Agent Zero canvas and floating modal workflows.
version: 1.0.0
always_enabled: true
always_enabled: false

View file

@ -79,6 +79,16 @@
<div class="editor-toolbar" x-show="$store.editor.session" style="display: none;">
<div class="editor-toolbar-row">
<button
type="button"
class="editor-icon-button editor-mode-toggle"
:title="$store.editor.viewModeTitle()"
:aria-label="$store.editor.viewModeTitle()"
@click="$store.editor.toggleViewMode()"
>
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
</button>
<div class="editor-tool-group editor-source-tools" x-show="$store.editor.isMarkdown() && $store.editor.isSourceMode()" style="display: none;">
<button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
<span class="material-symbols-outlined">undo</span>
@ -127,12 +137,14 @@
<button
type="button"
class="editor-icon-button editor-mode-toggle"
:title="$store.editor.viewModeTitle()"
:aria-label="$store.editor.viewModeTitle()"
@click="$store.editor.toggleViewMode()"
class="editor-icon-button editor-save-button"
:class="{ 'is-primary': $store.editor.dirty || $store.editor.saving }"
:title="$store.editor.saving ? 'Saving' : 'Save'"
:aria-label="$store.editor.saving ? 'Saving' : 'Save'"
:disabled="$store.editor.saving"
@click="$store.editor.save()"
>
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
<span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
</button>
<div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
@ -149,10 +161,6 @@
<span class="material-symbols-outlined">more_vert</span>
</button>
<div class="editor-new-menu editor-file-menu" role="menu" x-show="open" @click.stop>
<button type="button" class="editor-new-menu-item" :class="{ 'is-emphasized': $store.editor.dirty }" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.save()">
<span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
<span>Save</span>
</button>
<button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.renameActiveFile()">
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
<span>Rename</span>

View file

@ -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.
}

View file

@ -13,6 +13,7 @@
- `conf/model_providers.yaml` owns OAuth-backed model provider definitions and their `api_key_mode: oauth` metadata.
- `api/` owns provider-aware settings modal endpoints such as status, login start, polling, manual callback, models, and disconnect.
- `helpers/providers/` owns provider implementations, provider metadata, registry wiring, token storage helpers, and provider-specific endpoint validation.
- `helpers/summary.py` owns the shared provider-status/account summary shape consumed by the status API, discovery cards, onboarding, and OAuth settings UI.
- `helpers/routes.py` owns local OAuth callback and OpenAI-compatible proxy routes mounted by the route bootstrap extension.
- `webui/config.html` and `webui/oauth-config-store.js` own the OAuth Connections settings UI.
- `extensions/python/_functions/models/get_api_key/end/` owns the dummy API-key extension used by OAuth model providers.
@ -23,7 +24,12 @@
- Core model code such as `models.py` must stay provider-agnostic. Do not add Codex, GitHub Copilot, Gemini, xAI, or other OAuth provider knowledge outside plugin-owned config or plugin hooks.
- Add OAuth model providers in `_oauth/conf/model_providers.yaml`, not `_model_config/provider_metadata.yaml`.
- 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.
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
- API handlers should remain provider-aware. Missing or blank `provider_id` defaults to Codex only for existing backward compatibility; falsey non-string IDs must not silently default.
- Codex success contracts must preserve legacy fields such as `account_id` while allowing newer fields such as `account_label`.
@ -41,6 +47,7 @@
- Keep provider policy visible in each provider module; avoid abstracting away endpoint validation or billing/quota caveats.
- Prefer plugin-local imports such as `plugins._oauth.helpers...` for bundled plugin code.
- Keep `_oauth` account providers separate from API-key providers in core configuration.
- Keep the OAuth settings page account-backed only. API-key and local provider setup belongs in model configuration and onboarding.
- Treat Gemini API OAuth as a Google Cloud OAuth-client flow. Do not conflate it with Antigravity, Gemini Code Assist, Gemini CLI, Google AI Pro, or Google AI Ultra subscription quota.
- Treat Claude Code subscription auth and Antigravity product auth as non-connectable unless their vendors provide an explicit third-party provider contract.
- Keep user-facing errors safe: report setup or tier restrictions without exposing tokens, callback secrets, or raw auth payloads.

View file

@ -4,6 +4,12 @@ Generic local OAuth bridge for Agent Zero.
Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only. Do not configure `auth_file_path` to share a rotating refresh-token file with Codex CLI or another client.
The settings UI groups providers as account-backed connections. More than one account provider can be connected at the same time, and the Main/Utility model slots can choose models from any connected OAuth provider.
Each model slot has its own provider selector. The selector lists connected OAuth accounts only, so Main and Utility can use different account-backed providers when more than one account is connected.
OAuth-backed model providers do not require users to enter API keys. Agent Zero supplies a local dummy key only at runtime after the selected account provider is connected, so unconnected providers stay blank in API-key surfaces.
## Providers
### Codex/ChatGPT (`codex_oauth`)
@ -37,6 +43,8 @@ Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on t
The status API exposes `usage_plan_catalog` for subscription and billing context. It covers only connectable providers: Codex, GitHub Copilot, Google Gemini API, and xAI Grok.
The same status response also includes `oauth_accounts`, a compact summary used by the settings modal, welcome discovery card, and onboarding wizard. Keep that summary provider-registry driven so new OAuth providers appear consistently across those surfaces.
## Remote xAI Callback
When Agent Zero is running on a remote host, the browser may complete the xAI authorization step somewhere other than the machine serving the local callback route. In that case, paste the callback value into the xAI card.

View file

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

View file

@ -2,31 +2,14 @@ from __future__ import annotations
from helpers.api import ApiHandler, Request
from plugins._oauth.helpers.route_bootstrap import is_installed
from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, provider_registry
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
from plugins._oauth.helpers.providers import provider_registry
from plugins._oauth.helpers.summary import build_oauth_status_summary
class Status(ApiHandler):
async def process(self, input: dict, request: Request) -> dict:
del input, request
providers = [_provider_status(provider) for provider in provider_registry().values()]
provider_map = {provider["provider_id"]: provider for provider in providers}
return {
"ok": True,
"routes_installed": is_installed(),
"providers": providers,
"provider_map": provider_map,
"usage_plan_catalog": usage_plan_catalog(),
"codex": provider_map.get(CODEX_PROVIDER_ID, {}),
}
def _provider_status(provider) -> dict:
try:
return provider.status()
except Exception as exc:
return {
"provider_id": str(getattr(provider, "provider_id", "")),
"connected": False,
"error": str(exc),
}
return build_oauth_status_summary(
provider_registry=provider_registry,
routes_installed=is_installed,
)

View file

@ -7,7 +7,6 @@ chat:
endpoint_url: "/models"
kwargs:
api_base: "http://127.0.0.1/oauth/codex/v1"
api_key: "oauth"
github_copilot_oauth:
name: GitHub Copilot Account
litellm_provider: openai
@ -16,7 +15,6 @@ chat:
endpoint_url: "/models"
kwargs:
api_base: "http://127.0.0.1/oauth/github-copilot/v1"
api_key: "oauth"
gemini_api_oauth:
name: Google Gemini API Account
litellm_provider: openai
@ -25,7 +23,6 @@ chat:
endpoint_url: "/models"
kwargs:
api_base: "http://127.0.0.1/oauth/gemini-api/v1"
api_key: "oauth"
xai_grok_oauth:
name: xAI Grok Account
litellm_provider: openai
@ -34,4 +31,3 @@ chat:
endpoint_url: "/models"
kwargs:
api_base: "http://127.0.0.1/oauth/xai-grok/v1"
api_key: "oauth"

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from helpers.extension import Extension
from plugins._oauth.helpers.providers import DUMMY_API_KEY, oauth_provider_ids
from plugins._oauth.helpers.providers import DUMMY_API_KEY, get_provider, oauth_provider_ids
class OAuthAccountDummyKey(Extension):
@ -22,5 +22,15 @@ class OAuthAccountDummyKey(Extension):
return
result = str(data.get("result") or "").strip()
if not result or result == "None":
if (not result or result == "None") and oauth_provider_is_connected(service):
data["result"] = DUMMY_API_KEY
def oauth_provider_is_connected(provider_id: str) -> bool:
try:
status = get_provider(provider_id).status()
except Exception:
return False
if not isinstance(status, dict):
return False
return bool(status.get("connected"))

View file

@ -0,0 +1,128 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
ProviderRegistryFactory = Callable[[], Mapping[str, Any]]
RoutesInstalledFactory = Callable[[], bool]
def build_oauth_status_summary(
*,
provider_registry: ProviderRegistryFactory,
routes_installed: RoutesInstalledFactory,
) -> dict[str, Any]:
"""Return the shared OAuth account status payload for APIs and discovery cards."""
providers = [_provider_status(provider) for provider in provider_registry().values()]
provider_map = {
provider["provider_id"]: provider
for provider in providers
if provider.get("provider_id")
}
connected = [provider for provider in providers if provider.get("connected")]
available = [provider for provider in providers if not provider.get("connected")]
catalog = usage_plan_catalog()
return {
"ok": True,
"routes_installed": routes_installed(),
"providers": providers,
"provider_map": provider_map,
"usage_plan_catalog": catalog,
"connected_count": len(connected),
"available_count": len(available),
"oauth_accounts": {
"connected_count": len(connected),
"available_count": len(available),
"total_count": len(providers),
"connected": [_account_summary(provider, catalog) for provider in connected],
"available": [_account_summary(provider, catalog) for provider in available],
},
"codex": provider_map.get(CODEX_PROVIDER_ID, {}),
}
def _provider_status(provider: Any) -> dict[str, Any]:
try:
status = provider.status()
if not isinstance(status, dict):
status = {}
provider_id = str(status.get("provider_id") or getattr(provider, "provider_id", ""))
status = {
**status,
"provider_id": provider_id,
"connected": bool(status.get("connected")),
}
usage_windows = _usage_windows(status)
if usage_windows:
status["usage_windows"] = usage_windows
return status
except Exception as exc:
return {
"provider_id": str(getattr(provider, "provider_id", "")),
"connected": False,
"error": str(exc),
}
def _account_summary(provider: dict[str, Any], catalog: dict[str, Any]) -> dict[str, Any]:
provider_id = str(provider.get("provider_id") or "")
plan_entry = catalog.get(provider_id) if isinstance(catalog, dict) else None
plans = plan_entry.get("plans", []) if isinstance(plan_entry, dict) else []
return {
"provider_id": provider_id,
"display_name": provider.get("display_name") or provider_id,
"short_name": provider.get("short_name") or provider.get("display_name") or provider_id,
"connected": bool(provider.get("connected")),
"account_label": provider.get("account_label") or provider.get("email") or "",
"auth_flow": provider.get("auth_flow") or "",
"icon": provider.get("icon") or "",
"warning": provider.get("warning") or provider.get("models_warning") or "",
"usage_windows": list(provider.get("usage_windows") or []),
"plan_count": len(plans) if isinstance(plans, list) else 0,
}
def _usage_windows(status: dict[str, Any]) -> list[dict[str, Any]]:
usage = status.get("usage") if isinstance(status, dict) else {}
if not isinstance(usage, dict) or not usage.get("available"):
return []
windows: list[dict[str, Any]] = []
for key, title in (("primary", "Session"), ("secondary", "Week")):
window = usage.get(key)
if not isinstance(window, dict):
continue
remaining = _remaining_percent(window)
if remaining is None:
continue
windows.append({
"key": key,
"title": title,
"label": window.get("label") or "",
"remaining_percent": remaining,
"reset_at": window.get("reset_at") or 0,
})
return windows
def _remaining_percent(window: dict[str, Any]) -> float | None:
remaining = window.get("remaining_percent")
if remaining is not None:
try:
return max(0, min(100, float(remaining)))
except (TypeError, ValueError):
pass
used = window.get("used_percent")
if used is not None:
try:
return max(0, min(100, 100 - float(used)))
except (TypeError, ValueError):
return None
return None

View file

@ -15,187 +15,160 @@
x-effect="$store.oauthConfig.bindConfig(config)"
x-destroy="$store.oauthConfig.cleanup()"
>
<section class="oauth-provider-grid">
<template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
<article class="oauth-provider-card" :class="card.connected ? 'is-connected' : ''">
<div class="oauth-provider-head">
<div class="oauth-mark">
<span class="material-symbols-outlined" x-text="card.connected ? 'check' : card.mark"></span>
</div>
<div class="oauth-copy">
<h2 x-text="card.display_name"></h2>
<p x-text="$store.oauthConfig.providerStatusLabel(card.provider_id)"></p>
</div>
</div>
<label class="oauth-provider-input" x-show="card.supports_enterprise_domain && !card.connected">
<span>Enterprise domain</span>
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).enterprise_domain"
name="enterprise_domain"
placeholder="github.com"
/>
</label>
<div class="oauth-provider-fields" x-show="card.supports_oauth_client_config && !card.connected">
<label class="oauth-provider-input">
<span>OAuth client ID</span>
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_id"
name="oauth_client_id"
placeholder="Google Cloud OAuth client ID"
/>
</label>
<label class="oauth-provider-input">
<span>OAuth client secret</span>
<input
type="password"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_secret"
name="oauth_client_secret"
autocomplete="off"
placeholder="Google Cloud OAuth client secret"
/>
</label>
<label class="oauth-provider-input" x-show="card.supports_quota_project">
<span>Quota project</span>
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).quota_project_id"
name="quota_project_id"
placeholder="Optional Google Cloud project ID"
/>
</label>
</div>
<div class="oauth-device" x-show="$store.oauthConfig.devices[card.provider_id]?.user_code">
<span>Enter this code</span>
<strong x-text="$store.oauthConfig.devices[card.provider_id]?.user_code"></strong>
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
</div>
<div class="oauth-manual-callback" x-show="card.supports_manual_callback && $store.oauthConfig.devices[card.provider_id]?.flow === 'browser_pkce' && !card.connected">
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).manualCallback"
placeholder="Paste callback URL, query string, or code"
/>
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback(card.provider_id)">
<span class="material-symbols-outlined">check</span>
<span>Submit</span>
</button>
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
</div>
<div class="oauth-auth-attempt" x-show="$store.oauthConfig.devices[card.provider_id] && !$store.oauthConfig.devices[card.provider_id]?.user_code && !card.supports_manual_callback">
<span>Sign-in started</span>
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
</div>
<p class="oauth-provider-note" x-show="card.warning || card.note" x-text="card.warning || card.note"></p>
<div class="oauth-primary">
<button
class="oauth-connect"
type="button"
@click="$store.oauthConfig.connectProvider(card.provider_id)"
:disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"
x-show="!card.connected"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : 'login'"></span>
<span x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'Waiting' : 'Connect'"></span>
</button>
<button
class="oauth-connect danger"
type="button"
@click="$store.oauthConfig.disconnectProvider(card.provider_id)"
:disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
x-show="card.connected"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
<span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
</button>
<button
class="oauth-connect secondary"
type="button"
@click="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
:disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
<span>Check models</span>
</button>
</div>
</article>
</template>
</section>
<section class="oauth-usage" x-show="$store.oauthConfig.providerConnected('codex_oauth') && $store.oauthConfig.usageWindows('codex_oauth').length">
<template x-for="window in $store.oauthConfig.usageWindows('codex_oauth')" :key="window.key">
<div class="oauth-usage-window">
<div class="oauth-usage-head">
<span>
<span x-text="window.title"></span>
<small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
</span>
<strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
</div>
<div class="oauth-usage-bar" aria-hidden="true">
<i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
</div>
<p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
</div>
</template>
</section>
<section class="oauth-status-row">
<div>
<span>OAuth Connections</span>
<strong x-text="$store.oauthConfig.providerCards().filter((card) => card.connected).length + ' connected'"></strong>
</div>
<div>
<span>Model provider</span>
<strong x-text="$store.oauthConfig.providerLabel($store.oauthConfig.activeModelProvider)"></strong>
</div>
<button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
<span class="material-symbols-outlined">refresh</span>
</button>
</section>
<section class="oauth-plan-catalog" x-show="$store.oauthConfig.usagePlanEntries().length">
<section class="oauth-provider-list">
<div class="oauth-section-head">
<div>
<h3>Usage plans</h3>
<p>Subscription and billing metadata for account-backed model providers.</p>
<h3>Providers</h3>
<p>Each account can be connected independently.</p>
</div>
<button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
<span class="material-symbols-outlined">refresh</span>
</button>
</div>
<div class="oauth-plan-grid">
<template x-for="entry in $store.oauthConfig.usagePlanEntries()" :key="entry.provider_id">
<article class="oauth-plan-card">
<div class="oauth-plan-head">
<strong x-text="entry.display_name"></strong>
<span x-text="$store.oauthConfig.usagePlanStatus(entry)"></span>
<div class="oauth-provider-rows">
<template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
<article
class="oauth-provider-row"
:class="{ selected: $store.oauthConfig.selectedProviderId === card.provider_id, connected: card.connected }"
@click="$store.oauthConfig.selectProvider(card.provider_id)"
>
<span class="oauth-mark" :class="{ connected: card.connected }">
<span class="material-symbols-outlined" x-text="card.connected ? 'check' : card.mark"></span>
</span>
<div class="oauth-provider-row-copy">
<strong x-text="card.display_name"></strong>
<span x-text="$store.oauthConfig.providerReadinessLabel(card.provider_id)"></span>
</div>
<div class="oauth-plan-list">
<template x-for="plan in entry.plans" :key="entry.provider_id + plan.id">
<span x-text="plan.label"></span>
<span class="oauth-account-state" :class="{ connected: card.connected }" x-text="card.connected ? 'Connected' : 'Available'"></span>
<div class="oauth-row-actions">
<button
class="oauth-connect compact"
type="button"
@click.stop="$store.oauthConfig.handleProviderPrimary(card.provider_id)"
:disabled="$store.oauthConfig.providerPrimaryDisabled(card.provider_id)"
x-show="!card.connected"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : ($store.oauthConfig.providerSetupReady(card.provider_id) ? 'login' : 'tune')"></span>
<span x-text="$store.oauthConfig.providerPrimaryLabel(card.provider_id)"></span>
</button>
<button
class="oauth-connect secondary compact"
type="button"
@click.stop="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
:disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
<span>Check models</span>
</button>
<button
class="oauth-connect danger compact"
type="button"
@click.stop="$store.oauthConfig.disconnectProvider(card.provider_id)"
:disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
x-show="card.connected"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
<span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
</button>
</div>
<div class="oauth-provider-usage" x-show="$store.oauthConfig.usageWindows(card.provider_id).length">
<template x-for="window in $store.oauthConfig.usageWindows(card.provider_id)" :key="`${card.provider_id}-${window.key}`">
<div class="oauth-usage-window">
<div class="oauth-usage-head">
<span>
<span x-text="`${card.short_name || card.display_name || 'Account'} ${window.title}`"></span>
<small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
</span>
<strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
</div>
<div class="oauth-usage-bar" aria-hidden="true">
<i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
</div>
<p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
</div>
</template>
</div>
<template x-for="note in $store.oauthConfig.usagePlanNotes(entry)" :key="entry.provider_id + note">
<p x-text="note"></p>
</template>
<div class="oauth-provider-row-detail" x-show="$store.oauthConfig.providerDetailOpen(card.provider_id)" @click.stop>
<div class="oauth-detail-grid">
<label class="oauth-provider-input" x-show="$store.oauthConfig.providerShowSetupFields(card.provider_id) && card.supports_enterprise_domain">
<span>Enterprise domain</span>
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).enterprise_domain"
name="enterprise_domain"
placeholder="github.com"
/>
</label>
<div class="oauth-provider-fields" x-show="$store.oauthConfig.providerShowSetupFields(card.provider_id) && card.supports_oauth_client_config">
<label class="oauth-provider-input">
<span>OAuth client ID</span>
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_id"
name="oauth_client_id"
placeholder="Google Cloud OAuth client ID"
/>
</label>
<label class="oauth-provider-input">
<span>OAuth client secret</span>
<input
type="password"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_secret"
name="oauth_client_secret"
autocomplete="off"
placeholder="Google Cloud OAuth client secret"
/>
</label>
<label class="oauth-provider-input" x-show="card.supports_quota_project">
<span>Quota project</span>
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).quota_project_id"
name="quota_project_id"
placeholder="Optional Google Cloud project ID"
/>
</label>
</div>
<div class="oauth-device" x-show="$store.oauthConfig.providerDevice(card.provider_id)?.user_code">
<span>Enter this code</span>
<strong x-text="$store.oauthConfig.providerDevice(card.provider_id)?.user_code"></strong>
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
</div>
<div class="oauth-manual-callback" x-show="card.supports_manual_callback && $store.oauthConfig.providerDevice(card.provider_id)?.flow === 'browser_pkce' && !card.connected">
<input
type="text"
x-model="$store.oauthConfig.providerUiFor(card.provider_id).manualCallback"
placeholder="Paste callback URL, query string, or code"
/>
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback(card.provider_id)">
<span class="material-symbols-outlined">check</span>
<span>Submit</span>
</button>
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
</div>
<div class="oauth-auth-attempt" x-show="$store.oauthConfig.providerDevice(card.provider_id) && !$store.oauthConfig.providerDevice(card.provider_id)?.user_code && !card.supports_manual_callback">
<span>Sign-in started</span>
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
</div>
<p class="oauth-provider-note" x-show="$store.oauthConfig.providerShowNote(card.provider_id)" x-text="card.warning || card.note"></p>
</div>
</div>
</article>
</template>
</div>
@ -204,8 +177,8 @@
<section class="oauth-model-config">
<div class="oauth-section-head">
<div>
<h3>Agent Zero models</h3>
<p>Select OAuth provider models used by the Main model and Utility model slots.</p>
<h3>Choose your models</h3>
<p>Select account-backed models used by the Main model and Utility model slots.</p>
</div>
<span class="oauth-save-chip" x-show="$store.oauthConfig.modelConfigDirty">Pending changes</span>
</div>
@ -222,20 +195,9 @@
<span class="oauth-model-icon material-symbols-outlined" x-text="slot.icon"></span>
<div class="oauth-model-title">
<strong x-text="slot.title"></strong>
<span x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
<span x-show="$store.oauthConfig.slotStatusLabel(slot.key)" x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
</div>
<div class="oauth-model-actions">
<template x-for="provider in $store.oauthConfig.providerCards().filter((card) => card.connected)" :key="slot.key + provider.provider_id">
<button
class="oauth-model-action"
type="button"
x-show="!$store.oauthConfig.slotUsesProvider(slot.key, provider.provider_id)"
@click="$store.oauthConfig.useProviderForSlot(slot.key, provider.provider_id)"
>
<span class="material-symbols-outlined">swap_horiz</span>
<span x-text="$store.oauthConfig.providerUseLabel(provider.provider_id)"></span>
</button>
</template>
<button
class="oauth-model-action icon"
type="button"
@ -252,14 +214,28 @@
<p class="oauth-model-description" x-text="slot.description"></p>
<div class="oauth-model-picker" @click.outside="$store.oauthConfig.closeModelDropdown(slot.key)">
<label class="oauth-model-provider-field">
<span>Provider</span>
<select
:value="$store.oauthConfig.modelSlot(slot.key).provider"
@change="$store.oauthConfig.useProviderForSlot(slot.key, $event.target.value)"
:disabled="!$store.oauthConfig.connectedProviderCards().length"
>
<option value="" disabled>Choose connected provider</option>
<template x-for="provider in $store.oauthConfig.slotProviderChoices(slot.key)" :key="`${slot.key}-${provider.provider_id}`">
<option :value="provider.provider_id" x-text="$store.oauthConfig.modelProviderOptionLabel(provider)"></option>
</template>
</select>
</label>
<div class="oauth-model-input-row">
<input
type="text"
x-model="$store.oauthConfig.modelSlot(slot.key).name"
@input="$store.oauthConfig.markModelDirty(slot.key)"
@focus="$store.oauthConfig.openModelDropdown(slot.key)"
:disabled="!$store.oauthConfig.slotUsesOauth(slot.key)"
placeholder="Search or enter a provider model"
:disabled="!$store.oauthConfig.slotCanUseModels(slot.key)"
:placeholder="$store.oauthConfig.slotCanUseModels(slot.key) ? 'Search or enter a provider model' : 'Choose a connected provider first'"
/>
<button
class="oauth-model-search"
@ -267,7 +243,7 @@
title="Check models"
aria-label="Check models"
@click="$store.oauthConfig.loadModels({ providerId: $store.oauthConfig.modelSlot(slot.key).provider, openDropdown: slot.key })"
:disabled="!$store.oauthConfig.slotUsesOauth(slot.key) || !$store.oauthConfig.providerConnected($store.oauthConfig.modelSlot(slot.key).provider) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
:disabled="!$store.oauthConfig.slotCanUseModels(slot.key) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
>
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider ? 'progress_activity' : 'search'"></span>
</button>
@ -297,15 +273,15 @@
</template>
</section>
<section class="oauth-models-panel" x-show="$store.oauthConfig.models.length">
<section class="oauth-models-panel" x-show="$store.oauthConfig.activeProviderModels().length">
<div class="oauth-section-head">
<div>
<h3>Available models</h3>
<p>Available models from selected provider</p>
<p x-text="$store.oauthConfig.activeModelsDescription()"></p>
</div>
</div>
<div class="oauth-models">
<template x-for="model in $store.oauthConfig.models" :key="model">
<template x-for="model in $store.oauthConfig.activeProviderModels()" :key="model">
<span x-text="model"></span>
</template>
</div>
@ -373,6 +349,119 @@
color: var(--color-text);
}
.oauth-provider-list {
display: grid;
gap: 12px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.oauth-provider-row-copy strong {
overflow: hidden;
display: block;
font-size: 0.88rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.oauth-provider-rows {
display: grid;
gap: 8px;
}
.oauth-provider-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 62px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--color-background) 24%, transparent);
cursor: pointer;
transition: border-color .18s ease, background .18s ease;
}
.oauth-provider-row:hover,
.oauth-provider-row.selected {
border-color: color-mix(in srgb, var(--color-primary) 62%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-panel));
}
.oauth-provider-row-copy {
display: grid;
min-width: 0;
gap: 3px;
}
.oauth-provider-row-copy span {
overflow: hidden;
color: var(--color-text-secondary);
font-size: 0.74rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.oauth-account-state {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 2px 8px;
border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
border-radius: 999px;
color: var(--color-text-secondary);
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.oauth-account-state.connected {
border-color: color-mix(in srgb, #35d07f 46%, var(--color-border));
color: var(--color-text);
}
.oauth .oauth-provider-row.connected .oauth-provider-row-copy strong {
color: var(--color-text, #fff);
}
.oauth .oauth-provider-row.connected .oauth-provider-row-copy span {
color: var(--color-text-secondary, rgba(255, 255, 255, .72));
}
.oauth-row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.oauth-provider-usage {
display: grid;
grid-column: 2 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
min-width: 0;
padding: 4px 0 0;
}
.oauth-provider-row-detail {
display: grid;
grid-column: 1 / -1;
min-width: 0;
padding-top: 2px;
}
.oauth-detail-grid {
display: grid;
gap: 12px;
}
.oauth-provider-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -396,6 +485,11 @@
background: #35d07f;
}
.oauth-mark.connected {
color: #08120c;
background: #35d07f;
}
.oauth-provider-head {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
@ -474,6 +568,14 @@
opacity: .65;
}
.oauth-connect.compact {
min-width: 0;
min-height: 34px;
padding: 0 10px;
font-size: 0.76rem;
white-space: nowrap;
}
.oauth-provider-input,
.oauth-provider-fields,
.oauth-manual-callback,
@ -552,15 +654,6 @@
letter-spacing: 0;
}
.oauth-usage {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.oauth-usage-window {
display: grid;
min-width: 0;
@ -584,6 +677,18 @@
font-weight: 750;
}
.oauth .oauth-provider-row.connected .oauth-usage-head span {
color: var(--color-text-secondary, rgba(255, 255, 255, .72));
}
.oauth .oauth-provider-row.connected .oauth-usage-head strong {
color: var(--color-text, #fff);
}
.oauth .oauth-provider-row.connected .oauth-usage-window p {
color: var(--color-text-secondary, rgba(255, 255, 255, .72));
}
.oauth-usage-head small {
padding: 2px 6px;
border-radius: 999px;
@ -671,77 +776,6 @@
border: 0;
}
.oauth-plan-catalog {
display: grid;
gap: 12px;
padding: 0;
border: 0;
}
.oauth-plan-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.oauth-plan-card {
display: grid;
min-width: 0;
align-content: start;
gap: 10px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
}
.oauth-plan-head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.oauth-plan-head strong {
overflow: hidden;
font-size: 0.88rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.oauth-plan-head span {
flex: 0 0 auto;
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 48%, transparent);
color: var(--color-text-secondary);
font-size: 0.68rem;
font-weight: 750;
}
.oauth-plan-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.oauth-plan-list span {
padding: 4px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 34%, transparent);
color: var(--color-text-secondary);
font-size: 0.72rem;
line-height: 1.2;
}
.oauth-plan-card p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.74rem;
line-height: 1.35;
}
.oauth-models-panel {
display: grid;
gap: 10px;
@ -885,9 +919,40 @@
.oauth-model-picker {
position: relative;
display: grid;
gap: 8px;
min-width: 0;
}
.oauth-model-provider-field {
display: grid;
min-width: 0;
gap: 6px;
}
.oauth-model-provider-field span {
color: var(--color-text-secondary);
font-size: 0.74rem;
font-weight: 750;
}
.oauth-model-provider-field select {
width: 100%;
min-width: 0;
min-height: 36px;
padding: 7px 10px;
border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
border-radius: 8px;
background: var(--color-input);
color: var(--color-text);
font: inherit;
font-size: 0.82rem;
}
.oauth-model-provider-field select:disabled {
opacity: .58;
}
.oauth-model-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@ -951,13 +1016,13 @@
}
.oauth-advanced {
border: 1px solid var(--color-border);
border-radius: 8px;
border: 0;
border-radius: 0;
padding: 0;
}
.oauth-advanced summary {
padding: 12px 14px;
padding: 8px 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
font-weight: 750;
@ -1045,19 +1110,33 @@
.oauth-provider-grid {
grid-template-columns: 1fr;
}
.oauth-model-grid {
grid-template-columns: 1fr;
}
.oauth-provider-row {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.oauth-row-actions {
grid-column: 2 / -1;
justify-content: flex-start;
}
}
@media (max-width: 720px) {
.oauth-device,
.oauth-usage,
.oauth-provider-row-detail,
.oauth-provider-usage,
.oauth-status-row,
.oauth-plan-grid,
.oauth-model-grid,
.oauth-model-head,
.oauth-grid,
.oauth-details div,
.oauth-manual-callback,
.oauth-auth-attempt {
.oauth-auth-attempt,
.oauth-provider-row {
grid-template-columns: 1fr;
}
@ -1069,6 +1148,14 @@
width: 100%;
}
.oauth-row-actions {
grid-column: auto;
}
.oauth-provider-usage {
grid-column: auto;
}
.oauth-model-actions {
justify-content: flex-start;
}

View file

@ -111,6 +111,7 @@ export const store = createStore("oauthConfig", {
connectingProvider: "",
disconnectingProvider: "",
loadingModelsProvider: "",
selectedProviderId: "",
activeModelProvider: CODEX_PROVIDER,
models: [],
modelSlots: MODEL_SLOTS,
@ -153,6 +154,7 @@ export const store = createStore("oauthConfig", {
this.connectingProvider = "";
this.disconnectingProvider = "";
this.loadingModelsProvider = "";
this.selectedProviderId = "";
this.activeModelProvider = CODEX_PROVIDER;
this.models = [];
this.modelConfig = null;
@ -241,6 +243,32 @@ export const store = createStore("oauthConfig", {
});
},
connectedProviderCards() {
return this.providerCards().filter((card) => card.connected);
},
availableProviderCards() {
return this.providerCards().filter((card) => !card.connected);
},
selectedProvider() {
const selected = this.providerCards().find((card) => card.provider_id === this.selectedProviderId);
return selected || this.providerCards()[0] || null;
},
selectProvider(providerId) {
if (!this.isOauthProvider(providerId)) return;
this.selectedProviderId = providerId;
},
connectedSummaryLabel() {
const connected = this.connectedProviderCards().length;
const total = this.providerCards().length;
if (!connected) return "Connect account-backed providers here. More than one can be connected at the same time.";
if (connected === 1) return `1 of ${total} account providers connected.`;
return `${connected} of ${total} account providers connected.`;
},
providerIds() {
return this.providerCards().map((card) => card.provider_id);
},
@ -271,6 +299,73 @@ 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";
if (status.connected) return this.providerStatusLabel(providerId);
if (!this.providerSetupReady(providerId)) return "Needs OAuth client details";
if (status.warning) return "Available with restrictions";
return "Ready to connect";
},
providerSetupReady(providerId) {
const status = this.providerStatus(providerId);
if (!status.supports_oauth_client_config) return true;
const ui = this.providerUiFor(providerId);
const clientId = ui.client_id || status.client_id || this.geminiApi().client_id || "";
const hasSecret = Boolean(
ui.client_secret
|| this.geminiApi().client_secret
|| status.client_secret_configured
);
return Boolean(String(clientId).trim() && hasSecret);
},
providerPrimaryLabel(providerId) {
if (this.connectingProvider === providerId) return "Waiting";
return this.providerSetupReady(providerId) ? "Connect" : "Configure";
},
providerPrimaryDisabled(providerId) {
if (!this.isOauthProvider(providerId)) return true;
if (this.connectingProvider || this.disconnectingProvider) return true;
return false;
},
handleProviderPrimary(providerId) {
this.selectProvider(providerId);
if (!this.providerSetupReady(providerId)) return;
void this.connectProvider(providerId);
},
providerEndpointUrl(providerId) {
const status = this.providerStatus(providerId);
const proxyBase = String(status.proxy_base_path || "").replace(/\/$/, "");
@ -289,6 +384,10 @@ export const store = createStore("oauthConfig", {
},
usageWindows(providerId = CODEX_PROVIDER) {
const status = this.providerStatus(providerId);
if (Array.isArray(status.usage_windows) && status.usage_windows.length) {
return status.usage_windows.filter((window) => Number.isFinite(this.remainingPercent(window)));
}
const usage = this.usage(providerId);
if (!usage?.available) return [];
return [
@ -297,6 +396,20 @@ export const store = createStore("oauthConfig", {
].filter((window) => Number.isFinite(this.remainingPercent(window)));
},
connectedUsageWindows() {
const windows = [];
for (const card of this.connectedProviderCards()) {
for (const window of this.usageWindows(card.provider_id)) {
windows.push({
...window,
key: `${card.provider_id}-${window.key}`,
title: `${card.short_name || card.display_name || "Account"} ${window.title}`,
});
}
}
return windows;
},
usagePlanCatalog() {
const catalog = this.status?.usage_plan_catalog;
return catalog && typeof catalog === "object" ? catalog : {};
@ -419,19 +532,42 @@ export const store = createStore("oauthConfig", {
return found?.label || this.providerLabel(provider);
},
slotProviderChoices() {
return this.connectedProviderCards();
},
modelProviderOptionLabel(provider) {
return provider?.display_name || provider?.short_name || provider?.provider_id || "Connected account";
},
slotStatusLabel(key) {
const slot = this.modelSlot(key);
if (this.slotUsesOauth(key)) return `Using ${this.providerLabel(slot.provider)}`;
if (this.slotUsesOauth(key)) return "";
return `Currently ${this.providerName(slot.provider)}`;
},
slotCanUseModels(key) {
const providerId = this.modelSlot(key).provider;
return this.isOauthProvider(providerId) && this.providerConnected(providerId);
},
activeProviderModels() {
if (!this.providerConnected(this.activeModelProvider)) return [];
return this.providerModels[this.activeModelProvider] || [];
},
activeModelsDescription() {
if (!this.providerConnected(this.activeModelProvider)) return "";
return `Available models from ${this.providerLabel(this.activeModelProvider)}`;
},
markModelDirty(key) {
this.modelConfigDirty = true;
this.modelSlotDirty = { ...this.modelSlotDirty, [key]: true };
},
useProviderForSlot(key, providerId) {
if (!this.isOauthProvider(providerId)) return;
if (!this.providerConnected(providerId)) return;
const slot = this.modelSlot(key);
const previousProvider = slot.provider;
slot.provider = providerId;
@ -441,7 +577,7 @@ export const store = createStore("oauthConfig", {
}
if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
this.activeModelProvider = providerId;
this.models = this.providerModels[providerId] || [];
this.models = this.activeProviderModels();
this.markModelDirty(key);
if (this.models.length) {
this.openModelDropdown(key);
@ -459,14 +595,15 @@ export const store = createStore("oauthConfig", {
utility.api_base = main.api_base || "";
utility.kwargs = clone(main.kwargs || {});
this.activeModelProvider = utility.provider || this.activeModelProvider;
this.models = this.activeProviderModels();
this.markModelDirty("utility_model");
},
openModelDropdown(key) {
if (!this.slotUsesOauth(key)) return;
if (!this.slotCanUseModels(key)) return;
const providerId = this.modelSlot(key).provider;
this.activeModelProvider = providerId;
this.models = this.providerModels[providerId] || [];
this.models = this.activeProviderModels();
this.modelDropdown[key] = { ...this.modelDropdown[key], open: true };
if (!this.models.length && !this.loadingModelsProvider && this.providerConnected(providerId)) {
void this.loadModels({ providerId, openDropdown: key, silent: true });
@ -480,7 +617,7 @@ export const store = createStore("oauthConfig", {
filteredModels(key) {
const slot = this.modelSlot(key);
const query = String(slot.name || "").trim().toLowerCase();
const models = this.providerModels[slot.provider] || this.models || [];
const models = this.providerModels[slot.provider] || [];
const filtered = query
? models.filter((model) => String(model).toLowerCase().includes(query))
: models;
@ -489,13 +626,11 @@ export const store = createStore("oauthConfig", {
selectModel(key, model) {
const slot = this.modelSlot(key);
const providerId = this.isOauthProvider(this.activeModelProvider)
? this.activeModelProvider
: slot.provider;
if (this.isOauthProvider(providerId)) {
slot.provider = providerId;
}
const providerId = slot.provider;
if (!this.providerConnected(providerId)) return;
slot.name = model;
this.activeModelProvider = providerId;
this.models = this.activeProviderModels();
this.markModelDirty(key);
this.closeModelDropdown(key);
},
@ -553,8 +688,16 @@ export const store = createStore("oauthConfig", {
ui.quota_project_id = card.quota_project_id;
}
}
if (!this.isOauthProvider(this.activeModelProvider)) {
this.activeModelProvider = this.providerCards()[0]?.provider_id || CODEX_PROVIDER;
if (!this.providerConnected(this.activeModelProvider)) {
this.activeModelProvider = this.connectedProviderCards()[0]?.provider_id
|| this.providerCards()[0]?.provider_id
|| CODEX_PROVIDER;
this.models = this.activeProviderModels();
}
if (!this.isOauthProvider(this.selectedProviderId)) {
this.selectedProviderId = this.connectedProviderCards()[0]?.provider_id
|| this.providerCards()[0]?.provider_id
|| "";
}
} catch (error) {
void toastFrontendError(messageOf(error), "OAuth Connections");
@ -615,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,
@ -639,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);
@ -646,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) {
@ -691,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;
@ -703,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 = {};

View file

@ -2,6 +2,7 @@ name: _office
title: LibreOffice
description: ODF-first LibreOffice Office artifacts for Writer, Calc, and Impress files.
version: "0.1"
always_enabled: false
settings_sections:
- developer
per_project_config: false

View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>DeepSeek</title>
<!-- Source: Wikimedia Commons File:DeepSeek-icon.svg. Copyright DeepSeek, MIT/Expat licensed. -->
<path d="M27.501 8.46875C27.249 8.3457 27.1406 8.58008 26.9932 8.69922C26.9434 8.73828 26.9004 8.78906 26.8584 8.83398C26.4902 9.22852 26.0605 9.48633 25.5 9.45508C24.6787 9.41016 23.9785 9.66797 23.3594 10.2969C23.2275 9.52148 22.79 9.05859 22.125 8.76172C21.7764 8.60742 21.4238 8.45312 21.1807 8.11719C21.0098 7.87891 20.9639 7.61328 20.8779 7.35156C20.8242 7.19336 20.7695 7.03125 20.5879 7.00391C20.3906 6.97266 20.3135 7.13867 20.2363 7.27734C19.9258 7.84375 19.8066 8.46875 19.8174 9.10156C19.8447 10.5234 20.4453 11.6562 21.6367 12.4629C21.7725 12.5547 21.8076 12.6484 21.7646 12.7832C21.6836 13.0605 21.5869 13.3301 21.501 13.6074C21.4473 13.7852 21.3662 13.8242 21.1768 13.7461C20.5225 13.4727 19.957 13.0684 19.458 12.5781C18.6104 11.7578 17.8438 10.8516 16.8877 10.1426C16.6631 9.97656 16.4395 9.82227 16.207 9.67578C15.2314 8.72656 16.335 7.94727 16.5898 7.85547C16.8574 7.75977 16.6826 7.42773 15.8193 7.43164C14.957 7.43555 14.167 7.72461 13.1611 8.10938C13.0137 8.16797 12.8594 8.21094 12.7002 8.24414C11.7871 8.07227 10.8389 8.0332 9.84766 8.14453C7.98242 8.35352 6.49219 9.23633 5.39648 10.7441C4.08105 12.5547 3.77148 14.6133 4.15039 16.7617C4.54883 19.0234 5.70215 20.8984 7.47559 22.3633C9.31348 23.8809 11.4307 24.625 13.8457 24.4824C15.3125 24.3984 16.9463 24.2012 18.7881 22.6406C19.2529 22.8711 19.7402 22.9629 20.5498 23.0332C21.1729 23.0918 21.7725 23.002 22.2373 22.9062C22.9648 22.752 22.9141 22.0781 22.6514 21.9531C20.5186 20.959 20.9863 21.3633 20.5605 21.0371C21.6445 19.752 23.2783 18.418 23.917 14.0977C23.9668 13.7539 23.9238 13.5391 23.917 13.2598C23.9131 13.0918 23.9512 13.0254 24.1445 13.0059C24.6787 12.9453 25.1973 12.7988 25.6738 12.5352C27.0557 11.7793 27.6123 10.5391 27.7441 9.05078C27.7637 8.82422 27.7402 8.58789 27.501 8.46875ZM15.46 21.8613C13.3926 20.2344 12.3906 19.6992 11.9766 19.7227C11.5898 19.7441 11.6592 20.1875 11.7441 20.4766C11.833 20.7617 11.9492 20.959 12.1123 21.209C12.2246 21.375 12.3018 21.623 12 21.8066C11.334 22.2207 10.1768 21.668 10.1221 21.6406C8.77539 20.8477 7.64941 19.7988 6.85547 18.3652C6.08984 16.9844 5.64453 15.5039 5.57129 13.9238C5.55176 13.541 5.66406 13.4062 6.04297 13.3379C6.54199 13.2461 7.05762 13.2266 7.55664 13.2988C9.66602 13.6074 11.4619 14.5527 12.9668 16.0469C13.8262 16.9004 14.4766 17.918 15.1465 18.9121C15.8584 19.9688 16.625 20.9746 17.6006 21.7988C17.9443 22.0879 18.2197 22.3086 18.4824 22.4707C17.6895 22.5586 16.3652 22.5781 15.46 21.8613ZM16.4502 15.4805C16.4502 15.3105 16.5859 15.1758 16.7568 15.1758C16.7949 15.1758 16.8301 15.1836 16.8613 15.1953C16.9033 15.2109 16.9424 15.2344 16.9727 15.2695C17.0273 15.3223 17.0586 15.4004 17.0586 15.4805C17.0586 15.6504 16.9229 15.7852 16.7529 15.7852C16.582 15.7852 16.4502 15.6504 16.4502 15.4805ZM19.5273 17.0625C19.3301 17.1426 19.1328 17.2129 18.9434 17.2207C18.6494 17.2344 18.3281 17.1152 18.1533 16.9688C17.8828 16.7422 17.6895 16.6152 17.6074 16.2168C17.5732 16.0469 17.5928 15.7852 17.623 15.6348C17.6934 15.3105 17.6152 15.1035 17.3877 14.9141C17.2012 14.7598 16.9658 14.7188 16.7061 14.7188C16.6094 14.7188 16.5205 14.6758 16.4541 14.6406C16.3457 14.5859 16.2568 14.4512 16.3418 14.2852C16.3691 14.2324 16.501 14.1016 16.5322 14.0781C16.8838 13.877 17.29 13.9434 17.666 14.0938C18.0146 14.2363 18.2773 14.498 18.6562 14.8672C19.0439 15.3145 19.1133 15.4395 19.334 15.7734C19.5078 16.0371 19.667 16.3066 19.7754 16.6152C19.8408 16.8066 19.7559 16.9648 19.5273 17.0625Z" fill="#4D6BFE" fill-rule="nonzero"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -71,7 +71,7 @@ export const ONBOARDING_PROVIDER_OVERRIDES = {
short_description: "Multi-model API gateway.",
},
deepseek: {
logo: "https://www.deepseek.com/favicon.ico",
logo: "/plugins/_onboarding/webui/assets/provider-logos/deepseek.svg",
setup_url: "https://platform.deepseek.com/",
api_key_url: "https://platform.deepseek.com/api_keys",
docs_url: "https://platform.deepseek.com/api_keys",

View file

@ -11,25 +11,23 @@ import {
const MODEL_CONFIG_API = "/plugins/_model_config";
const OAUTH_STATUS_API = "/plugins/_oauth/status";
const OAUTH_START_API = "/plugins/_oauth/start_device_login";
const OAUTH_START_API = "/plugins/_oauth/start_login";
const OAUTH_POLL_API = "/plugins/_oauth/poll_device_login";
const OAUTH_MANUAL_CALLBACK_API = "/plugins/_oauth/manual_callback";
const OAUTH_MODELS_API = "/plugins/_oauth/models";
const MAX_OAUTH_POLL_MS = 120000;
const TOP_CLOUD_IDS = TOP_CLOUD_PROVIDER_IDS;
const MORE_CLOUD_IDS = MORE_CLOUD_PROVIDER_IDS;
const OAUTH_MARKS = {
github: "terminal",
google: "cloud",
openai: "key",
xai: "neurology",
};
const FALLBACKS = {
codex_oauth: {
id: "codex_oauth",
name: "ChatGPT/Codex Account",
logo: "https://openai.com/favicon.ico",
onboarding_category: "account",
api_key_mode: "oauth",
short_description: "Use your connected ChatGPT or Codex account.",
setup_url: "https://chatgpt.com/",
docs_url: "https://platform.openai.com/docs/codex",
},
other: {
id: "other",
name: "Other OpenAI-compatible",
@ -102,10 +100,13 @@ export const store = createStore("onboarding", {
oauthStatus: null,
oauthLoading: false,
oauthConnecting: false,
oauthConnectingProvider: "",
oauthDevice: null,
oauthPollTimer: null,
oauthCallbackPollTimer: null,
oauthPollStartedAt: 0,
oauthModels: [],
oauthModels: {},
oauthProviderUi: {},
steps: [
{ step: "path", label: "Choose path" },
@ -138,9 +139,12 @@ export const store = createStore("onboarding", {
this.oauthStatus = null;
this.oauthLoading = false;
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.oauthDevice = null;
this.oauthModels = [];
this.oauthModels = {};
this.oauthProviderUi = {};
this.stopOauthPolling();
this.stopOauthCallbackPolling();
},
async onOpen() {
@ -181,6 +185,26 @@ export const store = createStore("onboarding", {
const fromDetails = this.providerDetails[providerId] || {};
const fallback = FALLBACKS[providerId] || {};
const override = ONBOARDING_PROVIDER_OVERRIDES[providerId] || {};
const oauth = this.oauthProviderStatus(providerId);
if (oauth) {
const defaultModels = Array.isArray(oauth.default_models) ? oauth.default_models : [];
return {
...fallback,
...fromDetails,
...override,
id: providerId,
name: oauth.display_name || fromDetails.name || override.name || providerId,
short_description: oauth.connected
? (oauth.account_label || "Connected")
: (oauth.note || oauth.warning || "Connect this account-backed provider."),
logo: override.logo || fromDetails.logo || "/public/darkSymbol.svg",
api_key_mode: "oauth",
default_chat_model: oauth.default_model || defaultModels[0] || fromDetails.default_chat_model || "",
default_utility_model: defaultModels[1] || oauth.default_model || fromDetails.default_utility_model || "",
model_list_autoload: Boolean(oauth.connected),
auth_flow: oauth.auth_flow || "",
};
}
return {
...fallback,
...fromDetails,
@ -227,12 +251,56 @@ export const store = createStore("onboarding", {
});
},
accountMeta() {
return this.providerMeta("codex_oauth");
oauthProviderStatus(providerId) {
const key = String(providerId || "").trim();
if (!key) return null;
return this.oauthStatus?.provider_map?.[key] || null;
},
accountActionLabel() {
return this.oauthConnected() ? "Use connected account" : "Connect via device code";
oauthProviderCards() {
const providers = Array.isArray(this.oauthStatus?.providers) ? this.oauthStatus.providers : [];
return providers.filter((provider) => provider?.provider_id);
},
oauthProviderUiFor(providerId) {
const key = String(providerId || "").trim();
if (!key) return {};
if (!this.oauthProviderUi[key]) {
this.oauthProviderUi = {
...this.oauthProviderUi,
[key]: {
enterprise_domain: "",
client_id: "",
client_secret: "",
quota_project_id: "",
manualCallback: "",
},
};
}
return this.oauthProviderUi[key];
},
oauthMark(provider) {
return provider?.connected ? "check" : (provider?.mark || OAUTH_MARKS[provider?.icon] || "account_circle");
},
oauthCardTitle(provider) {
return provider?.display_name || provider?.short_name || provider?.provider_id || "Account";
},
oauthCardSubtitle(provider) {
if (this.oauthLoading) return "Checking";
if (provider?.connected) return provider.account_label || provider.email || "Connected";
if (!this.oauthSetupReady(provider?.provider_id)) return "Needs OAuth client details";
if (provider?.warning) return "Available with restrictions";
return "Not connected";
},
oauthAccountActionLabel(providerId) {
const provider = this.oauthProviderStatus(providerId);
if (provider?.connected) return "Use account";
if (this.oauthConnectingProvider === providerId) return "Waiting for sign-in";
return this.oauthSetupReady(providerId) ? "Connect account" : "Configure";
},
selectedProvider() {
@ -310,11 +378,15 @@ export const store = createStore("onboarding", {
primaryDisabled() {
if (this.loading || this.saving) return true;
if (this.step === "setup") {
if (this.isOAuthProvider() && !this.oauthConnected()) return true;
if (this.isOAuthProvider() && !this.oauthConnected(this.selectedProviderId)) return true;
if (this.providerNeedsKey(this.selectedProviderId) && !this.hasProviderKey(this.selectedProviderId)) return true;
return !this.config?.chat_model?.provider || !this.config?.chat_model?.name;
}
if (this.step === "utility") return !this.config?.utility_model?.provider || !this.config?.utility_model?.name;
if (this.step === "utility") {
const utilityProvider = this.config?.utility_model?.provider || "";
if (this.isOAuthProvider(utilityProvider) && !this.oauthConnected(utilityProvider)) return true;
return !utilityProvider || !this.config?.utility_model?.name;
}
return false;
},
@ -341,18 +413,22 @@ export const store = createStore("onboarding", {
this.pathChoice = origin;
const meta = this.providerMeta(providerId);
this.applyProviderToSlot("chat_model", providerId, meta, { forceApiBase: origin === "local" });
if (providerId === "codex_oauth") {
if (this.isOAuthProvider(providerId)) {
await this.loadOauthStatus({ silent: true });
}
this.step = "setup";
if (meta.model_list_autoload !== false) {
if (this.isOAuthProvider(providerId)) {
if (this.oauthConnected(providerId)) {
await this.loadModels("chat_model", { openDropdown: false });
}
} else if (meta.model_list_autoload !== false) {
await this.loadModels("chat_model", { openDropdown: false });
}
},
async selectCodexAccount() {
async selectOAuthProvider(providerId) {
this.pathChoice = "cloud";
await this.selectProvider("codex_oauth", "cloud");
await this.selectProvider(providerId, "cloud");
},
applyProviderToSlot(slotKey, providerId, meta, options = {}) {
@ -386,7 +462,7 @@ export const store = createStore("onboarding", {
},
setupPurpose() {
if (this.isOAuthProvider()) return "Connect once, then Agent Zero can use the local Codex/ChatGPT account bridge without an API key.";
if (this.isOAuthProvider()) return "Connect this account, then choose the account-backed model Agent Zero should use.";
if (this.selectedProviderOrigin === "local") return "Choose a local model and confirm where Agent Zero can reach it.";
return "Choose a model and add the key Agent Zero will use for this provider.";
},
@ -421,21 +497,38 @@ export const store = createStore("onboarding", {
return Boolean(draft.trim() || modelConfigStore.apiKeyStatus?.[providerId]);
},
isOAuthProvider() {
return this.selectedProviderId === "codex_oauth" || this.config?.chat_model?.provider === "codex_oauth";
isOAuthProvider(providerId = "") {
const key = String(providerId || this.selectedProviderId || this.config?.chat_model?.provider || "").trim();
if (!key) return false;
return Boolean(this.oauthProviderStatus(key) || this.providerMeta(key).api_key_mode === "oauth");
},
oauthConnected() {
return Boolean(this.oauthStatus?.codex?.connected);
oauthConnected(providerId = "") {
const key = String(providerId || this.selectedProviderId || this.config?.chat_model?.provider || "").trim();
return Boolean(this.oauthProviderStatus(key)?.connected);
},
oauthEmail() {
return this.oauthStatus?.codex?.email || this.oauthStatus?.codex?.account_email || this.oauthStatus?.codex?.account_id || "";
oauthEmail(providerId = "") {
const status = this.oauthProviderStatus(providerId || this.selectedProviderId) || {};
return status.account_label || status.email || status.account_email || status.account_id || "";
},
oauthStatusLabel() {
oauthStatusLabel(providerId = "") {
if (this.oauthLoading) return "Checking";
return this.oauthConnected() ? "Connected" : "Not connected";
const status = this.oauthProviderStatus(providerId || this.selectedProviderId);
if (!status) return "Not connected";
if (status.connected) return this.oauthEmail(status.provider_id) || "Connected";
if (!this.oauthSetupReady(status.provider_id)) return "Needs OAuth client details";
return "Not connected";
},
oauthSetupReady(providerId = "") {
const status = this.oauthProviderStatus(providerId || this.selectedProviderId);
if (!status?.supports_oauth_client_config) return true;
const ui = this.oauthProviderUiFor(status.provider_id);
const clientId = ui.client_id || status.client_id || "";
const hasSecret = Boolean(ui.client_secret || status.client_secret_configured);
return Boolean(String(clientId).trim() && hasSecret);
},
async loadOauthStatus({ silent = false } = {}) {
@ -443,6 +536,12 @@ export const store = createStore("onboarding", {
this.oauthLoading = true;
try {
this.oauthStatus = await callJsonApi(OAUTH_STATUS_API, {});
for (const provider of this.oauthProviderCards()) {
const ui = this.oauthProviderUiFor(provider.provider_id);
if (provider.enterprise_domain && !ui.enterprise_domain) ui.enterprise_domain = provider.enterprise_domain;
if (provider.client_id && !ui.client_id) ui.client_id = provider.client_id;
if (provider.quota_project_id && !ui.quota_project_id) ui.quota_project_id = provider.quota_project_id;
}
} catch (error) {
if (!silent) globalThis.justToast?.("Could not check account connection", "error");
} finally {
@ -450,37 +549,74 @@ export const store = createStore("onboarding", {
}
},
async connectCodex() {
if (this.oauthConnecting) return;
async connectOAuth(providerId = "") {
const selectedProvider = String(providerId || this.selectedProviderId || "").trim();
if (!selectedProvider || this.oauthConnecting) return;
this.oauthConnecting = true;
this.oauthConnectingProvider = selectedProvider;
const popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
try {
const response = await callJsonApi(OAUTH_START_API, {});
if (!response?.ok || !response.verification_url || !response.attempt_id) {
const payload = this.oauthLoginPayload(selectedProvider);
const response = await callJsonApi(OAUTH_START_API, payload);
if (!response?.ok) {
throw new Error(response?.error || "Could not start account connection.");
}
this.oauthDevice = response;
if (popup && !popup.closed) {
popup.location.assign(response.verification_url);
} else {
window.open(response.verification_url, "_blank", "noopener,noreferrer");
if (response.flow === "device_code" && response.verification_url && response.attempt_id) {
if (popup && !popup.closed) {
popup.location.assign(response.verification_url);
} else {
window.open(response.verification_url, "_blank", "noopener,noreferrer");
}
this.startOauthPolling(selectedProvider);
return;
}
this.startOauthPolling();
if (response.flow === "browser_pkce" && response.auth_url) {
if (popup && !popup.closed) {
popup.location.assign(response.auth_url);
} else {
window.open(response.auth_url, "_blank", "noopener,noreferrer");
}
this.startOauthCallbackPolling(selectedProvider);
return;
}
throw new Error(response?.error || "Could not start account connection.");
} catch (error) {
if (popup && !popup.closed) popup.close();
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
globalThis.justToast?.(error?.message || "Could not connect account", "error");
}
},
startOauthPolling() {
oauthLoginPayload(providerId) {
const status = this.oauthProviderStatus(providerId) || {};
const ui = this.oauthProviderUiFor(providerId);
const payload = { provider_id: providerId };
if (status.supports_enterprise_domain) {
payload.enterprise_domain = ui.enterprise_domain || "";
}
if (status.supports_oauth_client_config) {
payload.client_id = ui.client_id || "";
payload.client_secret = ui.client_secret || "";
}
if (status.supports_quota_project) {
payload.quota_project_id = ui.quota_project_id || "";
}
return payload;
},
startOauthPolling(providerId = "") {
this.stopOauthPolling();
this.oauthPollStartedAt = Date.now();
const tick = async () => {
if (!this.oauthDevice?.attempt_id) return;
try {
const response = await callJsonApi(OAUTH_POLL_API, { attempt_id: this.oauthDevice.attempt_id });
const response = await callJsonApi(OAUTH_POLL_API, {
provider_id: providerId,
attempt_id: this.oauthDevice.attempt_id,
});
if (!response?.ok) {
if (response?.expired) {
this.oauthDevice = null;
@ -489,21 +625,24 @@ export const store = createStore("onboarding", {
}
if (response.completed) {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.oauthDevice = null;
this.stopOauthPolling();
await this.loadOauthStatus();
this.applyProviderToSlot("chat_model", "codex_oauth", this.providerMeta("codex_oauth"));
await this.loadOauthModels();
this.applyProviderToSlot("chat_model", providerId, this.providerMeta(providerId));
await this.loadOauthModels(providerId, "chat_model");
return;
}
} catch (error) {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.stopOauthPolling();
globalThis.justToast?.(error?.message || "Could not connect account", "error");
return;
}
if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.oauthDevice = null;
this.stopOauthPolling();
}
@ -520,28 +659,113 @@ export const store = createStore("onboarding", {
this.oauthPollTimer = null;
},
async loadOauthModels() {
try {
const response = await callJsonApi(OAUTH_MODELS_API, {});
this.oauthModels = Array.isArray(response?.models) ? response.models : [];
if (this.oauthModels.length && !this.userTouchedModel.chat_model) {
this.config.chat_model.name = this.oauthModels[0];
startOauthCallbackPolling(providerId = "") {
this.stopOauthCallbackPolling();
this.oauthPollStartedAt = Date.now();
const tick = async () => {
await this.loadOauthStatus({ silent: true });
if (this.oauthConnected(providerId)) {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.oauthDevice = null;
this.stopOauthCallbackPolling();
this.applyProviderToSlot("chat_model", providerId, this.providerMeta(providerId));
await this.loadOauthModels(providerId, "chat_model");
return;
}
this.modelDropdown.chat_model.models = this.oauthModels;
this.modelDropdown.chat_model.source = "oauth";
} catch {
this.oauthModels = [];
if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.oauthDevice = null;
this.stopOauthCallbackPolling();
}
};
void tick();
this.oauthCallbackPollTimer = window.setInterval(tick, 2500);
},
stopOauthCallbackPolling() {
if (this.oauthCallbackPollTimer) window.clearInterval(this.oauthCallbackPollTimer);
this.oauthCallbackPollTimer = null;
},
async submitOAuthManualCallback(providerId = "") {
const selectedProvider = String(providerId || this.selectedProviderId || "").trim();
const callback = String(this.oauthProviderUiFor(selectedProvider).manualCallback || "").trim();
if (!selectedProvider || !callback) {
globalThis.justToast?.("Paste callback URL, query string, or code.", "error");
return;
}
this.oauthConnecting = true;
this.oauthConnectingProvider = selectedProvider;
try {
const response = await callJsonApi(OAUTH_MANUAL_CALLBACK_API, {
provider_id: selectedProvider,
callback,
});
if (!response?.ok) {
throw new Error(response?.error || "Could not finish account connection.");
}
this.oauthProviderUiFor(selectedProvider).manualCallback = "";
this.oauthDevice = null;
this.stopOauthCallbackPolling();
await this.loadOauthStatus();
this.applyProviderToSlot("chat_model", selectedProvider, this.providerMeta(selectedProvider));
await this.loadOauthModels(selectedProvider, "chat_model");
} catch (error) {
globalThis.justToast?.(error?.message || "Could not connect account", "error");
} finally {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
}
},
async loadOauthModels(providerId = "", slotKey = "chat_model", { openDropdown = true } = {}) {
const selectedProvider = String(providerId || this.config?.[slotKey]?.provider || this.selectedProviderId || "").trim();
if (!selectedProvider) return;
const dropdown = this.modelDropdown[slotKey];
dropdown.loading = true;
dropdown.error = "";
dropdown.source = "";
try {
const response = await callJsonApi(OAUTH_MODELS_API, { provider_id: selectedProvider });
if (!response?.ok) {
throw new Error(response?.error || "Could not load account models.");
}
const models = Array.isArray(response?.models) ? response.models : [];
this.oauthModels = { ...this.oauthModels, [selectedProvider]: models };
dropdown.models = models;
dropdown.source = "oauth";
dropdown.open = openDropdown && models.length > 0;
if (models.length && !this.userTouchedModel[slotKey]) {
const meta = this.providerMeta(selectedProvider);
const preferred = slotKey === "utility_model" ? meta.default_utility_model : meta.default_chat_model;
this.config[slotKey].name = preferred && models.includes(preferred) ? preferred : models[0];
}
} catch (error) {
this.oauthModels = { ...this.oauthModels, [selectedProvider]: [] };
dropdown.models = [];
dropdown.error = error?.message || "Could not load account models.";
dropdown.open = false;
} finally {
dropdown.loading = false;
}
},
cancelOauthConnect() {
this.oauthConnecting = false;
this.oauthConnectingProvider = "";
this.oauthDevice = null;
this.stopOauthPolling();
this.stopOauthCallbackPolling();
},
async loadModels(slotKey, { openDropdown = true } = {}) {
if (!this.config?.[slotKey]?.provider) return;
if (this.isOAuthProvider(this.config[slotKey].provider)) {
await this.loadOauthModels(this.config[slotKey].provider, slotKey, { openDropdown });
return;
}
const dropdown = this.modelDropdown[slotKey];
dropdown.loading = true;
dropdown.error = "";

View file

@ -175,6 +175,109 @@
.section-title { color: var(--onboard-ink); font-weight: 900; }
.setup-hero .section-title { font-weight: 720; }
.account-subtitle, .section-description { color: var(--onboard-muted); line-height: 1.45; }
.oauth-account-section {
display: grid;
gap: 10px;
margin-top: 14px;
}
.path-oauth-account-section {
margin-top: 24px;
}
.path-oauth-account-section .oauth-account-section-head {
justify-content: center;
text-align: center;
}
.path-oauth-account-section .oauth-account-section-head span {
font-weight: 760;
}
.oauth-account-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: var(--onboard-ink);
}
.oauth-account-section-head span {
font-weight: 900;
}
.oauth-account-section-head small {
color: var(--onboard-muted);
font-weight: 650;
}
.oauth-account-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.oauth-account-card {
min-height: 76px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--onboard-line);
border-radius: 15px;
background: color-mix(in srgb, var(--color-panel) 68%, transparent);
color: var(--onboard-ink);
text-align: left;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.oauth-account-card:hover,
.oauth-account-card:focus-visible {
border-color: var(--color-border);
background: color-mix(in srgb, var(--color-background-hover) 62%, var(--color-panel));
outline: none;
}
.oauth-account-card.connected {
border-color: color-mix(in srgb, #8fd8a8 44%, var(--onboard-line));
}
.oauth-account-mark {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 10px;
background: color-mix(in srgb, var(--color-background) 50%, transparent);
color: var(--onboard-muted);
}
.oauth-account-mark.connected {
color: #0c1b11;
background: #8fd8a8;
}
.oauth-account-copy {
display: grid;
min-width: 0;
gap: 3px;
}
.oauth-account-title,
.oauth-account-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oauth-account-title {
font-weight: 900;
line-height: 1.2;
}
.oauth-account-subtitle {
color: var(--onboard-muted);
font-size: 0.84rem;
}
.oauth-account-action {
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-background) 54%, transparent);
color: var(--onboard-ink);
font-size: 0.78rem;
font-weight: 900;
white-space: nowrap;
}
.provider-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@ -487,6 +590,25 @@
line-height: 1.4;
}
.oauth-device-note .material-symbols-outlined { font-size: 19px; color: #d9ad68; }
.oauth-provider-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.oauth-provider-fields .field {
display: grid;
gap: 6px;
}
.oauth-provider-fields .field span {
color: var(--onboard-ink);
font-weight: 850;
}
.oauth-manual-callback {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.status-pill {
display: inline-flex;
align-items: center;
@ -502,6 +624,7 @@
@media (max-width: 980px) {
.provider-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.more-provider-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.oauth-account-grid { grid-template-columns: 1fr; }
.setup-layout { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
@ -513,8 +636,14 @@
.more-provider-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.path-card { aspect-ratio: 21 / 10; }
.account-strip { align-items: flex-start; flex-direction: column; }
.oauth-account-card { grid-template-columns: auto minmax(0, 1fr); }
.oauth-account-action { grid-column: 2; justify-self: start; }
.oauth-connect-main { align-items: stretch; flex-direction: column; }
.oauth-connect-actions { justify-content: flex-start; }
.oauth-provider-fields,
.oauth-manual-callback {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.provider-grid { grid-template-columns: 1fr; }
@ -600,6 +729,25 @@
</span>
</button>
</div>
<div class="oauth-account-section path-oauth-account-section" x-show="$store.onboarding.oauthProviderCards().length">
<div class="oauth-account-section-head">
<span>or connect your AI account</span>
</div>
<div class="oauth-account-grid">
<template x-for="provider in $store.onboarding.oauthProviderCards()" :key="`path-${provider.provider_id}`">
<button type="button" class="oauth-account-card" :class="{ connected: provider.connected }" @click="$store.onboarding.selectOAuthProvider(provider.provider_id)">
<span class="oauth-account-mark" :class="{ connected: provider.connected }">
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthMark(provider)"></span>
</span>
<span class="oauth-account-copy">
<span class="oauth-account-title" x-text="$store.onboarding.oauthCardTitle(provider)"></span>
<span class="oauth-account-subtitle" x-text="$store.onboarding.oauthCardSubtitle(provider)"></span>
</span>
<span class="oauth-account-action" x-text="$store.onboarding.oauthAccountActionLabel(provider.provider_id)"></span>
</button>
</template>
</div>
</div>
</section>
<section x-show="$store.onboarding.isStep('cloud')" x-transition.opacity>
@ -613,15 +761,25 @@
</button>
</template>
</div>
<div class="account-strip">
<div class="account-strip-main">
<img :src="$store.onboarding.accountMeta().logo" alt="OpenAI logo">
<div>
<div class="account-title">Connect ChatGPT/Codex Account</div>
<div class="account-subtitle" x-text="$store.onboarding.oauthStatusLabel()"></div>
</div>
<div class="oauth-account-section" x-show="$store.onboarding.oauthProviderCards().length">
<div class="oauth-account-section-head">
<span>Accounts</span>
<small>Account-backed providers can be used without API keys.</small>
</div>
<div class="oauth-account-grid">
<template x-for="provider in $store.onboarding.oauthProviderCards()" :key="`cloud-${provider.provider_id}`">
<button type="button" class="oauth-account-card" :class="{ connected: provider.connected }" @click="$store.onboarding.selectOAuthProvider(provider.provider_id)">
<span class="oauth-account-mark" :class="{ connected: provider.connected }">
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthMark(provider)"></span>
</span>
<span class="oauth-account-copy">
<span class="oauth-account-title" x-text="$store.onboarding.oauthCardTitle(provider)"></span>
<span class="oauth-account-subtitle" x-text="$store.onboarding.oauthCardSubtitle(provider)"></span>
</span>
<span class="oauth-account-action" x-text="$store.onboarding.oauthAccountActionLabel(provider.provider_id)"></span>
</button>
</template>
</div>
<button type="button" class="btn btn-secondary" @click="$store.onboarding.selectCodexAccount()" x-text="$store.onboarding.accountActionLabel()"></button>
</div>
<div class="more-providers onboarding-panel">
<button type="button"
@ -703,31 +861,55 @@
<div class="oauth-connect-panel">
<div class="oauth-connect-main">
<div class="oauth-status-group">
<span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected() }">
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected() ? 'check_circle' : 'lock_open'"></span>
<span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected($store.onboarding.selectedProviderId) }">
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected($store.onboarding.selectedProviderId) ? 'check_circle' : $store.onboarding.oauthMark($store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId))"></span>
</span>
<span class="oauth-status-copy">
<span class="oauth-status-label">ChatGPT/Codex account</span>
<span class="oauth-status-label" x-text="$store.onboarding.selectedProviderName()"></span>
<span class="oauth-status-detail">
<span x-text="$store.onboarding.oauthStatusLabel()"></span>
<span x-show="$store.onboarding.oauthEmail()"> - <span x-text="$store.onboarding.oauthEmail()"></span></span>
<span x-text="$store.onboarding.oauthStatusLabel($store.onboarding.selectedProviderId)"></span>
</span>
</span>
</div>
<div class="oauth-connect-actions">
<button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected()" @click="$store.onboarding.connectCodex()" :disabled="$store.onboarding.oauthConnecting">
<span x-text="$store.onboarding.oauthConnecting ? 'Waiting for sign-in' : 'Connect account'"></span>
<button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)" @click="$store.onboarding.connectOAuth($store.onboarding.selectedProviderId)" :disabled="$store.onboarding.oauthConnecting || !$store.onboarding.oauthSetupReady($store.onboarding.selectedProviderId)">
<span x-text="$store.onboarding.oauthAccountActionLabel($store.onboarding.selectedProviderId)"></span>
</button>
<button type="button" class="btn btn-cancel" x-show="$store.onboarding.oauthConnecting" @click="$store.onboarding.cancelOauthConnect()">Cancel</button>
<span class="status-pill connected" x-show="$store.onboarding.oauthConnected()">
<span class="status-pill connected" x-show="$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
<span class="material-symbols-outlined">check_circle</span>
<span>Connected</span>
</span>
</div>
</div>
<div class="oauth-provider-fields" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_enterprise_domain && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
<label class="field">
<span>Enterprise domain</span>
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).enterprise_domain" placeholder="github.com">
</label>
</div>
<div class="oauth-provider-fields" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_oauth_client_config && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
<label class="field">
<span>OAuth client ID</span>
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).client_id" placeholder="Google Cloud OAuth client ID">
</label>
<label class="field">
<span>OAuth client secret</span>
<input type="password" autocomplete="off" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).client_secret" placeholder="Google Cloud OAuth client secret">
</label>
<label class="field" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_quota_project">
<span>Quota project</span>
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).quota_project_id" placeholder="Optional Google Cloud project ID">
</label>
</div>
<div class="oauth-device-note" x-show="$store.onboarding.oauthDevice">
<span class="material-symbols-outlined">key</span>
<span>Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
<span x-show="$store.onboarding.oauthDevice?.user_code">Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
<span x-show="!$store.onboarding.oauthDevice?.user_code">Finish sign-in in the opened browser tab.</span>
</div>
<div class="oauth-manual-callback" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_manual_callback && $store.onboarding.oauthDevice?.flow === 'browser_pkce' && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).manualCallback" placeholder="Paste callback URL, query string, or code">
<button type="button" class="btn btn-secondary" @click="$store.onboarding.submitOAuthManualCallback($store.onboarding.selectedProviderId)">Submit</button>
</div>
</div>
</template>

View file

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

View file

@ -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,
}

View file

@ -1,13 +1,23 @@
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import pytest
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from plugins._document_query import hooks as document_query_hooks
from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource
from plugins._document_query.helpers.document_query import DocumentQueryHelper
import plugins._document_query.helpers.document_query as document_query_module
from plugins._document_query.helpers.document_query import (
DocumentQueryHelper,
DocumentQueryStore,
)
from plugins._document_query.helpers.parsers.base import BaseParser
from plugins._document_query.helpers.parsers import get_parsers_for_mimetype
from plugins._document_query.helpers.parsers import liteparse as liteparse_module
@ -15,9 +25,6 @@ from plugins._document_query.helpers.parsers.liteparse import LiteParseParser
from plugins._document_query.helpers.parsers.text import TextParser
ROOT = Path(__file__).resolve().parents[1]
def run_async(coro):
with asyncio.Runner() as runner:
return runner.run(coro)
@ -48,6 +55,52 @@ class CountingAsyncParser(BaseParser):
return document.uri
class _StoreContext:
def __init__(self, context_id: str):
self.id = context_id
self.data = {}
def get_data(self, key: str, recursive: bool = True):
return self.data.get(key)
def set_data(self, key: str, value, recursive: bool = True):
self.data[key] = value
class _StoreAgent:
def __init__(self, context_id: str):
self.config = object()
self.context = _StoreContext(context_id)
class _FakeVectorDB:
def __init__(self):
self.docs = []
async def insert_documents(self, docs):
ids = []
for doc in docs:
doc_id = f"doc-{len(self.docs)}"
doc.metadata["id"] = doc_id
ids.append(doc_id)
self.docs.append(doc)
return ids
async def search_by_metadata(self, filter: str, limit: int = 0):
document_uri = filter.split("'", 2)[1]
docs = [
doc
for doc in self.docs
if doc.metadata.get("document_uri") == document_uri
]
return docs[:limit] if limit > 0 else docs
async def delete_documents_by_ids(self, ids: list[str]):
removed = [doc for doc in self.docs if doc.metadata.get("id") in ids]
self.docs = [doc for doc in self.docs if doc.metadata.get("id") not in ids]
return removed
def test_fetch_file_detects_mimetype_and_reads_once(tmp_path):
document = tmp_path / "notes.txt"
document.write_text("hello\nworld\n", encoding="utf-8")
@ -105,12 +158,14 @@ def test_compatibility_imports_point_to_plugin_classes():
def test_liteparse_is_installed_by_docker_and_plugin_hook_requirements():
root_requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
plugin_requirements = (
ROOT / "plugins" / "_document_query" / "requirements.txt"
hooks_source = (
ROOT / "plugins" / "_document_query" / "hooks.py"
).read_text(encoding="utf-8")
assert "liteparse==2.0.3" in root_requirements
assert plugin_requirements.strip().splitlines() == ["liteparse==2.0.3"]
assert "_ROOT_REQUIREMENTS_FILE" in hooks_source
assert document_query_hooks._liteparse_requirement() == "liteparse==2.0.3"
assert not (ROOT / "plugins" / "_document_query" / "requirements.txt").exists()
def test_default_config_bounds_liteparse_runtime_concurrency():
@ -120,6 +175,7 @@ def test_default_config_bounds_liteparse_runtime_concurrency():
assert "parser_concurrency: 1" in default_config
assert "context_intro_chunks: 2" in default_config
assert "max_index_chunks: 1200" in default_config
assert "liteparse_num_workers: 2" in default_config
assert "liteparse_ocr_auto_disable_pages: 30" in default_config
assert "liteparse_subprocess" not in default_config
@ -137,6 +193,7 @@ def test_config_panel_exposes_document_query_settings():
"gather_timeout",
"chunk_size",
"chunk_overlap",
"max_index_chunks",
"search_threshold",
"search_limit",
"context_intro_chunks",
@ -162,6 +219,67 @@ def test_config_panel_exposes_document_query_settings():
assert "liteparse_subprocess" not in config_html
def test_document_query_adapts_chunk_size_for_large_documents():
store = object.__new__(DocumentQueryStore)
store.config = {
"chunk_size": 100,
"chunk_overlap": 10,
"max_index_chunks": 10,
}
chunks = store._split_text_for_index(("alpha beta gamma delta " * 400).strip())
assert 1 < len(chunks) <= 10
def test_document_query_allows_uncapped_index_chunks():
store = object.__new__(DocumentQueryStore)
store.config = {
"chunk_size": 100,
"chunk_overlap": 10,
"max_index_chunks": 0,
}
chunks = store._split_text_for_index(("alpha beta gamma delta " * 400).strip())
assert len(chunks) > 10
def test_document_query_store_reuses_vector_db_per_context(monkeypatch):
monkeypatch.setattr(
document_query_module,
"_load_config",
lambda _agent: {
"chunk_size": 100,
"chunk_overlap": 10,
"max_index_chunks": 20,
},
)
monkeypatch.setattr(
DocumentQueryStore,
"init_vector_db",
lambda _self: _FakeVectorDB(),
)
agent = _StoreAgent("ctx-one")
store = DocumentQueryStore.get(agent)
success, ids = run_async(
store.add_document("alpha beta gamma " * 20, "/tmp/book.txt")
)
second_store = DocumentQueryStore.get(agent)
assert success is True
assert ids
assert second_store is store
assert second_store.vector_db is store.vector_db
assert run_async(second_store.document_exists("/tmp/book.txt")) is True
isolated_store = DocumentQueryStore.get(_StoreAgent("ctx-two"))
assert isolated_store is not store
assert run_async(isolated_store.document_exists("/tmp/book.txt")) is False
def test_document_query_thumbnail_matches_plugin_hub_limits():
thumbnail = ROOT / "plugins" / "_document_query" / "webui" / "thumbnail.jpg"
@ -251,7 +369,7 @@ def test_liteparse_keeps_ocr_for_small_pdf(monkeypatch):
assert kwargs["ocr_enabled"] is True
def test_liteparse_keeps_ocr_for_large_text_sparse_pdf(monkeypatch):
def test_liteparse_disables_ocr_for_large_text_sparse_pdf(monkeypatch):
parser = LiteParseParser()
fetched = FetchedDocument(
uri="/tmp/scan.pdf",
@ -273,7 +391,7 @@ def test_liteparse_keeps_ocr_for_large_text_sparse_pdf(monkeypatch):
kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/scan.pdf")
assert kwargs["ocr_enabled"] is True
assert kwargs["ocr_enabled"] is False
def test_liteparse_respects_explicit_ocr_disabled(monkeypatch):

View file

@ -0,0 +1,58 @@
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers.file_browser import FileBrowser
def read(*parts: str) -> str:
return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
def test_file_browser_remember_last_directory_defaults_enabled() -> None:
settings_source = read("helpers", "settings.py")
assert "file_browser_remember_last_directory: bool" in settings_source
assert "file_browser_remember_last_directory=get_default_value(" in settings_source
assert '"file_browser_remember_last_directory",\n True,' in settings_source
def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> None:
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
assert 'class="path-navigator"' in html
assert 'x-model="$store.fileBrowser.pathInput"' in html
assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
assert "Go to directory" in html
assert "$store.fileBrowser.pathError" in html
assert "FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY" in store
assert 'callJsonApi("settings_get", null)' in store
assert "file_browser_remember_last_directory" in store
assert "getRememberedDirectory()" in store
assert "rememberCurrentDirectory(this.browser.currentPath)" in store
assert "clearRememberedDirectory()" in store
explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
remembered_path_index = store.index("const rememberedPath = !explicitPath")
assert explicit_path_index < remembered_path_index
assert "Remember last file browser location" in workdir_settings
assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
missing_directory = tmp_path / "missing"
result = FileBrowser().get_files(str(missing_directory))
assert result["entries"] == []
assert result["current_path"] == str(missing_directory)
assert result["error"] == "Directory not found"

View file

@ -122,3 +122,72 @@ def test_http_csrf_accepts_valid_cookie(monkeypatch) -> None:
_set_csrf_cookie(client, "csrf-4")
response = client.get("/secure")
assert response.status_code == 200
def test_safe_next_url_accepts_plugin_page_path() -> None:
from helpers.api import get_safe_next_url, is_safe_next_url
target = "/plugins/a0_voqualizer/webui/voqualizer.html"
assert is_safe_next_url(target)
assert get_safe_next_url(target, "/") == target
def test_safe_next_url_preserves_query_string() -> None:
from helpers.api import get_safe_next_url
target = "/plugins/a0_voqualizer/webui/voqualizer.html?context=rlO1iMV7"
assert get_safe_next_url(target, "/") == target
def test_safe_next_url_rejects_external_and_protocol_relative_urls() -> None:
from helpers.api import get_safe_next_url, is_safe_next_url
fallback = "/"
for value in [
"https://evil.example/plugins/a0_voqualizer/webui/voqualizer.html",
"//evil.example/plugins/a0_voqualizer/webui/voqualizer.html",
"javascript:alert(1)",
"/safe\nLocation: https://evil.example",
]:
assert not is_safe_next_url(value)
assert get_safe_next_url(value, fallback) == fallback
def test_auth_redirect_includes_original_path_and_query(monkeypatch) -> None:
from run_ui import requires_auth
monkeypatch.setattr("helpers.login.get_credentials_hash", lambda: "hash")
app = _make_app()
@app.get("/plugins/a0_voqualizer/webui/voqualizer.html")
@requires_auth
async def voqualizer_page():
return Response("ok", status=200)
client = app.test_client()
response = client.get("/plugins/a0_voqualizer/webui/voqualizer.html?context=rlO1iMV7")
assert response.status_code == 302
location = response.headers["Location"]
assert location.startswith("/login?next=")
assert "%2Fplugins%2Fa0_voqualizer%2Fwebui%2Fvoqualizer.html%3Fcontext%3DrlO1iMV7" in location
def test_is_safe_next_url_rejects_backslash_open_redirects() -> None:
from helpers.api import is_safe_next_url
# Raw backslash forms
assert is_safe_next_url("/\\evil.example") is False
assert is_safe_next_url("\\/evil.example") is False
assert is_safe_next_url("/path\\evil") is False
# Percent-encoded backslash forms
assert is_safe_next_url("/%5Cevil.example") is False
assert is_safe_next_url("%5C/evil.example") is False
assert is_safe_next_url("/%5cevil.example") is False # lowercase hex
# Mixed / double-encoded edge
assert is_safe_next_url("/path/%5Cevil") is False
# Sanity: a legitimate relative path still passes
assert is_safe_next_url("/plugins/a0_voqualizer/webui/voqualizer.html") is True

View file

@ -15,8 +15,8 @@ import yaml
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from plugins._oauth.helpers import codex
from plugins._oauth.helpers import routes
from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_oauth_account_dummy_key import (
OAuthAccountDummyKey,
from plugins._oauth.extensions.python._functions.models.get_api_key.end import (
_20_oauth_account_dummy_key as oauth_dummy_key,
)
@ -686,10 +686,20 @@ def test_provider_config_uses_container_local_agent_zero_origin():
assert "50001" not in json.dumps(codex_provider)
def test_codex_provider_reports_dummy_api_key_when_missing():
def test_codex_provider_leaves_api_key_empty_until_connected(monkeypatch):
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: False)
data = {"args": ("codex_oauth",), "kwargs": {}, "result": "None"}
OAuthAccountDummyKey(agent=None).execute(data=data)
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == "None"
def test_codex_provider_reports_dummy_api_key_when_connected(monkeypatch):
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: True)
data = {"args": ("codex_oauth",), "kwargs": {}, "result": "None"}
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == "oauth"
@ -697,6 +707,6 @@ def test_codex_provider_reports_dummy_api_key_when_missing():
def test_codex_provider_preserves_configured_api_key():
data = {"args": ("codex_oauth",), "kwargs": {}, "result": "configured"}
OAuthAccountDummyKey(agent=None).execute(data=data)
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == "configured"

View file

@ -55,8 +55,8 @@ from plugins._oauth.api import poll_device_login as poll_device_login_api
from plugins._oauth.api import start_device_login as start_device_login_api
from plugins._oauth.api import start_login as start_login_api
from plugins._oauth.api.models import Models
from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_oauth_account_dummy_key import (
OAuthAccountDummyKey,
from plugins._oauth.extensions.python._functions.models.get_api_key.end import (
_20_oauth_account_dummy_key as oauth_dummy_key,
)
from plugins._oauth.helpers import state
from plugins._oauth.helpers.providers import base as provider_base
@ -75,6 +75,7 @@ from plugins._oauth.helpers.providers.base import (
write_private_json,
)
from plugins._oauth.helpers.providers.registry import get_provider, provider_registry
from plugins._oauth.helpers.summary import build_oauth_status_summary
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
@ -347,6 +348,38 @@ def test_status_api_returns_provider_registry_shape(monkeypatch):
assert response["codex"] == response["provider_map"][CODEX_PROVIDER_ID]
def test_oauth_status_summary_adds_accounts_and_usage_windows():
class FakeProvider:
provider_id = CODEX_PROVIDER_ID
def status(self):
return {
"provider_id": CODEX_PROVIDER_ID,
"display_name": "Codex/ChatGPT",
"short_name": "Codex",
"connected": True,
"account_label": "user@example.com",
"usage": {
"available": True,
"primary": {"remaining_percent": 91, "label": "5h", "reset_at": 123},
"secondary": {"used_percent": 14, "label": "7d", "reset_at": 456},
},
}
summary = build_oauth_status_summary(
provider_registry=lambda: {CODEX_PROVIDER_ID: FakeProvider()},
routes_installed=lambda: True,
)
assert summary["routes_installed"] is True
assert summary["connected_count"] == 1
assert summary["oauth_accounts"]["connected"][0]["account_label"] == "user@example.com"
assert summary["provider_map"][CODEX_PROVIDER_ID]["usage_windows"] == [
{"key": "primary", "title": "Session", "label": "5h", "remaining_percent": 91.0, "reset_at": 123},
{"key": "secondary", "title": "Week", "label": "7d", "remaining_percent": 86.0, "reset_at": 456},
]
def test_status_api_contains_provider_status_exceptions(monkeypatch):
class GoodProvider:
provider_id = CODEX_PROVIDER_ID
@ -507,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:
@ -543,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 = []
@ -632,10 +715,25 @@ def test_poll_device_login_unknown_provider_returns_structured_error():
[CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
)
@pytest.mark.parametrize("initial", [None, "None"])
def test_oauth_providers_report_dummy_api_key_when_missing(provider_id, initial):
def test_oauth_providers_leave_api_key_empty_until_connected(monkeypatch, provider_id, initial):
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: False)
data = {"args": (provider_id,), "kwargs": {}, "result": initial}
OAuthAccountDummyKey(agent=None).execute(data=data)
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == initial
@pytest.mark.parametrize(
"provider_id",
[CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
)
@pytest.mark.parametrize("initial", [None, "None"])
def test_oauth_providers_report_dummy_api_key_when_connected(monkeypatch, provider_id, initial):
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: True)
data = {"args": (provider_id,), "kwargs": {}, "result": initial}
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == DUMMY_API_KEY
@ -644,12 +742,13 @@ def test_oauth_providers_report_dummy_api_key_when_missing(provider_id, initial)
"provider_id",
[CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
)
def test_oauth_providers_report_dummy_api_key_when_result_missing(provider_id):
def test_oauth_providers_leave_missing_result_unset_when_disconnected(monkeypatch, provider_id):
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: False)
data = {"args": (provider_id,), "kwargs": {}}
OAuthAccountDummyKey(agent=None).execute(data=data)
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == DUMMY_API_KEY
assert "result" not in data
@pytest.mark.parametrize(
@ -659,7 +758,7 @@ def test_oauth_providers_report_dummy_api_key_when_result_missing(provider_id):
def test_oauth_providers_preserve_configured_api_key(provider_id):
data = {"args": (provider_id,), "kwargs": {}, "result": "configured"}
OAuthAccountDummyKey(agent=None).execute(data=data)
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
assert data["result"] == "configured"
@ -675,10 +774,10 @@ def test_model_provider_config_contains_all_oauth_providers():
GEMINI_API_PROVIDER_ID,
XAI_GROK_PROVIDER_ID,
}
assert chat[CODEX_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
assert chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
assert chat[GEMINI_API_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
assert chat[XAI_GROK_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
assert "api_key" not in chat[CODEX_PROVIDER_ID]["kwargs"]
assert "api_key" not in chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]
assert "api_key" not in chat[GEMINI_API_PROVIDER_ID]["kwargs"]
assert "api_key" not in chat[XAI_GROK_PROVIDER_ID]["kwargs"]
assert chat[CODEX_PROVIDER_ID]["kwargs"]["api_base"] == "http://127.0.0.1/oauth/codex/v1"
assert (
chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]["api_base"]

View file

@ -12,14 +12,29 @@ def test_oauth_settings_exposes_provider_cards_and_model_slots():
assert "OAuth Connections" in config_html + store_js
assert "OAUTH_PROVIDERS" not in store_js
assert "PROVIDER_FALLBACKS" not in store_js
assert "Agent Zero models" in config_html
assert "Connected accounts" not in config_html
assert "Choose your models" in config_html
assert "Main model" in store_js
assert "Utility model" in store_js
assert "provider_map" in store_js
assert "Usage plans" in config_html
assert "<h3>Accounts</h3>" not in config_html
assert "oauth-plan-catalog" not in config_html
assert "oauth-model-provider-field" in config_html
assert "slotProviderChoices(slot.key)" in config_html
assert "connectedProviderCards().length" in config_html
assert "useProviderForSlot(slot.key, $event.target.value)" in config_html
assert "slotCanUseModels(slot.key)" in config_html
assert "<span>Account</span>" not in config_html
assert "oauth-connected-panel" not in config_html
assert "oauth-provider-usage" in config_html
assert "usageWindows(card.provider_id)" in config_html
assert "connectedUsageWindows()" not in config_html
assert "Agent Zero models" not in config_html
assert "Usage plans" not in config_html
assert "usagePlanEntries" in store_js
assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/api/status.py").read_text(encoding="utf-8")
assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/helpers/summary.py").read_text(encoding="utf-8")
assert "copyMainToUtility" in config_html + store_js
assert "connectedSummaryLabel()" not in config_html
def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
@ -37,19 +52,34 @@ 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 "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 "Available models from selected provider" in config_html
assert "activeModelsDescription()" in config_html + store_js
assert "activeProviderModels()" in config_html + store_js
assert "Available models from Codex account" not in config_html
assert "disconnectProvider(card.provider_id)" in config_html
assert "disconnectProvider($store.oauthConfig.selectedProviderId)" not in config_html
assert "color: #35d07f;" not in config_html
assert ".oauth .oauth-provider-row.connected .oauth-provider-row-copy span" in config_html
assert ".oauth .oauth-provider-row.connected .oauth-usage-head strong" in config_html
assert ".oauth .oauth-provider-row.connected .oauth-usage-window p" in config_html
assert "var(--color-text-secondary, rgba(255, 255, 255, .72))" in config_html
def test_oauth_connect_buttons_disable_during_any_provider_connection():
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")
assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"' in config_html
assert "providerPrimaryDisabled" in config_html + store_js
assert "this.connectingProvider || this.disconnectingProvider" in store_js
assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || $store.oauthConfig.disconnectingProvider"' not in config_html
assert "cancelConnect(providerId = \"\")" in store_js
assert "if (this.connectingProvider === providerId) this.connectingProvider = \"\";" in store_js
@ -58,9 +88,14 @@ def test_oauth_connect_buttons_disable_during_any_provider_connection():
def test_oauth_available_models_list_sits_above_advanced_without_borders():
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
assert "Available models from selected provider" in config_html
assert "activeModelsDescription()" in config_html
assert config_html.index("<h3>Providers</h3>") < config_html.index("Choose your models")
assert config_html.index("<h3>Providers</h3>") < config_html.index("<summary>Advanced</summary>")
assert config_html.index("Available models") < config_html.index("<summary>Advanced</summary>")
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 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
@ -75,6 +110,12 @@ def test_oauth_model_slots_reuse_model_config_api():
assert "isOauthProvider" in store_js
assert "providerCards()" in store_js
assert "saveModelConfigIfDirty" in store_js
assert "slotProviderChoices()" in store_js
assert "modelProviderOptionLabel(provider)" in store_js
assert "return this.connectedProviderCards();" in store_js
assert "if (!this.providerConnected(providerId)) return;" in store_js
assert "const providerId = slot.provider;" in store_js
assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
def test_browser_callback_completion_is_observed_from_modal():
@ -85,12 +126,28 @@ def test_browser_callback_completion_is_observed_from_modal():
assert "this.providerConnected(providerId)" in store_js
def test_usage_plan_catalog_is_visible_without_provider_cards():
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")
plans_py = (PROJECT_ROOT / "plugins/_oauth/helpers/usage_plans.py").read_text(encoding="utf-8")
assert "oauth-plan-catalog" in config_html
assert "oauth-plan-catalog" not in config_html
assert "usagePlanEntries" in store_js
assert "Google Gemini API" in plans_py
assert "GEMINI_API_PROVIDER_ID" in plans_py
assert "Google Gemini / Antigravity" not in plans_py
@ -120,9 +177,17 @@ def test_connected_codex_welcome_card_renders_usage_limit_bars():
).read_text(encoding="utf-8")
discovery_store = (PROJECT_ROOT / "plugins/_discovery/webui/discovery-store.js").read_text(encoding="utf-8")
assert "ChatGPT/Codex Connected" in discovery_cards
assert "discovery-oauth-accounts" in discovery_cards
assert "Your AI accounts" in discovery_cards
assert "Connected OAuth accounts" not in discovery_cards
assert "discovery-codex-oauth" not in discovery_cards
assert "5h and weekly limits are ready." not in discovery_cards
assert "account providers connected and ready for model selection" not in discovery_cards
assert '"icon": "account_circle"' not in discovery_cards
assert "usage_windows" in discovery_cards
assert "account_chips" in discovery_cards
assert "discovery-account-chip" in welcome_cards
assert 'x-show="card.thumbnail || card.icon"' in welcome_cards
assert "discovery-usage" in welcome_cards
assert "discovery-usage-bar" in welcome_cards
assert "formatRemainingPercent(window)" in welcome_cards + discovery_store

View file

@ -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,45 @@ 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_editor_toolbar_places_preview_toggle_left_and_save_on_right():
editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
toolbar_start = editor_panel.index('<div class="editor-toolbar"')
toolbar_end = editor_panel.index('<div class="editor-search-bar"', toolbar_start)
toolbar = editor_panel[toolbar_start:toolbar_end]
mode_toggle = toolbar.index("editor-mode-toggle")
source_tools = toolbar.index("editor-source-tools")
preview_tools = toolbar.index("editor-preview-tools")
spacer = toolbar.index("editor-toolbar-spacer")
save_button = toolbar.index("editor-save-button")
file_actions = toolbar.index("editor-file-actions")
file_menu = toolbar.index("editor-file-menu")
assert mode_toggle < source_tools
assert mode_toggle < preview_tools
assert spacer < save_button < file_actions < file_menu
assert "@click=\"$store.editor.save()\"" in toolbar
file_menu_markup = toolbar[file_menu:]
assert "<span>Save</span>" not in file_menu_markup
assert "<span>Rename</span>" in file_menu_markup
assert "<span>Close File</span>" in file_menu_markup
def test_office_and_desktop_skills_are_rehomed_and_renamed():
office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills"
desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills"

View file

@ -17,15 +17,26 @@ def test_onboarding_contains_guided_cloud_local_flow():
assert "Choose your local LLM provider" in html + store
assert "cloud-card.webp" in html
assert "local-card.webp" in html
assert "Connect ChatGPT/Codex Account" in html
assert "oauthProviderCards()" in html + store
assert "selectOAuthProvider(provider.provider_id)" in html
assert "path-oauth-account-section" in html
assert "or connect your AI account" in html
assert "Connect one or more account-backed providers." not in html
assert ".path-oauth-account-section .oauth-account-section-head" in html
assert "font-weight: 760;" in html
assert "Connect ChatGPT/Codex Account" not in html
assert "Main model" in html
assert "Refresh model list" in html
assert "Search or enter Utility Model" in html
assert "Advanced Settings" in html
assert "selectedProviderName() + ' Docs'" in html
assert "openSelectedProviderDocs" in html + store
assert "Connect via device code" in html + store
assert "accountActionLabel" in html + store
assert "Connect account" in html + store
assert "oauthAccountActionLabel" in html + store
assert "connectOAuth" in html + store
assert "submitOAuthManualCallback" in html + store
assert 'const OAUTH_START_API = "/plugins/_oauth/start_login";' in store
assert "/plugins/_oauth/start_device_login" not in store
assert "Click here if you don't see your provider" in html
assert "provider-description" not in html
assert "!$store.onboarding.isStep('cloud')" in html
@ -99,6 +110,7 @@ def test_onboarding_provider_grid_names_are_present_in_metadata():
assert forbidden not in provider_yaml
for logo in [
"deepseek.svg",
"google-gemini.svg",
"groq.svg",
"sambanova.png",
@ -131,11 +143,12 @@ def test_discovery_auto_modal_extension_contains_required_guards():
assert "auto_modal_surfaces" in content
def test_onboarding_success_filters_codex_discovery_card():
def test_onboarding_success_filters_oauth_discovery_cards():
content = (
PROJECT_ROOT
/ "plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html"
).read_text(encoding="utf-8")
assert "discovery-codex-oauth" in content
assert "filter(card => card.id !== 'discovery-codex-oauth')" in content
assert "discovery-oauth-accounts" in content
assert "includes(card.id)" in content

View file

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

View file

@ -112,12 +112,19 @@ const model = {
// Handle drop
document.addEventListener(
"drop",
(e) => {
console.log("Drop detected with files:", e.dataTransfer.files.length);
async (e) => {
const dataTransfer = e.dataTransfer;
console.log("Drop detected with files:", dataTransfer?.files?.length || 0);
dragCounter = 0;
this.hideDragDropOverlay();
const files = e.dataTransfer.files;
let files = [];
try {
files = await this.getDroppedFiles(dataTransfer);
} catch (error) {
console.error("Failed to read dropped files:", error);
files = Array.from(dataTransfer?.files || []);
}
this.handleFiles(files);
},
false
@ -209,10 +216,76 @@ const model = {
event.target.value = ""; // clear uploader selection to fix issue where same file is ignored the second time
},
async getDroppedFiles(dataTransfer) {
const items = Array.from(dataTransfer?.items || []);
const entries = items
.map((item) =>
typeof item.webkitGetAsEntry === "function"
? item.webkitGetAsEntry()
: null
)
.filter(Boolean);
if (!entries.length) {
return Array.from(dataTransfer?.files || []);
}
const nested = await Promise.all(
entries.map((entry) => this.readEntryFiles(entry))
);
const files = nested.flat();
return files.length ? files : Array.from(dataTransfer?.files || []);
},
async readEntryFiles(entry) {
if (entry.isFile) {
return await new Promise((resolve, reject) => {
entry.file(
(file) => resolve([file]),
(error) => reject(error)
);
});
}
if (!entry.isDirectory) return [];
const reader = entry.createReader();
const childEntries = await this.readAllDirectoryEntries(reader);
const nested = await Promise.all(
childEntries.map((child) => this.readEntryFiles(child))
);
return nested.flat();
},
async readAllDirectoryEntries(reader) {
const entries = [];
return await new Promise((resolve, reject) => {
const readBatch = () => {
reader.readEntries(
(batch) => {
if (!batch.length) {
resolve(entries);
return;
}
entries.push(...batch);
readBatch();
},
(error) => reject(error)
);
};
readBatch();
});
},
// File handling logic (moved from index.js)
handleFiles(files) {
console.log("handleFiles called with", files.length, "files");
Array.from(files).forEach((file) => {
const fileList = Array.from(files || []);
console.log("handleFiles called with", fileList.length, "files");
fileList.forEach((file) => {
if (!file?.name) return;
console.log("Processing file:", file.name, file.type);
const ext = file.name.split(".").pop().toLowerCase();
const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes(

View file

@ -17,7 +17,7 @@
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="dragdrop-overlay">
<img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
<div class="dragdrop-text">Drop files to attach them to your message</div>
<div class="dragdrop-text">Drop files or folders to attach them to your message</div>
<div class="dragdrop-subtext"></div>
</div>
</template>

View file

@ -11,20 +11,42 @@
<body>
<template x-if="$store.chatInput">
<div class="chat-bottom-actions-menu">
<label class="chat-bottom-menu-item" @click.stop>
<input
type="file"
id="chat-file-input"
class="chat-bottom-menu-file-input"
accept="*"
multiple
@change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
>
<input
type="file"
id="chat-file-input"
class="chat-bottom-menu-file-input"
accept="*"
multiple
@change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
>
<button
type="button"
class="chat-bottom-menu-item"
@click.stop="document.getElementById('chat-file-input')?.click()"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
<span>Add attachments</span>
</label>
<span>Attach files</span>
</button>
<input
type="file"
id="chat-folder-input"
class="chat-bottom-menu-file-input"
webkitdirectory
directory
multiple
@change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
>
<button
type="button"
class="chat-bottom-menu-item"
@click.stop="document.getElementById('chat-folder-input')?.click()"
>
<span class="material-symbols-outlined" aria-hidden="true">drive_folder_upload</span>
<span>Attach folder</span>
</button>
<button
type="button"
@ -94,10 +116,6 @@
button.chat-bottom-menu-item {
border: none;
}
label.chat-bottom-menu-item {
position: relative;
font-weight: inherit;
}
.chat-bottom-menu-file-input {
position: absolute;
width: 1px;
@ -110,13 +128,11 @@
border: 0;
opacity: 0;
}
button.chat-bottom-menu-item:hover:not(:disabled),
label.chat-bottom-menu-item:hover {
button.chat-bottom-menu-item:hover:not(:disabled) {
opacity: 1;
background-color: var(--color-background-hover);
}
button.chat-bottom-menu-item:active:not(:disabled) { opacity: 0.72; }
label.chat-bottom-menu-item:active { opacity: 0.72; }
button.chat-bottom-menu-item:disabled {
opacity: 0.38;
cursor: not-allowed;

View file

@ -1,9 +1,11 @@
import { createStore } from "/js/AlpineStore.js";
import { fetchApi } from "/js/api.js";
import { callJsonApi, fetchApi } from "/js/api.js";
import { formatDateTime } from "/js/time-utils.js";
import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
import { openLatest as openLatestSurface } from "/js/surfaces.js";
const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"]);
const BROWSER_EXTENSIONS = new Set([
@ -60,6 +62,12 @@ const model = {
initialPath: "", // Store path for open() call
closePromise: null,
error: null,
pathInput: "",
pathError: "",
isPathSubmitting: false,
rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
settingsLoadPromise: null,
settingsUpdatedHandler: null,
renameTarget: null,
renameName: "",
renameMode: "rename",
@ -74,7 +82,14 @@ const model = {
// --- Lifecycle -----------------------------------------------------------
init() {
// Nothing special to do here; all methods available immediately
if (this.settingsUpdatedHandler) return;
this.settingsUpdatedHandler = (event) => {
const value = event?.detail?.file_browser_remember_last_directory;
if (typeof value !== "boolean") return;
this.rememberLastDirectory = value;
if (!value) this.clearRememberedDirectory();
};
document.addEventListener("settings-updated", this.settingsUpdatedHandler);
},
// --- Public API (called from button/link) --------------------------------
@ -85,6 +100,8 @@ const model = {
this.history = [];
this.searchQuery = "";
this.isBulkBusy = false;
this.pathError = "";
this.isPathSubmitting = false;
try {
// Open modal FIRST (immediate UI feedback)
@ -92,12 +109,22 @@ const model = {
"modals/file-browser/file-browser.html"
);
// Use stored initial path or default
path = path || this.initialPath || this.browser.currentPath || "$WORK_DIR";
await this.loadDirectoryPreference();
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
path = explicitPath || rememberedPath || "$WORK_DIR";
this.browser.currentPath = path;
this.syncPathInput();
// Fetch files
await this.fetchFiles(this.browser.currentPath);
const loaded = await this.fetchFiles(this.browser.currentPath, {
preserveOnError: Boolean(rememberedPath && path === rememberedPath),
suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
});
if (!loaded && rememberedPath && path === rememberedPath) {
this.clearRememberedDirectory();
await this.fetchFiles("$WORK_DIR");
}
// await modal close
await this.closePromise;
@ -112,6 +139,7 @@ const model = {
handleClose() {
// Close the modal manually
this.disposeScopedTooltips();
window.closeModal();
},
@ -124,6 +152,9 @@ const model = {
this.openDropdownPath = null;
this.searchQuery = "";
this.isBulkBusy = false;
this.pathInput = "";
this.pathError = "";
this.isPathSubmitting = false;
this.resetRenameState();
},
@ -249,6 +280,85 @@ const model = {
});
},
normalizeOpeningPath(path) {
return String(path || "").trim();
},
normalizeSubmittedPath(path) {
const trimmed = String(path || "").trim();
if (!trimmed || trimmed === "$WORK_DIR") return trimmed;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
},
syncPathInput() {
this.pathInput = this.browser.currentPath || "";
},
resetPathInput() {
this.syncPathInput();
this.pathError = "";
},
async loadDirectoryPreference() {
if (this.settingsLoadPromise) return await this.settingsLoadPromise;
this.settingsLoadPromise = (async () => {
try {
const response = await callJsonApi("settings_get", null);
const remember = response?.settings?.file_browser_remember_last_directory;
this.rememberLastDirectory =
typeof remember === "boolean" ? remember : DEFAULT_REMEMBER_LAST_DIRECTORY;
} catch (error) {
console.warn("Failed to load file browser directory preference:", error);
this.rememberLastDirectory = DEFAULT_REMEMBER_LAST_DIRECTORY;
} finally {
if (!this.rememberLastDirectory) this.clearRememberedDirectory();
this.settingsLoadPromise = null;
}
return this.rememberLastDirectory;
})();
return await this.settingsLoadPromise;
},
getRememberedDirectory() {
if (!this.rememberLastDirectory) return "";
try {
return localStorage.getItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY) || "";
} catch {
return "";
}
},
rememberCurrentDirectory(path = this.browser.currentPath) {
if (!this.rememberLastDirectory) return;
const directory = this.normalizeOpeningPath(path);
if (!directory || directory === "$WORK_DIR") return;
try {
localStorage.setItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY, directory);
} catch {}
},
clearRememberedDirectory() {
try {
localStorage.removeItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY);
} catch {}
},
disposeScopedTooltips() {
const root = document.querySelector(".file-browser-root");
const tooltipApi = globalThis.bootstrap?.Tooltip;
if (!root || !tooltipApi) return;
root.querySelectorAll("[data-bs-tooltip-initialized]").forEach((element) => {
const instance = tooltipApi.getInstance(element);
try {
instance?.dispose();
} catch {}
});
document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove());
},
// --- Modal helpers -------------------------------------------------------
normalizePath(path) {
if (!path) return "";
@ -380,7 +490,9 @@ const model = {
},
// --- Navigation ----------------------------------------------------------
async fetchFiles(path = "") {
async fetchFiles(path = "", options = {}) {
const preserveOnError = options?.preserveOnError === true;
const suppressErrorToast = options?.suppressErrorToast === true;
this.isLoading = true;
// Preserve scroll position if refreshing the same path
@ -397,14 +509,31 @@ const model = {
);
const data = await response.json().catch(() => ({}));
if (response.ok && !data.error) {
const result = data.data || {};
const requestedPath = String(path || "");
const resultError =
data.error ||
result.error ||
(
requestedPath &&
requestedPath !== "$WORK_DIR" &&
!result.current_path &&
!(result.entries || []).length
? "Directory not found or not accessible"
: ""
);
if (response.ok && !resultError) {
if (!isSamePath) this.searchQuery = "";
this.browser.entries = this.decorateEntries(
data.data.entries || [],
result.entries || [],
selectedPaths
);
this.browser.currentPath = data.data.current_path;
this.browser.parentPath = data.data.parent_path;
this.browser.currentPath = result.current_path;
this.browser.parentPath = result.parent_path;
this.syncPathInput();
this.pathError = "";
this.rememberCurrentDirectory(this.browser.currentPath);
// Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
this.isLoading = false;
@ -413,20 +542,23 @@ const model = {
if (scrollPos) {
this.restoreScrollPosition(scrollPos);
}
return true;
} else {
const msg = data.error || "Error fetching files";
const msg = resultError || "Error fetching files";
console.error("Error fetching files:", msg);
this.browser.entries = [];
if (!preserveOnError) this.browser.entries = [];
this.isLoading = false;
window.toastFrontendError(msg, "File Browser Error");
if (!suppressErrorToast) window.toastFrontendError(msg, "File Browser Error");
return false;
}
} catch (e) {
window.toastFrontendError(
"Error fetching files: " + e.message,
"File Browser Error"
);
this.browser.entries = [];
const message = "Error fetching files: " + e.message;
if (!suppressErrorToast) {
window.toastFrontendError(message, "File Browser Error");
}
if (!preserveOnError) this.browser.entries = [];
this.isLoading = false;
return false;
}
},
@ -437,6 +569,38 @@ const model = {
await this.fetchFiles(path);
},
async submitPath() {
if (this.isPathSubmitting || this.isLoading) return;
const path = this.normalizeSubmittedPath(this.pathInput);
if (!path) {
this.pathError = "Enter a directory path.";
return;
}
this.isPathSubmitting = true;
this.pathError = "";
try {
const previousPath = this.browser.currentPath;
const loaded = await this.fetchFiles(path, {
preserveOnError: true,
suppressErrorToast: true,
});
if (loaded) {
if (previousPath && previousPath !== this.browser.currentPath) {
this.history.push(previousPath);
}
return;
}
this.pathError = "Directory not found or not accessible.";
} finally {
this.isPathSubmitting = false;
}
},
async navigateUp() {
if (this.browser.parentPath) {
this.history.push(this.browser.currentPath);
@ -855,6 +1019,7 @@ const model = {
}
}
this.disposeScopedTooltips();
await window.closeModal?.("modals/file-browser/file-browser.html");
} catch (error) {
window.toastFrontendError?.(

View file

@ -19,14 +19,41 @@
<!-- File Browser Content -->
<div x-show="!$store.fileBrowser.isLoading" class="file-browser-content">
<!-- Path navigator -->
<div class="path-navigator">
<button class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
<path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
Up
</button>
<div id="current-path"><span id="path-text" x-text="$store.fileBrowser.browser.currentPath"></span></div>
<div class="path-navigator-wrap">
<form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
<button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
<path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
Up
</button>
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
<input
id="current-path"
class="path-input"
type="text"
x-model="$store.fileBrowser.pathInput"
@focus="$event.target.select()"
@keydown.escape.stop.prevent="$store.fileBrowser.resetPathInput()"
:aria-invalid="$store.fileBrowser.pathError ? 'true' : 'false'"
aria-label="Directory path"
spellcheck="false"
autocomplete="off"
/>
<button
type="submit"
class="btn-icon-action path-submit"
:disabled="$store.fileBrowser.isPathSubmitting || $store.fileBrowser.isLoading"
aria-label="Go to directory"
>
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
</form>
<template x-if="$store.fileBrowser.pathError">
<div class="path-error" x-text="$store.fileBrowser.pathError"></div>
</template>
</div>
<div class="file-browser-toolbar">
<div class="file-search-shell">
@ -442,11 +469,17 @@
}
/* Path Navigator Styles */
.path-navigator-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.path-navigator {
overflow: hidden;
display: flex;
align-items: center;
gap: 24px;
gap: 0.75rem;
background-color: var(--color-message-bg);
padding: 0.5rem var(--spacing-sm);
margin: 0;
@ -454,6 +487,75 @@
border-radius: 8px;
}
.path-navigator .back-button {
flex: 0 0 auto;
}
.path-input-shell {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.path-input-icon {
position: absolute;
left: 0.75rem;
font-size: 1.15rem;
color: var(--color-primary);
opacity: 0.8;
pointer-events: none;
}
.path-input {
width: 100%;
height: 2.25rem;
min-width: 0;
border: 1px solid transparent;
border-radius: 6px;
background: color-mix(in srgb, var(--color-input) 78%, transparent);
color: var(--color-text);
padding: 0 2.5rem 0 2.35rem;
font: inherit;
font-family: 'Roboto Mono', monospace;
-webkit-font-optical-sizing: auto;
font-optical-sizing: auto;
line-height: 1;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.path-input:focus {
outline: none;
border-color: var(--color-primary);
background: var(--color-input-focus);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.path-input-shell.has-error .path-input {
border-color: var(--color-error);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-error) 18%, transparent);
}
.path-submit {
position: absolute;
right: 0.35rem;
width: 1.75rem;
height: 1.75rem;
color: var(--color-text);
}
.path-submit:disabled {
opacity: 0.5;
cursor: wait;
}
.path-error {
color: var(--color-error);
font-size: 0.8rem;
padding: 0 0.25rem;
}
.file-browser-toolbar {
display: flex;
align-items: center;
@ -549,16 +651,6 @@
.nav-button.back-button:hover {
background-color: var(--color-secondary-dark);
}
#current-path {
opacity: 0.9;
}
#path-text {
font-family: 'Roboto Mono', monospace;
-webkit-font-optical-sizing: auto;
font-optical-sizing: auto;
opacity: 0.9;
}
/* Folder Specific Styles */
.file-item[data-is-dir="true"] {
cursor: pointer;
@ -621,6 +713,14 @@
}
/* Responsive Design */
@media (max-width: 768px) {
.path-navigator {
align-items: stretch;
flex-direction: column;
gap: 0.5rem;
}
.path-navigator .back-button {
align-self: flex-start;
}
.file-browser-toolbar {
align-items: stretch;
flex-direction: column;

View file

@ -28,6 +28,21 @@
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Remember last file browser location</div>
<div class="field-description">
Open the file browser at the most recently visited directory when no specific path is requested.
</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="$store.settings.settings.file_browser_remember_last_directory" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Show workdir structure to the agent</div>

View file

@ -266,10 +266,20 @@ function _normalizeApiUrl(url) {
}
function redirect(response) {
if (!(response.redirected && response.url.endsWith("/login"))) return false;
if (!response.redirected) return false;
const _redirectUrl = new URL(response.url);
if (_redirectUrl.origin === window.location.origin) {
window.location.href = response.url;
if (
_redirectUrl.origin === window.location.origin &&
_redirectUrl.pathname === "/login"
) {
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (currentUrl && currentUrl !== "/login") {
_redirectUrl.searchParams.set("next", currentUrl);
}
window.location.href = _redirectUrl.toString();
return true;
}
return true;
}
return false;
}

View file

@ -9,7 +9,10 @@
</head>
<body>
<div class="login-container">
<form class="login-form" method="POST" action="/login">
<form class="login-form" method="POST" action="/login{% if next %}?next={{ next|urlencode }}{% endif %}">
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<img src="/public/splash.jpg" alt="Agent Zero Logo" class="logo">
<h2>Agent Zero</h2>
<div class="input-group">