mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 22:02:20 +02:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7cbc885c1 | ||
|
|
cee319050c | ||
|
|
0dd70a7556 | ||
|
|
9c71948376 | ||
|
|
18991d6f67 | ||
|
|
79b891c7ee | ||
|
|
60bed54703 | ||
|
|
443f7d2963 | ||
|
|
2c394704c6 | ||
|
|
d401e806d4 | ||
|
|
105a7c0d96 | ||
|
|
73a4b10642 | ||
|
|
94cf119b11 | ||
|
|
7a138e8a3f | ||
|
|
2b72531eaa | ||
|
|
58b2a4bfa9 | ||
|
|
fd50561af6 | ||
|
|
1b09c568d8 | ||
|
|
2811c7e815 | ||
|
|
1f216cfd0e | ||
|
|
b715b81ad0 | ||
|
|
05442a9945 | ||
|
|
2295504141 | ||
|
|
329f9d298d | ||
|
|
fef0e6f3c0 | ||
|
|
f9235ebbf1 | ||
|
|
49e4e55d2c | ||
|
|
b2789d04fb | ||
|
|
a6bc86e331 | ||
|
|
c4369305f0 |
@@ -76,6 +76,14 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# Change this if another local service already uses 7000 (macOS AirPlay often does).
|
||||
# APP_PORT=7000
|
||||
|
||||
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
|
||||
# when Docker would otherwise advertise a container address or loopback. Use a
|
||||
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
|
||||
# name that the phone can reach. HTTPS and public hostnames are not supported
|
||||
# by the current companion client. Do not include credentials, a path, query,
|
||||
# or fragment.
|
||||
# COMPANION_BASE_URL=http://192.168.1.50:7000
|
||||
|
||||
# Development-only auth bypass for loopback requests.
|
||||
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
||||
# LOCALHOST_BYPASS=false
|
||||
|
||||
@@ -59,7 +59,12 @@ Help is welcome. The best entry points are fresh-install testing, provider setup
|
||||
|
||||
## Security
|
||||
|
||||
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
||||
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly.
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
|
||||
Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is
|
||||
|
||||
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
|
||||
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
|
||||
- **Reserved usernames:** `internal-tool`, `api`, `demo`, `system` cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
||||
- **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
||||
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
|
||||
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
|
||||
|
||||
|
||||
@@ -67,7 +67,13 @@ from core.constants import (
|
||||
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
|
||||
)
|
||||
from core.database import SessionLocal, ApiToken
|
||||
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
||||
from core.middleware import (
|
||||
SecurityHeadersMiddleware,
|
||||
get_application_route_path,
|
||||
is_cors_preflight,
|
||||
path_is_route_or_child,
|
||||
with_asgi_root_path,
|
||||
)
|
||||
from core.auth import AuthManager, normalize_known_username
|
||||
from core.exceptions import (
|
||||
SessionNotFoundError, InvalidFileUploadError,
|
||||
@@ -78,6 +84,7 @@ import bcrypt as _bcrypt
|
||||
|
||||
from src.app_helpers import abs_join, serve_html_with_nonce
|
||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||
from src.owner_identity import auth_disabled
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
# ========= LOGGING =========
|
||||
@@ -248,7 +255,7 @@ from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
||||
|
||||
auth_manager = AuthManager()
|
||||
app.state.auth_manager = auth_manager
|
||||
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false"
|
||||
AUTH_ENABLED = not auth_disabled()
|
||||
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
|
||||
if LOCALHOST_BYPASS:
|
||||
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
|
||||
@@ -284,7 +291,7 @@ if AUTH_ENABLED:
|
||||
def _is_auth_exempt(path: str) -> bool:
|
||||
if path in AUTH_EXEMPT_EXACT:
|
||||
return True
|
||||
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES):
|
||||
if any(path_is_route_or_child(path, p) for p in AUTH_EXEMPT_PREFIXES):
|
||||
return True
|
||||
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
|
||||
|
||||
@@ -355,7 +362,7 @@ if AUTH_ENABLED:
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
path = get_application_route_path(request.scope)
|
||||
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
|
||||
# carries no credentials by design and must reach CORSMiddleware to be
|
||||
# answered. AuthMiddleware is the outermost middleware, so gating the
|
||||
@@ -399,7 +406,10 @@ if AUTH_ENABLED:
|
||||
if not auth_manager.is_configured:
|
||||
# No users yet — redirect to login for first-time setup
|
||||
if not path.startswith("/api/"):
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
return RedirectResponse(
|
||||
url=with_asgi_root_path(request.scope, "/login"),
|
||||
status_code=302,
|
||||
)
|
||||
return JSONResponse(status_code=401, content={"error": "Setup required"})
|
||||
|
||||
# --- Bearer token auth (API tokens for external integrations) ---
|
||||
@@ -461,7 +471,10 @@ if AUTH_ENABLED:
|
||||
if not auth_manager.validate_token(token):
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
return RedirectResponse(
|
||||
url=with_asgi_root_path(request.scope, "/login"),
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
# Attach current username to request state for downstream routes
|
||||
request.state.current_user = auth_manager.get_username_for_token(token)
|
||||
@@ -630,13 +643,24 @@ app.include_router(auth_router)
|
||||
|
||||
@app.post("/api/activity/heartbeat")
|
||||
async def activity_heartbeat():
|
||||
from src.interactive_gate import mark_browser_activity
|
||||
from src.interactive_gate import (
|
||||
mark_browser_activity,
|
||||
maybe_stop_background_tasks_for_heartbeat,
|
||||
)
|
||||
|
||||
await mark_browser_activity()
|
||||
|
||||
async def _stop_background():
|
||||
try:
|
||||
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
|
||||
await maybe_stop_background_tasks_for_heartbeat(
|
||||
task_scheduler.stop_background_tasks_for_foreground
|
||||
)
|
||||
except Exception:
|
||||
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
|
||||
logging.getLogger("app.foreground_gate").debug(
|
||||
"heartbeat task stop failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_stop_background())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ units so the route layer stays thin and the logic is directly testable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import uuid
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import bcrypt
|
||||
|
||||
@@ -20,6 +23,102 @@ PAIRING_VERSION = 1
|
||||
COMPANION_SCOPE = "chat"
|
||||
|
||||
|
||||
_COMPANION_IPV4_NETWORKS = tuple(
|
||||
ipaddress.ip_network(cidr)
|
||||
for cidr in (
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
)
|
||||
)
|
||||
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
||||
|
||||
|
||||
def _valid_companion_client_host(host: str) -> bool:
|
||||
"""Match the host forms supported by the current v1 Expo client."""
|
||||
if not host or len(host) > 253 or not host.isascii() or "%" in host:
|
||||
return False
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
labels = host.split(".")
|
||||
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
|
||||
return False
|
||||
if any(label.startswith("xn--") for label in labels):
|
||||
return False
|
||||
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
|
||||
# as an IPv4 number even though Python's strict ``ipaddress`` parser
|
||||
# rejects that spelling. The v1 client interpolates this host back
|
||||
# into a URL, so accepting e.g. ``134744072`` would make the phone send
|
||||
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
|
||||
if len(labels) == 1 and (
|
||||
labels[0].isdigit()
|
||||
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
|
||||
):
|
||||
return False
|
||||
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
|
||||
|
||||
return isinstance(address, ipaddress.IPv4Address) and any(
|
||||
address in network for network in _COMPANION_IPV4_NETWORKS
|
||||
)
|
||||
|
||||
|
||||
def parse_companion_base_url(value: str) -> tuple[str, int]:
|
||||
"""Validate a v1 companion address and return its legacy (host, port).
|
||||
|
||||
The deployed client understands only HTTP plus a LAN-style host and port.
|
||||
Reject anything outside that exact contract instead of advertising a URL
|
||||
the client would reject, downgrade, or interpret differently.
|
||||
"""
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||
if not value.isascii():
|
||||
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
|
||||
if any(
|
||||
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
|
||||
for char in value
|
||||
):
|
||||
raise ValueError(
|
||||
"COMPANION_BASE_URL contains a forbidden character"
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
|
||||
|
||||
host = parsed.hostname
|
||||
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
|
||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("COMPANION_BASE_URL must not contain credentials")
|
||||
if parsed.path or parsed.query or parsed.fragment:
|
||||
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
|
||||
if not _valid_companion_client_host(host):
|
||||
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
|
||||
|
||||
netloc = f"{host}:{port}" if port is not None else host
|
||||
origin = f"http://{netloc}"
|
||||
if value != origin:
|
||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||
return host, port or 80
|
||||
|
||||
|
||||
def configured_companion_origin() -> tuple[str, int] | None:
|
||||
"""Return the validated operator-configured v1 address, if any."""
|
||||
value = os.environ.get("COMPANION_BASE_URL")
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return parse_companion_base_url(value)
|
||||
|
||||
|
||||
def default_port() -> int:
|
||||
"""Best guess at the port the server is reachable on. Callers that know the
|
||||
real request port should pass it explicitly."""
|
||||
|
||||
+23
-8
@@ -23,7 +23,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
|
||||
from companion import pairing as _pairing
|
||||
|
||||
@@ -113,8 +113,9 @@ def setup_companion_routes() -> APIRouter:
|
||||
The stock /api/models route scopes to get_current_user, which for a
|
||||
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
|
||||
scope to the token's real owner instead, plus legacy null-owner shared
|
||||
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
||||
material.
|
||||
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
|
||||
the stock route's single-user all-endpoints view. Read-only; never
|
||||
returns api_key material.
|
||||
"""
|
||||
require_models_scope(request)
|
||||
import json as _json
|
||||
@@ -123,6 +124,11 @@ def setup_companion_routes() -> APIRouter:
|
||||
from src.endpoint_resolver import build_chat_url
|
||||
|
||||
owner = token_owner(request)
|
||||
single_user_mode = (
|
||||
owner is None
|
||||
and not getattr(request.state, "api_token", False)
|
||||
and _auth_disabled()
|
||||
)
|
||||
out = []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -133,7 +139,7 @@ def setup_companion_routes() -> APIRouter:
|
||||
if owner:
|
||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||
for ep in q.all():
|
||||
if not owner_can_see(ep.owner, owner):
|
||||
if not single_user_mode and not owner_can_see(ep.owner, owner):
|
||||
continue
|
||||
try:
|
||||
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
|
||||
@@ -194,19 +200,27 @@ def setup_companion_routes() -> APIRouter:
|
||||
the code works immediately, no restart. `?format=json` returns the
|
||||
payload for an in-app pairing screen."""
|
||||
require_admin(request)
|
||||
try:
|
||||
configured_origin = _pairing.configured_companion_origin()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(500, str(exc)) from None
|
||||
owner = get_current_user(request)
|
||||
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
||||
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
||||
|
||||
hosts = _pairing.lan_ip_candidates()
|
||||
host = hosts[0] if hosts else "127.0.0.1"
|
||||
port = request.url.port or _pairing.default_port()
|
||||
if configured_origin:
|
||||
host, port = configured_origin
|
||||
hosts = [host]
|
||||
else:
|
||||
hosts = _pairing.lan_ip_candidates()
|
||||
host = hosts[0] if hosts else "127.0.0.1"
|
||||
port = request.url.port or _pairing.default_port()
|
||||
payload = _pairing.pairing_payload(host, port, raw_token)
|
||||
qr = _pairing.pairing_qr_png_data_uri(payload)
|
||||
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
||||
|
||||
if (request.query_params.get("format") or "").lower() == "json":
|
||||
return {
|
||||
response = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": raw_token,
|
||||
@@ -215,6 +229,7 @@ def setup_companion_routes() -> APIRouter:
|
||||
"payload": payload,
|
||||
"qr": qr if qr_ok else None,
|
||||
}
|
||||
return response
|
||||
|
||||
import json as _json
|
||||
payload_json = _json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
+9
-16
@@ -20,7 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
||||
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
|
||||
|
||||
DEFAULT_PRIVILEGES = {
|
||||
"can_use_agent": True,
|
||||
@@ -49,24 +48,18 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
||||
# sentinels; they must never belong to a real account. The most dangerous is
|
||||
# "internal-tool": `core.middleware.require_admin` treats any request whose
|
||||
# `current_user == "internal-tool"` as the in-process tool loopback and grants
|
||||
# admin, and because the cookie auth path sets `current_user` to the raw
|
||||
# username, an account literally named "internal-tool" would be silently
|
||||
# treated as an admin by every `require_admin`-gated route. "api" collides with
|
||||
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
|
||||
# synthetic-owner set the rest of the codebase already special-cases (see
|
||||
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
|
||||
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
|
||||
# of those names would be denied an assistant and inconsistently owner-scoped.
|
||||
# Refuse to create or rename into any of them so the sentinels can't be
|
||||
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
||||
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
||||
# Usernames the auth + middleware layer reserves for request sentinels and
|
||||
# internal storage owners; they must never belong to a real login account.
|
||||
# "internal-tool" is the most dangerous because `core.middleware.require_admin`
|
||||
# treats it as the in-process tool loopback. "api" collides with bearer-token
|
||||
# attribution. "demo"/"system" are synthetic owners already special-cased by
|
||||
# scheduler/assistant/research paths. The Default/Local owner is a storage
|
||||
# bucket for explicit auth-disabled no-login mode, not a login username.
|
||||
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
||||
|
||||
|
||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||
|
||||
+19
-2
@@ -1491,8 +1491,25 @@ def _migrate_assign_legacy_owner():
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
prefs = _json.load(f)
|
||||
if "_users" not in prefs and prefs:
|
||||
# Flat format → nest under admin user
|
||||
new_prefs = {"_users": {admin_user: prefs}}
|
||||
# Flat format → nest ordinary preferences under the admin
|
||||
# user. Foreground fallback is an explicit per-owner opt-in,
|
||||
# so auth-disabled consent must remain inert at the flat root
|
||||
# rather than becoming consent for the first named owner.
|
||||
foreground_keys = {
|
||||
"foreground_fallback_enabled",
|
||||
"foreground_model_fallbacks",
|
||||
}
|
||||
named_prefs = {
|
||||
key: value
|
||||
for key, value in prefs.items()
|
||||
if key not in foreground_keys
|
||||
}
|
||||
new_prefs = {
|
||||
key: prefs[key]
|
||||
for key in foreground_keys
|
||||
if key in prefs
|
||||
}
|
||||
new_prefs["_users"] = {admin_user: named_prefs}
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
_json.dump(new_prefs, f, indent=2)
|
||||
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
||||
|
||||
+29
-3
@@ -3,10 +3,14 @@
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import get_route_path
|
||||
|
||||
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
|
||||
|
||||
|
||||
# Per-process token that lets the in-app tool layer hit admin-gated
|
||||
@@ -15,8 +19,30 @@ from starlette.responses import Response
|
||||
# same value from this module. Never persisted or exposed externally.
|
||||
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
|
||||
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
||||
# Pseudo-username on in-process tool-loopback requests; require_admin trusts it and it is reserved.
|
||||
INTERNAL_TOOL_USER = "internal-tool"
|
||||
|
||||
|
||||
def get_application_route_path(scope: Mapping[str, object]) -> str:
|
||||
"""Return the application-relative path used by Starlette routing.
|
||||
|
||||
Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``;
|
||||
Starlette removes that prefix before matching routes. Middleware policy
|
||||
must use the same path form or a deployment prefix can change which policy
|
||||
applies to an otherwise unchanged application route.
|
||||
"""
|
||||
return get_route_path(scope)
|
||||
|
||||
|
||||
def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str:
|
||||
"""Prefix an application path for a client-facing redirect target."""
|
||||
root_path = scope.get("root_path", "")
|
||||
if not isinstance(root_path, str) or not root_path:
|
||||
return path
|
||||
return f"{root_path.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def path_is_route_or_child(path: str, prefix: str) -> bool:
|
||||
"""Return whether ``path`` is exactly ``prefix`` or below that route."""
|
||||
return path == prefix or path.startswith(prefix + "/")
|
||||
|
||||
|
||||
def is_cors_preflight(method: str, headers) -> bool:
|
||||
@@ -47,7 +73,7 @@ def require_admin(request: Request):
|
||||
pass
|
||||
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if os.getenv("AUTH_ENABLED", "true").lower() == "false":
|
||||
if auth_disabled():
|
||||
return
|
||||
if not auth_mgr or not auth_mgr.is_configured:
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||
@@ -129,12 +130,17 @@ services:
|
||||
fi
|
||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||
fi
|
||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||
# not be what stops searxng from booting. It explains itself on stderr
|
||||
# and we carry on, letting searxng report anything genuinely wrong.
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||
@@ -132,12 +133,17 @@ services:
|
||||
fi
|
||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||
fi
|
||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||
# not be what stops searxng from booting. It explains itself on stderr
|
||||
# and we carry on, letting searxng report anything genuinely wrong.
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||
@@ -110,12 +111,17 @@ services:
|
||||
fi
|
||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||
fi
|
||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||
# not be what stops searxng from booting. It explains itself on stderr
|
||||
# and we carry on, letting searxng report anything genuinely wrong.
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
|
||||
+148
@@ -497,6 +497,154 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
#### Faster over the network: HTTP/2
|
||||
|
||||
The frontend is raw ES modules with no bundler, so a page load is a few hundred
|
||||
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
|
||||
number of concurrent connections per host (commonly around six), so many of
|
||||
those requests are serialized across multiple round trips. On localhost that
|
||||
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
|
||||
part of load time, especially as latency increases.
|
||||
|
||||
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
|
||||
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
|
||||
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
|
||||
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
|
||||
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
|
||||
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
|
||||
HTTPS but not HTTP/2 — uvicorn does not speak it.
|
||||
|
||||
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
|
||||
for your platform; on macOS, `brew install caddy`.
|
||||
|
||||
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
|
||||
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
|
||||
uses `7860`.
|
||||
|
||||
Public domain, Caddy obtains and renews the certificate itself:
|
||||
|
||||
```
|
||||
odysseus.example.com {
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
|
||||
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
|
||||
|
||||
```bash
|
||||
tailscale cert myhost.tailnet-name.ts.net
|
||||
```
|
||||
|
||||
```
|
||||
myhost.tailnet-name.ts.net {
|
||||
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
LAN with your own certificate — same shape, your own files:
|
||||
|
||||
```
|
||||
odysseus.lan {
|
||||
tls /path/to/cert.pem /path/to/key.pem
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
Give `tls` absolute paths: a service starts in a working directory you did not
|
||||
choose. If port 443 is already taken, append a port to the site address
|
||||
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
|
||||
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
|
||||
start with `listen tcp :80: bind: address already in use` if something else
|
||||
holds it. Turn the redirect off with a global block at the top of the file:
|
||||
|
||||
```
|
||||
{
|
||||
auto_https disable_redirects
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run it in the foreground first:**
|
||||
|
||||
```bash
|
||||
caddy run --config ./Caddyfile
|
||||
```
|
||||
|
||||
Once that works, run it as a service:
|
||||
|
||||
```bash
|
||||
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
|
||||
sudo systemctl enable --now caddy # Linux, if your package installed the unit
|
||||
```
|
||||
|
||||
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
||||
run the proxy as another container, or on the host pointing at the published
|
||||
port.
|
||||
|
||||
**4. Point Odysseus at the new origin** in `.env`, then restart it:
|
||||
|
||||
```bash
|
||||
SECURE_COOKIES=true
|
||||
# only if you use remote MCP servers with OAuth:
|
||||
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
||||
```
|
||||
|
||||
Gmail OAuth needs nothing here when the proxy runs on the same host: the
|
||||
redirect URI is built from the incoming request, and uvicorn rewrites the
|
||||
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
|
||||
`127.0.0.1`. A proxy in a separate container or on another machine is not
|
||||
trusted, so pin the URI there:
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
|
||||
```
|
||||
|
||||
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
|
||||
environment uvicorn starts with — `.env` is read by the app afterwards, too
|
||||
late for it to take effect.)
|
||||
|
||||
**5. Confirm HTTP/2 is really on:**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
|
||||
# 2
|
||||
```
|
||||
|
||||
The status code is not the thing to check here — a logged-out request redirects
|
||||
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
|
||||
the part that matters. The browser reports the same in the Network panel's
|
||||
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
|
||||
enable it by right-clicking the column headers.
|
||||
|
||||
Three things bite when moving an existing install behind TLS:
|
||||
|
||||
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
|
||||
not before. The flag is applied to every login regardless of the scheme the
|
||||
request arrived on, so while an HTTP entrypoint is still reachable the
|
||||
browser will reject the `Secure` cookie there and login will appear to loop.
|
||||
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
||||
Gmail redirect URI it cannot be derived from a request — it is registered
|
||||
with each MCP authorization server up front — so set it to the external
|
||||
origin if you use remote MCP servers over OAuth.
|
||||
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
|
||||
https`. HSTS applies to the whole hostname and ignores the port, so any other
|
||||
plain-HTTP service on that same hostname becomes unreachable in browsers that
|
||||
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
|
||||
the proxy (`header_down -Strict-Transport-Security` in Caddy).
|
||||
|
||||
Server-sent events are not buffered by this configuration, so chat streaming
|
||||
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
|
||||
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
|
||||
for the same reason.
|
||||
|
||||
Changing the external origin also affects state scoped to it. Service workers
|
||||
and their caches are origin-scoped, so moving to a different origin starts with
|
||||
a cold load. Cookies follow their own domain/path/security rules rather than
|
||||
being port-scoped: changing the hostname normally requires a new login, while
|
||||
changing only the scheme or port does not by itself guarantee that existing
|
||||
cookies disappear.
|
||||
|
||||
Common internal-only ports from the default docs/compose setup:
|
||||
|
||||
| Port | Service |
|
||||
|
||||
@@ -1802,7 +1802,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
except Exception as exc:
|
||||
@@ -1843,13 +1842,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
||||
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
||||
for cand in utility_fallbacks:
|
||||
_add(*cand)
|
||||
try:
|
||||
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
|
||||
except TypeError:
|
||||
chat_fallbacks = resolve_chat_fallback_candidates() or []
|
||||
for cand in chat_fallbacks:
|
||||
_add(*cand)
|
||||
|
||||
if not candidates:
|
||||
return {"error": "No LLM endpoint configured for AI reply"}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||
from src.auth_helpers import get_current_user
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.task_scheduler import compute_next_run
|
||||
|
||||
|
||||
@@ -90,11 +90,12 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
||||
# check-in tasks seeded. Hitting any /assistant route under one of these
|
||||
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
||||
# owner, which then double-fired alongside the real user's check-ins.
|
||||
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "".
|
||||
# REQUEST_SENTINEL_OWNERS covers request-only identities; Default/Local is a
|
||||
# reserved login name but remains a valid storage owner.
|
||||
|
||||
async def _get_or_create(owner: str) -> CrewMember:
|
||||
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
||||
if not owner or owner in RESERVED_USERNAMES:
|
||||
if not owner or owner in REQUEST_SENTINEL_OWNERS:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
@@ -22,6 +22,8 @@ from src.settings import (
|
||||
load_features as _load_features,
|
||||
save_features as _save_features,
|
||||
DEFAULT_SETTINGS,
|
||||
RETIRED_SETTING_KEYS,
|
||||
without_retired_settings,
|
||||
)
|
||||
from src.integrations import (
|
||||
load_integrations,
|
||||
@@ -689,7 +691,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
a scrubbed copy with secret keys blanked. The frontend uses this
|
||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||
user = _get_current_user(request)
|
||||
settings = _load_settings()
|
||||
settings = without_retired_settings(_load_settings())
|
||||
if user and auth_manager.is_admin(user):
|
||||
return settings
|
||||
return scrub_settings(settings)
|
||||
@@ -709,6 +711,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||
}
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in RETIRED_SETTING_KEYS:
|
||||
continue
|
||||
if key not in body:
|
||||
continue
|
||||
val = body[key]
|
||||
@@ -721,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
val = max(lo, min(val, hi))
|
||||
current[key] = val
|
||||
_save_settings(current)
|
||||
return current
|
||||
return without_retired_settings(current)
|
||||
|
||||
# ---- Integrations CRUD ----
|
||||
|
||||
|
||||
+47
-100
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
||||
from src.llm_core import normalize_model_id
|
||||
from src.endpoint_resolver import normalize_base
|
||||
from src.context_compactor import maybe_compact, trim_for_context
|
||||
from src.model_context import estimate_tokens
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
from src.auth_helpers import effective_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.attachment_refs import attachment_ref
|
||||
@@ -152,10 +152,38 @@ class ChatContext:
|
||||
# Uploads attached to this user turn, resolved and owner-checked for the
|
||||
# agent's private context. This is not emitted to the browser.
|
||||
uploaded_files: list = field(default_factory=list)
|
||||
# Route-neutral prompt before any model-window compaction/trimming. This is
|
||||
# retained only when explicit foreground fallbacks are enabled so each
|
||||
# concrete candidate can apply its own context budget independently.
|
||||
route_messages: list = field(default_factory=list)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||
|
||||
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
|
||||
if privs.get("block_all_models"):
|
||||
return frozenset()
|
||||
allowed_raw = privs.get("allowed_models")
|
||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
||||
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
|
||||
|
||||
|
||||
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
||||
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
||||
|
||||
try:
|
||||
user = effective_user(request)
|
||||
except Exception:
|
||||
user = None
|
||||
if not user:
|
||||
return None
|
||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||
if not auth_manager:
|
||||
return None
|
||||
privs = auth_manager.get_privileges(user) or {}
|
||||
return _allowed_models_from_privileges(privs)
|
||||
|
||||
def _enforce_chat_privileges(request, sess) -> None:
|
||||
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
||||
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
|
||||
@@ -185,10 +213,8 @@ def _enforce_chat_privileges(request, sess) -> None:
|
||||
if privs.get("block_all_models"):
|
||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||
|
||||
allowed_raw = privs.get("allowed_models")
|
||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
||||
if restricted and sess.model and sess.model not in allowed:
|
||||
allowed_models = _allowed_models_from_privileges(privs)
|
||||
if allowed_models is not None and sess.model and sess.model not in allowed_models:
|
||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||
|
||||
cap = int(privs.get("max_messages_per_day") or 0)
|
||||
@@ -287,96 +313,6 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
||||
"""Find an alternative working endpoint when the current one fails.
|
||||
|
||||
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
||||
"""
|
||||
import requests as _req
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
build_models_url,
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
)
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
|
||||
current_url = sess.endpoint_url or ""
|
||||
owner = getattr(sess, "owner", None)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True
|
||||
)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
for ep in endpoints:
|
||||
base = normalize_base(ep.base_url)
|
||||
# Skip current endpoint
|
||||
if current_url and base in current_url:
|
||||
continue
|
||||
try:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
ping_url = build_models_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
try:
|
||||
if ping_url:
|
||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not models:
|
||||
models = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
models = json.loads(ep.cached_models or "[]")
|
||||
if not models:
|
||||
continue
|
||||
# Found a working endpoint — update session
|
||||
new_model = models[0]
|
||||
chat_url = build_chat_url(base)
|
||||
new_headers = build_headers(api_key, base)
|
||||
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
|
||||
|
||||
sess.model = new_model
|
||||
sess.endpoint_url = chat_url
|
||||
sess.headers = new_headers
|
||||
|
||||
# Persist
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
||||
"model": new_model,
|
||||
"endpoint_url": chat_url,
|
||||
"headers": persisted_headers,
|
||||
})
|
||||
_db.commit()
|
||||
finally:
|
||||
_db.close()
|
||||
|
||||
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
|
||||
return {
|
||||
"model": new_model,
|
||||
"endpoint_url": chat_url,
|
||||
"endpoint_name": ep.name,
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
||||
"""Extract preset parameters via chat_handler."""
|
||||
temperature, max_tokens, system_prompt, char_name = (
|
||||
@@ -687,6 +623,7 @@ async def build_chat_context(
|
||||
use_enhanced_message: bool = False,
|
||||
agent_mode: bool = False,
|
||||
allow_tool_preprocessing: bool = True,
|
||||
defer_context_shaping: bool = False,
|
||||
) -> ChatContext:
|
||||
"""Build the full context (preface + messages) for an LLM call.
|
||||
|
||||
@@ -830,13 +767,22 @@ async def build_chat_context(
|
||||
except Exception:
|
||||
logger.debug("Failed to add current date/time context", exc_info=True)
|
||||
|
||||
# Auto-compact
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
)
|
||||
route_messages = list(messages)
|
||||
# Explicit fallback routing must shape from the same route-neutral prompt
|
||||
# for every candidate. Running selected-model compaction here would mutate
|
||||
# session history before we know which route can answer and would make a
|
||||
# later larger-context candidate unable to recover discarded history.
|
||||
if defer_context_shaping:
|
||||
context_length = get_context_length(sess.endpoint_url, sess.model)
|
||||
was_compacted = False
|
||||
else:
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
)
|
||||
_before_trim_messages = len(messages)
|
||||
_before_trim_tokens = estimate_tokens(messages)
|
||||
messages = trim_for_context(messages, context_length)
|
||||
if not defer_context_shaping:
|
||||
messages = trim_for_context(messages, context_length)
|
||||
_after_trim_messages = len(messages)
|
||||
_after_trim_tokens = estimate_tokens(messages)
|
||||
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
|
||||
@@ -860,6 +806,7 @@ async def build_chat_context(
|
||||
context_tokens_after_trim=_after_trim_tokens,
|
||||
auto_opened_docs=auto_opened_docs,
|
||||
uploaded_files=uploaded_files,
|
||||
route_messages=route_messages,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+664
-40
File diff suppressed because it is too large
Load Diff
+3
-10
@@ -5004,7 +5004,6 @@ def setup_email_routes():
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
|
||||
@@ -5066,8 +5065,6 @@ def setup_email_routes():
|
||||
pass
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
if not candidates:
|
||||
return {"success": False, "error": "No LLM endpoint configured"}
|
||||
|
||||
@@ -5327,13 +5324,11 @@ def setup_email_routes():
|
||||
# Build a candidate chain so a stale session-stored API key
|
||||
# (the most common cause of "authentication failed" here)
|
||||
# doesn't kill AI Reply outright — fall through to the
|
||||
# user's Utility / Default endpoints and the active Utility
|
||||
# fallback chain. The retired default-fallback hook stays empty.
|
||||
# Dedupe by url+model so we don't retry the same broken endpoint.
|
||||
# user's Utility / Default endpoints and active Utility fallback
|
||||
# chain. Dedupe by url+model so we don't retry the same endpoint.
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
from src.endpoint_resolver import (
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
_seen = set()
|
||||
_candidates = []
|
||||
@@ -5358,11 +5353,9 @@ def setup_email_routes():
|
||||
_add(_d_url, _d_model, _d_headers)
|
||||
except Exception:
|
||||
pass
|
||||
# Active Utility fallbacks, then the retired default hook.
|
||||
# Active Utility fallbacks last.
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
_messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_msg},
|
||||
|
||||
@@ -46,6 +46,7 @@ _ENDPOINT_SETTING_FIELDS = {
|
||||
}
|
||||
|
||||
_ENDPOINT_FALLBACK_FIELDS = {
|
||||
"foreground_model_fallbacks": "Foreground Model Fallbacks",
|
||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||
"vision_model_fallbacks": "Vision Model Fallbacks",
|
||||
}
|
||||
@@ -180,7 +181,12 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
||||
if not isinstance(all_prefs, dict):
|
||||
return 0
|
||||
users = all_prefs.get("_users")
|
||||
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
|
||||
# A mixed store can contain auth-disabled foreground policy at the root
|
||||
# alongside named-owner preferences. Both are active namespaces; legacy
|
||||
# `default_model_fallbacks` remains untouched by the field allowlist.
|
||||
pref_sets = [all_prefs]
|
||||
if isinstance(users, dict):
|
||||
pref_sets.extend(users.values())
|
||||
cleared_users = 0
|
||||
for prefs in pref_sets:
|
||||
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
||||
|
||||
+163
-92
@@ -1,11 +1,13 @@
|
||||
# routes/personal_routes.py
|
||||
"""Routes for personal documents management."""
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from src.request_models import DirectoryRequest
|
||||
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||
from src.rag_singleton import get_rag_manager
|
||||
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
||||
"""Return the per-owner upload directory used for direct RAG uploads."""
|
||||
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
||||
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
"""
|
||||
router = APIRouter(prefix="/api/personal")
|
||||
|
||||
# Serializes directory index jobs across requests. Indexing runs in the
|
||||
# threadpool (#5558), so concurrent requests would otherwise run in parallel
|
||||
# and race PersonalDocsManager's unsynchronized list mutations and file
|
||||
# writes; before the threadpool move they serialized on the blocked event
|
||||
# loop, so one-at-a-time is behavior parity.
|
||||
#
|
||||
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
|
||||
# request parks on the event loop instead of pinning a threadpool worker (an
|
||||
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
|
||||
# tokens while blocked, starving every other run_in_threadpool caller).
|
||||
# add/remove/reload all take this lock, so their mutations never interleave.
|
||||
# Per-router (not module-global) so each app binds it to its own event loop.
|
||||
# Scope is the single process: multi-worker deployments would need a shared
|
||||
# lock (out of scope for #5558).
|
||||
_index_job_lock = asyncio.Lock()
|
||||
|
||||
def _rag():
|
||||
"""Get the current RAG manager, retrying init if needed."""
|
||||
return get_rag_manager()
|
||||
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
return {"files": files, "directories": directories}
|
||||
|
||||
@router.post("/reload")
|
||||
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
personal_docs_manager.refresh_index()
|
||||
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
# refresh_index() re-extracts text across every tracked directory —
|
||||
# blocking work. Take the shared job lock (so it cannot race an add /
|
||||
# remove) and run it off the event loop.
|
||||
async with _index_job_lock:
|
||||
await run_in_threadpool(personal_docs_manager.refresh_index)
|
||||
return {"ok": True, "count": len(personal_docs_manager.index)}
|
||||
|
||||
@router.post("/add_directory")
|
||||
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
# Use the RAGManager to index the directory
|
||||
rag = _rag()
|
||||
if rag:
|
||||
result = rag.index_personal_documents(directory, owner=owner)
|
||||
|
||||
def _index_directory():
|
||||
result = rag.index_personal_documents(directory, owner=owner)
|
||||
if result["success"]:
|
||||
# Also update the personal_docs_manager to track this
|
||||
# directory. Kept inside the offloaded call: it triggers
|
||||
# refresh_index(), which re-extracts text across tracked
|
||||
# directories.
|
||||
personal_docs_manager.add_directory(directory, index=False)
|
||||
return result
|
||||
|
||||
# Indexing walks, embeds, and stores the whole tree — minutes
|
||||
# on a real directory. The handler is async, so calling it
|
||||
# inline runs it on the event loop and every other request
|
||||
# queues behind it until it finishes (#5558). Serialize on the
|
||||
# async job lock BEFORE offloading so a queued request parks on
|
||||
# the loop instead of pinning a threadpool worker.
|
||||
async with _index_job_lock:
|
||||
result = await run_in_threadpool(_index_directory)
|
||||
|
||||
if result["success"]:
|
||||
# Also update the personal_docs_manager to track this directory
|
||||
personal_docs_manager.add_directory(directory, index=False)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
||||
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
|
||||
logger.info(f"Removing directory from RAG: {directory}")
|
||||
|
||||
# Always remove from personal_docs_manager tracking
|
||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||
personal_docs_manager.remove_directory(directory)
|
||||
|
||||
# Remove from RAG vector store (best-effort)
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
rag.remove_directory(directory)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||
|
||||
def _remove_directory():
|
||||
# Always remove from personal_docs_manager tracking. This
|
||||
# mutates the same unsynchronized list/index an add job touches
|
||||
# and re-extracts text (refresh_index), so it is blocking work.
|
||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||
personal_docs_manager.remove_directory(directory)
|
||||
# Remove from RAG vector store (best-effort).
|
||||
if rag:
|
||||
try:
|
||||
rag.remove_directory(directory)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||
|
||||
# Same job lock as add/reload so remove cannot interleave with an
|
||||
# in-flight add; offloaded off the event loop.
|
||||
async with _index_job_lock:
|
||||
await run_in_threadpool(_remove_directory)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
total_failed = 0
|
||||
uploaded_files = []
|
||||
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext == ".pdf":
|
||||
from src.personal_docs import extract_pdf_text
|
||||
text = extract_pdf_text(file_path)
|
||||
else:
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if not text or not text.strip():
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
# Chunk and index
|
||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||
for i, chunk in enumerate(chunks):
|
||||
metadata = {
|
||||
"source": file_path,
|
||||
"filename": safe_name,
|
||||
"stored_filename": stored_name,
|
||||
"directory": upload_dir,
|
||||
"type": ext,
|
||||
"chunk_id": i,
|
||||
}
|
||||
if user:
|
||||
metadata["owner"] = user
|
||||
if rag.add_document(chunk, metadata):
|
||||
total_indexed += 1
|
||||
else:
|
||||
# Chunking, embedding and the tracking update are blocking work over the
|
||||
# same vector/tracking state add_directory mutates (#5634). Take the
|
||||
# shared job lock BEFORE offloading so a queued request parks on the loop
|
||||
# instead of pinning a threadpool worker, matching add_directory.
|
||||
# Read and process one capped payload at a time so a multi-file request
|
||||
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
|
||||
async with _index_job_lock:
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(
|
||||
upload_dir, upload.filename
|
||||
)
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
uploaded_files.append(safe_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||
total_failed += 1
|
||||
def _index_upload():
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
# Track uploads directory
|
||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||
personal_docs_manager.add_directory(upload_dir, index=False)
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext == ".pdf":
|
||||
from src.personal_docs import extract_pdf_text
|
||||
text = extract_pdf_text(file_path)
|
||||
else:
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if not text or not text.strip():
|
||||
return 0, 1, None
|
||||
|
||||
indexed = 0
|
||||
failed = 0
|
||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||
for i, chunk in enumerate(chunks):
|
||||
metadata = {
|
||||
"source": file_path,
|
||||
"filename": safe_name,
|
||||
"stored_filename": stored_name,
|
||||
"directory": upload_dir,
|
||||
"type": ext,
|
||||
"chunk_id": i,
|
||||
}
|
||||
if user:
|
||||
metadata["owner"] = user
|
||||
if rag.add_document(chunk, metadata):
|
||||
indexed += 1
|
||||
else:
|
||||
failed += 1
|
||||
return indexed, failed, safe_name
|
||||
|
||||
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
|
||||
total_indexed += indexed
|
||||
total_failed += failed
|
||||
if uploaded_name:
|
||||
uploaded_files.append(uploaded_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||
total_failed += 1
|
||||
|
||||
# Same transition, same lock: the tracking update must not land
|
||||
# while another job is mid-write over the same state.
|
||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||
await run_in_threadpool(
|
||||
personal_docs_manager.add_directory, upload_dir, index=False
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
"""Delete a specific file from RAG index and optionally from disk."""
|
||||
try:
|
||||
# Remove chunks from RAG vector store (best-effort)
|
||||
removed = 0
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
removed = rag.delete_by_source(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||
def _delete_file():
|
||||
# Remove chunks from RAG vector store (best-effort)
|
||||
removed = 0
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
removed = rag.delete_by_source(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||
|
||||
# Delete file from disk if it's in the caller's own uploads dir.
|
||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||
# admin can't delete another user's personal files by path.
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
)
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs:
|
||||
# Delete file from disk if it's in the caller's own uploads dir.
|
||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||
# admin can't delete another user's personal files by path.
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
)
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs:
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
return removed, deleted_from_disk
|
||||
|
||||
# Vector removal, the disk unlink and the exclusion write are one
|
||||
# transition over the same state add_directory mutates (#5634), and
|
||||
# all three block. Take the shared job lock BEFORE offloading, as
|
||||
# add_directory does.
|
||||
async with _index_job_lock:
|
||||
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
+51
-11
@@ -7,6 +7,10 @@ from src.auth_helpers import get_current_user
|
||||
from src.constants import USER_PREFS_FILE
|
||||
|
||||
PREFS_FILE = USER_PREFS_FILE
|
||||
_FOREGROUND_POLICY_KEYS = (
|
||||
"foreground_fallback_enabled",
|
||||
"foreground_model_fallbacks",
|
||||
)
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -26,14 +30,27 @@ def _save(prefs):
|
||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||
"""Load preferences for a specific user."""
|
||||
all_prefs = _load()
|
||||
if "_users" in all_prefs:
|
||||
users = all_prefs.get("_users")
|
||||
if isinstance(users, dict):
|
||||
if user is None:
|
||||
# Auth disabled — return first user's prefs for backward compat
|
||||
users = all_prefs["_users"]
|
||||
return dict(next(iter(users.values()), {}))
|
||||
return dict(all_prefs["_users"].get(user, {}))
|
||||
# Legacy flat format — return as-is
|
||||
return dict(all_prefs)
|
||||
prefs = dict(next(iter(users.values()), {}))
|
||||
# Foreground fallback consent is never borrowed from a named
|
||||
# owner. Auth-disabled operation has a separate flat/root opt-in
|
||||
# that remains inert when authentication is enabled again.
|
||||
for key in _FOREGROUND_POLICY_KEYS:
|
||||
prefs.pop(key, None)
|
||||
if key in all_prefs:
|
||||
prefs[key] = all_prefs[key]
|
||||
return prefs
|
||||
prefs = users.get(user, {})
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
# A legacy flat store belongs only to auth-disabled single-user mode.
|
||||
# Copying it into the first named user's new `_users` record during an
|
||||
# auth transition would silently transfer another user's preferences and,
|
||||
# critically, foreground fallback consent. Named owners therefore start
|
||||
# with an empty record and must write their own preferences explicitly.
|
||||
return dict(all_prefs) if user is None else {}
|
||||
|
||||
|
||||
def _save_for_user(user: Optional[str], prefs: dict):
|
||||
@@ -45,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
|
||||
# `prefs` flat would overwrite the whole `_users` map and destroy every
|
||||
# other user's preferences. Instead write back into the same (first)
|
||||
# slot _load_for_user(None) reads from, preserving the others.
|
||||
if "_users" in all_prefs:
|
||||
users = all_prefs["_users"]
|
||||
users = all_prefs.get("_users")
|
||||
if isinstance(users, dict):
|
||||
first_key = next(iter(users), None)
|
||||
if first_key is not None:
|
||||
users[first_key] = prefs
|
||||
existing_named = users.get(first_key)
|
||||
existing_named = (
|
||||
dict(existing_named)
|
||||
if isinstance(existing_named, dict)
|
||||
else {}
|
||||
)
|
||||
named_foreground = {
|
||||
key: existing_named[key]
|
||||
for key in _FOREGROUND_POLICY_KEYS
|
||||
if key in existing_named
|
||||
}
|
||||
users[first_key] = {
|
||||
key: value
|
||||
for key, value in prefs.items()
|
||||
if key not in _FOREGROUND_POLICY_KEYS
|
||||
}
|
||||
users[first_key].update(named_foreground)
|
||||
for key in _FOREGROUND_POLICY_KEYS:
|
||||
if key in prefs:
|
||||
all_prefs[key] = prefs[key]
|
||||
_save(all_prefs)
|
||||
return
|
||||
_save(prefs)
|
||||
return
|
||||
if "_users" not in all_prefs:
|
||||
all_prefs = {"_users": {}}
|
||||
if not isinstance(all_prefs.get("_users"), dict):
|
||||
# Preserve the flat single-user object as inert legacy data while
|
||||
# creating the first named-owner namespace. In particular, historical
|
||||
# fallback values must not be deleted or copied into the new owner.
|
||||
all_prefs = dict(all_prefs)
|
||||
all_prefs["_users"] = {}
|
||||
all_prefs["_users"][user] = prefs
|
||||
_save(all_prefs)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
@@ -496,7 +496,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
user = require_privilege(request, "can_use_research")
|
||||
if user == INTERNAL_TOOL_USER:
|
||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
||||
if tool_owner and tool_owner not in REQUEST_SENTINEL_OWNERS:
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||
try:
|
||||
|
||||
+251
-17
@@ -18,6 +18,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -107,6 +108,23 @@ def _skill_test_task(skill: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _skill_test_messages(md: str, task: str) -> list[dict]:
|
||||
"""Keep user-editable skill text out of the trusted system role."""
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are TESTING a skill. Follow the supplied reusable procedure "
|
||||
"to complete the user's task for real, using available tools step "
|
||||
"by step. If the skill is wrong, unclear, or references tools that "
|
||||
"do not exist, do your best; the problems will be reviewed afterward."
|
||||
),
|
||||
},
|
||||
untrusted_context_message("skill under test", md),
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
|
||||
|
||||
async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
url: str, model: str, headers: Optional[dict]) -> dict:
|
||||
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
|
||||
@@ -411,7 +429,21 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
||||
_skill_test_jobs: dict = {}
|
||||
|
||||
|
||||
async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, skills_manager=None):
|
||||
async def _run_skill_test_job(
|
||||
key,
|
||||
name,
|
||||
md,
|
||||
task,
|
||||
url,
|
||||
model,
|
||||
headers,
|
||||
owner,
|
||||
skills_manager=None,
|
||||
*,
|
||||
messages=None,
|
||||
transcript=None,
|
||||
exact_approval=None,
|
||||
):
|
||||
"""Background coroutine: run the skill in an agent loop, capture a condensed
|
||||
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
|
||||
import json as _json
|
||||
@@ -421,7 +453,7 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
||||
if job is None:
|
||||
return
|
||||
log = job["log"]
|
||||
transcript = []
|
||||
transcript = transcript if isinstance(transcript, list) else []
|
||||
say_buf = []
|
||||
|
||||
def _flush_say():
|
||||
@@ -429,18 +461,12 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
||||
log.append({"type": "say", "text": "".join(say_buf)})
|
||||
say_buf.clear()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content":
|
||||
"You are TESTING a skill. Below is a reusable skill (a procedure). Follow it "
|
||||
"to complete the user's task for real, using your available tools, step by "
|
||||
"step. If the skill is wrong, unclear, or references tools that don't exist, "
|
||||
"do your best — the problems will be reviewed afterward.\n\n=== SKILL ===\n" + md},
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
|
||||
try:
|
||||
async for chunk in stream_agent_loop(
|
||||
url, model, messages, headers=headers,
|
||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
||||
exact_approval=exact_approval,
|
||||
):
|
||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||
continue
|
||||
@@ -458,8 +484,25 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
||||
elif d.get("type") == "tool_output":
|
||||
_flush_say()
|
||||
out = str(d.get("output") or "")[:600]
|
||||
log.append({"type": "tool_output", "output": out})
|
||||
tool_log = {"type": "tool_output", "output": out}
|
||||
approval = d.get("ask_user")
|
||||
if isinstance(approval, dict):
|
||||
tool_log["ask_user"] = approval
|
||||
log.append(tool_log)
|
||||
transcript.append(f"[output] {out}\n")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
and approval.get("approval_id")
|
||||
):
|
||||
# Manual skill tests have their own polling UI instead of a
|
||||
# chat session. Pause the run and retain only server-side
|
||||
# continuation state until the same owner approves/denies
|
||||
# this exact sealed action.
|
||||
job["status"] = "awaiting_approval"
|
||||
job["approval"] = approval
|
||||
job["_transcript"] = transcript
|
||||
return
|
||||
elif d.get("type") == "agent_step":
|
||||
_flush_say()
|
||||
log.append({"type": "agent_step", "round": d.get("round")})
|
||||
@@ -471,6 +514,9 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
||||
_flush_say()
|
||||
log.append({"type": "error", "error": str(e)})
|
||||
|
||||
job.pop("approval", None)
|
||||
job.pop("_transcript", None)
|
||||
job.pop("_run", None)
|
||||
log.append({"type": "evaluating"})
|
||||
try:
|
||||
job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers)
|
||||
@@ -694,12 +740,8 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
|
||||
import json as _json
|
||||
from src.agent_loop import stream_agent_loop
|
||||
transcript = []
|
||||
messages = [
|
||||
{"role": "system", "content":
|
||||
"You are TESTING a skill. Follow this skill's procedure to complete the task "
|
||||
"for real, using your tools, step by step.\n\n=== SKILL ===\n" + md},
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
approval_required = None
|
||||
messages = _skill_test_messages(md, task)
|
||||
try:
|
||||
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
|
||||
# OpenAI-compat) generate an empty completion, which manifested as
|
||||
@@ -719,11 +761,44 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
|
||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||
elif d.get("type") == "tool_output":
|
||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||
approval = d.get("ask_user")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
):
|
||||
approval_required = approval
|
||||
break
|
||||
elif d.get("type") == "agent_step":
|
||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||
except Exception as e:
|
||||
transcript.append(f"\n[run error] {e}\n")
|
||||
text = "".join(transcript)
|
||||
if approval_required is not None:
|
||||
# Unattended audits have no authority to approve and no UI that could
|
||||
# resume this record. Destructively deny it now instead of leaving a
|
||||
# reusable opaque grant pending until TTL/cap eviction.
|
||||
try:
|
||||
from src.tool_approvals import tool_approval_store
|
||||
tool_approval_store.consume(
|
||||
approval_required.get("approval_id"),
|
||||
decision="deny",
|
||||
owner=owner,
|
||||
session_id=None,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not retire unattended skill approval", exc_info=True)
|
||||
return text, {
|
||||
"verdict": "inconclusive",
|
||||
"confidence": 1.0,
|
||||
"summary": (
|
||||
"This automated audit reached an exact action that requires "
|
||||
"a human approval; no action was executed."
|
||||
),
|
||||
"issues": [
|
||||
"Run this skill's manual test and review the sealed action."
|
||||
],
|
||||
"approval_required": True,
|
||||
}
|
||||
verdict = await _eval_skill_run(md, task, text, url, model, headers)
|
||||
return text, verdict
|
||||
|
||||
@@ -863,6 +938,26 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
||||
transcript, verdict = await _run_skill_test_once(md, task, url, model, headers, owner)
|
||||
v = verdict.get("verdict")
|
||||
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
|
||||
if verdict.get("approval_required"):
|
||||
# An unattended audit is not authority for an action influenced by the
|
||||
# skill under test. Preserve the skill's current publication/confidence
|
||||
# state and route the exact action to the manual test UI instead of
|
||||
# letting a safety pause demote, rewrite, or auto-publish the skill.
|
||||
skills_manager.set_audit(
|
||||
name,
|
||||
"inconclusive",
|
||||
by_teacher=False,
|
||||
worker_model=model,
|
||||
owner=owner,
|
||||
)
|
||||
status = skill.get("status") or "draft"
|
||||
log(f"{name}: {status} unchanged — exact action needs manual approval")
|
||||
return {
|
||||
"skill": name,
|
||||
"result": "approval_required",
|
||||
"verdict": verdict,
|
||||
"status": status,
|
||||
}
|
||||
if v == "pass":
|
||||
# Procedure works. If the reviewer still flagged metadata (tags/category/
|
||||
# when_to_use/description), do ONE fixer pass to correct the frontmatter
|
||||
@@ -1431,6 +1526,19 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
logger.warning(f"Skill-test model resolve failed: {_e}")
|
||||
|
||||
key = (user or "", name)
|
||||
previous_job = _skill_test_jobs.get(key) or {}
|
||||
previous_approval = previous_job.get("approval") or {}
|
||||
if previous_approval.get("approval_id"):
|
||||
try:
|
||||
from src.tool_approvals import tool_approval_store
|
||||
tool_approval_store.consume(
|
||||
previous_approval["approval_id"],
|
||||
decision="deny",
|
||||
owner=user,
|
||||
session_id=None,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not retire replaced skill approval", exc_info=True)
|
||||
_skill_test_jobs[key] = {
|
||||
"status": "running",
|
||||
"task": task,
|
||||
@@ -1439,10 +1547,135 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
"started": _time.time(),
|
||||
"log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}],
|
||||
"verdict": None,
|
||||
"_run": {
|
||||
"md": md,
|
||||
"url": url,
|
||||
"model": model,
|
||||
"headers": headers,
|
||||
"owner": user,
|
||||
},
|
||||
}
|
||||
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
|
||||
return {"ok": True, "status": "running", "skill": name, "model": model}
|
||||
|
||||
@router.post("/{skill_id}/test-approval")
|
||||
async def approve_skill_test_action(request: Request, skill_id: str):
|
||||
"""Resume a manual skill test with one exact server-sealed action."""
|
||||
import asyncio as _asyncio
|
||||
from src.tool_approvals import tool_approval_store
|
||||
|
||||
user = _owner(request)
|
||||
skills = skills_manager.load(owner=user)
|
||||
match = next(
|
||||
(s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id),
|
||||
None,
|
||||
)
|
||||
if not match:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
_verify_owner(match, user)
|
||||
name = match.get("name")
|
||||
key = (user or "", name)
|
||||
job = _skill_test_jobs.get(key)
|
||||
if not job or job.get("status") != "awaiting_approval":
|
||||
raise HTTPException(409, "This skill test is not awaiting an approval.")
|
||||
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Tool approval body must be a JSON object.")
|
||||
approval_id = str(body.get("approval_id") or "")
|
||||
decision = str(body.get("decision") or "").strip().lower()
|
||||
expected = job.get("approval") or {}
|
||||
if approval_id != str(expected.get("approval_id") or ""):
|
||||
raise HTTPException(409, "This approval does not match the pending skill test action.")
|
||||
if decision not in {"approve", "deny"}:
|
||||
raise HTTPException(400, "Invalid tool approval decision.")
|
||||
|
||||
pending = tool_approval_store.peek(approval_id)
|
||||
normalized_owner = str(user or "").strip().casefold()
|
||||
if (
|
||||
pending is None
|
||||
or pending.owner != normalized_owner
|
||||
or pending.session_id != ""
|
||||
):
|
||||
raise HTTPException(409, "This tool approval is invalid or expired.")
|
||||
exact_approval = tool_approval_store.consume(
|
||||
approval_id,
|
||||
decision=decision,
|
||||
owner=user,
|
||||
session_id=None,
|
||||
)
|
||||
|
||||
if decision == "approve" and exact_approval is None:
|
||||
raise HTTPException(409, "This tool approval could not be consumed.")
|
||||
job.pop("approval", None)
|
||||
if decision == "deny":
|
||||
job.pop("_transcript", None)
|
||||
job.pop("_run", None)
|
||||
job["log"].append({
|
||||
"type": "approval_denied",
|
||||
"text": "Exact action denied; the skill test stopped without executing it.",
|
||||
})
|
||||
job["verdict"] = {
|
||||
"verdict": "inconclusive",
|
||||
"confidence": 1.0,
|
||||
"summary": "The test stopped because its exact action was denied.",
|
||||
"issues": [],
|
||||
}
|
||||
job["status"] = "done"
|
||||
return {"ok": True, "status": "done", "decision": "deny"}
|
||||
|
||||
run = job.get("_run") or {}
|
||||
transcript = job.pop("_transcript", [])
|
||||
# stream_agent_loop owns its per-round message list internally. Rebuild
|
||||
# continuation context from the original untrusted skill plus the
|
||||
# accumulated transcript so repeated approvals do not lose earlier
|
||||
# approved results, while keeping every transcript byte tainted.
|
||||
messages = _skill_test_messages(
|
||||
run.get("md", ""),
|
||||
job.get("task", ""),
|
||||
)
|
||||
if transcript:
|
||||
messages.append(untrusted_context_message(
|
||||
"skill test transcript",
|
||||
"".join(str(item) for item in transcript),
|
||||
))
|
||||
messages.extend([
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": str(expected.get("question") or "Allow this exact action once?"),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Approved the exact {exact_approval.pending.tool_name} "
|
||||
"action shown above once."
|
||||
),
|
||||
},
|
||||
])
|
||||
job["status"] = "running"
|
||||
job["log"].append({
|
||||
"type": "approval_granted",
|
||||
"text": (
|
||||
f"Approved exact {exact_approval.pending.tool_name} action once; "
|
||||
"resuming test."
|
||||
),
|
||||
})
|
||||
_asyncio.create_task(_run_skill_test_job(
|
||||
key,
|
||||
name,
|
||||
run.get("md", ""),
|
||||
job.get("task", ""),
|
||||
run.get("url"),
|
||||
run.get("model"),
|
||||
run.get("headers"),
|
||||
run.get("owner"),
|
||||
skills_manager,
|
||||
messages=messages,
|
||||
transcript=transcript,
|
||||
exact_approval=exact_approval,
|
||||
))
|
||||
return {"ok": True, "status": "running", "decision": "approve"}
|
||||
|
||||
@router.get("/{skill_id}/test-status")
|
||||
async def test_skill_status(request: Request, skill_id: str):
|
||||
"""Current background-test state for a skill (status / log / verdict)."""
|
||||
@@ -1459,6 +1692,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
"model": job.get("model"),
|
||||
"log": job.get("log", []),
|
||||
"verdict": job.get("verdict"),
|
||||
"approval": job.get("approval"),
|
||||
}
|
||||
|
||||
@router.post("/audit-all")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make retained SearXNG settings inherit defaults without replacing them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from yaml.nodes import MappingNode
|
||||
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
|
||||
|
||||
|
||||
_UTF8_BOM = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
|
||||
"""Parse settings with the same safe YAML semantics SearXNG uses."""
|
||||
try:
|
||||
loaded = yaml.safe_load(text)
|
||||
node = yaml.compose(text, Loader=yaml.SafeLoader)
|
||||
except yaml.YAMLError:
|
||||
raise ValueError("settings file is not valid single-document YAML") from None
|
||||
|
||||
if loaded is None and node is None:
|
||||
return None, {}
|
||||
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
|
||||
raise ValueError("settings root is not a mapping")
|
||||
return node, loaded
|
||||
|
||||
|
||||
def _flow_mapping_start(text: str) -> int:
|
||||
"""Return the root flow mapping's opening-brace character offset."""
|
||||
try:
|
||||
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
||||
if isinstance(token, FlowMappingStartToken):
|
||||
return token.start_mark.index
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
raise ValueError("flow-style settings mapping has no opening brace")
|
||||
|
||||
|
||||
def _newline_for(contents: bytes) -> bytes:
|
||||
first_lf = contents.find(b"\n")
|
||||
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
|
||||
return b"\r\n"
|
||||
return b"\n"
|
||||
|
||||
|
||||
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
|
||||
"""Return a safe character offset and indent for a root block mapping key."""
|
||||
if root is None:
|
||||
return len(text), 0
|
||||
|
||||
try:
|
||||
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
||||
if not isinstance(token, BlockMappingStartToken):
|
||||
continue
|
||||
line_start = token.start_mark.index - token.start_mark.column
|
||||
if not text[line_start : token.start_mark.index].strip():
|
||||
return line_start, token.start_mark.column
|
||||
return root.end_mark.index, token.start_mark.column
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
return root.end_mark.index, root.start_mark.column
|
||||
|
||||
|
||||
def _add_block_default_inheritance(
|
||||
contents: bytes, text: str, root: MappingNode | None
|
||||
) -> bytes:
|
||||
newline = _newline_for(contents)
|
||||
character_offset, indent_width = _block_mapping_position(text, root)
|
||||
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
||||
offset = bom_length + len(text[:character_offset].encode("utf-8"))
|
||||
separator = b""
|
||||
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
|
||||
separator = newline
|
||||
addition = (
|
||||
separator
|
||||
+ b" " * indent_width
|
||||
+ b"use_default_settings: true"
|
||||
+ newline
|
||||
)
|
||||
return contents[:offset] + addition + contents[offset:]
|
||||
|
||||
|
||||
def migrate_settings(path: Path) -> bool:
|
||||
"""Add the missing inheritance key atomically; return whether the file changed."""
|
||||
source_stat = path.lstat()
|
||||
if not stat.S_ISREG(source_stat.st_mode):
|
||||
raise ValueError(f"settings path is not a regular file: {path}")
|
||||
|
||||
contents = path.read_bytes()
|
||||
if not contents:
|
||||
return False
|
||||
|
||||
text = contents.decode("utf-8-sig")
|
||||
root, loaded = _parse_root_mapping(text)
|
||||
if "use_default_settings" in loaded:
|
||||
return False
|
||||
|
||||
if root is not None and root.flow_style:
|
||||
start = _flow_mapping_start(text)
|
||||
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
||||
offset = bom_length + len(text[: start + 1].encode("utf-8"))
|
||||
separator = b", " if root.value else b""
|
||||
updated = (
|
||||
contents[:offset]
|
||||
+ b"use_default_settings: true"
|
||||
+ separator
|
||||
+ contents[offset:]
|
||||
)
|
||||
else:
|
||||
updated = _add_block_default_inheritance(contents, text, root)
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.odysseus-", dir=path.parent
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
|
||||
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
|
||||
# file belongs to searxng:searxng — which every retained settings file
|
||||
# does, because searxng's entrypoint chowns /etc/searxng — root can no
|
||||
# longer chmod it and the migration dies with EPERM.
|
||||
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
|
||||
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
fd = -1
|
||||
handle.write(updated)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
temporary.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) > 2:
|
||||
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
|
||||
try:
|
||||
changed = migrate_settings(path)
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if changed:
|
||||
print("Added use_default_settings inheritance to retained SearXNG settings")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from src.url_safety import check_outbound_url
|
||||
from src.url_safety import _default_resolver, check_outbound_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +27,7 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
|
||||
_GITHUB_HOSTS = frozenset({
|
||||
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
|
||||
})
|
||||
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
|
||||
|
||||
|
||||
def _github_host(url: str) -> str:
|
||||
@@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
|
||||
_MAX_FETCH_REDIRECTS = 5
|
||||
|
||||
|
||||
def _check_fetch_url(url: str) -> None:
|
||||
"""SSRF guard for skill-import fetches (defense-in-depth).
|
||||
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
|
||||
"""Parse and de-duplicate one resolver snapshot in resolver order."""
|
||||
ips: List[ipaddress._BaseAddress] = []
|
||||
seen = set()
|
||||
for raw in raw_ips:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw.split("%", 1)[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ip in seen:
|
||||
continue
|
||||
seen.add(ip)
|
||||
ips.append(ip)
|
||||
return ips
|
||||
|
||||
Skill bundles only ever come from public GitHub, never an internal
|
||||
address, so block private/loopback/link-local targets on every hop —
|
||||
matching the hardened web-fetch path in
|
||||
``services/search/content.py:_get_public_url`` rather than the lenient
|
||||
default used for admin-configured model endpoints.
|
||||
"""
|
||||
ok, reason = check_outbound_url(url, block_private=True)
|
||||
|
||||
def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
|
||||
"""Return the exact address snapshot approved for one fetch hop."""
|
||||
resolved_ips: List[str] = []
|
||||
|
||||
def _recording_resolver(host: str) -> List[str]:
|
||||
answers = list(_default_resolver(host))
|
||||
resolved_ips[:] = answers
|
||||
return answers
|
||||
|
||||
ok, reason = check_outbound_url(
|
||||
url,
|
||||
block_private=True,
|
||||
resolver=_recording_resolver,
|
||||
)
|
||||
if not ok:
|
||||
raise SkillImportError(reason)
|
||||
raise SkillImportError(f"outbound URL blocked: {reason}")
|
||||
|
||||
pinned_ips = _validated_ips(resolved_ips)
|
||||
if not pinned_ips:
|
||||
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
|
||||
return pinned_ips
|
||||
|
||||
|
||||
# Backward compatibility alias for tests importing _check_fetch_url directly
|
||||
_check_fetch_url = _resolve_and_check_url
|
||||
|
||||
|
||||
class _PinnedBackend(httpcore.NetworkBackend):
|
||||
"""Connect only to addresses from one validated DNS snapshot."""
|
||||
|
||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||
self._ips = [str(ip) for ip in ips]
|
||||
self._real = httpcore.SyncBackend()
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options=None,
|
||||
):
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
last_exc: Optional[Exception] = None
|
||||
for ip in self._ips:
|
||||
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||
try:
|
||||
return self._real.connect_tcp(
|
||||
ip,
|
||||
port,
|
||||
remaining,
|
||||
local_address,
|
||||
socket_options,
|
||||
)
|
||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
|
||||
last_exc = exc
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
break
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise httpcore.ConnectError("no validated address available")
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
return self._real.sleep(seconds)
|
||||
|
||||
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ProxyError: httpx.ProxyError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedTransport(httpx.BaseTransport):
|
||||
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
|
||||
|
||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||
self._pinned_ips = list(ips)
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=httpx.create_ssl_context(),
|
||||
http1=True,
|
||||
http2=False,
|
||||
network_backend=_PinnedBackend(ips),
|
||||
)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
core_request = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
core_response = None
|
||||
try:
|
||||
core_response = self._pool.handle_request(core_request)
|
||||
content = b"".join(cast(Iterable[bytes], core_response.stream))
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
finally:
|
||||
if core_response is not None:
|
||||
core_response.close()
|
||||
|
||||
return httpx.Response(
|
||||
status_code=core_response.status,
|
||||
headers=core_response.headers,
|
||||
content=content,
|
||||
extensions=core_response.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
|
||||
def _get_checked(
|
||||
@@ -100,49 +243,76 @@ def _get_checked(
|
||||
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
||||
"""
|
||||
current = url
|
||||
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
|
||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||
_check_fetch_url(current)
|
||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||
pinned_ips = _resolve_and_check_url(current)
|
||||
with httpx.Client(
|
||||
transport=_PinnedTransport(pinned_ips),
|
||||
follow_redirects=False,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
r = client.get(current, headers=headers)
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
location = r.headers.get("location")
|
||||
if not location:
|
||||
return r
|
||||
current = urljoin(str(r.url), location)
|
||||
continue
|
||||
return r
|
||||
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
location = r.headers.get("location")
|
||||
if not location:
|
||||
return r
|
||||
current = urljoin(str(r.url), location)
|
||||
continue
|
||||
return r
|
||||
raise SkillImportError("too many redirects while fetching skill bundle")
|
||||
|
||||
|
||||
def parse_skill_source(url: str) -> ResolvedSource:
|
||||
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
||||
raw = (url or "").strip()
|
||||
if not raw:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
raise SkillImportError("URL is required")
|
||||
|
||||
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
||||
if "skills.sh" in raw and "github.com" not in raw:
|
||||
r = _get_checked(raw, timeout=20.0)
|
||||
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
|
||||
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
|
||||
# schemeless ``host:port`` both parse a "scheme" that is not one, so they
|
||||
# fall through to the host check below and are rejected on the host instead.
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme not in ("http", "https"):
|
||||
if scheme and url.lower().startswith(f"{scheme}://"):
|
||||
raise SkillImportError(f"unsupported URL scheme: {scheme}")
|
||||
# Schemeless "github.com/owner/repo" — accept only a supported host.
|
||||
rough_host = (urlparse("//" + url).hostname or "").lower()
|
||||
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
|
||||
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||
url = "https://" + url
|
||||
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
|
||||
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||
|
||||
# A skills.sh link is only usable if it redirects to an exact supported
|
||||
# GitHub host. Scraping the page body for a github.com link cannot work:
|
||||
# skill pages only ever link the repository root, never the skill's
|
||||
# subdirectory, so the scrape resolves every skill in a repo to the same
|
||||
# (wrong) bundle. Fail with an actionable message instead.
|
||||
if hostname in _SKILLS_SH_HOSTS:
|
||||
r = _get_checked(url, timeout=20.0)
|
||||
if r.status_code >= 400:
|
||||
raise _github_response_error(r)
|
||||
final = str(r.url)
|
||||
_assert_github_url(final, context="redirect target")
|
||||
# Page may embed a github link; prefer final URL if redirected.
|
||||
if "github.com" in final:
|
||||
raw = final
|
||||
else:
|
||||
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
|
||||
if m:
|
||||
raw = m.group(0).rstrip(".,)")
|
||||
if _github_host(final) not in _GITHUB_HOSTS:
|
||||
raise SkillImportError(
|
||||
"skills.sh did not redirect to GitHub — open the skill's "
|
||||
"repository on GitHub, navigate to the exact skill folder or "
|
||||
"SKILL.md file, and paste that URL; the repository-root link "
|
||||
"alone is not sufficient"
|
||||
)
|
||||
url = final
|
||||
|
||||
parsed = urlparse(raw)
|
||||
host = _github_host(raw)
|
||||
if host not in _GITHUB_HOSTS:
|
||||
raise SkillImportError(
|
||||
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
|
||||
)
|
||||
# Update parsed and hostname to reflect the new GitHub URL
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
if host == "raw.githubusercontent.com":
|
||||
_assert_github_url(url)
|
||||
|
||||
if hostname == "raw.githubusercontent.com":
|
||||
# /owner/repo/ref/path/to/file
|
||||
bits = [p for p in parsed.path.split("/") if p]
|
||||
if len(bits) < 4:
|
||||
|
||||
+1636
-464
File diff suppressed because it is too large
Load Diff
+84
-26
@@ -17,13 +17,14 @@ close / navigation / refresh). It does NOT survive a server restart.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Run:
|
||||
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
|
||||
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer: list = [] # ordered SSE event strings (replay log)
|
||||
@@ -31,6 +32,9 @@ class _Run:
|
||||
self.status: str = "running" # running | done | error | stopped
|
||||
self.task: Optional[asyncio.Task] = None
|
||||
self.evict_task: Optional[asyncio.Task] = None
|
||||
# Stable across every subscription/replay of this exact detached run.
|
||||
# The browser uses it to make local cost accounting replay-idempotent.
|
||||
self.run_id: str = uuid.uuid4().hex
|
||||
|
||||
|
||||
_RUNS: Dict[str, _Run] = {}
|
||||
@@ -53,13 +57,24 @@ def _publish(run: _Run, ev: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_evict(session_id: str) -> None:
|
||||
def _wake_run_subscribers(run: _Run) -> None:
|
||||
"""Close subscribers even when the drain task never reached its body."""
|
||||
for q in list(run.subscribers):
|
||||
try:
|
||||
q.put_nowait((None, None))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_evict(session_id: str, expected_run: Optional[_Run] = None) -> None:
|
||||
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
|
||||
Identity-checked so a run that gets replaced/reused is never evicted by a
|
||||
stale timer."""
|
||||
run = _RUNS.get(session_id)
|
||||
if run is None:
|
||||
return
|
||||
if expected_run is not None and run is not expected_run:
|
||||
return
|
||||
if run.evict_task and not run.evict_task.done():
|
||||
run.evict_task.cancel()
|
||||
|
||||
@@ -85,25 +100,38 @@ def get_status(session_id: str) -> Optional[str]:
|
||||
return r.status if r else None
|
||||
|
||||
|
||||
async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
||||
def get_run_id(session_id: str) -> Optional[str]:
|
||||
"""Return the opaque identity of the current detached run, if present."""
|
||||
r = _RUNS.get(session_id)
|
||||
return r.run_id if r else None
|
||||
|
||||
|
||||
def get_active_run(session_id: str) -> Optional[_Run]:
|
||||
"""Return the exact active run currently registered for a session."""
|
||||
r = _RUNS.get(session_id)
|
||||
return r if r and r.status == "running" else None
|
||||
|
||||
|
||||
async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
|
||||
prev_task: Optional[asyncio.Task] = None) -> None:
|
||||
"""Pull every event from the wrapped generator into the run buffer, fanning
|
||||
each out to live subscribers. Runs to completion regardless of subscribers."""
|
||||
run = _RUNS.get(session_id)
|
||||
if run is None:
|
||||
return
|
||||
subscribers_woken = False
|
||||
|
||||
def _wake_subscribers() -> None:
|
||||
nonlocal subscribers_woken
|
||||
if subscribers_woken:
|
||||
return
|
||||
subscribers_woken = True
|
||||
_wake_run_subscribers(run)
|
||||
|
||||
# If this run replaced an in-flight one (rapid double-send), wait for that
|
||||
# one to fully finish first. Its CancelledError handler calls aclose(), which
|
||||
# persists its partial response — letting it complete before we start writing
|
||||
# keeps the two runs' session saves sequential instead of interleaved.
|
||||
if prev_task is not None and not prev_task.done():
|
||||
try:
|
||||
await asyncio.wait({prev_task})
|
||||
except asyncio.CancelledError:
|
||||
raise # our own cancellation — propagate
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if prev_task is not None and not prev_task.done():
|
||||
await asyncio.wait({prev_task})
|
||||
async for ev in agen:
|
||||
_publish(run, ev)
|
||||
if run.status == "running":
|
||||
@@ -116,6 +144,16 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
||||
await agen.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
# A rapid third replacement can cancel this task while it is still
|
||||
# waiting for its predecessor. Close this run's subscribers promptly,
|
||||
# but keep the task alive until the predecessor finishes so the next
|
||||
# run still observes the transitive session-save ordering barrier.
|
||||
_wake_subscribers()
|
||||
if prev_task is not None and not prev_task.done():
|
||||
try:
|
||||
await asyncio.shield(prev_task)
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
|
||||
run.status = "error"
|
||||
@@ -127,15 +165,11 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
||||
_publish(run, "data: [DONE]\n\n")
|
||||
finally:
|
||||
# Wake every subscriber with the end sentinel so their SSE closes.
|
||||
for q in list(run.subscribers):
|
||||
try:
|
||||
q.put_nowait((None, None))
|
||||
except Exception:
|
||||
pass
|
||||
_wake_subscribers()
|
||||
# Run is terminal — arm the grace timer so it (and its buffer) is
|
||||
# eventually freed even if nobody ever reconnects. subscribe() cancels
|
||||
# this on connect and re-arms on disconnect.
|
||||
_schedule_evict(session_id)
|
||||
_schedule_evict(session_id, run)
|
||||
|
||||
|
||||
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
||||
@@ -145,20 +179,37 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
||||
prev_task: Optional[asyncio.Task] = None
|
||||
if prev:
|
||||
if prev.task and not prev.task.done():
|
||||
# A task cancelled before its first instruction never enters
|
||||
# _drain(), so its except/finally blocks cannot update status or
|
||||
# wake a response already bound to this exact run. Terminalize it
|
||||
# synchronously before cancelling; _drain's cleanup is idempotent
|
||||
# when the task had already started.
|
||||
if prev.status == "running":
|
||||
prev.status = "stopped"
|
||||
_wake_run_subscribers(prev)
|
||||
prev.task.cancel()
|
||||
prev_task = prev.task # new run awaits this before it starts writing
|
||||
if prev.evict_task and not prev.evict_task.done():
|
||||
prev.evict_task.cancel()
|
||||
run = _Run()
|
||||
_RUNS[session_id] = run
|
||||
run.task = asyncio.create_task(_drain(session_id, agen, prev_task))
|
||||
run.task = asyncio.create_task(_drain(session_id, run, agen, prev_task))
|
||||
return run
|
||||
|
||||
|
||||
async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
||||
async def subscribe(
|
||||
session_id: str,
|
||||
expected_run: Optional[_Run] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Replay the run's buffer from the start, then stream live until it ends.
|
||||
Safe to call repeatedly (reconnect) and from multiple clients at once."""
|
||||
run = _RUNS.get(session_id)
|
||||
Safe to call repeatedly (reconnect) and from multiple clients at once.
|
||||
|
||||
``expected_run`` binds a lazy StreamingResponse body to the same run whose
|
||||
identity was put in its response headers. Without that binding, a rapid
|
||||
replacement between response construction and body iteration could replay
|
||||
the replacement run under the prior run's identity.
|
||||
"""
|
||||
run = expected_run or _RUNS.get(session_id)
|
||||
if run is None:
|
||||
return
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
@@ -201,12 +252,19 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
||||
# Last subscriber gone on a finished run — (re)arm eviction so the
|
||||
# buffer doesn't linger indefinitely.
|
||||
if not run.subscribers and run.status != "running":
|
||||
_schedule_evict(session_id)
|
||||
_schedule_evict(session_id, run)
|
||||
|
||||
|
||||
def stop(session_id: str) -> bool:
|
||||
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
|
||||
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
|
||||
"""Cancel the matching in-flight run (which saves its partial output).
|
||||
|
||||
A stale browser may issue Stop after another tab has replaced the session's
|
||||
run. Once the caller knows its opaque run identity, fail closed rather than
|
||||
cancelling that newer run.
|
||||
"""
|
||||
run = _RUNS.get(session_id)
|
||||
if not expected_run_id or run is None or run.run_id != expected_run_id:
|
||||
return False
|
||||
if run and run.task and not run.task.done():
|
||||
run.task.cancel()
|
||||
return True
|
||||
|
||||
@@ -510,7 +510,12 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# set/get/list/delete operate on the REAL app settings (the same store
|
||||
# the Settings panel writes), so changing a model / voice / search
|
||||
# engine / reminder channel from chat actually takes effect.
|
||||
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
||||
from src.settings import (
|
||||
DEFAULT_SETTINGS,
|
||||
RETIRED_SETTING_KEYS,
|
||||
load_settings,
|
||||
save_settings,
|
||||
)
|
||||
|
||||
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||
# so API keys never flow through chat. User sets these in the panel.
|
||||
@@ -562,6 +567,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return k2
|
||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||
|
||||
def _is_managed_key(key):
|
||||
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||
|
||||
_ENUMS = {
|
||||
"image_quality": ["low", "medium", "high"],
|
||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||
@@ -624,14 +632,18 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
if action == "list":
|
||||
s = load_settings()
|
||||
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
||||
shown = {
|
||||
k: _mask(k, v)
|
||||
for k, v in s.items()
|
||||
if _is_managed_key(k) and not isinstance(v, dict)
|
||||
}
|
||||
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||
|
||||
elif action == "get":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if not key:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
if not _is_managed_key(key):
|
||||
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||
@@ -642,11 +654,11 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if not raw:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
key = _resolve(raw)
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
if not _is_managed_key(key):
|
||||
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
||||
# Structured settings (dicts/lists like keybinds or vision fallbacks)
|
||||
# have no safe scalar coercion; _coerce would pass a bare string
|
||||
# straight through and clobber the structure. Refuse them here; they're
|
||||
# edited in their dedicated panels. (reset/delete still restore the
|
||||
@@ -675,7 +687,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
elif action == "delete" or action == "reset":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
if not _is_managed_key(key):
|
||||
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import re
|
||||
from src.constants import MAX_READ_CHARS
|
||||
from src.tool_approvals import document_content_digest
|
||||
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||
from src.upload_handler import reserve_upload_references
|
||||
|
||||
@@ -80,6 +81,40 @@ def _most_recent_owned_document(db, Document, owner: Optional[str], active_only:
|
||||
return q.order_by(Document.updated_at.desc()).first()
|
||||
|
||||
|
||||
def _approved_document_version_error(doc: Any, ctx: dict) -> Optional[Dict]:
|
||||
"""Reject a sealed document action when its target changed meanwhile."""
|
||||
expected_version = ctx.get("expected_document_version")
|
||||
expected_digest = (
|
||||
str(ctx.get("expected_document_digest") or "").strip().lower()
|
||||
)
|
||||
if expected_version is None and not expected_digest:
|
||||
return None
|
||||
try:
|
||||
version_unchanged = (
|
||||
expected_version is None
|
||||
or int(getattr(doc, "version_count", -1)) == int(expected_version)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
version_unchanged = False
|
||||
content_unchanged = True
|
||||
if expected_digest:
|
||||
content_unchanged = (
|
||||
doc is not None
|
||||
and document_content_digest(getattr(doc, "current_content", ""))
|
||||
== expected_digest
|
||||
)
|
||||
if version_unchanged and content_unchanged:
|
||||
return None
|
||||
return {
|
||||
"error": (
|
||||
"The target document changed after this action was proposed. "
|
||||
"Review the latest version and request the edit again."
|
||||
),
|
||||
"exit_code": 1,
|
||||
"document_changed": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document tools — create/update/edit/suggest living documents
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -454,6 +489,12 @@ class UpdateDocumentTool:
|
||||
doc = None
|
||||
if target_id:
|
||||
doc = _get_owned_document(db, Document, target_id, owner)
|
||||
if (
|
||||
not doc
|
||||
and target_id
|
||||
and ctx.get("expected_document_version") is not None
|
||||
):
|
||||
return _approved_document_version_error(None, ctx)
|
||||
if not doc:
|
||||
doc = _most_recent_owned_document(db, Document, owner)
|
||||
if doc:
|
||||
@@ -463,6 +504,10 @@ class UpdateDocumentTool:
|
||||
if not doc:
|
||||
return {"error": "No documents exist to update"}
|
||||
|
||||
version_error = _approved_document_version_error(doc, ctx)
|
||||
if version_error:
|
||||
return version_error
|
||||
|
||||
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||
new_content = _coerce_email_document_content(doc.current_content or "", content) if is_email_doc else content.strip()
|
||||
if is_email_doc:
|
||||
@@ -530,6 +575,12 @@ class EditDocumentTool:
|
||||
doc = None
|
||||
if target_id:
|
||||
doc = _get_owned_document(db, Document, target_id, owner)
|
||||
if (
|
||||
not doc
|
||||
and target_id
|
||||
and ctx.get("expected_document_version") is not None
|
||||
):
|
||||
return _approved_document_version_error(None, ctx)
|
||||
if not doc:
|
||||
# Fallback: most recently updated document. Avoids "no active doc" errors
|
||||
# after server restart or when the agent loses track of which doc to edit.
|
||||
@@ -541,6 +592,10 @@ class EditDocumentTool:
|
||||
if not doc:
|
||||
return {"error": "No documents exist to edit"}
|
||||
|
||||
version_error = _approved_document_version_error(doc, ctx)
|
||||
if version_error:
|
||||
return version_error
|
||||
|
||||
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
|
||||
if blank_find_edits:
|
||||
@@ -677,6 +732,10 @@ class SuggestDocumentTool:
|
||||
if not doc:
|
||||
return {"error": f"Document {target_id} not found"}
|
||||
|
||||
version_error = _approved_document_version_error(doc, ctx)
|
||||
if version_error:
|
||||
return version_error
|
||||
|
||||
# Validate that FIND text exists in document
|
||||
valid = []
|
||||
for s in suggestions:
|
||||
|
||||
@@ -64,7 +64,10 @@ async def chat_with_model(content: str, session_id: Optional[str] = None, owner:
|
||||
return {"model": model, "response": response}
|
||||
except Exception as e:
|
||||
logger.error(f"chat_with_model failed: {e}")
|
||||
return {"error": f"Failed to get response from {model_spec}: {e}"}
|
||||
return {
|
||||
"error": f"Failed to get response from {model_spec}: {e}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
|
||||
async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
@@ -110,7 +113,10 @@ async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Opt
|
||||
return {"model": model, "response": response, "teacher": True}
|
||||
except Exception as e:
|
||||
logger.error(f"ask_teacher failed: {e}")
|
||||
return {"error": f"Teacher call failed ({model_spec}): {e}"}
|
||||
return {
|
||||
"error": f"Teacher call failed ({model_spec}): {e}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
|
||||
async def list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
@@ -240,7 +240,10 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"send_to_session failed: {e}")
|
||||
return {"error": f"Failed to send to session: {e}"}
|
||||
return {
|
||||
"error": f"Failed to send to session: {e}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
async def manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage sessions: rename, archive, delete, important, truncate, fork.
|
||||
|
||||
@@ -66,6 +66,7 @@ class WebSearchTool:
|
||||
return {
|
||||
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
if progress_cb:
|
||||
await progress_cb({
|
||||
@@ -136,7 +137,11 @@ class WebFetchTool:
|
||||
|
||||
if not text:
|
||||
if err:
|
||||
return {"error": f"web_fetch: {url}: {err}", "exit_code": 1}
|
||||
return {
|
||||
"error": f"web_fetch: {url}: {err}",
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
|
||||
|
||||
# Tell the model when the download budget cut the body short and how
|
||||
|
||||
+24
-6
@@ -324,7 +324,10 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"pipeline failed at step {len(step_outputs) + 1}: {e}")
|
||||
return {"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}"}
|
||||
return {
|
||||
"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1089,7 +1092,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text))
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": f"Image generation failed ({resp.status_code}): {error_text}"}
|
||||
return {
|
||||
"error": f"Image generation failed ({resp.status_code}): {error_text}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
@@ -1173,7 +1179,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Image generation timed out (300s). The model may be overloaded — try again or use quality=low."}
|
||||
except Exception as e:
|
||||
return {"error": f"Image generation error: {str(e)}"}
|
||||
return {
|
||||
"error": f"Image generation error: {str(e)}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
|
||||
async def do_edit_image(
|
||||
@@ -1310,7 +1319,10 @@ async def do_edit_image(
|
||||
error_text = err_json.get("detail") or err_json.get("error") or error_text
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
|
||||
return {
|
||||
"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
fallback_data = fallback_resp.json()
|
||||
image_b64 = fallback_data.get("image")
|
||||
if not image_b64:
|
||||
@@ -1394,7 +1406,10 @@ async def do_edit_image(
|
||||
"model for attached-image prompts."
|
||||
)
|
||||
}
|
||||
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
|
||||
return {
|
||||
"error": f"Image edit failed ({resp.status_code}): {error_text}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
@@ -1434,7 +1449,10 @@ async def do_edit_image(
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
|
||||
except Exception as e:
|
||||
return {"error": f"Image edit error: {str(e)}"}
|
||||
return {
|
||||
"error": f"Image edit error: {str(e)}",
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+13
-1
@@ -4,6 +4,8 @@ import os
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from src.owner_identity import auth_disabled, effective_storage_owner
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> Optional[str]:
|
||||
"""Get current username from request state (set by auth middleware)."""
|
||||
@@ -56,7 +58,17 @@ def _auth_disabled() -> bool:
|
||||
"""True when the operator has explicitly turned off auth via .env.
|
||||
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
|
||||
three call sites agree on what "off" means."""
|
||||
return os.getenv("AUTH_ENABLED", "true").lower() == "false"
|
||||
return auth_disabled()
|
||||
|
||||
|
||||
def storage_owner_for_request(request: Request) -> Optional[str]:
|
||||
"""Resolve the storage owner for code paths that need an owner bucket.
|
||||
|
||||
This does not replace route authentication. It only gives auth-disabled
|
||||
no-login mode a stable storage identity instead of writing new data as
|
||||
legacy NULL/ownerless state.
|
||||
"""
|
||||
return effective_storage_owner(effective_user(request))
|
||||
|
||||
|
||||
def require_user(request: Request) -> str:
|
||||
|
||||
+20
-9
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
|
||||
from src import bg_jobs
|
||||
from src.prompt_security import untrusted_context_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +26,16 @@ POLL_INTERVAL_S = 5
|
||||
_FOLLOWUP_MAX_ROUNDS = 12
|
||||
|
||||
|
||||
def _background_result_message(rec):
|
||||
inject = (
|
||||
f"[Background job {rec['id']} finished]\n\n"
|
||||
f"{bg_jobs.result_text(rec)}\n\n"
|
||||
"Continue the task using this output. Don't repeat work that's already done. "
|
||||
"If the task is now complete, give the user the final result."
|
||||
)
|
||||
return untrusted_context_message("background job output", inject)
|
||||
|
||||
|
||||
async def _drain_agent(sess, messages):
|
||||
"""Run the agent loop headless against a session. Returns
|
||||
(final_prose, tool_events) — tool_events in the same shape the live chat
|
||||
@@ -62,13 +73,19 @@ async def _drain_agent(sess, messages):
|
||||
round_num = d.get("round", round_num)
|
||||
elif d.get("type") == "tool_output":
|
||||
# Mirror the live chat's tool_event shape (chat_routes / chatRenderer).
|
||||
tool_events.append({
|
||||
tool_event = {
|
||||
"round": round_num,
|
||||
"tool": d.get("tool"),
|
||||
"command": d.get("command"),
|
||||
"output": d.get("output"),
|
||||
"exit_code": d.get("exit_code"),
|
||||
})
|
||||
}
|
||||
if isinstance(d.get("ask_user"), dict):
|
||||
# Preserve exact-approval cards from a tainted background-job
|
||||
# continuation so the user can authorize the sealed action on
|
||||
# the next foreground turn instead of losing it headlessly.
|
||||
tool_event["ask_user"] = d["ask_user"]
|
||||
tool_events.append(tool_event)
|
||||
return full, tool_events
|
||||
|
||||
|
||||
@@ -101,14 +118,8 @@ async def _run_followup(rec: dict) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
inject = (
|
||||
f"[Background job {rec['id']} finished]\n\n"
|
||||
f"{bg_jobs.result_text(rec)}\n\n"
|
||||
"Continue the task using this output. Don't repeat work that's already done. "
|
||||
"If the task is now complete, give the user the final result."
|
||||
)
|
||||
context = sess.get_context_messages()
|
||||
context.append({"role": "user", "content": inject})
|
||||
context.append(_background_result_message(rec))
|
||||
|
||||
full, tool_events = await _drain_agent(sess, context)
|
||||
|
||||
|
||||
@@ -381,7 +381,10 @@ class ChatProcessor:
|
||||
)
|
||||
if len(rag_content) > 10000:
|
||||
rag_content = rag_content[:10000] + "\n[Truncated]"
|
||||
preface.append(untrusted_context_message("retrieved documents", rag_content))
|
||||
preface.append(untrusted_context_message(
|
||||
"retrieved documents",
|
||||
rag_content,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG retrieval failed: {e}")
|
||||
|
||||
@@ -465,6 +468,7 @@ class ChatProcessor:
|
||||
preface.append(untrusted_context_message(
|
||||
f"web page: {url}",
|
||||
f"Content from {url}:\n\n{content}",
|
||||
provenance_origin="external",
|
||||
))
|
||||
|
||||
# Skills index — progressive disclosure. Only injected when the
|
||||
@@ -488,6 +492,9 @@ class ChatProcessor:
|
||||
for s in sorted(by_cat[cat], key=lambda x: x["name"]):
|
||||
desc = s.get("description") or ""
|
||||
lines.append(f" - {s['name']}: {desc}" if desc else f" - {s['name']}")
|
||||
preface.append(untrusted_context_message("available skills index", "\n".join(lines)))
|
||||
preface.append(untrusted_context_message(
|
||||
"available skills index",
|
||||
"\n".join(lines),
|
||||
))
|
||||
|
||||
return preface, rag_sources, web_sources
|
||||
|
||||
@@ -282,7 +282,9 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
|
||||
if essential_system:
|
||||
sys_text = essential_system[0].get("content", "")
|
||||
if len(sys_text) > 2000:
|
||||
essential_system[0] = {"role": "system", "content": sys_text[:2000] + "\n[System prompt truncated for context limits]"}
|
||||
truncated_system = dict(essential_system[0])
|
||||
truncated_system["content"] = sys_text[:2000] + "\n[System prompt truncated for context limits]"
|
||||
essential_system[0] = truncated_system
|
||||
trimmed = essential_system + convo_msgs
|
||||
if estimate_tokens(trimmed) <= budget:
|
||||
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
|
||||
@@ -325,6 +327,9 @@ async def maybe_compact(
|
||||
messages: List[Dict],
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
persist: bool = True,
|
||||
compaction_state: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple:
|
||||
"""Check context usage and compact if above threshold.
|
||||
|
||||
@@ -416,7 +421,17 @@ async def maybe_compact(
|
||||
# offset — session.history INCLUDES the system messages, but
|
||||
# split_point is indexed against convo_msgs which does NOT. Without
|
||||
# this, the slice drops the leading system message(s).
|
||||
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
|
||||
if compaction_state is not None:
|
||||
compaction_state.update({
|
||||
"split_point": split_point,
|
||||
"summary": summary,
|
||||
"system_msg_count": len(system_msgs),
|
||||
"applied": False,
|
||||
})
|
||||
if persist:
|
||||
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
|
||||
if compaction_state is not None:
|
||||
compaction_state["applied"] = True
|
||||
|
||||
new_used = estimate_tokens(compacted)
|
||||
logger.info(
|
||||
@@ -427,6 +442,51 @@ async def maybe_compact(
|
||||
return compacted, context_length, True
|
||||
|
||||
|
||||
def apply_compaction_state(session, compaction_state: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Persist a route-specific compaction after that route commits output.
|
||||
|
||||
Candidate prompts may be compacted speculatively while an explicit
|
||||
foreground fallback chain is being tried. Persisting at construction time
|
||||
would let an unavailable route rewrite history before another route answers,
|
||||
so callers hold this small plan and apply only the winning route's plan.
|
||||
"""
|
||||
|
||||
state = compaction_state if isinstance(compaction_state, dict) else None
|
||||
if not state or state.get("applied"):
|
||||
return False
|
||||
summary = state.get("summary")
|
||||
split_point = state.get("split_point")
|
||||
system_msg_count = state.get("system_msg_count", 0)
|
||||
if not isinstance(summary, str) or not isinstance(split_point, int):
|
||||
return False
|
||||
_update_session_history(
|
||||
session,
|
||||
split_point,
|
||||
summary,
|
||||
system_msg_count=system_msg_count if isinstance(system_msg_count, int) else 0,
|
||||
)
|
||||
state["applied"] = True
|
||||
return True
|
||||
|
||||
|
||||
def apply_compaction_state_for_session(
|
||||
session_id: Optional[str],
|
||||
compaction_state: Optional[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Resolve an in-memory session and apply a deferred compaction plan."""
|
||||
|
||||
if not session_id:
|
||||
return False
|
||||
try:
|
||||
from core.models import get_session_manager_instance
|
||||
|
||||
manager = get_session_manager_instance()
|
||||
session = manager.get_session(session_id) if manager else None
|
||||
except Exception:
|
||||
session = None
|
||||
return apply_compaction_state(session, compaction_state) if session else False
|
||||
|
||||
|
||||
def _update_session_history(session, split_point: int, summary: str,
|
||||
system_msg_count: int = 0):
|
||||
"""Update the in-memory session history after compaction.
|
||||
|
||||
+216
-20
@@ -5,6 +5,7 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
|
||||
"""
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -27,6 +28,43 @@ _NON_CHAT_MODEL = (
|
||||
)
|
||||
|
||||
|
||||
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
|
||||
"""Return whether token cost should be tracked for a concrete route.
|
||||
|
||||
This is intentionally a non-secret route classification. It mirrors the
|
||||
frontend's local/subscription exclusions without exposing endpoint URLs to
|
||||
message metadata.
|
||||
"""
|
||||
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = (parsed.hostname or "").lower().rstrip(".")
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
except Exception:
|
||||
return False
|
||||
if not host:
|
||||
return False
|
||||
if host == "chatgpt.com" and (
|
||||
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
|
||||
):
|
||||
return False
|
||||
kind = str(endpoint_kind or "auto").strip().lower()
|
||||
if kind == "local":
|
||||
return False
|
||||
if kind in {"api", "proxy"}:
|
||||
return True
|
||||
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return ip.is_global
|
||||
except ValueError:
|
||||
pass
|
||||
if "." not in host:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _first_chat_model(models) -> Optional[str]:
|
||||
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
||||
for m in (models or []):
|
||||
@@ -396,10 +434,14 @@ def resolve_endpoint(
|
||||
db.close()
|
||||
|
||||
|
||||
def resolve_endpoint_by_id(
|
||||
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
|
||||
) -> Optional[Tuple[str, str, Dict]]:
|
||||
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
|
||||
def _resolve_endpoint_by_id_with_descriptor(
|
||||
ep_id: str,
|
||||
model: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
|
||||
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
|
||||
|
||||
Returns None if the endpoint doesn't exist or is disabled. Used to turn
|
||||
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
|
||||
@@ -426,15 +468,34 @@ def resolve_endpoint_by_id(
|
||||
chat_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
m = (model or "").strip()
|
||||
# Drop a model the user disabled on the endpoint, then pick the first
|
||||
# enabled chat model rather than a hidden one.
|
||||
if m and m in _endpoint_hidden_models(ep):
|
||||
m = ""
|
||||
if not m:
|
||||
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
|
||||
enabled_models = _endpoint_enabled_models(ep)
|
||||
if require_exact_model:
|
||||
# Explicit foreground fallback entries are concrete choices. A
|
||||
# hidden or known-missing model must disable the entry instead of
|
||||
# silently substituting another model from the endpoint.
|
||||
if not m or m in _endpoint_hidden_models(ep):
|
||||
return None
|
||||
if enabled_models and m not in enabled_models:
|
||||
return None
|
||||
else:
|
||||
# Legacy Utility/Vision chains retain their model-repair behavior.
|
||||
if m and m in _endpoint_hidden_models(ep):
|
||||
m = ""
|
||||
if not m:
|
||||
m = _first_chat_model(enabled_models) or ""
|
||||
if not m:
|
||||
return None
|
||||
return chat_url, m, headers
|
||||
return (
|
||||
(chat_url, m, headers),
|
||||
{
|
||||
"endpoint_id": ep.id,
|
||||
"endpoint_label": getattr(ep, "name", None) or ep.id,
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(
|
||||
chat_url,
|
||||
getattr(ep, "endpoint_kind", None),
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
||||
return None
|
||||
@@ -442,11 +503,101 @@ def resolve_endpoint_by_id(
|
||||
db.close()
|
||||
|
||||
|
||||
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Compatibility shim for the retired default-chat fallback chain."""
|
||||
def resolve_endpoint_by_id(
|
||||
ep_id: str,
|
||||
model: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> Optional[Tuple[str, str, Dict]]:
|
||||
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
|
||||
|
||||
del owner
|
||||
return []
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
ep_id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
return resolved[0] if resolved else None
|
||||
|
||||
|
||||
def resolve_route_descriptor(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Return the visible endpoint identity for an already-resolved route.
|
||||
|
||||
Headers are compared only inside the process so two endpoints using the
|
||||
same provider URL/model but different credentials remain distinguishable.
|
||||
No credential material is returned or logged.
|
||||
"""
|
||||
|
||||
if not endpoint_url or not model:
|
||||
return {
|
||||
"endpoint_id": None,
|
||||
"endpoint_label": "Selected route",
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
||||
}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
expected = (endpoint_url.rstrip("/"), model, headers or {})
|
||||
for ep in q.all():
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
ep.id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
if not resolved:
|
||||
continue
|
||||
candidate, descriptor = resolved
|
||||
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
||||
if actual == expected:
|
||||
return descriptor
|
||||
except Exception as e:
|
||||
logger.debug("Could not identify selected endpoint route: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
return {
|
||||
"endpoint_id": None,
|
||||
"endpoint_label": "Selected route",
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
||||
}
|
||||
|
||||
|
||||
def resolve_route_descriptor_by_id(
|
||||
endpoint_id: str,
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Resolve a selected route's identity without relying on row order.
|
||||
|
||||
The explicit endpoint id is still verified against the resolved runtime
|
||||
route. This prevents stale or mismatched request metadata from being used
|
||||
for attribution while disambiguating endpoints whose routes are otherwise
|
||||
identical.
|
||||
"""
|
||||
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
endpoint_id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
if not resolved:
|
||||
return None
|
||||
candidate, descriptor = resolved
|
||||
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
|
||||
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
||||
return descriptor if actual == expected else None
|
||||
|
||||
|
||||
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
@@ -460,17 +611,62 @@ def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
|
||||
|
||||
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
|
||||
out = []
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
settings = load_settings()
|
||||
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
||||
except Exception:
|
||||
return out
|
||||
for entry in chain:
|
||||
return []
|
||||
return resolve_fallback_entries(chain, owner=owner)
|
||||
|
||||
|
||||
def resolve_fallback_entries(
|
||||
entries,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> list:
|
||||
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
|
||||
|
||||
out = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
|
||||
if resolved:
|
||||
resolved = resolve_endpoint_by_id(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
if resolved and resolved not in out:
|
||||
out.append(resolved)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_fallback_entries_with_descriptors(
|
||||
entries,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> list:
|
||||
"""Resolve ordered entries while retaining safe endpoint provenance."""
|
||||
|
||||
out = []
|
||||
seen = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
if not resolved:
|
||||
continue
|
||||
candidate, descriptor = resolved
|
||||
if any(candidate == prior for prior in seen):
|
||||
continue
|
||||
seen.append(candidate)
|
||||
out.append((candidate, descriptor))
|
||||
return out
|
||||
|
||||
+189
-14
@@ -1,22 +1,155 @@
|
||||
"""Foreground Chat and Agent model-routing policy.
|
||||
"""Explicit foreground Chat and Agent model-routing policy."""
|
||||
|
||||
The selected session model is strict by default. Historical
|
||||
``default_model_fallbacks`` values remain stored for compatibility, but this
|
||||
policy intentionally does not read or migrate them.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from src.endpoint_resolver import (
|
||||
endpoint_cost_tracked,
|
||||
resolve_fallback_entries,
|
||||
resolve_fallback_entries_with_descriptors,
|
||||
resolve_route_descriptor,
|
||||
resolve_route_descriptor_by_id,
|
||||
)
|
||||
|
||||
_DEFAULT_FALLBACK_ENTRY_RESOLVER = resolve_fallback_entries
|
||||
|
||||
|
||||
FOREGROUND_FALLBACK_ENABLED_KEY = "foreground_fallback_enabled"
|
||||
FOREGROUND_FALLBACK_LIST_KEY = "foreground_model_fallbacks"
|
||||
FOREGROUND_AVAILABILITY_STATUSES: FrozenSet[int] = frozenset({
|
||||
408, 425, 429, 500, 502, 503, 504, 507, 508, 529,
|
||||
})
|
||||
MAX_FOREGROUND_FALLBACKS = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundModelPolicy:
|
||||
"""Resolved per-user foreground fallback policy."""
|
||||
|
||||
enabled: bool = False
|
||||
fallback_candidates: Tuple[tuple, ...] = ()
|
||||
fallback_descriptors: Tuple[dict, ...] = ()
|
||||
eligible_statuses: FrozenSet[int] = FOREGROUND_AVAILABILITY_STATUSES
|
||||
fallback_on_empty: bool = False
|
||||
|
||||
|
||||
def _load_policy_preferences(owner: Optional[str]) -> dict:
|
||||
"""Load only preferences that explicitly belong to ``owner``.
|
||||
|
||||
The generic preferences loader intentionally treats a legacy flat store as
|
||||
the single-user preferences object. That compatibility must not cross an
|
||||
authentication transition: once a named owner is present, foreground
|
||||
fallback consent exists only in an actual ``_users[owner]`` dictionary.
|
||||
"""
|
||||
|
||||
from routes import prefs_routes
|
||||
|
||||
if owner is None:
|
||||
prefs = prefs_routes._load_for_user(None)
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
|
||||
raw = prefs_routes._load()
|
||||
users = raw.get("_users") if isinstance(raw, dict) else None
|
||||
if not isinstance(users, dict):
|
||||
return {}
|
||||
prefs = users.get(owner)
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
|
||||
|
||||
def resolve_foreground_model_policy(
|
||||
owner: Optional[str] = None,
|
||||
allowed_models: Optional[Collection[str]] = None,
|
||||
) -> ForegroundModelPolicy:
|
||||
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
|
||||
|
||||
The policy is stored in user preferences even when authentication is
|
||||
disabled. Historical ``default_model_fallbacks`` values are deliberately
|
||||
unrelated and are never read or migrated.
|
||||
"""
|
||||
|
||||
try:
|
||||
prefs = _load_policy_preferences(owner)
|
||||
except Exception:
|
||||
return ForegroundModelPolicy()
|
||||
|
||||
if prefs.get(FOREGROUND_FALLBACK_ENABLED_KEY) is not True:
|
||||
return ForegroundModelPolicy()
|
||||
|
||||
entries = prefs.get(FOREGROUND_FALLBACK_LIST_KEY)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ForegroundModelPolicy()
|
||||
if allowed_models is not None:
|
||||
allowed = frozenset(allowed_models)
|
||||
entries = [
|
||||
entry for entry in entries
|
||||
if (
|
||||
isinstance(entry, dict)
|
||||
and isinstance(entry.get("model"), str)
|
||||
and entry.get("model") in allowed
|
||||
)
|
||||
]
|
||||
if not entries:
|
||||
return ForegroundModelPolicy()
|
||||
entries = entries[:MAX_FOREGROUND_FALLBACKS]
|
||||
|
||||
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
|
||||
# Preserve the long-standing resolver seam used by downstream tests and
|
||||
# integrations. Production uses the descriptor-aware resolver below.
|
||||
compatibility_candidates = resolve_fallback_entries(
|
||||
entries,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
# Known limitation of this test-only seam: alignment matches on model
|
||||
# alone, so when two entries share a model and the resolver skips the
|
||||
# first, the surviving candidate inherits the skipped entry's
|
||||
# endpoint_id. Production uses the descriptor-aware branch below,
|
||||
# which is unaffected.
|
||||
resolved_routes = []
|
||||
remaining_entries = list(entries)
|
||||
for candidate in compatibility_candidates:
|
||||
matching_index = next(
|
||||
(
|
||||
index for index, entry in enumerate(remaining_entries)
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("model") == candidate[1]
|
||||
),
|
||||
None,
|
||||
)
|
||||
matching_entry = (
|
||||
remaining_entries.pop(matching_index)
|
||||
if matching_index is not None
|
||||
else {}
|
||||
)
|
||||
descriptor = {
|
||||
"endpoint_id": matching_entry.get("endpoint_id"),
|
||||
"endpoint_label": matching_entry.get("endpoint_id") or "Fallback route",
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
|
||||
}
|
||||
resolved_routes.append((candidate, descriptor))
|
||||
else:
|
||||
resolved_routes = resolve_fallback_entries_with_descriptors(
|
||||
entries,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
candidates = [candidate for candidate, _descriptor in resolved_routes]
|
||||
if not candidates:
|
||||
return ForegroundModelPolicy()
|
||||
|
||||
return ForegroundModelPolicy(
|
||||
enabled=True,
|
||||
fallback_candidates=tuple(candidates),
|
||||
fallback_descriptors=tuple(
|
||||
dict(descriptor) for _candidate, descriptor in resolved_routes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Return fallback candidates for a foreground Chat or Agent request.
|
||||
"""Return only candidates explicitly enabled by the current user."""
|
||||
|
||||
Foreground routing is strict, so no alternate endpoint/model is eligible.
|
||||
``owner`` is accepted to keep this policy boundary owner-aware.
|
||||
"""
|
||||
|
||||
del owner
|
||||
return []
|
||||
return list(resolve_foreground_model_policy(owner).fallback_candidates)
|
||||
|
||||
|
||||
def build_foreground_model_candidates(
|
||||
@@ -24,8 +157,50 @@ def build_foreground_model_candidates(
|
||||
model: str,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
owner: Optional[str] = None,
|
||||
policy: Optional[ForegroundModelPolicy] = None,
|
||||
) -> list:
|
||||
"""Build the ordered candidate list for a foreground request."""
|
||||
|
||||
policy = policy or resolve_foreground_model_policy(owner)
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
|
||||
candidates = [primary]
|
||||
for candidate in policy.fallback_candidates:
|
||||
if candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def build_foreground_route_descriptors(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
owner: Optional[str] = None,
|
||||
policy: Optional[ForegroundModelPolicy] = None,
|
||||
selected_endpoint_id: Optional[str] = None,
|
||||
) -> list:
|
||||
"""Build safe route metadata parallel to foreground candidates."""
|
||||
|
||||
policy = policy or resolve_foreground_model_policy(owner)
|
||||
selected = None
|
||||
if selected_endpoint_id:
|
||||
selected = resolve_route_descriptor_by_id(
|
||||
selected_endpoint_id,
|
||||
endpoint_url,
|
||||
model,
|
||||
headers or {},
|
||||
owner=owner,
|
||||
)
|
||||
if selected is None:
|
||||
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
candidates = [primary]
|
||||
descriptors = [selected]
|
||||
for candidate, descriptor in zip(
|
||||
policy.fallback_candidates,
|
||||
policy.fallback_descriptors,
|
||||
):
|
||||
if candidate in candidates:
|
||||
continue
|
||||
candidates.append(candidate)
|
||||
descriptors.append(dict(descriptor))
|
||||
return descriptors
|
||||
|
||||
+8
-1
@@ -719,7 +719,14 @@ async def execute_api_call(
|
||||
output = f"HTTP {status}\n{formatted}"
|
||||
|
||||
if status >= 400:
|
||||
return {"error": output, "exit_code": 1}
|
||||
return {
|
||||
"error": output,
|
||||
"exit_code": 1,
|
||||
# The error string includes the remote response body. Preserve
|
||||
# it for diagnostics, but make its provenance explicit so the
|
||||
# agent gate does not treat HTTP failure as content-free.
|
||||
"untrusted_content": True,
|
||||
}
|
||||
|
||||
return {"output": output, "exit_code": 0}
|
||||
|
||||
|
||||
@@ -63,8 +63,11 @@ _PASSIVE_EXACT_PATHS = {
|
||||
"/api/activity/heartbeat",
|
||||
"/api/client-perf",
|
||||
"/api/tasks/notifications",
|
||||
"/api/tasks/runs/recent",
|
||||
"/api/research/active",
|
||||
"/api/email/urgency-state",
|
||||
# UI idle poll sibling of urgency-state; must not pre-empt background tasks.
|
||||
"/api/email/unread-state",
|
||||
}
|
||||
|
||||
_PASSIVE_PREFIXES = (
|
||||
@@ -74,6 +77,19 @@ _PASSIVE_PREFIXES = (
|
||||
)
|
||||
|
||||
|
||||
async def maybe_stop_background_tasks_for_heartbeat(stop_background) -> bool:
|
||||
"""Stop background work for browser activity only when the gate is enabled.
|
||||
|
||||
``stop_background`` is injected by the application boundary so this policy
|
||||
remains independently testable without importing the full FastAPI app.
|
||||
"""
|
||||
if not _enabled():
|
||||
return False
|
||||
|
||||
await stop_background(reason="browser heartbeat")
|
||||
return True
|
||||
|
||||
|
||||
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
|
||||
if not _enabled():
|
||||
return False
|
||||
|
||||
+885
-126
File diff suppressed because it is too large
Load Diff
@@ -530,6 +530,8 @@ class McpManager:
|
||||
"stderr": output if is_error else "",
|
||||
"exit_code": 1 if is_error else 0,
|
||||
}
|
||||
if is_error and output:
|
||||
result_dict["untrusted_content"] = True
|
||||
if images:
|
||||
result_dict["images"] = images
|
||||
return result_dict
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Shared owner identity constants and helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
DEFAULT_LOCAL_OWNER = "__odysseus_local__"
|
||||
DEFAULT_LOCAL_OWNER_LABEL = "Local"
|
||||
INTERNAL_TOOL_USER = "internal-tool"
|
||||
|
||||
REQUEST_SENTINEL_OWNERS = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
||||
RESERVED_AUTH_USERNAMES = REQUEST_SENTINEL_OWNERS | {DEFAULT_LOCAL_OWNER}
|
||||
|
||||
|
||||
def auth_disabled() -> bool:
|
||||
"""Return True only when auth is explicitly disabled by configuration."""
|
||||
return os.getenv("AUTH_ENABLED", "true").strip().lower() == "false"
|
||||
|
||||
|
||||
def normalize_owner(owner: str | None) -> Optional[str]:
|
||||
"""Normalize an owner-like value without inventing a fallback identity."""
|
||||
value = str(owner or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def owner_key(owner: str | None) -> Optional[str]:
|
||||
normalized = normalize_owner(owner)
|
||||
return normalized.lower() if normalized else None
|
||||
|
||||
|
||||
def is_request_sentinel_owner(owner: str | None) -> bool:
|
||||
return owner_key(owner) in REQUEST_SENTINEL_OWNERS
|
||||
|
||||
|
||||
def effective_storage_owner(owner: str | None, *, auth_is_disabled: bool | None = None) -> Optional[str]:
|
||||
"""Resolve the owner used for storage writes that need a real bucket.
|
||||
|
||||
``None`` still means no authenticated owner when auth is enabled. In the
|
||||
explicit no-login mode, it resolves to the reserved local owner instead of
|
||||
conflating local-operator writes with legacy NULL/ownerless rows.
|
||||
"""
|
||||
normalized = normalize_owner(owner)
|
||||
if normalized:
|
||||
if is_request_sentinel_owner(normalized):
|
||||
return None
|
||||
return normalized
|
||||
disabled = auth_disabled() if auth_is_disabled is None else auth_is_disabled
|
||||
if disabled:
|
||||
return DEFAULT_LOCAL_OWNER
|
||||
return None
|
||||
|
||||
|
||||
def is_default_local_owner(owner: str | None) -> bool:
|
||||
return owner_key(owner) == DEFAULT_LOCAL_OWNER
|
||||
+15
-2
@@ -61,7 +61,13 @@ def _sanitize_label(label: str) -> str:
|
||||
return label
|
||||
|
||||
|
||||
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
||||
def untrusted_context_message(
|
||||
label: str,
|
||||
content: Any,
|
||||
*,
|
||||
provenance_origin: str | None = None,
|
||||
arm_tool_gate: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return an LLM message that keeps retrieved/source text out of system role.
|
||||
|
||||
The template is structured so that *only* the hardcoded
|
||||
@@ -73,6 +79,13 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
||||
safe_label = _sanitize_label(label)
|
||||
text = "" if content is None else str(content)
|
||||
text = _escape_guard_markers(text)
|
||||
metadata: Dict[str, Any] = {
|
||||
"trusted": False,
|
||||
"source": label,
|
||||
"tool_gate_untrusted": bool(arm_tool_gate),
|
||||
}
|
||||
if provenance_origin:
|
||||
metadata["provenance_origin"] = provenance_origin
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
@@ -82,5 +95,5 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
||||
f"{text}\n"
|
||||
f"{GUARD_CLOSE}"
|
||||
),
|
||||
"metadata": {"trusted": False, "source": label},
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
|
||||
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
|
||||
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
|
||||
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
|
||||
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
|
||||
|
||||
@field_validator('message')
|
||||
@classmethod
|
||||
|
||||
+19
-1
@@ -14,6 +14,13 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys retained in the raw settings store for compatibility and rollback, but
|
||||
# deliberately unavailable through generic settings APIs or agent tools. They
|
||||
# must stay in ``DEFAULT_SETTINGS`` so old files continue to load without data
|
||||
# loss; callers that present or mutate settings should use this set as a
|
||||
# tombstone boundary.
|
||||
RETIRED_SETTING_KEYS = frozenset({"default_model_fallbacks"})
|
||||
|
||||
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
|
||||
# (every chat, every preprocess); without this it re-parses the JSON each call.
|
||||
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
|
||||
@@ -197,6 +204,17 @@ DEFAULT_SETTINGS = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def without_retired_settings(settings: dict) -> dict:
|
||||
"""Return a shallow copy suitable for generic settings interfaces."""
|
||||
if not isinstance(settings, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in settings.items()
|
||||
if key not in RETIRED_SETTING_KEYS
|
||||
}
|
||||
|
||||
DEFAULT_FEATURES = {
|
||||
"web_search": True,
|
||||
"web_fetch": True,
|
||||
@@ -269,7 +287,7 @@ _PER_USER_KEYS = {
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
"default_endpoint_id", "default_model", "default_model_fallbacks",
|
||||
"default_endpoint_id", "default_model",
|
||||
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||
"research_endpoint_id", "research_model",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Shared resolver for background-task AI endpoints."""
|
||||
|
||||
from src.endpoint_resolver import (
|
||||
resolve_chat_fallback_candidates,
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
)
|
||||
@@ -32,7 +31,6 @@ def resolve_task_candidates(
|
||||
2. Utility endpoint/model
|
||||
3. Default endpoint/model
|
||||
4. Utility fallback chain
|
||||
5. Retired default-fallback compatibility hook (currently empty)
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
@@ -49,9 +47,6 @@ def resolve_task_candidates(
|
||||
_append(*resolve_endpoint("default", owner=owner))
|
||||
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
|
||||
_append(url, model, headers)
|
||||
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
|
||||
_append(url, model, headers)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
|
||||
+38
-1
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||
|
||||
from core.auth import RESERVED_USERNAMES
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
@@ -1883,6 +1884,7 @@ class TaskScheduler:
|
||||
pass
|
||||
full_text = ""
|
||||
tool_results = []
|
||||
approval_pause = None
|
||||
|
||||
# Honor per-task max_steps (defense against runaway agent loops).
|
||||
# Falls back to 20 if not set — the historical default.
|
||||
@@ -1929,9 +1931,44 @@ class TaskScheduler:
|
||||
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
|
||||
if isinstance(tool_summary, str) and tool_summary.strip():
|
||||
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
|
||||
approval = data.get("ask_user")
|
||||
if (
|
||||
isinstance(approval, dict)
|
||||
and approval.get("kind") == "tool_approval"
|
||||
):
|
||||
approval_pause = {
|
||||
"tool": data.get("tool") or "tool",
|
||||
"approval_id": approval.get("approval_id"),
|
||||
}
|
||||
# Scheduled tasks have no interactive surface that
|
||||
# can safely resume a one-use grant. Retire the
|
||||
# record immediately instead of leaving it pending
|
||||
# and report an explicit manual-action boundary.
|
||||
try:
|
||||
from src.tool_approvals import tool_approval_store
|
||||
tool_approval_store.consume(
|
||||
approval_pause["approval_id"],
|
||||
decision="deny",
|
||||
owner=task.owner,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not retire scheduled-task approval",
|
||||
exc_info=True,
|
||||
)
|
||||
break
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
if approval_pause is not None:
|
||||
return (
|
||||
"Scheduled task paused safely: "
|
||||
f"{approval_pause['tool']} requested an exact action after "
|
||||
"untrusted context. That action was not executed. Run this task "
|
||||
"interactively to inspect and approve the action."
|
||||
)
|
||||
|
||||
# Grace summarization — if the model exhausted rounds on tool calls
|
||||
# without producing a final text response, do one last LLM call
|
||||
# asking it to summarize what it did. Guarantees output.
|
||||
@@ -2484,7 +2521,7 @@ class TaskScheduler:
|
||||
# check-ins seeded, which then double-fire alongside the human user's
|
||||
# check-ins. This was the root cause of the duplicate 'Morning check-in'
|
||||
# rows we had to manually clean up.
|
||||
if not owner or owner in RESERVED_USERNAMES:
|
||||
if not owner or owner in REQUEST_SENTINEL_OWNERS:
|
||||
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
||||
return
|
||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||
|
||||
+110
-74
@@ -439,56 +439,11 @@ async def escalate_and_learn(
|
||||
failure_reason: str,
|
||||
owner: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Call the teacher, evaluate ITS attempt, save a skill on success.
|
||||
|
||||
Returns the saved skill name (or None if the teacher couldn't
|
||||
write one). Logs but doesn't raise — escalation is best-effort.
|
||||
"""
|
||||
from src.settings import get_setting
|
||||
teacher_spec = (get_setting("teacher_model", "") or "").strip()
|
||||
if not teacher_spec:
|
||||
return None
|
||||
|
||||
prompt = _TEACHER_ESCALATION_PROMPT.format(
|
||||
user_request=user_request or "(no user request captured)",
|
||||
failure_reason=failure_reason or "(failure reason not captured)",
|
||||
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
|
||||
trace=_format_trace(tool_results, agent_reply),
|
||||
"""Retire legacy background learning when no approval UI is available."""
|
||||
logger.info(
|
||||
"background teacher learning skipped: generated skills require an "
|
||||
"interactive exact approval"
|
||||
)
|
||||
response = await _call_teacher(teacher_spec, prompt, owner=owner)
|
||||
if not response:
|
||||
return None
|
||||
|
||||
skill = _extract_skill_json(response)
|
||||
if not skill:
|
||||
# Teacher chose not to write a skill — see prompt contract.
|
||||
logger.info("teacher declined to write a skill for this failure")
|
||||
return None
|
||||
|
||||
# Same regex eval applied to the teacher's response — if the
|
||||
# teacher itself sounded uncertain ("I don't have a tool"), drop
|
||||
# the skill rather than persist a sketchy one.
|
||||
status, reason = evaluate_turn_regex([], response)
|
||||
if status == "failure":
|
||||
logger.info(f"teacher response failed eval, skipping skill save: {reason}")
|
||||
return None
|
||||
|
||||
# Tag the skill with the escalation source for auditability.
|
||||
skill.setdefault("source", "teacher-escalation")
|
||||
skill.setdefault("teacher_model", teacher_spec)
|
||||
# Force action=add regardless of what the teacher wrote.
|
||||
skill["action"] = "add"
|
||||
|
||||
import json
|
||||
from src.tool_implementations import do_manage_skills
|
||||
try:
|
||||
result = await do_manage_skills(json.dumps(skill), owner=owner)
|
||||
if isinstance(result, dict) and not result.get("error"):
|
||||
logger.info(f"teacher wrote skill: {skill.get('name')}")
|
||||
return skill.get("name")
|
||||
logger.warning(f"skill save failed: {result}")
|
||||
except Exception as e:
|
||||
logger.warning(f"skill save raised: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -563,6 +518,12 @@ async def run_teacher_inline(
|
||||
student_tool_events: List[Dict[str, Any]],
|
||||
student_reply: str,
|
||||
owner: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
disabled_tools: Optional[set[str]] = None,
|
||||
tool_policy: Any = None,
|
||||
active_document: Any = None,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Async generator. Yields SSE event strings.
|
||||
|
||||
@@ -661,6 +622,7 @@ async def run_teacher_inline(
|
||||
from src.agent_loop import stream_agent_loop
|
||||
captured_tool_events: List[Dict[str, Any]] = []
|
||||
captured_text_parts: List[str] = []
|
||||
captured_metrics: Dict[str, Any] = {}
|
||||
|
||||
async for evt_str in stream_agent_loop(
|
||||
endpoint_url=teacher_url,
|
||||
@@ -668,6 +630,12 @@ async def run_teacher_inline(
|
||||
messages=teacher_messages,
|
||||
headers=teacher_headers,
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
disabled_tools=disabled_tools,
|
||||
tool_policy=tool_policy,
|
||||
active_document=active_document,
|
||||
active_email=active_email,
|
||||
_is_teacher_run=True,
|
||||
):
|
||||
# Swallow teacher's own [DONE] — outer loop emits the real one
|
||||
@@ -682,13 +650,21 @@ async def run_teacher_inline(
|
||||
if isinstance(payload, dict):
|
||||
payload["teacher"] = True
|
||||
typ = payload.get("type")
|
||||
if typ == "metrics" and isinstance(payload.get("data"), dict):
|
||||
# The outer chat route persists only the last metrics
|
||||
# payload. Keep a copy so any approval produced after the
|
||||
# recursive teacher run's metrics remains reloadable.
|
||||
captured_metrics = dict(payload["data"])
|
||||
if typ == "tool_output":
|
||||
captured_tool_events.append({
|
||||
captured_tool_event = {
|
||||
"tool": payload.get("tool"),
|
||||
"command": payload.get("command"),
|
||||
"output": payload.get("output"),
|
||||
"exit_code": payload.get("exit_code"),
|
||||
})
|
||||
}
|
||||
if isinstance(payload.get("ask_user"), dict):
|
||||
captured_tool_event["ask_user"] = payload["ask_user"]
|
||||
captured_tool_events.append(captured_tool_event)
|
||||
if "delta" in payload and isinstance(payload["delta"], str):
|
||||
if payload.get("thinking"):
|
||||
continue
|
||||
@@ -697,6 +673,12 @@ async def run_teacher_inline(
|
||||
continue
|
||||
yield evt_str
|
||||
|
||||
# A takeover that paused for a question or exact action has not completed
|
||||
# yet. Its server-owned approval card is already in the live/persisted tool
|
||||
# events; do not evaluate the partial trace or distill it into a skill.
|
||||
if any(event.get("ask_user") for event in captured_tool_events):
|
||||
return
|
||||
|
||||
teacher_text = "".join(captured_text_parts).strip()
|
||||
t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text)
|
||||
if t_status == "failure":
|
||||
@@ -740,31 +722,85 @@ async def run_teacher_inline(
|
||||
skill.setdefault("source", "teacher-escalation")
|
||||
skill.setdefault("teacher_model", teacher_spec)
|
||||
|
||||
import json as _json
|
||||
from src.tool_implementations import do_manage_skills
|
||||
try:
|
||||
result = await do_manage_skills(_json.dumps(skill), owner=owner)
|
||||
if isinstance(result, dict) and not result.get("error"):
|
||||
logger.info(f"teacher succeeded; saved skill: {skill.get('name')}")
|
||||
yield (
|
||||
'data: ' + json.dumps({
|
||||
"type": "skill_saved",
|
||||
"name": skill.get("name"),
|
||||
"category": skill.get("category", "general"),
|
||||
}) + '\n\n'
|
||||
)
|
||||
else:
|
||||
yield (
|
||||
'data: ' + json.dumps({
|
||||
"type": "skill_save_failed",
|
||||
"reason": str(result),
|
||||
}) + '\n\n'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"skill save raised: {e}")
|
||||
if not session_id:
|
||||
yield (
|
||||
'data: ' + json.dumps({
|
||||
"type": "skill_save_failed",
|
||||
"reason": str(e),
|
||||
"reason": (
|
||||
"Teacher-generated skills require an interactive exact "
|
||||
"approval before they can be saved."
|
||||
),
|
||||
}) + '\n\n'
|
||||
)
|
||||
return
|
||||
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from src.tool_approvals import tool_approval_store
|
||||
from src.tool_capabilities import capabilities_for_action
|
||||
|
||||
skill_content = _json.dumps(skill, ensure_ascii=False)
|
||||
pending = tool_approval_store.create(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
origin_run_id=f"teacher-skill-{_uuid.uuid4().hex}",
|
||||
tool_name="manage_skills",
|
||||
content=skill_content,
|
||||
workspace=workspace,
|
||||
external_untrusted_context_seen=True,
|
||||
capabilities=capabilities_for_action("manage_skills", skill_content),
|
||||
)
|
||||
approval = pending.public_payload(
|
||||
reason=(
|
||||
"The teacher generated this reusable skill. Review and approve "
|
||||
"the complete skill definition before it is saved."
|
||||
),
|
||||
)
|
||||
persisted_metrics = dict(captured_metrics)
|
||||
persisted_tool_events = list(persisted_metrics.get("tool_events") or [])
|
||||
persisted_round_texts = list(persisted_metrics.get("round_texts") or [])
|
||||
prior_rounds = [
|
||||
event.get("round")
|
||||
for event in persisted_tool_events
|
||||
if isinstance(event, dict) and isinstance(event.get("round"), int)
|
||||
]
|
||||
approval_round = max([len(persisted_round_texts), *prior_rounds, 0]) + 1
|
||||
approval_tool_event = {
|
||||
"round": approval_round,
|
||||
"model": teacher_model,
|
||||
"tool": "manage_skills",
|
||||
"command": str(skill.get("name") or "teacher-generated skill"),
|
||||
"output": "Waiting for an exact user approval.",
|
||||
"exit_code": None,
|
||||
"ask_user": approval,
|
||||
}
|
||||
persisted_tool_events.append(approval_tool_event)
|
||||
persisted_metrics["tool_events"] = persisted_tool_events
|
||||
persisted_metrics.setdefault("model", teacher_model)
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps({"delta": "Review the teacher-generated skill before saving it."})
|
||||
+ "\n\n"
|
||||
)
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps({
|
||||
"type": "tool_output",
|
||||
**approval_tool_event,
|
||||
"teacher": True,
|
||||
})
|
||||
+ "\n\n"
|
||||
)
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps({"type": "ask_user", "data": approval, "teacher": True})
|
||||
+ "\n\n"
|
||||
)
|
||||
# This must be the final metrics event: chat_routes saves only last_metrics
|
||||
# when the outer stream reaches [DONE]. Without it, the live approval card
|
||||
# disappears after a reload even though the server grant remains pending.
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps({"type": "metrics", "data": persisted_metrics, "teacher": True})
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Opaque, exact, one-use approvals for tainted model-requested actions.
|
||||
|
||||
The model may propose an action after untrusted context, but only the server
|
||||
stores and later executes the exact approved tool input. Browser-visible
|
||||
fields are display copies, never authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||
|
||||
|
||||
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
||||
DEFAULT_MAX_PENDING_APPROVALS = 2048
|
||||
|
||||
|
||||
def _normalized_owner(owner: Any) -> str:
|
||||
return str(owner or "").strip().casefold()
|
||||
|
||||
|
||||
def _normalized_workspace(workspace: Any) -> str:
|
||||
if not isinstance(workspace, str) or not workspace.strip():
|
||||
return ""
|
||||
return os.path.realpath(os.path.expanduser(workspace))
|
||||
|
||||
|
||||
def _canonical_digest(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def document_content_digest(content: Any) -> str:
|
||||
"""Return the stable server-side fingerprint used to seal a document."""
|
||||
return hashlib.sha256(str(content or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _binding_payload(
|
||||
*,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
origin_run_id: Any,
|
||||
tool_name: Any,
|
||||
content: Any,
|
||||
workspace: Any,
|
||||
document_id: Any,
|
||||
document_version: Any,
|
||||
document_digest: Any,
|
||||
external_untrusted_context_seen: bool,
|
||||
effects: tuple[str, ...],
|
||||
result_integrity: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"owner": _normalized_owner(owner),
|
||||
"session_id": str(session_id or ""),
|
||||
"origin_run_id": str(origin_run_id or ""),
|
||||
"tool_name": str(tool_name or ""),
|
||||
"content": str(content or ""),
|
||||
"workspace": _normalized_workspace(workspace),
|
||||
"document_id": str(document_id or ""),
|
||||
"document_version": (
|
||||
int(document_version) if document_version is not None else None
|
||||
),
|
||||
"document_digest": str(document_digest or "").strip().lower(),
|
||||
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
||||
"effects": list(effects),
|
||||
"result_integrity": str(result_integrity),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingToolApproval:
|
||||
approval_id: str
|
||||
owner: str
|
||||
session_id: str
|
||||
origin_run_id: str
|
||||
tool_name: str
|
||||
content: str
|
||||
workspace: str
|
||||
document_id: str
|
||||
document_version: int | None
|
||||
document_digest: str
|
||||
external_untrusted_context_seen: bool
|
||||
effects: tuple[str, ...]
|
||||
result_integrity: str
|
||||
digest: str
|
||||
created_at: float
|
||||
expires_at: float
|
||||
|
||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": "tool_approval",
|
||||
"approval_id": self.approval_id,
|
||||
"question": "Allow this exact action once?",
|
||||
"description": reason or (
|
||||
"Untrusted context influenced this run, so this action needs "
|
||||
"your explicit approval."
|
||||
),
|
||||
"options": [
|
||||
{
|
||||
"label": "Allow once",
|
||||
"value": "approve",
|
||||
"description": "Execute only the sealed action shown here.",
|
||||
},
|
||||
{
|
||||
"label": "Deny",
|
||||
"value": "deny",
|
||||
"description": "Do not execute it.",
|
||||
},
|
||||
],
|
||||
"action": {
|
||||
"tool": self.tool_name,
|
||||
# Show the complete sealed input so approval never hides
|
||||
# trailing lines. This is not read back as authority.
|
||||
"content": self.content,
|
||||
"digest": self.digest[:16],
|
||||
"effects": list(self.effects),
|
||||
"workspace": self.workspace or None,
|
||||
"document_id": self.document_id or None,
|
||||
"document_version": self.document_version,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExactToolApproval:
|
||||
"""A consumed grant that the dispatcher can claim exactly once."""
|
||||
|
||||
pending: PendingToolApproval
|
||||
_claimed: bool = field(default=False, init=False, repr=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||
|
||||
def _matches_unlocked(
|
||||
self,
|
||||
*,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
tool_name: Any,
|
||||
content: Any,
|
||||
workspace: Any,
|
||||
) -> bool:
|
||||
if self._claimed:
|
||||
return False
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
||||
result_integrity = capabilities.result_integrity.value
|
||||
if (
|
||||
effects != self.pending.effects
|
||||
or result_integrity != self.pending.result_integrity
|
||||
):
|
||||
return False
|
||||
expected = _binding_payload(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
origin_run_id=self.pending.origin_run_id,
|
||||
tool_name=tool_name,
|
||||
content=content,
|
||||
workspace=workspace,
|
||||
document_id=self.pending.document_id,
|
||||
document_version=self.pending.document_version,
|
||||
document_digest=self.pending.document_digest,
|
||||
external_untrusted_context_seen=(
|
||||
self.pending.external_untrusted_context_seen
|
||||
),
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
)
|
||||
return _canonical_digest(expected) == self.pending.digest
|
||||
|
||||
def matches(
|
||||
self,
|
||||
*,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
tool_name: Any,
|
||||
content: Any,
|
||||
workspace: Any,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
return self._matches_unlocked(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
tool_name=tool_name,
|
||||
content=content,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
tool_name: Any,
|
||||
content: Any,
|
||||
workspace: Any,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
if not self._matches_unlocked(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
tool_name=tool_name,
|
||||
content=content,
|
||||
workspace=workspace,
|
||||
):
|
||||
return False
|
||||
self._claimed = True
|
||||
return True
|
||||
|
||||
|
||||
class ToolApprovalStore:
|
||||
"""Thread-safe pending approval registry with destructive consumption."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ttl_seconds: int = DEFAULT_APPROVAL_TTL_SECONDS,
|
||||
max_pending: int = DEFAULT_MAX_PENDING_APPROVALS,
|
||||
):
|
||||
self._ttl_seconds = max(1, int(ttl_seconds))
|
||||
self._max_pending = max(1, int(max_pending))
|
||||
self._pending: dict[str, PendingToolApproval] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _purge_expired_locked(self, now: float) -> None:
|
||||
expired = [
|
||||
approval_id
|
||||
for approval_id, pending in self._pending.items()
|
||||
if pending.expires_at <= now
|
||||
]
|
||||
for approval_id in expired:
|
||||
self._pending.pop(approval_id, None)
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
origin_run_id: Any,
|
||||
tool_name: Any,
|
||||
content: Any,
|
||||
workspace: Any,
|
||||
document_id: Any = None,
|
||||
document_version: Any = None,
|
||||
document_digest: Any = None,
|
||||
external_untrusted_context_seen: bool,
|
||||
capabilities: ToolCapabilities,
|
||||
) -> PendingToolApproval:
|
||||
now = time.time()
|
||||
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
||||
result_integrity = capabilities.result_integrity.value
|
||||
payload = _binding_payload(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
origin_run_id=origin_run_id,
|
||||
tool_name=tool_name,
|
||||
content=content,
|
||||
workspace=workspace,
|
||||
document_id=document_id,
|
||||
document_version=document_version,
|
||||
document_digest=document_digest,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
)
|
||||
pending = PendingToolApproval(
|
||||
approval_id=secrets.token_urlsafe(32),
|
||||
owner=payload["owner"],
|
||||
session_id=payload["session_id"],
|
||||
origin_run_id=payload["origin_run_id"],
|
||||
tool_name=payload["tool_name"],
|
||||
content=payload["content"],
|
||||
workspace=payload["workspace"],
|
||||
document_id=payload["document_id"],
|
||||
document_version=payload["document_version"],
|
||||
document_digest=payload["document_digest"],
|
||||
external_untrusted_context_seen=payload[
|
||||
"external_untrusted_context_seen"
|
||||
],
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
digest=_canonical_digest(payload),
|
||||
created_at=now,
|
||||
expires_at=now + self._ttl_seconds,
|
||||
)
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
# The chat UI exposes one pending card per session, so supersede an
|
||||
# older action there. Headless/manual-test callers use an empty
|
||||
# session id; keep independent origin runs separate so two skill
|
||||
# tests owned by the same user cannot invalidate each other.
|
||||
superseded = [
|
||||
approval_id
|
||||
for approval_id, existing in self._pending.items()
|
||||
if (
|
||||
existing.owner == pending.owner
|
||||
and existing.session_id == pending.session_id
|
||||
and (
|
||||
bool(pending.session_id)
|
||||
or existing.origin_run_id == pending.origin_run_id
|
||||
)
|
||||
)
|
||||
]
|
||||
for approval_id in superseded:
|
||||
self._pending.pop(approval_id, None)
|
||||
while len(self._pending) >= self._max_pending:
|
||||
oldest_id = min(
|
||||
self._pending,
|
||||
key=lambda approval_id: self._pending[approval_id].created_at,
|
||||
)
|
||||
self._pending.pop(oldest_id, None)
|
||||
self._pending[pending.approval_id] = pending
|
||||
return pending
|
||||
|
||||
def consume(
|
||||
self,
|
||||
approval_id: Any,
|
||||
*,
|
||||
decision: Any,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
) -> ExactToolApproval | None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
approval_key = str(approval_id or "")
|
||||
pending = self._pending.get(approval_key)
|
||||
if pending is None:
|
||||
return None
|
||||
if (
|
||||
pending.owner != _normalized_owner(owner)
|
||||
or pending.session_id != str(session_id or "")
|
||||
):
|
||||
# Authentication is checked before destructive consumption so
|
||||
# a leaked/guessed opaque id cannot be used to invalidate
|
||||
# another owner's pending action.
|
||||
return None
|
||||
self._pending.pop(approval_key, None)
|
||||
if str(decision or "").strip().lower() != "approve":
|
||||
return None
|
||||
return ExactToolApproval(pending)
|
||||
|
||||
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
return self._pending.get(str(approval_id or ""))
|
||||
|
||||
def retire_for_session(self, *, owner: Any, session_id: Any) -> bool:
|
||||
"""Discard pending actions superseded by an ordinary user turn.
|
||||
|
||||
Returns whether any retired action carried external provenance, so the
|
||||
caller can preserve that security state without treating the new user
|
||||
message as an approval continuation.
|
||||
"""
|
||||
now = time.time()
|
||||
normalized_owner = _normalized_owner(owner)
|
||||
normalized_session = str(session_id or "")
|
||||
if not normalized_session:
|
||||
return False
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
retired_ids = [
|
||||
approval_id
|
||||
for approval_id, pending in self._pending.items()
|
||||
if (
|
||||
pending.owner == normalized_owner
|
||||
and pending.session_id == normalized_session
|
||||
)
|
||||
]
|
||||
carried_taint = any(
|
||||
self._pending[approval_id].external_untrusted_context_seen
|
||||
for approval_id in retired_ids
|
||||
)
|
||||
for approval_id in retired_ids:
|
||||
self._pending.pop(approval_id, None)
|
||||
return carried_taint
|
||||
|
||||
|
||||
tool_approval_store = ToolApprovalStore()
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Deterministic capability metadata for agent tools.
|
||||
|
||||
Model output requests an action; it never supplies the authority for that
|
||||
action. This module classifies the effects of each built-in tool and applies
|
||||
run-local integrity gates before dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
|
||||
|
||||
class ToolEffect(str, Enum):
|
||||
READ_PUBLIC = "read_public"
|
||||
READ_WORKSPACE = "read_workspace"
|
||||
READ_PRIVATE = "read_private"
|
||||
WRITE_WORKSPACE = "write_workspace"
|
||||
WRITE_PRIVATE = "write_private"
|
||||
EXECUTE_CODE = "execute_code"
|
||||
BROKERED_NETWORK_READ = "brokered_network_read"
|
||||
NETWORK_EGRESS = "network_egress"
|
||||
EXTERNAL_SIDE_EFFECT = "external_side_effect"
|
||||
UI_SIDE_EFFECT = "ui_side_effect"
|
||||
ADMIN_CHANGE = "admin_change"
|
||||
DESTRUCTIVE = "destructive"
|
||||
USER_INTERACTION = "user_interaction"
|
||||
|
||||
|
||||
class ResultIntegrity(str, Enum):
|
||||
SYSTEM = "system"
|
||||
WORKSPACE_UNTRUSTED = "workspace_untrusted"
|
||||
EXTERNAL_UNTRUSTED = "external_untrusted"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCapabilities:
|
||||
effects: frozenset[ToolEffect]
|
||||
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM
|
||||
known: bool = True
|
||||
|
||||
|
||||
def _capabilities(
|
||||
*effects: ToolEffect,
|
||||
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
|
||||
) -> ToolCapabilities:
|
||||
return ToolCapabilities(frozenset(effects), result_integrity)
|
||||
|
||||
|
||||
_REGISTRY: dict[str, ToolCapabilities] = {}
|
||||
|
||||
|
||||
def _register(
|
||||
names: Iterable[str],
|
||||
*effects: ToolEffect,
|
||||
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
|
||||
) -> None:
|
||||
capabilities = _capabilities(*effects, result_integrity=result_integrity)
|
||||
for name in names:
|
||||
if name in _REGISTRY:
|
||||
raise RuntimeError(f"Duplicate tool capability classification: {name}")
|
||||
_REGISTRY[name] = capabilities
|
||||
|
||||
|
||||
_register(
|
||||
{"ask_user", "update_plan"},
|
||||
ToolEffect.USER_INTERACTION,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"list_cached_models",
|
||||
"list_cookbook_servers",
|
||||
"list_downloads",
|
||||
"list_models",
|
||||
"list_serve_presets",
|
||||
"list_served_models",
|
||||
},
|
||||
ToolEffect.READ_PRIVATE,
|
||||
# These readers return provider-controlled model identifiers or durable
|
||||
# user/admin-authored Cookbook and process state. Local brokering does not
|
||||
# make the returned text server-authored.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"search_hf_models"},
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"get_workspace", "glob", "grep", "ls", "read_file"},
|
||||
ToolEffect.READ_WORKSPACE,
|
||||
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"web_search"},
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"web_fetch"},
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"list_email_accounts",
|
||||
"list_emails",
|
||||
"read_email",
|
||||
"resolve_contact",
|
||||
"scan_email_unsubscribes",
|
||||
"search_chats",
|
||||
"search_emails",
|
||||
"list_sessions",
|
||||
"tail_serve_output",
|
||||
"vault_get",
|
||||
"vault_search",
|
||||
},
|
||||
ToolEffect.READ_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"bash", "manage_bg_jobs", "python"},
|
||||
ToolEffect.EXECUTE_CODE,
|
||||
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"apply_patch", "edit_file", "write_file"},
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
# Successful writes include unified diffs that can echo arbitrary existing
|
||||
# workspace content back into the next model round.
|
||||
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"create_document",
|
||||
"manage_calendar",
|
||||
"manage_contact",
|
||||
"manage_documents",
|
||||
"manage_memory",
|
||||
"manage_notes",
|
||||
"manage_research",
|
||||
"manage_session",
|
||||
"manage_skills",
|
||||
"manage_tasks",
|
||||
"suggest_document",
|
||||
"todowrite",
|
||||
},
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"ai_draft_email_reply",
|
||||
"create_session",
|
||||
"draft_email",
|
||||
"draft_email_reply",
|
||||
},
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
# These tools resolve user-configured endpoints/accounts or read stored
|
||||
# email content before returning model-visible status text.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"edit_document", "update_document"},
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
# These tools can echo stored document content that was not present in
|
||||
# their arguments. edit_document returns the complete edited document;
|
||||
# update_document also preserves stored email headers/thread history.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"pipeline"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"send_to_session"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"chat_with_model", "ask_teacher"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"download_attachment"},
|
||||
ToolEffect.READ_PRIVATE,
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"edit_image", "generate_image", "trigger_research"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"archive_email",
|
||||
"bulk_email",
|
||||
"mark_email_read",
|
||||
"reply_to_email",
|
||||
"send_email",
|
||||
"unsubscribe_email",
|
||||
},
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
# Email action results can include stored headers/account labels or remote
|
||||
# SMTP/IMAP responses, even when the action itself succeeded.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"delete_email"},
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
ToolEffect.DESTRUCTIVE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"ui_control"},
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
# Model switches and custom-theme validation read mutable user settings.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"adopt_served_model",
|
||||
"cancel_download",
|
||||
"download_model",
|
||||
"serve_model",
|
||||
"serve_preset",
|
||||
"stop_served_model",
|
||||
"vault_unlock",
|
||||
},
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
# Cookbook/process operations can return stored presets, provider data,
|
||||
# remote shell output, and command errors.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"api_call",
|
||||
"app_api",
|
||||
"manage_endpoints",
|
||||
"manage_mcp",
|
||||
"manage_settings",
|
||||
"manage_tokens",
|
||||
"manage_webhooks",
|
||||
},
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
# api_call/app_api return remote or stored application data, and the
|
||||
# admin managers can echo user-controlled configuration. Conservatively
|
||||
# retain the action effect while treating every successful result as data.
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
|
||||
|
||||
TOOL_CAPABILITIES: Mapping[str, ToolCapabilities] = MappingProxyType(dict(_REGISTRY))
|
||||
KNOWN_CAPABILITY_TOOLS = frozenset(TOOL_CAPABILITIES)
|
||||
|
||||
_UNKNOWN_CAPABILITIES = _capabilities(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
ToolEffect.EXECUTE_CODE,
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
ToolEffect.DESTRUCTIVE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_UNKNOWN_CAPABILITIES = ToolCapabilities(
|
||||
_UNKNOWN_CAPABILITIES.effects,
|
||||
_UNKNOWN_CAPABILITIES.result_integrity,
|
||||
known=False,
|
||||
)
|
||||
_BROWSER_MCP_READ_CAPABILITIES = _capabilities(
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_BROWSER_MCP_READ_TOOLS = frozenset(
|
||||
{
|
||||
"mcp__builtin_browser__browser_console_messages",
|
||||
"mcp__builtin_browser__browser_network_requests",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
"mcp__builtin_browser__browser_take_screenshot",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
"""Return deterministic capabilities; malformed and unknown tools fail high."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return _UNKNOWN_CAPABILITIES
|
||||
capabilities = TOOL_CAPABILITIES.get(tool_name)
|
||||
if capabilities is not None:
|
||||
return capabilities
|
||||
if tool_name.startswith("mcp__email__"):
|
||||
bare_name = tool_name[len("mcp__email__"):]
|
||||
capabilities = TOOL_CAPABILITIES.get(bare_name)
|
||||
if bare_name in BUILTIN_EMAIL_TOOLS and capabilities is not None:
|
||||
return capabilities
|
||||
if tool_name in _BROWSER_MCP_READ_TOOLS:
|
||||
return _BROWSER_MCP_READ_CAPABILITIES
|
||||
return _UNKNOWN_CAPABILITIES
|
||||
|
||||
|
||||
_PRIVATE_ACTION_READS: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||
{
|
||||
"manage_calendar": frozenset({"list_calendars", "list_events"}),
|
||||
"manage_contact": frozenset({"list"}),
|
||||
"manage_documents": frozenset({"list", "read", "view", "open", "get"}),
|
||||
"manage_memory": frozenset({"list", "search"}),
|
||||
"manage_notes": frozenset({"list", "search", "find", "view"}),
|
||||
"manage_research": frozenset({"list", "read", "open", "view", "get"}),
|
||||
"manage_session": frozenset({"list", "switch", "open", "select", "view"}),
|
||||
"manage_skills": frozenset({"list", "index", "view", "view_ref", "search"}),
|
||||
"manage_tasks": frozenset({"list"}),
|
||||
}
|
||||
)
|
||||
|
||||
_PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||
{
|
||||
"manage_calendar": frozenset(
|
||||
{"create_event", "update_event", "delete_event"}
|
||||
),
|
||||
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
|
||||
"manage_documents": frozenset({"delete", "tidy"}),
|
||||
"manage_memory": frozenset({"add", "edit", "delete"}),
|
||||
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
|
||||
"manage_research": frozenset({"delete"}),
|
||||
"manage_session": frozenset(
|
||||
{
|
||||
"rename",
|
||||
"archive",
|
||||
"unarchive",
|
||||
"delete",
|
||||
"important",
|
||||
"unimportant",
|
||||
"truncate",
|
||||
"fork",
|
||||
}
|
||||
),
|
||||
"manage_skills": frozenset({"add", "edit", "patch", "publish", "delete"}),
|
||||
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
|
||||
}
|
||||
)
|
||||
|
||||
_ACTION_DESTRUCTIVE: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||
{
|
||||
"manage_calendar": frozenset({"delete_event"}),
|
||||
"manage_contact": frozenset({"delete"}),
|
||||
"manage_documents": frozenset({"delete", "tidy"}),
|
||||
"manage_endpoints": frozenset({"delete"}),
|
||||
"manage_bg_jobs": frozenset({"kill", "stop", "cancel", "terminate"}),
|
||||
"manage_memory": frozenset({"delete"}),
|
||||
"manage_mcp": frozenset({"delete"}),
|
||||
"manage_notes": frozenset({"delete"}),
|
||||
"manage_research": frozenset({"delete"}),
|
||||
"manage_session": frozenset({"delete", "truncate"}),
|
||||
"manage_settings": frozenset({"delete", "reset"}),
|
||||
"manage_skills": frozenset({"delete"}),
|
||||
"manage_tasks": frozenset({"delete"}),
|
||||
"manage_tokens": frozenset({"delete"}),
|
||||
"manage_webhooks": frozenset({"delete"}),
|
||||
}
|
||||
)
|
||||
|
||||
_ACTION_DEFAULTS: Mapping[str, str] = MappingProxyType(
|
||||
{
|
||||
"manage_calendar": "list_events",
|
||||
"manage_documents": "list",
|
||||
"manage_research": "list",
|
||||
"manage_tasks": "list",
|
||||
}
|
||||
)
|
||||
|
||||
_ACTION_ALIASES: Mapping[str, Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"manage_calendar": MappingProxyType(
|
||||
{
|
||||
"create": "create_event",
|
||||
"update": "update_event",
|
||||
"delete": "delete_event",
|
||||
"list": "list_events",
|
||||
}
|
||||
),
|
||||
"manage_notes": MappingProxyType(
|
||||
{
|
||||
"create": "add",
|
||||
"new": "add",
|
||||
"save": "add",
|
||||
"remind": "add",
|
||||
"reminder": "add",
|
||||
"remove": "delete",
|
||||
"remove_item": "toggle_item",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_LINE_ACTION_TOOLS = frozenset({"manage_memory", "manage_session"})
|
||||
|
||||
|
||||
def _action_from_content(tool_name: str, content: Any) -> str | None:
|
||||
"""Extract the action discriminator using the same accepted input shapes."""
|
||||
if isinstance(content, Mapping):
|
||||
payload: Any = dict(content)
|
||||
elif isinstance(content, str):
|
||||
raw = content.strip()
|
||||
if tool_name in _LINE_ACTION_TOOLS and raw and not raw.startswith("{"):
|
||||
return raw.splitlines()[0].strip().replace("-", "_").casefold() or None
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
else:
|
||||
payload = {}
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if (
|
||||
len(payload) == 1
|
||||
and isinstance(payload.get("body"), dict)
|
||||
and "action" in payload["body"]
|
||||
):
|
||||
payload = payload["body"]
|
||||
|
||||
action = payload.get("action")
|
||||
if (
|
||||
not action
|
||||
and tool_name == "manage_calendar"
|
||||
and isinstance(payload.get("events"), list)
|
||||
):
|
||||
action = "create_event"
|
||||
if not action and tool_name == "manage_tasks" and any(
|
||||
payload.get(key) is not None
|
||||
for key in ("task", "description", "schedule", "time", "day_of_week")
|
||||
):
|
||||
action = "create"
|
||||
if not isinstance(action, str) or not action.strip():
|
||||
action = _ACTION_DEFAULTS.get(tool_name)
|
||||
if not action:
|
||||
return None
|
||||
normalized = action.strip().replace("-", "_").casefold()
|
||||
return _ACTION_ALIASES.get(tool_name, {}).get(normalized, normalized)
|
||||
|
||||
|
||||
def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
|
||||
"""Classify a sealed multiplexed action; ambiguous actions fail high."""
|
||||
base = capabilities_for_tool(tool_name)
|
||||
if not isinstance(tool_name, str):
|
||||
return base
|
||||
|
||||
action = _action_from_content(tool_name, content)
|
||||
destructive = action in _ACTION_DESTRUCTIVE.get(tool_name, ())
|
||||
if tool_name not in _PRIVATE_ACTION_READS:
|
||||
if not destructive:
|
||||
return base
|
||||
return ToolCapabilities(
|
||||
frozenset(set(base.effects) | {ToolEffect.DESTRUCTIVE}),
|
||||
base.result_integrity,
|
||||
known=base.known,
|
||||
)
|
||||
if action in _PRIVATE_ACTION_READS[tool_name]:
|
||||
return _capabilities(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
if action in _PRIVATE_ACTION_WRITES[tool_name]:
|
||||
effects = set(base.effects)
|
||||
if destructive:
|
||||
effects.add(ToolEffect.DESTRUCTIVE)
|
||||
return ToolCapabilities(
|
||||
frozenset(effects),
|
||||
ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
known=base.known,
|
||||
)
|
||||
|
||||
return _capabilities(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
|
||||
|
||||
def tool_result_is_successful(result: Any) -> bool:
|
||||
"""Return whether a result actually introduced successful tool output."""
|
||||
return bool(
|
||||
isinstance(result, dict)
|
||||
and not result.get("blocked")
|
||||
and not result.get("approval_required")
|
||||
and not result.get("error")
|
||||
and result.get("exit_code") in (None, 0)
|
||||
and result.get("success") is not False
|
||||
)
|
||||
|
||||
|
||||
def tool_result_should_arm_gate(
|
||||
tool_name: Any,
|
||||
result: Any,
|
||||
content: Any = None,
|
||||
) -> bool:
|
||||
"""Return whether a result introduced non-system content to the model.
|
||||
|
||||
A blocked/approval placeholder and a genuinely content-free failure do not
|
||||
change authority. Once a non-system tool returns text or structured data
|
||||
that will be folded into model context, however, failure status cannot make
|
||||
that payload trusted: MCP ``isError`` text, provider exception messages,
|
||||
and HTTP error bodies are all attacker-controlled input surfaces.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return False
|
||||
if result.get("blocked") or result.get("approval_required"):
|
||||
return False
|
||||
# A producer that knows a particular response body came from a remote or
|
||||
# stored source overrides a coarse static SYSTEM default.
|
||||
if result.get("untrusted_content") is True:
|
||||
return True
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
if capabilities.result_integrity is ResultIntegrity.SYSTEM:
|
||||
return False
|
||||
if tool_result_is_successful(result):
|
||||
return True
|
||||
# ``format_tool_result`` serializes every additional structured field, so
|
||||
# a fixed allowlist here would inevitably miss model-visible payloads such
|
||||
# as ``details``, ``events``, or provider-specific response keys. Exclude
|
||||
# only status/policy controls that carry no producer content; any other
|
||||
# non-empty field crosses the same integrity boundary even on failure.
|
||||
non_content_keys = frozenset(
|
||||
{
|
||||
"approval_required",
|
||||
"blocked",
|
||||
"exit_code",
|
||||
"policy",
|
||||
"success",
|
||||
"untrusted_content",
|
||||
}
|
||||
)
|
||||
return any(
|
||||
key not in non_content_keys and value not in (None, "", [], {}, ())
|
||||
for key, value in result.items()
|
||||
)
|
||||
|
||||
|
||||
POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
|
||||
{
|
||||
ToolEffect.READ_PRIVATE,
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
ToolEffect.EXECUTE_CODE,
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
ToolEffect.DESTRUCTIVE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolGateDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
_EXTERNAL_MESSAGE_SOURCES = frozenset(
|
||||
{
|
||||
"injected research context",
|
||||
"prefetched search context",
|
||||
"research context",
|
||||
"web search results",
|
||||
"youtube transcript",
|
||||
}
|
||||
)
|
||||
_EXTERNAL_MESSAGE_SOURCE_PREFIXES = ("web page:",)
|
||||
|
||||
|
||||
def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> bool:
|
||||
"""Detect explicitly labelled external context already present in a run."""
|
||||
for message in messages or ():
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
metadata = message.get("metadata")
|
||||
if not isinstance(metadata, dict) or metadata.get("trusted") is not False:
|
||||
continue
|
||||
gate_marker = metadata.get("tool_gate_untrusted")
|
||||
if gate_marker is True:
|
||||
return True
|
||||
if gate_marker is False:
|
||||
# Explicit current-format opt-outs are authoritative. The source
|
||||
# label heuristics below exist only for older saved wrappers that
|
||||
# predate the marker.
|
||||
continue
|
||||
if metadata.get("provenance_origin") == "external":
|
||||
return True
|
||||
source = metadata.get("source")
|
||||
if not isinstance(source, str):
|
||||
continue
|
||||
normalized_source = source.strip().casefold()
|
||||
if normalized_source in _EXTERNAL_MESSAGE_SOURCES:
|
||||
return True
|
||||
if normalized_source.startswith(_EXTERNAL_MESSAGE_SOURCE_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRunSecurityContext:
|
||||
"""Server-owned integrity state for one agent run."""
|
||||
|
||||
external_untrusted_context_seen: bool = False
|
||||
external_sources: list[str] = field(default_factory=list)
|
||||
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Promote any server-labelled untrusted prompt context into the gate."""
|
||||
if messages_contain_external_untrusted_context(messages):
|
||||
self.external_untrusted_context_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
if not self.external_untrusted_context_seen:
|
||||
return ToolGateDecision(True)
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
blocked_effects = capabilities.effects & POST_EXTERNAL_BLOCKED_EFFECTS
|
||||
if capabilities.known and not blocked_effects:
|
||||
return ToolGateDecision(True)
|
||||
effects = ", ".join(sorted(effect.value for effect in blocked_effects))
|
||||
if not capabilities.known:
|
||||
effects = "unknown/high-impact"
|
||||
return ToolGateDecision(
|
||||
False,
|
||||
(
|
||||
"External untrusted context has already influenced this run. "
|
||||
f"Tool '{tool_name}' requires a separate user-authorized action "
|
||||
f"because it can cause {effects}."
|
||||
),
|
||||
)
|
||||
|
||||
def observe_tool_result(
|
||||
self,
|
||||
tool_name: Any,
|
||||
result: Any,
|
||||
content: Any = None,
|
||||
) -> None:
|
||||
if not tool_result_should_arm_gate(tool_name, result, content):
|
||||
return
|
||||
self.external_untrusted_context_seen = True
|
||||
if isinstance(tool_name, str) and tool_name not in self.external_sources:
|
||||
self.external_sources.append(tool_name)
|
||||
|
||||
|
||||
def blocked_tool_result(tool_name: Any, reason: str) -> tuple[str, dict]:
|
||||
return (
|
||||
f"{tool_name}: BLOCKED",
|
||||
{
|
||||
"error": reason,
|
||||
"exit_code": 1,
|
||||
"blocked": True,
|
||||
"policy": "external_untrusted_context",
|
||||
},
|
||||
)
|
||||
+161
-2
@@ -27,10 +27,24 @@ from src.tool_security import (
|
||||
is_public_blocked_tool,
|
||||
owner_is_admin_or_single_user,
|
||||
)
|
||||
from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
|
||||
from src.tool_approvals import ExactToolApproval
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
||||
from src.tool_utils import _truncate, get_mcp_manager
|
||||
|
||||
|
||||
class _MissingToolSecurityContext:
|
||||
pass
|
||||
|
||||
|
||||
class _NoToolSecurityContext:
|
||||
"""Explicit sentinel for non-agent callers that have no run provenance."""
|
||||
|
||||
|
||||
_MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
|
||||
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
||||
|
||||
# Persistent working directory for agent subprocesses.
|
||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||
# (/app/data) and the local data directory for manual installs.
|
||||
@@ -554,10 +568,19 @@ async def _document_tool_dispatch(
|
||||
content: str,
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
document_id: Optional[str] = None,
|
||||
document_version: Optional[int] = None,
|
||||
document_digest: Optional[str] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Route a document tool through TOOL_HANDLERS with the right ctx shape."""
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
ctx = {"session_id": session_id, "owner": owner}
|
||||
ctx = {
|
||||
"session_id": session_id,
|
||||
"owner": owner,
|
||||
"doc_id": document_id,
|
||||
"expected_document_version": document_version,
|
||||
"expected_document_digest": document_digest,
|
||||
}
|
||||
if tool in TOOL_HANDLERS:
|
||||
return await TOOL_HANDLERS[tool](content, ctx)
|
||||
return None
|
||||
@@ -575,6 +598,12 @@ async def execute_tool_block(
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
tool_policy: Optional[Any] = None,
|
||||
security_context: (
|
||||
ToolRunSecurityContext
|
||||
| _NoToolSecurityContext
|
||||
| _MissingToolSecurityContext
|
||||
) = _MISSING_TOOL_SECURITY_CONTEXT,
|
||||
exact_approval: Optional[ExactToolApproval] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""Execute a single tool block. Returns (description, result_dict).
|
||||
|
||||
@@ -582,6 +611,104 @@ async def execute_tool_block(
|
||||
cwd confine to it) for the duration of this call, then delegate. Reset on the
|
||||
way out so the binding never leaks to the next tool call.
|
||||
"""
|
||||
if security_context is _MISSING_TOOL_SECURITY_CONTEXT:
|
||||
raise TypeError(
|
||||
"execute_tool_block requires security_context; pass a "
|
||||
"ToolRunSecurityContext or NO_TOOL_SECURITY_CONTEXT explicitly"
|
||||
)
|
||||
if (
|
||||
not isinstance(security_context, ToolRunSecurityContext)
|
||||
and security_context is not NO_TOOL_SECURITY_CONTEXT
|
||||
):
|
||||
raise TypeError(
|
||||
"security_context must be a ToolRunSecurityContext or "
|
||||
"NO_TOOL_SECURITY_CONTEXT"
|
||||
)
|
||||
|
||||
approval_claimed = False
|
||||
if exact_approval is not None:
|
||||
if (
|
||||
not isinstance(security_context, ToolRunSecurityContext)
|
||||
or not security_context.external_untrusted_context_seen
|
||||
or not exact_approval.pending.external_untrusted_context_seen
|
||||
):
|
||||
return (
|
||||
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||
{
|
||||
"error": "Exact-action approval requires an armed run security context.",
|
||||
"exit_code": 1,
|
||||
"blocked": True,
|
||||
"policy": "exact_tool_approval",
|
||||
},
|
||||
)
|
||||
if (
|
||||
exact_approval.pending.tool_name
|
||||
in {"edit_document", "suggest_document", "update_document"}
|
||||
and (
|
||||
not exact_approval.pending.document_id
|
||||
or exact_approval.pending.document_version is None
|
||||
or not exact_approval.pending.document_digest
|
||||
)
|
||||
):
|
||||
return (
|
||||
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||
{
|
||||
"error": (
|
||||
"The approved document action has no sealed target and "
|
||||
"cannot be executed."
|
||||
),
|
||||
"exit_code": 1,
|
||||
"blocked": True,
|
||||
"policy": "exact_tool_approval",
|
||||
},
|
||||
)
|
||||
sealed_workspace = exact_approval.pending.workspace
|
||||
if sealed_workspace and vet_workspace(sealed_workspace) != sealed_workspace:
|
||||
return (
|
||||
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||
{
|
||||
"error": (
|
||||
"The approved workspace is no longer a valid safe "
|
||||
"directory. Review the action again."
|
||||
),
|
||||
"exit_code": 1,
|
||||
"blocked": True,
|
||||
"policy": "exact_tool_approval",
|
||||
},
|
||||
)
|
||||
approval_claimed = exact_approval.claim(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
tool_name=getattr(block, "tool_type", None),
|
||||
content=getattr(block, "content", None),
|
||||
workspace=workspace,
|
||||
)
|
||||
if not approval_claimed:
|
||||
return (
|
||||
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||
{
|
||||
"error": "The exact-action approval did not match this tool request.",
|
||||
"exit_code": 1,
|
||||
"blocked": True,
|
||||
"policy": "exact_tool_approval",
|
||||
},
|
||||
)
|
||||
|
||||
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
if not decision.allowed:
|
||||
logger.warning(
|
||||
"External-context policy blocked tool=%r",
|
||||
getattr(block, "tool_type", None),
|
||||
)
|
||||
return blocked_tool_result(
|
||||
getattr(block, "tool_type", None),
|
||||
decision.reason or "Tool blocked by external-context policy.",
|
||||
)
|
||||
|
||||
token = _active_workspace.set(workspace or None)
|
||||
try:
|
||||
output = await _execute_tool_block_impl(
|
||||
@@ -591,7 +718,28 @@ async def execute_tool_block(
|
||||
owner=owner,
|
||||
progress_cb=progress_cb,
|
||||
tool_policy=tool_policy,
|
||||
approved_document_id=(
|
||||
exact_approval.pending.document_id
|
||||
if approval_claimed
|
||||
else None
|
||||
),
|
||||
approved_document_version=(
|
||||
exact_approval.pending.document_version
|
||||
if approval_claimed
|
||||
else None
|
||||
),
|
||||
approved_document_digest=(
|
||||
exact_approval.pending.document_digest
|
||||
if approval_claimed
|
||||
else None
|
||||
),
|
||||
)
|
||||
if isinstance(security_context, ToolRunSecurityContext):
|
||||
security_context.observe_tool_result(
|
||||
getattr(block, "tool_type", None),
|
||||
output[1],
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
return output
|
||||
finally:
|
||||
_active_workspace.reset(token)
|
||||
@@ -604,6 +752,9 @@ async def _execute_tool_block_impl(
|
||||
owner: Optional[str] = None,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
tool_policy: Optional[Any] = None,
|
||||
approved_document_id: Optional[str] = None,
|
||||
approved_document_version: Optional[int] = None,
|
||||
approved_document_digest: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""Execute a single tool block. Returns (description, result_dict).
|
||||
|
||||
@@ -765,7 +916,15 @@ async def _execute_tool_block_impl(
|
||||
elif tool in ("create_document", "update_document", "edit_document",
|
||||
"suggest_document", "manage_documents"):
|
||||
desc = f"{tool}: {content.split(chr(10))[0][:80]}"
|
||||
result = await _document_tool_dispatch(tool, content, session_id, owner) \
|
||||
result = await _document_tool_dispatch(
|
||||
tool,
|
||||
content,
|
||||
session_id,
|
||||
owner,
|
||||
document_id=approved_document_id,
|
||||
document_version=approved_document_version,
|
||||
document_digest=approved_document_digest,
|
||||
) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
if tool in ("edit_document", "suggest_document") and "title" in (result or {}):
|
||||
desc = f"{tool}: {result.get('title', '')}"
|
||||
|
||||
+10
-2
@@ -954,7 +954,11 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
||||
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
|
||||
json={"command": cmd}, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
||||
return {
|
||||
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
@@ -1083,7 +1087,11 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
|
||||
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
|
||||
json={"command": cmd}, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
||||
return {
|
||||
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
data = resp.json() if resp.content else {}
|
||||
output_text = (data.get("stdout") or "").strip()
|
||||
stderr_text = (data.get("stderr") or "").strip()
|
||||
|
||||
@@ -123,7 +123,11 @@ async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict
|
||||
resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
|
||||
json=payload, headers=_internal_headers(owner))
|
||||
if resp.status_code >= 400:
|
||||
return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
||||
return {
|
||||
"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
data = resp.json()
|
||||
sid = data.get("session_id", "?")
|
||||
return {
|
||||
|
||||
@@ -725,6 +725,7 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"status_code": resp.status_code,
|
||||
"body": preview,
|
||||
"exit_code": 1,
|
||||
"untrusted_content": True,
|
||||
}
|
||||
return {
|
||||
"output": f"{method} {path} -> {resp.status_code}\n{preview}",
|
||||
|
||||
+4
-4
@@ -10,9 +10,9 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
||||
import ragModule from './js/rag.js';
|
||||
import presetsModule from './js/presets.js';
|
||||
import searchModule from './js/search.js';
|
||||
import chatModule from './js/chat.js?v=20260801fix1';
|
||||
import chatModule from './js/chat.js?v=20260815toolapproval4';
|
||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
||||
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
settleSessionHydration
|
||||
} from './js/startupShell.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
|
||||
import sessionModule from './js/sessions.js';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
@@ -33,7 +33,7 @@ import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
import adminModule from './js/admin.js?v=20260716openrouter3';
|
||||
import settingsModule from './js/settings.js?v=20260722emailfastindex1';
|
||||
import settingsModule from './js/settings.js?v=20260815approvalsave1';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js?v=20260723compareicon2';
|
||||
// Desktop window tiling — drag a modal near an edge/corner to snap.
|
||||
|
||||
+59
-16
@@ -258,8 +258,8 @@
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260815toolapproval4">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval4">
|
||||
<link rel="modulepreload" href="/static/js/ui.js">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||
@@ -1421,6 +1421,55 @@
|
||||
</div>
|
||||
<div class="settings-layout">
|
||||
<div class="settings-sidebar">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-sidebar-toggle"
|
||||
id="settings-sidebar-toggle"
|
||||
aria-label="Collapse settings navigation"
|
||||
title="Collapse settings navigation"
|
||||
>
|
||||
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="settings-sidebar-resize-handle"
|
||||
id="settings-sidebar-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize settings navigation"
|
||||
aria-valuemin="150"
|
||||
aria-valuemax="340"
|
||||
aria-valuenow="220"
|
||||
tabindex="0"
|
||||
></div>
|
||||
|
||||
<div class="settings-sidebar-content">
|
||||
<div class="settings-nav-search-wrap">
|
||||
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<path d="M20 20l-4-4"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="settings-nav-search"
|
||||
class="settings-nav-search"
|
||||
placeholder="Find settings…"
|
||||
autocomplete="off"
|
||||
aria-label="Find settings"
|
||||
aria-controls="settings-nav-search-results"
|
||||
/>
|
||||
<div
|
||||
id="settings-nav-search-results"
|
||||
class="settings-nav-search-results hidden"
|
||||
role="listbox"
|
||||
aria-label="Settings search results"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
||||
<button class="settings-nav-item active" data-settings-tab="services">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
@@ -1481,9 +1530,10 @@
|
||||
<span>Users</span>
|
||||
</button>
|
||||
<button class="settings-nav-item admin-only" data-settings-tab="system">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"/></svg>
|
||||
<span>System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
|
||||
@@ -1504,13 +1554,6 @@
|
||||
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||
</div>
|
||||
<div class="settings-row" style="align-items:flex-start;" hidden>
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2539,20 +2582,20 @@
|
||||
<script type="module" src="/static/js/search.js"></script>
|
||||
<script type="module" src="/static/js/spinner.js"></script>
|
||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/theme.js"></script>
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/assistant.js"></script>
|
||||
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20260815toolapproval4"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||
|
||||
+637
-193
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
/** Select and update the response holder for a route-provenance event. */
|
||||
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
|
||||
const target = event && event.round && roundHolder ? roundHolder : holder;
|
||||
if (!target) return null;
|
||||
|
||||
target._requestedModel = (
|
||||
event.requested_model
|
||||
|| event.selected_model
|
||||
|| target._requestedModel
|
||||
|| defaultModel
|
||||
);
|
||||
target._actualModel = (
|
||||
event.model
|
||||
|| event.answered_by
|
||||
|| target._actualModel
|
||||
|| target._requestedModel
|
||||
);
|
||||
const hasEndpointRoute = Boolean(
|
||||
event.requested_endpoint_id
|
||||
|| event.selected_endpoint_id
|
||||
|| event.endpoint_id
|
||||
|| event.answered_by_endpoint_id
|
||||
|| event.requested_endpoint_label
|
||||
|| event.selected_endpoint_label
|
||||
|| event.endpoint_label
|
||||
|| event.answered_by_endpoint_label
|
||||
|| target._requestedEndpointLabel
|
||||
);
|
||||
if (hasEndpointRoute) {
|
||||
target._requestedEndpointId = (
|
||||
event.requested_endpoint_id
|
||||
|| event.selected_endpoint_id
|
||||
|| target._requestedEndpointId
|
||||
|| null
|
||||
);
|
||||
target._requestedEndpointLabel = (
|
||||
event.requested_endpoint_label
|
||||
|| event.selected_endpoint_label
|
||||
|| target._requestedEndpointLabel
|
||||
|| 'Selected route'
|
||||
);
|
||||
target._actualEndpointId = (
|
||||
event.endpoint_id
|
||||
|| event.answered_by_endpoint_id
|
||||
|| target._actualEndpointId
|
||||
|| target._requestedEndpointId
|
||||
|| null
|
||||
);
|
||||
target._actualEndpointLabel = (
|
||||
event.endpoint_label
|
||||
|| event.answered_by_endpoint_label
|
||||
|| target._actualEndpointLabel
|
||||
|| target._requestedEndpointLabel
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Copy the active route into the bubble created for the next Agent round. */
|
||||
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
|
||||
if (!target) return null;
|
||||
const source = roundHolder || holder;
|
||||
target._requestedModel = source?._requestedModel || defaultModel;
|
||||
target._actualModel = source?._actualModel || target._requestedModel;
|
||||
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
|
||||
target._requestedEndpointId = source?._requestedEndpointId || null;
|
||||
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
|
||||
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
|
||||
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Apply final/metrics provenance to the active round, not the first bubble. */
|
||||
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
|
||||
const target = roundHolder || holder;
|
||||
if (!target || !metrics) return target || null;
|
||||
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
|
||||
const roundModel = roundHolder && roundModels.length
|
||||
? roundModels[roundModels.length - 1]
|
||||
: null;
|
||||
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
|
||||
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
|
||||
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
|
||||
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
|
||||
if (
|
||||
metrics.requested_endpoint_label
|
||||
|| metrics.endpoint_label
|
||||
|| roundEndpointLabels.length
|
||||
|| target._requestedEndpointLabel
|
||||
) {
|
||||
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
|
||||
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
|
||||
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
|
||||
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
|
||||
target._actualEndpointId = hasRoundEndpointId
|
||||
? roundEndpointIds[roundEndpointIds.length - 1]
|
||||
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
|
||||
target._actualEndpointLabel = hasRoundEndpointLabel
|
||||
? roundEndpointLabels[roundEndpointLabels.length - 1]
|
||||
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
+303
-54
@@ -615,10 +615,36 @@ export function sameModelName(left, right) {
|
||||
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
|
||||
}
|
||||
|
||||
export function modelRouteLabel(requestedModel, actualModel) {
|
||||
function shortEndpointLabel(label) {
|
||||
const value = modelValue(label);
|
||||
if (!value) return '';
|
||||
return value.length > 18 ? value.slice(0, 17) + '…' : value;
|
||||
}
|
||||
|
||||
export function modelRouteLabel(
|
||||
requestedModel,
|
||||
actualModel,
|
||||
requestedEndpointLabel = '',
|
||||
actualEndpointLabel = '',
|
||||
requestedEndpointId = '',
|
||||
actualEndpointId = '',
|
||||
) {
|
||||
const requested = modelValue(requestedModel);
|
||||
const actual = modelValue(actualModel) || requested;
|
||||
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
|
||||
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
|
||||
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
|
||||
const routeChanged = Boolean(
|
||||
actualRoute
|
||||
&& requestedRoute
|
||||
&& actualRoute !== requestedRoute
|
||||
);
|
||||
if (!requested || sameModelName(requested, actual)) {
|
||||
const model = shortModel(actual || requested);
|
||||
if (!routeChanged) return model;
|
||||
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
|
||||
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
|
||||
return model + ' (' + from + ' -> ' + to + ')';
|
||||
}
|
||||
return shortModel(requested) + ' -> ' + shortModel(actual);
|
||||
}
|
||||
|
||||
@@ -629,10 +655,24 @@ export function replyModelPair(modelName, metadata) {
|
||||
if (actualFromMeta || requestedFromMeta) {
|
||||
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
|
||||
const requested = requestedFromMeta || actual;
|
||||
return { requestedModel: requested, actualModel: actual };
|
||||
return {
|
||||
requestedModel: requested,
|
||||
actualModel: actual,
|
||||
requestedEndpointId: meta.requested_endpoint_id || null,
|
||||
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
|
||||
actualEndpointId: meta.endpoint_id || null,
|
||||
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
|
||||
};
|
||||
}
|
||||
const fallback = modelValue(modelName);
|
||||
return { requestedModel: fallback, actualModel: fallback };
|
||||
return {
|
||||
requestedModel: fallback,
|
||||
actualModel: fallback,
|
||||
requestedEndpointId: null,
|
||||
requestedEndpointLabel: 'Selected route',
|
||||
actualEndpointId: null,
|
||||
actualEndpointLabel: 'Selected route',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -824,12 +864,50 @@ export function isCostTrackedEndpoint(url) {
|
||||
}
|
||||
|
||||
/** Cost for the current turn, returning null for non-billable endpoints. */
|
||||
function _billableCost(model, inputTokens, outputTokens) {
|
||||
const url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(url)) return null;
|
||||
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
|
||||
// Foreground fallback can answer on a different endpoint than the session's
|
||||
// selected route. Prefer the backend's non-secret actual-route
|
||||
// classification; retain the selected-endpoint check for older history.
|
||||
if (endpointCostTracked === false) return null;
|
||||
const selectedUrl = selectedEndpointUrl === undefined
|
||||
? _currentEndpointUrl()
|
||||
: selectedEndpointUrl;
|
||||
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
|
||||
return null;
|
||||
}
|
||||
return getModelCost(model, inputTokens, outputTokens);
|
||||
}
|
||||
|
||||
/** Sum cost using the route/model that produced each Agent round. */
|
||||
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
|
||||
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
|
||||
if (!buckets.length) {
|
||||
return _billableCost(
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
metrics.endpoint_cost_tracked,
|
||||
selectedEndpointUrl,
|
||||
);
|
||||
}
|
||||
let total = 0;
|
||||
let hasPricedUsage = false;
|
||||
for (const bucket of buckets) {
|
||||
if (!bucket || typeof bucket !== 'object') continue;
|
||||
const bucketCost = _billableCost(
|
||||
bucket.model || model,
|
||||
Number(bucket.input_tokens) || 0,
|
||||
Number(bucket.output_tokens) || 0,
|
||||
bucket.endpoint_cost_tracked,
|
||||
selectedEndpointUrl,
|
||||
);
|
||||
if (bucketCost === null) continue;
|
||||
total += bucketCost;
|
||||
hasPricedUsage = true;
|
||||
}
|
||||
return hasPricedUsage ? total : null;
|
||||
}
|
||||
|
||||
export function getImageCost(model, quality, size) {
|
||||
if (!model) return null;
|
||||
const m = model.toLowerCase();
|
||||
@@ -844,6 +922,9 @@ export function getImageCost(model, quality, size) {
|
||||
|
||||
/* ── Session cost helpers ─────────────────────────────────────────── */
|
||||
const _COST_KEY = 'ody-session-cost';
|
||||
const _COST_RUNS_KEY = 'ody-session-cost-runs';
|
||||
const _MAX_COST_RUNS_PER_SESSION = 256;
|
||||
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
|
||||
|
||||
/** Return the accumulated cost for the current (or given) session. */
|
||||
export function getSessionCost(sessionId) {
|
||||
@@ -851,7 +932,14 @@ export function getSessionCost(sessionId) {
|
||||
if (!sid) return 0;
|
||||
try {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
return costs[sid] || 0;
|
||||
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
|
||||
? Object.values(runCosts[sid])
|
||||
: [];
|
||||
return (costs[sid] || 0) + recordedRuns.reduce(
|
||||
(total, value) => total + (Number(value) || 0),
|
||||
0,
|
||||
);
|
||||
} catch (_e) { return 0; }
|
||||
}
|
||||
|
||||
@@ -863,6 +951,9 @@ export function resetSessionCost(sessionId) {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
delete costs[sid];
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||
delete runCosts[sid];
|
||||
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
|
||||
} catch (_e) { /* ignore */ }
|
||||
updateSessionCostUI();
|
||||
}
|
||||
@@ -871,21 +962,8 @@ export function resetSessionCost(sessionId) {
|
||||
export function updateSessionCostUI() {
|
||||
const el = document.getElementById('session-cost-display');
|
||||
if (!el) return;
|
||||
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
||||
// cloud-rate calculation may have left in localStorage for this session.
|
||||
const _url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(_url)) {
|
||||
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (sid && getSessionCost(sid) > 0) {
|
||||
try {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
delete costs[sid];
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// The ledger records billable work already performed in this session. A
|
||||
// selected local endpoint does not erase cost from a paid fallback route.
|
||||
const cost = getSessionCost();
|
||||
if (cost > 0) {
|
||||
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
|
||||
@@ -895,6 +973,94 @@ export function updateSessionCostUI() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Record one metrics payload in a session ledger at most once. */
|
||||
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
|
||||
if (!metrics || typeof metrics !== 'object') return null;
|
||||
const cost = _metricsBillableCost(
|
||||
metrics,
|
||||
metrics.model || 'Unknown',
|
||||
metrics.input_tokens || 0,
|
||||
metrics.output_tokens || 0,
|
||||
selectedEndpointUrl,
|
||||
);
|
||||
if (metrics._fromHistory) return cost;
|
||||
const sid = sessionId || (
|
||||
window.sessionModule && window.sessionModule.getCurrentSessionId()
|
||||
);
|
||||
if (!sid || cost === null) return cost;
|
||||
const runId = typeof metrics._costRecordId === 'string'
|
||||
? metrics._costRecordId.trim()
|
||||
: '';
|
||||
if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost;
|
||||
// Recorded is only set once the write actually runs; pending covers the
|
||||
// window while the write waits on the cross-tab lock, so a replay in that
|
||||
// window cannot double-add and a tab closed mid-queue never claims recorded.
|
||||
metrics._costRecordPending = true;
|
||||
const writeCost = () => {
|
||||
if (runId) {
|
||||
try {
|
||||
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
|
||||
? runCosts[sid]
|
||||
: {};
|
||||
// Assigning by detached-run identity is replay-idempotent even when a
|
||||
// refresh produces a fresh metrics object. The Web Lock around this
|
||||
// read/modify/write also keeps distinct runs from two tabs from
|
||||
// overwriting one another's stale snapshot.
|
||||
sessionRuns[runId] = cost;
|
||||
const entries = Object.entries(sessionRuns);
|
||||
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
|
||||
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
costs[sid] = (costs[sid] || 0) + overflow.reduce(
|
||||
(total, entry) => total + (Number(entry[1]) || 0),
|
||||
0,
|
||||
);
|
||||
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
}
|
||||
runCosts[sid] = sessionRuns;
|
||||
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
|
||||
} catch (_e) { /* ignore */ }
|
||||
} else {
|
||||
try {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
costs[sid] = (costs[sid] || 0) + cost;
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
metrics._costRecorded = true;
|
||||
metrics._costRecordPending = false;
|
||||
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (currentSid === sid) updateSessionCostUI();
|
||||
};
|
||||
|
||||
let writeStarted = false;
|
||||
const guardedWrite = () => {
|
||||
writeStarted = true;
|
||||
writeCost();
|
||||
};
|
||||
try {
|
||||
if (
|
||||
typeof navigator !== 'undefined'
|
||||
&& navigator.locks
|
||||
&& typeof navigator.locks.request === 'function'
|
||||
) {
|
||||
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
|
||||
if (pendingWrite && typeof pendingWrite.catch === 'function') {
|
||||
pendingWrite.catch(() => {
|
||||
if (!writeStarted) guardedWrite();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
guardedWrite();
|
||||
}
|
||||
} catch (_e) {
|
||||
if (!writeStarted) guardedWrite();
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
/** Create a timestamp span for role labels.
|
||||
* Pass an ISO string / Date / epoch-ms to render the message's own time
|
||||
* (used when replaying history). Falls back to "now" when no value is given. */
|
||||
@@ -1201,7 +1367,7 @@ document.addEventListener('click', function(e) {
|
||||
} catch {}
|
||||
});
|
||||
} else if (kind === 'document') {
|
||||
import('./document.js?v=20260722emailfastindex1').then(mod => {
|
||||
import('./document.js?v=20260815approvalsave1').then(mod => {
|
||||
const open = mod.loadDocument
|
||||
|| mod.openDocument
|
||||
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
||||
@@ -1223,7 +1389,7 @@ document.addEventListener('click', function(e) {
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'email') {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
||||
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (open) open({ uid: id });
|
||||
}).catch(() => {});
|
||||
@@ -1874,23 +2040,19 @@ export function displayMetrics(messageElement, metrics) {
|
||||
const isReal = metrics.usage_source === 'real';
|
||||
const ctxPct = metrics.context_percent;
|
||||
const model = metrics.model || 'Unknown';
|
||||
const cost = _billableCost(model, inputTokens, outputTokens);
|
||||
const cost = _metricsBillableCost(
|
||||
metrics,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
);
|
||||
|
||||
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
||||
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
||||
|
||||
// Accumulate session cost (only on fresh metrics, not history reload)
|
||||
if (!metrics._fromHistory) {
|
||||
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (_sid && cost !== null) {
|
||||
try {
|
||||
const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
_costs[_sid] = (_costs[_sid] || 0) + cost;
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
|
||||
} catch (_e) { /* ignore */ }
|
||||
updateSessionCostUI();
|
||||
}
|
||||
}
|
||||
// Rendering can occur when metrics arrive and again after [DONE]. The
|
||||
// ledger mutation is idempotent for that shared payload.
|
||||
recordSessionMetricsCost(metrics);
|
||||
|
||||
// Keep token counts in the Message Stats popup; the footer should stay slim.
|
||||
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
||||
@@ -2180,6 +2342,7 @@ export function renderAskUserCard(payload, options) {
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
const multi = !!aq.multi;
|
||||
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||
|
||||
const head = document.createElement('div');
|
||||
@@ -2204,6 +2367,27 @@ export function renderAskUserCard(payload, options) {
|
||||
card.appendChild(question);
|
||||
card.setAttribute('aria-labelledby', question.id);
|
||||
|
||||
if (isToolApproval && aq.action) {
|
||||
const action = document.createElement('div');
|
||||
action.className = 'ask-user-option-desc';
|
||||
const effects = Array.isArray(aq.action.effects)
|
||||
? aq.action.effects.join(', ')
|
||||
: '';
|
||||
action.textContent = [
|
||||
aq.action.tool || 'tool',
|
||||
aq.action.content || '',
|
||||
effects ? `Effects: ${effects}` : '',
|
||||
aq.action.workspace ? `Workspace: ${aq.action.workspace}` : '',
|
||||
aq.action.document_id ? `Document: ${aq.action.document_id}` : '',
|
||||
aq.action.document_version != null
|
||||
? `Document version: ${aq.action.document_version}`
|
||||
: '',
|
||||
aq.action.digest ? `Approval fingerprint: ${aq.action.digest}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
action.style.whiteSpace = 'pre-wrap';
|
||||
card.appendChild(action);
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
@@ -2241,7 +2425,23 @@ export function renderAskUserCard(payload, options) {
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => send(label));
|
||||
row.addEventListener('click', () => {
|
||||
if (isToolApproval) {
|
||||
card.remove();
|
||||
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
|
||||
detail: {
|
||||
approval_id: aq.approval_id,
|
||||
decision: String((opt && opt.value) || '').toLowerCase(),
|
||||
label,
|
||||
document_id: aq.action && aq.action.document_id
|
||||
? String(aq.action.document_id)
|
||||
: '',
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
send(label);
|
||||
}
|
||||
});
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
@@ -2277,7 +2477,7 @@ export function renderAskUserCard(payload, options) {
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
if (!isToolApproval) card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
if (renderOptions.scroll !== false) {
|
||||
@@ -2307,9 +2507,19 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||
|
||||
// --- Agent multi-bubble reconstruction from saved metadata ---
|
||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||
if (
|
||||
role === 'assistant'
|
||||
&& metadata
|
||||
&& (
|
||||
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|
||||
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
|
||||
)
|
||||
) {
|
||||
const roundTexts = metadata.round_texts || [];
|
||||
const toolEvents = metadata.tool_events;
|
||||
const roundModels = metadata.round_models || [];
|
||||
const roundEndpointIds = metadata.round_endpoint_ids || [];
|
||||
const roundEndpointLabels = metadata.round_endpoint_labels || [];
|
||||
const toolEvents = metadata.tool_events || [];
|
||||
let pendingAskUser = null;
|
||||
let lastWrap = null;
|
||||
let firstMsgAi = null;
|
||||
@@ -2317,16 +2527,20 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
|
||||
const toolsByRound = {};
|
||||
for (const ev of toolEvents) {
|
||||
const r = ev.round || 1;
|
||||
const r = ev.round ?? 1;
|
||||
if (!toolsByRound[r]) toolsByRound[r] = [];
|
||||
toolsByRound[r].push(ev);
|
||||
}
|
||||
|
||||
const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
|
||||
const toolRounds = Object.keys(toolsByRound).map(Number);
|
||||
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
|
||||
|
||||
for (let r = 0; r < maxRound; r++) {
|
||||
const roundNum = r + 1;
|
||||
const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
|
||||
const firstRound = (toolsByRound[0] || []).length ? 0 : 1;
|
||||
for (let roundNum = firstRound; roundNum <= maxRound; roundNum++) {
|
||||
const r = roundNum - 1;
|
||||
const txt = r >= 0
|
||||
? resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata)
|
||||
: '';
|
||||
|
||||
if (txt) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -2334,10 +2548,31 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
const pair = replyModelPair(modelName, metadata);
|
||||
const contModel = pair.actualModel || pair.requestedModel;
|
||||
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
|
||||
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
|
||||
roleEl.title = pair.requestedModel + ' -> ' + contModel;
|
||||
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
|
||||
const contEndpointId = r < roundEndpointIds.length
|
||||
? roundEndpointIds[r]
|
||||
: pair.actualEndpointId;
|
||||
const contEndpointLabel = r < roundEndpointLabels.length
|
||||
? roundEndpointLabels[r]
|
||||
: pair.actualEndpointLabel;
|
||||
roleEl.textContent = modelRouteLabel(
|
||||
pair.requestedModel,
|
||||
contModel,
|
||||
pair.requestedEndpointLabel,
|
||||
contEndpointLabel,
|
||||
pair.requestedEndpointId,
|
||||
contEndpointId,
|
||||
);
|
||||
if (
|
||||
pair.requestedModel
|
||||
&& contModel
|
||||
&& (
|
||||
!sameModelName(pair.requestedModel, contModel)
|
||||
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
|
||||
)
|
||||
) {
|
||||
roleEl.title = pair.requestedModel + ' -> ' + contModel
|
||||
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
|
||||
}
|
||||
applyModelColor(roleEl, contModel);
|
||||
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
||||
@@ -2492,7 +2727,14 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const isCompacted = metadata?.compacted;
|
||||
const replyModels = replyModelPair(modelName, metadata);
|
||||
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
|
||||
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
|
||||
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
|
||||
replyModels.requestedModel,
|
||||
resolvedModel,
|
||||
replyModels.requestedEndpointLabel,
|
||||
replyModels.actualEndpointLabel,
|
||||
replyModels.requestedEndpointId,
|
||||
replyModels.actualEndpointId,
|
||||
);
|
||||
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
|
||||
_roleText += ' (Research)';
|
||||
}
|
||||
@@ -2503,8 +2745,14 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
}
|
||||
r.textContent = _roleText;
|
||||
if (role !== 'user') {
|
||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
|
||||
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
|
||||
const endpointChanged = Boolean(
|
||||
replyModels.requestedEndpointId
|
||||
&& replyModels.actualEndpointId
|
||||
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
|
||||
);
|
||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
|
||||
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
|
||||
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
|
||||
}
|
||||
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
|
||||
r.appendChild(roleTimestamp(metadata?.timestamp));
|
||||
@@ -2788,6 +3036,7 @@ const chatRenderer = {
|
||||
getSessionCost,
|
||||
resetSessionCost,
|
||||
updateSessionCostUI,
|
||||
recordSessionMetricsCost,
|
||||
roleTimestamp,
|
||||
stripToolBlocks,
|
||||
copyMessageText,
|
||||
|
||||
@@ -7,7 +7,7 @@ import Storage from './storage.js';
|
||||
import themeModule from './theme.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
import documentModule from './document.js?v=20260815approvalsave1';
|
||||
|
||||
/**
|
||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'email') {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) {
|
||||
import('./emailLibrary.js?v=20260815approvalsave1').then(function(mod) {
|
||||
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
|
||||
} catch (e) {
|
||||
console.warn('open_email_reply existing draft update failed:', e);
|
||||
}
|
||||
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) {
|
||||
import('./emailInbox.js?v=20260815approvalsave1').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Build a terminal stream error while preserving provider-supplied text. */
|
||||
export function createTerminalStreamError(payload = {}) {
|
||||
const rawError = payload.error;
|
||||
const message = (
|
||||
payload.text
|
||||
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|
||||
|| `Error ${payload.status || 'unknown'}`
|
||||
);
|
||||
const error = new Error(message);
|
||||
error.name = 'TerminalStreamError';
|
||||
error.terminalStreamError = true;
|
||||
error.status = payload.status;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Only connection-class stream failures are safe to resubmit automatically. */
|
||||
export function isRecoverableStreamError(error) {
|
||||
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
|
||||
if (error.name === 'TypeError') return true;
|
||||
const message = (error.message || '').toLowerCase();
|
||||
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
|
||||
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
|
||||
}
|
||||
@@ -3934,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
leadingIcon: 'check',
|
||||
action: 'View Message',
|
||||
onAction: () => {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
||||
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (open) open({
|
||||
account_id: data.account_id || activeAccountId || null,
|
||||
@@ -9401,9 +9401,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
/** Save manual edits */
|
||||
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
|
||||
if (!activeDocId) return;
|
||||
if (!activeDocId) return false;
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!textarea) return;
|
||||
if (!textarea) return false;
|
||||
const savingDocId = activeDocId;
|
||||
saveCurrentToMap();
|
||||
const localDoc = docs.get(savingDocId);
|
||||
@@ -9422,7 +9422,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
});
|
||||
if (res.status === 404) {
|
||||
if (silent && localDoc?.language === 'email') {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// Streaming/empty email drafts can leave a local tab pointing at a temp
|
||||
// or already-deleted document. Do not keep surfacing autosave errors for
|
||||
@@ -9434,7 +9434,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
_syncDocIndicator();
|
||||
if (!silent && uiModule) uiModule.showError('Document no longer exists');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
|
||||
const doc = await res.json();
|
||||
@@ -9447,6 +9447,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
_syncDocIndicator();
|
||||
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to save document:', e);
|
||||
const now = Date.now();
|
||||
@@ -9454,6 +9455,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
|
||||
_lastAutoSaveErrorAt = now;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260815approvalsave1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260815approvalsave1';
|
||||
import settingsModule from './settings.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
@@ -6680,7 +6680,7 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
ownerModal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
const docMod = await import('./document.js?v=20260722emailfastindex1');
|
||||
const docMod = await import('./document.js?v=20260815approvalsave1');
|
||||
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
|
||||
if (typeof load === 'function') {
|
||||
await load(json.doc_id);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule, { autoResize, styledPrompt } from './ui.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
|
||||
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
|
||||
import themeModule from './theme.js';
|
||||
|
||||
+84
-224
@@ -3,8 +3,25 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import searchModule from './search.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { clearDockSide } from './modalSnap.js';
|
||||
import { byId } from './settings/dom.js';
|
||||
import {
|
||||
getSettingsRegistryIssues,
|
||||
isAdminManagedSettingsTab,
|
||||
} from './settings/registry.js';
|
||||
import { bindSettingsSearch } from './settings/search.js';
|
||||
import { bindSettingsSidebar } from './settings/sidebar.js';
|
||||
import {
|
||||
activateSettingsPanel,
|
||||
getActiveSettingsTab,
|
||||
bindSettingsNavigation,
|
||||
} from './settings/navigation.js';
|
||||
import {
|
||||
bindSettingsDrag,
|
||||
bindSettingsClose,
|
||||
bindOpenPromptModalLink,
|
||||
showSettingsModal,
|
||||
hideSettingsModal,
|
||||
} from './settings/lifecycle.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
@@ -14,133 +31,29 @@ let initialized = false;
|
||||
let modalEl = null;
|
||||
let _authPolicy = { password_min_length: 8 };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
const el = byId;
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
function safeRasterDataUrl(raw) {
|
||||
const value = String(raw || '').trim();
|
||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/* ── Tab switching ── */
|
||||
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
|
||||
/* ── Settings shell coordination ── */
|
||||
function onSettingsPanelActivated(tab) {
|
||||
// Appearance keeps its existing transparent preview behavior.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
|
||||
function initTabs() {
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.settingsTab;
|
||||
// Lazy-init admin when first clicking an admin tab
|
||||
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open(tab);
|
||||
return;
|
||||
}
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
|
||||
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
|
||||
// Mark when the Appearance tab is open so the modal can go
|
||||
// semi-transparent — lets the user see the rest of the UI react as
|
||||
// they flip toggles instead of having to close + reopen the modal.
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
});
|
||||
});
|
||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
}
|
||||
|
||||
/* ── Dragging ── */
|
||||
function initDrag() {
|
||||
const header = modalEl.querySelector('.modal-header');
|
||||
const content = modalEl.querySelector('.settings-modal-content');
|
||||
if (!header || !content) return;
|
||||
// Skip interactive controls in the header (e.g. the opacity slider) so
|
||||
// grabbing them doesn't start a window-drag.
|
||||
makeWindowDraggable(modalEl, {
|
||||
content,
|
||||
header,
|
||||
skipSelector: 'button, input, select, .theme-opacity-wrap',
|
||||
enableDock: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resetWindowPlacement() {
|
||||
const content = modalEl && modalEl.querySelector('.settings-modal-content');
|
||||
if (!content) return;
|
||||
const hadLeft = modalEl.classList.contains('modal-left-docked');
|
||||
const hadRight = modalEl.classList.contains('modal-right-docked');
|
||||
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
|
||||
if (hadLeft) clearDockSide('left', modalEl);
|
||||
if (hadRight) clearDockSide('right', modalEl);
|
||||
if (content._leftDockNavObs) {
|
||||
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
|
||||
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
|
||||
delete content._leftDockNavObs;
|
||||
function openAdminSettingsTab(tab) {
|
||||
if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open(tab);
|
||||
return true;
|
||||
}
|
||||
delete content._preDockSnapshot;
|
||||
delete content._dockSide;
|
||||
delete content._dockSuspended;
|
||||
delete content.dataset._tilePreSnap;
|
||||
delete content.dataset._tileZone;
|
||||
[
|
||||
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
|
||||
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
|
||||
].forEach(prop => content.style.removeProperty(prop));
|
||||
}
|
||||
|
||||
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
|
||||
function initOpenPromptModalLink() {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const link = e.target.closest('[data-open-prompt-modal]');
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
// Close settings first so the prompt modal isn't stacked on top.
|
||||
if (modalEl && !modalEl.classList.contains('hidden')) close();
|
||||
try {
|
||||
const m = await import('./presets.js');
|
||||
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
|
||||
if (typeof fn === 'function') fn();
|
||||
} catch (_) {
|
||||
const modal = document.getElementById('custom-preset-modal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
// Force the Persona tab (data-chartab="character") since the link's
|
||||
// whole purpose is editing personas — not landing on Inject by default.
|
||||
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
|
||||
if (personaTab) personaTab.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Close on backdrop / X ── */
|
||||
function initClose() {
|
||||
modalEl.querySelector('.close-btn').addEventListener('click', close);
|
||||
modalEl.addEventListener('mousedown', e => {
|
||||
if (uiModule.isTouchInsideModal()) return;
|
||||
if (e.target === modalEl) close();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
|
||||
// Bail when a transient popover inside the modal is open — Esc should
|
||||
// dismiss just that, not the whole modal. Same-document listeners fire
|
||||
// in registration order regardless of capture/bubble, so the popover's
|
||||
// own handler can't pre-empt ours; we have to opt out here.
|
||||
const popoverOpen = modalEl.querySelector(
|
||||
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
|
||||
);
|
||||
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
// If an integration edit/add form is open inside the modal, close
|
||||
// just that — don't dismiss the whole settings modal. (Pressing
|
||||
// ESC mid-edit and losing the modal was a fast-typing footgun.)
|
||||
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
|
||||
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
innerForm.style.display = 'none';
|
||||
innerForm.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Appearance-tab opacity slider ──
|
||||
@@ -445,14 +358,7 @@ async function initDefaultChat() {
|
||||
var epSel = el('set-defaultEpSelect');
|
||||
var modelSel = el('set-defaultModelSelect');
|
||||
var msg = el('set-defaultChatMsg');
|
||||
var fbContainer = el('set-defaultFallbacks');
|
||||
var addFbBtn = el('set-defaultAddFallback');
|
||||
var _endpoints = [];
|
||||
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
|
||||
|
||||
function enabledEndpoints() {
|
||||
return _endpoints.filter(function(e) { return e.is_enabled; });
|
||||
}
|
||||
|
||||
// Fill any <select> with the models for a given endpoint id.
|
||||
function fillModels(selectEl, epId, selected) {
|
||||
@@ -469,64 +375,6 @@ async function initDefaultChat() {
|
||||
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
||||
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
||||
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
||||
renderFallbacks();
|
||||
}
|
||||
|
||||
// Render the fallback chain. Each row is endpoint + model + remove.
|
||||
function renderFallbacks() {
|
||||
fbContainer.innerHTML = '';
|
||||
_fallbacks.forEach(function(fb, idx) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'settings-fallback-row';
|
||||
|
||||
var num = document.createElement('span');
|
||||
num.className = 'settings-fallback-num';
|
||||
num.textContent = (idx + 1) + '.';
|
||||
|
||||
var epS = document.createElement('select');
|
||||
epS.className = 'settings-select';
|
||||
enabledEndpoints().forEach(function(ep) {
|
||||
var o = document.createElement('option');
|
||||
o.value = ep.id;
|
||||
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
||||
epS.appendChild(o);
|
||||
});
|
||||
var first = enabledEndpoints()[0];
|
||||
epS.value = fb.endpoint_id || (first ? first.id : '');
|
||||
|
||||
var mS = document.createElement('select');
|
||||
mS.className = 'settings-select';
|
||||
fillModels(mS, epS.value, fb.model);
|
||||
|
||||
// Keep the model in sync with the values actually shown.
|
||||
fb.endpoint_id = epS.value;
|
||||
fb.model = mS.value;
|
||||
|
||||
epS.addEventListener('change', function() {
|
||||
fb.endpoint_id = epS.value;
|
||||
fillModels(mS, epS.value, '');
|
||||
fb.model = mS.value;
|
||||
saveDefault();
|
||||
});
|
||||
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
|
||||
|
||||
var rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'settings-fallback-remove';
|
||||
rm.title = 'Remove fallback';
|
||||
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
||||
rm.addEventListener('click', function() {
|
||||
_fallbacks.splice(idx, 1);
|
||||
renderFallbacks();
|
||||
saveDefault();
|
||||
});
|
||||
|
||||
row.appendChild(num);
|
||||
row.appendChild(epS);
|
||||
row.appendChild(mS);
|
||||
row.appendChild(rm);
|
||||
fbContainer.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -534,7 +382,6 @@ async function initDefaultChat() {
|
||||
var settings = await res.json();
|
||||
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
||||
refreshModels(settings.default_model || '');
|
||||
renderFallbacks();
|
||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
||||
@@ -554,13 +401,6 @@ async function initDefaultChat() {
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
if (addFbBtn) addFbBtn.addEventListener('click', function() {
|
||||
var first = enabledEndpoints()[0];
|
||||
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
|
||||
renderFallbacks();
|
||||
saveDefault();
|
||||
});
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_endpoints = endpoints;
|
||||
refreshEndpointOptions(epSel.value, modelSel.value);
|
||||
@@ -2311,10 +2151,39 @@ function initAccount() {
|
||||
|
||||
function initAll() {
|
||||
modalEl = el('settings-modal');
|
||||
initTabs();
|
||||
initDrag();
|
||||
initClose();
|
||||
initOpenPromptModalLink();
|
||||
|
||||
bindSettingsNavigation(modalEl, {
|
||||
openAdminTab: openAdminSettingsTab,
|
||||
onPanelActivated: onSettingsPanelActivated,
|
||||
});
|
||||
|
||||
bindSettingsSearch(modalEl, {
|
||||
isAdmin: () => !!window._isAdmin,
|
||||
openPanel(tab) {
|
||||
const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`);
|
||||
if (button) button.click();
|
||||
},
|
||||
});
|
||||
|
||||
bindSettingsSidebar(modalEl);
|
||||
|
||||
const registryIssues = getSettingsRegistryIssues(modalEl);
|
||||
if (registryIssues.length) {
|
||||
console.warn('Settings registry/DOM mismatch:', registryIssues);
|
||||
}
|
||||
|
||||
bindSettingsDrag(modalEl);
|
||||
|
||||
bindSettingsClose(modalEl, {
|
||||
closeSettings: close,
|
||||
isTouchInsideModal: () => uiModule.isTouchInsideModal(),
|
||||
});
|
||||
|
||||
bindOpenPromptModalLink({
|
||||
getModal: () => modalEl,
|
||||
closeSettings: close,
|
||||
});
|
||||
|
||||
initOpacityToggle();
|
||||
initialized = true;
|
||||
initDefaultChat();
|
||||
@@ -2818,7 +2687,7 @@ async function initEmailAccountsSettings() {
|
||||
|
||||
el('set-email-open-library-settings')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
|
||||
const mod = await import('./emailLibrary.js?v=20260815approvalsave1');
|
||||
if (typeof mod.openEmailLibrarySettings === 'function') {
|
||||
await mod.openEmailLibrarySettings();
|
||||
}
|
||||
@@ -5734,44 +5603,35 @@ function syncAdminVisibility() {
|
||||
═══════════════════════════════════════════ */
|
||||
export function open(tab) {
|
||||
if (!initialized) initAll();
|
||||
|
||||
syncAppearanceCheckboxes();
|
||||
if (modalEl.classList.contains('hidden')) {
|
||||
resetWindowPlacement();
|
||||
}
|
||||
modalEl.classList.remove('hidden');
|
||||
showSettingsModal(modalEl);
|
||||
syncAdminVisibility();
|
||||
const content = modalEl.querySelector('.settings-modal-content');
|
||||
|
||||
if (tab) {
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
|
||||
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
}
|
||||
// Auto-init admin data if showing an admin tab
|
||||
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
|
||||
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
||||
syncAppearanceOpacity(activeTab === 'appearance');
|
||||
if (activeTab === 'ai') refreshAiModelEndpoints();
|
||||
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
|
||||
// Preserve existing panel-specific side effects when Settings is opened
|
||||
// directly to a tab as well as when the user navigates there.
|
||||
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||
onSettingsPanelActivated(activeTab);
|
||||
|
||||
// Auto-init admin data if showing an admin tab.
|
||||
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
window.adminModule._initData();
|
||||
}
|
||||
}
|
||||
|
||||
export function close() {
|
||||
if (!modalEl) return;
|
||||
// Always clear the appearance-tab body class so the rest of the app
|
||||
// doesn't keep its dimmed state if the modal got closed mid-tab.
|
||||
|
||||
// Always clear the Appearance state so the rest of the app does not remain
|
||||
// dimmed if Settings is closed while that panel is active.
|
||||
document.body.classList.remove('settings-appearance-open');
|
||||
syncAppearanceOpacity(false); // clear any opacity-slider fade
|
||||
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
|
||||
if (content && !content.classList.contains('modal-closing')) {
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', () => {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}, { once: true });
|
||||
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
|
||||
} else {
|
||||
modalEl.classList.add('hidden');
|
||||
}
|
||||
syncAppearanceOpacity(false);
|
||||
|
||||
hideSettingsModal(modalEl);
|
||||
}
|
||||
|
||||
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Shared DOM helpers for the Settings modules.
|
||||
// Keep this module intentionally small so panel modules do not grow their own
|
||||
// element-lookup conventions as Settings is split out of settings.js.
|
||||
|
||||
export function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Settings modal lifecycle primitives.
|
||||
//
|
||||
// Panel-specific behavior belongs elsewhere. This module owns only the window
|
||||
// shell: dragging/docking reset, close semantics, visibility animation, and the
|
||||
// delegated link that leaves Settings for the Persona editor.
|
||||
|
||||
import { makeWindowDraggable } from '../windowDrag.js';
|
||||
import { clearDockSide } from '../modalSnap.js';
|
||||
|
||||
const _dragBound = new WeakSet();
|
||||
const _closeBound = new WeakSet();
|
||||
let _promptLinkBound = false;
|
||||
|
||||
export function bindSettingsDrag(modalEl) {
|
||||
if (!modalEl || _dragBound.has(modalEl)) return;
|
||||
|
||||
const header = modalEl.querySelector('.modal-header');
|
||||
const content = modalEl.querySelector('.settings-modal-content');
|
||||
if (!header || !content) return;
|
||||
|
||||
_dragBound.add(modalEl);
|
||||
makeWindowDraggable(modalEl, {
|
||||
content,
|
||||
header,
|
||||
skipSelector: 'button, input, select, .theme-opacity-wrap',
|
||||
enableDock: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetSettingsWindowPlacement(modalEl) {
|
||||
const content = modalEl?.querySelector('.settings-modal-content');
|
||||
if (!content) return;
|
||||
|
||||
const hadLeft = modalEl.classList.contains('modal-left-docked');
|
||||
const hadRight = modalEl.classList.contains('modal-right-docked');
|
||||
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
|
||||
if (hadLeft) clearDockSide('left', modalEl);
|
||||
if (hadRight) clearDockSide('right', modalEl);
|
||||
|
||||
if (content._leftDockNavObs) {
|
||||
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
|
||||
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
|
||||
delete content._leftDockNavObs;
|
||||
}
|
||||
|
||||
delete content._preDockSnapshot;
|
||||
delete content._dockSide;
|
||||
delete content._dockSuspended;
|
||||
delete content.dataset._tilePreSnap;
|
||||
delete content.dataset._tileZone;
|
||||
|
||||
[
|
||||
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
|
||||
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
|
||||
].forEach(property => content.style.removeProperty(property));
|
||||
}
|
||||
|
||||
export function bindOpenPromptModalLink({ getModal, closeSettings } = {}) {
|
||||
if (_promptLinkBound) return;
|
||||
_promptLinkBound = true;
|
||||
|
||||
document.addEventListener('click', async event => {
|
||||
const link = event.target?.closest?.('[data-open-prompt-modal]');
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
|
||||
const settingsModal = typeof getModal === 'function' ? getModal() : null;
|
||||
if (
|
||||
settingsModal
|
||||
&& !settingsModal.classList.contains('hidden')
|
||||
&& typeof closeSettings === 'function'
|
||||
) {
|
||||
closeSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await import('../presets.js');
|
||||
const openPrompt = module.openCustomPresetModal
|
||||
|| (module.default && module.default.openCustomPresetModal);
|
||||
if (typeof openPrompt === 'function') openPrompt();
|
||||
} catch (_) {
|
||||
const modal = document.getElementById('custom-preset-modal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const personaTab = document.querySelector(
|
||||
'#custom-preset-modal .preset-tab[data-chartab="character"]'
|
||||
);
|
||||
if (personaTab) personaTab.click();
|
||||
});
|
||||
}
|
||||
|
||||
export function bindSettingsClose(modalEl, options = {}) {
|
||||
if (!modalEl || _closeBound.has(modalEl)) return;
|
||||
_closeBound.add(modalEl);
|
||||
|
||||
const closeSettings = options.closeSettings;
|
||||
const isTouchInsideModal = options.isTouchInsideModal;
|
||||
|
||||
const closeButton = modalEl.querySelector('.close-btn');
|
||||
closeButton?.addEventListener('click', () => {
|
||||
if (typeof closeSettings === 'function') closeSettings();
|
||||
});
|
||||
|
||||
modalEl.addEventListener('mousedown', event => {
|
||||
if (typeof isTouchInsideModal === 'function' && isTouchInsideModal()) return;
|
||||
if (event.target === modalEl && typeof closeSettings === 'function') {
|
||||
closeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || modalEl.classList.contains('hidden')) return;
|
||||
|
||||
// Esc should dismiss transient popovers before the Settings window.
|
||||
const popoverOpen = modalEl.querySelector(
|
||||
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
|
||||
);
|
||||
if (
|
||||
popoverOpen
|
||||
&& popoverOpen.style.display !== 'none'
|
||||
&& !popoverOpen.classList.contains('hidden')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Integration/account editors are nested flows. Close the editor first so
|
||||
// an accidental Esc does not discard the entire Settings context.
|
||||
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
|
||||
if (
|
||||
innerForm
|
||||
&& innerForm.style.display !== 'none'
|
||||
&& innerForm.children.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
innerForm.style.display = 'none';
|
||||
innerForm.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (typeof closeSettings === 'function') closeSettings();
|
||||
});
|
||||
}
|
||||
|
||||
export function showSettingsModal(modalEl) {
|
||||
if (!modalEl) return;
|
||||
if (modalEl.classList.contains('hidden')) {
|
||||
resetSettingsWindowPlacement(modalEl);
|
||||
}
|
||||
modalEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hideSettingsModal(modalEl) {
|
||||
if (!modalEl) return;
|
||||
|
||||
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
|
||||
if (content && !content.classList.contains('modal-closing')) {
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', () => {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}, { once: true });
|
||||
setTimeout(() => {
|
||||
if (!modalEl.classList.contains('hidden')) {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
modalEl.classList.add('hidden');
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Settings navigation primitives.
|
||||
//
|
||||
// This module owns panel activation and sidebar click routing only. Individual
|
||||
// panels continue to own their data loading and side effects.
|
||||
|
||||
import { DEFAULT_SETTINGS_PANEL_ID, isAdminManagedSettingsTab } from './registry.js';
|
||||
|
||||
const _boundModals = new WeakSet();
|
||||
|
||||
export function activateSettingsPanel(modalEl, tab) {
|
||||
if (!modalEl || !tab) return null;
|
||||
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.settingsTab === tab);
|
||||
});
|
||||
modalEl.querySelectorAll('[data-settings-panel]').forEach(panel => {
|
||||
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tab);
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function getActiveSettingsTab(modalEl, fallback = DEFAULT_SETTINGS_PANEL_ID) {
|
||||
if (!modalEl) return fallback;
|
||||
const active = modalEl.querySelector('[data-settings-tab].active');
|
||||
return active?.dataset?.settingsTab || fallback;
|
||||
}
|
||||
|
||||
export function bindSettingsNavigation(modalEl, options = {}) {
|
||||
if (!modalEl || _boundModals.has(modalEl)) return;
|
||||
_boundModals.add(modalEl);
|
||||
|
||||
const openAdminTab = options.openAdminTab;
|
||||
const onPanelActivated = options.onPanelActivated;
|
||||
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const tab = button.dataset.settingsTab;
|
||||
if (!tab) return;
|
||||
|
||||
// Preserve the existing lazy-admin path: when the admin module accepts
|
||||
// the tab, it owns activation/rendering and the Settings shell does not
|
||||
// perform a second local switch.
|
||||
if (
|
||||
isAdminManagedSettingsTab(tab)
|
||||
&& typeof openAdminTab === 'function'
|
||||
&& openAdminTab(tab, button) === true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activateSettingsPanel(modalEl, tab);
|
||||
if (typeof onPanelActivated === 'function') {
|
||||
onPanelActivated(tab, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Canonical metadata for the existing Settings information architecture.
|
||||
//
|
||||
// This module describes Settings; it does not render the sidebar, load panel
|
||||
// data, or own panel behavior. Keeping those concerns separate lets the
|
||||
// current markup remain stable while navigation/search code shares one source
|
||||
// of truth for panel identity and ownership.
|
||||
|
||||
function defineGroup(definition) {
|
||||
return Object.freeze({ ...definition });
|
||||
}
|
||||
|
||||
function definePanel(definition) {
|
||||
return Object.freeze({
|
||||
controller: 'settings',
|
||||
adminOnly: false,
|
||||
...definition,
|
||||
keywords: Object.freeze([...(definition.keywords || [])]),
|
||||
});
|
||||
}
|
||||
|
||||
export const SETTINGS_GROUPS = Object.freeze([
|
||||
defineGroup({
|
||||
id: 'models',
|
||||
label: 'Models & AI',
|
||||
}),
|
||||
defineGroup({
|
||||
id: 'communications',
|
||||
label: 'Communications',
|
||||
}),
|
||||
defineGroup({
|
||||
id: 'experience',
|
||||
label: 'Experience',
|
||||
}),
|
||||
defineGroup({
|
||||
id: 'account',
|
||||
label: 'Account',
|
||||
}),
|
||||
defineGroup({
|
||||
id: 'administration',
|
||||
label: 'Administration',
|
||||
adminOnly: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Order intentionally mirrors the existing Settings sidebar.
|
||||
export const SETTINGS_PANELS = Object.freeze([
|
||||
definePanel({
|
||||
id: 'services',
|
||||
label: 'Add Models',
|
||||
group: 'models',
|
||||
controller: 'admin',
|
||||
keywords: ['models', 'provider', 'endpoint'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'added-models',
|
||||
label: 'Added Models',
|
||||
group: 'models',
|
||||
controller: 'admin',
|
||||
keywords: ['models', 'configured', 'provider', 'endpoint'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'ai',
|
||||
label: 'AI Defaults',
|
||||
group: 'models',
|
||||
keywords: ['ai', 'defaults', 'model', 'vision', 'image', 'tts', 'stt'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'search',
|
||||
label: 'Search',
|
||||
group: 'models',
|
||||
keywords: ['search', 'research', 'provider'],
|
||||
}),
|
||||
|
||||
definePanel({
|
||||
id: 'integrations',
|
||||
label: 'Integrations',
|
||||
group: 'communications',
|
||||
controller: 'admin',
|
||||
keywords: ['integrations', 'connections', 'services'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'email',
|
||||
label: 'Email',
|
||||
group: 'communications',
|
||||
keywords: ['email', 'imap', 'smtp', 'oauth'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'reminders',
|
||||
label: 'Reminders',
|
||||
group: 'communications',
|
||||
keywords: ['reminders', 'notifications', 'alerts'],
|
||||
}),
|
||||
|
||||
definePanel({
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
group: 'experience',
|
||||
keywords: ['appearance', 'theme', 'font', 'density', 'peek'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'shortcuts',
|
||||
label: 'Shortcuts',
|
||||
group: 'experience',
|
||||
keywords: ['shortcuts', 'keyboard', 'hotkeys'],
|
||||
}),
|
||||
|
||||
definePanel({
|
||||
id: 'account',
|
||||
label: 'Account',
|
||||
group: 'account',
|
||||
keywords: ['account', 'password', 'logout'],
|
||||
}),
|
||||
|
||||
definePanel({
|
||||
id: 'tools',
|
||||
label: 'Agent Tools',
|
||||
group: 'administration',
|
||||
controller: 'admin',
|
||||
adminOnly: true,
|
||||
keywords: ['agent', 'tools'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
group: 'administration',
|
||||
controller: 'admin',
|
||||
adminOnly: true,
|
||||
keywords: ['users', 'accounts', 'admin'],
|
||||
}),
|
||||
definePanel({
|
||||
id: 'system',
|
||||
label: 'System',
|
||||
group: 'administration',
|
||||
controller: 'admin',
|
||||
adminOnly: true,
|
||||
keywords: ['system', 'admin', 'server'],
|
||||
}),
|
||||
]);
|
||||
|
||||
export const DEFAULT_SETTINGS_PANEL_ID = 'services';
|
||||
|
||||
const _panelsById = new Map(
|
||||
SETTINGS_PANELS.map(panel => [panel.id, panel]),
|
||||
);
|
||||
|
||||
export function getSettingsPanel(id) {
|
||||
return _panelsById.get(String(id || '')) || null;
|
||||
}
|
||||
|
||||
export function getSettingsPanelsForGroup(groupId) {
|
||||
return SETTINGS_PANELS.filter(panel => panel.group === groupId);
|
||||
}
|
||||
|
||||
export function isAdminManagedSettingsTab(id) {
|
||||
return getSettingsPanel(id)?.controller === 'admin';
|
||||
}
|
||||
|
||||
export function isAdminOnlySettingsTab(id) {
|
||||
return getSettingsPanel(id)?.adminOnly === true;
|
||||
}
|
||||
|
||||
export function getSettingsPanelSearchText(panelOrId) {
|
||||
const panel = typeof panelOrId === 'string'
|
||||
? getSettingsPanel(panelOrId)
|
||||
: panelOrId;
|
||||
|
||||
if (!panel) return '';
|
||||
|
||||
return [
|
||||
panel.label,
|
||||
...(panel.keywords || []),
|
||||
].join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeSettingsSearch(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function searchSettingsPanels(query, options = {}) {
|
||||
const normalized = normalizeSettingsSearch(query);
|
||||
if (!normalized) return [];
|
||||
|
||||
const terms = normalized.split(' ');
|
||||
const isAdmin = options.isAdmin === true;
|
||||
|
||||
return SETTINGS_PANELS.filter(panel => {
|
||||
if (panel.adminOnly && !isAdmin) return false;
|
||||
|
||||
const haystack = getSettingsPanelSearchText(panel);
|
||||
return terms.every(term => haystack.includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettingsRegistryIssues(modalEl) {
|
||||
if (!modalEl) return ['Settings modal is unavailable'];
|
||||
|
||||
const tabIds = Array.from(
|
||||
modalEl.querySelectorAll('[data-settings-tab]'),
|
||||
element => element.dataset.settingsTab,
|
||||
).filter(Boolean);
|
||||
|
||||
const panelIds = Array.from(
|
||||
modalEl.querySelectorAll('[data-settings-panel]'),
|
||||
element => element.dataset.settingsPanel,
|
||||
).filter(Boolean);
|
||||
|
||||
const registryIds = SETTINGS_PANELS.map(panel => panel.id);
|
||||
const issues = [];
|
||||
|
||||
const duplicates = ids => ids.filter(
|
||||
(id, index) => ids.indexOf(id) !== index,
|
||||
);
|
||||
|
||||
for (const id of new Set(duplicates(tabIds))) {
|
||||
issues.push(`Duplicate Settings tab: ${id}`);
|
||||
}
|
||||
for (const id of new Set(duplicates(panelIds))) {
|
||||
issues.push(`Duplicate Settings panel: ${id}`);
|
||||
}
|
||||
|
||||
for (const id of registryIds) {
|
||||
if (!tabIds.includes(id)) issues.push(`Registry tab missing from DOM: ${id}`);
|
||||
if (!panelIds.includes(id)) issues.push(`Registry panel missing from DOM: ${id}`);
|
||||
}
|
||||
|
||||
for (const id of tabIds) {
|
||||
if (!registryIds.includes(id)) issues.push(`DOM tab missing from registry: ${id}`);
|
||||
}
|
||||
for (const id of panelIds) {
|
||||
if (!registryIds.includes(id)) issues.push(`DOM panel missing from registry: ${id}`);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
SETTINGS_GROUPS,
|
||||
getSettingsPanel,
|
||||
searchSettingsPanels,
|
||||
} from './registry.js';
|
||||
|
||||
const _boundModals = new WeakSet();
|
||||
|
||||
function groupLabelFor(panel) {
|
||||
const group = SETTINGS_GROUPS.find(candidate => candidate.id === panel.group);
|
||||
return group?.label || '';
|
||||
}
|
||||
|
||||
function clearResults(resultsEl) {
|
||||
if (!resultsEl) return;
|
||||
resultsEl.replaceChildren();
|
||||
resultsEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
function getResultButtons(resultsEl) {
|
||||
if (!resultsEl) return [];
|
||||
return Array.from(resultsEl.querySelectorAll('[data-settings-search-result]'));
|
||||
}
|
||||
|
||||
function setActiveResult(resultsEl, index) {
|
||||
const buttons = getResultButtons(resultsEl);
|
||||
if (!buttons.length) return -1;
|
||||
|
||||
let next = index;
|
||||
if (next < 0) next = buttons.length - 1;
|
||||
if (next >= buttons.length) next = 0;
|
||||
|
||||
buttons.forEach((button, buttonIndex) => {
|
||||
const active = buttonIndex === next;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
|
||||
if (typeof buttons[next].scrollIntoView === 'function') {
|
||||
buttons[next].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function bindSettingsSearch(modalEl, options = {}) {
|
||||
if (!modalEl || _boundModals.has(modalEl)) return;
|
||||
|
||||
const input = modalEl.querySelector('#settings-nav-search');
|
||||
const resultsEl = modalEl.querySelector('#settings-nav-search-results');
|
||||
|
||||
if (!input || !resultsEl) return;
|
||||
_boundModals.add(modalEl);
|
||||
|
||||
const isAdmin = typeof options.isAdmin === 'function'
|
||||
? options.isAdmin
|
||||
: () => false;
|
||||
|
||||
const openPanel = typeof options.openPanel === 'function'
|
||||
? options.openPanel
|
||||
: () => {};
|
||||
|
||||
let activeIndex = -1;
|
||||
|
||||
function reset() {
|
||||
input.value = '';
|
||||
activeIndex = -1;
|
||||
clearResults(resultsEl);
|
||||
}
|
||||
|
||||
function activateResult(button) {
|
||||
const panelId = button?.dataset?.settingsSearchResult;
|
||||
if (!panelId || !getSettingsPanel(panelId)) return;
|
||||
|
||||
openPanel(panelId);
|
||||
reset();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const query = input.value.trim();
|
||||
activeIndex = -1;
|
||||
resultsEl.replaceChildren();
|
||||
|
||||
if (!query) {
|
||||
resultsEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = searchSettingsPanels(query, {
|
||||
isAdmin: isAdmin(),
|
||||
});
|
||||
|
||||
if (!matches.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'settings-search-empty';
|
||||
empty.textContent = 'No settings found';
|
||||
resultsEl.appendChild(empty);
|
||||
resultsEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const panel of matches) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'settings-search-result';
|
||||
button.setAttribute('data-settings-search-result', panel.id);
|
||||
button.setAttribute('role', 'option');
|
||||
button.setAttribute('aria-selected', 'false');
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'settings-search-result-label';
|
||||
label.textContent = panel.label;
|
||||
|
||||
const group = document.createElement('span');
|
||||
group.className = 'settings-search-result-group';
|
||||
group.textContent = groupLabelFor(panel);
|
||||
|
||||
button.append(label, group);
|
||||
button.addEventListener('click', () => activateResult(button));
|
||||
resultsEl.appendChild(button);
|
||||
}
|
||||
|
||||
resultsEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
input.addEventListener('input', render);
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
if (input.value.trim()) render();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', event => {
|
||||
const buttons = getResultButtons(resultsEl);
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (!input.value && resultsEl.classList.contains('hidden')) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!buttons.length) return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex = setActiveResult(resultsEl, activeIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex = setActiveResult(resultsEl, activeIndex - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const target = buttons[activeIndex >= 0 ? activeIndex : 0];
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
activateResult(target);
|
||||
}
|
||||
});
|
||||
|
||||
modalEl.addEventListener('mousedown', event => {
|
||||
if (event.target === input || resultsEl.contains(event.target)) return;
|
||||
clearResults(resultsEl);
|
||||
activeIndex = -1;
|
||||
});
|
||||
|
||||
return { reset, render };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
const STORAGE_WIDTH = 'odysseus-settings-sidebar-width';
|
||||
const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed';
|
||||
|
||||
export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220;
|
||||
export const SETTINGS_SIDEBAR_MIN_WIDTH = 150;
|
||||
export const SETTINGS_SIDEBAR_MAX_WIDTH = 340;
|
||||
export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110;
|
||||
|
||||
const _bound = new WeakSet();
|
||||
|
||||
function clampWidth(value) {
|
||||
const width = Number(value);
|
||||
if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
||||
return Math.max(
|
||||
SETTINGS_SIDEBAR_MIN_WIDTH,
|
||||
Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width),
|
||||
);
|
||||
}
|
||||
|
||||
function readStoredWidth() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_WIDTH);
|
||||
if (stored == null || String(stored).trim() === '') {
|
||||
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
||||
}
|
||||
return clampWidth(stored);
|
||||
} catch {
|
||||
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredCollapsed() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_COLLAPSED) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storeWidth(width) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_WIDTH, String(Math.round(width)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function storeCollapsed(collapsed) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function syncResizeHandleAria(modalEl, width = null) {
|
||||
const handle = modalEl?.querySelector('#settings-sidebar-resize-handle');
|
||||
if (!handle) return;
|
||||
|
||||
const sidebar = modalEl.querySelector('.settings-sidebar');
|
||||
const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed');
|
||||
|
||||
const current = collapsed
|
||||
? SETTINGS_SIDEBAR_MIN_WIDTH
|
||||
: clampWidth(
|
||||
width ?? sidebar?.getBoundingClientRect?.().width
|
||||
?? SETTINGS_SIDEBAR_DEFAULT_WIDTH
|
||||
);
|
||||
|
||||
handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH));
|
||||
handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH));
|
||||
handle.setAttribute('aria-valuenow', String(Math.round(current)));
|
||||
}
|
||||
|
||||
function isDesktopSidebarMode(modalEl) {
|
||||
const content = modalEl?.querySelector('.settings-modal-content');
|
||||
if (!content) return false;
|
||||
|
||||
// Mirrors the existing container breakpoint where the sidebar becomes a
|
||||
// horizontal rail. Resizing/collapse applies only to the vertical desktop
|
||||
// navigation layout.
|
||||
return content.getBoundingClientRect().width > 620;
|
||||
}
|
||||
|
||||
export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) {
|
||||
const sidebar = modalEl?.querySelector('.settings-sidebar');
|
||||
if (!sidebar) return false;
|
||||
|
||||
const next = collapsed === true;
|
||||
sidebar.classList.toggle('settings-sidebar-collapsed', next);
|
||||
|
||||
const toggle = sidebar.querySelector('#settings-sidebar-toggle');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', next ? 'false' : 'true');
|
||||
toggle.setAttribute(
|
||||
'aria-label',
|
||||
next ? 'Expand settings navigation' : 'Collapse settings navigation',
|
||||
);
|
||||
toggle.title = next
|
||||
? 'Expand settings navigation'
|
||||
: 'Collapse settings navigation';
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
const width = clampWidth(options.width ?? readStoredWidth());
|
||||
sidebar.style.setProperty('--settings-sidebar-width', `${width}px`);
|
||||
syncResizeHandleAria(modalEl, width);
|
||||
} else {
|
||||
syncResizeHandleAria(modalEl);
|
||||
}
|
||||
|
||||
if (options.persist !== false) storeCollapsed(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setSettingsSidebarWidth(modalEl, width, options = {}) {
|
||||
const sidebar = modalEl?.querySelector('.settings-sidebar');
|
||||
if (!sidebar) return null;
|
||||
|
||||
const next = clampWidth(width);
|
||||
sidebar.style.setProperty('--settings-sidebar-width', `${next}px`);
|
||||
syncResizeHandleAria(modalEl, next);
|
||||
|
||||
if (options.persist !== false) storeWidth(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function bindSettingsSidebar(modalEl) {
|
||||
if (!modalEl || _bound.has(modalEl)) return;
|
||||
_bound.add(modalEl);
|
||||
|
||||
const sidebar = modalEl.querySelector('.settings-sidebar');
|
||||
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
|
||||
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
|
||||
|
||||
if (!sidebar || !handle || !toggle) return;
|
||||
|
||||
setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false });
|
||||
setSettingsSidebarCollapsed(
|
||||
modalEl,
|
||||
readStoredCollapsed(),
|
||||
{ persist: false },
|
||||
);
|
||||
|
||||
toggle.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!isDesktopSidebarMode(modalEl)) return;
|
||||
|
||||
const collapsed = sidebar.classList.contains('settings-sidebar-collapsed');
|
||||
setSettingsSidebarCollapsed(modalEl, !collapsed);
|
||||
});
|
||||
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
function stopResize() {
|
||||
if (!sidebar.classList.contains('settings-sidebar-resizing')) return;
|
||||
|
||||
sidebar.classList.remove('settings-sidebar-resizing');
|
||||
document.body.classList.remove('settings-sidebar-resize-active');
|
||||
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', stopResize);
|
||||
|
||||
const width = sidebar.getBoundingClientRect().width;
|
||||
if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
|
||||
setSettingsSidebarCollapsed(modalEl, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
|
||||
setSettingsSidebarWidth(modalEl, width);
|
||||
storeCollapsed(false);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
const rawWidth = startWidth + (event.clientX - startX);
|
||||
|
||||
if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
|
||||
sidebar.style.setProperty(
|
||||
'--settings-sidebar-width',
|
||||
`${Math.max(34, rawWidth)}px`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
|
||||
setSettingsSidebarWidth(modalEl, rawWidth, { persist: false });
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerdown', event => {
|
||||
if (!isDesktopSidebarMode(modalEl)) return;
|
||||
|
||||
event.preventDefault();
|
||||
startX = event.clientX;
|
||||
startWidth = sidebar.getBoundingClientRect().width;
|
||||
|
||||
sidebar.classList.remove('settings-sidebar-collapsed');
|
||||
sidebar.classList.add('settings-sidebar-resizing');
|
||||
document.body.classList.add('settings-sidebar-resize-active');
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', stopResize);
|
||||
});
|
||||
|
||||
handle.addEventListener('keydown', event => {
|
||||
if (!isDesktopSidebarMode(modalEl)) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggle.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (sidebar.classList.contains('settings-sidebar-collapsed')) {
|
||||
setSettingsSidebarCollapsed(modalEl, false);
|
||||
}
|
||||
|
||||
const current = sidebar.getBoundingClientRect().width;
|
||||
const delta = event.key === 'ArrowLeft' ? -16 : 16;
|
||||
|
||||
// Once keyboard resizing reaches the declared minimum, another ArrowLeft
|
||||
// collapses the rail. Without this explicit boundary transition the width
|
||||
// setter clamps 134px back to 150px forever, making keyboard collapse via
|
||||
// ArrowLeft unreachable.
|
||||
if (
|
||||
event.key === 'ArrowLeft'
|
||||
&& current <= SETTINGS_SIDEBAR_MIN_WIDTH
|
||||
) {
|
||||
setSettingsSidebarCollapsed(modalEl, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingsSidebarWidth(modalEl, current + delta);
|
||||
});
|
||||
}
|
||||
@@ -1128,10 +1128,67 @@ function _renderTestLog(logEl, verdictEl, job, card, name) {
|
||||
else if (ev.type === 'agent_step') add('— round ' + ev.round + ' —', 'skill-test-round');
|
||||
else if (ev.type === 'tool_start') add('▸ ' + ev.tool + ' ' + String(ev.command || '').slice(0, 200), 'skill-test-tool');
|
||||
else if (ev.type === 'tool_output') add(String(ev.output || '').slice(0, 500), 'skill-test-out');
|
||||
else if (ev.type === 'approval_granted' || ev.type === 'approval_denied') add(ev.text || '', 'skill-test-meta');
|
||||
else if (ev.type === 'say') add(ev.text || '', 'skill-test-say');
|
||||
else if (ev.type === 'evaluating') add('Evaluating run…', 'skill-test-meta');
|
||||
else if (ev.type === 'error') add('Error: ' + (ev.error || 'run failed'), 'skill-test-err');
|
||||
}
|
||||
if (job.status === 'awaiting_approval' && job.approval) {
|
||||
const approval = job.approval;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'skill-test-approval';
|
||||
const question = document.createElement('div');
|
||||
question.className = 'skill-test-meta';
|
||||
question.textContent = approval.question || 'Allow this exact action once?';
|
||||
box.appendChild(question);
|
||||
if (approval.action) {
|
||||
const action = document.createElement('pre');
|
||||
action.className = 'skill-test-out';
|
||||
action.textContent = [
|
||||
approval.action.tool || 'tool',
|
||||
approval.action.content || '',
|
||||
Array.isArray(approval.action.effects)
|
||||
? `Effects: ${approval.action.effects.join(', ')}`
|
||||
: '',
|
||||
approval.action.workspace ? `Workspace: ${approval.action.workspace}` : '',
|
||||
approval.action.digest ? `Approval fingerprint: ${approval.action.digest}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
box.appendChild(action);
|
||||
}
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'modal-footer';
|
||||
const decide = async (decision) => {
|
||||
actions.querySelectorAll('button').forEach(btn => { btn.disabled = true; });
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API}/api/skills/${encodeURIComponent(name)}/test-approval`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approval_id: approval.approval_id, decision }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
await _testSkill(card, name, false);
|
||||
} catch (error) {
|
||||
add(`Approval failed: ${error.message || error}`, 'skill-test-err');
|
||||
actions.querySelectorAll('button').forEach(btn => { btn.disabled = false; });
|
||||
}
|
||||
};
|
||||
for (const [decision, label, cls] of [
|
||||
['deny', 'Deny', 'confirm-btn confirm-btn-secondary'],
|
||||
['approve', 'Allow once', 'confirm-btn confirm-btn-primary'],
|
||||
]) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = cls;
|
||||
button.textContent = label;
|
||||
button.addEventListener('click', () => decide(decision));
|
||||
actions.appendChild(button);
|
||||
}
|
||||
box.appendChild(actions);
|
||||
logEl.appendChild(box);
|
||||
}
|
||||
if (job.status === 'running') add('…running (you can close this — it keeps going)', 'skill-test-meta');
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
if (job.status === 'done' && job.verdict) _renderTestVerdict(verdictEl, job.verdict, card, name);
|
||||
|
||||
@@ -16,7 +16,7 @@ import modelsModule from './models.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
import documentModule from './document.js?v=20260815approvalsave1';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
@@ -2027,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
|
||||
const messageCount = Number(session?.message_count || 0);
|
||||
const totalTokens = Number(session?.total_tokens || 0);
|
||||
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
||||
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||
const costLine = costTracked
|
||||
? (cost > 0
|
||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||
: 'Estimated local cost: unavailable or zero')
|
||||
: 'Estimated local cost: not tracked for this endpoint';
|
||||
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||
const costLine = cost > 0
|
||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||
: costTracked
|
||||
? 'Estimated local cost: unavailable or zero'
|
||||
: 'Estimated local cost: no billable usage recorded';
|
||||
|
||||
slashReply(`<pre>${[
|
||||
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
||||
|
||||
+36
-16
@@ -5,10 +5,29 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
|
||||
<title>Odysseus — Login</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="manifest" href="static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
function computeAppBasePath() {
|
||||
var path = window.location.pathname || '/login';
|
||||
if (path === '/login') {
|
||||
return '';
|
||||
}
|
||||
if (path.endsWith('/login')) {
|
||||
return path.slice(0, -'/login'.length).replace(/\/+$/, '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
var appBasePath = computeAppBasePath();
|
||||
window.__odysseusLoginBasePath = appBasePath;
|
||||
window.__odysseusLoginAppUrl = function(path) {
|
||||
var normalizedPath = String(path || '/');
|
||||
if (!normalizedPath.startsWith('/')) {
|
||||
normalizedPath = '/' + normalizedPath;
|
||||
}
|
||||
return appBasePath + normalizedPath;
|
||||
};
|
||||
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
|
||||
// static/js/theme.js so login picks the same default pattern as the
|
||||
// main app for users who never explicitly chose one.
|
||||
@@ -85,8 +104,8 @@
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
||||
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
||||
localStorage — renders the login page in the same palette as the rest
|
||||
@@ -300,9 +319,10 @@
|
||||
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(async () => {
|
||||
const appUrl = window.__odysseusLoginAppUrl || ((path) => path);
|
||||
// Load version
|
||||
try {
|
||||
const vr = await fetch('/api/version');
|
||||
const vr = await fetch(appUrl('/api/version'));
|
||||
if (vr.ok) {
|
||||
const vd = await vr.json();
|
||||
document.getElementById('version-label').textContent = 'v' + vd.version;
|
||||
@@ -363,12 +383,12 @@
|
||||
|
||||
// Check auth status and fetch policy in parallel, but don't block the
|
||||
// authenticated redirect on the policy response.
|
||||
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
|
||||
const policyPromise = fetch(appUrl('/api/auth/policy'), { credentials: 'same-origin' }).catch(() => null);
|
||||
try {
|
||||
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
const statusRes = await fetch(appUrl('/api/auth/status'), { credentials: 'same-origin' });
|
||||
const data = await statusRes.json();
|
||||
if (data.authenticated) {
|
||||
window.location.replace('/');
|
||||
window.location.replace(appUrl('/'));
|
||||
return;
|
||||
}
|
||||
signupAllowed = !!data.signup_enabled;
|
||||
@@ -405,7 +425,7 @@
|
||||
if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
|
||||
const remember = document.getElementById('remember').checked;
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
const res = await fetch(appUrl('/api/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
@@ -449,7 +469,7 @@
|
||||
|
||||
// Setup or signup first
|
||||
if (mode === 'setup' || mode === 'signup') {
|
||||
const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/signup';
|
||||
const endpoint = mode === 'setup' ? appUrl('/api/auth/setup') : appUrl('/api/auth/signup');
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
@@ -477,19 +497,19 @@
|
||||
submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
|
||||
submitBtn.disabled = true;
|
||||
Promise.all([
|
||||
fetch('/api/sessions', { credentials: 'same-origin' }).then(r => r.json()),
|
||||
fetch('/api/auth/features', { credentials: 'same-origin' }).then(r => r.json()),
|
||||
fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()),
|
||||
fetch(appUrl('/api/sessions'), { credentials: 'same-origin' }).then(r => r.json()),
|
||||
fetch(appUrl('/api/auth/features'), { credentials: 'same-origin' }).then(r => r.json()),
|
||||
fetch(appUrl('/api/auth/settings'), { credentials: 'same-origin' }).then(r => r.json()),
|
||||
]).then(([sess, feat, sett]) => {
|
||||
sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
|
||||
sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
|
||||
sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
|
||||
}).catch(() => {}).finally(() => { window.location.replace('/'); });
|
||||
}).catch(() => {}).finally(() => { window.location.replace(appUrl('/')); });
|
||||
}
|
||||
async function doLogin(totpCode) {
|
||||
const loginBody = { username, password, remember };
|
||||
if (totpCode) loginBody.totp_code = totpCode;
|
||||
const res = await fetch('/api/auth/login', {
|
||||
const res = await fetch(appUrl('/api/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
@@ -587,7 +607,7 @@ if (window.visualViewport) {
|
||||
// `applyBgPattern` would never fire on its own. We call it directly
|
||||
// here against the pattern the bootstrap already chose.
|
||||
try {
|
||||
const tm = await import('/static/js/theme.js');
|
||||
const tm = await import((window.__odysseusLoginAppUrl || ((path) => path))('/static/js/theme.js'));
|
||||
const pattern = window.__loginBgPattern;
|
||||
if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
|
||||
} catch (e) {
|
||||
|
||||
+271
-8
@@ -9685,11 +9685,11 @@ body.fullwidth-chat .chat-history {
|
||||
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* -17px (was -15) nudges the terminating dot 2px further left so it
|
||||
sits flush with the thread's left rail when the search node is
|
||||
expanded. This is the "big glow dot at the bottom" the user sees
|
||||
after a web_search step. */
|
||||
left: -17px;
|
||||
/* Centre the 6px dot on the rail: the rail sits at left:5px + 2px wide,
|
||||
so its centre is 6px from the thread's left edge and the dot's left
|
||||
edge belongs at 3px — i.e. 3px - padding-left. Keep this in step with
|
||||
.agent-thread-dot and with the mobile padding override below. */
|
||||
left: -19px;
|
||||
bottom: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -9958,6 +9958,11 @@ body.fullwidth-chat .chat-history {
|
||||
left: -16px;
|
||||
top: 10px;
|
||||
}
|
||||
/* padding-left drops to 18px here, so the terminating dot needs its own
|
||||
offset (3px - 18px) to stay centred on the rail. */
|
||||
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
|
||||
left: -15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */
|
||||
@@ -23600,7 +23605,7 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
|
||||
/* ===== Settings Modal Layout ===== */
|
||||
.settings-modal-content {
|
||||
width: min(720px, 92vw);
|
||||
width: min(1040px, 94vw);
|
||||
max-height: 85vh;
|
||||
padding: 0;
|
||||
container-type: inline-size;
|
||||
@@ -23654,10 +23659,13 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: var(--settings-sidebar-width, 220px);
|
||||
flex: 0 0 var(--settings-sidebar-width, 220px);
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
transition: width 0.16s ease, flex-basis 0.16s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -41136,3 +41144,258 @@ body.theme-frosted .modal {
|
||||
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
|
||||
.compare-pane { min-height: 60dvh; }
|
||||
}
|
||||
|
||||
/* ── Settings navigation / finder ────────────────────────────── */
|
||||
.settings-sidebar-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.settings-nav-search-wrap {
|
||||
position: relative;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.settings-nav-search {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 5px 9px 5px 29px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-nav-search:focus {
|
||||
border-color: var(--accent, var(--red));
|
||||
}
|
||||
|
||||
.settings-nav-search::placeholder {
|
||||
color: var(--fg);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.settings-nav-search-icon {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 9px;
|
||||
left: 9px;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-nav-search-results {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.settings-search-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-search-result:hover,
|
||||
.settings-search-result.active {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
|
||||
.settings-search-result-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-search-result-group {
|
||||
flex: 0 0 auto;
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-search-empty {
|
||||
padding: 8px;
|
||||
color: var(--fg);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-sidebar-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.settings-sidebar-resize-handle:hover,
|
||||
.settings-sidebar-resizing .settings-sidebar-resize-handle {
|
||||
background: var(--accent, var(--red));
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.settings-sidebar-resizing {
|
||||
transition: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body.settings-sidebar-resize-active {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-sidebar-toggle {
|
||||
position: absolute;
|
||||
z-index: 25;
|
||||
top: 9px;
|
||||
right: -13px;
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.settings-sidebar:hover .settings-sidebar-toggle,
|
||||
.settings-sidebar-toggle:focus-visible,
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-sidebar-toggle:hover,
|
||||
.settings-sidebar-toggle:focus-visible {
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
|
||||
.settings-sidebar-toggle-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed {
|
||||
width: 34px;
|
||||
flex-basis: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
|
||||
top: 10px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-collapse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-expand {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The existing narrow/snapped layout is a horizontal tab rail. Sidebar
|
||||
resizing/collapse applies only to the vertical desktop layout. */
|
||||
@container settings-modal (max-width: 620px) {
|
||||
.settings-sidebar {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.settings-sidebar-content {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.settings-nav-search-wrap,
|
||||
.settings-sidebar-resize-handle,
|
||||
.settings-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed {
|
||||
width: auto;
|
||||
flex-basis: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.settings-sidebar {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.settings-sidebar-content {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.settings-nav-search-wrap,
|
||||
.settings-sidebar-resize-handle,
|
||||
.settings-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
class ClassList {
|
||||
constructor() { this.values = new Set(); }
|
||||
add(...names) { names.filter(Boolean).forEach(name => this.values.add(name)); }
|
||||
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||
contains(name) { return this.values.has(name); }
|
||||
toggle(name, force) {
|
||||
if (force === undefined) force = !this.contains(name);
|
||||
force ? this.add(name) : this.remove(name);
|
||||
return force;
|
||||
}
|
||||
}
|
||||
|
||||
class Style {
|
||||
constructor() { this.values = {}; this.display = ''; this.cssText = ''; }
|
||||
setProperty(name, value) { this.values[name] = value; this[name] = value; }
|
||||
removeProperty(name) { delete this.values[name]; delete this[name]; }
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor(tagName, documentRef) {
|
||||
this.tagName = String(tagName || 'div').toUpperCase();
|
||||
this.ownerDocument = documentRef;
|
||||
this.children = [];
|
||||
this.parentElement = null;
|
||||
this.attributes = {};
|
||||
this.dataset = {};
|
||||
this.classList = new ClassList();
|
||||
this.style = new Style();
|
||||
this._listeners = new Map();
|
||||
this._innerHTML = '';
|
||||
this._textContent = '';
|
||||
}
|
||||
set id(value) { this.attributes.id = String(value); }
|
||||
get id() { return this.attributes.id || ''; }
|
||||
set className(value) {
|
||||
this.classList.values = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() { return Array.from(this.classList.values).join(' '); }
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = String(value || '');
|
||||
if (!this._innerHTML) {
|
||||
this.children.forEach(child => { child.parentElement = null; });
|
||||
this.children = [];
|
||||
}
|
||||
}
|
||||
get innerHTML() { return this._innerHTML; }
|
||||
set textContent(value) {
|
||||
this._textContent = String(value ?? '');
|
||||
this.children.forEach(child => { child.parentElement = null; });
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._textContent + this.children.map(child => child.textContent).join('');
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
const text = String(value);
|
||||
this.attributes[name] = text;
|
||||
if (name === 'class') this.className = text;
|
||||
if (name.startsWith('data-')) {
|
||||
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||
this.dataset[key] = text;
|
||||
}
|
||||
}
|
||||
getAttribute(name) { return this.attributes[name] ?? null; }
|
||||
appendChild(child) {
|
||||
child.parentElement = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
append(...children) { children.forEach(child => this.appendChild(child)); }
|
||||
replaceChildren(...children) {
|
||||
this.children.forEach(child => { child.parentElement = null; });
|
||||
this.children = [];
|
||||
children.forEach(child => this.appendChild(child));
|
||||
}
|
||||
contains(candidate) {
|
||||
if (candidate === this) return true;
|
||||
return descendants(this).includes(candidate);
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
const width = Number.parseFloat(
|
||||
this.style.values['--settings-sidebar-width']
|
||||
|| this.style.width
|
||||
// This is only a desktop-mode fixture (>620px), not a CSS assertion.
|
||||
|| (this.classList.contains('settings-modal-content') ? '800' : '220'),
|
||||
);
|
||||
return { width, height: 600, left: 0, right: width, top: 0, bottom: 600 };
|
||||
}
|
||||
addEventListener(type, handler, options = {}) {
|
||||
if (!this._listeners.has(type)) this._listeners.set(type, []);
|
||||
this._listeners.get(type).push({ handler, once: !!options.once });
|
||||
}
|
||||
removeEventListener(type, handler) {
|
||||
const entries = this._listeners.get(type) || [];
|
||||
this._listeners.set(type, entries.filter(entry => entry.handler !== handler));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
event.target ||= this;
|
||||
event.currentTarget = this;
|
||||
const entries = [...(this._listeners.get(event.type) || [])];
|
||||
for (const entry of entries) {
|
||||
entry.handler.call(this, event);
|
||||
if (entry.once) this.removeEventListener(event.type, entry.handler);
|
||||
}
|
||||
}
|
||||
click() {
|
||||
this.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
|
||||
}
|
||||
matches(selector) { return matchesSelector(this, selector); }
|
||||
querySelector(selector) { return queryAll(this, selector)[0] || null; }
|
||||
querySelectorAll(selector) { return queryAll(this, selector); }
|
||||
closest(selector) {
|
||||
let node = this;
|
||||
while (node) {
|
||||
if (matchesSelector(node, selector)) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function simpleMatch(element, selector) {
|
||||
const text = selector.trim();
|
||||
if (!text) return false;
|
||||
const id = text.match(/#([\w-]+)/);
|
||||
if (id && element.id !== id[1]) return false;
|
||||
for (const match of text.matchAll(/\.([\w-]+)/g)) {
|
||||
if (!element.classList.contains(match[1])) return false;
|
||||
}
|
||||
for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
|
||||
const actual = element.getAttribute(match[1]);
|
||||
if (actual === null) return false;
|
||||
if (match[2] !== undefined && actual !== match[2]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesSelector(element, selector) {
|
||||
return selector.split(',').some(part => simpleMatch(element, part));
|
||||
}
|
||||
|
||||
function descendants(root) {
|
||||
const result = [];
|
||||
const visit = node => {
|
||||
for (const child of node.children || []) {
|
||||
result.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function queryAll(root, selector) {
|
||||
const selectors = selector.split(',').map(item => item.trim()).filter(Boolean);
|
||||
const nodes = descendants(root);
|
||||
const result = [];
|
||||
for (const candidate of nodes) {
|
||||
if (selectors.some(sel => simpleMatch(candidate, sel))) result.push(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class DocumentShim {
|
||||
constructor() {
|
||||
this.body = new Element('body', this);
|
||||
this.head = new Element('head', this);
|
||||
this.listeners = new Map();
|
||||
}
|
||||
createElement(tag) { return new Element(tag, this); }
|
||||
getElementById(id) {
|
||||
return [this.body, this.head, ...descendants(this.body), ...descendants(this.head)]
|
||||
.find(node => node.id === id) || null;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return [...queryAll(this.body, selector), ...queryAll(this.head, selector)];
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, []);
|
||||
this.listeners.get(type).push(handler);
|
||||
}
|
||||
removeEventListener(type, handler) {
|
||||
this.listeners.set(type, (this.listeners.get(type) || []).filter(item => item !== handler));
|
||||
}
|
||||
dispatch(type, event) {
|
||||
for (const handler of [...(this.listeners.get(type) || [])]) handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFixture(document) {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'settings-modal';
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
modal.appendChild(header);
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.className = 'close-btn';
|
||||
header.appendChild(close);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'settings-modal-content modal-content';
|
||||
modal.appendChild(content);
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'settings-sidebar';
|
||||
content.appendChild(nav);
|
||||
|
||||
const sidebarToggle = document.createElement('button');
|
||||
sidebarToggle.id = 'settings-sidebar-toggle';
|
||||
nav.appendChild(sidebarToggle);
|
||||
|
||||
const sidebarHandle = document.createElement('div');
|
||||
sidebarHandle.id = 'settings-sidebar-resize-handle';
|
||||
nav.appendChild(sidebarHandle);
|
||||
|
||||
const sidebarContent = document.createElement('div');
|
||||
sidebarContent.className = 'settings-sidebar-content';
|
||||
nav.appendChild(sidebarContent);
|
||||
|
||||
const finder = document.createElement('div');
|
||||
sidebarContent.appendChild(finder);
|
||||
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.id = 'settings-nav-search';
|
||||
finder.appendChild(searchInput);
|
||||
|
||||
const searchResults = document.createElement('div');
|
||||
searchResults.id = 'settings-nav-search-results';
|
||||
searchResults.classList.add('hidden');
|
||||
finder.appendChild(searchResults);
|
||||
|
||||
const panels = document.createElement('div');
|
||||
content.appendChild(panels);
|
||||
|
||||
const makeTab = (id, active = false) => {
|
||||
const button = document.createElement('button');
|
||||
button.setAttribute('data-settings-tab', id);
|
||||
if (active) button.classList.add('active');
|
||||
sidebarContent.appendChild(button);
|
||||
const panel = document.createElement('section');
|
||||
panel.setAttribute('data-settings-panel', id);
|
||||
if (!active) panel.classList.add('hidden');
|
||||
panels.appendChild(panel);
|
||||
return { button, panel };
|
||||
};
|
||||
|
||||
const panelIds = [
|
||||
'services',
|
||||
'added-models',
|
||||
'ai',
|
||||
'search',
|
||||
'integrations',
|
||||
'email',
|
||||
'reminders',
|
||||
'appearance',
|
||||
'shortcuts',
|
||||
'account',
|
||||
'tools',
|
||||
'users',
|
||||
'system',
|
||||
];
|
||||
|
||||
const settingsPanels = Object.fromEntries(
|
||||
panelIds.map((id, index) => [id, makeTab(id, index === 0)]),
|
||||
);
|
||||
|
||||
return {
|
||||
modal,
|
||||
header,
|
||||
close,
|
||||
content,
|
||||
services: settingsPanels.services,
|
||||
appearance: settingsPanels.appearance,
|
||||
system: settingsPanels.system,
|
||||
settingsPanels,
|
||||
searchInput,
|
||||
searchResults,
|
||||
sidebar: nav,
|
||||
sidebarToggle,
|
||||
sidebarHandle,
|
||||
};
|
||||
}
|
||||
|
||||
function moduleSource(relativePath) {
|
||||
let source = fs.readFileSync(
|
||||
path.join(__dirname, '../../static/js/settings', relativePath),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The production files are real ES modules. This lightweight VM harness
|
||||
// removes imports because dependencies are loaded into the same context,
|
||||
// but each module still needs its own lexical scope so private const/let
|
||||
// bindings do not collide across modules.
|
||||
source = source.replace(/^\s*import[\s\S]*?;\s*$/gm, '');
|
||||
|
||||
// Preserve exported API on globalThis while keeping all non-exported
|
||||
// bindings private inside the module block below.
|
||||
source = source
|
||||
.replace(
|
||||
/\bexport\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g,
|
||||
'globalThis.$1 = function $1(',
|
||||
)
|
||||
.replace(
|
||||
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=/g,
|
||||
'globalThis.$1 =',
|
||||
)
|
||||
.replace(
|
||||
/\bexport\s+let\s+([A-Za-z_$][\w$]*)\s*=/g,
|
||||
'globalThis.$1 =',
|
||||
)
|
||||
.replace(
|
||||
/\bexport\s+var\s+([A-Za-z_$][\w$]*)\s*=/g,
|
||||
'globalThis.$1 =',
|
||||
);
|
||||
|
||||
return `{\n${source}\n}`;
|
||||
}
|
||||
|
||||
(function runTests() {
|
||||
const document = new DocumentShim();
|
||||
const fixture = buildFixture(document);
|
||||
const dragCalls = [];
|
||||
const dockCalls = [];
|
||||
const removedWindowListeners = [];
|
||||
|
||||
const storage = new Map();
|
||||
const context = {
|
||||
console,
|
||||
document,
|
||||
localStorage: {
|
||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||
setItem(key, value) { storage.set(key, String(value)); },
|
||||
removeItem(key) { storage.delete(key); },
|
||||
},
|
||||
window: {
|
||||
removeEventListener: (...args) => removedWindowListeners.push(args),
|
||||
addEventListener() {},
|
||||
},
|
||||
makeWindowDraggable: (...args) => dragCalls.push(args),
|
||||
clearDockSide: (...args) => dockCalls.push(args),
|
||||
setTimeout: callback => { callback(); return 1; },
|
||||
WeakSet,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(moduleSource('registry.js'), context, { filename: 'registry.js' });
|
||||
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
|
||||
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
|
||||
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
|
||||
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
|
||||
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
|
||||
|
||||
const results = [];
|
||||
const check = (test, pass, detail = '') => results.push({ test, pass: Boolean(pass), detail });
|
||||
|
||||
const registryPanelIds = vm.runInContext(
|
||||
'SETTINGS_PANELS.map(panel => panel.id).join(",")',
|
||||
context,
|
||||
);
|
||||
const registryGroupIds = vm.runInContext(
|
||||
'SETTINGS_GROUPS.map(group => group.id).join(",")',
|
||||
context,
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry preserves the existing sidebar panel order',
|
||||
registryPanelIds === [
|
||||
'services',
|
||||
'added-models',
|
||||
'ai',
|
||||
'search',
|
||||
'integrations',
|
||||
'email',
|
||||
'reminders',
|
||||
'appearance',
|
||||
'shortcuts',
|
||||
'account',
|
||||
'tools',
|
||||
'users',
|
||||
'system',
|
||||
].join(','),
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry defines the intended information-architecture groups',
|
||||
registryGroupIds === [
|
||||
'models',
|
||||
'communications',
|
||||
'experience',
|
||||
'account',
|
||||
'administration',
|
||||
].join(','),
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry keeps services, models, integrations and admin panels on the existing admin controller',
|
||||
['services', 'added-models', 'integrations', 'tools', 'users', 'system']
|
||||
.every(id => context.isAdminManagedSettingsTab(id))
|
||||
&& ['ai', 'search', 'email', 'reminders', 'appearance', 'shortcuts', 'account']
|
||||
.every(id => !context.isAdminManagedSettingsTab(id)),
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry distinguishes admin-only visibility from admin-controlled routing',
|
||||
['tools', 'users', 'system'].every(id => context.isAdminOnlySettingsTab(id))
|
||||
&& ['services', 'added-models', 'integrations']
|
||||
.every(id => !context.isAdminOnlySettingsTab(id)),
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry provides search metadata without owning search UI',
|
||||
context.getSettingsPanelSearchText('appearance').includes('theme')
|
||||
&& context.getSettingsPanelSearchText('email').includes('smtp')
|
||||
&& context.getSettingsPanelSearchText('missing') === '',
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry exposes group membership in sidebar order',
|
||||
context.getSettingsPanelsForGroup('communications')
|
||||
.map(panel => panel.id)
|
||||
.join(',') === 'integrations,email,reminders',
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings registry matches every production-style tab and panel in the DOM fixture',
|
||||
context.getSettingsRegistryIssues(fixture.modal).length === 0,
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings search resolves metadata terms in registry order',
|
||||
context.searchSettingsPanels('provider', { isAdmin: true })
|
||||
.map(panel => panel.id)
|
||||
.join(',') === 'services,added-models,search',
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings search excludes admin-only panels for non-admin users',
|
||||
context.searchSettingsPanels('agent tools', { isAdmin: false }).length === 0
|
||||
&& context.searchSettingsPanels('agent tools', { isAdmin: true })
|
||||
.map(panel => panel.id)
|
||||
.join(',') === 'tools',
|
||||
);
|
||||
|
||||
check(
|
||||
'Settings search requires all query terms',
|
||||
context.searchSettingsPanels('appearance theme', { isAdmin: false })
|
||||
.map(panel => panel.id)
|
||||
.join(',') === 'appearance',
|
||||
);
|
||||
|
||||
let searchedPanel = null;
|
||||
context.bindSettingsSearch(fixture.modal, {
|
||||
isAdmin: () => false,
|
||||
openPanel(tab) { searchedPanel = tab; },
|
||||
});
|
||||
|
||||
fixture.searchInput.value = 'theme';
|
||||
fixture.searchInput.dispatchEvent({
|
||||
type: 'input',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'Settings finder renders matching production registry results',
|
||||
!fixture.searchResults.classList.contains('hidden')
|
||||
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1
|
||||
&& fixture.searchResults.querySelector('[data-settings-search-result]').dataset.settingsSearchResult === 'appearance',
|
||||
);
|
||||
|
||||
const finderOutsideTarget = document.createElement('div');
|
||||
fixture.content.appendChild(finderOutsideTarget);
|
||||
|
||||
fixture.modal.dispatchEvent({
|
||||
type: 'mousedown',
|
||||
target: finderOutsideTarget,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'Settings finder click-away hides results while retaining the query',
|
||||
fixture.searchInput.value === 'theme'
|
||||
&& fixture.searchResults.classList.contains('hidden'),
|
||||
);
|
||||
|
||||
fixture.searchInput.dispatchEvent({
|
||||
type: 'focus',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'Settings finder refocus restores results for an unchanged retained query',
|
||||
fixture.searchInput.value === 'theme'
|
||||
&& !fixture.searchResults.classList.contains('hidden')
|
||||
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1,
|
||||
);
|
||||
|
||||
fixture.searchInput.dispatchEvent({
|
||||
type: 'keydown',
|
||||
key: 'Enter',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'Settings finder Enter opens the first result and resets the finder',
|
||||
searchedPanel === 'appearance'
|
||||
&& fixture.searchInput.value === ''
|
||||
&& fixture.searchResults.classList.contains('hidden'),
|
||||
);
|
||||
|
||||
searchedPanel = null;
|
||||
fixture.searchInput.value = 'agent tools';
|
||||
fixture.searchInput.dispatchEvent({
|
||||
type: 'input',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'Settings finder does not expose admin-only results to non-admin users',
|
||||
fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 0
|
||||
&& fixture.searchResults.textContent !== '',
|
||||
);
|
||||
|
||||
fixture.searchInput.value = 'theme';
|
||||
fixture.searchInput.dispatchEvent({
|
||||
type: 'input',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
fixture.searchInput.dispatchEvent({
|
||||
type: 'keydown',
|
||||
key: 'Escape',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'Settings finder Escape clears results without closing Settings',
|
||||
fixture.searchInput.value === ''
|
||||
&& fixture.searchResults.classList.contains('hidden'),
|
||||
);
|
||||
|
||||
context.setSettingsSidebarWidth(fixture.modal, 280);
|
||||
check(
|
||||
'Settings sidebar width is clamped and applied through the production controller',
|
||||
fixture.sidebar.style.values['--settings-sidebar-width'] === '280px',
|
||||
);
|
||||
|
||||
const widthStorageKey = 'odysseus-settings-sidebar-width';
|
||||
|
||||
storage.clear();
|
||||
const firstRunSidebar = buildFixture(document);
|
||||
context.bindSettingsSidebar(firstRunSidebar.modal);
|
||||
check(
|
||||
'Settings sidebar first bind uses the declared 220px default when storage is empty',
|
||||
firstRunSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(widthStorageKey, '276');
|
||||
const storedSidebar = buildFixture(document);
|
||||
context.bindSettingsSidebar(storedSidebar.modal);
|
||||
check(
|
||||
'Settings sidebar first bind restores a valid stored width',
|
||||
storedSidebar.sidebar.style.values['--settings-sidebar-width'] === '276px',
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(widthStorageKey, 'not-a-number');
|
||||
const malformedSidebar = buildFixture(document);
|
||||
context.bindSettingsSidebar(malformedSidebar.modal);
|
||||
check(
|
||||
'Settings sidebar first bind falls back to default for malformed storage',
|
||||
malformedSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
storage.set(widthStorageKey, '10');
|
||||
const minimumSidebar = buildFixture(document);
|
||||
context.bindSettingsSidebar(minimumSidebar.modal);
|
||||
|
||||
storage.clear();
|
||||
storage.set(widthStorageKey, '999');
|
||||
const maximumSidebar = buildFixture(document);
|
||||
context.bindSettingsSidebar(maximumSidebar.modal);
|
||||
|
||||
check(
|
||||
'Settings sidebar first bind clamps stored widths to declared bounds',
|
||||
minimumSidebar.sidebar.style.values['--settings-sidebar-width'] === '150px'
|
||||
&& maximumSidebar.sidebar.style.values['--settings-sidebar-width'] === '340px',
|
||||
);
|
||||
|
||||
context.setSettingsSidebarCollapsed(fixture.modal, true);
|
||||
check(
|
||||
'Settings sidebar collapse leaves the compact navigation rail state active',
|
||||
fixture.sidebar.classList.contains('settings-sidebar-collapsed')
|
||||
&& fixture.sidebarToggle.getAttribute('aria-label') === 'Expand settings navigation',
|
||||
);
|
||||
|
||||
context.setSettingsSidebarCollapsed(fixture.modal, false, { width: 260 });
|
||||
check(
|
||||
'Settings sidebar expansion restores the requested expanded width',
|
||||
!fixture.sidebar.classList.contains('settings-sidebar-collapsed')
|
||||
&& fixture.sidebar.style.values['--settings-sidebar-width'] === '260px',
|
||||
);
|
||||
|
||||
// Keyboard behavior must be exercised on a fixture that went through the
|
||||
// real binding path; direct controller calls above intentionally do not bind
|
||||
// event listeners.
|
||||
storage.clear();
|
||||
const keyboardSidebar = buildFixture(document);
|
||||
context.bindSettingsSidebar(keyboardSidebar.modal);
|
||||
|
||||
context.setSettingsSidebarWidth(
|
||||
keyboardSidebar.modal,
|
||||
150,
|
||||
{ persist: false },
|
||||
);
|
||||
|
||||
keyboardSidebar.sidebarHandle.dispatchEvent({
|
||||
type: 'keydown',
|
||||
key: 'ArrowLeft',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
|
||||
check(
|
||||
'ArrowLeft at the sidebar minimum collapses the rail instead of getting stuck at 150px',
|
||||
keyboardSidebar.sidebar.classList.contains('settings-sidebar-collapsed'),
|
||||
);
|
||||
|
||||
context.setSettingsSidebarCollapsed(
|
||||
keyboardSidebar.modal,
|
||||
false,
|
||||
{ width: 220, persist: false },
|
||||
);
|
||||
|
||||
check(
|
||||
'resizable Settings separator exposes its current ARIA range and value',
|
||||
keyboardSidebar.sidebarHandle.getAttribute('aria-valuemin') === '150'
|
||||
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuemax') === '340'
|
||||
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuenow') === '220',
|
||||
);
|
||||
|
||||
check('byId resolves elements through the production DOM helper', context.byId('settings-modal') === fixture.modal);
|
||||
|
||||
context.activateSettingsPanel(fixture.modal, 'appearance');
|
||||
check(
|
||||
'activateSettingsPanel switches sidebar and panel state together',
|
||||
fixture.appearance.button.classList.contains('active')
|
||||
&& !fixture.appearance.panel.classList.contains('hidden')
|
||||
&& !fixture.services.button.classList.contains('active')
|
||||
&& fixture.services.panel.classList.contains('hidden'),
|
||||
);
|
||||
check('getActiveSettingsTab reports the active panel', context.getActiveSettingsTab(fixture.modal) === 'appearance');
|
||||
|
||||
let activated = null;
|
||||
let delegated = null;
|
||||
context.bindSettingsNavigation(fixture.modal, {
|
||||
openAdminTab(tab) { delegated = tab; return tab === 'system'; },
|
||||
onPanelActivated(tab) { activated = tab; },
|
||||
});
|
||||
fixture.services.button.click();
|
||||
check(
|
||||
'normal navigation activates locally and notifies the coordinator',
|
||||
activated === 'services' && fixture.services.button.classList.contains('active'),
|
||||
);
|
||||
activated = null;
|
||||
fixture.system.button.click();
|
||||
check(
|
||||
'admin navigation delegates without performing a second local activation',
|
||||
delegated === 'system' && activated === null && fixture.services.button.classList.contains('active'),
|
||||
);
|
||||
|
||||
context.bindSettingsDrag(fixture.modal);
|
||||
check(
|
||||
'drag binding preserves the existing Settings drag contract',
|
||||
dragCalls.length === 1
|
||||
&& dragCalls[0][0] === fixture.modal
|
||||
&& dragCalls[0][1].content === fixture.content
|
||||
&& dragCalls[0][1].header === fixture.header
|
||||
&& dragCalls[0][1].enableDock === true
|
||||
&& dragCalls[0][1].skipSelector === 'button, input, select, .theme-opacity-wrap',
|
||||
);
|
||||
|
||||
let disconnected = 0;
|
||||
fixture.modal.classList.add('modal-left-docked');
|
||||
fixture.content.style.setProperty('left', '123px');
|
||||
fixture.content.dataset._tileZone = 'left';
|
||||
fixture.content._leftDockNavObs = {
|
||||
navObs: { disconnect() { disconnected += 1; } },
|
||||
reanchor() {},
|
||||
};
|
||||
context.resetSettingsWindowPlacement(fixture.modal);
|
||||
check(
|
||||
'window placement reset clears docking observers and inline placement',
|
||||
!fixture.modal.classList.contains('modal-left-docked')
|
||||
&& dockCalls.some(call => call[0] === 'left' && call[1] === fixture.modal)
|
||||
&& disconnected === 1
|
||||
&& !('_tileZone' in fixture.content.dataset)
|
||||
&& fixture.content.style.left === undefined
|
||||
&& removedWindowListeners.some(call => call[0] === 'resize'),
|
||||
);
|
||||
|
||||
fixture.modal.classList.add('modal-right-docked', 'hidden');
|
||||
context.showSettingsModal(fixture.modal);
|
||||
check(
|
||||
'showSettingsModal restores a hidden modal before showing it',
|
||||
!fixture.modal.classList.contains('hidden')
|
||||
&& !fixture.modal.classList.contains('modal-right-docked')
|
||||
&& dockCalls.some(call => call[0] === 'right'),
|
||||
);
|
||||
|
||||
let closeCount = 0;
|
||||
context.bindSettingsClose(fixture.modal, {
|
||||
closeSettings() { closeCount += 1; },
|
||||
isTouchInsideModal() { return false; },
|
||||
});
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.id = 'unified-intg-form';
|
||||
form.style.display = '';
|
||||
form.appendChild(document.createElement('input'));
|
||||
fixture.content.appendChild(form);
|
||||
document.dispatch('keydown', {
|
||||
key: 'Escape',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
check(
|
||||
'Escape closes an inner integration editor before closing Settings',
|
||||
form.style.display === 'none' && form.children.length === 0 && closeCount === 0,
|
||||
);
|
||||
|
||||
document.dispatch('keydown', {
|
||||
key: 'Escape',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
check('Escape closes Settings when no nested flow is active', closeCount === 1);
|
||||
|
||||
const popover = document.createElement('div');
|
||||
popover.setAttribute('data-popover-open', '1');
|
||||
popover.style.display = 'block';
|
||||
fixture.content.appendChild(popover);
|
||||
document.dispatch('keydown', {
|
||||
key: 'Escape',
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
check('Escape leaves Settings open while a transient popover is active', closeCount === 1);
|
||||
popover.classList.add('hidden');
|
||||
|
||||
context.hideSettingsModal(fixture.modal);
|
||||
check(
|
||||
'hideSettingsModal preserves the closing animation fallback semantics',
|
||||
fixture.modal.classList.contains('hidden') && !fixture.content.classList.contains('modal-closing'),
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(results));
|
||||
if (results.some(result => !result.pass)) process.exitCode = 1;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
||||
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
|
||||
the indexing job on the event loop.
|
||||
|
||||
The handler is ``async def`` but called ``rag.index_personal_documents``
|
||||
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
|
||||
FastAPI ran the whole job on the event loop and every other request queued
|
||||
behind it: indexing a real directory froze the UI and API for 25+ minutes.
|
||||
``personal_docs_manager.add_directory`` sits in the same blocking section — it
|
||||
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
|
||||
|
||||
These tests build the real router with fake managers and compare the thread
|
||||
the indexing work runs on against the event loop's thread.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _serialization_probe():
|
||||
"""Shared counter proving two critical sections never overlap."""
|
||||
state = {"active": 0, "max_active": 0}
|
||||
lock = threading.Lock()
|
||||
|
||||
def enter():
|
||||
with lock:
|
||||
state["active"] += 1
|
||||
state["max_active"] = max(state["max_active"], state["active"])
|
||||
|
||||
def leave():
|
||||
with lock:
|
||||
state["active"] -= 1
|
||||
|
||||
return state, enter, leave
|
||||
|
||||
|
||||
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
|
||||
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
|
||||
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
|
||||
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
|
||||
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
|
||||
# requests on the test's own loop.
|
||||
def _async_client(app):
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
|
||||
|
||||
import routes.personal_routes as personal_routes
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import require_user
|
||||
|
||||
|
||||
class _FakeRag:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
def index_personal_documents(self, directory, owner=None):
|
||||
self._record["index_thread"] = threading.get_ident()
|
||||
return {"success": True, "indexed_count": 3, "failed_count": 0}
|
||||
|
||||
def _split_into_chunks(self, text, chunk_size=500):
|
||||
return [text]
|
||||
|
||||
def add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
return True
|
||||
|
||||
def delete_by_source(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
return 1
|
||||
|
||||
|
||||
class _FakeDocsManager:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
self.index = []
|
||||
|
||||
def add_directory(self, directory, *, index=True, owner=None):
|
||||
self._record["bookkeeping_thread"] = threading.get_ident()
|
||||
self._record["bookkeeping_index_flag"] = index
|
||||
|
||||
def exclude_file(self, filepath):
|
||||
self._record["exclude_thread"] = threading.get_ident()
|
||||
|
||||
|
||||
def _build_app(tmp_path, monkeypatch, record):
|
||||
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
|
||||
)
|
||||
app.dependency_overrides[require_user] = lambda: "tester"
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
|
||||
@app.get("/loop-thread")
|
||||
async def loop_thread_probe():
|
||||
return {"thread": threading.get_ident()}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
# Context-manager client: one portal/event loop serves both requests, so
|
||||
# the probe and the POST are guaranteed to see the same loop thread.
|
||||
with TestClient(app) as client:
|
||||
loop_thread = client.get("/loop-thread").json()["thread"]
|
||||
resp = client.post(
|
||||
"/api/personal/add_directory", json={"directory": str(target)}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert record["index_thread"] != loop_thread, (
|
||||
"index_personal_documents ran on the event loop thread — every other "
|
||||
"request queues behind the indexing job (#5558)"
|
||||
)
|
||||
assert record["bookkeeping_thread"] != loop_thread, (
|
||||
"personal_docs_manager.add_directory (refresh_index) ran on the event "
|
||||
"loop thread"
|
||||
)
|
||||
|
||||
|
||||
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["indexed_count"] == 3
|
||||
assert body["failed_count"] == 0
|
||||
assert body["directory"] == os.path.realpath(str(target))
|
||||
assert record["bookkeeping_index_flag"] is False
|
||||
|
||||
|
||||
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
|
||||
"""Off-loop execution must not mean parallel index jobs: concurrent
|
||||
requests would race PersonalDocsManager's unsynchronized list mutations
|
||||
and file writes (save_directories/_save_excluded are plain open('w'))."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.2); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
for name in ("docs_a", "docs_b"):
|
||||
(tmp_path / name).mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} index jobs ran in parallel — concurrent "
|
||||
"add_directory requests must serialize"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
def _fail(directory, owner=None):
|
||||
return {"success": False, "message": "boom"}
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||
assert resp.status_code == 500
|
||||
assert "boom" in resp.json()["detail"]
|
||||
|
||||
|
||||
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
|
||||
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
|
||||
running while an add job is in flight races PersonalDocsManager's
|
||||
unsynchronized list/index mutations — the inconsistent state the PR's
|
||||
'add/remove are serialized' guarantee claims to prevent."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_remove(self, directory):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
(tmp_path / "docs_b").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/remove critical sections overlapped — "
|
||||
"remove must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
|
||||
calls personal_docs_manager.add_directory — the same vector/tracking state
|
||||
add_directory mutates. It must hold the SAME job lock, or an upload landing
|
||||
mid-add interleaves two writers over unsynchronized state."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
# The test coroutine runs on the event loop, so this IS the loop thread.
|
||||
assert record["add_document_thread"] != threading.get_ident(), (
|
||||
"rag.add_document ran on the event loop thread — chunk writes block "
|
||||
"every other request for the duration of the upload"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/upload critical sections overlapped — "
|
||||
"upload must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
|
||||
"""A multi-file upload must retain at most one capped payload at a time."""
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
reads = []
|
||||
original_read = StarletteUploadFile.read
|
||||
|
||||
async def _recording_read(upload, size=-1):
|
||||
reads.append(upload.filename)
|
||||
return await original_read(upload, size)
|
||||
|
||||
def _record_first_index(self, chunk, metadata):
|
||||
self._record.setdefault("reads_at_first_index", len(reads))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
|
||||
files = [
|
||||
("files", ("a.txt", b"alpha", "text/plain")),
|
||||
("files", ("b.txt", b"bravo", "text/plain")),
|
||||
("files", ("c.txt", b"charlie", "text/plain")),
|
||||
]
|
||||
async with _async_client(app) as ac:
|
||||
response = await ac.post("/api/personal/upload", files=files)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
|
||||
assert reads == ["a.txt", "b.txt", "c.txt"]
|
||||
assert record["reads_at_first_index"] == 1, (
|
||||
"all upload bodies were retained before worker processing began"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
|
||||
calls personal_docs_manager.exclude_file. Both mutate state add_directory
|
||||
also touches, so the delete must hold the SAME job lock as add."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_delete(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
doomed = tmp_path / "doomed.txt"
|
||||
doomed.write_text("bye")
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert record["delete_thread"] != threading.get_ident(), (
|
||||
"rag.delete_by_source ran on the event loop thread"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/delete critical sections overlapped — "
|
||||
"delete must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
|
||||
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
|
||||
the same job lock so it cannot race an in-flight add job."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_refresh(self):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/reload"),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/reload critical sections overlapped — "
|
||||
"reload must hold the same index job lock as add"
|
||||
)
|
||||
@@ -314,17 +314,21 @@ class TestComputeFinalMetrics:
|
||||
def test_tool_events_included(self):
|
||||
events = [{"tool": "bash", "duration": 1.0}]
|
||||
texts = ["round 1 text"]
|
||||
models = ["round-1-model"]
|
||||
m = _compute_final_metrics(**self._base_args(
|
||||
tool_events=events,
|
||||
round_texts=texts,
|
||||
round_models=models,
|
||||
))
|
||||
assert m["tool_events"] == events
|
||||
assert m["round_texts"] == texts
|
||||
assert m["round_models"] == models
|
||||
|
||||
def test_no_tool_events_excluded(self):
|
||||
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
|
||||
assert "tool_events" not in m
|
||||
assert "round_texts" not in m
|
||||
assert "round_models" not in m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Saved Agent rounds must render and bill with actual per-round provenance."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_CHAT_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_SLASH_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _function_source(name):
|
||||
match = re.search(
|
||||
rf"^(?:export )?function {name}\(.*?^\}}",
|
||||
_SOURCE,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
assert match, f"{name} not found"
|
||||
return match.group(0).replace("export function", "function", 1)
|
||||
|
||||
|
||||
def _run_node(source):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_saved_agent_rounds_prefer_round_model_provenance():
|
||||
assert "const roundModels = metadata.round_models || [];" in _SOURCE
|
||||
assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
|
||||
assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
|
||||
assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
|
||||
assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
|
||||
assert "r < roundEndpointIds.length" in _SOURCE
|
||||
assert "r < roundEndpointLabels.length" in _SOURCE
|
||||
assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
|
||||
|
||||
|
||||
def test_metrics_cost_uses_actual_fallback_endpoint_classification():
|
||||
assert "metrics.endpoint_cost_tracked" in _SOURCE
|
||||
assert "endpointCostTracked === false" in _SOURCE
|
||||
assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
|
||||
assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_agent_usage_buckets_sum_only_billable_answering_routes():
|
||||
source = "\n".join([
|
||||
"let currentUrl = '';",
|
||||
"function _currentEndpointUrl() { return currentUrl; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
"const paidSelected = {usage_buckets: [",
|
||||
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
|
||||
" {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
|
||||
"]};",
|
||||
"const localSelected = {usage_buckets: [",
|
||||
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
|
||||
" {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
|
||||
"]};",
|
||||
"currentUrl = 'local';",
|
||||
"const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
|
||||
"currentUrl = 'paid';",
|
||||
"const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
|
||||
"console.log(JSON.stringify({paidToLocal, localToPaid}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
|
||||
source = "\n".join([
|
||||
"function _currentEndpointUrl() { return 'local-selected'; }",
|
||||
"function isCostTrackedEndpoint() { return false; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
"const metrics = {usage_buckets: [",
|
||||
" {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
|
||||
" {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
|
||||
"]};",
|
||||
"console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.2}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_repeated_live_metrics_render_records_session_cost_once():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'local'; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
|
||||
"recordSessionMetricsCost(metrics);",
|
||||
"recordSessionMetricsCost(metrics);",
|
||||
"console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.11, "recorded": True}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const _MAX_COST_RUNS_PER_SESSION = 256;",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'local'; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
_function_source("getSessionCost"),
|
||||
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
|
||||
"const replayedObject = {...firstObject};",
|
||||
"recordSessionMetricsCost(firstObject);",
|
||||
"recordSessionMetricsCost(replayedObject);",
|
||||
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const _MAX_COST_RUNS_PER_SESSION = 256;",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'paid'; }",
|
||||
"function isCostTrackedEndpoint() { return true; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
_function_source("getSessionCost"),
|
||||
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
|
||||
"recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
|
||||
"recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
|
||||
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"cost": 0.37,
|
||||
"runs": {"run:primary": 0.14, "run:teacher": 0.23},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const badge = {style: {}, textContent: ''};",
|
||||
"const document = {getElementById() { return badge; }};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
|
||||
_function_source("getSessionCost"),
|
||||
_function_source("updateSessionCostUI"),
|
||||
"updateSessionCostUI();",
|
||||
"console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"stored": 0.125,
|
||||
"display": "",
|
||||
"text": "$0.125",
|
||||
}
|
||||
|
||||
|
||||
def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
|
||||
assert "metrics = json.data || metrics;" in _CHAT_SOURCE
|
||||
assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
|
||||
assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
|
||||
assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
|
||||
assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
|
||||
assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
|
||||
assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
|
||||
assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
|
||||
|
||||
|
||||
def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
|
||||
assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
|
||||
assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE
|
||||
@@ -37,7 +37,7 @@ def _patch_common(monkeypatch):
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
|
||||
async def _fake_exec(block, *a, **k):
|
||||
return ("bash", {"output": "ok", "exit_code": 0})
|
||||
return (block.tool_type, {"output": "ok", "exit_code": 0})
|
||||
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
|
||||
|
||||
|
||||
@@ -58,8 +58,14 @@ def _run_loop(monkeypatch, round_text, max_rounds=2):
|
||||
|
||||
def test_emits_rounds_exhausted_when_cap_hit_mid_task(monkeypatch):
|
||||
_patch_common(monkeypatch)
|
||||
# Every round returns a tool block -> never "done" -> loop exhausts the cap.
|
||||
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=2)
|
||||
# Use a system-owned interaction result so this remains a loop-control test:
|
||||
# Bash output is workspace-derived and now correctly pauses for exact user
|
||||
# approval before a later Bash call.
|
||||
events = _run_loop(
|
||||
monkeypatch,
|
||||
'```update_plan\n{"plan":"- [ ] keep going"}\n```',
|
||||
max_rounds=2,
|
||||
)
|
||||
assert any(e.get("type") == "rounds_exhausted" for e in events), events
|
||||
|
||||
|
||||
@@ -84,7 +90,11 @@ def test_emits_intent_nudge_exhausted_when_cap_is_exhausted(monkeypatch):
|
||||
def test_emits_loop_breaker_triggered_when_loop_breaker_trips(monkeypatch):
|
||||
_patch_common(monkeypatch)
|
||||
|
||||
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=6)
|
||||
events = _run_loop(
|
||||
monkeypatch,
|
||||
'```update_plan\n{"plan":"- [ ] keep going"}\n```',
|
||||
max_rounds=6,
|
||||
)
|
||||
|
||||
guard = next((e for e in events if e.get("type") == "loop_breaker_triggered"), None)
|
||||
assert guard is not None, events
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Agent-thread timeline dots must stay centred on the vertical rail.
|
||||
|
||||
Source-text assertions here are the narrow exception allowed by
|
||||
TESTING_STANDARD.md: the invariant is pure CSS geometry, and driving it at
|
||||
runtime would need a real layout engine, which the suite has no runner for.
|
||||
The test parses the declared pixel values and recomputes the centres rather
|
||||
than pinning literals, so retuning the spacing keeps it green — but moving a
|
||||
breakpoint's padding without moving both dot offsets turns it red.
|
||||
|
||||
The thread's padding is restated once per breakpoint, and each breakpoint's
|
||||
overrides follow its own `.agent-thread` rule, so a rule's position in the
|
||||
file identifies the breakpoint it belongs to.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CSS = (Path(__file__).resolve().parents[1] / "static" / "style.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
THREAD = r"^[ \t]*\.agent-thread[ \t]*\{"
|
||||
RAIL = r"^[ \t]*\.agent-thread::before[ \t]*\{"
|
||||
STEP_DOT = r"^[ \t]*\.agent-thread-dot[ \t]*\{"
|
||||
END_DOT = (
|
||||
r"^[ \t]*\.agent-thread:not\(\.has-bottom\) "
|
||||
r"\.agent-thread-node\.open:last-child::after[ \t]*\{"
|
||||
)
|
||||
|
||||
|
||||
def _blocks(selector: str) -> list[tuple[int, str]]:
|
||||
"""(position, declaration block) for each rule using this exact selector."""
|
||||
return [
|
||||
(m.start(), CSS[m.end() : CSS.index("}", m.end())])
|
||||
for m in re.finditer(selector, CSS, re.MULTILINE)
|
||||
]
|
||||
|
||||
|
||||
def _px(block: str, prop: str) -> float | None:
|
||||
m = re.search(rf"(?:^|;|/)\s*{prop}\s*:\s*(-?[\d.]+)px", block)
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _padding_left(block: str) -> float | None:
|
||||
explicit = _px(block, "padding-left")
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
shorthand = re.search(r"(?:^|;)\s*padding\s*:([^;]+);", block)
|
||||
sides = shorthand.group(1).split() if shorthand else []
|
||||
return float(sides[3].removesuffix("px")) if len(sides) == 4 else None
|
||||
|
||||
|
||||
def _breakpoints() -> list[tuple[float, int, int]]:
|
||||
"""(padding-left, window start, window end) for each declared breakpoint."""
|
||||
threads = _blocks(THREAD)
|
||||
assert threads, "no .agent-thread rule found"
|
||||
bounds = [pos for pos, _ in threads] + [len(CSS)]
|
||||
return [
|
||||
(_padding_left(block), bounds[i], bounds[i + 1])
|
||||
for i, (_, block) in enumerate(threads)
|
||||
]
|
||||
|
||||
|
||||
def _in_force(selector: str, prop: str, start: int, end: int, fallback):
|
||||
"""Value a breakpoint's window declares, else the one it inherits."""
|
||||
declared = [_px(b, prop) for pos, b in _blocks(selector) if start <= pos < end]
|
||||
values = [v for v in declared if v is not None]
|
||||
return values[-1] if values else fallback
|
||||
|
||||
|
||||
def _centres() -> list[tuple[float, float, float]]:
|
||||
rail = _blocks(RAIL)[0][1]
|
||||
rail_centre = _px(rail, "left") + _px(rail, "width") / 2
|
||||
|
||||
base_step, base_end = _blocks(STEP_DOT)[0][1], _blocks(END_DOT)[0][1]
|
||||
step_width, end_width = _px(base_step, "width"), _px(base_end, "width")
|
||||
|
||||
rows = []
|
||||
for padding, start, end in _breakpoints():
|
||||
step_left = _in_force(STEP_DOT, "left", start, end, _px(base_step, "left"))
|
||||
end_left = _in_force(END_DOT, "left", start, end, _px(base_end, "left"))
|
||||
rows.append(
|
||||
(
|
||||
rail_centre,
|
||||
padding + step_left + step_width / 2,
|
||||
padding + end_left + end_width / 2,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_step_dots_are_centred_on_the_rail_at_every_breakpoint():
|
||||
for index, (rail, step, _) in enumerate(_centres()):
|
||||
assert step == rail, (
|
||||
f"step dot at breakpoint {index} sits {step - rail}px off the rail"
|
||||
)
|
||||
|
||||
|
||||
def test_terminating_dot_is_centred_on_the_rail_at_every_breakpoint():
|
||||
for index, (rail, _, end) in enumerate(_centres()):
|
||||
assert end == rail, (
|
||||
f"terminating dot at breakpoint {index} sits {end - rail}px "
|
||||
"off the rail"
|
||||
)
|
||||
@@ -8,13 +8,16 @@ import asyncio
|
||||
import json
|
||||
|
||||
from src.agent_tools import ToolBlock, TOOL_TAGS # noqa: E402 (import first to avoid circular)
|
||||
from src.tool_execution import execute_tool_block
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
||||
from src.tool_security import is_public_blocked_tool
|
||||
|
||||
|
||||
def _run(content):
|
||||
return asyncio.run(execute_tool_block(ToolBlock("ask_user", content)))
|
||||
return asyncio.run(execute_tool_block(
|
||||
ToolBlock("ask_user", content),
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
))
|
||||
|
||||
|
||||
def test_valid_question_returns_ask_user_payload():
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Auth middleware must evaluate the same path that Starlette routes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from core.middleware import (
|
||||
get_application_route_path,
|
||||
path_is_route_or_child,
|
||||
with_asgi_root_path,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_login_page_uses_mount_aware_urls():
|
||||
html = (ROOT / "static/login.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "window.__odysseusLoginAppUrl" in html
|
||||
for api_path in (
|
||||
"/api/version",
|
||||
"/api/auth/policy",
|
||||
"/api/auth/status",
|
||||
"/api/auth/login",
|
||||
"/api/auth/setup",
|
||||
"/api/auth/signup",
|
||||
"/api/sessions",
|
||||
"/api/auth/features",
|
||||
"/api/auth/settings",
|
||||
):
|
||||
assert f"appUrl('{api_path}')" in html
|
||||
|
||||
assert "window.location.replace(appUrl('/'))" in html
|
||||
assert (
|
||||
"__odysseusLoginAppUrl || ((path) => path))('/static/js/theme.js')"
|
||||
in html
|
||||
)
|
||||
|
||||
|
||||
def test_login_page_static_assets_resolve_under_mount_path():
|
||||
html = (ROOT / "static/login.html").read_text(encoding="utf-8")
|
||||
for forbidden in (
|
||||
"fetch('/api/",
|
||||
'fetch("/api/',
|
||||
"window.location.replace('/')",
|
||||
'window.location.replace("/")',
|
||||
"import('/static/",
|
||||
'href="/static/',
|
||||
"url('/static/",
|
||||
):
|
||||
assert forbidden not in html
|
||||
|
||||
mounted_login_url = "https://example.test/odysseus/login"
|
||||
for relative_asset, mounted_path in (
|
||||
("static/manifest.json", "/odysseus/static/manifest.json"),
|
||||
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
|
||||
(
|
||||
"static/fonts/FiraCode-Regular.woff2",
|
||||
"/odysseus/static/fonts/FiraCode-Regular.woff2",
|
||||
),
|
||||
(
|
||||
"static/fonts/FiraCode-SemiBold.woff2",
|
||||
"/odysseus/static/fonts/FiraCode-SemiBold.woff2",
|
||||
),
|
||||
):
|
||||
assert relative_asset in html
|
||||
assert urlparse(urljoin(mounted_login_url, relative_asset)).path == mounted_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("root_path", "path", "expected"),
|
||||
[
|
||||
("", "/api/models", "/api/models"),
|
||||
("/odysseus", "/odysseus/api/models", "/api/models"),
|
||||
("/odysseus/", "/odysseus//api/models", "/api/models"),
|
||||
("/", "//api/models", "/api/models"),
|
||||
("/odysseus", "/odyssey/api/models", "/odyssey/api/models"),
|
||||
("/app", "/application/api/models", "/application/api/models"),
|
||||
("/odysseus", "/odysseus", ""),
|
||||
],
|
||||
)
|
||||
def test_application_route_path_matches_starlette_semantics(
|
||||
root_path,
|
||||
path,
|
||||
expected,
|
||||
):
|
||||
assert get_application_route_path({
|
||||
"root_path": root_path,
|
||||
"path": path,
|
||||
}) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("root_path", "expected"),
|
||||
[
|
||||
("", "/login"),
|
||||
("/odysseus", "/odysseus/login"),
|
||||
("/odysseus/", "/odysseus/login"),
|
||||
("/", "/login"),
|
||||
],
|
||||
)
|
||||
def test_client_redirect_path_includes_asgi_root_path(root_path, expected):
|
||||
assert with_asgi_root_path({"root_path": root_path}, "/login") == expected
|
||||
|
||||
|
||||
def test_route_prefix_matching_is_segment_aware():
|
||||
assert path_is_route_or_child("/assets", "/assets") is True
|
||||
assert path_is_route_or_child("/assets/app.js", "/assets") is True
|
||||
assert path_is_route_or_child("/assets-v2/app.js", "/assets") is False
|
||||
|
||||
|
||||
def test_real_auth_middleware_uses_application_relative_path(tmp_path):
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"AUTH_ENABLED": "true",
|
||||
"CHROMADB_CONNECT_TIMEOUT": "0.01",
|
||||
"CHROMADB_HOST": "127.0.0.1",
|
||||
"CHROMADB_PORT": "9",
|
||||
"DATABASE_URL": f"sqlite:///{tmp_path / 'app.db'}",
|
||||
"LOCALHOST_BYPASS": "false",
|
||||
"ODYSSEUS_DATA_DIR": str(tmp_path),
|
||||
"ODYSSEUS_DISABLE_MCP": "1",
|
||||
"OPENAI_API_KEY": "",
|
||||
"PYTHONPATH": str(ROOT),
|
||||
"PYTHON_DOTENV_DISABLED": "1",
|
||||
})
|
||||
probe = textwrap.dedent(
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
class _AuthManager:
|
||||
def __init__(self, configured):
|
||||
self.is_configured = configured
|
||||
|
||||
@staticmethod
|
||||
def validate_token(_token):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_username_for_token(_token):
|
||||
return None
|
||||
|
||||
|
||||
def _scope(root_path, route_path, downstream):
|
||||
full_path = root_path + route_path
|
||||
return {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": full_path,
|
||||
"raw_path": full_path.encode(),
|
||||
"root_path": root_path,
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("192.0.2.10", 4321),
|
||||
"server": ("testserver", 80),
|
||||
"app": downstream,
|
||||
}
|
||||
|
||||
|
||||
async def _case(root_path, route_path, *, configured):
|
||||
manager = _AuthManager(configured)
|
||||
app_module.auth_manager = manager
|
||||
calls = []
|
||||
|
||||
async def endpoint(request):
|
||||
calls.append(request)
|
||||
return JSONResponse({"reached": True})
|
||||
|
||||
downstream = Starlette(routes=[Route(route_path, endpoint)])
|
||||
downstream.state.auth_manager = manager
|
||||
middleware = app_module.AuthMiddleware(downstream)
|
||||
scope = _scope(root_path, route_path, downstream)
|
||||
sent = []
|
||||
request_sent = False
|
||||
|
||||
async def receive():
|
||||
nonlocal request_sent
|
||||
if request_sent:
|
||||
return {"type": "http.disconnect"}
|
||||
request_sent = True
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
response_start = next(m for m in sent if m["type"] == "http.response.start")
|
||||
headers = {k.decode().lower(): v.decode() for k, v in response_start["headers"]}
|
||||
return {
|
||||
"status": response_start["status"],
|
||||
"location": headers.get("location"),
|
||||
"called": len(calls),
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
setup = await _case("/odysseus", "/api/auth/setup", configured=False)
|
||||
mounted_api = await _case("/odysseus", "/api/models", configured=True)
|
||||
mounted_browser = await _case("/odysseus", "/notes", configured=True)
|
||||
webhook = await _case(
|
||||
"/odysseus",
|
||||
"/api/tasks/task-1/webhook/secret-token",
|
||||
configured=True,
|
||||
)
|
||||
static_child = await _case(
|
||||
"/odysseus",
|
||||
"/static/app.js",
|
||||
configured=True,
|
||||
)
|
||||
static_lookalike = await _case(
|
||||
"/odysseus",
|
||||
"/static-v2/app.js",
|
||||
configured=True,
|
||||
)
|
||||
default_api = await _case("", "/api/models", configured=True)
|
||||
print("RESULT=" + json.dumps({
|
||||
"setup": setup,
|
||||
"mounted_api": mounted_api,
|
||||
"mounted_browser": mounted_browser,
|
||||
"webhook": webhook,
|
||||
"static_child": static_child,
|
||||
"static_lookalike": static_lookalike,
|
||||
"default_api": default_api,
|
||||
}, sort_keys=True))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", probe],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
result_line = next(
|
||||
(line for line in result.stdout.splitlines() if line.startswith("RESULT=")),
|
||||
None,
|
||||
)
|
||||
assert result_line is not None, result.stdout
|
||||
payload = json.loads(result_line.removeprefix("RESULT="))
|
||||
|
||||
assert payload["setup"] == {"status": 200, "location": None, "called": 1}
|
||||
assert payload["webhook"] == {"status": 200, "location": None, "called": 1}
|
||||
assert payload["static_child"] == {
|
||||
"status": 200,
|
||||
"location": None,
|
||||
"called": 1,
|
||||
}
|
||||
assert payload["static_lookalike"] == {
|
||||
"status": 302,
|
||||
"location": "/odysseus/login",
|
||||
"called": 0,
|
||||
}
|
||||
assert payload["mounted_browser"] == {
|
||||
"status": 302,
|
||||
"location": "/odysseus/login",
|
||||
"called": 0,
|
||||
}
|
||||
for name in ("mounted_api", "default_api"):
|
||||
assert payload[name] == {"status": 401, "location": None, "called": 0}
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
@@ -37,3 +38,50 @@ def test_drain_agent_ignores_non_string_deltas(monkeypatch):
|
||||
"output": "done",
|
||||
"exit_code": None,
|
||||
}]
|
||||
|
||||
|
||||
def test_background_job_output_is_wrapped_and_arms_gate(monkeypatch):
|
||||
monkeypatch.setattr(bg_monitor.bg_jobs, "result_text", lambda rec: "injected output")
|
||||
|
||||
message = bg_monitor._background_result_message({"id": "job-1"})
|
||||
|
||||
assert message["metadata"]["trusted"] is False
|
||||
assert message["metadata"]["tool_gate_untrusted"] is True
|
||||
assert "injected output" in message["content"]
|
||||
|
||||
|
||||
def test_background_drain_preserves_exact_approval_card(monkeypatch):
|
||||
approval = {
|
||||
"kind": "tool_approval",
|
||||
"approval_id": "opaque-id",
|
||||
"question": "Allow this exact action once?",
|
||||
"options": [{"label": "Allow once"}, {"label": "Deny"}],
|
||||
}
|
||||
|
||||
async def fake_stream_agent_loop(*args, **kwargs):
|
||||
yield "data: " + json.dumps({
|
||||
"type": "tool_output",
|
||||
"tool": "bash",
|
||||
"command": "echo ok",
|
||||
"output": "Waiting for an exact user approval.",
|
||||
"exit_code": None,
|
||||
"ask_user": approval,
|
||||
})
|
||||
yield "data: [DONE]"
|
||||
|
||||
agent_loop = types.ModuleType("src.agent_loop")
|
||||
agent_loop.stream_agent_loop = fake_stream_agent_loop
|
||||
monkeypatch.setitem(sys.modules, "src.agent_loop", agent_loop)
|
||||
|
||||
sess = SimpleNamespace(
|
||||
endpoint_url="http://example.test",
|
||||
model="model",
|
||||
headers=None,
|
||||
context_length=0,
|
||||
id="s1",
|
||||
owner="owner",
|
||||
)
|
||||
|
||||
_, events = asyncio.run(bg_monitor._drain_agent(sess, []))
|
||||
|
||||
assert events[0]["ask_user"] == approval
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Execute the round-aware live model-provenance state helper under Node."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MODULE = (_REPO / "static" / "js" / "chatModelProvenance.js").as_uri()
|
||||
|
||||
|
||||
def test_round_two_fallback_then_provider_alias_does_not_relabel_round_one():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelRouteEventState }} from {json.dumps(_MODULE)};
|
||||
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
const round2 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
|
||||
const fallbackTarget = applyModelRouteEventState({{
|
||||
type: 'fallback', round: 2,
|
||||
selected_model: 'selected-model', answered_by: 'backup-model'
|
||||
}}, round1, round2, 'selected-model');
|
||||
const aliasTarget = applyModelRouteEventState({{
|
||||
type: 'model_actual', round: 2,
|
||||
requested_model: 'selected-model', model: 'provider-backup-alias'
|
||||
}}, round1, round2, 'selected-model');
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
fallbackIsRound2: fallbackTarget === round2,
|
||||
aliasIsRound2: aliasTarget === round2,
|
||||
round1,
|
||||
round2,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
state = json.loads(result.stdout)
|
||||
assert state == {
|
||||
"fallbackIsRound2": True,
|
||||
"aliasIsRound2": True,
|
||||
"round1": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "selected-model",
|
||||
},
|
||||
"round2": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "provider-backup-alias",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_next_round_and_final_metrics_preserve_each_agent_round_route():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{
|
||||
applyModelMetricsState,
|
||||
applyModelRouteEventState,
|
||||
inheritModelRouteState,
|
||||
}} from {json.dumps(_MODULE)};
|
||||
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
const round2 = {{}};
|
||||
inheritModelRouteState(round1, round1, round2, 'selected-model');
|
||||
applyModelRouteEventState({{
|
||||
type: 'fallback', round: 2,
|
||||
selected_model: 'selected-model', answered_by: 'backup-model'
|
||||
}}, round1, round2, 'selected-model');
|
||||
applyModelRouteEventState({{
|
||||
type: 'model_actual', round: 2,
|
||||
requested_model: 'selected-model', model: 'provider-backup-alias'
|
||||
}}, round1, round2, 'selected-model');
|
||||
|
||||
const round3 = {{}};
|
||||
inheritModelRouteState(round1, round2, round3, 'selected-model');
|
||||
const metricsTarget = applyModelMetricsState({{
|
||||
requested_model: 'selected-model',
|
||||
model: 'provider-backup-alias',
|
||||
round_models: ['selected-model', 'provider-backup-alias', 'backup-model'],
|
||||
}}, round1, round3, 'selected-model');
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
metricsIsRound3: metricsTarget === round3,
|
||||
round1,
|
||||
round2,
|
||||
round3,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"metricsIsRound3": True,
|
||||
"round1": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "selected-model",
|
||||
},
|
||||
"round2": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "provider-backup-alias",
|
||||
},
|
||||
"round3": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "backup-model",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_same_model_fallback_preserves_distinct_endpoint_route_state():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelMetricsState, applyModelRouteEventState }} from {json.dumps(_MODULE)};
|
||||
const holder = {{ _requestedModel: 'same-model', _actualModel: 'same-model' }};
|
||||
applyModelRouteEventState({{
|
||||
type: 'fallback',
|
||||
selected_model: 'same-model', answered_by: 'same-model',
|
||||
selected_endpoint_id: 'account-one', selected_endpoint_label: 'Account one',
|
||||
answered_by_endpoint_id: 'account-two', answered_by_endpoint_label: 'Account two',
|
||||
}}, holder, null, 'same-model');
|
||||
applyModelMetricsState({{
|
||||
requested_model: 'same-model', model: 'same-model',
|
||||
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
|
||||
endpoint_id: 'account-two', endpoint_label: 'Account two',
|
||||
}}, holder, null, 'same-model');
|
||||
console.log(JSON.stringify(holder));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"_requestedModel": "same-model",
|
||||
"_actualModel": "same-model",
|
||||
"_requestedEndpointId": "account-one",
|
||||
"_requestedEndpointLabel": "Account one",
|
||||
"_actualEndpointId": "account-two",
|
||||
"_actualEndpointLabel": "Account two",
|
||||
}
|
||||
|
||||
|
||||
def test_metrics_preserve_explicitly_unknown_round_endpoint():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelMetricsState }} from {json.dumps(_MODULE)};
|
||||
const holder = {{
|
||||
_requestedModel: 'same-model',
|
||||
_actualModel: 'same-model',
|
||||
_requestedEndpointId: 'account-one',
|
||||
_requestedEndpointLabel: 'Account one',
|
||||
}};
|
||||
const roundHolder = {{}};
|
||||
applyModelMetricsState({{
|
||||
requested_model: 'same-model', model: 'same-model',
|
||||
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
|
||||
endpoint_id: 'account-two', endpoint_label: 'Account two',
|
||||
round_endpoint_ids: [null], round_endpoint_labels: [null],
|
||||
}}, holder, roundHolder, 'same-model');
|
||||
console.log(JSON.stringify(roundHolder));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"_requestedModel": "same-model",
|
||||
"_actualModel": "same-model",
|
||||
"_requestedEndpointId": "account-one",
|
||||
"_requestedEndpointLabel": "Account one",
|
||||
"_actualEndpointId": None,
|
||||
"_actualEndpointLabel": None,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Execute terminal stream-error classification under Node."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MODULE = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
|
||||
|
||||
|
||||
def test_terminal_provider_errors_preserve_text_and_never_auto_retry():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ createTerminalStreamError, isRecoverableStreamError }} from {json.dumps(_MODULE)};
|
||||
const stringError = createTerminalStreamError({{ status: 401, error: 'invalid key' }});
|
||||
const objectError = createTerminalStreamError({{ status: 404, error: {{ message: 'model missing' }} }});
|
||||
console.log(JSON.stringify({{
|
||||
stringMessage: stringError.message,
|
||||
objectMessage: objectError.message,
|
||||
terminalRecoverable: isRecoverableStreamError(stringError),
|
||||
eofRecoverable: isRecoverableStreamError(new Error('Stream closed before completion')),
|
||||
networkRecoverable: isRecoverableStreamError(new TypeError('fetch failed')),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"stringMessage": "invalid key",
|
||||
"objectMessage": "model missing",
|
||||
"terminalRecoverable": False,
|
||||
"eofRecoverable": True,
|
||||
"networkRecoverable": True,
|
||||
}
|
||||
@@ -55,6 +55,7 @@ _db.ApiToken = _ApiToken
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _companion_pairing_stubs(monkeypatch):
|
||||
monkeypatch.delenv("COMPANION_BASE_URL", raising=False)
|
||||
monkeypatch.setitem(sys.modules, "core.database", _db)
|
||||
for _name, _attrs in {
|
||||
"core.auth": {"AuthManager": MagicMock()},
|
||||
@@ -116,6 +117,96 @@ def test_pairing_payload_shape():
|
||||
assert p == {"v": 1, "host": "192.168.1.9", "port": 7000, "token": "ody_x"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("http://odysseus", ("odysseus", 80)),
|
||||
("http://odysseus:7000", ("odysseus", 7000)),
|
||||
("http://localhost:7000", ("localhost", 7000)),
|
||||
("http://odysseus.local", ("odysseus.local", 80)),
|
||||
("http://api.odysseus.local:7000", ("api.odysseus.local", 7000)),
|
||||
("http://10.0.0.1:7000", ("10.0.0.1", 7000)),
|
||||
("http://100.64.0.1:7000", ("100.64.0.1", 7000)),
|
||||
("http://100.127.255.254:7000", ("100.127.255.254", 7000)),
|
||||
("http://127.0.0.1:7000", ("127.0.0.1", 7000)),
|
||||
("http://169.254.1.1:7000", ("169.254.1.1", 7000)),
|
||||
("http://172.16.0.1:7000", ("172.16.0.1", 7000)),
|
||||
("http://172.31.255.254:7000", ("172.31.255.254", 7000)),
|
||||
("http://192.168.1.9:7000", ("192.168.1.9", 7000)),
|
||||
],
|
||||
)
|
||||
def test_parse_companion_base_url_accepts_v1_client_addresses(value, expected):
|
||||
assert P.parse_companion_base_url(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"",
|
||||
"odysseus.example",
|
||||
"ftp://odysseus.example",
|
||||
"https://odysseus.local",
|
||||
"http://user:password@odysseus.local",
|
||||
"http://odysseus.local/",
|
||||
"http://odysseus.local/path",
|
||||
"http://odysseus.local?query=1",
|
||||
"http://odysseus.local#fragment",
|
||||
"http://odysseus.local:not-a-port",
|
||||
"http://odysseus.local:0",
|
||||
"http://odysseus.local:65536",
|
||||
"http://odysseus.local:07000",
|
||||
"HTTP://odysseus.local:7000",
|
||||
"http://Odysseus.local:7000",
|
||||
" http://odysseus.local",
|
||||
"http://odysseus.local ",
|
||||
"http://odysseus\\local",
|
||||
"http://odysseus.local\n",
|
||||
"http://odysseus.local\t",
|
||||
"http://odysseus.local\x7f",
|
||||
"http://example.com:7000",
|
||||
"http://1.1.1.1:7000",
|
||||
"http://100.63.255.255:7000",
|
||||
"http://100.128.0.1:7000",
|
||||
"http://126.255.255.255:7000",
|
||||
"http://128.0.0.1:7000",
|
||||
"http://169.253.255.255:7000",
|
||||
"http://169.255.0.1:7000",
|
||||
"http://172.15.255.255:7000",
|
||||
"http://172.32.0.1:7000",
|
||||
"http://192.167.255.255:7000",
|
||||
"http://192.169.0.1:7000",
|
||||
# WHATWG URL parsing normalizes these legacy numeric host spellings to
|
||||
# IPv4 addresses even though Python's strict ipaddress parser rejects
|
||||
# them. 134744072 / 0x08080808 both become public 8.8.8.8.
|
||||
"http://134744072:7000",
|
||||
"http://0x:7000",
|
||||
"http://0x08080808:7000",
|
||||
"http://017700000001:7000",
|
||||
"http://[fd00::1]:7000",
|
||||
"http://[fe80::1%25eth0]:7000",
|
||||
"http://b\N{LATIN SMALL LETTER U WITH DIAERESIS}cher.local:7000",
|
||||
"http://xn--bcher-kva.local:7000",
|
||||
"http://xn--bcher-kva:7000",
|
||||
"http://odysseus%2elocal:7000",
|
||||
"http://%31%39%32.168.1.9:7000",
|
||||
"http://odysseus%40local:7000",
|
||||
"http://.local:7000",
|
||||
"http://odysseus..local:7000",
|
||||
"http://odysseus.local.:7000",
|
||||
"http://-odysseus:7000",
|
||||
"http://odysseus-:7000",
|
||||
"http://odysseus_name:7000",
|
||||
f"http://{'a' * 64}:7000",
|
||||
f"http://{'a' * 250}.local:7000",
|
||||
],
|
||||
)
|
||||
def test_parse_companion_base_url_rejects_unsupported_or_noncanonical_addresses(
|
||||
value,
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
P.parse_companion_base_url(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["[]", '{"users": []}'])
|
||||
def test_find_admin_user_ignores_invalid_auth_shape(tmp_path, monkeypatch, payload):
|
||||
auth_file = tmp_path / "auth.json"
|
||||
@@ -244,6 +335,15 @@ def test_pair_post_json_returns_pairing_payload(monkeypatch):
|
||||
assert response["port"] == 7000
|
||||
assert response["token"] == "ody_raw"
|
||||
assert response["token_id"] == "tok123"
|
||||
assert set(response) == {
|
||||
"host",
|
||||
"port",
|
||||
"token",
|
||||
"token_id",
|
||||
"hosts",
|
||||
"payload",
|
||||
"qr",
|
||||
}
|
||||
assert response["payload"] == {
|
||||
"v": 1,
|
||||
"host": "192.168.1.50",
|
||||
@@ -255,6 +355,59 @@ def test_pair_post_json_returns_pairing_payload(monkeypatch):
|
||||
assert secret_key not in response["payload"]
|
||||
|
||||
|
||||
def test_pair_post_json_prefers_configured_origin(monkeypatch):
|
||||
monkeypatch.setenv("COMPANION_BASE_URL", "http://odysseus.local:7000")
|
||||
mint = MagicMock(return_value=("tok123", "ody_raw"))
|
||||
discovery = MagicMock(side_effect=AssertionError("configured origin must skip LAN discovery"))
|
||||
monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False)
|
||||
monkeypatch.setattr(R, "get_current_user", lambda request: "alice")
|
||||
monkeypatch.setattr(R, "mint_pairing_token", mint)
|
||||
monkeypatch.setattr(R._pairing, "lan_ip_candidates", discovery)
|
||||
monkeypatch.setattr(R._pairing, "pairing_qr_png_data_uri", lambda payload: None)
|
||||
|
||||
request = _fake_pair_request(format="json", port=7000)
|
||||
response = _pair_route("POST")(request)
|
||||
|
||||
assert response["host"] == "odysseus.local"
|
||||
assert response["port"] == 7000
|
||||
assert response["hosts"] == ["odysseus.local"]
|
||||
assert set(response) == {
|
||||
"host",
|
||||
"port",
|
||||
"token",
|
||||
"token_id",
|
||||
"hosts",
|
||||
"payload",
|
||||
"qr",
|
||||
}
|
||||
assert response["payload"] == {
|
||||
"v": 1,
|
||||
"host": "odysseus.local",
|
||||
"port": 7000,
|
||||
"token": "ody_raw",
|
||||
}
|
||||
discovery.assert_not_called()
|
||||
|
||||
|
||||
def test_pair_post_rejects_invalid_config_before_mint_without_echoing_it(monkeypatch):
|
||||
configured_secret = "secret-password"
|
||||
monkeypatch.setenv(
|
||||
"COMPANION_BASE_URL",
|
||||
f"http://admin:{configured_secret}@odysseus.local",
|
||||
)
|
||||
mint = MagicMock(side_effect=AssertionError("invalid config must not mint a token"))
|
||||
monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False)
|
||||
monkeypatch.setattr(R, "mint_pairing_token", mint)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_pair_route("POST")(_fake_pair_request(format="json"))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert "COMPANION_BASE_URL" in exc.value.detail
|
||||
assert configured_secret not in exc.value.detail
|
||||
mint.assert_not_called()
|
||||
|
||||
|
||||
def test_pair_post_json_qr_failure_returns_null_qr(monkeypatch):
|
||||
monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False)
|
||||
monkeypatch.setattr(R, "get_current_user", lambda request: "alice")
|
||||
|
||||
@@ -257,6 +257,7 @@ def test_models_route_rejects_api_token_without_chat_scope(monkeypatch):
|
||||
|
||||
|
||||
def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
rows = [
|
||||
_ep(1, "alice-endpoint", "alice"),
|
||||
_ep(2, "shared-endpoint", None),
|
||||
@@ -278,6 +279,87 @@ def test_models_route_unresolved_owner_returns_only_shared_rows(monkeypatch):
|
||||
assert _endpoint_names(endpoints) == ["shared-endpoint"]
|
||||
|
||||
|
||||
def test_models_route_auth_disabled_does_not_widen_ownerless_api_token(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
rows = [
|
||||
_ep(1, "alice-endpoint", "alice"),
|
||||
_ep(2, "shared-endpoint", None),
|
||||
_ep(3, "bob-endpoint", "bob"),
|
||||
]
|
||||
monkeypatch.setattr(companion_routes, "get_current_user", lambda request: None)
|
||||
|
||||
endpoints = _call_models_route(
|
||||
monkeypatch,
|
||||
rows,
|
||||
_request(
|
||||
api_token=True,
|
||||
api_token_owner=None,
|
||||
api_token_scopes=["chat"],
|
||||
current_user="api",
|
||||
),
|
||||
)
|
||||
|
||||
assert _endpoint_names(endpoints) == ["shared-endpoint"]
|
||||
|
||||
|
||||
def test_models_route_auth_disabled_keeps_cookie_owner_scoped(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
rows = [
|
||||
_ep(1, "alice-endpoint", "alice"),
|
||||
_ep(2, "shared-endpoint", None),
|
||||
_ep(3, "bob-endpoint", "bob"),
|
||||
]
|
||||
monkeypatch.setattr(companion_routes, "get_current_user", lambda request: "alice")
|
||||
|
||||
endpoints = _call_models_route(
|
||||
monkeypatch,
|
||||
rows,
|
||||
_request(api_token=False, current_user="alice"),
|
||||
)
|
||||
|
||||
assert _endpoint_names(endpoints) == ["alice-endpoint", "shared-endpoint"]
|
||||
|
||||
|
||||
def test_models_route_auth_enabled_anonymous_returns_only_shared_rows(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
rows = [
|
||||
_ep(1, "alice-endpoint", "alice"),
|
||||
_ep(2, "shared-endpoint", None),
|
||||
_ep(3, "bob-endpoint", "bob"),
|
||||
]
|
||||
monkeypatch.setattr(companion_routes, "get_current_user", lambda request: None)
|
||||
|
||||
endpoints = _call_models_route(
|
||||
monkeypatch,
|
||||
rows,
|
||||
_request(api_token=False, current_user=None),
|
||||
)
|
||||
|
||||
assert _endpoint_names(endpoints) == ["shared-endpoint"]
|
||||
|
||||
|
||||
def test_models_route_auth_disabled_returns_all_enabled_rows(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
rows = [
|
||||
_ep(1, "alice-endpoint", "alice"),
|
||||
_ep(2, "shared-endpoint", None),
|
||||
_ep(3, "bob-endpoint", "bob"),
|
||||
]
|
||||
monkeypatch.setattr(companion_routes, "get_current_user", lambda request: None)
|
||||
|
||||
endpoints = _call_models_route(
|
||||
monkeypatch,
|
||||
rows,
|
||||
_request(api_token=False, current_user=None),
|
||||
)
|
||||
|
||||
assert _endpoint_names(endpoints) == [
|
||||
"alice-endpoint",
|
||||
"shared-endpoint",
|
||||
"bob-endpoint",
|
||||
]
|
||||
|
||||
|
||||
def test_models_route_filters_hidden_models_and_secret_fields(monkeypatch):
|
||||
rows = [
|
||||
_ep(
|
||||
|
||||
@@ -81,6 +81,13 @@ def _make_stream_with_save(sink, chunks, *, hang_after=None):
|
||||
return gen()
|
||||
|
||||
|
||||
async def _collect_subscription(session_id, expected_run=None):
|
||||
return [
|
||||
event
|
||||
async for event in agent_runs.subscribe(session_id, expected_run)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# agent_runs: detached-run semantics (what NORMAL chat/agent streams use)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -136,7 +143,7 @@ async def test_stop_cancels_detached_run_and_saves_partial_exactly_once():
|
||||
break
|
||||
await sub.aclose()
|
||||
|
||||
stopped = agent_runs.stop(session_id)
|
||||
stopped = agent_runs.stop(session_id, run.run_id)
|
||||
assert stopped is True
|
||||
|
||||
await run.task # propagates promptly — not stuck on the hung await
|
||||
@@ -165,6 +172,172 @@ async def test_normal_completion_saves_exactly_once_not_partial():
|
||||
assert sink.saves == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detached_run_identity_is_stable_for_replay_and_unique_per_run():
|
||||
session_id = "sess-detached-run-identity"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
first = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["one"]))
|
||||
first_id = first.run_id
|
||||
assert agent_runs.get_run_id(session_id) == first_id
|
||||
await first.task
|
||||
assert agent_runs.get_run_id(session_id) == first_id
|
||||
|
||||
second = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["two"]))
|
||||
assert second.run_id != first_id
|
||||
assert agent_runs.get_run_id(session_id) == second.run_id
|
||||
await second.task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_subscription_stays_bound_to_header_run_after_replacement():
|
||||
session_id = "sess-detached-lazy-subscription"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
async def stream(label):
|
||||
yield f'data: {{"delta":"{label}"}}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, stream("first"))
|
||||
await first.task
|
||||
# StreamingResponse does not iterate its body until after construction.
|
||||
# Capture the same exact run object used for its identity header.
|
||||
lazy_body = agent_runs.subscribe(session_id, first)
|
||||
|
||||
second = agent_runs.start(session_id, stream("second"))
|
||||
await second.task
|
||||
|
||||
replayed = [event async for event in lazy_body]
|
||||
assert replayed == ['data: {"delta":"first"}\n\n']
|
||||
assert agent_runs.get_run_id(session_id) == second.run_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_run_identity_cannot_stop_replacement_run():
|
||||
session_id = "sess-detached-stale-stop"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def finished():
|
||||
yield 'data: {"delta":"old"}\n\n'
|
||||
|
||||
async def replacement():
|
||||
yield 'data: {"delta":"new"}\n\n'
|
||||
await release.wait()
|
||||
|
||||
first = agent_runs.start(session_id, finished())
|
||||
await first.task
|
||||
second = agent_runs.start(session_id, replacement())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert agent_runs.stop(session_id) is False
|
||||
assert agent_runs.stop(session_id, first.run_id) is False
|
||||
assert second.task is not None and not second.task.done()
|
||||
assert agent_runs.stop(session_id, second.run_id) is True
|
||||
await second.task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triple_replacement_closes_middle_subscriber_and_preserves_save_order():
|
||||
session_id = "sess-detached-triple-replacement"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
first_closing = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
third_started = asyncio.Event()
|
||||
|
||||
async def first_stream():
|
||||
try:
|
||||
yield 'data: {"delta":"first"}\n\n'
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
first_closing.set()
|
||||
await release_first.wait()
|
||||
|
||||
async def middle_stream():
|
||||
yield 'data: {"delta":"middle"}\n\n'
|
||||
|
||||
async def third_stream():
|
||||
third_started.set()
|
||||
yield 'data: {"delta":"third"}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, first_stream())
|
||||
while not first.buffer:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
middle = agent_runs.start(session_id, middle_stream())
|
||||
await first_closing.wait()
|
||||
assert middle.task is not None and not middle.task.done()
|
||||
|
||||
middle_events_task = asyncio.create_task(
|
||||
_collect_subscription(session_id, middle)
|
||||
)
|
||||
while not middle.subscribers:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
third = agent_runs.start(session_id, third_stream())
|
||||
|
||||
# The superseded middle response closes immediately even though its task
|
||||
# remains as the transitive barrier for the first run's partial save.
|
||||
assert await asyncio.wait_for(middle_events_task, timeout=1) == []
|
||||
assert middle.status == "stopped"
|
||||
assert middle.task is not None and not middle.task.done()
|
||||
assert not third_started.is_set()
|
||||
|
||||
release_first.set()
|
||||
await asyncio.wait_for(first.task, timeout=1)
|
||||
await asyncio.wait_for(middle.task, timeout=1)
|
||||
await asyncio.wait_for(third.task, timeout=1)
|
||||
|
||||
assert first.status == "stopped"
|
||||
assert middle.status == "stopped"
|
||||
assert third.status == "done"
|
||||
assert third_started.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_replays_pinned_fallback_run_without_restarting_tools():
|
||||
session_id = "sess-detached-fallback-resume"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
release = asyncio.Event()
|
||||
tool_executions = 0
|
||||
fallback = 'data: {"type":"fallback","answered_by":"backup","candidate_index":1}\n\n'
|
||||
tool = 'data: {"type":"tool_output","tool":"bash","output":"ok"}\n\n'
|
||||
|
||||
async def pinned_run():
|
||||
nonlocal tool_executions
|
||||
yield fallback
|
||||
tool_executions += 1
|
||||
yield tool
|
||||
await release.wait()
|
||||
yield 'data: {"delta":"backup finished"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
run = agent_runs.start(session_id, pinned_run())
|
||||
first = agent_runs.subscribe(session_id)
|
||||
first_events = []
|
||||
async for event in first:
|
||||
first_events.append(event)
|
||||
if len(first_events) == 2:
|
||||
break
|
||||
await first.aclose()
|
||||
|
||||
assert run.status == "running"
|
||||
assert tool_executions == 1
|
||||
assert agent_runs._RUNS[session_id] is run
|
||||
|
||||
resumed_events = []
|
||||
resumed = agent_runs.subscribe(session_id)
|
||||
async for event in resumed:
|
||||
resumed_events.append(event)
|
||||
if len(resumed_events) == 2:
|
||||
release.set()
|
||||
await run.task
|
||||
|
||||
assert resumed_events[:2] == [fallback, tool]
|
||||
assert resumed_events[-1] == "data: [DONE]\n\n"
|
||||
assert tool_executions == 1
|
||||
assert agent_runs._RUNS[session_id] is run
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
|
||||
# the SSE) cancels the upstream generator promptly — exercising the same
|
||||
|
||||
@@ -63,6 +63,23 @@ class TestSelfSummaryPrompt:
|
||||
|
||||
|
||||
class TestTrimForContext:
|
||||
def test_system_truncation_preserves_internal_route_metadata(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "persona\n\n" + ("agent prompt " * 2000),
|
||||
"_agent_injected": "merged_prompt",
|
||||
"_agent_base_message": {"role": "system", "content": "persona"},
|
||||
},
|
||||
{"role": "user", "content": "latest"},
|
||||
]
|
||||
|
||||
trimmed = trim_for_context(messages, context_length=1024, reserve_tokens=256)
|
||||
|
||||
system = next(message for message in trimmed if message.get("role") == "system")
|
||||
assert system["_agent_injected"] == "merged_prompt"
|
||||
assert system["_agent_base_message"] == {"role": "system", "content": "persona"}
|
||||
|
||||
def test_keeps_current_large_user_message_by_truncating(self):
|
||||
huge = "A" * 20000
|
||||
messages = [
|
||||
@@ -194,6 +211,50 @@ class TestMaybeCompactFourthMessage:
|
||||
assert len(result) == 3 and result[2] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deferred_compaction_persists_only_after_route_commit(monkeypatch):
|
||||
updates = []
|
||||
state = {}
|
||||
messages = [
|
||||
{"role": "system", "content": "system " * 100},
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
{"role": "user", "content": "five"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(cc, "get_context_length", lambda *args: 100)
|
||||
monkeypatch.setattr(cc, "resolve_endpoint", lambda *args, **kwargs: (None, None, None))
|
||||
|
||||
async def fake_summary(*args, **kwargs):
|
||||
return "route-specific summary"
|
||||
|
||||
monkeypatch.setattr(cc, "llm_call_async", fake_summary)
|
||||
monkeypatch.setattr(
|
||||
cc,
|
||||
"_update_session_history",
|
||||
lambda *args, **kwargs: updates.append((args, kwargs)),
|
||||
)
|
||||
|
||||
_compacted, _context, was_compacted = await cc.maybe_compact(
|
||||
object(),
|
||||
"https://candidate.example/v1",
|
||||
"candidate-model",
|
||||
messages,
|
||||
persist=False,
|
||||
compaction_state=state,
|
||||
)
|
||||
|
||||
assert was_compacted is True
|
||||
assert updates == []
|
||||
assert state["summary"] == "route-specific summary"
|
||||
assert cc.apply_compaction_state(object(), state) is True
|
||||
assert len(updates) == 1
|
||||
assert cc.apply_compaction_state(object(), state) is False
|
||||
assert len(updates) == 1
|
||||
|
||||
|
||||
class TestResearchPrimerPreserved:
|
||||
"""A research-spinoff primer (metadata research_spinoff_from) must never be
|
||||
trimmed away — it is the Discuss chat's sole knowledge base (drift fix)."""
|
||||
|
||||
@@ -55,6 +55,11 @@ def test_compose_files_forward_every_upload_limit_env_var():
|
||||
assert expected <= _compose_env_names(path), path.name
|
||||
|
||||
|
||||
def test_compose_files_forward_companion_base_url():
|
||||
for path in COMPOSE_FILES:
|
||||
assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name
|
||||
|
||||
|
||||
def test_default_compose_files_do_not_mount_host_docker_socket():
|
||||
for path in COMPOSE_FILES:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -50,6 +50,7 @@ async def test_edit_file_blocked_at_execution_for_non_admin(monkeypatch):
|
||||
_desc, result = await te.execute_tool_block(
|
||||
ToolBlock("edit_file", json.dumps({"path": p, "old_string": "a", "new_string": "b"})),
|
||||
owner="bob",
|
||||
security_context=te.NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("exit_code") == 1 and "admin" in result.get("error", "").lower()
|
||||
os.unlink(p)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,8 @@ def _patch_common(monkeypatch, exec_calls):
|
||||
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
||||
# These tests exercise tool-channel parsing, not owner authorization.
|
||||
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
||||
|
||||
async def _fake_exec(block, *a, **k):
|
||||
exec_calls.append(block)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user