mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-18 02:35:23 -04:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f70db19cc6 | |||
| 56ba144875 | |||
| 97a7f59fe7 | |||
| 24ace44888 | |||
| 93569b141b | |||
| 9a00401507 | |||
| 76562ae31d | |||
| 497f455da6 | |||
| dd20c2bc75 | |||
| a36b423a4e | |||
| 4e477741e7 | |||
| a2261c38c1 | |||
| bf56010aad | |||
| ee72d71872 | |||
| 2b519bf355 | |||
| d795d9a923 | |||
| 648db61b45 | |||
| 260ce8ba59 | |||
| 2f9ae43a58 | |||
| 293bbfabf4 | |||
| 0086399656 | |||
| 9d2989f386 | |||
| b5edbd3df7 | |||
| 33fe7276be | |||
| a031a94a2e | |||
| 4d10c16d02 | |||
| 745c10e0d7 | |||
| 6b7a4c1e70 | |||
| 422f23fb12 | |||
| 0f966d6b9f | |||
| 7b09491557 | |||
| fafaf089c5 | |||
| b58af4267b | |||
| 8ff76f083c | |||
| 2196869c86 | |||
| dd2e23c9af | |||
| facc50cb0f | |||
| 074a1e6eff | |||
| 2fab378c6a | |||
| 5bafc30622 | |||
| d6d2e17214 | |||
| f4e8990635 | |||
| fc3a5e555e | |||
| 270b8570fc | |||
| 0750486654 | |||
| d38e2cbc07 | |||
| 7fd937fa57 | |||
| c41caac438 | |||
| 1747c13133 | |||
| ffd0aaf69b | |||
| 81e7074d93 | |||
| f66a23d19d | |||
| f602819523 | |||
| 85a773ea02 | |||
| fb0a64fe4f | |||
| bcf46dafb9 |
@@ -15,6 +15,10 @@ build/
|
|||||||
# at runtime — never baked into the image. Mirrored in .gitignore.
|
# at runtime — never baked into the image. Mirrored in .gitignore.
|
||||||
secrets.env
|
secrets.env
|
||||||
secrets.env.*
|
secrets.env.*
|
||||||
|
secrets.env~
|
||||||
|
.secrets.env.swp
|
||||||
|
.secrets.env.swo
|
||||||
|
**/#secrets.env#
|
||||||
!secrets.env.example
|
!secrets.env.example
|
||||||
/data/
|
/data/
|
||||||
/logs/
|
/logs/
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ Manual development uses Python 3.11+:
|
|||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python -m uvicorn app:app --host 0.0.0.0 --port 7000
|
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['launcher.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='Odysseus',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=['static\\icon.ico'],
|
||||||
|
)
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='Odysseus',
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# app.py — slim orchestrator
|
# app.py — slim orchestrator
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def register_static_mime_types() -> None:
|
def register_static_mime_types() -> None:
|
||||||
@@ -113,12 +114,13 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ========= CORS =========
|
# ========= CORS =========
|
||||||
|
CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]
|
||||||
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
|
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
allow_methods=CORS_ALLOW_METHODS,
|
||||||
allow_headers=[
|
allow_headers=[
|
||||||
"Accept",
|
"Accept",
|
||||||
"Authorization",
|
"Authorization",
|
||||||
@@ -316,7 +318,7 @@ if AUTH_ENABLED:
|
|||||||
# (no admin cookie available in that context). Restricted to
|
# (no admin cookie available in that context). Restricted to
|
||||||
# loopback clients + matching token to keep it locked down.
|
# loopback clients + matching token to keep it locked down.
|
||||||
try:
|
try:
|
||||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT, INTERNAL_TOOL_USER
|
||||||
_hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
_hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
if _hdr and secrets.compare_digest(_hdr, _ITT) and _is_trusted_loopback(request):
|
if _hdr and secrets.compare_digest(_hdr, _ITT) and _is_trusted_loopback(request):
|
||||||
# Impersonation: when the agent's loopback call sets
|
# Impersonation: when the agent's loopback call sets
|
||||||
@@ -328,11 +330,11 @@ if AUTH_ENABLED:
|
|||||||
if _impersonate and _impersonate in getattr(_auth_mgr, "users", {}):
|
if _impersonate and _impersonate in getattr(_auth_mgr, "users", {}):
|
||||||
request.state.current_user = _impersonate
|
request.state.current_user = _impersonate
|
||||||
else:
|
else:
|
||||||
request.state.current_user = "internal-tool"
|
request.state.current_user = INTERNAL_TOOL_USER
|
||||||
request.state.api_token = False
|
request.state.api_token = False
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
except Exception:
|
except Exception as _e:
|
||||||
pass
|
logger.warning("Internal tool auth header check failed", exc_info=_e)
|
||||||
# Allow DIRECT localhost requests (internal service calls from
|
# Allow DIRECT localhost requests (internal service calls from
|
||||||
# heartbeats etc.). Tunnel/proxy-forwarded requests are excluded by
|
# heartbeats etc.). Tunnel/proxy-forwarded requests are excluded by
|
||||||
# _is_trusted_loopback so LOCALHOST_BYPASS can't be abused over a
|
# _is_trusted_loopback so LOCALHOST_BYPASS can't be abused over a
|
||||||
@@ -385,11 +387,10 @@ if AUTH_ENABLED:
|
|||||||
_db.close()
|
_db.close()
|
||||||
try:
|
try:
|
||||||
await _asyncio.to_thread(_do)
|
await _asyncio.to_thread(_do)
|
||||||
except Exception:
|
except Exception as _e:
|
||||||
pass
|
logger.debug("Failed to update token last_used_at", exc_info=_e)
|
||||||
_asyncio.create_task(_touch_last_used(matched_id))
|
_asyncio.create_task(_touch_last_used(matched_id))
|
||||||
# Keep bearer-token callers out of normal cookie/user
|
# Keep bearer-token callers out of normal cookie/user
|
||||||
# routes. API-aware routes can read api_token_owner.
|
|
||||||
request.state.current_user = "api"
|
request.state.current_user = "api"
|
||||||
request.state.api_token = True
|
request.state.api_token = True
|
||||||
request.state.api_token_id = matched_id
|
request.state.api_token_id = matched_id
|
||||||
@@ -438,7 +439,7 @@ class _RevalidatingStatic(StaticFiles):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", _RevalidatingStatic(directory="static"), name="static")
|
app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
# ========= GENERATED IMAGES =========
|
# ========= GENERATED IMAGES =========
|
||||||
@app.get("/api/generated-image/{filename}")
|
@app.get("/api/generated-image/{filename}")
|
||||||
@@ -464,8 +465,8 @@ async def serve_generated_image(filename: str, request: Request):
|
|||||||
_db.close()
|
_db.close()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception as _e:
|
||||||
pass
|
logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
|
||||||
ext = filename.rsplit('.', 1)[-1].lower()
|
ext = filename.rsplit('.', 1)[-1].lower()
|
||||||
mime = {
|
mime = {
|
||||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||||
@@ -528,6 +529,7 @@ memory_vector = components.get("memory_vector")
|
|||||||
upload_handler = components["upload_handler"]
|
upload_handler = components["upload_handler"]
|
||||||
app.state.upload_handler = upload_handler
|
app.state.upload_handler = upload_handler
|
||||||
personal_docs_mgr = components["personal_docs_manager"]
|
personal_docs_mgr = components["personal_docs_manager"]
|
||||||
|
app.state.personal_docs_manager = personal_docs_mgr
|
||||||
api_key_manager = components["api_key_manager"]
|
api_key_manager = components["api_key_manager"]
|
||||||
preset_manager = components["preset_manager"]
|
preset_manager = components["preset_manager"]
|
||||||
chat_processor = components["chat_processor"]
|
chat_processor = components["chat_processor"]
|
||||||
@@ -1171,3 +1173,12 @@ async def _shutdown_event():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MCP shutdown error: {e}")
|
logger.warning(f"MCP shutdown error: {e}")
|
||||||
logger.info("Application shutdown complete")
|
logger.info("Application shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
||||||
|
bind_port = int(os.getenv("APP_PORT", "7000"))
|
||||||
|
|
||||||
|
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
Build a portable Windows distribution for Odysseus.
|
||||||
|
|
||||||
|
Output layout:
|
||||||
|
dist\Odysseus\Odysseus.exe
|
||||||
|
dist\Odysseus\static\...
|
||||||
|
dist\Odysseus\scripts\...
|
||||||
|
dist\Odysseus\mcp_servers\...
|
||||||
|
dist\Odysseus\services\hwfit\data\...
|
||||||
|
|
||||||
|
The app then keeps using its normal filesystem layout when frozen.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\build-windows-portable.ps1
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location -Path $PSScriptRoot
|
||||||
|
|
||||||
|
function Write-Step($msg) { Write-Host ""; Write-Host ("==> " + $msg) -ForegroundColor Cyan }
|
||||||
|
function Fail($msg) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("ERROR: " + $msg) -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Checking for Python"
|
||||||
|
$pyExe = $null
|
||||||
|
if (Test-Path ".\.venv\Scripts\python.exe") {
|
||||||
|
$pyExe = (Resolve-Path ".\.venv\Scripts\python.exe").Path
|
||||||
|
} else {
|
||||||
|
foreach ($c in @("py", "python")) {
|
||||||
|
$cmd = Get-Command $c -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { $pyExe = $cmd.Source; break }
|
||||||
|
}
|
||||||
|
if ($pyExe -like "*WindowsApps*python.exe") {
|
||||||
|
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pyCmd) {
|
||||||
|
$pyExe = $pyCmd.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $pyExe) {
|
||||||
|
Fail "Python not found on PATH. Install Python 3.11+ first."
|
||||||
|
}
|
||||||
|
Write-Host ("Using Python: " + $pyExe)
|
||||||
|
|
||||||
|
Write-Step "Installing build dependencies"
|
||||||
|
& $pyExe -m pip install --upgrade pip --quiet
|
||||||
|
& $pyExe -m pip install -r requirements.txt pyinstaller pystray Pillow
|
||||||
|
if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed." }
|
||||||
|
|
||||||
|
Write-Step "Building portable exe bundle"
|
||||||
|
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$dataArgs = @(
|
||||||
|
"--add-data", "static;static",
|
||||||
|
"--add-data", "scripts;scripts",
|
||||||
|
"--add-data", "mcp_servers;mcp_servers",
|
||||||
|
"--add-data", "services/hwfit/data;services/hwfit/data",
|
||||||
|
"--add-data", "config;config",
|
||||||
|
"--add-data", ".env.example;.env.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
& $pyExe -m PyInstaller --noconfirm --clean --onedir --noconsole --icon=static/icon.ico --name Odysseus @dataArgs launcher.py
|
||||||
|
if ($LASTEXITCODE -ne 0) { Fail "PyInstaller build failed." }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Build complete." -ForegroundColor Green
|
||||||
|
Write-Host "Portable app folder: $PSScriptRoot\dist\Odysseus" -ForegroundColor Green
|
||||||
|
Write-Host "Distribute the whole folder (or zip it) so static assets and scripts stay with the exe." -ForegroundColor Green
|
||||||
+17
-3
@@ -5,8 +5,9 @@ offers and pair to it, without duplicating any LLM logic.
|
|||||||
|
|
||||||
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
||||||
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
||||||
API token. The read endpoints (ping/info/models) accept either; the pairing
|
API token. Ping/info accept either credential type, models requires a chat-
|
||||||
endpoints are admin-cookie only.
|
scoped API token for bearer callers, and the pairing endpoints are admin-cookie
|
||||||
|
only.
|
||||||
|
|
||||||
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
||||||
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
||||||
@@ -18,7 +19,7 @@ on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
@@ -52,6 +53,18 @@ def owner_can_see(row_owner, owner) -> bool:
|
|||||||
return row_owner is None or row_owner == owner
|
return row_owner is None or row_owner == owner
|
||||||
|
|
||||||
|
|
||||||
|
def require_models_scope(request: Request) -> None:
|
||||||
|
"""Require the companion chat scope for bearer-token model inventory."""
|
||||||
|
if not getattr(request.state, "api_token", False):
|
||||||
|
return
|
||||||
|
scopes = getattr(request.state, "api_token_scopes", None) or []
|
||||||
|
if isinstance(scopes, str):
|
||||||
|
scopes = [scope.strip() for scope in scopes.split(",")]
|
||||||
|
scope_set = {str(scope).strip() for scope in scopes if str(scope).strip()}
|
||||||
|
if _pairing.COMPANION_SCOPE not in scope_set:
|
||||||
|
raise HTTPException(403, "API token requires chat scope")
|
||||||
|
|
||||||
|
|
||||||
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
||||||
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
||||||
cache, so the new token is accepted on the very next request without a server
|
cache, so the new token is accepted on the very next request without a server
|
||||||
@@ -103,6 +116,7 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
||||||
material.
|
material.
|
||||||
"""
|
"""
|
||||||
|
require_models_scope(request)
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
|||||||
+22
-8
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
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 = {
|
DEFAULT_PRIVILEGES = {
|
||||||
"can_use_agent": True,
|
"can_use_agent": True,
|
||||||
@@ -47,7 +48,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
|||||||
# backwards for this sentinel.
|
# backwards for this sentinel.
|
||||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||||
|
|
||||||
from src.constants import AUTH_FILE
|
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
|||||||
# of those names would be denied an assistant and inconsistently owner-scoped.
|
# 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
|
# 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.)
|
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
||||||
RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"})
|
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
||||||
|
|
||||||
|
|
||||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||||
@@ -243,6 +244,15 @@ class AuthManager:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return len(self.users) > 0
|
return len(self.users) > 0
|
||||||
|
|
||||||
|
def policy(self) -> dict:
|
||||||
|
"""Return public auth policy constants for the frontend."""
|
||||||
|
return {
|
||||||
|
"password_min_length": PASSWORD_MIN_LENGTH,
|
||||||
|
"reserved_usernames": sorted(RESERVED_USERNAMES),
|
||||||
|
"signup_enabled": self.signup_enabled,
|
||||||
|
"session_days": TOKEN_TTL // 86400,
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Account management
|
# Account management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -573,16 +583,20 @@ class AuthManager:
|
|||||||
return None
|
return None
|
||||||
return self.create_session_trusted(username)
|
return self.create_session_trusted(username)
|
||||||
|
|
||||||
def create_session_trusted(self, username: str) -> str:
|
def create_session_trusted(self, username: str) -> Optional[str]:
|
||||||
"""Issue a session token for an already-verified user.
|
"""Issue a session token for an already-verified user.
|
||||||
Call only after verify_password (and TOTP if enabled) have passed."""
|
Call only after verify_password (and TOTP if enabled) have passed."""
|
||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
with self._sessions_lock:
|
with self._config_lock:
|
||||||
self._sessions[token] = {
|
if username not in self.users:
|
||||||
"username": username,
|
logger.warning("Refused to issue session for missing user '%s'", username)
|
||||||
"expiry": time.time() + TOKEN_TTL,
|
return None
|
||||||
}
|
with self._sessions_lock:
|
||||||
|
self._sessions[token] = {
|
||||||
|
"username": username,
|
||||||
|
"expiry": time.time() + TOKEN_TTL,
|
||||||
|
}
|
||||||
self._save_sessions()
|
self._save_sessions()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|||||||
+49
-2
@@ -2,12 +2,15 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||||
from sqlalchemy.orm import relationship, sessionmaker, backref
|
from sqlalchemy.orm import relationship, sessionmaker, backref
|
||||||
|
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Create base class for declarative models
|
# Create base class for declarative models
|
||||||
@@ -29,9 +32,26 @@ class TimestampMixin:
|
|||||||
def updated_at(cls):
|
def updated_at(cls):
|
||||||
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
|
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
|
||||||
|
|
||||||
# Get database URL from environment, default to SQLite in DATA_DIR
|
# Ensure the writable data directory exists before SQLite connects.
|
||||||
from src.constants import DATA_DIR, AUTH_FILE, MEMORY_FILE, USER_PREFS_FILE, SETTINGS_FILE
|
from src.constants import DATA_DIR, AUTH_FILE, MEMORY_FILE, USER_PREFS_FILE, SETTINGS_FILE
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR}/app.db")
|
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_database_url() -> str:
|
||||||
|
return f"sqlite:///{Path(DATA_DIR) / 'app.db'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sqlite_url(url: str) -> str:
|
||||||
|
if not url.startswith("sqlite:///"):
|
||||||
|
return url
|
||||||
|
db_path = url.replace("sqlite:///", "", 1)
|
||||||
|
if db_path == ":memory:" or os.path.isabs(db_path):
|
||||||
|
return url
|
||||||
|
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
|
# Get database URL from environment, default to SQLite in DATA_DIR
|
||||||
|
DATABASE_URL = _normalize_sqlite_url(os.getenv("DATABASE_URL", _default_database_url()))
|
||||||
|
|
||||||
# Create engine
|
# Create engine
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
@@ -324,6 +344,13 @@ class EmailAccount(TimestampMixin, Base):
|
|||||||
smtp_password = Column(String, default="")
|
smtp_password = Column(String, default="")
|
||||||
|
|
||||||
from_address = Column(String, default="")
|
from_address = Column(String, default="")
|
||||||
|
display_name = Column(String, nullable=True) # "Hriday Ranka" — used in From: header
|
||||||
|
|
||||||
|
# OAuth2 (Google / Google Workspace). Tokens stored encrypted via secret_storage.
|
||||||
|
oauth_provider = Column(String, nullable=True) # "google" or None
|
||||||
|
oauth_access_token = Column(String, nullable=True) # encrypted
|
||||||
|
oauth_refresh_token = Column(String, nullable=True) # encrypted
|
||||||
|
oauth_token_expiry = Column(String, nullable=True) # unix timestamp string
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_email_accounts_owner_default', 'owner', 'is_default'),
|
Index('ix_email_accounts_owner_default', 'owner', 'is_default'),
|
||||||
@@ -1427,6 +1454,25 @@ def _migrate_add_task_automation_columns():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"task automation migration: {e}")
|
logging.getLogger(__name__).warning(f"task automation migration: {e}")
|
||||||
|
|
||||||
|
def _migrate_add_email_oauth_columns():
|
||||||
|
"""Add Google OAuth and display_name columns to email_accounts if missing."""
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(email_accounts)"))]
|
||||||
|
for col, typedef in [
|
||||||
|
("oauth_provider", "TEXT"),
|
||||||
|
("oauth_access_token", "TEXT"),
|
||||||
|
("oauth_refresh_token", "TEXT"),
|
||||||
|
("oauth_token_expiry", "TEXT"),
|
||||||
|
("display_name", "TEXT"),
|
||||||
|
]:
|
||||||
|
if col not in cols:
|
||||||
|
conn.execute(text(f"ALTER TABLE email_accounts ADD COLUMN {col} {typedef}"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"email oauth columns migration: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_oauth_config():
|
def _migrate_add_oauth_config():
|
||||||
"""Add oauth_config column to mcp_servers table if missing."""
|
"""Add oauth_config column to mcp_servers table if missing."""
|
||||||
try:
|
try:
|
||||||
@@ -1771,6 +1817,7 @@ def init_db():
|
|||||||
_migrate_add_tidy_verdict()
|
_migrate_add_tidy_verdict()
|
||||||
_migrate_add_doc_source_email_cols()
|
_migrate_add_doc_source_email_cols()
|
||||||
_migrate_add_oauth_config()
|
_migrate_add_oauth_config()
|
||||||
|
_migrate_add_email_oauth_columns()
|
||||||
_migrate_add_task_automation_columns()
|
_migrate_add_task_automation_columns()
|
||||||
_migrate_add_disabled_tools()
|
_migrate_add_disabled_tools()
|
||||||
_migrate_add_mcp_oauth_tokens_column()
|
_migrate_add_mcp_oauth_tokens_column()
|
||||||
|
|||||||
+6
-7
@@ -15,6 +15,8 @@ from starlette.responses import Response
|
|||||||
# same value from this module. Never persisted or exposed externally.
|
# 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_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
|
||||||
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
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 is_cors_preflight(method: str, headers) -> bool:
|
def is_cors_preflight(method: str, headers) -> bool:
|
||||||
@@ -39,7 +41,7 @@ def require_admin(request: Request):
|
|||||||
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
||||||
return
|
return
|
||||||
if getattr(request.state, "current_user", None) == "internal-tool":
|
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -65,10 +67,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
# Tool render endpoints are served inside iframes — allow framing by self
|
# Tool render endpoints
|
||||||
is_tool_render = path.startswith("/api/tools/") and path.endswith("/render")
|
is_tool_render = path.startswith("/api/tools/") and path.endswith("/render")
|
||||||
# PDF previews are embedded by the in-app document library. Keep the
|
# Document library PDF preview endpoint
|
||||||
# exception route-scoped so normal app pages remain unframeable.
|
|
||||||
is_document_pdf_preview = path.startswith("/api/document/") and path.endswith("/render-pdf")
|
is_document_pdf_preview = path.startswith("/api/document/") and path.endswith("/render-pdf")
|
||||||
# Visual report pages are self-contained HTML — need inline scripts + external images
|
# Visual report pages are self-contained HTML — need inline scripts + external images
|
||||||
is_report = path.startswith("/api/research/report/")
|
is_report = path.startswith("/api/research/report/")
|
||||||
@@ -95,9 +96,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
"frame-ancestors 'none'"
|
"frame-ancestors 'none'"
|
||||||
)
|
)
|
||||||
elif is_tool_render:
|
elif is_tool_render:
|
||||||
# Tool iframe content: skip all framing headers — the iframe's
|
# Skip framing headers for tools.
|
||||||
# sandbox="allow-scripts" attribute provides isolation.
|
|
||||||
# Don't overwrite the route's own restrictive CSP either.
|
|
||||||
pass
|
pass
|
||||||
elif is_document_pdf_preview:
|
elif is_document_pdf_preview:
|
||||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ services:
|
|||||||
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||||
|
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ services:
|
|||||||
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||||
|
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ services:
|
|||||||
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||||
|
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
+52
-19
@@ -13,6 +13,8 @@ set -e
|
|||||||
|
|
||||||
PUID="${PUID:-1000}"
|
PUID="${PUID:-1000}"
|
||||||
PGID="${PGID:-1000}"
|
PGID="${PGID:-1000}"
|
||||||
|
GOSU_BIN="$(command -v gosu)"
|
||||||
|
PYTHON_BIN="$(command -v python)"
|
||||||
|
|
||||||
# Reuse an existing matching group/user if the host's UID/GID already
|
# Reuse an existing matching group/user if the host's UID/GID already
|
||||||
# corresponds to one in /etc/passwd (e.g. when the image is rebuilt
|
# corresponds to one in /etc/passwd (e.g. when the image is rebuilt
|
||||||
@@ -24,26 +26,57 @@ if ! getent passwd "$PUID" >/dev/null 2>&1; then
|
|||||||
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
|
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Repair ownership on every writable path the app touches at runtime.
|
mount_root_for() {
|
||||||
#
|
awk -v target="$1" '$5 == target { print $4; exit }' /proc/self/mountinfo 2>/dev/null || true
|
||||||
# Bind-mounted dirs (/app/data, /app/logs) are the obvious ones, but
|
}
|
||||||
# the app ALSO writes inside the image's own source tree at runtime:
|
|
||||||
# - services/cache/{search,content}/* (search cache LRU)
|
is_broad_mount_root() {
|
||||||
# - services/search_analytics.json
|
case "$1" in
|
||||||
# - services/search_engine_error.log
|
/|/home|/srv|/var|/usr|/opt|/tmp|/mnt|/media)
|
||||||
# - services/tts cache, etc.
|
return 0
|
||||||
# These dirs were created as root during `docker build`, so dropping
|
;;
|
||||||
# to PUID:PGID would otherwise crash on the first import that tries
|
esac
|
||||||
# to mkdir them. Chown the whole /app tree — fast (<1s on this size)
|
return 1
|
||||||
# and idempotent via the `-not -uid` filter so we only touch files
|
}
|
||||||
# that need fixing.
|
|
||||||
for dir in /app /app/data /app/logs; do
|
repair_tree_ownership() {
|
||||||
|
dir="$1"
|
||||||
if [ -d "$dir" ]; then
|
if [ -d "$dir" ]; then
|
||||||
# `find ... -not -uid` keeps this O(touched-files), not
|
find "$dir" -xdev -not -uid "$PUID" -print0 2>/dev/null \
|
||||||
# O(everything), so terabyte-sized maildirs don't slow startup.
|
|
||||||
find "$dir" -not -uid "$PUID" -print0 2>/dev/null \
|
|
||||||
| xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
|
| xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
repair_app_tree_ownership() {
|
||||||
|
if [ -d /app ]; then
|
||||||
|
find /app -xdev \
|
||||||
|
\( -path /app/data -o -path /app/logs -o -path /app/.ssh -o -path /app/.cache -o -path /app/.local \) -prune \
|
||||||
|
-o -not -uid "$PUID" -print0 2>/dev/null \
|
||||||
|
| xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
repair_bind_mount_ownership() {
|
||||||
|
dir="$1"
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
mount_root="$(mount_root_for "$dir")"
|
||||||
|
if is_broad_mount_root "$mount_root"; then
|
||||||
|
echo "Skipping recursive ownership repair for $dir because it maps to broad host path $mount_root" >&2
|
||||||
|
chown "$PUID:$PGID" "$dir" 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
repair_tree_ownership "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Repair image-owned writable paths without walking into bind-mounted host
|
||||||
|
# trees, then repair the app-owned mount roots separately.
|
||||||
|
repair_app_tree_ownership
|
||||||
|
for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.local; do
|
||||||
|
repair_bind_mount_ownership "$dir"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Cookbook installs vllm/etc. via `pip install --user`, which pulls
|
# Cookbook installs vllm/etc. via `pip install --user`, which pulls
|
||||||
@@ -83,9 +116,9 @@ export PATH="/app/.local/bin:$PATH"
|
|||||||
# Run first-time setup as the app user so data/ files get the right ownership.
|
# Run first-time setup as the app user so data/ files get the right ownership.
|
||||||
# setup.py is idempotent — skips auth.json / .env if they already exist.
|
# setup.py is idempotent — skips auth.json / .env if they already exist.
|
||||||
# || true so a setup failure never prevents the container from starting.
|
# || true so a setup failure never prevents the container from starting.
|
||||||
gosu "$PUID:$PGID" python /app/setup.py || true
|
"$GOSU_BIN" "$PUID:$PGID" "$PYTHON_BIN" /app/setup.py || true
|
||||||
|
|
||||||
# Drop root and run the actual app. `gosu` is preferred over `su` /
|
# Drop root and run the actual app. `gosu` is preferred over `su` /
|
||||||
# `sudo` because it cleans up the process tree (no extra shell layer)
|
# `sudo` because it cleans up the process tree (no extra shell layer)
|
||||||
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
|
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
|
||||||
exec gosu "$PUID:$PGID" "$@"
|
exec "$GOSU_BIN" "$PUID:$PGID" "$@"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1003 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB |
+14
-9
@@ -1,14 +1,16 @@
|
|||||||
# Security CI guide
|
# Security CI guide
|
||||||
|
|
||||||
This project runs a set of automated security checks on every pull request and
|
This project runs a set of automated security checks on pull requests and
|
||||||
on every push to `main`. This page explains what each one does, whether it can
|
selected branch pushes. This page explains what each one does, whether it can
|
||||||
block a merge, and the few one-time settings you should turn on to get the full
|
block a merge, and the few one-time settings you should turn on to get the full
|
||||||
benefit.
|
benefit.
|
||||||
|
|
||||||
## What runs, and why
|
## What runs, and why
|
||||||
|
|
||||||
Each check lives in its own file under `.github/workflows/`. They run
|
Most checks live in files under `.github/workflows/`. CodeQL is configured
|
||||||
automatically; you do not start them.
|
through GitHub's code scanning default setup, so it appears as a dynamic GitHub
|
||||||
|
workflow instead of a checked-in workflow file. They run automatically; you do
|
||||||
|
not start them.
|
||||||
|
|
||||||
| Check | What it protects against | Blocks a merge? |
|
| Check | What it protects against | Blocks a merge? |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -88,11 +90,14 @@ let the workflows run on one pull request first, then add them here.
|
|||||||
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
||||||
powers Dependency review and Dependabot.
|
powers Dependency review and Dependabot.
|
||||||
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
||||||
4. Under **Code scanning**, you have two ways to scan the app code with CodeQL:
|
4. Under **Code scanning**, use **Set up -> Default** for CodeQL. GitHub then
|
||||||
- The included `codeql.yml` workflow already scans `main` and runs weekly.
|
runs CodeQL as a dynamic workflow without the fork-token limitations that
|
||||||
- To also scan **pull requests** (recommended, since most contributions come
|
affect checked-in advanced workflows.
|
||||||
from forks), click **Set up -> Default** under Code scanning. GitHub then
|
|
||||||
runs CodeQL on pull requests for you, with no token limitations.
|
Do not also add a checked-in CodeQL workflow while default setup is enabled:
|
||||||
|
GitHub rejects advanced CodeQL uploads when default setup is active. If the
|
||||||
|
project later needs an advanced CodeQL workflow, disable default setup first
|
||||||
|
and keep only one CodeQL publishing path active.
|
||||||
|
|
||||||
## Keeping it current
|
## Keeping it current
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ if (-not $pyExe) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($pyExe -like "*WindowsApps*python.exe") {
|
||||||
|
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pyCmd) {
|
||||||
|
$pyExe = $pyCmd.Source
|
||||||
|
$pyArgs = @("-3.11")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (-not $pyExe) {
|
if (-not $pyExe) {
|
||||||
Fail "Couldn't find Python 3.11+ for Windows setup. Install Python 3.11+ (or open the Python launcher with 'py -3.11') from https://www.python.org/downloads/, then re-run this script."
|
Fail "Couldn't find Python 3.11+ for Windows setup. Install Python 3.11+ (or open the Python launcher with 'py -3.11') from https://www.python.org/downloads/, then re-run this script."
|
||||||
}
|
}
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# launcher.py
|
||||||
|
"""Dedicated entrypoint for the standalone Windows portable launcher.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Immediate GUI splash screen creation using tkinter.
|
||||||
|
- Suppressing console stream crashes in windowed GUI mode via NullWriter.
|
||||||
|
- Spawning system tray icon via pystray and Pillow (lazy-loaded).
|
||||||
|
- Auto-opening default browser pointing to the running backend.
|
||||||
|
- Launching the FastAPI server (importing and running app.py).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
# Define a dummy NullWriter to suppress standard stream crashes (isatty etc.) in GUI mode
|
||||||
|
class NullWriter:
|
||||||
|
def write(self, text):
|
||||||
|
pass
|
||||||
|
def flush(self):
|
||||||
|
pass
|
||||||
|
def isatty(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if sys.stdout is None:
|
||||||
|
sys.stdout = NullWriter()
|
||||||
|
if sys.stderr is None:
|
||||||
|
sys.stderr = NullWriter()
|
||||||
|
|
||||||
|
|
||||||
|
splash_root = None
|
||||||
|
|
||||||
|
# If running from a frozen PyInstaller bundle, launch the splash screen IMMEDIATELY
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
def show_splash_instantly():
|
||||||
|
global splash_root
|
||||||
|
try:
|
||||||
|
splash_root = tk.Tk()
|
||||||
|
splash_root.title("Odysseus")
|
||||||
|
splash_root.overrideredirect(True)
|
||||||
|
splash_root.configure(bg="#1a1c23")
|
||||||
|
|
||||||
|
# Accented borders
|
||||||
|
splash_root.config(highlightbackground="#e06c75", highlightcolor="#e06c75", highlightthickness=1)
|
||||||
|
|
||||||
|
w, h = 360, 160
|
||||||
|
ws = splash_root.winfo_screenwidth()
|
||||||
|
hs = splash_root.winfo_screenheight()
|
||||||
|
x = (ws - w) // 2
|
||||||
|
y = (hs - h) // 2
|
||||||
|
splash_root.geometry(f"{w}x{h}+{x}+{y}")
|
||||||
|
|
||||||
|
tk.Label(splash_root, text="⛵ Odysseus", font=("Segoe UI", 22, "bold"), bg="#1a1c23", fg="#e06c75").pack(pady=(22, 2))
|
||||||
|
tk.Label(splash_root, text="Launching background services...", font=("Segoe UI", 10), bg="#1a1c23", fg="#d1d4e0").pack(pady=2)
|
||||||
|
tk.Label(splash_root, text="Please wait, this will take a few seconds.", font=("Segoe UI", 8, "italic"), bg="#1a1c23", fg="#5c6370").pack(pady=(12, 0))
|
||||||
|
|
||||||
|
splash_root.attributes("-topmost", True)
|
||||||
|
splash_root.mainloop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Launch the GUI splash screen immediately on a background thread
|
||||||
|
threading.Thread(target=show_splash_instantly, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def create_tray_image():
|
||||||
|
# Generate a beautiful 64x64 icon matching Odysseus brand red accent (#e06c75)
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
image = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
|
||||||
|
dc = ImageDraw.Draw(image)
|
||||||
|
accent_red = (224, 108, 117, 255)
|
||||||
|
light_red = (224, 108, 117, 150)
|
||||||
|
|
||||||
|
# Draw premium sailing boat
|
||||||
|
dc.polygon([(32, 10), (32, 45), (12, 45)], fill=accent_red)
|
||||||
|
dc.polygon([(32, 18), (32, 45), (48, 45)], fill=light_red)
|
||||||
|
dc.polygon([(8, 48), (56, 48), (44, 56), (20, 56)], fill=accent_red)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def on_open_browser(icon, item, url):
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
|
def on_exit(icon, item):
|
||||||
|
icon.stop()
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_system_tray(url):
|
||||||
|
try:
|
||||||
|
import pystray
|
||||||
|
icon_img = create_tray_image()
|
||||||
|
menu = (
|
||||||
|
pystray.MenuItem('Open Odysseus', lambda icon, item: on_open_browser(icon, item, url), default=True),
|
||||||
|
pystray.MenuItem('Exit', on_exit)
|
||||||
|
)
|
||||||
|
tray_icon = pystray.Icon(
|
||||||
|
"Odysseus",
|
||||||
|
icon_img,
|
||||||
|
"Odysseus",
|
||||||
|
menu
|
||||||
|
)
|
||||||
|
tray_icon.run()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def open_browser(url):
|
||||||
|
# Allow uvicorn and app lifecycles to complete warmups
|
||||||
|
time.sleep(3.5)
|
||||||
|
|
||||||
|
# Safely close the splash screen
|
||||||
|
try:
|
||||||
|
global splash_root
|
||||||
|
if splash_root:
|
||||||
|
splash_root.after(0, splash_root.destroy)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
# Import the FastAPI app from app.py
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
||||||
|
bind_port = int(os.getenv("APP_PORT", "7000"))
|
||||||
|
url = f"http://{bind_host}:{bind_port}"
|
||||||
|
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Start browser manager thread
|
||||||
|
threading.Thread(target=open_browser, args=(url,), daemon=True).start()
|
||||||
|
# Start system tray manager thread
|
||||||
|
threading.Thread(target=setup_system_tray, args=(url,), daemon=True).start()
|
||||||
|
|
||||||
|
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
||||||
+89
-12
@@ -23,6 +23,7 @@ import os.path
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.stdio import stdio_server
|
from mcp.server.stdio import stdio_server
|
||||||
@@ -55,6 +56,8 @@ def _uid_fetch_rows(data) -> list:
|
|||||||
# flat keys when no DB row matches (legacy single-account behaviour).
|
# flat keys when no DB row matches (legacy single-account behaviour).
|
||||||
|
|
||||||
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
|
_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict
|
||||||
|
_MCP_OWNER_ARG = "_odysseus_owner"
|
||||||
|
_CURRENT_OWNER: ContextVar[str | None] = ContextVar("email_mcp_owner", default=None)
|
||||||
|
|
||||||
|
|
||||||
def _clean_header_value(value) -> str:
|
def _clean_header_value(value) -> str:
|
||||||
@@ -68,6 +71,45 @@ def _db_path() -> Path:
|
|||||||
return Path(APP_DB)
|
return Path(APP_DB)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_owner() -> str:
|
||||||
|
owner = _CURRENT_OWNER.get()
|
||||||
|
return str(owner or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _account_visible_to_owner(row: dict, owner: str) -> bool:
|
||||||
|
row_owner = str(row.get("owner") or "").strip()
|
||||||
|
if row_owner == owner:
|
||||||
|
return True
|
||||||
|
if row_owner:
|
||||||
|
return False
|
||||||
|
# Legacy ownerless accounts are only visible to a scoped caller when the
|
||||||
|
# mailbox itself matches the owner, mirroring the HTTP email route fallback.
|
||||||
|
owner_l = owner.lower()
|
||||||
|
return owner_l in {
|
||||||
|
str(row.get("imap_user") or "").strip().lower(),
|
||||||
|
str(row.get("from_address") or "").strip().lower(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_accounts_for_owner(rows: list[dict]) -> list[dict]:
|
||||||
|
owner = _current_owner()
|
||||||
|
if owner:
|
||||||
|
return [r for r in rows if _account_visible_to_owner(r, owner)]
|
||||||
|
|
||||||
|
owners = {str(r.get("owner") or "").strip() for r in rows if str(r.get("owner") or "").strip()}
|
||||||
|
if len(owners) > 1:
|
||||||
|
return []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
|
||||||
|
if _current_owner():
|
||||||
|
return False
|
||||||
|
rows = rows if rows is not None else _read_accounts_from_db()
|
||||||
|
owners = {str(r.get("owner") or "").strip() for r in rows if str(r.get("owner") or "").strip()}
|
||||||
|
return len(owners) > 1
|
||||||
|
|
||||||
|
|
||||||
def _load_email_writing_style() -> str:
|
def _load_email_writing_style() -> str:
|
||||||
"""Return the existing Settings > Email > Writing Style value."""
|
"""Return the existing Settings > Email > Writing Style value."""
|
||||||
try:
|
try:
|
||||||
@@ -121,9 +163,8 @@ def _default_document_owner() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _list_accounts_raw() -> list:
|
def _read_accounts_from_db() -> list:
|
||||||
"""Return list of dicts from the email_accounts table. Empty list if table
|
"""Return all enabled email account rows. Empty list if missing. Never raises."""
|
||||||
missing or empty. Never raises."""
|
|
||||||
path = _db_path()
|
path = _db_path()
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -131,9 +172,10 @@ def _list_accounts_raw() -> list:
|
|||||||
conn = sqlite3.connect(str(path))
|
conn = sqlite3.connect(str(path))
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
columns = {r[1] for r in conn.execute("PRAGMA table_info(email_accounts)").fetchall()}
|
columns = {r[1] for r in conn.execute("PRAGMA table_info(email_accounts)").fetchall()}
|
||||||
|
owner_select = "owner" if "owner" in columns else "NULL AS owner"
|
||||||
smtp_security_select = "smtp_security" if "smtp_security" in columns else "'' AS smtp_security"
|
smtp_security_select = "smtp_security" if "smtp_security" in columns else "'' AS smtp_security"
|
||||||
rows = conn.execute(f"""
|
rows = conn.execute(f"""
|
||||||
SELECT id, name, is_default, enabled,
|
SELECT id, {owner_select}, name, is_default, enabled,
|
||||||
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
||||||
smtp_host, smtp_port, {smtp_security_select}, smtp_user, smtp_password, from_address
|
smtp_host, smtp_port, {smtp_security_select}, smtp_user, smtp_password, from_address
|
||||||
FROM email_accounts WHERE enabled = 1
|
FROM email_accounts WHERE enabled = 1
|
||||||
@@ -147,11 +189,15 @@ def _list_accounts_raw() -> list:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _resolve_account(selector: str | None) -> dict | None:
|
def _list_accounts_raw() -> list:
|
||||||
|
"""Return owner-visible email account rows for the active MCP call."""
|
||||||
|
return _filter_accounts_for_owner(_read_accounts_from_db())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_account_from_rows(rows: list[dict], selector: str | None) -> dict | None:
|
||||||
"""Given a selector (None = default, or a name/user/id string), return the
|
"""Given a selector (None = default, or a name/user/id string), return the
|
||||||
matching row or None. Matching is case-insensitive substring on name +
|
matching row or None. Matching is case-insensitive substring on name +
|
||||||
imap_user + from_address, plus exact id match."""
|
imap_user + from_address, plus exact id match."""
|
||||||
rows = _list_accounts_raw()
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
if not selector:
|
if not selector:
|
||||||
@@ -186,6 +232,10 @@ def _resolve_account(selector: str | None) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_account(selector: str | None) -> dict | None:
|
||||||
|
return _resolve_account_from_rows(_list_accounts_raw(), selector)
|
||||||
|
|
||||||
|
|
||||||
def _load_config(account: str | None = None) -> dict:
|
def _load_config(account: str | None = None) -> dict:
|
||||||
"""Return the full config dict for the requested account (or default).
|
"""Return the full config dict for the requested account (or default).
|
||||||
|
|
||||||
@@ -194,7 +244,7 @@ def _load_config(account: str | None = None) -> dict:
|
|||||||
2. env vars + settings.json flat keys (legacy)
|
2. env vars + settings.json flat keys (legacy)
|
||||||
3. hardcoded fallbacks (localhost:31143 etc.)
|
3. hardcoded fallbacks (localhost:31143 etc.)
|
||||||
"""
|
"""
|
||||||
cache_key = (account or "").strip().lower() or "__default__"
|
cache_key = (_current_owner(), (account or "").strip().lower() or "__default__")
|
||||||
if cache_key in _ACCOUNT_CACHE:
|
if cache_key in _ACCOUNT_CACHE:
|
||||||
return _ACCOUNT_CACHE[cache_key]
|
return _ACCOUNT_CACHE[cache_key]
|
||||||
|
|
||||||
@@ -223,8 +273,11 @@ def _load_config(account: str | None = None) -> dict:
|
|||||||
"account_name": None,
|
"account_name": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
rows = _list_accounts_raw()
|
raw_rows = _read_accounts_from_db()
|
||||||
row = _resolve_account(account)
|
rows = _filter_accounts_for_owner(raw_rows)
|
||||||
|
row = _resolve_account_from_rows(rows, account)
|
||||||
|
if _current_owner() and raw_rows and not rows:
|
||||||
|
raise ValueError("No email account is configured for the authenticated owner")
|
||||||
if account and rows and not row:
|
if account and rows and not row:
|
||||||
available = ", ".join(
|
available = ", ".join(
|
||||||
f"{r.get('name') or r.get('imap_user')} <{r.get('imap_user') or r.get('from_address') or '?'}>"
|
f"{r.get('name') or r.get('imap_user')} <{r.get('imap_user') or r.get('from_address') or '?'}>"
|
||||||
@@ -953,7 +1006,7 @@ def _stash_agent_draft(*, to, subject, body, in_reply_to=None, references=None,
|
|||||||
now,
|
now,
|
||||||
account or None,
|
account or None,
|
||||||
"agent_draft",
|
"agent_draft",
|
||||||
"",
|
_current_owner(),
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1139,7 +1192,7 @@ def _create_email_draft_document(
|
|||||||
doc_id = str(uuid.uuid4())
|
doc_id = str(uuid.uuid4())
|
||||||
ver_id = str(uuid.uuid4())
|
ver_id = str(uuid.uuid4())
|
||||||
doc_title = (title or subject or "Email draft").strip() or "Email draft"
|
doc_title = (title or subject or "Email draft").strip() or "Email draft"
|
||||||
doc_owner = _default_document_owner()
|
doc_owner = _current_owner() or _default_document_owner()
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -1925,10 +1978,22 @@ async def list_tools() -> list[Tool]:
|
|||||||
|
|
||||||
@server.call_tool()
|
@server.call_tool()
|
||||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
arguments = dict(arguments) if isinstance(arguments, dict) else {}
|
||||||
|
owner = str(arguments.pop(_MCP_OWNER_ARG, "") or "").strip()
|
||||||
|
owner_token = _CURRENT_OWNER.set(owner or None)
|
||||||
try:
|
try:
|
||||||
|
all_db_accounts = _read_accounts_from_db()
|
||||||
|
if _mcp_owner_required(all_db_accounts):
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text="Error: email MCP requires an authenticated owner when multiple email account owners are configured.",
|
||||||
|
)]
|
||||||
|
|
||||||
if name == "list_email_accounts":
|
if name == "list_email_accounts":
|
||||||
rows = _list_accounts_raw()
|
rows = _filter_accounts_for_owner(all_db_accounts)
|
||||||
if not rows:
|
if not rows:
|
||||||
|
if all_db_accounts and owner:
|
||||||
|
return [TextContent(type="text", text="No email accounts configured for this owner.")]
|
||||||
return [TextContent(type="text", text="No email accounts configured. Legacy single-account mode active.")]
|
return [TextContent(type="text", text="No email accounts configured. Legacy single-account mode active.")]
|
||||||
lines = [f"Found {len(rows)} email account(s):\n"]
|
lines = [f"Found {len(rows)} email account(s):\n"]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -2108,6 +2173,16 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
bcc=arguments.get("bcc"),
|
bcc=arguments.get("bcc"),
|
||||||
account=acct,
|
account=acct,
|
||||||
)
|
)
|
||||||
|
if "error" in result:
|
||||||
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
||||||
|
if result.get("pending"):
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=(
|
||||||
|
f"Draft staged for approval (pending id: {result.get('pending_id')}). "
|
||||||
|
"Nothing has been sent yet. Review and approve it in Odysseus before delivery."
|
||||||
|
),
|
||||||
|
)]
|
||||||
acct_note = f" (from {result['account']})" if result.get("account") else ""
|
acct_note = f" (from {result['account']})" if result.get("account") else ""
|
||||||
return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")]
|
return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")]
|
||||||
|
|
||||||
@@ -2283,6 +2358,8 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return [TextContent(type="text", text=f"Error: {e}")]
|
return [TextContent(type="text", text=f"Error: {e}")]
|
||||||
|
finally:
|
||||||
|
_CURRENT_OWNER.reset(owner_token)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──
|
# ── Main ──
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Imports MemoryManager and MemoryVectorStore from the Odysseus codebase.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,6 +24,55 @@ _memory_manager = None
|
|||||||
_memory_vector = None
|
_memory_vector = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
|
_OWNER_ENV_KEYS = ("ODYSSEUS_MCP_MEMORY_OWNER", "ODYSSEUS_MEMORY_OWNER")
|
||||||
|
_OWNER_SCOPE_ERROR = (
|
||||||
|
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
||||||
|
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_owner() -> str | None:
|
||||||
|
for key in _OWNER_ENV_KEYS:
|
||||||
|
owner = os.environ.get(key, "").strip()
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_owner(entry: dict) -> str | None:
|
||||||
|
owner = entry.get("owner")
|
||||||
|
if owner is None:
|
||||||
|
return None
|
||||||
|
owner_text = str(owner).strip()
|
||||||
|
return owner_text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_scoped_store(entries: list[dict]) -> bool:
|
||||||
|
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
|
||||||
|
"""Return configured owner, all entries, visible entries, and optional error."""
|
||||||
|
entries = _memory_manager.load_all()
|
||||||
|
owner = _configured_owner()
|
||||||
|
if owner is None and _owner_scoped_store(entries):
|
||||||
|
return None, entries, [], _OWNER_SCOPE_ERROR
|
||||||
|
if owner is None:
|
||||||
|
visible = [
|
||||||
|
entry for entry in entries
|
||||||
|
if isinstance(entry, dict) and _entry_owner(entry) is None
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
visible = [
|
||||||
|
entry for entry in entries
|
||||||
|
if isinstance(entry, dict) and _entry_owner(entry) == owner
|
||||||
|
]
|
||||||
|
return owner, entries, visible, None
|
||||||
|
|
||||||
|
|
||||||
|
def _text_result(text: str) -> list[TextContent]:
|
||||||
|
return [TextContent(type="text", text=text)]
|
||||||
|
|
||||||
|
|
||||||
def _ensure_init():
|
def _ensure_init():
|
||||||
"""Lazy-init memory managers on first use."""
|
"""Lazy-init memory managers on first use."""
|
||||||
@@ -75,24 +125,26 @@ async def list_tools() -> list[Tool]:
|
|||||||
@server.call_tool()
|
@server.call_tool()
|
||||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
if name != "manage_memory":
|
if name != "manage_memory":
|
||||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
return _text_result(f"Unknown tool: {name}")
|
||||||
|
|
||||||
_ensure_init()
|
_ensure_init()
|
||||||
if not _memory_manager:
|
if not _memory_manager:
|
||||||
return [TextContent(type="text", text="Error: Memory manager not available")]
|
return _text_result("Error: Memory manager not available")
|
||||||
|
|
||||||
action = arguments.get("action", "")
|
action = arguments.get("action", "")
|
||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
category_filter = arguments.get("category", "")
|
category_filter = arguments.get("category", "")
|
||||||
memories = _memory_manager.load()
|
_owner, _all_memories, memories, scope_error = _scope_entries()
|
||||||
|
if scope_error:
|
||||||
|
return _text_result(scope_error)
|
||||||
if category_filter:
|
if category_filter:
|
||||||
memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()]
|
memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()]
|
||||||
if not memories:
|
if not memories:
|
||||||
msg = "No memories found"
|
msg = "No memories found"
|
||||||
if category_filter:
|
if category_filter:
|
||||||
msg += f" in category '{category_filter}'"
|
msg += f" in category '{category_filter}'"
|
||||||
return [TextContent(type="text", text=msg + ".")]
|
return _text_result(msg + ".")
|
||||||
|
|
||||||
lines = [f"Found {len(memories)} memory entries:\n"]
|
lines = [f"Found {len(memories)} memory entries:\n"]
|
||||||
for m in memories:
|
for m in memories:
|
||||||
@@ -102,15 +154,17 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if len(text) > 150:
|
if len(text) > 150:
|
||||||
text = text[:150] + "..."
|
text = text[:150] + "..."
|
||||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||||
return [TextContent(type="text", text="\n".join(lines))]
|
return _text_result("\n".join(lines))
|
||||||
|
|
||||||
elif action == "add":
|
elif action == "add":
|
||||||
text = arguments.get("text", "")
|
text = arguments.get("text", "")
|
||||||
category = arguments.get("category", "fact")
|
category = arguments.get("category", "fact")
|
||||||
if not text:
|
if not text:
|
||||||
return [TextContent(type="text", text="Error: Memory text cannot be empty")]
|
return _text_result("Error: Memory text cannot be empty")
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category)
|
owner, memories, _visible, scope_error = _scope_entries()
|
||||||
memories = _memory_manager.load_all()
|
if scope_error:
|
||||||
|
return _text_result(scope_error)
|
||||||
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||||
memories.append(entry)
|
memories.append(entry)
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
if _memory_vector and _memory_vector.healthy:
|
if _memory_vector and _memory_vector.healthy:
|
||||||
@@ -118,25 +172,28 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
_memory_vector.add(entry["id"], text)
|
_memory_vector.add(entry["id"], text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return [TextContent(type="text", text=f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")]
|
return _text_result(f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")
|
||||||
|
|
||||||
elif action == "edit":
|
elif action == "edit":
|
||||||
memory_id = arguments.get("memory_id", "")
|
memory_id = arguments.get("memory_id", "")
|
||||||
new_text = arguments.get("text", "")
|
new_text = arguments.get("text", "")
|
||||||
if not memory_id or not new_text:
|
if not memory_id or not new_text:
|
||||||
return [TextContent(type="text", text="Error: edit needs memory_id and text")]
|
return _text_result("Error: edit needs memory_id and text")
|
||||||
memories = _memory_manager.load_all()
|
_owner, memories, visible, scope_error = _scope_entries()
|
||||||
found = False
|
if scope_error:
|
||||||
|
return _text_result(scope_error)
|
||||||
full_id = None
|
full_id = None
|
||||||
for m in memories:
|
for m in visible:
|
||||||
if m.get("id", "").startswith(memory_id):
|
if m.get("id", "").startswith(memory_id):
|
||||||
m["text"] = new_text
|
|
||||||
m["timestamp"] = int(time.time())
|
|
||||||
found = True
|
|
||||||
full_id = m["id"]
|
full_id = m["id"]
|
||||||
break
|
break
|
||||||
if not found:
|
if not full_id:
|
||||||
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
|
return _text_result(f"Error: Memory '{memory_id}' not found")
|
||||||
|
for m in memories:
|
||||||
|
if m.get("id") == full_id:
|
||||||
|
m["text"] = new_text
|
||||||
|
m["timestamp"] = int(time.time())
|
||||||
|
break
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
if _memory_vector and _memory_vector.healthy and full_id:
|
if _memory_vector and _memory_vector.healthy and full_id:
|
||||||
try:
|
try:
|
||||||
@@ -144,24 +201,26 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
_memory_vector.add(full_id, new_text)
|
_memory_vector.add(full_id, new_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return [TextContent(type="text", text=f"Memory updated: {new_text}")]
|
return _text_result(f"Memory updated: {new_text}")
|
||||||
|
|
||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
memory_id = arguments.get("memory_id", "")
|
memory_id = arguments.get("memory_id", "")
|
||||||
if not memory_id:
|
if not memory_id:
|
||||||
return [TextContent(type="text", text="Error: delete needs memory_id")]
|
return _text_result("Error: delete needs memory_id")
|
||||||
memories = _memory_manager.load_all()
|
_owner, memories, visible, scope_error = _scope_entries()
|
||||||
|
if scope_error:
|
||||||
|
return _text_result(scope_error)
|
||||||
full_id = None
|
full_id = None
|
||||||
deleted_text = ""
|
deleted_text = ""
|
||||||
deleted_category = ""
|
deleted_category = ""
|
||||||
for m in memories:
|
for m in visible:
|
||||||
if m.get("id", "").startswith(memory_id):
|
if m.get("id", "").startswith(memory_id):
|
||||||
full_id = m["id"]
|
full_id = m["id"]
|
||||||
deleted_text = m.get("text", "")
|
deleted_text = m.get("text", "")
|
||||||
deleted_category = m.get("category", "")
|
deleted_category = m.get("category", "")
|
||||||
break
|
break
|
||||||
if not full_id:
|
if not full_id:
|
||||||
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
|
return _text_result(f"Error: Memory '{memory_id}' not found")
|
||||||
memories = [m for m in memories if m.get("id") != full_id]
|
memories = [m for m in memories if m.get("id") != full_id]
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
if _memory_vector and _memory_vector.healthy and full_id:
|
if _memory_vector and _memory_vector.healthy and full_id:
|
||||||
@@ -171,30 +230,32 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
pass
|
pass
|
||||||
cat = f"[{deleted_category}] " if deleted_category else ""
|
cat = f"[{deleted_category}] " if deleted_category else ""
|
||||||
snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..."
|
snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..."
|
||||||
return [TextContent(type="text", text=f"Memory deleted: {cat}{snippet} (id: {memory_id})")]
|
return _text_result(f"Memory deleted: {cat}{snippet} (id: {memory_id})")
|
||||||
|
|
||||||
elif action == "search":
|
elif action == "search":
|
||||||
query = arguments.get("text", "")
|
query = arguments.get("text", "")
|
||||||
if not query:
|
if not query:
|
||||||
return [TextContent(type="text", text="Error: search needs text (query)")]
|
return _text_result("Error: search needs text (query)")
|
||||||
memories = _memory_manager.load()
|
_owner, _all_memories, memories, scope_error = _scope_entries()
|
||||||
|
if scope_error:
|
||||||
|
return _text_result(scope_error)
|
||||||
if hasattr(_memory_manager, 'get_relevant_memories'):
|
if hasattr(_memory_manager, 'get_relevant_memories'):
|
||||||
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
||||||
else:
|
else:
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
|
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
|
||||||
if not results:
|
if not results:
|
||||||
return [TextContent(type="text", text=f"No memories found matching '{query}'.")]
|
return _text_result(f"No memories found matching '{query}'.")
|
||||||
lines = [f"Found {len(results)} matching memories:\n"]
|
lines = [f"Found {len(results)} matching memories:\n"]
|
||||||
for m in results:
|
for m in results:
|
||||||
cat = m.get("category", "fact")
|
cat = m.get("category", "fact")
|
||||||
mid = m.get("id", "?")[:8]
|
mid = m.get("id", "?")[:8]
|
||||||
text = m.get("text", "")
|
text = m.get("text", "")
|
||||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||||
return [TextContent(type="text", text="\n".join(lines))]
|
return _text_result("\n".join(lines))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")]
|
return _text_result(f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")
|
||||||
|
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
payload = {}
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||||
if not token:
|
if not token:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
|
|
||||||
@@ -89,11 +90,11 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
|||||||
# check-in tasks seeded. Hitting any /assistant route under one of these
|
# check-in tasks seeded. Hitting any /assistant route under one of these
|
||||||
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
||||||
# owner, which then double-fired alongside the real user's check-ins.
|
# owner, which then double-fired alongside the real user's check-ins.
|
||||||
_SYNTHETIC_OWNERS = frozenset({"internal-tool", "api", "demo", "system", ""})
|
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "".
|
||||||
|
|
||||||
async def _get_or_create(owner: str) -> CrewMember:
|
async def _get_or_create(owner: str) -> CrewMember:
|
||||||
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
||||||
if not owner or owner in _SYNTHETIC_OWNERS:
|
if not owner or owner in RESERVED_USERNAMES:
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
+44
-10
@@ -12,8 +12,8 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||||
from core.auth import AuthManager, SetAdminResult
|
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult
|
||||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
|
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||||
from src.rate_limiter import RateLimiter
|
from src.rate_limiter import RateLimiter
|
||||||
from src.settings_scrub import scrub_settings
|
from src.settings_scrub import scrub_settings
|
||||||
from src.settings import (
|
from src.settings import (
|
||||||
@@ -102,8 +102,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(429, "Too many requests — try again later")
|
raise HTTPException(429, "Too many requests — try again later")
|
||||||
if auth_manager.is_configured:
|
if auth_manager.is_configured:
|
||||||
raise HTTPException(400, "Already configured")
|
raise HTTPException(400, "Already configured")
|
||||||
if len(body.password) < 8:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
|
if len(body.username.strip()) < 1:
|
||||||
|
raise HTTPException(400, "Username is required")
|
||||||
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(500, "Setup failed")
|
raise HTTPException(500, "Setup failed")
|
||||||
@@ -118,10 +122,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(400, "Run setup first")
|
raise HTTPException(400, "Run setup first")
|
||||||
if not auth_manager.signup_enabled:
|
if not auth_manager.signup_enabled:
|
||||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||||
if len(body.password) < 8:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
if len(body.username.strip()) < 1:
|
if len(body.username.strip()) < 1:
|
||||||
raise HTTPException(400, "Username is required")
|
raise HTTPException(400, "Username is required")
|
||||||
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
@@ -144,6 +150,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(401, "Invalid 2FA code")
|
raise HTTPException(401, "Invalid 2FA code")
|
||||||
# All checks passed — create session (password already verified above)
|
# All checks passed — create session (password already verified above)
|
||||||
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
|
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, "Invalid credentials")
|
||||||
cookie_kwargs = dict(
|
cookie_kwargs = dict(
|
||||||
key=SESSION_COOKIE,
|
key=SESSION_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -182,13 +190,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
pass
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@router.get("/policy")
|
||||||
|
async def auth_policy():
|
||||||
|
"""Return public auth policy constants for the frontend."""
|
||||||
|
return auth_manager.policy()
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(body: ChangePasswordRequest, request: Request):
|
async def change_password(body: ChangePasswordRequest, request: Request):
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
if len(body.new_password) < 8:
|
if len(body.new_password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
current_token = request.cookies.get(SESSION_COOKIE)
|
current_token = request.cookies.get(SESSION_COOKIE)
|
||||||
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -268,8 +281,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
if not user or not auth_manager.is_admin(user):
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
if len(body.password) < 8:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
|
if len(body.username.strip()) < 1:
|
||||||
|
raise HTTPException(400, "Username is required")
|
||||||
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
@@ -432,6 +449,23 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to rename upload owner references %s -> %s: %s", old_username, new_username, e)
|
logger.warning("Failed to rename upload owner references %s -> %s: %s", old_username, new_username, e)
|
||||||
|
|
||||||
|
# direct personal RAG uploads live in per-owner directories and the
|
||||||
|
# vector metadata also carries the username used for owner-filtered
|
||||||
|
# search. Keep both in sync with the auth rename.
|
||||||
|
try:
|
||||||
|
from routes.personal_routes import rename_personal_upload_owner
|
||||||
|
personal_docs_manager = getattr(request.app.state, "personal_docs_manager", None)
|
||||||
|
if personal_docs_manager is not None:
|
||||||
|
rag_manager = getattr(personal_docs_manager, "rag_manager", None)
|
||||||
|
rename_personal_upload_owner(
|
||||||
|
old_username,
|
||||||
|
new_username,
|
||||||
|
personal_docs_manager=personal_docs_manager,
|
||||||
|
rag_manager=rag_manager,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to rename personal RAG upload owner references %s -> %s: %s", old_username, new_username, e)
|
||||||
|
|
||||||
# skills: SKILL.md frontmatter carries owner: <username>; the usage
|
# skills: SKILL.md frontmatter carries owner: <username>; the usage
|
||||||
# sidecar (_usage.json) keys entries as owner::skill-name. Both must
|
# sidecar (_usage.json) keys entries as owner::skill-name. Both must
|
||||||
# be updated or the renamed user's Skills panel goes empty.
|
# be updated or the renamed user's Skills panel goes empty.
|
||||||
|
|||||||
+13
-20
@@ -14,7 +14,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
|||||||
from src.llm_core import normalize_model_id
|
from src.llm_core import normalize_model_id
|
||||||
from src.endpoint_resolver import normalize_base
|
from src.endpoint_resolver import normalize_base
|
||||||
from src.context_compactor import maybe_compact, trim_for_context
|
from src.context_compactor import maybe_compact, trim_for_context
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import effective_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ def _enforce_chat_privileges(request, sess) -> None:
|
|||||||
which means unrestricted allowed_models / zero cap -> no-op for them.
|
which means unrestricted allowed_models / zero cap -> no-op for them.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
user = get_current_user(request)
|
user = effective_user(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
user = None
|
user = None
|
||||||
if not user:
|
if not user:
|
||||||
@@ -159,17 +159,9 @@ async def auto_name_session(session_manager, sess):
|
|||||||
return
|
return
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
owner = getattr(sess, "owner", None)
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(owner=owner)
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
if not t_model:
|
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||||
# If no task/utility model is configured at all, fall back to
|
)
|
||||||
# the session's own model so auto-naming still works even on
|
|
||||||
# minimal setups.
|
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
_fallback = resolve_endpoint("default", owner=owner)
|
|
||||||
if _fallback and _fallback[1]:
|
|
||||||
t_url, t_model, t_headers = _fallback
|
|
||||||
else:
|
|
||||||
t_url, t_model, t_headers = sess.endpoint_url, sess.model, sess.headers
|
|
||||||
if not t_model:
|
if not t_model:
|
||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
return
|
return
|
||||||
@@ -346,11 +338,11 @@ def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, inco
|
|||||||
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
||||||
"""Fire webhook and event_bus events for a new user message."""
|
"""Fire webhook and event_bus events for a new user message."""
|
||||||
if webhook_manager and not compare_mode:
|
if webhook_manager and not compare_mode:
|
||||||
asyncio.create_task(webhook_manager.fire("chat.message", {
|
webhook_manager.fire_and_forget("chat.message", {
|
||||||
"session_id": session_id, "model": sess.model, "message": message[:2000],
|
"session_id": session_id, "model": sess.model, "message": message[:2000],
|
||||||
}))
|
})
|
||||||
from src.event_bus import fire_event
|
from src.event_bus import fire_event
|
||||||
user = get_current_user(request)
|
user = effective_user(request)
|
||||||
fire_event("message_sent", user)
|
fire_event("message_sent", user)
|
||||||
|
|
||||||
|
|
||||||
@@ -576,8 +568,9 @@ async def build_chat_context(
|
|||||||
if not incognito:
|
if not incognito:
|
||||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||||
|
|
||||||
# Resolve user prefs
|
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
||||||
user = get_current_user(request)
|
# bearer-token chat requests use the token owner instead of the "api" sentinel.
|
||||||
|
user = effective_user(request)
|
||||||
uprefs = load_prefs_for_user(user)
|
uprefs = load_prefs_for_user(user)
|
||||||
|
|
||||||
# Memory enabled?
|
# Memory enabled?
|
||||||
@@ -1120,10 +1113,10 @@ def run_post_response_tasks(
|
|||||||
|
|
||||||
# Webhook
|
# Webhook
|
||||||
if webhook_manager and not compare_mode:
|
if webhook_manager and not compare_mode:
|
||||||
asyncio.create_task(webhook_manager.fire("chat.completed", {
|
webhook_manager.fire_and_forget("chat.completed", {
|
||||||
"session_id": session_id, "model": sess.model,
|
"session_id": session_id, "model": sess.model,
|
||||||
"user_message": message, "response": full_response[:2000],
|
"user_message": message, "response": full_response[:2000],
|
||||||
}))
|
})
|
||||||
|
|
||||||
# Auto-name
|
# Auto-name
|
||||||
if needs_auto_name(sess.name):
|
if needs_auto_name(sess.name):
|
||||||
|
|||||||
+13
-10
@@ -23,7 +23,7 @@ from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_
|
|||||||
from src.session_search import search_session_messages
|
from src.session_search import search_session_messages
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.exceptions import SessionNotFoundError
|
from core.exceptions import SessionNotFoundError
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import effective_user, get_current_user
|
||||||
from routes.session_routes import _verify_session_owner
|
from routes.session_routes import _verify_session_owner
|
||||||
from routes.document_helpers import _owner_session_filter
|
from routes.document_helpers import _owner_session_filter
|
||||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||||
@@ -126,7 +126,8 @@ def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
|
|||||||
sess.model = ""
|
sess.model = ""
|
||||||
sess.headers = {}
|
sess.headers = {}
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning("Failed to clear orphaned session endpoint", exc_info=e)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
@@ -144,7 +145,8 @@ def _endpoint_cache_contains_model(endpoint, model: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
models = json.loads(raw) if isinstance(raw, str) else raw
|
models = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse cached models list, treating as containing model", exc_info=e)
|
||||||
return True
|
return True
|
||||||
if not isinstance(models, list) or not models:
|
if not isinstance(models, list) or not models:
|
||||||
return True
|
return True
|
||||||
@@ -236,7 +238,8 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
is_chatgpt_subscription = False
|
is_chatgpt_subscription = False
|
||||||
try:
|
try:
|
||||||
cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or [])
|
cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or [])
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse cached_models for endpoint %r", getattr(ep, "id", "?"), exc_info=e)
|
||||||
cached = []
|
cached = []
|
||||||
if not cached:
|
if not cached:
|
||||||
visible = []
|
visible = []
|
||||||
@@ -360,7 +363,7 @@ def setup_chat_routes(
|
|||||||
sess = session_manager.get_session(session)
|
sess = session_manager.get_session(session)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(404, f"Session '{session}' not found")
|
raise HTTPException(404, f"Session '{session}' not found")
|
||||||
owner = get_current_user(request)
|
owner = effective_user(request)
|
||||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||||
|
|
||||||
@@ -600,7 +603,7 @@ def setup_chat_routes(
|
|||||||
# but BEFORE loading. Prevents cross-user session hijack.
|
# but BEFORE loading. Prevents cross-user session hijack.
|
||||||
_verify_session_owner(request, session)
|
_verify_session_owner(request, session)
|
||||||
sess = session_manager.get_session(session)
|
sess = session_manager.get_session(session)
|
||||||
owner = get_current_user(request)
|
owner = effective_user(request)
|
||||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||||
# Issue #587: picker shows a model from the endpoint cache but
|
# Issue #587: picker shows a model from the endpoint cache but
|
||||||
@@ -631,7 +634,7 @@ def setup_chat_routes(
|
|||||||
_enforce_chat_privileges(request, sess)
|
_enforce_chat_privileges(request, sess)
|
||||||
|
|
||||||
# Ensure session has auth headers
|
# Ensure session has auth headers
|
||||||
resolve_session_auth(sess, session, owner=get_current_user(request))
|
resolve_session_auth(sess, session, owner=effective_user(request))
|
||||||
|
|
||||||
# Check for research_pending BEFORE mode persist overwrites it
|
# Check for research_pending BEFORE mode persist overwrites it
|
||||||
do_research = str(use_research).lower() == "true"
|
do_research = str(use_research).lower() == "true"
|
||||||
@@ -646,8 +649,8 @@ def setup_chat_routes(
|
|||||||
elif attachments:
|
elif attachments:
|
||||||
try:
|
try:
|
||||||
att_ids = [str(x) for x in json.loads(attachments)]
|
att_ids = [str(x) for x in json.loads(attachments)]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
||||||
|
|
||||||
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
||||||
pre_context_tool_policy = build_effective_tool_policy(
|
pre_context_tool_policy = build_effective_tool_policy(
|
||||||
@@ -1482,7 +1485,7 @@ def setup_chat_routes(
|
|||||||
if not q or not q.strip():
|
if not q or not q.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
_user = get_current_user(request)
|
_user = effective_user(request)
|
||||||
return [
|
return [
|
||||||
result.to_dict()
|
result.to_dict()
|
||||||
for result in search_session_messages(
|
for result in search_session_messages(
|
||||||
|
|||||||
+11
-4
@@ -46,8 +46,12 @@ def _ssh_prefix_for_task(task: dict) -> tuple[str, str]:
|
|||||||
shell metacharacters in ``remoteHost`` is rejected with 400 rather than
|
shell metacharacters in ``remoteHost`` is rejected with 400 rather than
|
||||||
injected.
|
injected.
|
||||||
"""
|
"""
|
||||||
host = validate_remote_host((task.get("remoteHost") or "").strip() or None) or ""
|
raw_host = task.get("remoteHost")
|
||||||
ssh_port = validate_ssh_port((task.get("sshPort") or "").strip() or None) or ""
|
raw_port = task.get("sshPort")
|
||||||
|
host_value = str(raw_host).strip() if raw_host is not None else None
|
||||||
|
port_value = str(raw_port).strip() if raw_port is not None else None
|
||||||
|
host = validate_remote_host(host_value or None) or ""
|
||||||
|
ssh_port = validate_ssh_port(port_value or None) or ""
|
||||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||||
return host, port_flag
|
return host, port_flag
|
||||||
|
|
||||||
@@ -306,7 +310,10 @@ def setup_codex_routes(
|
|||||||
|
|
||||||
@router.post("/emails/draft-document")
|
@router.post("/emails/draft-document")
|
||||||
async def codex_email_draft_document(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
async def codex_email_draft_document(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||||
owner = _scope_owner_all(request, {"email:draft", "documents:write"})
|
owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)
|
||||||
|
docs_owner = _scope_owner_all(request, DOCS_WRITE_SCOPES)
|
||||||
|
if docs_owner != owner:
|
||||||
|
raise HTTPException(403, "API token owner mismatch")
|
||||||
if documents_create_endpoint is None:
|
if documents_create_endpoint is None:
|
||||||
raise HTTPException(503, "Documents integration is not available")
|
raise HTTPException(503, "Documents integration is not available")
|
||||||
from routes.document_routes import DocumentCreate
|
from routes.document_routes import DocumentCreate
|
||||||
@@ -790,7 +797,7 @@ def setup_codex_routes(
|
|||||||
norm = dict(body or {})
|
norm = dict(body or {})
|
||||||
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
||||||
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
||||||
host = (norm.get("host") or norm.get("remote_host") or "").strip()
|
host = validate_remote_host((norm.get("host") or norm.get("remote_host") or "").strip() or None) or ""
|
||||||
port = norm.get("port") or 8000
|
port = norm.get("port") or 8000
|
||||||
import re as _re
|
import re as _re
|
||||||
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
|
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
|
||||||
|
|||||||
@@ -505,6 +505,8 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" if u.startswith('KB'): return int(n * 1024)",
|
" if u.startswith('KB'): return int(n * 1024)",
|
||||||
" return int(n)",
|
" return int(n)",
|
||||||
"def scan_ollama():",
|
"def scan_ollama():",
|
||||||
|
" if any(m.get('is_ollama') for m in models): return",
|
||||||
|
" if os.name == 'nt' and not os.environ.get('ODYSSEUS_ALLOW_OLLAMA_CLI_SCAN'): return",
|
||||||
" if not shutil.which('ollama'): return",
|
" if not shutil.which('ollama'): return",
|
||||||
" try:",
|
" try:",
|
||||||
" p = subprocess.run(['ollama', 'list'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=6)",
|
" p = subprocess.run(['ollama', 'list'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=6)",
|
||||||
@@ -535,8 +537,8 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" models.append({'repo_id':name,'size_bytes':size_bytes,'nb_files':1,'has_incomplete':False,'path':'ollama','backend':'ollama','is_ollama':True})",
|
" models.append({'repo_id':name,'size_bytes':size_bytes,'nb_files':1,'has_incomplete':False,'path':'ollama','backend':'ollama','is_ollama':True})",
|
||||||
" return",
|
" return",
|
||||||
"for _hf_cache in hf_cache_paths(): scan_hf(_hf_cache)",
|
"for _hf_cache in hf_cache_paths(): scan_hf(_hf_cache)",
|
||||||
"scan_ollama()",
|
|
||||||
"scan_ollama_api()",
|
"scan_ollama_api()",
|
||||||
|
"scan_ollama()",
|
||||||
]
|
]
|
||||||
for model_dir in model_dirs or []:
|
for model_dir in model_dirs or []:
|
||||||
lines.append(f"scan_dir(os.path.expanduser({model_dir!r}))")
|
lines.append(f"scan_dir(os.path.expanduser({model_dir!r}))")
|
||||||
|
|||||||
@@ -1284,6 +1284,11 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# LOCAL execution on a native-Windows host never uses tmux (detached
|
# LOCAL execution on a native-Windows host never uses tmux (detached
|
||||||
# process path below), regardless of the UI-supplied platform.
|
# process path below), regardless of the UI-supplied platform.
|
||||||
local_windows = IS_WINDOWS and not remote
|
local_windows = IS_WINDOWS and not remote
|
||||||
|
if is_windows and remote and "diffusion_server.py" in req.cmd:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"Remote Windows Diffusers serving is not supported yet; use local Windows or a Linux remote server.",
|
||||||
|
)
|
||||||
|
|
||||||
if not is_windows and not local_windows and not await _binary_available("tmux", remote, req.ssh_port):
|
if not is_windows and not local_windows and not await _binary_available("tmux", remote, req.ssh_port):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -102,8 +102,11 @@ def _owner_session_filter(q, user):
|
|||||||
|
|
||||||
The owner backfill runs in init_db before the app serves requests, so
|
The owner backfill runs in init_db before the app serves requests, so
|
||||||
by the time this filter is live there are no NULL-owner rows to leak;
|
by the time this filter is live there are no NULL-owner rows to leak;
|
||||||
we therefore match the owner strictly."""
|
we therefore match the owner strictly for authenticated callers."""
|
||||||
if user is None:
|
if not user:
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
if user == "" or _auth_disabled():
|
||||||
|
return q
|
||||||
return q.filter(False)
|
return q.filter(False)
|
||||||
return q.filter(Document.owner == user)
|
return q.filter(Document.owner == user)
|
||||||
|
|
||||||
|
|||||||
@@ -503,7 +503,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||||
data = {}
|
data = {}
|
||||||
ids = data.get("ids") or []
|
ids = data.get("ids") or []
|
||||||
if not ids:
|
if not ids:
|
||||||
@@ -645,8 +646,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
from src.agent_tools.document_tools import clear_active_document
|
from src.agent_tools.document_tools import clear_active_document
|
||||||
clear_active_document(doc_id)
|
clear_active_document(doc_id)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed to clear active document %r on detach", doc_id, exc_info=e)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
return _doc_to_dict(doc)
|
return _doc_to_dict(doc)
|
||||||
@@ -1331,6 +1332,12 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
if not pdf_path:
|
if not pdf_path:
|
||||||
raise HTTPException(404, f"Source PDF {upload_id} not found")
|
raise HTTPException(404, f"Source PDF {upload_id} not found")
|
||||||
|
|
||||||
|
# Fail fast with a clear 503 if the optional PyMuPDF dependency
|
||||||
|
# is missing — fill_fields/stamp_annotations will otherwise
|
||||||
|
# raise RuntimeError deep inside and bubble out as a 500.
|
||||||
|
# Mirrors the convention in _load_pdf_viewer_fitz above.
|
||||||
|
_load_pdf_viewer_fitz()
|
||||||
|
|
||||||
values = parse_markdown_to_values(doc.current_content or "")
|
values = parse_markdown_to_values(doc.current_content or "")
|
||||||
out_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name
|
out_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name
|
||||||
_to_unlink.append(out_path)
|
_to_unlink.append(out_path)
|
||||||
|
|||||||
+134
-10
@@ -13,6 +13,8 @@ and `email_pollers.py` (the background loops):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
import imaplib
|
import imaplib
|
||||||
import smtplib
|
import smtplib
|
||||||
import email as email_mod
|
import email as email_mod
|
||||||
@@ -38,6 +40,106 @@ from src.secret_storage import decrypt as _decrypt
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _xoauth2_raw(user: str, access_token: str) -> str:
|
||||||
|
"""The SASL XOAUTH2 initial-response string (unencoded).
|
||||||
|
|
||||||
|
Both smtplib.SMTP.auth() and imaplib.IMAP4.authenticate() base64-encode
|
||||||
|
the value their callback returns, so callers pass this raw form — never
|
||||||
|
pre-encoded — to avoid double base64.
|
||||||
|
"""
|
||||||
|
return f"user={user}\x01auth=Bearer {access_token}\x01\x01"
|
||||||
|
|
||||||
|
|
||||||
|
def _xoauth2_bytes(user: str, access_token: str) -> bytes:
|
||||||
|
"""Raw XOAUTH2 bytes for imaplib's authenticate() callback."""
|
||||||
|
return _xoauth2_raw(user, access_token).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def make_oauth_state(account_id: str, owner: str) -> str:
|
||||||
|
"""Return an HMAC-signed, base64-encoded OAuth state token.
|
||||||
|
|
||||||
|
Encodes account_id + owner + a random nonce, signed with the app secret
|
||||||
|
so the callback can validate that the flow was initiated by an
|
||||||
|
authenticated, owning user (CSRF / state-forgery protection).
|
||||||
|
"""
|
||||||
|
import hmac as _hmac, hashlib as _hl, secrets as _sec
|
||||||
|
from src.secret_storage import _load_or_create_key
|
||||||
|
nonce = _sec.token_hex(16)
|
||||||
|
payload = json.dumps({"a": account_id, "o": owner, "n": nonce}, separators=(",", ":"))
|
||||||
|
sig = _hmac.new(_load_or_create_key(), payload.encode(), _hl.sha256).hexdigest()
|
||||||
|
return base64.urlsafe_b64encode(f"{payload}|{sig}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_oauth_state(state: str) -> dict | None:
|
||||||
|
"""Verify an OAuth state token's HMAC signature.
|
||||||
|
|
||||||
|
Returns the decoded payload dict ({"a", "o", "n"}) on success, or None if
|
||||||
|
the token is malformed, tampered, or signed with a different key.
|
||||||
|
"""
|
||||||
|
import hmac as _hmac, hashlib as _hl
|
||||||
|
from src.secret_storage import _load_or_create_key
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(state.encode()).decode()
|
||||||
|
payload, sig = decoded.rsplit("|", 1)
|
||||||
|
expected = _hmac.new(_load_or_create_key(), payload.encode(), _hl.sha256).hexdigest()
|
||||||
|
if not _hmac.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
return json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_google_token(account_id: str) -> str | None:
|
||||||
|
"""Exchange the stored refresh token for a new access token and persist it."""
|
||||||
|
import httpx
|
||||||
|
from core.database import SessionLocal as _SL, EmailAccount as _EA
|
||||||
|
from src.secret_storage import encrypt as _enc, decrypt as _dec
|
||||||
|
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||||
|
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
return None
|
||||||
|
db = _SL()
|
||||||
|
try:
|
||||||
|
row = db.get(_EA, account_id)
|
||||||
|
if not row or not row.oauth_refresh_token:
|
||||||
|
return None
|
||||||
|
refresh_token = _dec(row.oauth_refresh_token or "")
|
||||||
|
if not refresh_token:
|
||||||
|
return None
|
||||||
|
resp = httpx.post("https://oauth2.googleapis.com/token", data={
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
access_token = data["access_token"]
|
||||||
|
row.oauth_access_token = _enc(access_token)
|
||||||
|
row.oauth_token_expiry = str(int(time.time()) + data.get("expires_in", 3600))
|
||||||
|
db.commit()
|
||||||
|
return access_token
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Google token refresh failed for account {account_id}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_valid_google_token(account_id: str, cfg: dict) -> str | None:
|
||||||
|
"""Return a valid Google access token, refreshing if expired or missing."""
|
||||||
|
from src.secret_storage import decrypt as _dec
|
||||||
|
access_token = _dec(cfg.get("oauth_access_token") or "")
|
||||||
|
expiry_str = cfg.get("oauth_token_expiry") or ""
|
||||||
|
if access_token and expiry_str:
|
||||||
|
try:
|
||||||
|
if int(expiry_str) - 60 > time.time():
|
||||||
|
return access_token
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return _refresh_google_token(account_id)
|
||||||
|
|
||||||
|
|
||||||
def _smtp_security_mode(cfg: dict) -> str:
|
def _smtp_security_mode(cfg: dict) -> str:
|
||||||
raw = str(cfg.get("smtp_security") or "").strip().lower()
|
raw = str(cfg.get("smtp_security") or "").strip().lower()
|
||||||
if raw in {"ssl", "starttls", "none"}:
|
if raw in {"ssl", "starttls", "none"}:
|
||||||
@@ -54,20 +156,29 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
|
|||||||
port = int(cfg.get("smtp_port") or 465)
|
port = int(cfg.get("smtp_port") or 465)
|
||||||
user = cfg.get("smtp_user") or ""
|
user = cfg.get("smtp_user") or ""
|
||||||
password = cfg.get("smtp_password") or ""
|
password = cfg.get("smtp_password") or ""
|
||||||
|
|
||||||
|
def _auth_smtp(smtp):
|
||||||
|
if cfg.get("oauth_provider") == "google":
|
||||||
|
token = _get_valid_google_token(cfg.get("account_id"), cfg)
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
|
||||||
|
smtp.ehlo()
|
||||||
|
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(user, token), initial_response_ok=True)
|
||||||
|
elif user and password:
|
||||||
|
smtp.login(user, password)
|
||||||
|
|
||||||
security = _smtp_security_mode(cfg)
|
security = _smtp_security_mode(cfg)
|
||||||
|
|
||||||
if security == "ssl":
|
if security == "ssl":
|
||||||
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
||||||
if user and password:
|
_auth_smtp(smtp)
|
||||||
smtp.login(user, password)
|
|
||||||
smtp.sendmail(from_addr, recipients, message)
|
smtp.sendmail(from_addr, recipients, message)
|
||||||
return
|
return
|
||||||
|
|
||||||
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||||
if security == "starttls":
|
if security == "starttls":
|
||||||
smtp.starttls()
|
smtp.starttls()
|
||||||
if user and password:
|
_auth_smtp(smtp)
|
||||||
smtp.login(user, password)
|
|
||||||
smtp.sendmail(from_addr, recipients, message)
|
smtp.sendmail(from_addr, recipients, message)
|
||||||
|
|
||||||
|
|
||||||
@@ -701,10 +812,16 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
|||||||
"imap_password": _decrypt(row.imap_password or ""),
|
"imap_password": _decrypt(row.imap_password or ""),
|
||||||
"imap_starttls": bool(row.imap_starttls),
|
"imap_starttls": bool(row.imap_starttls),
|
||||||
"from_address": row.from_address or row.imap_user or "",
|
"from_address": row.from_address or row.imap_user or "",
|
||||||
|
"oauth_provider": row.oauth_provider or "",
|
||||||
|
"oauth_access_token": row.oauth_access_token or "",
|
||||||
|
"oauth_refresh_token": row.oauth_refresh_token or "",
|
||||||
|
"oauth_token_expiry": row.oauth_token_expiry or "",
|
||||||
|
"display_name": row.display_name or "",
|
||||||
}
|
}
|
||||||
if not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]):
|
is_oauth = bool(cfg.get("oauth_provider"))
|
||||||
|
if not is_oauth and not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]):
|
||||||
logger.warning(f"SMTP not configured for account {row.name!r}")
|
logger.warning(f"SMTP not configured for account {row.name!r}")
|
||||||
if not (cfg["imap_host"] and cfg["imap_user"] and cfg["imap_password"]):
|
if not is_oauth and not (cfg["imap_host"] and cfg["imap_user"] and cfg["imap_password"]):
|
||||||
logger.warning(f"IMAP not configured for account {row.name!r}")
|
logger.warning(f"IMAP not configured for account {row.name!r}")
|
||||||
return cfg
|
return cfg
|
||||||
finally:
|
finally:
|
||||||
@@ -825,12 +942,19 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
conn.login(cfg["imap_user"], cfg["imap_password"])
|
if cfg.get("oauth_provider") == "google":
|
||||||
|
token = _get_valid_google_token(cfg.get("account_id"), cfg)
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Google OAuth token unavailable — reconnect the account in Settings → Integrations")
|
||||||
|
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(cfg["imap_user"], token))
|
||||||
|
else:
|
||||||
|
conn.login(cfg["imap_user"], cfg["imap_password"])
|
||||||
except Exception:
|
except Exception:
|
||||||
# A failed AUTHENTICATE (e.g. an Office 365 app password on an
|
# A failed AUTHENTICATE (e.g. an Office 365 app password on an
|
||||||
# MFA-enabled tenant, #3174) otherwise orphans the already-connected
|
# MFA-enabled tenant, #3174, or an expired/revoked OAuth token)
|
||||||
# socket; close it before propagating so a misconfigured account
|
# otherwise orphans the already-connected socket; close it before
|
||||||
# can't leak one descriptor per retry / background poller pass.
|
# propagating so a misconfigured account can't leak one descriptor
|
||||||
|
# per retry / background poller pass.
|
||||||
try:
|
try:
|
||||||
conn.shutdown()
|
conn.shutdown()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+149
-13
@@ -13,7 +13,9 @@ handlers need. The split is mechanical — no behavior change.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import sqlite3 as _sql3
|
import sqlite3 as _sql3
|
||||||
|
import time
|
||||||
import email as email_mod
|
import email as email_mod
|
||||||
import email.header
|
import email.header
|
||||||
import email.utils
|
import email.utils
|
||||||
@@ -43,6 +45,7 @@ from routes.email_helpers import (
|
|||||||
_load_settings, _save_settings, _get_email_config,
|
_load_settings, _save_settings, _get_email_config,
|
||||||
_send_smtp_message, _smtp_security_mode,
|
_send_smtp_message, _smtp_security_mode,
|
||||||
_IMAP_TIMEOUT_SECONDS, _open_imap_connection,
|
_IMAP_TIMEOUT_SECONDS, _open_imap_connection,
|
||||||
|
make_oauth_state, verify_oauth_state,
|
||||||
_imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder,
|
_imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder,
|
||||||
_extract_attachment_text, _list_attachments_from_msg,
|
_extract_attachment_text, _list_attachments_from_msg,
|
||||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||||
@@ -76,15 +79,16 @@ def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[st
|
|||||||
cfg.get("smtp_user") or "",
|
cfg.get("smtp_user") or "",
|
||||||
cfg.get("from_address") or "",
|
cfg.get("from_address") or "",
|
||||||
])
|
])
|
||||||
except Exception:
|
except Exception as _e:
|
||||||
|
logger.warning("Failed to resolve email account alias", exc_info=_e)
|
||||||
resolved_account_id = None
|
resolved_account_id = None
|
||||||
row = db.get(_EA, resolved_account_id) if resolved_account_id else None
|
row = db.get(_EA, resolved_account_id) if resolved_account_id else None
|
||||||
if row:
|
if row:
|
||||||
aliases.extend([row.owner or "", row.imap_user or "", row.from_address or ""])
|
aliases.extend([row.owner or "", row.imap_user or "", row.from_address or ""])
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception as _e:
|
||||||
pass
|
logger.warning("Failed to load email aliases", exc_info=_e)
|
||||||
out = []
|
out = []
|
||||||
for a in aliases:
|
for a in aliases:
|
||||||
a = (a or "").strip()
|
a = (a or "").strip()
|
||||||
@@ -285,7 +289,9 @@ def _group_uid_fetch_records(msg_data) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _smtp_ready(cfg: dict) -> bool:
|
def _smtp_ready(cfg: dict) -> bool:
|
||||||
return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password"))
|
if not cfg.get("smtp_host") or not cfg.get("smtp_user"):
|
||||||
|
return False
|
||||||
|
return bool(cfg.get("smtp_password") or cfg.get("oauth_provider"))
|
||||||
|
|
||||||
|
|
||||||
def _resolve_send_config(account_id: str | None = None, owner: str = "") -> dict:
|
def _resolve_send_config(account_id: str | None = None, owner: str = "") -> dict:
|
||||||
@@ -2021,7 +2027,7 @@ def setup_email_routes():
|
|||||||
outer = MIMEMultipart("alternative")
|
outer = MIMEMultipart("alternative")
|
||||||
body_container = outer
|
body_container = outer
|
||||||
|
|
||||||
outer["From"] = cfg["from_address"]
|
outer["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||||
outer["To"] = to
|
outer["To"] = to
|
||||||
if cc:
|
if cc:
|
||||||
outer["Cc"] = cc
|
outer["Cc"] = cc
|
||||||
@@ -2165,12 +2171,10 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(SCHEDULED_DB)
|
conn = sqlite3.connect(SCHEDULED_DB)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
# The MCP server can't easily set owner, so it stores '' — fall
|
|
||||||
# back to those rows in addition to the caller's owner.
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, to_addr, subject, body, created_at, account_id
|
"""SELECT id, to_addr, subject, body, created_at, account_id
|
||||||
FROM scheduled_emails
|
FROM scheduled_emails
|
||||||
WHERE status = 'agent_draft' AND (owner = ? OR owner = '')
|
WHERE status = 'agent_draft' AND owner = ?
|
||||||
ORDER BY created_at DESC""",
|
ORDER BY created_at DESC""",
|
||||||
(owner or "",),
|
(owner or "",),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -2191,7 +2195,7 @@ def setup_email_routes():
|
|||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""UPDATE scheduled_emails
|
"""UPDATE scheduled_emails
|
||||||
SET status = 'pending', send_at = ?
|
SET status = 'pending', send_at = ?
|
||||||
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
|
WHERE id = ? AND status = 'agent_draft' AND owner = ?""",
|
||||||
(datetime.utcnow().isoformat(), sid, owner or ""),
|
(datetime.utcnow().isoformat(), sid, owner or ""),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -2212,7 +2216,7 @@ def setup_email_routes():
|
|||||||
conn = sqlite3.connect(SCHEDULED_DB)
|
conn = sqlite3.connect(SCHEDULED_DB)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""UPDATE scheduled_emails SET status = 'cancelled'
|
"""UPDATE scheduled_emails SET status = 'cancelled'
|
||||||
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
|
WHERE id = ? AND status = 'agent_draft' AND owner = ?""",
|
||||||
(sid, owner or ""),
|
(sid, owner or ""),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -2285,6 +2289,7 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
cfg = _resolve_send_config(req.account_id, owner=owner)
|
cfg = _resolve_send_config(req.account_id, owner=owner)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning(f"No SMTP-capable account resolved: {e}")
|
||||||
return {"success": False, "error": str(e) or "No SMTP-capable email account configured"}
|
return {"success": False, "error": str(e) or "No SMTP-capable email account configured"}
|
||||||
|
|
||||||
# Use 'mixed' if we have attachments, 'alternative' otherwise
|
# Use 'mixed' if we have attachments, 'alternative' otherwise
|
||||||
@@ -2297,7 +2302,7 @@ def setup_email_routes():
|
|||||||
outer = MIMEMultipart("alternative")
|
outer = MIMEMultipart("alternative")
|
||||||
body_container = outer
|
body_container = outer
|
||||||
|
|
||||||
outer["From"] = cfg["from_address"]
|
outer["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||||
outer["To"] = req.to
|
outer["To"] = req.to
|
||||||
if req.cc:
|
if req.cc:
|
||||||
outer["Cc"] = req.cc
|
outer["Cc"] = req.cc
|
||||||
@@ -2348,6 +2353,10 @@ def setup_email_routes():
|
|||||||
|
|
||||||
_account_id = cfg.get("account_id") or req.account_id # capture for the IMAP append in the closure
|
_account_id = cfg.get("account_id") or req.account_id # capture for the IMAP append in the closure
|
||||||
_in_reply_to = (req.in_reply_to or "").strip()
|
_in_reply_to = (req.in_reply_to or "").strip()
|
||||||
|
_oauth_provider = cfg.get("oauth_provider") or ""
|
||||||
|
_oauth_access_token = cfg.get("oauth_access_token") or ""
|
||||||
|
_oauth_refresh_token = cfg.get("oauth_refresh_token") or ""
|
||||||
|
_oauth_token_expiry = cfg.get("oauth_token_expiry") or ""
|
||||||
|
|
||||||
def _deliver():
|
def _deliver():
|
||||||
try:
|
try:
|
||||||
@@ -2358,6 +2367,11 @@ def setup_email_routes():
|
|||||||
"smtp_security": _smtp_security,
|
"smtp_security": _smtp_security,
|
||||||
"smtp_user": _smtp_user,
|
"smtp_user": _smtp_user,
|
||||||
"smtp_password": _smtp_pw,
|
"smtp_password": _smtp_pw,
|
||||||
|
"account_id": _account_id,
|
||||||
|
"oauth_provider": _oauth_provider,
|
||||||
|
"oauth_access_token": _oauth_access_token,
|
||||||
|
"oauth_refresh_token": _oauth_refresh_token,
|
||||||
|
"oauth_token_expiry": _oauth_token_expiry,
|
||||||
},
|
},
|
||||||
_from,
|
_from,
|
||||||
_recipients,
|
_recipients,
|
||||||
@@ -2470,7 +2484,7 @@ def setup_email_routes():
|
|||||||
msg.attach(MIMEText(_draft_html, "html", "utf-8"))
|
msg.attach(MIMEText(_draft_html, "html", "utf-8"))
|
||||||
else:
|
else:
|
||||||
msg = MIMEText(req.body, "plain", "utf-8")
|
msg = MIMEText(req.body, "plain", "utf-8")
|
||||||
msg["From"] = cfg["from_address"]
|
msg["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||||
msg["To"] = req.to
|
msg["To"] = req.to
|
||||||
if req.cc:
|
if req.cc:
|
||||||
msg["Cc"] = req.cc
|
msg["Cc"] = req.cc
|
||||||
@@ -3122,6 +3136,8 @@ def setup_email_routes():
|
|||||||
"from_address": r.from_address or "",
|
"from_address": r.from_address or "",
|
||||||
"has_imap_password": bool(r.imap_password),
|
"has_imap_password": bool(r.imap_password),
|
||||||
"has_smtp_password": bool(r.smtp_password),
|
"has_smtp_password": bool(r.smtp_password),
|
||||||
|
"oauth_provider": r.oauth_provider or "",
|
||||||
|
"display_name": r.display_name or "",
|
||||||
})
|
})
|
||||||
return {"accounts": out}
|
return {"accounts": out}
|
||||||
finally:
|
finally:
|
||||||
@@ -3154,6 +3170,7 @@ def setup_email_routes():
|
|||||||
smtp_user=(data.get("smtp_user") or "").strip(),
|
smtp_user=(data.get("smtp_user") or "").strip(),
|
||||||
smtp_password=_enc(data.get("smtp_password") or ""),
|
smtp_password=_enc(data.get("smtp_password") or ""),
|
||||||
from_address=(data.get("from_address") or "").strip(),
|
from_address=(data.get("from_address") or "").strip(),
|
||||||
|
display_name=(data.get("display_name") or "").strip(),
|
||||||
# SECURITY: stamp the creator so all subsequent reads / mutations
|
# SECURITY: stamp the creator so all subsequent reads / mutations
|
||||||
# can filter by user. Without this every new account leaks to
|
# can filter by user. Without this every new account leaks to
|
||||||
# every other user.
|
# every other user.
|
||||||
@@ -3188,7 +3205,7 @@ def setup_email_routes():
|
|||||||
if not row:
|
if not row:
|
||||||
return {"ok": False, "error": "Account not found"}
|
return {"ok": False, "error": "Account not found"}
|
||||||
# Simple fields
|
# Simple fields
|
||||||
for key in ("name", "imap_host", "imap_user", "smtp_host", "smtp_user", "from_address"):
|
for key in ("name", "imap_host", "imap_user", "smtp_host", "smtp_user", "from_address", "display_name"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, (data[key] or "").strip())
|
setattr(row, key, (data[key] or "").strip())
|
||||||
for key in ("imap_port", "smtp_port"):
|
for key in ("imap_port", "smtp_port"):
|
||||||
@@ -3377,4 +3394,123 @@ def setup_email_routes():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# ── Google OAuth2 routes ──
|
||||||
|
|
||||||
|
@router.get("/oauth/google/authorize")
|
||||||
|
async def google_oauth_authorize(account_id: str = Query(...), request: Request = None, owner: str = Depends(require_user)):
|
||||||
|
import urllib.parse
|
||||||
|
_assert_owns_account(account_id, owner)
|
||||||
|
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||||
|
if not client_id:
|
||||||
|
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
|
||||||
|
redirect_uri = (
|
||||||
|
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||||
|
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||||
|
)
|
||||||
|
state = make_oauth_state(account_id, owner)
|
||||||
|
params = urllib.parse.urlencode({
|
||||||
|
"client_id": client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": "https://mail.google.com/ email",
|
||||||
|
"access_type": "offline",
|
||||||
|
"prompt": "consent",
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
from fastapi.responses import RedirectResponse as _RR
|
||||||
|
return _RR(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
|
||||||
|
|
||||||
|
@router.get("/oauth/google/callback")
|
||||||
|
async def google_oauth_callback(
|
||||||
|
code: str = Query(None),
|
||||||
|
state: str = Query(None),
|
||||||
|
error: str = Query(None),
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
|
import urllib.parse
|
||||||
|
from fastapi.responses import RedirectResponse as _RR
|
||||||
|
if error:
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=google_error")
|
||||||
|
if not code or not state:
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=missing_code")
|
||||||
|
state_data = verify_oauth_state(state)
|
||||||
|
if not state_data:
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=invalid_state")
|
||||||
|
account_id = state_data.get("a", "")
|
||||||
|
owner = state_data.get("o", "")
|
||||||
|
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||||
|
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||||
|
redirect_uri = (
|
||||||
|
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||||
|
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||||
|
)
|
||||||
|
import httpx as _httpx
|
||||||
|
try:
|
||||||
|
resp = _httpx.post("https://oauth2.googleapis.com/token", data={
|
||||||
|
"code": code,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
}, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Google token exchange failed")
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=token_exchange_failed")
|
||||||
|
access_token = data.get("access_token", "")
|
||||||
|
refresh_token = data.get("refresh_token", "")
|
||||||
|
expiry = str(int(time.time()) + data.get("expires_in", 3600))
|
||||||
|
# Fetch the email address from userinfo so we can auto-fill imap_user.
|
||||||
|
email_addr = ""
|
||||||
|
display_name = ""
|
||||||
|
try:
|
||||||
|
ui = _httpx.get("https://www.googleapis.com/oauth2/v1/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"}, timeout=10)
|
||||||
|
if ui.is_success:
|
||||||
|
ui_data = ui.json()
|
||||||
|
email_addr = ui_data.get("email", "")
|
||||||
|
display_name = ui_data.get("name", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from core.database import SessionLocal, EmailAccount
|
||||||
|
from src.secret_storage import encrypt as _enc
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(EmailAccount).filter(EmailAccount.id == account_id).first()
|
||||||
|
if not row:
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=account_not_found")
|
||||||
|
# SECURITY: verify the account belongs to the initiating user.
|
||||||
|
if owner and row.owner and row.owner != owner:
|
||||||
|
logger.warning("OAuth callback owner mismatch — rejecting token write")
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=ownership_error")
|
||||||
|
row.oauth_provider = "google"
|
||||||
|
row.oauth_access_token = _enc(access_token)
|
||||||
|
if refresh_token:
|
||||||
|
row.oauth_refresh_token = _enc(refresh_token)
|
||||||
|
row.oauth_token_expiry = expiry
|
||||||
|
# Auto-fill Google IMAP/SMTP settings if not already configured.
|
||||||
|
if not row.imap_host:
|
||||||
|
row.imap_host = "imap.gmail.com"
|
||||||
|
row.imap_port = 993
|
||||||
|
row.imap_starttls = False
|
||||||
|
if not row.smtp_host:
|
||||||
|
row.smtp_host = "smtp.gmail.com"
|
||||||
|
row.smtp_port = 587
|
||||||
|
if email_addr:
|
||||||
|
if not row.imap_user:
|
||||||
|
row.imap_user = email_addr
|
||||||
|
if not row.smtp_user:
|
||||||
|
row.smtp_user = email_addr
|
||||||
|
if not row.from_address:
|
||||||
|
row.from_address = email_addr
|
||||||
|
if not row.name or row.name == row.id:
|
||||||
|
row.name = email_addr
|
||||||
|
if display_name and not row.display_name:
|
||||||
|
row.display_name = display_name
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return _RR("/?section=integrations&email_oauth_success=1")
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, HTTPException, Form, Depends
|
from fastapi import APIRouter, HTTPException, Form, Depends
|
||||||
from core.constants import EMBEDDING_ENDPOINT_FILE, FASTEMBED_CACHE_DIR
|
from core.constants import EMBEDDING_ENDPOINT_FILE, FASTEMBED_CACHE_DIR
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -67,14 +67,6 @@ def _gallery_image_path(filename: str) -> Path:
|
|||||||
raise HTTPException(400, "Unsafe gallery filename")
|
raise HTTPException(400, "Unsafe gallery filename")
|
||||||
if safe_name != original:
|
if safe_name != original:
|
||||||
raise HTTPException(400, "Unsafe gallery filename")
|
raise HTTPException(400, "Unsafe gallery filename")
|
||||||
if not path.exists():
|
|
||||||
cwd_root = (Path.cwd() / "data" / "generated_images").resolve()
|
|
||||||
cwd_path = (cwd_root / safe_name).resolve()
|
|
||||||
try:
|
|
||||||
if os.path.commonpath([str(cwd_root), str(cwd_path)]) == str(cwd_root) and cwd_path.exists():
|
|
||||||
return cwd_path
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
@@ -232,8 +224,6 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/{image_id}/replace")
|
@router.post("/api/gallery/{image_id}/replace")
|
||||||
async def gallery_replace(request: Request, image_id: str):
|
async def gallery_replace(request: Request, image_id: str):
|
||||||
"""Replace an existing gallery image file with a new one."""
|
"""Replace an existing gallery image file with a new one."""
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -249,9 +239,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
raise HTTPException(400, "No image provided")
|
raise HTTPException(400, "No image provided")
|
||||||
|
|
||||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
|
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
GALLERY_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_path = _gallery_image_path(img.filename)
|
||||||
img_path = img_dir / _sanitize_gallery_filename(img.filename)
|
|
||||||
img_path.write_bytes(content)
|
img_path.write_bytes(content)
|
||||||
|
|
||||||
# Refresh dimensions in case the editor resized the canvas.
|
# Refresh dimensions in case the editor resized the canvas.
|
||||||
|
|||||||
+33
-58
@@ -273,65 +273,30 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
async def api_audit_memories(request: Request, session: str = Form(None)):
|
async def api_audit_memories(request: Request, session: str = Form(None)):
|
||||||
"""Deduplicate and consolidate memories via LLM.
|
"""Deduplicate and consolidate memories via LLM.
|
||||||
|
|
||||||
Uses the default model from settings, or falls back to a session's model.
|
Uses task/utility/default settings through the shared resolver, with
|
||||||
|
the active session as fallback when no task or utility model is set.
|
||||||
Returns before and after memory counts.
|
Returns before and after memory counts.
|
||||||
"""
|
"""
|
||||||
from routes.model_routes import _load_settings, _normalize_base, build_chat_url
|
|
||||||
from core.database import ModelEndpoint
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
endpoint_url = model = None
|
|
||||||
headers = {}
|
|
||||||
|
|
||||||
# Try utility model from settings first — memory audit is a background
|
|
||||||
# task and should prefer the lighter utility model over the main chat model.
|
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(owner=user)
|
fallback_url = fallback_model = None
|
||||||
if t_url and t_model:
|
fallback_headers = None
|
||||||
endpoint_url, model, headers = t_url, t_model, t_headers
|
if session:
|
||||||
else:
|
try:
|
||||||
# Fall back to default model if no task/utility model configured
|
sess = session_manager.get_session(session)
|
||||||
settings = _load_settings()
|
_assert_session_owner(sess, user)
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
fallback_url = sess.endpoint_url
|
||||||
default_model = settings.get("default_model", "")
|
fallback_model = sess.model
|
||||||
if ep_id:
|
fallback_headers = sess.headers
|
||||||
db = SessionLocal()
|
except KeyError:
|
||||||
try:
|
pass
|
||||||
ep = db.query(ModelEndpoint).filter(
|
|
||||||
ModelEndpoint.id == ep_id, ModelEndpoint.is_enabled == True
|
|
||||||
).first()
|
|
||||||
if ep:
|
|
||||||
base = _normalize_base(ep.base_url)
|
|
||||||
endpoint_url = build_chat_url(base)
|
|
||||||
model = default_model
|
|
||||||
if not model and ep.models:
|
|
||||||
try:
|
|
||||||
models = _json.loads(ep.models) if isinstance(ep.models, str) else ep.models
|
|
||||||
if models:
|
|
||||||
model = models[0]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if ep.api_key:
|
|
||||||
headers = {"Authorization": f"Bearer {ep.api_key}"}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Fall back to session model if no default configured
|
endpoint_url, model, headers = resolve_task_endpoint(
|
||||||
if not endpoint_url and session:
|
fallback_url, fallback_model, fallback_headers, owner=user
|
||||||
try:
|
)
|
||||||
sess = session_manager.get_session(session)
|
|
||||||
_assert_session_owner(sess, _owner(request))
|
|
||||||
endpoint_url = sess.endpoint_url
|
|
||||||
model = sess.model
|
|
||||||
headers = sess.headers
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise HTTPException(400, "No default model configured — set one in Settings")
|
raise HTTPException(400, "No default model configured — set one in Settings")
|
||||||
|
|
||||||
user = _owner(request)
|
|
||||||
result = await audit_memories(
|
result = await audit_memories(
|
||||||
memory_manager,
|
memory_manager,
|
||||||
memory_vector,
|
memory_vector,
|
||||||
@@ -369,18 +334,28 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
model = None
|
model = None
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
|
user = _owner(request)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
try:
|
try:
|
||||||
sess = session_manager.get_session(session)
|
sess = session_manager.get_session(session)
|
||||||
_assert_session_owner(sess, _owner(request))
|
_assert_session_owner(sess, user)
|
||||||
endpoint_url, model, headers = resolve_task_endpoint(
|
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
|
||||||
)
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logger.warning("Session %s not found, falling back to utility endpoint", session)
|
sess = None
|
||||||
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
|
except HTTPException as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
sess = None
|
||||||
|
|
||||||
|
if sess is None:
|
||||||
|
logger.warning("Session %s not found or inaccessible, falling back to utility endpoint", session)
|
||||||
|
endpoint_url, model, headers = resolve_endpoint("utility", owner=user)
|
||||||
|
else:
|
||||||
|
endpoint_url, model, headers = resolve_task_endpoint(
|
||||||
|
sess.endpoint_url, sess.model, sess.headers, owner=user
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
endpoint_url, model, headers = resolve_task_endpoint(owner=_owner(request))
|
endpoint_url, model, headers = resolve_task_endpoint(owner=user)
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
||||||
|
|||||||
+28
-17
@@ -5,6 +5,7 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
@@ -26,7 +27,7 @@ from src.endpoint_resolver import (
|
|||||||
build_models_url,
|
build_models_url,
|
||||||
build_headers,
|
build_headers,
|
||||||
)
|
)
|
||||||
from src.auth_helpers import _auth_disabled, owner_filter
|
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -562,6 +563,8 @@ def _safe_build_models_url(base_url: str) -> str:
|
|||||||
"""Build a /models URL without letting optional provider imports break probes."""
|
"""Build a /models URL without letting optional provider imports break probes."""
|
||||||
try:
|
try:
|
||||||
return build_models_url(base_url)
|
return build_models_url(base_url)
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Model URL detection failed for %s: %s", base_url, exc)
|
logger.debug("Model URL detection failed for %s: %s", base_url, exc)
|
||||||
return f"{(base_url or '').rstrip('/')}/models"
|
return f"{(base_url or '').rstrip('/')}/models"
|
||||||
@@ -633,7 +636,7 @@ def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 1
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
t0 = _time.time()
|
t0 = _time.time()
|
||||||
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout)
|
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout, verify=llm_verify())
|
||||||
latency = round((_time.time() - t0) * 1000)
|
latency = round((_time.time() - t0) * 1000)
|
||||||
if r.is_success:
|
if r.is_success:
|
||||||
return {"status": "ok", "latency_ms": latency}
|
return {"status": "ok", "latency_ms": latency}
|
||||||
@@ -659,13 +662,20 @@ def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 1
|
|||||||
|
|
||||||
# Hostnames / IP prefixes that indicate a local endpoint
|
# Hostnames / IP prefixes that indicate a local endpoint
|
||||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
|
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
|
||||||
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
_PRIVATE_NETWORKS = (
|
||||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
"172.30.", "172.31.", "192.168.")
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
)
|
||||||
|
_TAILSCALE_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
|
||||||
|
|
||||||
_TAILSCALE_RE = re.compile(r"^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.")
|
def _local_ip_literal(host: str) -> bool:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(ip in network for network in _PRIVATE_NETWORKS) or ip in _TAILSCALE_CGNAT
|
||||||
|
|
||||||
|
|
||||||
def _classify_endpoint(base_url: str, endpoint_kind: str = "auto") -> str:
|
def _classify_endpoint(base_url: str, endpoint_kind: str = "auto") -> str:
|
||||||
@@ -679,9 +689,7 @@ def _classify_endpoint(base_url: str, endpoint_kind: str = "auto") -> str:
|
|||||||
return "api"
|
return "api"
|
||||||
try:
|
try:
|
||||||
host = urlparse(base_url).hostname or ""
|
host = urlparse(base_url).hostname or ""
|
||||||
if host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES):
|
if host in _LOCAL_HOSTS or _local_ip_literal(host):
|
||||||
return "local"
|
|
||||||
if _TAILSCALE_RE.match(host):
|
|
||||||
return "local"
|
return "local"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1255,13 +1263,16 @@ def setup_model_routes(model_discovery):
|
|||||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||||
# "see everything" by _fetch_models.
|
# "see everything" by _fetch_models.
|
||||||
try:
|
try:
|
||||||
from src.auth_helpers import get_current_user as _gcu
|
if getattr(request.state, "api_token", False):
|
||||||
owner = _gcu(request) or ""
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
except Exception:
|
if "chat" not in scopes:
|
||||||
owner = ""
|
raise HTTPException(403, "API token is not scoped for chat")
|
||||||
# Reject anonymous in configured deployments — no leaking the model
|
if not getattr(request.state, "api_token_owner", None):
|
||||||
# list to unauthenticated callers.
|
raise HTTPException(403, "API token has no owner")
|
||||||
try:
|
owner = effective_user(request) or ""
|
||||||
|
|
||||||
|
# Reject anonymous in configured deployments — no leaking the model
|
||||||
|
# list to unauthenticated callers.
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
if not owner and not _auth_disabled() and auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
if not owner and not _auth_disabled() and auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
|||||||
+14
-5
@@ -10,7 +10,8 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core.database import SessionLocal, Note
|
from core.database import SessionLocal, Note
|
||||||
from src.auth_helpers import get_current_user
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
|
from src.auth_helpers import require_user
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
@@ -570,10 +571,19 @@ def setup_note_routes(task_scheduler=None):
|
|||||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||||
|
|
||||||
def _owner(request: Request) -> Optional[str]:
|
def _owner(request: Request) -> Optional[str]:
|
||||||
return get_current_user(request)
|
# require_user, not bare get_current_user: a request that reaches
|
||||||
|
# these owner-scoped routes with NO identity (auth-middleware
|
||||||
|
# regression, SSRF from a sibling service) must fail closed (401)
|
||||||
|
# when auth is configured — not be treated as the single-user mode
|
||||||
|
# and handed blanket access to every account's notes. The documented
|
||||||
|
# anonymous modes (AUTH_ENABLED=false, LOCALHOST_BYPASS on loopback,
|
||||||
|
# unconfigured first-run) still resolve to None, the single-user
|
||||||
|
# path. fire_reminder below already gated this way; the CRUD routes
|
||||||
|
# did not.
|
||||||
|
return require_user(request) or None
|
||||||
|
|
||||||
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
||||||
if user == "internal-tool":
|
if user == INTERNAL_TOOL_USER:
|
||||||
return True
|
return True
|
||||||
if not user:
|
if not user:
|
||||||
# require_user() already admitted this request, which only happens
|
# require_user() already admitted this request, which only happens
|
||||||
@@ -805,8 +815,7 @@ def setup_note_routes(task_scheduler=None):
|
|||||||
Returns {synthesis, email_sent}.
|
Returns {synthesis, email_sent}.
|
||||||
"""
|
"""
|
||||||
# Gate against anonymous callers — LLM synthesis can burn tokens.
|
# Gate against anonymous callers — LLM synthesis can burn tokens.
|
||||||
from src.auth_helpers import require_user as _ru
|
user = require_user(request)
|
||||||
user = _ru(request)
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
note_id = str(body.get("note_id") or "").strip()
|
note_id = str(body.get("note_id") or "").strip()
|
||||||
if not note_id:
|
if not note_id:
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
"""Routes for personal documents management."""
|
"""Routes for personal documents management."""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from typing import List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||||
from src.request_models import DirectoryRequest
|
from src.request_models import DirectoryRequest
|
||||||
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||||
@@ -18,14 +19,15 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _personal_upload_dir_for_owner(owner: str | None) -> str:
|
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
||||||
"""Return the per-owner upload directory used for direct RAG uploads."""
|
"""Return the per-owner upload directory used for direct RAG uploads."""
|
||||||
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
||||||
upload_dir = os.path.abspath(os.path.join(UPLOADS_DIR, owner_segment))
|
upload_dir = os.path.abspath(os.path.join(UPLOADS_DIR, owner_segment))
|
||||||
base_abs = os.path.abspath(UPLOADS_DIR)
|
base_abs = os.path.abspath(UPLOADS_DIR)
|
||||||
if os.path.commonpath([upload_dir, base_abs]) != base_abs:
|
if os.path.commonpath([upload_dir, base_abs]) != base_abs:
|
||||||
raise ValueError("Unsafe upload owner path")
|
raise ValueError("Unsafe upload owner path")
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
if create:
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
return upload_dir
|
return upload_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +46,87 @@ def _unique_personal_upload_path(upload_dir: str, original_name: str | None) ->
|
|||||||
raise ValueError("Unsafe upload filename")
|
raise ValueError("Unsafe upload filename")
|
||||||
return file_path, filename, safe_name
|
return file_path, filename, safe_name
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_existing_target(path: str) -> str:
|
||||||
|
"""Return a non-existing sibling path for rename collision handling."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return path
|
||||||
|
stem, ext = os.path.splitext(path)
|
||||||
|
while True:
|
||||||
|
candidate = f"{stem}-{uuid.uuid4().hex[:10]}{ext}"
|
||||||
|
if not os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_empty_tree(path: str) -> None:
|
||||||
|
"""Best-effort removal of empty directories under ``path``."""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return
|
||||||
|
for root, dirs, _files in os.walk(path, topdown=False):
|
||||||
|
for dirname in dirs:
|
||||||
|
candidate = os.path.join(root, dirname)
|
||||||
|
try:
|
||||||
|
os.rmdir(candidate)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.rmdir(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def rename_personal_upload_owner(
|
||||||
|
old_owner: str,
|
||||||
|
new_owner: str,
|
||||||
|
*,
|
||||||
|
personal_docs_manager: Any = None,
|
||||||
|
rag_manager: Any = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Move direct personal uploads and rewrite RAG owner metadata on user rename."""
|
||||||
|
old_dir = _personal_upload_dir_for_owner(old_owner, create=False)
|
||||||
|
new_dir = _personal_upload_dir_for_owner(new_owner, create=False)
|
||||||
|
path_map: Dict[str, str] = {}
|
||||||
|
moved_files = 0
|
||||||
|
|
||||||
|
if os.path.isdir(old_dir) and old_dir != new_dir:
|
||||||
|
os.makedirs(new_dir, exist_ok=True)
|
||||||
|
for root, _dirs, files in os.walk(old_dir):
|
||||||
|
rel_root = os.path.relpath(root, old_dir)
|
||||||
|
target_root = new_dir if rel_root == "." else os.path.join(new_dir, rel_root)
|
||||||
|
os.makedirs(target_root, exist_ok=True)
|
||||||
|
for filename in files:
|
||||||
|
source = os.path.abspath(os.path.join(root, filename))
|
||||||
|
target = _unique_existing_target(os.path.abspath(os.path.join(target_root, filename)))
|
||||||
|
shutil.move(source, target)
|
||||||
|
path_map[source] = target
|
||||||
|
moved_files += 1
|
||||||
|
_remove_empty_tree(old_dir)
|
||||||
|
|
||||||
|
if personal_docs_manager is not None:
|
||||||
|
rename_directory = getattr(personal_docs_manager, "rename_directory", None)
|
||||||
|
if callable(rename_directory):
|
||||||
|
rename_directory(old_dir, new_dir, path_map=path_map)
|
||||||
|
|
||||||
|
rag_result = None
|
||||||
|
if rag_manager is not None:
|
||||||
|
rename_owner = getattr(rag_manager, "rename_owner", None)
|
||||||
|
if callable(rename_owner):
|
||||||
|
rag_result = rename_owner(
|
||||||
|
old_owner,
|
||||||
|
new_owner,
|
||||||
|
path_map=path_map,
|
||||||
|
path_prefixes=[(old_dir, new_dir)],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"old_dir": old_dir,
|
||||||
|
"new_dir": new_dir,
|
||||||
|
"moved_files": moved_files,
|
||||||
|
"path_map": path_map,
|
||||||
|
"rag_result": rag_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||||
"""
|
"""
|
||||||
Setup personal documents related routes.
|
Setup personal documents related routes.
|
||||||
@@ -278,8 +361,8 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
# Delete file from disk if it's in uploads dir
|
# Delete file from disk if it's in uploads dir
|
||||||
deleted_from_disk = False
|
deleted_from_disk = False
|
||||||
try:
|
try:
|
||||||
abs_target = os.path.abspath(filepath)
|
abs_target = os.path.realpath(filepath)
|
||||||
base_abs = os.path.abspath(UPLOADS_DIR)
|
base_abs = os.path.realpath(UPLOADS_DIR)
|
||||||
in_uploads = (
|
in_uploads = (
|
||||||
abs_target == base_abs
|
abs_target == base_abs
|
||||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||||
@@ -385,9 +387,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"""Launch a research job from the dedicated panel."""
|
"""Launch a research job from the dedicated panel."""
|
||||||
from src.auth_helpers import require_privilege
|
from src.auth_helpers import require_privilege
|
||||||
user = require_privilege(request, "can_use_research")
|
user = require_privilege(request, "can_use_research")
|
||||||
if user == "internal-tool":
|
if user == INTERNAL_TOOL_USER:
|
||||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||||
if tool_owner and tool_owner not in {"internal-tool", "api", "demo", "system"}:
|
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from core.session_manager import SessionManager
|
|||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from src.request_models import SessionResponse
|
from src.request_models import SessionResponse
|
||||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||||
from src.auth_helpers import get_current_user, effective_user, _auth_disabled, owner_filter
|
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||||
from src.session_actions import is_session_recently_active
|
from src.session_actions import is_session_recently_active
|
||||||
|
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
endpoint_id: str = Form(""),
|
endpoint_id: str = Form(""),
|
||||||
):
|
):
|
||||||
skip_val = str(skip_validation).lower() == "true"
|
skip_val = str(skip_validation).lower() == "true"
|
||||||
user = get_current_user(request)
|
user = effective_user(request)
|
||||||
endpoint_api_key = ""
|
endpoint_api_key = ""
|
||||||
endpoint_base_url = ""
|
endpoint_base_url = ""
|
||||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||||
@@ -477,7 +477,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db.close()
|
db.close()
|
||||||
# Switch model/endpoint mid-session
|
# Switch model/endpoint mid-session
|
||||||
if model is not None and endpoint_url is not None:
|
if model is not None and endpoint_url is not None:
|
||||||
user = get_current_user(request)
|
user = effective_user(request)
|
||||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||||
endpoint_api_key = ""
|
endpoint_api_key = ""
|
||||||
endpoint_base_url = ""
|
endpoint_base_url = ""
|
||||||
@@ -1004,6 +1004,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
"""
|
"""
|
||||||
from src.llm_core import llm_call
|
from src.llm_core import llm_call
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
|
single_user_mode = not user and _auth_disabled()
|
||||||
user_sessions = session_manager.get_sessions_for_user(user)
|
user_sessions = session_manager.get_sessions_for_user(user)
|
||||||
|
|
||||||
# Delete empty and throwaway sessions before sorting
|
# Delete empty and throwaway sessions before sorting
|
||||||
@@ -1022,7 +1023,12 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
}
|
}
|
||||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
||||||
try:
|
try:
|
||||||
rows = db.query(DbSession).filter(DbSession.archived == False, DbSession.owner == user).limit(2000).all()
|
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||||
|
if user:
|
||||||
|
rows_q = rows_q.filter(DbSession.owner == user)
|
||||||
|
elif not single_user_mode:
|
||||||
|
rows_q = rows_q.filter(DbSession.owner == user)
|
||||||
|
rows = rows_q.limit(2000).all()
|
||||||
folder_map = {r.id: r.folder for r in rows}
|
folder_map = {r.id: r.folder for r in rows}
|
||||||
# Precompute per-session message counts in TWO aggregate queries
|
# Precompute per-session message counts in TWO aggregate queries
|
||||||
# instead of 1–3 queries PER session — with many chats the per-row
|
# instead of 1–3 queries PER session — with many chats the per-row
|
||||||
@@ -1242,7 +1248,12 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
for sid, folder_name in assignments.items():
|
for sid, folder_name in assignments.items():
|
||||||
db_session = db.query(DbSession).filter(DbSession.id == sid, DbSession.owner == user).first()
|
db_session_q = db.query(DbSession).filter(DbSession.id == sid)
|
||||||
|
if user:
|
||||||
|
db_session_q = db_session_q.filter(DbSession.owner == user)
|
||||||
|
elif not single_user_mode:
|
||||||
|
db_session_q = db_session_q.filter(DbSession.owner == user)
|
||||||
|
db_session = db_session_q.first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.folder = folder_name
|
db_session.folder = folder_name
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = datetime.utcnow()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from collections import namedtuple
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
||||||
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
from src.optional_deps import prepare_optional_dependency_import
|
from src.optional_deps import prepare_optional_dependency_import
|
||||||
|
|
||||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||||
@@ -55,7 +56,7 @@ def _require_admin(request: Request):
|
|||||||
# In-process tool loopback. The AuthMiddleware already validated the
|
# In-process tool loopback. The AuthMiddleware already validated the
|
||||||
# internal token + loopback client before setting this marker, so
|
# internal token + loopback client before setting this marker, so
|
||||||
# honour it here as admin-equivalent.
|
# honour it here as admin-equivalent.
|
||||||
if user == "internal-tool":
|
if user == INTERNAL_TOOL_USER:
|
||||||
return
|
return
|
||||||
if not user or user == "api":
|
if not user or user == "api":
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||||
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||||
@@ -427,7 +428,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
# In-process tool-loopback marker — AuthMiddleware validated
|
# In-process tool-loopback marker — AuthMiddleware validated
|
||||||
# the internal token + loopback client before stamping this,
|
# the internal token + loopback client before stamping this,
|
||||||
# so treat as admin-equivalent.
|
# so treat as admin-equivalent.
|
||||||
if user == "internal-tool":
|
if user == INTERNAL_TOOL_USER:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, File, UploadFile, HTTPException
|
|||||||
from typing import List
|
from typing import List
|
||||||
import logging
|
import logging
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import effective_user
|
||||||
from src.upload_handler import count_recent_uploads
|
from src.upload_handler import count_recent_uploads
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -78,7 +78,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
for u in files:
|
for u in files:
|
||||||
try:
|
try:
|
||||||
meta = upload_handler.save_upload(u, client_ip, owner=get_current_user(request))
|
meta = upload_handler.save_upload(u, client_ip, owner=effective_user(request))
|
||||||
out.append({
|
out.append({
|
||||||
"id": meta["id"],
|
"id": meta["id"],
|
||||||
"name": meta["name"],
|
"name": meta["name"],
|
||||||
@@ -138,7 +138,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
original_name = info.get("name", file_id)
|
original_name = info.get("name", file_id)
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||||
current_user = get_current_user(request)
|
current_user = effective_user(request)
|
||||||
file_owner = info.get("owner") if info else None
|
file_owner = info.get("owner") if info else None
|
||||||
if auth_configured:
|
if auth_configured:
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -204,7 +204,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
info = _load_upload_info(file_id)
|
info = _load_upload_info(file_id)
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||||
current_user = get_current_user(request)
|
current_user = effective_user(request)
|
||||||
file_owner = info.get("owner") if info else None
|
file_owner = info.get("owner") if info else None
|
||||||
if auth_configured:
|
if auth_configured:
|
||||||
if not current_user:
|
if not current_user:
|
||||||
@@ -247,7 +247,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
|
||||||
current_user = get_current_user(request)
|
current_user = effective_user(request)
|
||||||
file_owner = info.get("owner")
|
file_owner = info.get("owner")
|
||||||
if auth_configured:
|
if auth_configured:
|
||||||
if not current_user:
|
if not current_user:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Webhook, API Token, and sync chat routes."""
|
"""Webhook, API Token, and sync chat routes."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -385,10 +384,10 @@ def setup_webhook_routes(
|
|||||||
sess.add_message(ChatMessage("assistant", reply))
|
sess.add_message(ChatMessage("assistant", reply))
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
|
|
||||||
asyncio.create_task(webhook_manager.fire("chat.completed", {
|
webhook_manager.fire_and_forget("chat.completed", {
|
||||||
"session_id": session_id, "model": sess.model,
|
"session_id": session_id, "model": sess.model,
|
||||||
"user_message": message[:2000], "response": reply[:2000],
|
"user_message": message[:2000], "response": reply[:2000],
|
||||||
}))
|
})
|
||||||
|
|
||||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||||
|
|
||||||
|
|||||||
@@ -103,9 +103,13 @@ def cmd_list(args) -> None:
|
|||||||
end = _parse_dt(args.end) if args.end else (start + timedelta(days=30))
|
end = _parse_dt(args.end) if args.end else (start + timedelta(days=30))
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
# Overlap semantics, matching the web route (routes/calendar_routes.py)
|
||||||
|
# and the recurring-expansion contract: an event is in the window when
|
||||||
|
# it starts before the window end AND ends after the window start. This
|
||||||
|
# includes multi-day / in-progress events that began before `start`.
|
||||||
q = db.query(CalendarEvent).filter(
|
q = db.query(CalendarEvent).filter(
|
||||||
CalendarEvent.dtstart >= start,
|
|
||||||
CalendarEvent.dtstart < end,
|
CalendarEvent.dtstart < end,
|
||||||
|
CalendarEvent.dtend > start,
|
||||||
)
|
)
|
||||||
if args.calendar:
|
if args.calendar:
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.name == args.calendar).first()
|
cal = db.query(CalendarCal).filter(CalendarCal.name == args.calendar).first()
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ GPU_BANDWIDTH = {
|
|||||||
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
|
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
|
||||||
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
|
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
|
||||||
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
|
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
|
||||||
|
# NVIDIA GB10 Grace-Blackwell superchip (DGX Spark). Unified LPDDR5X memory,
|
||||||
|
# not Apple Silicon, so it lives in the generic GPU table — the Apple-only
|
||||||
|
# lookup never matches it (its name carries no "apple").
|
||||||
|
"gb10": 273,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pre-sort keys by length descending for correct substring matching
|
# Pre-sort keys by length descending for correct substring matching
|
||||||
@@ -126,6 +130,43 @@ def _lookup_bandwidth(system):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_cpu_backend(system):
|
||||||
|
"""Return the canonical CPU backend for cpu_only speed estimation.
|
||||||
|
|
||||||
|
Normalizes CPU-architecture aliases separately from the GPU backend, and
|
||||||
|
overrides GPU-only backends (CUDA/ROCm/Metal) so they do not inherit a
|
||||||
|
discrete-GPU fallback constant when the model is actually running on CPU.
|
||||||
|
"""
|
||||||
|
backend = (system.get("backend") or "").lower().strip()
|
||||||
|
cpu_arch = (system.get("cpu_arch") or "").lower().strip()
|
||||||
|
cpu_name = (system.get("cpu_name") or "").lower()
|
||||||
|
gpu_name = (system.get("gpu_name") or "").lower()
|
||||||
|
|
||||||
|
# Already-canonical CPU backends
|
||||||
|
if backend in ("cpu_x86", "cpu_arm"):
|
||||||
|
return backend
|
||||||
|
|
||||||
|
# Raw CPU-architecture aliases
|
||||||
|
if backend in ("x86_64", "amd64", "i386", "i686"):
|
||||||
|
return "cpu_x86"
|
||||||
|
if backend in ("arm64", "aarch64", "arm"):
|
||||||
|
return "cpu_arm"
|
||||||
|
|
||||||
|
# Prefer an explicit CPU architecture field when present
|
||||||
|
if cpu_arch:
|
||||||
|
if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"):
|
||||||
|
return "cpu_x86"
|
||||||
|
if cpu_arch in ("arm64", "aarch64", "arm"):
|
||||||
|
return "cpu_arm"
|
||||||
|
|
||||||
|
# Apple Silicon enters ranking as backend="metal"; its CPU path is ARM.
|
||||||
|
if backend in ("metal", "mps", "apple") or "apple" in cpu_name or "apple" in gpu_name:
|
||||||
|
return "cpu_arm"
|
||||||
|
|
||||||
|
# Conservative default for CUDA/ROCm/discrete GPU backends and unknowns.
|
||||||
|
return "cpu_x86"
|
||||||
|
|
||||||
|
|
||||||
def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
|
def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
|
||||||
"""Estimate tok/s. Uses active params for MoE (only active experts run per token).
|
"""Estimate tok/s. Uses active params for MoE (only active experts run per token).
|
||||||
|
|
||||||
@@ -143,6 +184,11 @@ def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
|
|||||||
bw = _lookup_bandwidth(system)
|
bw = _lookup_bandwidth(system)
|
||||||
backend = system.get("backend", "cpu_x86")
|
backend = system.get("backend", "cpu_x86")
|
||||||
|
|
||||||
|
# CPU-only inference must never inherit a GPU backend's fallback constant,
|
||||||
|
# even if the detected system happens to report a CUDA/Metal/ROCm backend.
|
||||||
|
if run_mode == "cpu_only":
|
||||||
|
backend = _canonical_cpu_backend(system)
|
||||||
|
|
||||||
if bw and run_mode in ("gpu", "cpu_offload"):
|
if bw and run_mode in ("gpu", "cpu_offload"):
|
||||||
bpp = QUANT_BYTES_PER_PARAM.get(quant, 0.5)
|
bpp = QUANT_BYTES_PER_PARAM.get(quant, 0.5)
|
||||||
model_gb = pb * bpp
|
model_gb = pb * bpp
|
||||||
|
|||||||
+163
-14
@@ -15,6 +15,8 @@ from urllib.parse import urljoin, urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
||||||
|
|
||||||
from .analytics import RateLimitError, error_logger
|
from .analytics import RateLimitError, error_logger
|
||||||
from .cache import (
|
from .cache import (
|
||||||
CONTENT_CACHE_DIR,
|
CONTENT_CACHE_DIR,
|
||||||
@@ -89,18 +91,128 @@ def _public_http_url(url: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5) -> httpx.Response:
|
class BodyTooLargeError(Exception):
|
||||||
|
"""The server declared a body larger than the hard fetch ceiling."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, declared_bytes: int):
|
||||||
|
self.url = url
|
||||||
|
self.declared_bytes = declared_bytes
|
||||||
|
super().__init__(
|
||||||
|
f"response body is {declared_bytes:,} bytes, over the "
|
||||||
|
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _CappedFetch:
|
||||||
|
"""Result of a size-capped streaming GET.
|
||||||
|
|
||||||
|
Carries just what fetch_webpage_content needs from an httpx.Response,
|
||||||
|
plus the cap bookkeeping: the (possibly truncated) body, whether the
|
||||||
|
cap cut it short, and the size the server declared via Content-Length
|
||||||
|
(wire bytes; None when absent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("status_code", "headers", "content", "truncated",
|
||||||
|
"declared_bytes", "encoding", "url")
|
||||||
|
|
||||||
|
def __init__(self, status_code, headers, content, truncated,
|
||||||
|
declared_bytes, encoding, url):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.headers = headers
|
||||||
|
self.content = content
|
||||||
|
self.truncated = truncated
|
||||||
|
self.declared_bytes = declared_bytes
|
||||||
|
self.encoding = encoding
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
request = httpx.Request("GET", self.url)
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"HTTP {self.status_code} for {self.url}",
|
||||||
|
request=request,
|
||||||
|
response=httpx.Response(self.status_code, request=request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
|
||||||
|
max_bytes: int = None) -> "_CappedFetch":
|
||||||
|
"""Capped streaming GET with SSRF-guarded manual redirects.
|
||||||
|
|
||||||
|
The body is streamed and buffering stops at ``max_bytes`` (default: the
|
||||||
|
soft cap), so an oversized resource cannot be pulled into memory or the
|
||||||
|
content cache in full. When Content-Length already declares a body over
|
||||||
|
the hard ceiling, the fetch is refused before any body bytes are read.
|
||||||
|
"""
|
||||||
|
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||||
current = url
|
current = url
|
||||||
for _ in range(max_redirects + 1):
|
for _ in range(max_redirects + 1):
|
||||||
if not _public_http_url(current):
|
if not _public_http_url(current):
|
||||||
raise httpx.RequestError("Blocked private/internal URL", request=httpx.Request("GET", current))
|
raise httpx.RequestError("Blocked private/internal URL", request=httpx.Request("GET", current))
|
||||||
response = httpx.get(current, headers=headers, timeout=timeout, follow_redirects=False)
|
# Force identity transfer-encoding. With gzip/deflate the wire bytes
|
||||||
if response.status_code not in (301, 302, 303, 307, 308):
|
# (and Content-Length) can be a small fraction of the decoded body, so
|
||||||
return response
|
# a tiny compressed response could pass the hard-cap preflight and then
|
||||||
location = response.headers.get("location")
|
# expand past the ceiling in a single decoded chunk before the streamed
|
||||||
if not location:
|
# cap below can slice it. Identity makes Content-Length the true body
|
||||||
return response
|
# size and keeps each streamed chunk bounded by the network read.
|
||||||
current = urljoin(str(response.url), location)
|
req_headers = dict(headers or {})
|
||||||
|
req_headers["Accept-Encoding"] = "identity"
|
||||||
|
with httpx.stream("GET", current, headers=req_headers, timeout=timeout,
|
||||||
|
follow_redirects=False) as response:
|
||||||
|
if response.status_code in (301, 302, 303, 307, 308):
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return _CappedFetch(response.status_code, response.headers, b"",
|
||||||
|
False, None, response.encoding, str(response.url))
|
||||||
|
current = urljoin(str(response.url), location)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A server can ignore the identity request and still return a
|
||||||
|
# compressed body; httpx.iter_bytes would then decode it, and a tiny
|
||||||
|
# gzip can balloon into one decoded chunk far past the cap before we
|
||||||
|
# slice. Refuse a compressed Content-Encoding so the streamed cap
|
||||||
|
# stays a real memory bound (Content-Length is the compressed wire
|
||||||
|
# length here, so the preflight and size metadata are unreliable too).
|
||||||
|
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||||
|
if enc and enc != "identity":
|
||||||
|
raise httpx.RequestError(
|
||||||
|
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||||
|
"requesting identity: cannot bound decoded body size",
|
||||||
|
request=httpx.Request("GET", current),
|
||||||
|
)
|
||||||
|
|
||||||
|
declared = None
|
||||||
|
raw_len = response.headers.get("content-length")
|
||||||
|
if raw_len and raw_len.isdigit():
|
||||||
|
declared = int(raw_len)
|
||||||
|
# Refuse before buffering anything when the server already tells
|
||||||
|
# us the body exceeds the absolute ceiling (Content-Length is wire
|
||||||
|
# bytes; the decompressed body can only be larger).
|
||||||
|
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||||
|
raise BodyTooLargeError(current, declared)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
read = 0
|
||||||
|
truncated = False
|
||||||
|
# We requested identity above, so iter_bytes yields the raw body in
|
||||||
|
# network-read-sized chunks (no decompression expansion); the cap
|
||||||
|
# therefore bounds what we actually buffer.
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
read += len(chunk)
|
||||||
|
if read > cap:
|
||||||
|
keep = cap - (read - len(chunk))
|
||||||
|
if keep > 0:
|
||||||
|
chunks.append(chunk[:keep])
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
return _CappedFetch(response.status_code, response.headers,
|
||||||
|
b"".join(chunks), truncated, declared,
|
||||||
|
response.encoding, str(response.url))
|
||||||
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
||||||
|
|
||||||
# PDF extraction (optional dependency)
|
# PDF extraction (optional dependency)
|
||||||
@@ -222,9 +334,19 @@ def _empty_result(url: str, error: str = "") -> dict:
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Main content fetcher
|
# Main content fetcher
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) -> dict:
|
def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0,
|
||||||
"""Fetch and extract meaningful content from a webpage with caching."""
|
max_bytes: int = None) -> dict:
|
||||||
cache_key = generate_cache_key(url)
|
"""Fetch and extract meaningful content from a webpage with caching.
|
||||||
|
|
||||||
|
``max_bytes`` raises the download budget per call (clamped to the hard
|
||||||
|
cap); the default is the soft cap. When the body is cut short the result
|
||||||
|
carries ``truncated``/``fetched_bytes``/``total_bytes`` so callers can
|
||||||
|
tell the model the content is partial (#3812).
|
||||||
|
"""
|
||||||
|
effective_cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||||
|
# The cap is part of the cache identity: a truncated soft-cap fetch must
|
||||||
|
# not be served to a later full-budget request for the same URL.
|
||||||
|
cache_key = generate_cache_key(f"{url}#cap={effective_cap}")
|
||||||
cache_file = CONTENT_CACHE_DIR / f"{cache_key}.cache"
|
cache_file = CONTENT_CACHE_DIR / f"{cache_key}.cache"
|
||||||
|
|
||||||
# Check cache
|
# Check cache
|
||||||
@@ -247,18 +369,24 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
|
|||||||
# Fetch
|
# Fetch
|
||||||
try:
|
try:
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
"User-Agent": WEB_FETCH_USER_AGENT,
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
"Accept-Language": "en-US,en;q=0.5",
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
"Accept-Encoding": "gzip, deflate",
|
# identity so the streamed size cap in _get_public_url stays honest
|
||||||
|
# (a compressed body can decode to far more than Content-Length).
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
}
|
}
|
||||||
response = _get_public_url(url, headers=headers, timeout=timeout)
|
response = _get_public_url(url, headers=headers, timeout=timeout,
|
||||||
|
max_bytes=effective_cap)
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
raise RateLimitError(f"Rate limit hit for {url} (attempt {retry_attempt})")
|
raise RateLimitError(f"Rate limit hit for {url} (attempt {retry_attempt})")
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
except BodyTooLargeError as e:
|
||||||
|
error_logger.warning(f"Refused oversized body for {url}: {e}")
|
||||||
|
return _empty_result(url, f"TooLarge: {e}")
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
error_logger.warning(f"HTTP {e.response.status_code} fetching {url}: {e}")
|
error_logger.warning(f"HTTP {e.response.status_code} fetching {url}: {e}")
|
||||||
return _empty_result(url, f"HTTP {e.response.status_code}: {e}")
|
return _empty_result(url, f"HTTP {e.response.status_code}: {e}")
|
||||||
@@ -269,9 +397,27 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
|
|||||||
error_logger.error(str(e))
|
error_logger.error(str(e))
|
||||||
return _empty_result(url, str(e))
|
return _empty_result(url, str(e))
|
||||||
|
|
||||||
|
# Size bookkeeping shared by every content branch below. getattr keeps
|
||||||
|
# plain httpx.Response stand-ins (tests) working without the cap fields.
|
||||||
|
_size_fields = {
|
||||||
|
"truncated": getattr(response, "truncated", False),
|
||||||
|
"fetched_bytes": len(response.content),
|
||||||
|
"total_bytes": getattr(response, "declared_bytes", None),
|
||||||
|
}
|
||||||
|
|
||||||
# PDF handling
|
# PDF handling
|
||||||
content_type = response.headers.get("Content-Type", "").lower()
|
content_type = response.headers.get("Content-Type", "").lower()
|
||||||
if "application/pdf" in content_type or url.lower().endswith(".pdf"):
|
if "application/pdf" in content_type or url.lower().endswith(".pdf"):
|
||||||
|
if _size_fields["truncated"]:
|
||||||
|
# A PDF cut mid-stream is not parseable; unlike text there is no
|
||||||
|
# useful partial result, so report the budget problem instead.
|
||||||
|
_declared = _size_fields["total_bytes"]
|
||||||
|
return _empty_result(
|
||||||
|
url,
|
||||||
|
f"TooLarge: PDF exceeds the {effective_cap:,}-byte fetch budget"
|
||||||
|
+ (f" (size {_declared:,} bytes)" if _declared else "")
|
||||||
|
+ "; retry with a larger budget if it fits under the hard cap",
|
||||||
|
)
|
||||||
if pdf_extract_text is None:
|
if pdf_extract_text is None:
|
||||||
logger.error("pdfminer.six is not installed; cannot extract PDF text.")
|
logger.error("pdfminer.six is not installed; cannot extract PDF text.")
|
||||||
pdf_text = ""
|
pdf_text = ""
|
||||||
@@ -295,6 +441,7 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
|
|||||||
"js_message": "",
|
"js_message": "",
|
||||||
"success": bool(pdf_text),
|
"success": bool(pdf_text),
|
||||||
"error": "" if pdf_text else "Failed to extract PDF text",
|
"error": "" if pdf_text else "Failed to extract PDF text",
|
||||||
|
**_size_fields,
|
||||||
}
|
}
|
||||||
_cache_result(cache_file, cache_key, result, url)
|
_cache_result(cache_file, cache_key, result, url)
|
||||||
return result
|
return result
|
||||||
@@ -329,6 +476,7 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
|
|||||||
"js_message": "",
|
"js_message": "",
|
||||||
"success": bool(text_body),
|
"success": bool(text_body),
|
||||||
"error": "" if text_body else "Empty response body",
|
"error": "" if text_body else "Empty response body",
|
||||||
|
**_size_fields,
|
||||||
}
|
}
|
||||||
_cache_result(cache_file, cache_key, result, url)
|
_cache_result(cache_file, cache_key, result, url)
|
||||||
return result
|
return result
|
||||||
@@ -391,6 +539,7 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
|
|||||||
"js_message": js_message,
|
"js_message": js_message,
|
||||||
"success": True,
|
"success": True,
|
||||||
"error": "",
|
"error": "",
|
||||||
|
**_size_fields,
|
||||||
}
|
}
|
||||||
_cache_result(cache_file, cache_key, result, url)
|
_cache_result(cache_file, cache_key, result, url)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ from urllib.parse import urljoin, urlparse, parse_qs
|
|||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from src.constants import SEARXNG_INSTANCE
|
from src.constants import SEARXNG_INSTANCE, REQUEST_TIMEOUT, WEB_FETCH_USER_AGENT
|
||||||
from .analytics import RateLimitError, error_logger
|
from .analytics import RateLimitError, error_logger
|
||||||
from .query import build_enhanced_query
|
from .query import build_enhanced_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 20
|
|
||||||
|
|
||||||
# Provider registry — maps setting value to (label, needs_key, needs_url)
|
# Provider registry — maps setting value to (label, needs_key, needs_url)
|
||||||
PROVIDER_INFO = {
|
PROVIDER_INFO = {
|
||||||
"searxng": ("SearXNG", False, True),
|
"searxng": ("SearXNG", False, True),
|
||||||
@@ -140,7 +138,7 @@ def searxng_search_api(query: str, count: Optional[int] = None, categories: str
|
|||||||
count = count if count is not None else _get_result_count()
|
count = count if count is not None else _get_result_count()
|
||||||
instance = _get_search_instance()
|
instance = _get_search_instance()
|
||||||
api_key = ""
|
api_key = ""
|
||||||
headers = {"User-Agent": "Mozilla/5.0"}
|
headers = {"User-Agent": WEB_FETCH_USER_AGENT}
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
# News/fresh queries do badly in the 'general' category — it favours
|
# News/fresh queries do badly in the 'general' category — it favours
|
||||||
@@ -252,7 +250,7 @@ def searxng_search(query, max_results=10):
|
|||||||
"""Search using SearXNG instance - parsing HTML."""
|
"""Search using SearXNG instance - parsing HTML."""
|
||||||
instance = _get_search_instance()
|
instance = _get_search_instance()
|
||||||
api_key = ""
|
api_key = ""
|
||||||
req_headers = {"User-Agent": "Mozilla/5.0"}
|
req_headers = {"User-Agent": WEB_FETCH_USER_AGENT}
|
||||||
if api_key:
|
if api_key:
|
||||||
req_headers["Authorization"] = f"Bearer {api_key}"
|
req_headers["Authorization"] = f"Bearer {api_key}"
|
||||||
try:
|
try:
|
||||||
@@ -391,7 +389,7 @@ def duckduckgo_search(query: str, count: Optional[int] = None, time_filter: Opti
|
|||||||
response = httpx.get(
|
response = httpx.get(
|
||||||
"https://html.duckduckgo.com/html/",
|
"https://html.duckduckgo.com/html/",
|
||||||
params={"q": query, "kp": _safesearch_for("duckduckgo_html")},
|
params={"q": query, "kp": _safesearch_for("duckduckgo_html")},
|
||||||
headers={"User-Agent": "Mozilla/5.0"},
|
headers={"User-Agent": WEB_FETCH_USER_AGENT},
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ sys.path.insert(0, BASE_DIR)
|
|||||||
from src.constants import (
|
from src.constants import (
|
||||||
DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR,
|
DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR,
|
||||||
TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR,
|
TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR,
|
||||||
RAG_DIR, MEMORY_VECTORS_DIR,
|
RAG_DIR, MEMORY_VECTORS_DIR, PASSWORD_MIN_LENGTH,
|
||||||
)
|
)
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
|
|
||||||
DIRS = [
|
DIRS = [
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
@@ -59,15 +60,23 @@ def _prompt_admin_credentials():
|
|||||||
print(" (Press Enter to accept defaults)")
|
print(" (Press Enter to accept defaults)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
username = input(" Username [admin]: ").strip().lower()
|
while True:
|
||||||
if not username:
|
username = input(" Username [admin]: ").strip().lower()
|
||||||
username = "admin"
|
if not username:
|
||||||
|
username = "admin"
|
||||||
|
if username in RESERVED_USERNAMES:
|
||||||
|
print(f" '{username}' is a reserved username. Choose another.")
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
password = getpass.getpass(" Password: ")
|
password = getpass.getpass(" Password: ")
|
||||||
if not password:
|
if not password:
|
||||||
print(" Password cannot be empty.")
|
print(" Password cannot be empty.")
|
||||||
continue
|
continue
|
||||||
|
if len(password) < PASSWORD_MIN_LENGTH:
|
||||||
|
print(f" Password must be at least {PASSWORD_MIN_LENGTH} characters.")
|
||||||
|
continue
|
||||||
confirm = getpass.getpass(" Confirm password: ")
|
confirm = getpass.getpass(" Confirm password: ")
|
||||||
if password != confirm:
|
if password != confirm:
|
||||||
print(" Passwords don't match. Try again.")
|
print(" Passwords don't match. Try again.")
|
||||||
@@ -93,8 +102,13 @@ def create_default_admin():
|
|||||||
password = os.getenv("ODYSSEUS_ADMIN_PASSWORD", "").strip()
|
password = os.getenv("ODYSSEUS_ADMIN_PASSWORD", "").strip()
|
||||||
|
|
||||||
if username and password:
|
if username and password:
|
||||||
# Both provided via env — use them directly
|
# Both provided via env — validate before using
|
||||||
pass
|
if username in RESERVED_USERNAMES:
|
||||||
|
print(f" [error] ODYSSEUS_ADMIN_USER '{username}' is a reserved username")
|
||||||
|
return "failed"
|
||||||
|
if len(password) < PASSWORD_MIN_LENGTH:
|
||||||
|
print(f" [error] ODYSSEUS_ADMIN_PASSWORD must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
|
return "failed"
|
||||||
elif sys.stdin.isatty() and not os.getenv("ODYSSEUS_SKIP_ADMIN_PROMPT"):
|
elif sys.stdin.isatty() and not os.getenv("ODYSSEUS_SKIP_ADMIN_PROMPT"):
|
||||||
# Interactive terminal — ask the user
|
# Interactive terminal — ask the user
|
||||||
username, password = _prompt_admin_credentials()
|
username, password = _prompt_admin_credentials()
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
# Architecture Runtime Inventory
|
||||||
|
|
||||||
|
> **Purpose**: Phase 0 planning baseline for codebase readability improvements (#4071).
|
||||||
|
> **Parent issue**: [#4082](https://github.com/pewdiepie-archdaemon/odysseus/issues/4082)
|
||||||
|
> **Last updated**: dev@b58af42 | 2026-06-16
|
||||||
|
> **Status**: Draft — to be reviewed before follow-up slices open.
|
||||||
|
> **Snapshot basis**: Importer / file / import-line counts are refreshed to `dev@b58af42` (2026-06-16) and are recomputable via the commands in §3.4. **Line counts** in §2.1 / §2.2 are a snapshot from an earlier baseline and drift as `dev` moves — recompute any of them with `wc -l <file>`. This inventory tracks structure and risk, not live metrics.
|
||||||
|
|
||||||
|
This document maps the current runtime module structure, identifies high-risk boundaries, and recommends safe first refactor slices. It does **not** move files, change imports, or alter runtime behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current Structure Overview
|
||||||
|
|
||||||
|
### 1.1 Top-Level Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
odysseus/
|
||||||
|
├── app.py # FastAPI app entrypoint (1,145 lines)
|
||||||
|
├── conf/ # Configuration (config.py, settings.py, settings_scrub.py)
|
||||||
|
├── src/ # 95 flat .py files + 2 subdirectories
|
||||||
|
│ ├── agent_tools/ # Tool helpers: document, filesystem, subprocess, web
|
||||||
|
│ └── search/ # Search subsystem
|
||||||
|
├── routes/ # 54 flat .py files — HTTP route handlers
|
||||||
|
├── core/ # 10 files — database models, auth, middleware, session
|
||||||
|
├── mcp_servers/ # 5 files — MCP server implementations
|
||||||
|
├── scripts/ # CLI tools and one-shot scripts
|
||||||
|
├── static/ # Frontend HTML/CSS/JS
|
||||||
|
├── tests/ # 583 test files (~54,800 lines)
|
||||||
|
└── services/ # (exists as needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Directory Flatness Metric
|
||||||
|
|
||||||
|
| Directory | Flat `.py` Files | Subdirectories | Concern |
|
||||||
|
|-----------|-----------------|----------------|---------|
|
||||||
|
| `src/` | **95** | 2 (`agent_tools/`, `search/`) | No domain grouping; 95 files in one directory |
|
||||||
|
| `routes/` | **54** | 0 | All route handlers in one flat directory |
|
||||||
|
| `core/` | 10 | 0 | Manageable, but `database.py` is oversized |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Largest Runtime Modules
|
||||||
|
|
||||||
|
### 2.1 Python Backend
|
||||||
|
|
||||||
|
| Rank | File | Lines | Classes | Functions | Risk |
|
||||||
|
|------|------|-------|---------|-----------|------|
|
||||||
|
| 1 | `src/tool_implementations.py` | **4,032** | 0 | ~48 | **HIGH** |
|
||||||
|
| 2 | `routes/email_routes.py` | **3,245** | — | — | **MEDIUM** |
|
||||||
|
| 3 | `routes/cookbook_routes.py` | **2,969** | — | — | **MEDIUM** |
|
||||||
|
| 4 | `src/agent_loop.py` | **2,961** | 0 | ~24 | **HIGH** |
|
||||||
|
| 5 | `src/task_scheduler.py` | **2,330** | — | 5 | MEDIUM |
|
||||||
|
| 6 | `routes/model_routes.py` | **2,266** | — | — | MEDIUM |
|
||||||
|
| 7 | `core/database.py` | **2,265** | 28 | ~59 helpers | **HIGH** |
|
||||||
|
| 8 | `src/builtin_actions.py` | **2,262** | 2 | ~24 | MEDIUM |
|
||||||
|
| 9 | `src/llm_core.py` | **2,164** | — | — | MEDIUM |
|
||||||
|
| 10 | `mcp_servers/email_server.py` | 2,197 | — | — | LOW (separate process) |
|
||||||
|
| 11 | `src/visual_report.py` | 1,918 | — | — | LOW |
|
||||||
|
| 12 | `routes/gallery_routes.py` | 1,896 | — | — | LOW |
|
||||||
|
| 13 | `src/ai_interaction.py` | 1,846 | — | — | MEDIUM |
|
||||||
|
| 14 | `routes/document_routes.py` | 1,717 | — | — | LOW |
|
||||||
|
| 15 | `routes/skills_routes.py` | 1,648 | — | — | LOW |
|
||||||
|
|
||||||
|
**Heuristic**: Files > 2,000 lines with 20+ public symbols and many importers are the highest-risk splits. Files 1,000–2,000 lines are medium-risk if tightly coupled.
|
||||||
|
|
||||||
|
### 2.2 Frontend
|
||||||
|
|
||||||
|
| File | Lines | Concern |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `static/style.css` | **36,653** | Entire app CSS in one file (tracked separately in #2617) |
|
||||||
|
| `static/js/document.js` | **9,776** | Single JS file for document functionality |
|
||||||
|
| `static/js/slashCommands.js` | 6,498 | |
|
||||||
|
| `static/js/settings.js` | 5,266 | |
|
||||||
|
| `static/js/emailLibrary.js` | 5,217 | |
|
||||||
|
| `static/js/notes.js` | 5,124 | |
|
||||||
|
| `static/js/chat.js` | 4,985 | |
|
||||||
|
| `static/app.js` | 4,090 | |
|
||||||
|
|
||||||
|
**Note**: Frontend modularization is tracked separately in #2617 (CSS) and is not the focus of this Phase 0 inventory. Frontend is listed here for completeness but follow-up slices should target Python backend boundaries first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Import Dependency Graph
|
||||||
|
|
||||||
|
### 3.1 Who Depends on `core/database.py`
|
||||||
|
|
||||||
|
**102 files** import from `core.database` — this is the most depended-upon module:
|
||||||
|
|
||||||
|
- All route handlers (`routes/*.py`)
|
||||||
|
- Most `src/*.py` files
|
||||||
|
- `core/session_manager.py`, `core/auth.py`
|
||||||
|
- Multiple test files
|
||||||
|
|
||||||
|
**Implication**: Any split of `core/database.py` is the highest-risk refactor. It should be tackled **last**, never first.
|
||||||
|
|
||||||
|
### 3.2 Who Depends on `src/tool_implementations.py`
|
||||||
|
|
||||||
|
**17 files** import from `src.tool_implementations`:
|
||||||
|
- `src/agent_loop.py`, `src/builtin_actions.py`, `src/tool_index.py`
|
||||||
|
- `src/task_scheduler.py`, `src/tool_policy.py`
|
||||||
|
- Various tests
|
||||||
|
|
||||||
|
### 3.3 Who Depends on `src/agent_loop.py`
|
||||||
|
|
||||||
|
**22 files** import from `src.agent_loop`:
|
||||||
|
|
||||||
|
- `src/tool_policy.py`, `src/teacher_escalation.py`, `src/bg_monitor.py`
|
||||||
|
- `src/task_scheduler.py`
|
||||||
|
- Multiple test files
|
||||||
|
|
||||||
|
### 3.4 Cross-Layer Import Violations
|
||||||
|
|
||||||
|
**`src/` importing from `routes/`** (backwards dependency — domain logic depending on HTTP layer):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/tool_implementations.py ──→ routes/calendar_routes.py
|
||||||
|
src/tool_implementations.py ──→ routes/cookbook_helpers.py
|
||||||
|
src/tool_implementations.py ──→ routes/email_helpers.py
|
||||||
|
src/tool_implementations.py ──→ routes/email_pollers.py
|
||||||
|
src/tool_implementations.py ──→ routes/email_routes.py
|
||||||
|
src/tool_implementations.py ──→ routes/model_routes.py
|
||||||
|
src/tool_implementations.py ──→ routes/note_routes.py
|
||||||
|
src/tool_implementations.py ──→ routes/prefs_routes.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> These are **runtime imports** (inside function bodies, not at module top), which mitigates circular import risk but indicates fuzzy layer boundaries. Function-level inline imports from the HTTP layer into business logic are a code smell.
|
||||||
|
|
||||||
|
**Import counts (top-level)**:
|
||||||
|
| Direction | Count | Notes |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| `routes/` → `src/` | **374** | Expected: HTTP handlers call domain logic |
|
||||||
|
| `routes/` → `core/` | **126** | Expected: handlers access DB models |
|
||||||
|
| `src/` → `routes/` | **31** | **Unexpected**: domain logic reaching into HTTP layer (direct grep of import lines referencing `routes/`) |
|
||||||
|
| `src/` → `core/` | **106** | Acceptable but could be reduced with a data-access layer |
|
||||||
|
|
||||||
|
> **How the metrics in this document are computed** — recompute against current `dev` before treating any count as authoritative (the tree drifts; these numbers are a snapshot, not a live value):
|
||||||
|
> - `src/` flat `.py` files: `find src -maxdepth 1 -name '*.py' | wc -l`
|
||||||
|
> - `tests/` test files: `find tests -name 'test_*.py' | wc -l`
|
||||||
|
> - `core.database` importers: `grep -rlE '(from|import) +core\.database' --include='*.py' . | grep -v core/database.py | wc -l`
|
||||||
|
> - `src.agent_loop` importers: `grep -rlE '(from|import) +src\.agent_loop' --include='*.py' . | grep -v src/agent_loop.py | wc -l`
|
||||||
|
> - Cross-layer import lines: `grep -rhE '(from|import) +<pkg>' --include='*.py' <dir>/ | wc -l` (e.g. `(from|import) +routes` over `src/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Route Ownership Map
|
||||||
|
|
||||||
|
Routes can be grouped into logical feature domains. Current flat structure obscures these boundaries:
|
||||||
|
|
||||||
|
| Domain | Route Files | Total Lines | Review Complexity |
|
||||||
|
|--------|-------------|-------------|-------------------|
|
||||||
|
| **Email** | `email_routes.py`, `email_helpers.py`, `email_pollers.py` | 5,936 | HIGH — most complex domain |
|
||||||
|
| **Chat / Agent** | `chat_routes.py`, `chat_helpers.py`, `shell_routes.py`, `codex_routes.py`, `skills_routes.py` | 6,365 | HIGH — core interaction surface |
|
||||||
|
| **Cookbook** | `cookbook_routes.py`, `cookbook_helpers.py`, `cookbook_output.py` | 4,110 | MEDIUM |
|
||||||
|
| **Model / LLM** | `model_routes.py`, `assistant_routes.py`, `copilot_routes.py` | 2,764 | MEDIUM |
|
||||||
|
| **Calendar / Contacts** | `calendar_routes.py`, `contacts_routes.py` | 2,336 | MEDIUM |
|
||||||
|
| **Documents** | `document_routes.py`, `document_helpers.py` | 1,954 | LOW |
|
||||||
|
| **Auth** | `auth_routes.py`, `api_token_routes.py`, `device_flow.py` | 1,171 | LOW |
|
||||||
|
| **Tasks** | `task_routes.py` (standalone) | 1,157 | LOW |
|
||||||
|
| **Session** | `session_routes.py` (standalone) | 1,287 | LOW |
|
||||||
|
| **Gallery** | `gallery_routes.py`, `gallery_helpers.py` | 1,896 | LOW |
|
||||||
|
| **Memory** | `memory_routes.py` | — | LOW |
|
||||||
|
| **Research** | `research_routes.py` | — | LOW |
|
||||||
|
| **MCP** | `mcp_routes.py` | — | LOW |
|
||||||
|
| **Notes** | `note_routes.py` | — | LOW |
|
||||||
|
| **Other** | `prefs_routes.py`, `upload_routes.py`, `vault_routes.py`, `webhook_routes.py`, `workspace_routes.py`, `search_routes.py`, `history_routes.py`, `hwfit_routes.py`, `preset_routes.py`, `signature_routes.py`, `backup_routes.py`, `cleanup_routes.py`, `diagnostics_routes.py`, `embedding_routes.py`, `emoji_routes.py`, `font_routes.py`, `stt_routes.py`, `tts_routes.py`, `compare_routes.py`, `personal_routes.py`, `editor_draft_routes.py`, `admin_wipe_routes.py`, `chatgpt_subscription_routes.py` | 2,000+ | LOW individual, HIGH cumulative |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tool Registry & Implementation Boundaries
|
||||||
|
|
||||||
|
### 5.1 Current Tool Architecture
|
||||||
|
|
||||||
|
| Component | File | Lines | Role |
|
||||||
|
|-----------|------|-------|------|
|
||||||
|
| Tool schemas | `src/tool_schemas.py` | 1,392 | JSON Schema tool definitions (Duck-TypedDict) |
|
||||||
|
| Tool index | `src/tool_index.py` | 542 | RAG-based tool retrieval from ChromaDB |
|
||||||
|
| Tool implementations | `src/tool_implementations.py` | 4,032 | 33 `do_*` functions — all tool execution logic |
|
||||||
|
| Tool security | `src/tool_security.py` | — | Owner-scoped tool blocking |
|
||||||
|
| Tool policy | `src/tool_policy.py` | — | Guide-only directive, plan-mode disabled tools |
|
||||||
|
| Tool utils | `src/tool_utils.py` | — | Shared tool helpers |
|
||||||
|
|
||||||
|
### 5.2 Tool Implementation Categories
|
||||||
|
|
||||||
|
The 33 `do_*` functions in `tool_implementations.py` fall into natural domain groups — the basis for slice 1's split in §6.2:
|
||||||
|
|
||||||
|
| Category | `do_*` functions | Count |
|
||||||
|
|----------|------------------|-------|
|
||||||
|
| **System / config** | `do_manage_skills`, `do_manage_tasks`, `do_manage_endpoints`, `do_manage_mcp`, `do_manage_webhooks`, `do_manage_tokens`, `do_manage_settings`, `do_api_call`, `do_app_api` | 9 |
|
||||||
|
| **Cookbook / model serving** | `do_download_model`, `do_serve_model`, `do_list_served_models`, `do_stop_served_model`, `do_tail_serve_output`, `do_list_downloads`, `do_cancel_download`, `do_search_hf_models`, `do_adopt_served_model`, `do_list_cookbook_servers`, `do_list_serve_presets`, `do_serve_preset`, `do_list_cached_models` | 13 |
|
||||||
|
| **Notes** | `do_manage_notes` | 1 |
|
||||||
|
| **Calendar** | `do_manage_calendar` | 1 |
|
||||||
|
| **Search** | `do_search_chats` | 1 |
|
||||||
|
| **Research** | `do_manage_research`, `do_trigger_research` | 2 |
|
||||||
|
| **Contacts** | `do_resolve_contact`, `do_manage_contact` | 2 |
|
||||||
|
| **Vault** | `do_vault_search`, `do_vault_get`, `do_vault_unlock` | 3 |
|
||||||
|
| **Image** | `do_edit_image` | 1 |
|
||||||
|
| | **Total** | **33** |
|
||||||
|
|
||||||
|
> Low-level tools (filesystem, subprocess, web fetch, document parsing) live in `src/agent_tools/`, **not** in `tool_implementations.py` — out of scope for this split.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risk Assessment & Candidate Slice Ranking
|
||||||
|
|
||||||
|
> **Candidate proposals, not a committed plan.** The rankings, package shapes (e.g. `src/pkg/`, `src/domain/`, `src/infra/`, `src/api/`), split ordering, and route-grouping strategy below are **options for maintainer discussion**. Per #4082/#4071, slice ownership and order are settled by maintainers before any follow-up PR. §1–§3 above are the factual current-state inventory.
|
||||||
|
|
||||||
|
### 6.1 Risk Scale
|
||||||
|
|
||||||
|
| Level | Criteria |
|
||||||
|
|-------|----------|
|
||||||
|
| **LOW** | File has ≤3 importers AND ≤500 lines, OR is a pure refactor with clear boundaries |
|
||||||
|
| **MEDIUM** | File has 4–15 importers OR 500–1,500 lines |
|
||||||
|
| **HIGH** | File has 16+ importers OR >2,000 lines, OR has cross-layer import violations |
|
||||||
|
|
||||||
|
### 6.2 Ranked Split Candidates
|
||||||
|
|
||||||
|
| Priority | Target | Risk | Rationale |
|
||||||
|
|----------|--------|------|-----------|
|
||||||
|
| **1** | `src/tool_implementations.py` → `src/tools/*.py` | **MEDIUM** | 4,032 lines → ~10 files by tool category. Already has natural boundaries. 17 importers, tracked in #3629. Use `__init__.py` shim to keep existing imports working. |
|
||||||
|
| **2** | `routes/` → domain subdirectories (one domain per PR) | **MEDIUM** | 54 flat files. Done **one domain at a time** (e.g. a standalone PR for the email domain, then chat, …), not a broad reorganization — route modules carry helper imports, registration assumptions, and test import paths. |
|
||||||
|
| **3** | `src/agent_loop.py` → `src/agent/loop.py` + submodules | **MEDIUM-HIGH** | 2,961 lines, 24 functions. Can extract prompt building, classification, verification, and runaway detection. Tracked in #3266. |
|
||||||
|
| **4** | `src/` → `src/pkg/`, `src/domain/`, `src/infra/`, `src/api/` | **MEDIUM** | Structural reorganization. Split flat `src/` into layered packages. Must come after routes and tools are stable. |
|
||||||
|
| **5** | `routes/email_*.py` consolidation | **LOW** | Already grouped by filename prefix. Low-risk cleanup within the email domain. |
|
||||||
|
| **6** | `core/database.py` → `src/infra/database/models/*.py` | **HIGH** | 28 classes, 102 importers. Highest-risk split. Must be **last** in any sequence. Requires careful import shim strategy. |
|
||||||
|
| **7** | Frontend CSS modularization | **MEDIUM** | 36,653 lines. Tracked in #2617. Separate timeline from backend work. |
|
||||||
|
| **8** | Frontend JS modularization | **MEDIUM** | 9,776 lines in `document.js`. Introduce ES modules at minimum. |
|
||||||
|
|
||||||
|
### 6.3 Candidate First 3 Behavior-Preserving Slices
|
||||||
|
|
||||||
|
**Slice 1: Split `tool_implementations.py`** (Lowest-risk high-impact)
|
||||||
|
|
||||||
|
- Create `src/tools/` package with one file per tool category
|
||||||
|
- Add `src/tools/__init__.py` re-exporting all symbols with current names
|
||||||
|
- Update 17 importers to use new paths (can be deferred via shim)
|
||||||
|
- Validation: `python -m pytest tests/ -x -q` + manual smoke test of tool execution
|
||||||
|
- Reference: #3629
|
||||||
|
|
||||||
|
**Slice 2: Group `routes/` by domain** (one domain per PR, not a broad sweep)
|
||||||
|
|
||||||
|
Route modules carry helper imports, router registration assumptions, and test import paths, so this must be done **one domain at a time** rather than as a single reorganization PR. Example sequence (each its own PR):
|
||||||
|
|
||||||
|
- PR 2a: move the **email** domain (`email_routes.py`, `email_helpers.py`, `email_pollers.py`) → `routes/email/` + shim
|
||||||
|
- PR 2b: move the **chat/agent** domain → `routes/chat/` + shim
|
||||||
|
- PR 2c: move the **cookbook** domain → `routes/cookbook/` + shim
|
||||||
|
- …and so on per domain from §4
|
||||||
|
|
||||||
|
Each PR: add `__init__.py` re-exporting old names, update `app.py` router imports, validation `python app.py` starts clean. **No behavior change** — pure file reorganization.
|
||||||
|
|
||||||
|
**Slice 3: Extract `agent_loop.py` submodules** (Improve reviewability)
|
||||||
|
|
||||||
|
- Move prompt assembly → `src/agent/prompt.py`
|
||||||
|
- Move request classification → `src/agent/classifier.py`
|
||||||
|
- Move sub-agent verification → `src/agent/verifier.py`
|
||||||
|
- Move runaway detection → `src/agent/runaway.py`
|
||||||
|
- Move context management → `src/agent/context.py`
|
||||||
|
- Keep `src/agent/loop.py` as the main orchestration module
|
||||||
|
- Validation: `python -m pytest tests/test_agent_loop.py tests/test_loop_breaker_runaway.py -v`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Safety Guardrails for Follow-Up Work
|
||||||
|
|
||||||
|
Per maintainer guidance in #4082 and #4071:
|
||||||
|
|
||||||
|
- [ ] **One domain/slice per PR** — never mix multiple reorganizations
|
||||||
|
- [ ] **No behavior changes** mixed with file moves — pure reorganization only
|
||||||
|
- [ ] **Keep compatibility shims** — `__init__.py` re-exports for all existing import paths
|
||||||
|
- [ ] **Add or identify focused tests** before risky splits
|
||||||
|
- [ ] **Do not start with `core/database.py`** or broad route movement unless this inventory shows a safe boundary
|
||||||
|
- [ ] **Prefer small, reviewable slices** over large restructures
|
||||||
|
- [ ] **No packaging/runtime/tooling migration** mixed into file moves
|
||||||
|
- [ ] **No frontend framework migration** inside this stabilization lane
|
||||||
|
- [ ] **Validate with `python -m compileall`** — every PR must pass CI checks
|
||||||
|
- [ ] **Validate with `pytest`** — run the full test suite before opening each PR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Validation Commands
|
||||||
|
|
||||||
|
Each follow-up PR should be verifiable with these commands before submission:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Syntax check — must pass with zero errors
|
||||||
|
python -m compileall src/ routes/ core/ conf/
|
||||||
|
|
||||||
|
# Full test suite — must match baseline pass rate
|
||||||
|
python -m pytest tests/ -x -q
|
||||||
|
|
||||||
|
# Import shim verification — existing import paths must still work
|
||||||
|
python -c "from src.tool_implementations import do_search_chats; print('OK')"
|
||||||
|
|
||||||
|
# App startup smoke test (if backend touched)
|
||||||
|
timeout 5 python app.py 2>&1 | head -5 || true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions
|
||||||
|
|
||||||
|
1. Is `#2538` (specs ground truth) the canonical behavior map baseline, and should this inventory be kept in sync with those specs once merged?
|
||||||
|
2. Should route grouping follow the domain map proposed here, or is there a different taxonomy preferred by maintainers?
|
||||||
|
3. For the `tool_implementations.py` split (#3629), is the tool categorization in §5.2 acceptable, or should it follow a different grouping?
|
||||||
|
4. Should compatibility shims (`__init__.py`) be temporary (removed in a follow-up wave) or permanent?
|
||||||
|
5. Should an ADR (Architecture Decision Record) document be started to track decisions made during this process?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Future Direction (NOT current state)
|
||||||
|
|
||||||
|
The following are **future refactor targets** (candidate directions **pending maintainer agreement**, not committed), recorded here so this inventory does not imply they exist today. None of them are present in the current `dev` tree:
|
||||||
|
|
||||||
|
- `main.py` — proposed rename of the `app.py` entrypoint. Today the app boots via `app.py`.
|
||||||
|
- `src/agent/` — proposed package to hold `agent_loop.py` submodules (prompt/classifier/verifier/runaway/context). Today `agent_loop.py` is a single flat file in `src/`.
|
||||||
|
- `src/infra/`, `src/domain/`, `src/pkg/`, `src/api/` — proposed layered reorganization of the flat `src/` directory (slice 4 in §6).
|
||||||
|
|
||||||
|
These become real only when the corresponding slices land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: File Listing
|
||||||
|
|
||||||
|
### `src/` (95 files — 61 shown; run `ls src/*.py` for the full list)
|
||||||
|
|
||||||
|
```
|
||||||
|
agent_loop.py tool_implementations.py tool_schemas.py
|
||||||
|
tool_index.py tool_security.py tool_policy.py
|
||||||
|
tool_utils.py builtin_actions.py task_scheduler.py
|
||||||
|
llm_core.py model_context.py model_discovery.py
|
||||||
|
session_search.py context_budget.py context_compactor.py
|
||||||
|
ai_interaction.py action_intents.py agent_runs.py
|
||||||
|
app_helpers.py app_initializer.py config.py
|
||||||
|
database.py memory.py memory_provider.py
|
||||||
|
secret_storage.py prompt_security.py url_security.py
|
||||||
|
url_safety.py rate_limiter.py cleanup_service.py
|
||||||
|
readiness.py service_health.py exceptions.py
|
||||||
|
request_models.py assistant_log.py bg_monitor.py
|
||||||
|
builtin_mcp.py chat_helpers.py chroma_client.py
|
||||||
|
document_processor.py embedding_lanes.py deep_research.py
|
||||||
|
research_handler.py research_utils.py personal_docs.py
|
||||||
|
rag_manager.py rag_singleton.py topic_analyzer.py
|
||||||
|
visual_report.py youtube_handler.py pdf_forms.py
|
||||||
|
pdf_form_doc.py pdf_runtime.py caldav_writeback.py
|
||||||
|
email_thread_parser.py text_helpers.py user_time.py
|
||||||
|
teacher_escalation.py cookbook_serve_lifecycle.py
|
||||||
|
chatgpt_subscription.py mcp_manager.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### `routes/` (54 files)
|
||||||
|
|
||||||
|
```
|
||||||
|
__init__.py _validators.py
|
||||||
|
auth_routes.py api_token_routes.py device_flow.py
|
||||||
|
chat_routes.py chat_helpers.py shell_routes.py
|
||||||
|
codex_routes.py skills_routes.py
|
||||||
|
email_routes.py email_helpers.py email_pollers.py
|
||||||
|
cookbook_routes.py cookbook_helpers.py cookbook_output.py
|
||||||
|
model_routes.py assistant_routes.py copilot_routes.py
|
||||||
|
calendar_routes.py contacts_routes.py
|
||||||
|
document_routes.py document_helpers.py
|
||||||
|
gallery_routes.py gallery_helpers.py
|
||||||
|
task_routes.py session_routes.py
|
||||||
|
note_routes.py memory_routes.py research_routes.py
|
||||||
|
mcp_routes.py search_routes.py history_routes.py
|
||||||
|
webhook_routes.py workspace_routes.py upload_routes.py
|
||||||
|
vault_routes.py prefs_routes.py preset_routes.py
|
||||||
|
signature_routes.py personal_routes.py hwfit_routes.py
|
||||||
|
backup_routes.py cleanup_routes.py diagnostics_routes.py
|
||||||
|
embedding_routes.py emoji_routes.py font_routes.py
|
||||||
|
stt_routes.py tts_routes.py compare_routes.py
|
||||||
|
editor_draft_routes.py chatgpt_subscription_routes.py admin_wipe_routes.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### `core/` (10 files)
|
||||||
|
|
||||||
|
```
|
||||||
|
__init__.py constants.py database.py models.py
|
||||||
|
auth.py middleware.py session_manager.py exceptions.py
|
||||||
|
atomic_io.py platform_compat.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Key Import Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
core/database.py ←── 102 importers (routes/*, src/*, core/*, tests/*)
|
||||||
|
↑
|
||||||
|
├── routes/auth_routes.py
|
||||||
|
├── routes/email_routes.py
|
||||||
|
├── src/builtin_actions.py
|
||||||
|
├── src/task_scheduler.py
|
||||||
|
├── src/tool_implementations.py (inline)
|
||||||
|
└── ...97 more
|
||||||
|
|
||||||
|
src/tool_implementations.py ←── 17 importers
|
||||||
|
↑
|
||||||
|
├── src/agent_loop.py
|
||||||
|
├── src/builtin_actions.py
|
||||||
|
├── src/tool_index.py
|
||||||
|
├── src/task_scheduler.py
|
||||||
|
├── src/tool_policy.py
|
||||||
|
└── ...12 more (mostly tests)
|
||||||
|
|
||||||
|
src/agent_loop.py ←── 22 importers
|
||||||
|
↑
|
||||||
|
├── src/tool_policy.py
|
||||||
|
├── src/teacher_escalation.py
|
||||||
|
├── src/bg_monitor.py
|
||||||
|
├── src/task_scheduler.py
|
||||||
|
└── 18 more (incl. tests)
|
||||||
|
```
|
||||||
+17
-8
@@ -524,7 +524,7 @@ def get_builtin_overrides() -> dict:
|
|||||||
ov = get_setting("builtin_tool_overrides", {})
|
ov = get_setting("builtin_tool_overrides", {})
|
||||||
return ov if isinstance(ov, dict) else {}
|
return ov if isinstance(ov, dict) else {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('Failed to load builtin tool overrides: %s', e)
|
logger.warning("Failed to load builtin tool overrides, using defaults", exc_info=e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -843,8 +843,11 @@ def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_c
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
content = " ".join(b.get("text", "") for b in content if isinstance(b, dict))
|
content = " ".join(b.get("text", "") for b in content if isinstance(b, dict))
|
||||||
content = (content or "").strip()
|
content = (content or "").strip()
|
||||||
# Skip injected tool-result envelopes — role=user but not human intent.
|
# Skip injected envelopes — role=user but not human intent. Tool results
|
||||||
if not content or content.startswith("[Tool execution results]"):
|
# are now wrapped via untrusted_context_message (metadata.trusted=False);
|
||||||
|
# keep the legacy "[Tool execution results]" prefix for older histories.
|
||||||
|
meta = msg.get("metadata") or {}
|
||||||
|
if not content or meta.get("trusted") is False or content.startswith("[Tool execution results]"):
|
||||||
continue
|
continue
|
||||||
collected.append(content)
|
collected.append(content)
|
||||||
if len(collected) >= max_user:
|
if len(collected) >= max_user:
|
||||||
@@ -929,8 +932,8 @@ def _build_system_prompt(
|
|||||||
try:
|
try:
|
||||||
from src.user_time import current_datetime_context_message
|
from src.user_time import current_datetime_context_message
|
||||||
_datetime_message = current_datetime_context_message()
|
_datetime_message = current_datetime_context_message()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed to build datetime context message", exc_info=e)
|
||||||
|
|
||||||
# Document context is kept as a SEPARATE message (not merged into the tool
|
# Document context is kept as a SEPARATE message (not merged into the tool
|
||||||
# prompt) so the context trimmer doesn't destroy it when truncating the
|
# prompt) so the context trimmer doesn't destroy it when truncating the
|
||||||
@@ -973,8 +976,8 @@ def _build_system_prompt(
|
|||||||
try:
|
try:
|
||||||
from src.pdf_form_doc import find_source_upload_id
|
from src.pdf_form_doc import find_source_upload_id
|
||||||
_is_form_backed = bool(find_source_upload_id(active_document.current_content or ""))
|
_is_form_backed = bool(find_source_upload_id(active_document.current_content or ""))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed to detect if document is form-backed, assuming plain", exc_info=e)
|
||||||
|
|
||||||
if _is_form_backed:
|
if _is_form_backed:
|
||||||
doc_ctx = (
|
doc_ctx = (
|
||||||
@@ -1562,8 +1565,14 @@ def _append_tool_results(
|
|||||||
if round_reasoning:
|
if round_reasoning:
|
||||||
msg["reasoning_content"] = round_reasoning
|
msg["reasoning_content"] = round_reasoning
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
# Tool output (shell/python stdout, file reads, fetched pages, email
|
||||||
|
# bodies, MCP results) is sourced from outside the server. Wrap it as
|
||||||
|
# untrusted data so prompt-injection inside a tool result is treated as
|
||||||
|
# data, not instructions — same hardening as skills (#788) and the
|
||||||
|
# web/RAG context. THREAT_MODEL.md lists tool output as a surface that
|
||||||
|
# must go through untrusted_context_message.
|
||||||
messages.append(
|
messages.append(
|
||||||
{"role": "user", "content": f"[Tool execution results]\n\n{tool_output_text}"}
|
untrusted_context_message("tool execution results", tool_output_text)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .subprocess_tools import BashTool, PythonTool
|
|||||||
from .web_tools import WebSearchTool, WebFetchTool
|
from .web_tools import WebSearchTool, WebFetchTool
|
||||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||||
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
|
|
||||||
TOOL_HANDLERS = {
|
TOOL_HANDLERS = {
|
||||||
"bash": BashTool().execute,
|
"bash": BashTool().execute,
|
||||||
@@ -40,6 +41,9 @@ TOOL_HANDLERS = {
|
|||||||
"suggest_document": SuggestDocumentTool().execute,
|
"suggest_document": SuggestDocumentTool().execute,
|
||||||
"manage_documents": ManageDocumentTool().execute,
|
"manage_documents": ManageDocumentTool().execute,
|
||||||
"get_workspace": GetWorkspaceTool().execute,
|
"get_workspace": GetWorkspaceTool().execute,
|
||||||
|
"chat_with_model": ChatWithModelTool().execute,
|
||||||
|
"ask_teacher": AskTeacherTool().execute,
|
||||||
|
"list_models": ListModelsTool().execute,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""model_interaction_tools.py - agent tools for talking to other models.
|
||||||
|
|
||||||
|
Owns the model-interaction tool implementations (chat_with_model, ask_teacher,
|
||||||
|
list_models) and their handler classes, registered in ``TOOL_HANDLERS``. Part
|
||||||
|
of the tool -> registry migration (#3629): the implementations were moved here
|
||||||
|
out of ``src.ai_interaction`` so dispatch flows through the registry instead of
|
||||||
|
the elif chain / dispatch_ai_tool in tool_execution.py.
|
||||||
|
|
||||||
|
Shared helpers that still live in ``src.ai_interaction`` and are used by tools
|
||||||
|
not yet migrated (``_resolve_model``, ``AI_CHAT_TIMEOUT``) are imported lazily
|
||||||
|
inside the functions to avoid an import cycle at module load.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_TEACHER_SYSTEM_PROMPT = (
|
||||||
|
"You are a senior AI mentor. A less capable model is stuck on a problem and asking for help. "
|
||||||
|
"Provide clear, actionable guidance:\n"
|
||||||
|
"1. Brief analysis of the problem\n"
|
||||||
|
"2. Recommended approach (step by step)\n"
|
||||||
|
"3. Key things to watch out for\n\n"
|
||||||
|
"Be concise and practical. No preamble."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_with_model(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Send a message to a specific model and return its response.
|
||||||
|
|
||||||
|
Content format:
|
||||||
|
Line 1: model_name (or model_name@endpoint_name)
|
||||||
|
Line 2+: the message to send
|
||||||
|
"""
|
||||||
|
from src.ai_interaction import _resolve_model, AI_CHAT_TIMEOUT
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
|
||||||
|
lines = content.strip().split("\n", 1)
|
||||||
|
if not lines or not lines[0].strip():
|
||||||
|
return {"error": "First line must be the model name"}
|
||||||
|
|
||||||
|
model_spec = lines[0].strip()
|
||||||
|
message = lines[1].strip() if len(lines) > 1 else ""
|
||||||
|
if not message:
|
||||||
|
return {"error": "No message provided (line 2+ is the message)"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url, model, headers = _resolve_model(model_spec, owner=owner)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await llm_call_async(
|
||||||
|
url, model,
|
||||||
|
[{"role": "user", "content": message}],
|
||||||
|
headers=headers,
|
||||||
|
timeout=AI_CHAT_TIMEOUT,
|
||||||
|
)
|
||||||
|
# Truncate very long responses
|
||||||
|
if len(response) > 10000:
|
||||||
|
response = response[:10000] + "\n... (truncated)"
|
||||||
|
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}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""Ask a more capable model for help.
|
||||||
|
|
||||||
|
Content format:
|
||||||
|
Line 1: model_name (or 'auto')
|
||||||
|
Line 2+: the problem description
|
||||||
|
"""
|
||||||
|
from src.ai_interaction import _resolve_model, AI_CHAT_TIMEOUT
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from src.settings import get_setting
|
||||||
|
|
||||||
|
lines = content.strip().split("\n", 1)
|
||||||
|
model_spec = lines[0].strip() if lines else "auto"
|
||||||
|
problem = lines[1].strip() if len(lines) > 1 else ""
|
||||||
|
|
||||||
|
if not problem:
|
||||||
|
return {"error": "No problem description provided"}
|
||||||
|
|
||||||
|
if model_spec.lower() in ("auto", ""):
|
||||||
|
model_spec = get_setting("teacher_model", "")
|
||||||
|
if not model_spec:
|
||||||
|
return {"error": "No teacher model configured. Specify a model name or set teacher_model in settings."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url, model, headers = _resolve_model(model_spec, owner=owner)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await llm_call_async(
|
||||||
|
url, model,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": _TEACHER_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": f"Problem:\n{problem}"},
|
||||||
|
],
|
||||||
|
headers=headers,
|
||||||
|
timeout=AI_CHAT_TIMEOUT,
|
||||||
|
)
|
||||||
|
if len(response) > 8000:
|
||||||
|
response = response[:8000] + "\n... (truncated)"
|
||||||
|
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}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
"""List all available models across configured endpoints.
|
||||||
|
|
||||||
|
Content = optional filter keyword.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from src.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime, build_headers, build_models_url
|
||||||
|
|
||||||
|
keyword = content.strip().lower() if content.strip() else None
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
|
if owner:
|
||||||
|
query = owner_filter(query, ModelEndpoint, owner)
|
||||||
|
endpoints = query.all()
|
||||||
|
if not endpoints:
|
||||||
|
return {"results": "No enabled model endpoints configured."}
|
||||||
|
|
||||||
|
result_lines = []
|
||||||
|
total_models = 0
|
||||||
|
|
||||||
|
for ep in endpoints:
|
||||||
|
try:
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
provider = _detect_provider(base)
|
||||||
|
headers = build_headers(api_key, base)
|
||||||
|
|
||||||
|
model_ids = []
|
||||||
|
if provider == "anthropic":
|
||||||
|
model_ids = list(ANTHROPIC_MODELS)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
models_url = build_models_url(base)
|
||||||
|
if models_url:
|
||||||
|
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not model_ids:
|
||||||
|
model_ids = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
model_ids = json.loads(ep.cached_models or "[]")
|
||||||
|
except Exception:
|
||||||
|
model_ids = ["(endpoint offline)"]
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
model_ids = [m for m in model_ids if keyword in m.lower() or keyword in (ep.name or "").lower()]
|
||||||
|
|
||||||
|
if model_ids:
|
||||||
|
result_lines.append(f"\n**{ep.name or base}** ({provider}):")
|
||||||
|
for mid in model_ids:
|
||||||
|
result_lines.append(f" - `{mid}`")
|
||||||
|
total_models += 1
|
||||||
|
|
||||||
|
if not result_lines:
|
||||||
|
return {"results": "No models found" + (f" matching '{keyword}'" if keyword else "") + "."}
|
||||||
|
|
||||||
|
header = f"Available models ({total_models} total):"
|
||||||
|
return {"results": header + "\n".join(result_lines)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"list_models failed: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Handler classes registered in TOOL_HANDLERS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ChatWithModelTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await chat_with_model(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
class AskTeacherTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await ask_teacher(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
|
|
||||||
|
|
||||||
|
class ListModelsTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> Dict:
|
||||||
|
return await list_models(content, ctx.get("session_id"), owner=ctx.get("owner"))
|
||||||
@@ -57,13 +57,23 @@ class WebSearchTool:
|
|||||||
class WebFetchTool:
|
class WebFetchTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
from src.search.content import fetch_webpage_content
|
from src.search.content import fetch_webpage_content
|
||||||
|
from src.constants import WEB_FETCH_HARD_MAX_BYTES
|
||||||
raw = content.strip()
|
raw = content.strip()
|
||||||
url = ""
|
url = ""
|
||||||
|
max_bytes = None
|
||||||
if raw.startswith("{"):
|
if raw.startswith("{"):
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
url = str(parsed.get("url") or "").strip()
|
url = str(parsed.get("url") or "").strip()
|
||||||
|
# Download-budget override (#3812): "full": true raises the
|
||||||
|
# budget to the hard cap; an explicit max_bytes is clamped
|
||||||
|
# to the hard cap downstream. Default stays the soft cap.
|
||||||
|
if parsed.get("full") is True:
|
||||||
|
max_bytes = WEB_FETCH_HARD_MAX_BYTES
|
||||||
|
mb = parsed.get("max_bytes")
|
||||||
|
if isinstance(mb, int) and mb > 0:
|
||||||
|
max_bytes = mb
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
url = ""
|
url = ""
|
||||||
if not url:
|
if not url:
|
||||||
@@ -78,7 +88,7 @@ class WebFetchTool:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10)),
|
loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10, max_bytes=max_bytes)),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
@@ -94,8 +104,28 @@ class WebFetchTool:
|
|||||||
return {"error": f"web_fetch: {url}: {err}", "exit_code": 1}
|
return {"error": f"web_fetch: {url}: {err}", "exit_code": 1}
|
||||||
return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
|
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
|
||||||
|
# to get the rest, instead of silently presenting a partial page as
|
||||||
|
# the whole thing.
|
||||||
|
size_note = ""
|
||||||
|
if result.get("truncated"):
|
||||||
|
fetched = result.get("fetched_bytes") or 0
|
||||||
|
total = result.get("total_bytes")
|
||||||
|
total_txt = f" of {total:,} bytes" if total else ""
|
||||||
|
size_note = (
|
||||||
|
f"[partial content: download stopped at {fetched:,} bytes{total_txt}. "
|
||||||
|
f'Re-call with {{"url": "{url}", "full": true}} to fetch up to '
|
||||||
|
f"{WEB_FETCH_HARD_MAX_BYTES:,} bytes.]\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The notice must lead the output so the MAX_OUTPUT_CHARS trim below can
|
||||||
|
# never drop it. The title is untrusted, uncapped page content, so a
|
||||||
|
# giant title ahead of the notice could push it out of range; keep the
|
||||||
|
# notice first and cap the title as a second guard.
|
||||||
|
if len(title) > 300:
|
||||||
|
title = title[:300] + "..."
|
||||||
header = (f"# {title}\n" if title else "") + f"Source: {url}\n\n"
|
header = (f"# {title}\n" if title else "") + f"Source: {url}\n\n"
|
||||||
output = header + text
|
output = size_note + header + text
|
||||||
if len(output) > MAX_OUTPUT_CHARS:
|
if len(output) > MAX_OUTPUT_CHARS:
|
||||||
output = output[:MAX_OUTPUT_CHARS] + "\n\n[...truncated]"
|
output = output[:MAX_OUTPUT_CHARS] + "\n\n[...truncated]"
|
||||||
return {"output": output, "exit_code": 0}
|
return {"output": output, "exit_code": 0}
|
||||||
|
|||||||
+19
-334
@@ -1,8 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
ai_interaction.py
|
ai_interaction.py
|
||||||
|
|
||||||
AI-to-AI interaction tools: chat_with_model, create_session, list_sessions,
|
AI-to-AI interaction tools: create_session, list_sessions, send_to_session,
|
||||||
send_to_session, pipeline.
|
pipeline, plus shared model resolution (_resolve_model).
|
||||||
|
|
||||||
|
chat_with_model, ask_teacher and list_models were moved to
|
||||||
|
src/agent_tools/model_interaction_tools.py as part of the tool -> registry
|
||||||
|
migration (#3629); they still reuse _resolve_model / AI_CHAT_TIMEOUT from here.
|
||||||
|
|
||||||
These are agent tools — the LLM writes fenced code blocks and they execute
|
These are agent tools — the LLM writes fenced code blocks and they execute
|
||||||
through the standard agent_tools.py pipeline.
|
through the standard agent_tools.py pipeline.
|
||||||
@@ -159,242 +163,6 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
|||||||
# Tool implementations
|
# Tool implementations
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def do_chat_with_model(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Send a message to a specific model and return its response.
|
|
||||||
|
|
||||||
Content format:
|
|
||||||
Line 1: model_name (or model_name@endpoint_name)
|
|
||||||
Line 2+: the message to send
|
|
||||||
"""
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
|
|
||||||
lines = content.strip().split("\n", 1)
|
|
||||||
if not lines or not lines[0].strip():
|
|
||||||
return {"error": "First line must be the model name"}
|
|
||||||
|
|
||||||
model_spec = lines[0].strip()
|
|
||||||
message = lines[1].strip() if len(lines) > 1 else ""
|
|
||||||
if not message:
|
|
||||||
return {"error": "No message provided (line 2+ is the message)"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
|
||||||
except ValueError as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await llm_call_async(
|
|
||||||
url, model,
|
|
||||||
[{"role": "user", "content": message}],
|
|
||||||
headers=headers,
|
|
||||||
timeout=AI_CHAT_TIMEOUT,
|
|
||||||
)
|
|
||||||
# Truncate very long responses
|
|
||||||
if len(response) > 10000:
|
|
||||||
response = response[:10000] + "\n... (truncated)"
|
|
||||||
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}"}
|
|
||||||
|
|
||||||
|
|
||||||
_TEACHER_SYSTEM_PROMPT = (
|
|
||||||
"You are a senior AI mentor. A less capable model is stuck on a problem and asking for help. "
|
|
||||||
"Provide clear, actionable guidance:\n"
|
|
||||||
"1. Brief analysis of the problem\n"
|
|
||||||
"2. Recommended approach (step by step)\n"
|
|
||||||
"3. Key things to watch out for\n\n"
|
|
||||||
"Be concise and practical. No preamble."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def do_ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Ask a more capable model for help.
|
|
||||||
|
|
||||||
Content format:
|
|
||||||
Line 1: model_name (or 'auto')
|
|
||||||
Line 2+: the problem description
|
|
||||||
"""
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
from src.settings import get_setting
|
|
||||||
|
|
||||||
lines = content.strip().split("\n", 1)
|
|
||||||
model_spec = lines[0].strip() if lines else "auto"
|
|
||||||
problem = lines[1].strip() if len(lines) > 1 else ""
|
|
||||||
|
|
||||||
if not problem:
|
|
||||||
return {"error": "No problem description provided"}
|
|
||||||
|
|
||||||
if model_spec.lower() in ("auto", ""):
|
|
||||||
model_spec = get_setting("teacher_model", "")
|
|
||||||
if not model_spec:
|
|
||||||
return {"error": "No teacher model configured. Specify a model name or set teacher_model in settings."}
|
|
||||||
|
|
||||||
try:
|
|
||||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
|
||||||
except ValueError as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await llm_call_async(
|
|
||||||
url, model,
|
|
||||||
[
|
|
||||||
{"role": "system", "content": _TEACHER_SYSTEM_PROMPT},
|
|
||||||
{"role": "user", "content": f"Problem:\n{problem}"},
|
|
||||||
],
|
|
||||||
headers=headers,
|
|
||||||
timeout=AI_CHAT_TIMEOUT,
|
|
||||||
)
|
|
||||||
if len(response) > 8000:
|
|
||||||
response = response[:8000] + "\n... (truncated)"
|
|
||||||
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}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_second_opinion(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""Get a second opinion from another model, then have the original model
|
|
||||||
evaluate the feedback and produce a unified version.
|
|
||||||
|
|
||||||
Content format:
|
|
||||||
Line 1: model_name (or model_name@endpoint_name)
|
|
||||||
Line 2+ (optional): specific question or focus area
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
1. Pull recent conversation context
|
|
||||||
2. Send to reviewer model → get honest feedback
|
|
||||||
3. Send feedback back to the session's own model → evaluate & unify
|
|
||||||
4. Return both the review and the unified response
|
|
||||||
"""
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
|
|
||||||
lines = content.strip().split("\n", 1)
|
|
||||||
if not lines or not lines[0].strip():
|
|
||||||
return {"error": "First line must be the model name"}
|
|
||||||
|
|
||||||
model_spec = lines[0].strip()
|
|
||||||
focus = lines[1].strip() if len(lines) > 1 else ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
reviewer_url, reviewer_model, reviewer_headers = _resolve_model(model_spec, owner=owner)
|
|
||||||
except ValueError as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
# Pull recent conversation context from current session
|
|
||||||
context_text = ""
|
|
||||||
sess = None
|
|
||||||
if session_id and _session_manager:
|
|
||||||
sess = _session_manager.get_session(session_id)
|
|
||||||
if sess:
|
|
||||||
messages = sess.get_context_messages()
|
|
||||||
recent = messages[-15:] if len(messages) > 15 else messages
|
|
||||||
parts = []
|
|
||||||
for m in recent:
|
|
||||||
role = m.get("role", "unknown").upper()
|
|
||||||
text = m.get("content", "")
|
|
||||||
if isinstance(text, list):
|
|
||||||
text = " ".join(
|
|
||||||
p.get("text", "") for p in text if isinstance(p, dict)
|
|
||||||
)
|
|
||||||
if text:
|
|
||||||
parts.append(f"[{role}]: {text[:2000]}")
|
|
||||||
context_text = "\n\n".join(parts)
|
|
||||||
|
|
||||||
if not context_text:
|
|
||||||
return {"error": "No conversation context found to review"}
|
|
||||||
|
|
||||||
# ── Step 1: Get the reviewer's feedback ──
|
|
||||||
reviewer_system = (
|
|
||||||
"You are giving a second opinion on a conversation between a user and an AI assistant. "
|
|
||||||
"Your job is to be genuinely helpful and honest — not a yes-man, but not a contrarian either.\n\n"
|
|
||||||
"Guidelines:\n"
|
|
||||||
"- If the plan/idea is solid, say so clearly. Don't manufacture problems that aren't there.\n"
|
|
||||||
"- If you spot a real flaw, blind spot, or simpler approach — call it out directly.\n"
|
|
||||||
"- Be practical. Don't over-engineer or over-analyze. Real-world tradeoffs matter.\n"
|
|
||||||
"- If there's a meaningfully better way to do something, suggest it concretely.\n"
|
|
||||||
"- Give credit where it's due — highlight what's working well.\n"
|
|
||||||
"- Keep it concise and actionable. No fluff.\n"
|
|
||||||
"- You're a second pair of eyes, not a professor grading a paper."
|
|
||||||
)
|
|
||||||
|
|
||||||
reviewer_message = f"Here's the conversation so far:\n\n{context_text}"
|
|
||||||
if focus:
|
|
||||||
reviewer_message += f"\n\n---\nSpecifically, I want your take on: {focus}"
|
|
||||||
else:
|
|
||||||
reviewer_message += "\n\n---\nGive me your honest second opinion on what's being discussed."
|
|
||||||
|
|
||||||
try:
|
|
||||||
review = await llm_call_async(
|
|
||||||
reviewer_url, reviewer_model,
|
|
||||||
[
|
|
||||||
{"role": "system", "content": reviewer_system},
|
|
||||||
{"role": "user", "content": reviewer_message},
|
|
||||||
],
|
|
||||||
headers=reviewer_headers,
|
|
||||||
timeout=AI_CHAT_TIMEOUT,
|
|
||||||
)
|
|
||||||
if len(review) > 8000:
|
|
||||||
review = review[:8000] + "\n... (truncated)"
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"second_opinion reviewer call failed: {e}")
|
|
||||||
return {"error": f"Failed to get second opinion from {model_spec}: {e}"}
|
|
||||||
|
|
||||||
# ── Step 2: Send review back to session's own model for evaluation ──
|
|
||||||
unified = ""
|
|
||||||
original_model = "unknown"
|
|
||||||
if sess:
|
|
||||||
original_url = sess.endpoint_url
|
|
||||||
original_model = sess.model
|
|
||||||
original_headers = getattr(sess, "headers", None) or {}
|
|
||||||
|
|
||||||
unify_system = (
|
|
||||||
"Another AI model just reviewed the conversation you've been having with the user. "
|
|
||||||
"Read their feedback carefully, then respond with:\n\n"
|
|
||||||
"1. **What you agree with** — acknowledge valid points honestly.\n"
|
|
||||||
"2. **What you disagree with** — explain why, briefly.\n"
|
|
||||||
"3. **Unified version** — produce an updated/refined version of whatever was being discussed, "
|
|
||||||
"incorporating the feedback you found valid. Don't accept every note blindly — "
|
|
||||||
"use your judgment on what actually improves things vs what's unnecessary.\n\n"
|
|
||||||
"Be concise and practical. The user wants a better result, not a meta-discussion."
|
|
||||||
)
|
|
||||||
|
|
||||||
unify_message = (
|
|
||||||
f"Here's the conversation context:\n\n{context_text}\n\n"
|
|
||||||
f"---\n\n"
|
|
||||||
f"**Review from {reviewer_model}:**\n\n{review}\n\n"
|
|
||||||
f"---\n\n"
|
|
||||||
f"Evaluate this feedback and produce a unified improved version."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
unified = await llm_call_async(
|
|
||||||
original_url, original_model,
|
|
||||||
[
|
|
||||||
{"role": "system", "content": unify_system},
|
|
||||||
{"role": "user", "content": unify_message},
|
|
||||||
],
|
|
||||||
headers=original_headers,
|
|
||||||
timeout=AI_CHAT_TIMEOUT,
|
|
||||||
)
|
|
||||||
if len(unified) > 10000:
|
|
||||||
unified = unified[:10000] + "\n... (truncated)"
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"second_opinion unify call failed: {e}")
|
|
||||||
unified = f"(Failed to get unified response: {e})"
|
|
||||||
|
|
||||||
# Build combined result
|
|
||||||
combined = (
|
|
||||||
f"## Second Opinion from {reviewer_model}\n\n{review}"
|
|
||||||
f"\n\n---\n\n"
|
|
||||||
f"## {original_model}'s Response\n\n{unified}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"model": reviewer_model,
|
|
||||||
"response": combined,
|
|
||||||
"instruction": "Present these results to the user exactly as they are. Do NOT call second_opinion again. The user can continue the conversation from here.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_create_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
async def do_create_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
@@ -1104,83 +872,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
|||||||
return {"error": f"Unknown action '{action}'. Use: list, add, edit, delete, search"}
|
return {"error": f"Unknown action '{action}'. Use: list, add, edit, delete, search"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# List models tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
|
||||||
"""List all available models across configured endpoints.
|
|
||||||
|
|
||||||
Content = optional filter keyword.
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
from src.database import SessionLocal, ModelEndpoint
|
|
||||||
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
|
|
||||||
keyword = content.strip().lower() if content.strip() else None
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
|
||||||
if owner:
|
|
||||||
query = owner_filter(query, ModelEndpoint, owner)
|
|
||||||
endpoints = query.all()
|
|
||||||
if not endpoints:
|
|
||||||
return {"results": "No enabled model endpoints configured."}
|
|
||||||
|
|
||||||
result_lines = []
|
|
||||||
total_models = 0
|
|
||||||
|
|
||||||
for ep in endpoints:
|
|
||||||
try:
|
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
provider = _detect_provider(base)
|
|
||||||
headers = build_headers(api_key, base)
|
|
||||||
|
|
||||||
model_ids = []
|
|
||||||
if provider == "anthropic":
|
|
||||||
model_ids = list(ANTHROPIC_MODELS)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
models_url = build_models_url(base)
|
|
||||||
if models_url:
|
|
||||||
r = httpx.get(models_url, headers=headers, timeout=5)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
|
||||||
if not model_ids:
|
|
||||||
model_ids = [
|
|
||||||
m.get("name") or m.get("model")
|
|
||||||
for m in (data.get("models") or [])
|
|
||||||
if m.get("name") or m.get("model")
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
model_ids = json.loads(ep.cached_models or "[]")
|
|
||||||
except Exception:
|
|
||||||
model_ids = ["(endpoint offline)"]
|
|
||||||
|
|
||||||
if keyword:
|
|
||||||
model_ids = [m for m in model_ids if keyword in m.lower() or keyword in (ep.name or "").lower()]
|
|
||||||
|
|
||||||
if model_ids:
|
|
||||||
result_lines.append(f"\n**{ep.name or base}** ({provider}):")
|
|
||||||
for mid in model_ids:
|
|
||||||
result_lines.append(f" - `{mid}`")
|
|
||||||
total_models += 1
|
|
||||||
|
|
||||||
if not result_lines:
|
|
||||||
return {"results": "No models found" + (f" matching '{keyword}'" if keyword else "") + "."}
|
|
||||||
|
|
||||||
header = f"Available models ({total_models} total):"
|
|
||||||
return {"results": header + "\n".join(result_lines)}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"list_models failed: {e}")
|
|
||||||
return {"error": str(e)}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1613,7 +1304,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import httpx
|
import httpx
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from src.url_safety import check_outbound_url
|
||||||
|
|
||||||
lines = content.strip().split("\n")
|
lines = content.strip().split("\n")
|
||||||
prompt = lines[0].strip() if lines else ""
|
prompt = lines[0].strip() if lines else ""
|
||||||
@@ -1779,8 +1472,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
|
|
||||||
elif img.get("url"):
|
elif img.get("url"):
|
||||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
# Download external URL and save locally (DALL-E returns temp URLs)
|
||||||
|
result_url = img["url"]
|
||||||
|
ok, reason = check_outbound_url(
|
||||||
|
result_url,
|
||||||
|
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return {"error": f"Image API returned unsafe image URL: {reason}"}
|
||||||
try:
|
try:
|
||||||
dl_resp = httpx.get(img["url"], timeout=60)
|
dl_resp = httpx.get(result_url, timeout=60)
|
||||||
if dl_resp.status_code == 200:
|
if dl_resp.status_code == 200:
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1790,10 +1490,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
image_url = f"/api/generated-image/{filename}"
|
image_url = f"/api/generated-image/{filename}"
|
||||||
image_id = _save_to_gallery(filename)
|
image_id = _save_to_gallery(filename)
|
||||||
else:
|
else:
|
||||||
image_url = img["url"] # fallback to external URL
|
image_url = result_url # fallback to external URL
|
||||||
except Exception as _dl_e:
|
except Exception as _dl_e:
|
||||||
logger.warning(f"Failed to download DALL-E image: {_dl_e}")
|
logger.warning(f"Failed to download DALL-E image: {_dl_e}")
|
||||||
image_url = img["url"] # fallback to external URL
|
image_url = result_url # fallback to external URL
|
||||||
else:
|
else:
|
||||||
return {"error": "Image API returned unexpected format (no b64_json or url)"}
|
return {"error": "Image API returned unexpected format (no b64_json or url)"}
|
||||||
|
|
||||||
@@ -1822,12 +1522,7 @@ async def dispatch_ai_tool(
|
|||||||
) -> Tuple[str, Dict]:
|
) -> Tuple[str, Dict]:
|
||||||
"""Dispatch an AI interaction tool. Returns (description, result_dict)."""
|
"""Dispatch an AI interaction tool. Returns (description, result_dict)."""
|
||||||
|
|
||||||
if tool == "chat_with_model":
|
if tool == "create_session":
|
||||||
model_spec = content.split("\n")[0].strip()[:60]
|
|
||||||
desc = f"chat_with_model: {model_spec}"
|
|
||||||
result = await do_chat_with_model(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
elif tool == "create_session":
|
|
||||||
name = content.split("\n")[0].strip()[:60]
|
name = content.split("\n")[0].strip()[:60]
|
||||||
desc = f"create_session: {name}"
|
desc = f"create_session: {name}"
|
||||||
result = await do_create_session(content, session_id, owner=owner)
|
result = await do_create_session(content, session_id, owner=owner)
|
||||||
@@ -1856,21 +1551,11 @@ async def dispatch_ai_tool(
|
|||||||
desc = f"manage_memory: {action}"
|
desc = f"manage_memory: {action}"
|
||||||
result = await do_manage_memory(content, session_id, owner=owner)
|
result = await do_manage_memory(content, session_id, owner=owner)
|
||||||
|
|
||||||
elif tool == "list_models":
|
|
||||||
keyword = content.strip()[:40]
|
|
||||||
desc = f"list_models{': ' + keyword if keyword else ''}"
|
|
||||||
result = await do_list_models(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
elif tool == "ui_control":
|
elif tool == "ui_control":
|
||||||
action = content.split("\n")[0].strip()[:60]
|
action = content.split("\n")[0].strip()[:60]
|
||||||
desc = f"ui_control: {action}"
|
desc = f"ui_control: {action}"
|
||||||
result = await do_ui_control(content, session_id, owner=owner)
|
result = await do_ui_control(content, session_id, owner=owner)
|
||||||
|
|
||||||
elif tool == "ask_teacher":
|
|
||||||
problem = content.split("\n", 1)[-1].strip()[:60]
|
|
||||||
desc = f"ask_teacher: {problem}"
|
|
||||||
result = await do_ask_teacher(content, session_id, owner=owner)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
desc = f"unknown ai tool: {tool}"
|
desc = f"unknown ai tool: {tool}"
|
||||||
result = {"error": f"Unknown AI interaction tool: {tool}"}
|
result = {"error": f"Unknown AI interaction tool: {tool}"}
|
||||||
|
|||||||
+3
-2
@@ -14,6 +14,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from core.platform_compat import IS_WINDOWS, which_tool
|
from core.platform_compat import IS_WINDOWS, which_tool
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ _BUILTIN_NPX_SERVERS = {
|
|||||||
"name": "Built-in: Browser",
|
"name": "Built-in: Browser",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@playwright/mcp@latest", "--headless", "--caps", "vision"],
|
"args": ["-y", "@playwright/mcp@latest", "--headless", "--caps", "vision"],
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Global flag to disable MCP if there are compatibility issues
|
# Global flag to disable MCP if there are compatibility issues
|
||||||
@@ -94,7 +95,7 @@ async def register_builtin_servers(mcp_manager):
|
|||||||
logger.info("Built-in MCP servers disabled via ODYSSEUS_DISABLE_MCP")
|
logger.info("Built-in MCP servers disabled via ODYSSEUS_DISABLE_MCP")
|
||||||
return
|
return
|
||||||
|
|
||||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
base_dir = get_app_root()
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
|
|
||||||
async def _connect_python_server(server_id: str, script_path: str, name: str):
|
async def _connect_python_server(server_id: str, script_path: str, name: str):
|
||||||
|
|||||||
+3
-2
@@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from src.constants import DATA_DIR as _DATA_DIR_CONST
|
from src.constants import DATA_DIR as _DATA_DIR_CONST
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
# Cross-platform OS flag, exposed here so callers can `from src.config import
|
# Cross-platform OS flag, exposed here so callers can `from src.config import
|
||||||
# IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported
|
# IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported
|
||||||
@@ -19,7 +20,7 @@ IS_WINDOWS = os.name == "nt"
|
|||||||
class DataConfig(BaseSettings):
|
class DataConfig(BaseSettings):
|
||||||
"""Configuration for data storage and file handling."""
|
"""Configuration for data storage and file handling."""
|
||||||
# Base directory
|
# Base directory
|
||||||
base_dir: Path = Field(default=Path(__file__).parent.parent, description="Base directory for the application")
|
base_dir: Path = Field(default=Path(get_app_root()), description="Base directory for the application")
|
||||||
|
|
||||||
# Data paths
|
# Data paths
|
||||||
data_dir: Path = Field(default=Path(_DATA_DIR_CONST), description="Main data directory")
|
data_dir: Path = Field(default=Path(_DATA_DIR_CONST), description="Main data directory")
|
||||||
@@ -138,7 +139,7 @@ class AppConfig(BaseSettings):
|
|||||||
if isinstance(v, dict) and "base_dir" in v:
|
if isinstance(v, dict) and "base_dir" in v:
|
||||||
base_dir = v["base_dir"]
|
base_dir = v["base_dir"]
|
||||||
else:
|
else:
|
||||||
base_dir = Path(__file__).parent.parent
|
base_dir = Path(get_app_root())
|
||||||
|
|
||||||
# Convert string paths to Path objects relative to base_dir
|
# Convert string paths to Path objects relative to base_dir
|
||||||
data_dir = Path(_DATA_DIR_CONST)
|
data_dir = Path(_DATA_DIR_CONST)
|
||||||
|
|||||||
+29
-3
@@ -2,12 +2,14 @@
|
|||||||
"""Application-wide constants and configuration values."""
|
"""Application-wide constants and configuration values."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from src.runtime_paths import get_app_root, get_default_data_dir
|
||||||
|
|
||||||
APP_VERSION = "1.0.0"
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
# Base paths
|
# Base paths
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
BASE_DIR = os.path.join(get_app_root(), "")
|
||||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||||
DATA_DIR = os.getenv("ODYSSEUS_DATA_DIR", os.path.join(BASE_DIR, "data"))
|
DATA_DIR = os.getenv("ODYSSEUS_DATA_DIR", get_default_data_dir())
|
||||||
|
|
||||||
# Data file paths
|
# Data file paths
|
||||||
# Single source of truth: every persisted file/dir lives under DATA_DIR, which
|
# Single source of truth: every persisted file/dir lives under DATA_DIR, which
|
||||||
@@ -55,7 +57,13 @@ MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors")
|
|||||||
|
|
||||||
# Paths with an intentional dedicated env override, defaulting under DATA_DIR.
|
# Paths with an intentional dedicated env override, defaulting under DATA_DIR.
|
||||||
MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments"))
|
MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments"))
|
||||||
FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH", os.path.join(DATA_DIR, "fastembed_cache"))
|
# `or` (not os.getenv's default arg) so a PRESENT-but-EMPTY value falls back to
|
||||||
|
# the default. docker-compose.yml injects `FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}`,
|
||||||
|
# which sets the var to "" when the host hasn't defined it. os.getenv(name, default)
|
||||||
|
# only returns the default when the var is ABSENT, so the empty string would win →
|
||||||
|
# os.makedirs("") raises [Errno 2] No such file or directory: '' → FastEmbed fails to
|
||||||
|
# init and all vector features (RAG, semantic memory, tool index) silently degrade.
|
||||||
|
FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH") or os.path.join(DATA_DIR, "fastembed_cache")
|
||||||
|
|
||||||
# Agent tool output limits (single source of truth — imported by tool_execution.py,
|
# Agent tool output limits (single source of truth — imported by tool_execution.py,
|
||||||
# tool_implementations.py, agent_tools.py, and any other module that needs them)
|
# tool_implementations.py, agent_tools.py, and any other module that needs them)
|
||||||
@@ -63,11 +71,26 @@ MAX_OUTPUT_CHARS = 10_000 # cap for bash/python/web_search/web_fetch outpu
|
|||||||
MAX_READ_CHARS = 20_000 # cap for read_file / document preview
|
MAX_READ_CHARS = 20_000 # cap for read_file / document preview
|
||||||
MAX_DIFF_LINES = 400 # cap for edit_file unified-diff display
|
MAX_DIFF_LINES = 400 # cap for edit_file unified-diff display
|
||||||
|
|
||||||
|
# web_fetch response-size policy (#3812). MAX_OUTPUT_CHARS above only trims
|
||||||
|
# what the agent SEES; these caps bound what the server downloads, parses,
|
||||||
|
# and writes to the content cache. The soft cap is the default download
|
||||||
|
# budget; the agent can raise it per call (full/max_bytes) but never past
|
||||||
|
# the hard cap, so a model can't decide to pull a multi-GB file.
|
||||||
|
WEB_FETCH_SOFT_MAX_BYTES = 2_000_000 # default download budget (2 MB)
|
||||||
|
WEB_FETCH_HARD_MAX_BYTES = 20_000_000 # absolute ceiling, even with override (20 MB)
|
||||||
|
|
||||||
# API Configuration
|
# API Configuration
|
||||||
MAX_CONTEXT_MESSAGES = 90
|
MAX_CONTEXT_MESSAGES = 90
|
||||||
REQUEST_TIMEOUT = 20
|
REQUEST_TIMEOUT = 20
|
||||||
OPENAI_COMPAT_PATH = "/v1/chat/completions"
|
OPENAI_COMPAT_PATH = "/v1/chat/completions"
|
||||||
|
|
||||||
|
# Outbound UA for web_fetch / web_search scraping; common desktop UA so pages serve normal HTML.
|
||||||
|
WEB_FETCH_USER_AGENT = os.environ.get(
|
||||||
|
"WEB_FETCH_USER_AGENT",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
||||||
|
)
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
|
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
|
||||||
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
|
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
|
||||||
@@ -79,6 +102,9 @@ SEARXNG_INSTANCE = os.getenv("SEARXNG_INSTANCE", "http://localhost:8080")
|
|||||||
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
||||||
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
||||||
|
|
||||||
|
# Auth policy
|
||||||
|
PASSWORD_MIN_LENGTH = 8
|
||||||
|
|
||||||
# Default parameters
|
# Default parameters
|
||||||
DEFAULT_TEMPERATURE = 1.0
|
DEFAULT_TEMPERATURE = 1.0
|
||||||
DEFAULT_MAX_TOKENS = 0
|
DEFAULT_MAX_TOKENS = 0
|
||||||
|
|||||||
@@ -161,11 +161,13 @@ async def _tick() -> None:
|
|||||||
# Re-read state once before writing so we capture any updates from
|
# Re-read state once before writing so we capture any updates from
|
||||||
# concurrent UI syncs.
|
# concurrent UI syncs.
|
||||||
stopped_any = False
|
stopped_any = False
|
||||||
|
successfully_stopped_sids = set()
|
||||||
for sid, host, port in to_stop:
|
for sid, host, port in to_stop:
|
||||||
ok = await _stop_serve(sid, host, port)
|
ok = await _stop_serve(sid, host, port)
|
||||||
logger.info(f"cookbook_serve_lifecycle: stop {sid} (host={host or 'local'}): {'ok' if ok else 'failed'}")
|
logger.info(f"cookbook_serve_lifecycle: stop {sid} (host={host or 'local'}): {'ok' if ok else 'failed'}")
|
||||||
if ok:
|
if ok:
|
||||||
stopped_any = True
|
stopped_any = True
|
||||||
|
successfully_stopped_sids.add(sid)
|
||||||
# Drop the auto-registered endpoint so the model picker and
|
# Drop the auto-registered endpoint so the model picker and
|
||||||
# the chat router don't keep pointing at a dead server.
|
# the chat router don't keep pointing at a dead server.
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
@@ -188,12 +190,11 @@ async def _tick() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
fresh = state
|
fresh = state
|
||||||
fresh_tasks = tasks
|
fresh_tasks = tasks
|
||||||
stopped_sids = {sid for sid, _, _ in to_stop}
|
|
||||||
for ft in fresh_tasks:
|
for ft in fresh_tasks:
|
||||||
if not isinstance(ft, dict):
|
if not isinstance(ft, dict):
|
||||||
continue
|
continue
|
||||||
ft_sid = ft.get("sessionId") or ft.get("id")
|
ft_sid = ft.get("sessionId") or ft.get("id")
|
||||||
if ft_sid in stopped_sids:
|
if ft_sid in successfully_stopped_sids:
|
||||||
ft["status"] = "stopped"
|
ft["status"] = "stopped"
|
||||||
ft["_scheduledStopAtMs"] = None
|
ft["_scheduledStopAtMs"] = None
|
||||||
ft["_lastStatusFlipAt"] = now_ms
|
ft["_lastStatusFlipAt"] = now_ms
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import numpy as np
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEFAULT_MODEL = "all-minilm:l6-v2"
|
_DEFAULT_MODEL = "all-minilm:l6-v2"
|
||||||
|
|||||||
+39
-11
@@ -161,6 +161,32 @@ def normalize_base(url: str) -> str:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_endpoint_base(url: str) -> str:
|
||||||
|
"""Return a base URL that is safe for endpoint path appends."""
|
||||||
|
base = (url or "").strip().rstrip("/")
|
||||||
|
if "?" in base or "#" in base:
|
||||||
|
raise ValueError("Endpoint base URL must not include query or fragment")
|
||||||
|
return urlunparse(urlparse(base)._replace(query="", fragment="")).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_endpoint_base(base: str) -> str:
|
||||||
|
base = _validated_endpoint_base(normalize_base(base))
|
||||||
|
return _validated_endpoint_base(normalize_base(resolve_url(base)))
|
||||||
|
|
||||||
|
|
||||||
|
def _append_endpoint_path(base: str, suffix: str) -> str:
|
||||||
|
parsed = urlparse(base)
|
||||||
|
current = (parsed.path or "").rstrip("/")
|
||||||
|
extra = "/" + suffix.lstrip("/")
|
||||||
|
path = f"{current}{extra}" if current else extra
|
||||||
|
return urlunparse(parsed._replace(path=path, query="", fragment=""))
|
||||||
|
|
||||||
|
|
||||||
|
def _pathless_host(base: str, host: str) -> bool:
|
||||||
|
parsed = urlparse(base)
|
||||||
|
return (parsed.hostname or "").lower() == host and not (parsed.path or "").strip("/")
|
||||||
|
|
||||||
|
|
||||||
def _anthropic_api_root(base: str) -> str:
|
def _anthropic_api_root(base: str) -> str:
|
||||||
"""Return Anthropic's API root, preserving /v1 for OpenAI-compatible APIs elsewhere."""
|
"""Return Anthropic's API root, preserving /v1 for OpenAI-compatible APIs elsewhere."""
|
||||||
base = (base or "").strip().rstrip("/")
|
base = (base or "").strip().rstrip("/")
|
||||||
@@ -171,15 +197,17 @@ def _anthropic_api_root(base: str) -> str:
|
|||||||
|
|
||||||
def build_chat_url(base: str) -> str:
|
def build_chat_url(base: str) -> str:
|
||||||
"""Return the correct chat endpoint URL for a given base."""
|
"""Return the correct chat endpoint URL for a given base."""
|
||||||
base = resolve_url(base)
|
base = _prepare_endpoint_base(base)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
return _anthropic_api_root(base) + "/v1/messages"
|
return _append_endpoint_path(_anthropic_api_root(base), "/v1/messages")
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
return _ollama_api_root(base) + "/chat"
|
return _append_endpoint_path(_ollama_api_root(base), "/chat")
|
||||||
if provider == "chatgpt-subscription":
|
if provider == "chatgpt-subscription":
|
||||||
return base.rstrip("/") + "/responses"
|
return _append_endpoint_path(base, "/responses")
|
||||||
return base + "/chat/completions"
|
if _pathless_host(base, "api.openai.com"):
|
||||||
|
base = _append_endpoint_path(base, "/v1")
|
||||||
|
return _append_endpoint_path(base, "/chat/completions")
|
||||||
|
|
||||||
|
|
||||||
def build_models_url(base: str) -> Optional[str]:
|
def build_models_url(base: str) -> Optional[str]:
|
||||||
@@ -193,12 +221,12 @@ def build_models_url(base: str) -> Optional[str]:
|
|||||||
untouched (so custom prefixes like ``/openai`` or ``/api/openai/v1`` keep
|
untouched (so custom prefixes like ``/openai`` or ``/api/openai/v1`` keep
|
||||||
their semantics).
|
their semantics).
|
||||||
"""
|
"""
|
||||||
base = normalize_base(resolve_url(base))
|
base = _prepare_endpoint_base(base)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
return _anthropic_api_root(base) + "/v1/models"
|
return _append_endpoint_path(_anthropic_api_root(base), "/v1/models")
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
return _ollama_api_root(base) + "/tags"
|
return _append_endpoint_path(_ollama_api_root(base), "/tags")
|
||||||
if provider == "chatgpt-subscription":
|
if provider == "chatgpt-subscription":
|
||||||
return None
|
return None
|
||||||
# Generic OpenAI-compatible fallback: local model servers with no explicit
|
# Generic OpenAI-compatible fallback: local model servers with no explicit
|
||||||
@@ -208,10 +236,10 @@ def build_models_url(base: str) -> Optional[str]:
|
|||||||
parsed = urlparse(base)
|
parsed = urlparse(base)
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
is_local = host in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
|
is_local = host in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
|
||||||
uses_v1_models_by_default = is_local or host in {"api.deepseek.com"}
|
uses_v1_models_by_default = is_local or host in {"api.deepseek.com", "api.openai.com"}
|
||||||
if not parsed.path and uses_v1_models_by_default:
|
if not parsed.path and uses_v1_models_by_default:
|
||||||
base = base + "/v1"
|
base = _append_endpoint_path(base, "/v1")
|
||||||
return base + "/models"
|
return _append_endpoint_path(base, "/models")
|
||||||
|
|
||||||
|
|
||||||
def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||||
|
|||||||
+35
-8
@@ -4,6 +4,7 @@ import uuid
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
from urllib.parse import urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -202,6 +203,22 @@ def mask_integration_secret(integration: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return safe
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_integration_base_url(base_url: Any) -> str:
|
||||||
|
if not isinstance(base_url, str) or not base_url.strip():
|
||||||
|
raise ValueError("Integration base URL is required")
|
||||||
|
cleaned = base_url.strip().rstrip("/")
|
||||||
|
if "?" in cleaned or "#" in cleaned:
|
||||||
|
raise ValueError("Integration base URL must not include query or fragment")
|
||||||
|
parsed = urlparse(cleaned)
|
||||||
|
if parsed.scheme.lower() not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise ValueError("Integration base URL must be an HTTP(S) URL")
|
||||||
|
return urlunparse(parsed._replace(scheme=parsed.scheme.lower(), query="", fragment="")).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _join_integration_url(base_url: str, path: str) -> str:
|
||||||
|
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
def load_integrations() -> List[Dict[str, Any]]:
|
def load_integrations() -> List[Dict[str, Any]]:
|
||||||
"""Load all integrations from disk with secrets decrypted for runtime use."""
|
"""Load all integrations from disk with secrets decrypted for runtime use."""
|
||||||
if not os.path.exists(DATA_FILE):
|
if not os.path.exists(DATA_FILE):
|
||||||
@@ -261,8 +278,10 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
if not isinstance(integration.get("name"), str) or not integration["name"].strip():
|
if not isinstance(integration.get("name"), str) or not integration["name"].strip():
|
||||||
raise HTTPException(400, "Integration name is required")
|
raise HTTPException(400, "Integration name is required")
|
||||||
if not isinstance(integration.get("base_url"), str) or not integration["base_url"].strip():
|
try:
|
||||||
raise HTTPException(400, "Integration base URL is required")
|
integration["base_url"] = _normalize_integration_base_url(integration.get("base_url"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
integrations = load_integrations()
|
integrations = load_integrations()
|
||||||
integrations.append(integration)
|
integrations.append(integration)
|
||||||
@@ -272,10 +291,14 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update fields on an existing integration. Returns updated integration or None."""
|
"""Update fields on an existing integration. Returns updated integration or None."""
|
||||||
|
data = dict(data)
|
||||||
if "name" in data and (not isinstance(data["name"], str) or not data["name"].strip()):
|
if "name" in data and (not isinstance(data["name"], str) or not data["name"].strip()):
|
||||||
raise HTTPException(400, "Integration name is required")
|
raise HTTPException(400, "Integration name is required")
|
||||||
if "base_url" in data and (not isinstance(data["base_url"], str) or not data["base_url"].strip()):
|
if "base_url" in data:
|
||||||
raise HTTPException(400, "Integration base URL is required")
|
try:
|
||||||
|
data["base_url"] = _normalize_integration_base_url(data["base_url"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
integrations = load_integrations()
|
integrations = load_integrations()
|
||||||
for item in integrations:
|
for item in integrations:
|
||||||
@@ -341,9 +364,10 @@ async def execute_api_call(
|
|||||||
if not integration.get("enabled", True):
|
if not integration.get("enabled", True):
|
||||||
return {"error": f"Integration '{integration.get('name')}' is disabled", "exit_code": 1}
|
return {"error": f"Integration '{integration.get('name')}' is disabled", "exit_code": 1}
|
||||||
|
|
||||||
base_url = integration.get("base_url", "").rstrip("/")
|
try:
|
||||||
if not base_url:
|
base_url = _normalize_integration_base_url(integration.get("base_url", ""))
|
||||||
return {"error": "Integration has no base_url configured", "exit_code": 1}
|
except ValueError as exc:
|
||||||
|
return {"error": str(exc), "exit_code": 1}
|
||||||
|
|
||||||
# Strip common API path suffixes users might accidentally include
|
# Strip common API path suffixes users might accidentally include
|
||||||
# (e.g. "http://host/v1/" → "http://host"). The integration's preset
|
# (e.g. "http://host/v1/" → "http://host"). The integration's preset
|
||||||
@@ -366,7 +390,10 @@ async def execute_api_call(
|
|||||||
if re.search(r"^https?://", path) or "://" in path:
|
if re.search(r"^https?://", path) or "://" in path:
|
||||||
return {"error": "Path must not contain a protocol scheme", "exit_code": 1}
|
return {"error": "Path must not contain a protocol scheme", "exit_code": 1}
|
||||||
|
|
||||||
url = base_url + path
|
if "#" in path:
|
||||||
|
return {"error": "Path must not contain a fragment", "exit_code": 1}
|
||||||
|
|
||||||
|
url = _join_integration_url(base_url, path)
|
||||||
method = method.upper()
|
method = method.upper()
|
||||||
|
|
||||||
# Build headers
|
# Build headers
|
||||||
|
|||||||
+4
-3
@@ -283,7 +283,8 @@ def _is_ollama_native_url(url: str) -> bool:
|
|||||||
"""Return True for native Ollama API URLs, including Ollama Cloud."""
|
"""Return True for native Ollama API URLs, including Ollama Cloud."""
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse URL for Ollama detection", exc_info=e)
|
||||||
return False
|
return False
|
||||||
host = parsed.hostname or ""
|
host = parsed.hostname or ""
|
||||||
path = (parsed.path or "").rstrip("/")
|
path = (parsed.path or "").rstrip("/")
|
||||||
@@ -1345,8 +1346,8 @@ def list_model_ids(
|
|||||||
r = httpx.get(root + "/api/tags", timeout=timeout)
|
r = httpx.get(root + "/api/tags", timeout=timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return [m.get("name") or m.get("model") for m in (r.json().get("models") or []) if m.get("name") or m.get("model")]
|
return [m.get("name") or m.get("model") for m in (r.json().get("models") or []) if m.get("name") or m.get("model")]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed to fetch model list from configured endpoint", exc_info=e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def normalize_model_id(
|
def normalize_model_id(
|
||||||
|
|||||||
+3
-1
@@ -11,6 +11,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def _format_mcp_connection_error(name: str, command: str = "", args: Optional[List[str]] = None, error: Exception = None) -> str:
|
def _format_mcp_connection_error(name: str, command: str = "", args: Optional[List[str]] = None, error: Exception = None) -> str:
|
||||||
@@ -508,7 +510,7 @@ class McpManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
script_rel, name = _BUILTIN_SERVERS[server_id]
|
script_rel, name = _BUILTIN_SERVERS[server_id]
|
||||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
base_dir = get_app_root()
|
||||||
script_path = os.path.join(base_dir, script_rel)
|
script_path = os.path.join(base_dir, script_rel)
|
||||||
|
|
||||||
# Clean up old connection
|
# Clean up old connection
|
||||||
|
|||||||
+14
-5
@@ -17,10 +17,11 @@ import httpx
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}
|
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}
|
||||||
_PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
_PRIVATE_NETWORKS = (
|
||||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
"172.30.", "172.31.", "192.168.")
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
)
|
||||||
|
|
||||||
# Tailscale uses the CGNAT range 100.64.0.0/10, NOT all of 100.0.0.0/8.
|
# Tailscale uses the CGNAT range 100.64.0.0/10, NOT all of 100.0.0.0/8.
|
||||||
# A bare "100." prefix would classify public addresses (e.g. AWS ranges
|
# A bare "100." prefix would classify public addresses (e.g. AWS ranges
|
||||||
@@ -36,6 +37,14 @@ def _in_tailscale_range(host: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_ip_literal(host: str) -> bool:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(ip in network for network in _PRIVATE_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_base_for_compare(url: str) -> str:
|
def _normalize_base_for_compare(url: str) -> str:
|
||||||
url = (url or "").strip().rstrip("/")
|
url = (url or "").strip().rstrip("/")
|
||||||
for suffix in ("/chat/completions", "/models", "/completions", "/v1/messages"):
|
for suffix in ("/chat/completions", "/models", "/completions", "/v1/messages"):
|
||||||
@@ -87,7 +96,7 @@ def is_local_endpoint(url: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
host = urlparse(url).hostname or ""
|
host = urlparse(url).hostname or ""
|
||||||
return host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES) or _in_tailscale_range(host)
|
return host in _LOCAL_HOSTS or _is_private_ip_literal(host) or _in_tailscale_range(host)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,47 @@ class PersonalDocsManager:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"Directory not in index: {directory}")
|
logger.info(f"Directory not in index: {directory}")
|
||||||
|
|
||||||
|
def rename_directory(self, old_directory: str, new_directory: str, *, path_map: Dict[str, str] = None):
|
||||||
|
"""Rewrite tracked directory and excluded-file paths after an owner rename."""
|
||||||
|
old_directory = os.path.abspath(old_directory)
|
||||||
|
new_directory = os.path.abspath(new_directory)
|
||||||
|
path_map = {os.path.abspath(k): os.path.abspath(v) for k, v in (path_map or {}).items()}
|
||||||
|
|
||||||
|
def rewrite(path: str) -> str:
|
||||||
|
abs_path = os.path.abspath(path)
|
||||||
|
mapped = path_map.get(abs_path)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
if abs_path == old_directory:
|
||||||
|
return new_directory
|
||||||
|
if abs_path.startswith(old_directory + os.sep):
|
||||||
|
return new_directory + abs_path[len(old_directory):]
|
||||||
|
return abs_path
|
||||||
|
|
||||||
|
changed_dirs = False
|
||||||
|
rewritten_dirs = []
|
||||||
|
for directory in self.indexed_directories:
|
||||||
|
rewritten = rewrite(directory)
|
||||||
|
changed_dirs = changed_dirs or rewritten != os.path.abspath(directory)
|
||||||
|
if rewritten not in rewritten_dirs:
|
||||||
|
rewritten_dirs.append(rewritten)
|
||||||
|
if changed_dirs:
|
||||||
|
self.indexed_directories = rewritten_dirs
|
||||||
|
self.save_directories()
|
||||||
|
|
||||||
|
changed_excluded = False
|
||||||
|
rewritten_excluded = set()
|
||||||
|
for path in self.excluded_files:
|
||||||
|
rewritten = rewrite(path)
|
||||||
|
changed_excluded = changed_excluded or rewritten != os.path.abspath(path)
|
||||||
|
rewritten_excluded.add(rewritten)
|
||||||
|
if changed_excluded:
|
||||||
|
self.excluded_files = rewritten_excluded
|
||||||
|
self._save_excluded()
|
||||||
|
|
||||||
|
if changed_dirs or changed_excluded:
|
||||||
|
self.refresh_index()
|
||||||
|
|
||||||
def get_indexed_directories(self):
|
def get_indexed_directories(self):
|
||||||
"""Get the list of all indexed directories."""
|
"""Get the list of all indexed directories."""
|
||||||
return self.indexed_directories.copy()
|
return self.indexed_directories.copy()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.constants import RAG_DIR
|
from src.constants import RAG_DIR
|
||||||
|
from src.runtime_paths import get_app_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ def _generate_doc_id(text: str, owner: str = "") -> str:
|
|||||||
return f"doc_{hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]}"
|
return f"doc_{hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_owner_path(value: str, path_map: Dict[str, str], path_prefixes: List[tuple]) -> str:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return value
|
||||||
|
abs_value = os.path.abspath(value)
|
||||||
|
mapped = path_map.get(abs_value)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
for old_prefix, new_prefix in path_prefixes:
|
||||||
|
old_abs = os.path.abspath(old_prefix)
|
||||||
|
new_abs = os.path.abspath(new_prefix)
|
||||||
|
if abs_value == old_abs:
|
||||||
|
return new_abs
|
||||||
|
if abs_value.startswith(old_abs + os.sep):
|
||||||
|
return new_abs + abs_value[len(old_abs):]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class VectorRAG:
|
class VectorRAG:
|
||||||
"""RAG system using ChromaDB vector storage with hybrid search."""
|
"""RAG system using ChromaDB vector storage with hybrid search."""
|
||||||
|
|
||||||
@@ -250,6 +267,75 @@ class VectorRAG:
|
|||||||
"failed_count": len(docs) - len(valid),
|
"failed_count": len(docs) - len(valid),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def rename_owner(
|
||||||
|
self,
|
||||||
|
old_owner: str,
|
||||||
|
new_owner: str,
|
||||||
|
*,
|
||||||
|
path_map: Optional[Dict[str, str]] = None,
|
||||||
|
path_prefixes: Optional[List[tuple]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Rewrite existing RAG metadata after an auth username rename."""
|
||||||
|
if not self.healthy:
|
||||||
|
return {"success": False, "updated_count": 0, "message": "Collection not initialized"}
|
||||||
|
|
||||||
|
old_owner = (old_owner or "").strip().lower()
|
||||||
|
new_owner = (new_owner or "").strip().lower()
|
||||||
|
if not old_owner or not new_owner or old_owner == new_owner:
|
||||||
|
return {"success": True, "updated_count": 0, "message": "No owner rename needed"}
|
||||||
|
|
||||||
|
path_map = {os.path.abspath(k): os.path.abspath(v) for k, v in (path_map or {}).items()}
|
||||||
|
path_prefixes = path_prefixes or []
|
||||||
|
updated_ids = set()
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for lane_name, collection in self._collections_for_delete():
|
||||||
|
try:
|
||||||
|
results = collection.get(
|
||||||
|
where={"owner": old_owner},
|
||||||
|
include=["metadatas"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("rename_owner metadata scan failed in %s lane: %s", lane_name, e)
|
||||||
|
failed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ids = results.get("ids") or []
|
||||||
|
metadatas = results.get("metadatas") or []
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_metas = []
|
||||||
|
selected_ids = []
|
||||||
|
for doc_id, meta in zip(ids, metadatas):
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
continue
|
||||||
|
next_meta = dict(meta)
|
||||||
|
if str(next_meta.get("owner", "")).strip().lower() == old_owner:
|
||||||
|
next_meta["owner"] = new_owner
|
||||||
|
for key in ("source", "directory"):
|
||||||
|
next_meta[key] = _rewrite_owner_path(next_meta.get(key), path_map, path_prefixes)
|
||||||
|
selected_ids.append(doc_id)
|
||||||
|
new_metas.append(next_meta)
|
||||||
|
|
||||||
|
if not selected_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
collection.update(ids=selected_ids, metadatas=new_metas)
|
||||||
|
updated_ids.update(selected_ids)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("rename_owner metadata update failed in %s lane: %s", lane_name, e)
|
||||||
|
failed_count += len(selected_ids)
|
||||||
|
|
||||||
|
success = failed_count == 0
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"updated_count": len(updated_ids),
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"message": f"Updated {len(updated_ids)} RAG chunk(s)",
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Search — hybrid: vector similarity + keyword overlap
|
# Search — hybrid: vector similarity + keyword overlap
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Helpers for resolving runtime paths in source and frozen builds."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_root() -> str:
|
||||||
|
"""Return the app root directory.
|
||||||
|
|
||||||
|
In normal source runs, this is the repository root. In a frozen Windows
|
||||||
|
build, it is the bundle content root (PyInstaller's internal directory)
|
||||||
|
so bundled runtime folders like `static/`, `scripts/`, and `data/` stay
|
||||||
|
together with the executable payload.
|
||||||
|
"""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(sys.executable)))
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_data_dir() -> str:
|
||||||
|
"""Return the default path to the data directory.
|
||||||
|
|
||||||
|
In normal runs, this is a 'data' subdirectory under the app root.
|
||||||
|
In frozen builds, it is a persistent user directory (~/.odysseus/data)
|
||||||
|
to prevent SQLite databases and other persistent files from being
|
||||||
|
written to the ephemeral, temporary extraction bundle directory.
|
||||||
|
"""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return os.path.join(os.path.expanduser("~"), ".odysseus", "data")
|
||||||
|
return os.path.join(get_app_root(), "data")
|
||||||
+76
-14
@@ -9,6 +9,8 @@ import uuid
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +19,34 @@ def _utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
# Shell/file tools a scheduled task's agent should be offered by default,
|
||||||
|
# mirroring the chat agent (where these are on unless a privilege or global
|
||||||
|
# setting turns them off). The RAG tool selector + ASSISTANT_ALWAYS_AVAILABLE
|
||||||
|
# never include bash/python, so on a host with an empty/degraded tool-embedding
|
||||||
|
# index a task could not run shell or Python even for an admin owner. Offering
|
||||||
|
# them here is safe: stream_agent_loop's blocked_tools_for_owner() still strips
|
||||||
|
# this whole group for non-admin multi-user owners, and only admits it for
|
||||||
|
# admins and single-user (AUTH_ENABLED=false) deployments.
|
||||||
|
TASK_DEFAULT_SHELL_TOOLS = frozenset({
|
||||||
|
"bash", "python", "read_file", "write_file", "edit_file",
|
||||||
|
"grep", "glob", "ls", "get_workspace",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def compose_task_relevant_tools(rag_tools, assistant_always, disabled_tools):
|
||||||
|
"""Compose the relevant-tools set offered to a scheduled task's agent.
|
||||||
|
|
||||||
|
Unions the RAG-retrieved tools, the assistant's always-available set, and
|
||||||
|
the default shell/file group, then removes anything the task's crew
|
||||||
|
explicitly disabled via its `enabled_tools` allowlist. Per-owner admin
|
||||||
|
gating is applied later by stream_agent_loop (blocked_tools_for_owner).
|
||||||
|
"""
|
||||||
|
tools = set(rag_tools) | set(assistant_always) | set(TASK_DEFAULT_SHELL_TOOLS)
|
||||||
|
if disabled_tools:
|
||||||
|
tools -= set(disabled_tools)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
# ── Shared TTL cache (singleflight) ────────────────────────────────────────
|
# ── Shared TTL cache (singleflight) ────────────────────────────────────────
|
||||||
# Multiple scheduled tasks firing in the same minute often need the same
|
# Multiple scheduled tasks firing in the same minute often need the same
|
||||||
# external data (Miniflux unreads, MCP tool snapshots, etc.). This cache
|
# external data (Miniflux unreads, MCP tool snapshots, etc.). This cache
|
||||||
@@ -236,6 +266,29 @@ def _digest_windows(now):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _checkin_calendar_events(db, owner, start, end):
|
||||||
|
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
|
||||||
|
|
||||||
|
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
||||||
|
The digest query had no owner scope, so it pulled EVERY user's events into
|
||||||
|
one user's check-in (a cross-tenant leak of summaries/locations). Scope it
|
||||||
|
by joining CalendarCal, mirroring routes/calendar_routes.list_events.
|
||||||
|
"""
|
||||||
|
from core.database import CalendarEvent as _CE, CalendarCal as _CC
|
||||||
|
return (
|
||||||
|
db.query(_CE)
|
||||||
|
.join(_CC, _CE.calendar_id == _CC.id)
|
||||||
|
.filter(
|
||||||
|
_CC.owner == owner,
|
||||||
|
_CE.dtstart >= start,
|
||||||
|
_CE.dtstart <= end,
|
||||||
|
_CE.status != "cancelled",
|
||||||
|
)
|
||||||
|
.order_by(_CE.dtstart)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TaskScheduler:
|
class TaskScheduler:
|
||||||
def __init__(self, session_manager):
|
def __init__(self, session_manager):
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
@@ -1127,11 +1180,7 @@ class TaskScheduler:
|
|||||||
# Strip timezone for naive DB comparison
|
# Strip timezone for naive DB comparison
|
||||||
_s = start.replace(tzinfo=None) if start.tzinfo else start
|
_s = start.replace(tzinfo=None) if start.tzinfo else start
|
||||||
_e = end.replace(tzinfo=None) if end.tzinfo else end
|
_e = end.replace(tzinfo=None) if end.tzinfo else end
|
||||||
evs = _db.query(_CE).filter(
|
evs = _checkin_calendar_events(_db, task.owner, _s, _e)
|
||||||
_CE.dtstart >= _s,
|
|
||||||
_CE.dtstart <= _e,
|
|
||||||
_CE.status != "cancelled",
|
|
||||||
).order_by(_CE.dtstart).all()
|
|
||||||
if not evs:
|
if not evs:
|
||||||
continue
|
continue
|
||||||
# Group by importance for richer output
|
# Group by importance for richer output
|
||||||
@@ -1370,17 +1419,30 @@ class TaskScheduler:
|
|||||||
time_str = _utcnow().strftime("%A, %B %d %Y, %H:%M UTC")
|
time_str = _utcnow().strftime("%A, %B %d %Y, %H:%M UTC")
|
||||||
system_prompt = f"Current time: {time_str}\n\n{system_prompt}"
|
system_prompt = f"Current time: {time_str}\n\n{system_prompt}"
|
||||||
|
|
||||||
# Compute tool filter from CrewMember.enabled_tools if set
|
# Compute the disabled-tools set: the crew's enabled_tools allowlist
|
||||||
disabled_tools = None
|
# (inverted) plus the operator's global disabled_tools setting. The
|
||||||
|
# global list must be merged here — chat does the same merge before
|
||||||
|
# entering the agent loop (routes/chat_routes.py) — otherwise an admin
|
||||||
|
# or AUTH_ENABLED=false scheduled task would still see and call shell/
|
||||||
|
# file tools after the operator disabled them globally, because the
|
||||||
|
# prompt/schema/execution gates only enforce what is passed in.
|
||||||
|
disabled_tools: set[str] = set()
|
||||||
if crew and crew.enabled_tools:
|
if crew and crew.enabled_tools:
|
||||||
try:
|
try:
|
||||||
enabled = json.loads(crew.enabled_tools)
|
enabled = json.loads(crew.enabled_tools)
|
||||||
if isinstance(enabled, list) and enabled:
|
if isinstance(enabled, list) and enabled:
|
||||||
from src.tool_index import BUILTIN_TOOL_DESCRIPTIONS
|
from src.tool_index import BUILTIN_TOOL_DESCRIPTIONS
|
||||||
all_tools = set(BUILTIN_TOOL_DESCRIPTIONS.keys())
|
all_tools = set(BUILTIN_TOOL_DESCRIPTIONS.keys())
|
||||||
disabled_tools = all_tools - set(enabled)
|
disabled_tools |= all_tools - set(enabled)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
from src.settings import get_setting
|
||||||
|
_global_disabled = get_setting("disabled_tools", [])
|
||||||
|
if isinstance(_global_disabled, list):
|
||||||
|
disabled_tools.update(_global_disabled)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# RAG-select relevant tools for this prompt + always-available assistant tools.
|
# RAG-select relevant tools for this prompt + always-available assistant tools.
|
||||||
# Without this, all 40+ tools get sent and models hit their tool limit.
|
# Without this, all 40+ tools get sent and models hit their tool limit.
|
||||||
@@ -1390,10 +1452,10 @@ class TaskScheduler:
|
|||||||
tool_idx = get_tool_index()
|
tool_idx = get_tool_index()
|
||||||
if tool_idx:
|
if tool_idx:
|
||||||
rag_tools = tool_idx.get_tools_for_query(task.prompt or "", k=8)
|
rag_tools = tool_idx.get_tools_for_query(task.prompt or "", k=8)
|
||||||
relevant_tools = (rag_tools | ASSISTANT_ALWAYS_AVAILABLE)
|
relevant_tools = compose_task_relevant_tools(
|
||||||
if disabled_tools:
|
rag_tools, ASSISTANT_ALWAYS_AVAILABLE, disabled_tools
|
||||||
relevant_tools -= disabled_tools
|
)
|
||||||
logger.info(f"[assistant] RAG selected {len(rag_tools)} tools + {len(ASSISTANT_ALWAYS_AVAILABLE)} always-available = {len(relevant_tools)} total for '{task.name}'")
|
logger.info(f"[assistant] RAG selected {len(rag_tools)} tools + {len(ASSISTANT_ALWAYS_AVAILABLE)} always-available + shell/file defaults = {len(relevant_tools)} total for '{task.name}'")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[assistant] RAG tool selection failed, using all: {e}")
|
logger.warning(f"[assistant] RAG tool selection failed, using all: {e}")
|
||||||
|
|
||||||
@@ -1401,7 +1463,7 @@ class TaskScheduler:
|
|||||||
try:
|
try:
|
||||||
result = await self._run_agent_loop(
|
result = await self._run_agent_loop(
|
||||||
endpoint_url, model, task, session_id,
|
endpoint_url, model, task, session_id,
|
||||||
system_prompt=system_prompt, disabled_tools=disabled_tools,
|
system_prompt=system_prompt, disabled_tools=disabled_tools or None,
|
||||||
relevant_tools=relevant_tools,
|
relevant_tools=relevant_tools,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2202,7 +2264,7 @@ class TaskScheduler:
|
|||||||
# check-ins seeded, which then double-fire alongside the human user's
|
# 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'
|
# check-ins. This was the root cause of the duplicate 'Morning check-in'
|
||||||
# rows we had to manually clean up.
|
# rows we had to manually clean up.
|
||||||
if not owner or owner in {"internal-tool", "api", "demo", "system"}:
|
if not owner or owner in RESERVED_USERNAMES:
|
||||||
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
||||||
return
|
return
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
|
|||||||
+38
-8
@@ -323,6 +323,24 @@ _MCP_TOOL_MAP = {
|
|||||||
"web_fetch": ("web_fetch", "web_fetch"),
|
"web_fetch": ("web_fetch", "web_fetch"),
|
||||||
"generate_image": ("image_gen", "generate_image"),
|
"generate_image": ("image_gen", "generate_image"),
|
||||||
}
|
}
|
||||||
|
_EMAIL_MCP_OWNER_ARG = "_odysseus_owner"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_qualified_mcp_args(tool: str, content: str) -> tuple[Dict, Optional[str]]:
|
||||||
|
raw = (content or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {}, None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
if tool.startswith("mcp__email__"):
|
||||||
|
return {}, "Email MCP tool arguments must be a JSON object."
|
||||||
|
return {}, None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
if tool.startswith("mcp__email__"):
|
||||||
|
return {}, "Email MCP tool arguments must be a JSON object."
|
||||||
|
return {}, None
|
||||||
|
return parsed, None
|
||||||
|
|
||||||
|
|
||||||
def _parse_generate_image(content: str) -> Dict:
|
def _parse_generate_image(content: str) -> Dict:
|
||||||
@@ -748,10 +766,19 @@ async def _execute_tool_block_impl(
|
|||||||
query = content.split("\n")[0].strip()
|
query = content.split("\n")[0].strip()
|
||||||
desc = f"search_chats: {query[:80]}"
|
desc = f"search_chats: {query[:80]}"
|
||||||
result = await do_search_chats(query, owner=owner)
|
result = await do_search_chats(query, owner=owner)
|
||||||
elif tool in ("chat_with_model", "create_session", "list_sessions",
|
elif tool in ("chat_with_model", "ask_teacher", "list_models"):
|
||||||
|
# Migrated to the agent_tools registry (#3629): dispatched through
|
||||||
|
# TOOL_HANDLERS with the owner/session ctx these tools need, instead
|
||||||
|
# of the legacy dispatch_ai_tool elif. The do_* impls stay in
|
||||||
|
# ai_interaction.py (dispatch_ai_tool + the owner-scope test use them).
|
||||||
|
first_line = content.split(chr(10))[0].strip()[:60]
|
||||||
|
desc = f"{tool}: {first_line}" if first_line else tool
|
||||||
|
result = await _document_tool_dispatch(tool, content, session_id, owner) \
|
||||||
|
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||||
|
elif tool in ("create_session", "list_sessions",
|
||||||
"send_to_session", "pipeline",
|
"send_to_session", "pipeline",
|
||||||
"manage_session", "manage_memory", "list_models",
|
"manage_session", "manage_memory",
|
||||||
"ui_control", "ask_teacher"):
|
"ui_control"):
|
||||||
from src.ai_interaction import dispatch_ai_tool
|
from src.ai_interaction import dispatch_ai_tool
|
||||||
desc, result = await dispatch_ai_tool(tool, content, session_id, owner=owner)
|
desc, result = await dispatch_ai_tool(tool, content, session_id, owner=owner)
|
||||||
elif tool == "manage_tasks":
|
elif tool == "manage_tasks":
|
||||||
@@ -858,12 +885,15 @@ async def _execute_tool_block_impl(
|
|||||||
# MCP tool dispatch
|
# MCP tool dispatch
|
||||||
mcp = get_mcp_manager()
|
mcp = get_mcp_manager()
|
||||||
if mcp:
|
if mcp:
|
||||||
try:
|
|
||||||
args = json.loads(content) if content.strip().startswith("{") else {}
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
args = {}
|
|
||||||
desc = f"mcp: {tool}"
|
desc = f"mcp: {tool}"
|
||||||
result = await mcp.call_tool(tool, args)
|
args, parse_error = _parse_qualified_mcp_args(tool, content)
|
||||||
|
if parse_error:
|
||||||
|
result = {"error": parse_error, "exit_code": 1}
|
||||||
|
else:
|
||||||
|
if tool.startswith("mcp__email__") and owner:
|
||||||
|
args = dict(args)
|
||||||
|
args[_EMAIL_MCP_OWNER_ARG] = owner
|
||||||
|
result = await mcp.call_tool(tool, args)
|
||||||
else:
|
else:
|
||||||
desc = f"mcp: {tool}"
|
desc = f"mcp: {tool}"
|
||||||
result = {"error": "MCP manager not available", "exit_code": 1}
|
result = {"error": "MCP manager not available", "exit_code": 1}
|
||||||
|
|||||||
+154
-6
@@ -645,6 +645,137 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
|
|||||||
# MCP server management tool
|
# MCP server management tool
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
|
||||||
|
# opposite policy: that gate guards an admin-only serve command and allows
|
||||||
|
# interpreters (python3/etc) because model-serving needs them, whereas this is
|
||||||
|
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
|
||||||
|
# runners are denied here.
|
||||||
|
#
|
||||||
|
# Commands that can execute arbitrary code regardless of their arguments. These
|
||||||
|
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
|
||||||
|
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
|
||||||
|
# interpreter or package runner must be registered via the trusted admin route.
|
||||||
|
_MCP_DENIED_COMMANDS = frozenset({
|
||||||
|
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
|
||||||
|
"cmd", "command.com", "powershell", "pwsh",
|
||||||
|
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
|
||||||
|
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
|
||||||
|
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
|
||||||
|
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
|
||||||
|
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
|
||||||
|
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
|
||||||
|
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
|
||||||
|
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
|
||||||
|
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
|
||||||
|
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
|
||||||
|
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Argv flags that make even an allowlisted binary execute inline code. Matched
|
||||||
|
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
|
||||||
|
# exact-token form.
|
||||||
|
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
|
||||||
|
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
|
||||||
|
|
||||||
|
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
|
||||||
|
|
||||||
|
# Shell metacharacters refused in command/args. Args are passed as an argv list
|
||||||
|
# (no shell), but refusing these keeps the surface narrow and obvious.
|
||||||
|
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
|
||||||
|
|
||||||
|
# Env vars that let a child process load attacker-supplied code before main().
|
||||||
|
_MCP_DANGEROUS_ENV = frozenset({
|
||||||
|
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
|
||||||
|
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
|
||||||
|
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
|
||||||
|
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
|
||||||
|
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_allowed_commands() -> set:
|
||||||
|
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
|
||||||
|
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
|
||||||
|
to opt specific trusted binaries in. Denied commands are rejected even if
|
||||||
|
listed here."""
|
||||||
|
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
|
||||||
|
return {c.strip().lower() for c in raw.split(",") if c.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mcp_command(command, args, env) -> Optional[str]:
|
||||||
|
"""Validate a model-supplied stdio MCP registration. Returns an error string
|
||||||
|
if it must be rejected, else None.
|
||||||
|
|
||||||
|
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
|
||||||
|
command/args/env straight to a subprocess spawn (issue #438): a payload
|
||||||
|
smuggled into a skill description, memory entry, fetched page, or email body
|
||||||
|
could register a stdio server running arbitrary code as the app UID.
|
||||||
|
"""
|
||||||
|
if not isinstance(command, str) or not command.strip():
|
||||||
|
return "command must be a non-empty string"
|
||||||
|
command = command.strip()
|
||||||
|
if "/" in command or "\\" in command:
|
||||||
|
return "command must be a bare executable name, not a path"
|
||||||
|
if any(ch in _MCP_SHELL_METACHARS for ch in command):
|
||||||
|
return "command contains shell metacharacters"
|
||||||
|
base = command.lower()
|
||||||
|
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
|
||||||
|
base = base.rsplit(".", 1)[0]
|
||||||
|
# Canonicalize a trailing version suffix so versioned aliases collapse to the
|
||||||
|
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
|
||||||
|
# raw basename and the canonical form are denied, so an operator cannot
|
||||||
|
# accidentally allowlist a runtime alias back into the path.
|
||||||
|
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
|
||||||
|
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
|
||||||
|
return (
|
||||||
|
f"command '{command}' is not allowed on the agent MCP path: "
|
||||||
|
"interpreters, runtimes, package runners, and shells can execute "
|
||||||
|
"arbitrary code. Register such a server via the admin route instead."
|
||||||
|
)
|
||||||
|
if base not in _mcp_allowed_commands():
|
||||||
|
return (
|
||||||
|
f"command '{command}' is not in the MCP allowlist. Add it to "
|
||||||
|
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
|
||||||
|
"server via the admin route."
|
||||||
|
)
|
||||||
|
|
||||||
|
if args is not None:
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except Exception:
|
||||||
|
return "args must be a JSON list"
|
||||||
|
if not isinstance(args, list):
|
||||||
|
return "args must be a list"
|
||||||
|
for a in args:
|
||||||
|
if not isinstance(a, str):
|
||||||
|
return "args must all be strings"
|
||||||
|
s = a.strip()
|
||||||
|
low = s.lower()
|
||||||
|
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
|
||||||
|
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||||
|
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
|
||||||
|
return f"arg '{a}' is a code-execution flag and is not allowed"
|
||||||
|
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
|
||||||
|
return f"arg '{a}' is a remote URL and is not allowed"
|
||||||
|
if any(ch in _MCP_SHELL_METACHARS for ch in a):
|
||||||
|
return f"arg '{a}' contains shell metacharacters"
|
||||||
|
|
||||||
|
if env:
|
||||||
|
if isinstance(env, str):
|
||||||
|
try:
|
||||||
|
env = json.loads(env)
|
||||||
|
except Exception:
|
||||||
|
return "env must be a JSON object"
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
return "env must be an object"
|
||||||
|
for k in env:
|
||||||
|
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
|
||||||
|
return f"env var '{k}' can inject code into the child process and is not allowed"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
||||||
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
|
||||||
try:
|
try:
|
||||||
@@ -684,6 +815,12 @@ async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
env = args.get("env", {})
|
env = args.get("env", {})
|
||||||
if not name or not command:
|
if not name or not command:
|
||||||
return {"error": "name and command are required", "exit_code": 1}
|
return {"error": "name and command are required", "exit_code": 1}
|
||||||
|
# Validate BEFORE any DB write or spawn: a rejected registration must
|
||||||
|
# leave no enabled row (which would otherwise auto-reconnect on restart)
|
||||||
|
# and must not attempt a connection.
|
||||||
|
_mcp_err = _validate_mcp_command(command, cmd_args, env)
|
||||||
|
if _mcp_err:
|
||||||
|
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
|
||||||
sid = str(_uuid.uuid4())[:8]
|
sid = str(_uuid.uuid4())[:8]
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -1579,10 +1716,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
text = str(raw).strip().lower()
|
text = str(raw).strip().lower()
|
||||||
if text in {"none", "no", "off", "false"}:
|
if text in {"none", "no", "off", "false"}:
|
||||||
return None
|
return None
|
||||||
m = re.search(r"(\d+)\s*(?:m|min|minute|minutes)\b", text)
|
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||||
if m:
|
if m:
|
||||||
return max(0, int(m.group(1)))
|
return max(0, int(m.group(1)))
|
||||||
m = re.search(r"(\d+)\s*(?:h|hr|hour|hours)\b", text)
|
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||||
if m:
|
if m:
|
||||||
return max(0, int(m.group(1)) * 60)
|
return max(0, int(m.group(1)) * 60)
|
||||||
if text.isdigit():
|
if text.isdigit():
|
||||||
@@ -1595,7 +1732,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return desc
|
return desc
|
||||||
reminder_only = re.compile(
|
reminder_only = re.compile(
|
||||||
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
||||||
r"(?:m|min|minute|minutes|h|hr|hour|hours)\b.*$",
|
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
return "" if reminder_only.match(desc) else desc
|
return "" if reminder_only.match(desc) else desc
|
||||||
@@ -3797,7 +3934,7 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if not name:
|
if not name:
|
||||||
return {"error": "name is required", "exit_code": 1}
|
return {"error": "name is required", "exit_code": 1}
|
||||||
|
|
||||||
contacts = {} # email -> {name, source}
|
contacts = {} # email_or_phone -> {name, source, phone?}
|
||||||
|
|
||||||
# 1. CardDAV (Radicale) — structured contacts. Call in-process: a
|
# 1. CardDAV (Radicale) — structured contacts. Call in-process: a
|
||||||
# server-side httpx GET to /api/contacts/search carries no session
|
# server-side httpx GET to /api/contacts/search carries no session
|
||||||
@@ -3812,10 +3949,18 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
match = q in hay_name or any(q in (e or "").lower() for e in c.get("emails", []))
|
match = q in hay_name or any(q in (e or "").lower() for e in c.get("emails", []))
|
||||||
if not match:
|
if not match:
|
||||||
continue
|
continue
|
||||||
|
has_email = False
|
||||||
for email in (c.get("emails") or []):
|
for email in (c.get("emails") or []):
|
||||||
email = (email or "").strip().lower()
|
email = (email or "").strip().lower()
|
||||||
if email and "@" in email:
|
if email and "@" in email:
|
||||||
contacts[email] = {"name": c.get("name") or email, "source": "contacts"}
|
contacts[email] = {"name": c.get("name") or email, "source": "contacts"}
|
||||||
|
has_email = True
|
||||||
|
# Fall back to phone numbers when the contact has no email address
|
||||||
|
if not has_email:
|
||||||
|
for phone in (c.get("phones") or []):
|
||||||
|
phone = (phone or "").strip()
|
||||||
|
if phone:
|
||||||
|
contacts[phone] = {"name": c.get("name") or phone, "source": "contacts", "phone": phone}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -3835,8 +3980,11 @@ async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return {"output": f"No contacts found matching '{name}'.", "exit_code": 0}
|
return {"output": f"No contacts found matching '{name}'.", "exit_code": 0}
|
||||||
|
|
||||||
lines = [f"Contacts matching '{name}':"]
|
lines = [f"Contacts matching '{name}':"]
|
||||||
for email, info in contacts.items():
|
for key, info in contacts.items():
|
||||||
lines.append(f"- {info['name']} <{email}> ({info['source']})")
|
if info.get("phone"):
|
||||||
|
lines.append(f"- {info['name']} — phone: {info['phone']} ({info['source']})")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {info['name']} <{key}> ({info['source']})")
|
||||||
return {"output": "\n".join(lines), "exit_code": 0}
|
return {"output": "\n".join(lines), "exit_code": 0}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -68,11 +68,12 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "web_fetch",
|
"name": "web_fetch",
|
||||||
"description": "Fetch and read the text content of a specific URL the user names (e.g. 'check example.com', 'what's on this page <url>'). Use when you already have a concrete URL/domain. NOT for open-ended searches (use web_search) or 'research X' jobs (use trigger_research).",
|
"description": "Fetch and read the text content of a specific URL the user names (e.g. 'check example.com', 'what's on this page <url>'). Use when you already have a concrete URL/domain. NOT for open-ended searches (use web_search) or 'research X' jobs (use trigger_research). Downloads are size-budgeted; a '[partial content: ...]' notice in the result means the body was cut short and you can re-call with full=true for the rest.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"url": {"type": "string", "description": "The URL or domain to fetch (http/https; a bare domain like example.com is fine)"}
|
"url": {"type": "string", "description": "The URL or domain to fetch (http/https; a bare domain like example.com is fine)"},
|
||||||
|
"full": {"type": "boolean", "description": "Raise the download budget to the hard cap for large pages/files. Use only after a result reported partial content."}
|
||||||
},
|
},
|
||||||
"required": ["url"]
|
"required": ["url"]
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1009,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "resolve_contact",
|
"name": "resolve_contact",
|
||||||
"description": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]' or 'email [name]' without an email address.",
|
"description": "Look up a contact by name. Searches CardDAV address book and sent email history. Returns email addresses (when available) or phone numbers. Use when the user says 'message [name]', 'email [name]', or asks for someone's contact details.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1205,23 +1206,26 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
logger.error(f"Failed to parse function call arguments for {name}: {arguments}")
|
logger.error(f"Failed to parse function call arguments for {name}: {arguments}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
tool_type = _TOOL_NAME_MAP.get(name, name)
|
||||||
|
_BUILTIN_EMAIL_TOOLS = {"list_email_accounts", "send_email", "list_emails", "read_email", "reply_to_email",
|
||||||
|
"archive_email", "delete_email", "mark_email_read", "bulk_email", "download_attachment"}
|
||||||
|
|
||||||
# Some models emit valid JSON that isn't an object (e.g. a bare array
|
# Some models emit valid JSON that isn't an object (e.g. a bare array
|
||||||
# ["ls -la"], string, or number) as the function arguments. Every branch
|
# ["ls -la"], string, or number) as function arguments. Most local tools keep
|
||||||
# below assumes a dict and calls args.get(...), so a non-dict would raise
|
# the legacy empty-object coercion for stream robustness, but email MCP tools
|
||||||
# AttributeError and abort the whole agent stream. Coerce to {} instead.
|
# must fail closed so a malformed call cannot read the default mailbox.
|
||||||
if not isinstance(args, dict):
|
if not isinstance(args, dict):
|
||||||
|
if tool_type.startswith("mcp__email__") or name in _BUILTIN_EMAIL_TOOLS:
|
||||||
|
logger.warning(f"Non-object email function call arguments for {name}: {args!r}; rejecting")
|
||||||
|
return None
|
||||||
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
|
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
|
||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
tool_type = _TOOL_NAME_MAP.get(name, name)
|
|
||||||
|
|
||||||
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
|
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
|
||||||
if tool_type.startswith("mcp__"):
|
if tool_type.startswith("mcp__"):
|
||||||
content = json.dumps(args) if args else "{}"
|
content = json.dumps(args) if args else "{}"
|
||||||
return ToolBlock(tool_type, content)
|
return ToolBlock(tool_type, content)
|
||||||
# Email tools are implemented as MCP — route them to email
|
# Email tools are implemented as MCP — route them to email
|
||||||
_BUILTIN_EMAIL_TOOLS = {"list_email_accounts", "send_email", "list_emails", "read_email", "reply_to_email",
|
|
||||||
"archive_email", "delete_email", "mark_email_read", "bulk_email", "download_attachment"}
|
|
||||||
if name in _BUILTIN_EMAIL_TOOLS:
|
if name in _BUILTIN_EMAIL_TOOLS:
|
||||||
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
|
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
|
||||||
if tool_type not in TOOL_TAGS:
|
if tool_type not in TOOL_TAGS:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 174 B |
+2
-2
@@ -1913,7 +1913,7 @@
|
|||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
||||||
<div class="settings-col">
|
<div class="settings-col">
|
||||||
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||||
<input id="settings-pw-new" type="password" placeholder="New password (min 8)" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
<input id="settings-pw-new" type="password" placeholder="New password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||||
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||||
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
||||||
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||||
@@ -2049,7 +2049,7 @@
|
|||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
||||||
<div class="admin-add-form">
|
<div class="admin-add-form">
|
||||||
<input id="adm-newUsername" type="text" placeholder="Username">
|
<input id="adm-newUsername" type="text" placeholder="Username">
|
||||||
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
|
<input id="adm-newPassword" type="password" placeholder="Password">
|
||||||
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row" style="margin-top:6px;">
|
<div class="settings-row" style="margin-top:6px;">
|
||||||
|
|||||||
+12
-2
@@ -13,6 +13,7 @@ let modalEl = null;
|
|||||||
// the endpoints list can flash a glow on that row. Cleared once the
|
// the endpoints list can flash a glow on that row. Cleared once the
|
||||||
// animation fires.
|
// animation fires.
|
||||||
let _recentlyAddedEpId = null;
|
let _recentlyAddedEpId = null;
|
||||||
|
let _authPolicy = { password_min_length: 8, reserved_usernames: [] };
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
function el(id) { return document.getElementById(id); }
|
||||||
function esc(s) { return uiModule.esc(s); }
|
function esc(s) { return uiModule.esc(s); }
|
||||||
@@ -343,6 +344,15 @@ function initSignupToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initAddUser() {
|
function initAddUser() {
|
||||||
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(policy => {
|
||||||
|
if (!policy) return;
|
||||||
|
_authPolicy = policy;
|
||||||
|
const admPw = el('adm-newPassword');
|
||||||
|
if (admPw) admPw.placeholder = `Password (min ${policy.password_min_length})`;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
el('adm-addBtn').addEventListener('click', async () => {
|
el('adm-addBtn').addEventListener('click', async () => {
|
||||||
const msg = el('adm-addMsg');
|
const msg = el('adm-addMsg');
|
||||||
msg.textContent = ''; msg.className = '';
|
msg.textContent = ''; msg.className = '';
|
||||||
@@ -350,7 +360,8 @@ function initAddUser() {
|
|||||||
const password = el('adm-newPassword').value;
|
const password = el('adm-newPassword').value;
|
||||||
const is_admin = el('adm-newIsAdmin').checked;
|
const is_admin = el('adm-newIsAdmin').checked;
|
||||||
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
|
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
|
||||||
if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; }
|
if (password.length < _authPolicy.password_min_length) { msg.textContent = `Password must be at least ${_authPolicy.password_min_length} characters`; msg.className = 'admin-error'; return; }
|
||||||
|
if (_authPolicy.reserved_usernames.includes(username.toLowerCase())) { msg.textContent = 'This username is reserved'; msg.className = 'admin-error'; return; }
|
||||||
el('adm-addBtn').disabled = true;
|
el('adm-addBtn').disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
|
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
|
||||||
@@ -1745,7 +1756,6 @@ const TOOL_META = {
|
|||||||
manage_skills: { name: 'Skills', desc: 'Learn and use procedures', cat: 'Knowledge', ctx: '~200' },
|
manage_skills: { name: 'Skills', desc: 'Learn and use procedures', cat: 'Knowledge', ctx: '~200' },
|
||||||
manage_rag: { name: 'RAG / Docs', desc: 'Query indexed documents', cat: 'Knowledge', ctx: '~150' },
|
manage_rag: { name: 'RAG / Docs', desc: 'Query indexed documents', cat: 'Knowledge', ctx: '~150' },
|
||||||
chat_with_model: { name: 'Chat with Model', desc: 'Talk to another AI model', cat: 'Multi-Agent', ctx: '~200' },
|
chat_with_model: { name: 'Chat with Model', desc: 'Talk to another AI model', cat: 'Multi-Agent', ctx: '~200' },
|
||||||
second_opinion: { name: 'Second Opinion', desc: 'Get another model\'s take', cat: 'Multi-Agent', ctx: '~150' },
|
|
||||||
pipeline: { name: 'Pipeline', desc: 'Multi-step AI workflows', cat: 'Multi-Agent', ctx: '~200' },
|
pipeline: { name: 'Pipeline', desc: 'Multi-step AI workflows', cat: 'Multi-Agent', ctx: '~200' },
|
||||||
ask_teacher: { name: 'Ask Teacher', desc: 'Query a more capable model', cat: 'Multi-Agent', ctx: '~150' },
|
ask_teacher: { name: 'Ask Teacher', desc: 'Query a more capable model', cat: 'Multi-Agent', ctx: '~150' },
|
||||||
send_to_session: { name: 'Send to Session', desc: 'Send message to another chat', cat: 'Sessions', ctx: '~100' },
|
send_to_session: { name: 'Send to Session', desc: 'Send message to another chat', cat: 'Sessions', ctx: '~100' },
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const TOOL_GROUPS = {
|
|||||||
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
||||||
'Code': ['bash', 'python', 'write_file'],
|
'Code': ['bash', 'python', 'write_file'],
|
||||||
'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'],
|
'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'],
|
||||||
'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
|
'AI & Models': ['chat_with_model', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
|
||||||
'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'],
|
'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
|||||||
});
|
});
|
||||||
row.appendChild(btn);
|
row.appendChild(btn);
|
||||||
}
|
}
|
||||||
body.appendChild(row);
|
diag.appendChild(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2462,10 +2462,13 @@ export async function open(opts) {
|
|||||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||||
// Running tab is rendered separately just below.
|
// Running tab is rendered separately just below.
|
||||||
_renderRecipes();
|
// Guard the render passes: a single broken task card must not throw out of
|
||||||
|
// open() and leave the modal stuck hidden (it has no catch, so the panel
|
||||||
|
// would silently never appear). Show the window regardless; log and move on.
|
||||||
|
try { _renderRecipes(); } catch (e) { console.error('[cookbook] renderRecipes failed', e); }
|
||||||
_rendered = true;
|
_rendered = true;
|
||||||
_clearCookbookNotif();
|
_clearCookbookNotif();
|
||||||
_renderRunningTab();
|
try { _renderRunningTab(); } catch (e) { console.error('[cookbook] renderRunningTab failed', e); }
|
||||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||||
// but were persisted as done/error (covers the "restarted server while a
|
// but were persisted as done/error (covers the "restarted server while a
|
||||||
// big multi-shard download was in flight" case — the task survived in
|
// big multi-shard download was in flight" case — the task survived in
|
||||||
|
|||||||
+34
-31
@@ -116,13 +116,28 @@ function _selectedServeTarget(panel) {
|
|||||||
: (server?.name || 'local server');
|
: (server?.name || 'local server');
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
port: host ? (_getPort(host) || server?.port || '') : '',
|
port: host ? (server?.port || _getPort(host) || '') : '',
|
||||||
|
env: server?.env || '',
|
||||||
venv,
|
venv,
|
||||||
platform: server?.platform || _envState.platform || '',
|
platform: server?.platform || _envState.platform || '',
|
||||||
label,
|
label,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _remoteWindowsDiffusersUnsupported(target) {
|
||||||
|
return !!(target?.host && target?.platform === 'windows');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _backendChoicesForTarget(target) {
|
||||||
|
if (target?.platform === 'windows') {
|
||||||
|
if (_remoteWindowsDiffusersUnsupported(target)) return [['llamacpp','llama.cpp']];
|
||||||
|
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
|
||||||
|
}
|
||||||
|
return _isMetal()
|
||||||
|
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||||
|
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
|
||||||
|
}
|
||||||
|
|
||||||
async function _fetchServeRuntimePackage(panel, backend) {
|
async function _fetchServeRuntimePackage(panel, backend) {
|
||||||
const packageByBackend = {
|
const packageByBackend = {
|
||||||
vllm: 'vllm',
|
vllm: 'vllm',
|
||||||
@@ -529,13 +544,14 @@ function _rerenderCachedModels() {
|
|||||||
const ss = (_byRepo[repo] && typeof _byRepo[repo] === 'object')
|
const ss = (_byRepo[repo] && typeof _byRepo[repo] === 'object')
|
||||||
? _byRepo[repo]
|
? _byRepo[repo]
|
||||||
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
||||||
|
const _serveTarget = _selectedServeTarget();
|
||||||
|
const _backendChoices = _backendChoicesForTarget(_serveTarget);
|
||||||
|
const _allowedBackends = new Set(_backendChoices.map(([v]) => v));
|
||||||
const detectedBackend = _detectBackend(m).backend;
|
const detectedBackend = _detectBackend(m).backend;
|
||||||
const _allowedBackends = new Set(_isWindows()
|
let defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||||
? ['llamacpp', 'diffusers']
|
|
||||||
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
|
||||||
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
|
||||||
? ss.backend
|
? ss.backend
|
||||||
: detectedBackend;
|
: detectedBackend;
|
||||||
|
if (!_allowedBackends.has(defaultBackend)) defaultBackend = _backendChoices[0]?.[0] || detectedBackend;
|
||||||
const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend;
|
const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend;
|
||||||
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
||||||
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1');
|
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1');
|
||||||
@@ -607,12 +623,6 @@ function _rerenderCachedModels() {
|
|||||||
}
|
}
|
||||||
// Row 1: Backend + Server + Env
|
// Row 1: Backend + Server + Env
|
||||||
panelHtml += `<div class="hwfit-serve-row">`;
|
panelHtml += `<div class="hwfit-serve-row">`;
|
||||||
const _backendChoices = _isWindows()
|
|
||||||
? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]
|
|
||||||
: _isMetal()
|
|
||||||
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
|
|
||||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
|
||||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
|
|
||||||
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
|
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
|
||||||
// Custom Backend picker — native <select> can't host SVG inside
|
// Custom Backend picker — native <select> can't host SVG inside
|
||||||
// options, so we render a button + menu that show the backend logo
|
// options, so we render a button + menu that show the backend logo
|
||||||
@@ -1971,6 +1981,12 @@ function _rerenderCachedModels() {
|
|||||||
else serveState[el.dataset.field] = el.value;
|
else serveState[el.dataset.field] = el.value;
|
||||||
});
|
});
|
||||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||||
|
const launchTarget = _selectedServeTarget(panel);
|
||||||
|
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||||
|
_restoreLaunchBtn();
|
||||||
|
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-launch: check our own task list for a serve already running
|
// Pre-launch: check our own task list for a serve already running
|
||||||
// on this host. Offer to stop+launch as the default action — the
|
// on this host. Offer to stop+launch as the default action — the
|
||||||
@@ -1979,7 +1995,7 @@ function _rerenderCachedModels() {
|
|||||||
// common case instantly without waiting for a network round-trip.
|
// common case instantly without waiting for a network round-trip.
|
||||||
try {
|
try {
|
||||||
const _runningMod = await import('./cookbookRunning.js');
|
const _runningMod = await import('./cookbookRunning.js');
|
||||||
const _hostStr = _envState.remoteHost || '';
|
const _hostStr = launchTarget.host || '';
|
||||||
const _active = (_runningMod._loadTasks ? _runningMod._loadTasks() : []).filter(t =>
|
const _active = (_runningMod._loadTasks ? _runningMod._loadTasks() : []).filter(t =>
|
||||||
t && t.type === 'serve'
|
t && t.type === 'serve'
|
||||||
&& (t.remoteHost || '') === _hostStr
|
&& (t.remoteHost || '') === _hostStr
|
||||||
@@ -2033,12 +2049,11 @@ function _rerenderCachedModels() {
|
|||||||
|| (serveState.backend === 'diffusers');
|
|| (serveState.backend === 'diffusers');
|
||||||
if (_needsGpu) {
|
if (_needsGpu) {
|
||||||
try {
|
try {
|
||||||
const _probeHost = (_envState.remoteHost || '').trim();
|
const _probeHost = (launchTarget.host || '').trim();
|
||||||
const _probeParams = new URLSearchParams();
|
const _probeParams = new URLSearchParams();
|
||||||
if (_probeHost) {
|
if (_probeHost) {
|
||||||
_probeParams.set('host', _probeHost);
|
_probeParams.set('host', _probeHost);
|
||||||
const _sp = (_serverByVal?.(_envState.remoteServerKey || _probeHost) || {}).port;
|
if (launchTarget.port) _probeParams.set('ssh_port', launchTarget.port);
|
||||||
if (_sp) _probeParams.set('ssh_port', _sp);
|
|
||||||
}
|
}
|
||||||
const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' });
|
const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' });
|
||||||
const _probeData = await _probeRes.json();
|
const _probeData = await _probeRes.json();
|
||||||
@@ -2071,10 +2086,10 @@ function _rerenderCachedModels() {
|
|||||||
|| launchCmd.match(/OLLAMA_HOST=[^:\s]+:(\d{2,5})\b/);
|
|| launchCmd.match(/OLLAMA_HOST=[^:\s]+:(\d{2,5})\b/);
|
||||||
const _port = _portMatch ? _portMatch[1] : '';
|
const _port = _portMatch ? _portMatch[1] : '';
|
||||||
if (_port) {
|
if (_port) {
|
||||||
const _portHost = (_envState.remoteHost || '').trim();
|
const _portHost = (launchTarget.host || '').trim();
|
||||||
const _checkInner = `ss -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}' || netstat -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}'`;
|
const _checkInner = `ss -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}' || netstat -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}'`;
|
||||||
const _cmd = _portHost
|
const _cmd = _portHost
|
||||||
? `ss h ${_portHost} <<<"" 2>/dev/null; ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_portHost} ${JSON.stringify(_checkInner)}`
|
? `ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_sshPrefix(launchTarget.port)}${_portHost} ${JSON.stringify(_checkInner)}`
|
||||||
: _checkInner;
|
: _checkInner;
|
||||||
const _res = await fetch('/api/shell/exec', {
|
const _res = await fetch('/api/shell/exec', {
|
||||||
method: 'POST', credentials: 'same-origin',
|
method: 'POST', credentials: 'same-origin',
|
||||||
@@ -2131,20 +2146,8 @@ function _rerenderCachedModels() {
|
|||||||
// Resolve the target host from the visible Server dropdown — the reliable
|
// Resolve the target host from the visible Server dropdown — the reliable
|
||||||
// source. Relying on _envState.remoteHost silently sent serves to Local
|
// source. Relying on _envState.remoteHost silently sent serves to Local
|
||||||
// when that value was stale/empty. Pass it explicitly to the launcher.
|
// when that value was stale/empty. Pass it explicitly to the launcher.
|
||||||
let serveHost = _envState.remoteHost || '';
|
let serveHost = launchTarget.host || '';
|
||||||
let _srvEnv = '', _srvEnvPath = '';
|
let _srvEnv = launchTarget.env || '', _srvEnvPath = launchTarget.venv || '';
|
||||||
const _ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
|
|
||||||
if (_ssEl && _ssEl.value != null) {
|
|
||||||
if (_ssEl.value === 'local') serveHost = '';
|
|
||||||
else {
|
|
||||||
const _srv = _serverByVal?.(_ssEl.value) || _envState.servers[parseInt(_ssEl.value)];
|
|
||||||
if (_srv) {
|
|
||||||
serveHost = _srv.host;
|
|
||||||
_srvEnv = _srv.env || '';
|
|
||||||
_srvEnvPath = _srv.envPath || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The venv field wins; otherwise fall back to the env configured for the
|
// The venv field wins; otherwise fall back to the env configured for the
|
||||||
// selected server in Settings, so the activation isn't silently dropped
|
// selected server in Settings, so the activation isn't silently dropped
|
||||||
// when the field is left blank (the per-server venv wasn't being applied).
|
// when the field is left blank (the per-server venv wasn't being applied).
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ import * as Modals from './modalManager.js';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _accountCanSend(account) {
|
function _accountCanSend(account) {
|
||||||
return !!(account && account.smtp_host && account.smtp_user && account.has_smtp_password);
|
if (!account || !account.smtp_host || !account.smtp_user) return false;
|
||||||
|
return !!(account.has_smtp_password || account.oauth_provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _resolveComposeSendAccountId() {
|
async function _resolveComposeSendAccountId() {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export function canvasCoords(e, canvas) {
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const scaleX = canvas.width / rect.width;
|
const scaleX = canvas.width / rect.width;
|
||||||
const scaleY = canvas.height / rect.height;
|
const scaleY = canvas.height / rect.height;
|
||||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
const clientY = e.touches && e.touches.length ? e.touches[0].clientY : e.clientY;
|
||||||
return {
|
return {
|
||||||
x: (clientX - rect.left) * scaleX,
|
x: (clientX - rect.left) * scaleX,
|
||||||
y: (clientY - rect.top) * scaleY,
|
y: (clientY - rect.top) * scaleY,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||||||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||||||
import { dismissOrRemove } from './escMenuStack.js';
|
import { dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { nextToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
||||||
|
|
||||||
@@ -63,7 +64,14 @@ function _applyRememberedDock(id) {
|
|||||||
// those statics and bump on every bring-to-front.
|
// those statics and bump on every bring-to-front.
|
||||||
let _modalTopZ = 300;
|
let _modalTopZ = 300;
|
||||||
function _bringToFront(modal) {
|
function _bringToFront(modal) {
|
||||||
if (modal) modal.style.setProperty('z-index', String(++_modalTopZ), 'important');
|
if (!modal) return;
|
||||||
|
const z = nextToolWindowZ({
|
||||||
|
exclude: modal,
|
||||||
|
current: getComputedStyle(modal).zIndex,
|
||||||
|
floor: _modalTopZ,
|
||||||
|
});
|
||||||
|
_modalTopZ = Math.max(_modalTopZ, z);
|
||||||
|
modal.style.setProperty('z-index', String(z), 'important');
|
||||||
}
|
}
|
||||||
|
|
||||||
function _emitModalOpened(id, modal) {
|
function _emitModalOpened(id, modal) {
|
||||||
|
|||||||
+26
-1
@@ -10,6 +10,7 @@ import { attachColorPicker } from './colorPicker.js';
|
|||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { snapModalToZone } from './tileManager.js';
|
import { snapModalToZone } from './tileManager.js';
|
||||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||||
|
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -200,6 +201,23 @@ function _restoreNotesSidebarDock(pane) {
|
|||||||
applyEdgeDock(pane, 'right');
|
applyEdgeDock(pane, 'right');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notes is not a `.modal`; its backdrop is the top-level stacking surface.
|
||||||
|
function _topToolWindowZ(exclude = null) {
|
||||||
|
return topToolWindowZ({ exclude });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _bringNotesToFront(pane = document.getElementById('notes-pane')) {
|
||||||
|
if (!pane) return;
|
||||||
|
const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement;
|
||||||
|
const z = _topToolWindowZ(backdrop) + 1;
|
||||||
|
if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important');
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:modal-opened', {
|
||||||
|
detail: { id: 'notes-panel', modal: pane },
|
||||||
|
}));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
function _loadPendingHighlights() {
|
function _loadPendingHighlights() {
|
||||||
try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
|
try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
|
||||||
catch { return new Set(); }
|
catch { return new Set(); }
|
||||||
@@ -1096,7 +1114,10 @@ export async function refreshDueBadge(opts = {}) {
|
|||||||
// ---- Panel ----
|
// ---- Panel ----
|
||||||
|
|
||||||
export function openPanel() {
|
export function openPanel() {
|
||||||
if (_open) return;
|
if (_open) {
|
||||||
|
_bringNotesToFront();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_open = true;
|
_open = true;
|
||||||
_editingId = null;
|
_editingId = null;
|
||||||
// Reset the search filter — the rebuilt pane's search input renders empty, so a
|
// Reset the search filter — the rebuilt pane's search input renders empty, so a
|
||||||
@@ -1192,6 +1213,7 @@ export function openPanel() {
|
|||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
_wireNotesWindow(pane);
|
_wireNotesWindow(pane);
|
||||||
_restoreNotesSidebarDock(pane);
|
_restoreNotesSidebarDock(pane);
|
||||||
|
_bringNotesToFront(pane);
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
// (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.)
|
// (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.)
|
||||||
@@ -1202,6 +1224,9 @@ export function openPanel() {
|
|||||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane);
|
_wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane);
|
||||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane);
|
_wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane);
|
||||||
|
|
||||||
|
pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true);
|
||||||
|
pane.addEventListener('focusin', () => _bringNotesToFront(pane), true);
|
||||||
|
|
||||||
const minBtn = document.getElementById('notes-minimize-btn');
|
const minBtn = document.getElementById('notes-minimize-btn');
|
||||||
if (minBtn) minBtn.addEventListener('click', (e) => {
|
if (minBtn) minBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+140
-11
@@ -11,6 +11,7 @@ import { isAltGrEvent } from './platform.js';
|
|||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
|
let _authPolicy = { password_min_length: 8 };
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
function el(id) { return document.getElementById(id); }
|
||||||
function esc(s) { return uiModule.esc(s); }
|
function esc(s) { return uiModule.esc(s); }
|
||||||
@@ -2160,6 +2161,16 @@ function initAccount() {
|
|||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Update password placeholder and policy from server
|
||||||
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(policy => {
|
||||||
|
if (!policy) return;
|
||||||
|
_authPolicy = policy;
|
||||||
|
const pwNew = el('settings-pw-new');
|
||||||
|
if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`;
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// Change password
|
// Change password
|
||||||
const saveBtn = el('settings-pw-save');
|
const saveBtn = el('settings-pw-save');
|
||||||
const msgEl = el('settings-pw-msg');
|
const msgEl = el('settings-pw-msg');
|
||||||
@@ -2170,7 +2181,7 @@ function initAccount() {
|
|||||||
const conf = el('settings-pw-confirm').value;
|
const conf = el('settings-pw-confirm').value;
|
||||||
msgEl.style.color = '';
|
msgEl.style.color = '';
|
||||||
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
|
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
|
||||||
if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; }
|
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; }
|
||||||
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
|
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
@@ -2913,13 +2924,14 @@ async function initEmailAccountsSettings() {
|
|||||||
// IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally
|
// IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally
|
||||||
// blank because it may live on another machine (DNS, LAN, Tailscale).
|
// blank because it may live on another machine (DNS, LAN, Tailscale).
|
||||||
const PROVIDERS = {
|
const PROVIDERS = {
|
||||||
gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
||||||
migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
google_workspace: { label: 'Google Workspace / .edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' },
|
||||||
icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
||||||
outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
||||||
fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } },
|
outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
||||||
yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
|
fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } },
|
||||||
dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } },
|
yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
|
||||||
|
dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } },
|
||||||
};
|
};
|
||||||
const _providerOptions = Object.entries(PROVIDERS)
|
const _providerOptions = Object.entries(PROVIDERS)
|
||||||
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
|
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
|
||||||
@@ -2932,11 +2944,17 @@ async function initEmailAccountsSettings() {
|
|||||||
<div id="eaf-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
<div id="eaf-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional — leave blank to use email)" value="${esc(a.name || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional — leave blank to use email)" value="${esc(a.name || '')}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="you@example.com" value="${esc(a.from_address || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="you@example.com" value="${esc(a.from_address || '')}"></div>
|
||||||
|
<div class="settings-row"><label class="settings-label">Display Name${_hint('Your name as it appears in the From: field of emails you send, e.g. Jane Smith. Auto-filled from Google during OAuth.')}</label><input id="eaf-display-name" class="settings-input" placeholder="Your Name" value="${esc(a.display_name || '')}"></div>
|
||||||
|
<div id="eaf-oauth-section" style="display:none;margin:8px 0;padding:10px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb,var(--accent,#50fa7b) 6%,transparent)">
|
||||||
|
<div style="font-size:11px;font-weight:600;margin-bottom:6px">Google OAuth2 — required for Workspace / .edu accounts</div>
|
||||||
|
<div id="eaf-oauth-status" style="font-size:11px;opacity:0.7;margin-bottom:6px">${a.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}</div>
|
||||||
|
<button type="button" id="eaf-oauth-btn" class="admin-btn-add" style="font-size:11px">${a.oauth_provider === 'google' ? 'Reconnect with Google' : 'Connect with Google'}</button>
|
||||||
|
</div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div>
|
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div>
|
<div class="eaf-password-section"><div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div></div>
|
||||||
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
|
||||||
@@ -2959,6 +2977,16 @@ async function initEmailAccountsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Show/hide OAuth section and password fields based on provider selection.
|
||||||
|
function _syncOauthUI(providerKey) {
|
||||||
|
const p = PROVIDERS[providerKey];
|
||||||
|
const isOauth = !!(p && p.oauth);
|
||||||
|
el('eaf-oauth-section').style.display = isOauth ? '' : 'none';
|
||||||
|
formEl.querySelectorAll('.eaf-password-section').forEach(r => {
|
||||||
|
r.style.display = isOauth ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const eafProviderNotes = {
|
const eafProviderNotes = {
|
||||||
outlook: {
|
outlook: {
|
||||||
title: 'Outlook / Office 365 needs OAuth',
|
title: 'Outlook / Office 365 needs OAuth',
|
||||||
@@ -2983,13 +3011,41 @@ async function initEmailAccountsSettings() {
|
|||||||
el('eaf-provider').addEventListener('change', (e) => {
|
el('eaf-provider').addEventListener('change', (e) => {
|
||||||
_renderEafProviderNote(e.target.value);
|
_renderEafProviderNote(e.target.value);
|
||||||
const p = PROVIDERS[e.target.value];
|
const p = PROVIDERS[e.target.value];
|
||||||
if (!p) return;
|
if (!p) { _syncOauthUI(''); return; }
|
||||||
el('eaf-imap-host').value = p.imap.host;
|
el('eaf-imap-host').value = p.imap.host;
|
||||||
el('eaf-imap-port').value = p.imap.port;
|
el('eaf-imap-port').value = p.imap.port;
|
||||||
el('eaf-imap-starttls').checked = !!p.imap.starttls;
|
el('eaf-imap-starttls').checked = !!p.imap.starttls;
|
||||||
el('eaf-smtp-host').value = p.smtp.host;
|
el('eaf-smtp-host').value = p.smtp.host;
|
||||||
el('eaf-smtp-port').value = p.smtp.port;
|
el('eaf-smtp-port').value = p.smtp.port;
|
||||||
el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
|
el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
|
||||||
|
_syncOauthUI(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init OAuth UI for accounts already connected via OAuth.
|
||||||
|
if (a.oauth_provider === 'google') _syncOauthUI('google_workspace');
|
||||||
|
|
||||||
|
// "Connect with Google" button — save the account first, then redirect to OAuth.
|
||||||
|
el('eaf-oauth-btn').addEventListener('click', async () => {
|
||||||
|
// Must save the account first to get an account_id to pass to the OAuth flow.
|
||||||
|
const body = {
|
||||||
|
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
||||||
|
from_address: el('eaf-from').value.trim(),
|
||||||
|
imap_host: el('eaf-imap-host').value.trim(),
|
||||||
|
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||||
|
imap_user: el('eaf-imap-user').value.trim(),
|
||||||
|
imap_starttls: el('eaf-imap-starttls').checked,
|
||||||
|
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||||
|
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
||||||
|
smtp_user: el('eaf-imap-user').value.trim(),
|
||||||
|
};
|
||||||
|
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||||
|
const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) { el('eaf-msg').textContent = d.error || 'Save failed'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||||
|
const accId = isEdit ? a.id : d.id;
|
||||||
|
window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`;
|
||||||
});
|
});
|
||||||
el('eaf-smtp-security').value = _smtpSecurity(a);
|
el('eaf-smtp-security').value = _smtpSecurity(a);
|
||||||
|
|
||||||
@@ -3009,6 +3065,7 @@ async function initEmailAccountsSettings() {
|
|||||||
const body = {
|
const body = {
|
||||||
name: el('eaf-name').value.trim(),
|
name: el('eaf-name').value.trim(),
|
||||||
from_address: el('eaf-from').value.trim(),
|
from_address: el('eaf-from').value.trim(),
|
||||||
|
display_name: el('eaf-display-name').value.trim(),
|
||||||
imap_host: el('eaf-imap-host').value.trim(),
|
imap_host: el('eaf-imap-host').value.trim(),
|
||||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||||
imap_user: el('eaf-imap-user').value.trim(),
|
imap_user: el('eaf-imap-user').value.trim(),
|
||||||
@@ -4317,6 +4374,7 @@ async function initUnifiedIntegrations() {
|
|||||||
// it may be remote (DNS, LAN, Tailscale), not localhost.
|
// it may be remote (DNS, LAN, Tailscale), not localhost.
|
||||||
const PROVIDERS = {
|
const PROVIDERS = {
|
||||||
gmail: { label: 'Gmail', emailEx: 'you@gmail.com', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
gmail: { label: 'Gmail', emailEx: 'you@gmail.com', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
||||||
|
google_workspace: { label: 'Google Workspace / .edu', emailEx: 'you@yourschool.edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' },
|
||||||
migadu: { label: 'Migadu', emailEx: 'you@yourdomain.com', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
migadu: { label: 'Migadu', emailEx: 'you@yourdomain.com', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
||||||
icloud: { label: 'iCloud', emailEx: 'you@icloud.com', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
icloud: { label: 'iCloud', emailEx: 'you@icloud.com', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
||||||
outlook: { label: 'Outlook / Office 365', emailEx: 'you@outlook.com', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
outlook: { label: 'Outlook / Office 365', emailEx: 'you@outlook.com', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
||||||
@@ -4334,6 +4392,7 @@ async function initUnifiedIntegrations() {
|
|||||||
const PROV_LOGO = {
|
const PROV_LOGO = {
|
||||||
'': _customLogo,
|
'': _customLogo,
|
||||||
gmail: _letterLogo('G', '#ea4335'),
|
gmail: _letterLogo('G', '#ea4335'),
|
||||||
|
google_workspace: _letterLogo('G', '#ea4335'),
|
||||||
migadu: _letterLogo('M', '#3aa39d'),
|
migadu: _letterLogo('M', '#3aa39d'),
|
||||||
icloud: _letterLogo('i', '#3693f3'),
|
icloud: _letterLogo('i', '#3693f3'),
|
||||||
outlook: _letterLogo('O', '#0078d4'),
|
outlook: _letterLogo('O', '#0078d4'),
|
||||||
@@ -4362,11 +4421,17 @@ async function initUnifiedIntegrations() {
|
|||||||
<div id="uf-email-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
<div id="uf-email-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="uf-email-name" class="settings-input" placeholder="(optional — leave blank to use email)"></div>
|
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="uf-email-name" class="settings-input" placeholder="(optional — leave blank to use email)"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="uf-email-from" class="settings-input" placeholder="you@example.com"></div>
|
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="uf-email-from" class="settings-input" placeholder="you@example.com"></div>
|
||||||
|
<div class="settings-row"><label class="settings-label">Display Name${_hint('Your name as it appears in the From: field of emails you send, e.g. Jane Smith. Auto-filled from Google during OAuth.')}</label><input id="uf-display-name" class="settings-input" placeholder="Your Name"></div>
|
||||||
|
<div id="uf-oauth-section" style="display:none;margin:8px 0;padding:10px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb,var(--accent,#50fa7b) 6%,transparent)">
|
||||||
|
<div style="font-size:11px;font-weight:600;margin-bottom:6px">Google OAuth2 — required for Workspace / .edu accounts</div>
|
||||||
|
<div id="uf-oauth-status" style="font-size:11px;opacity:0.7;margin-bottom:6px">${existing && existing.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}</div>
|
||||||
|
<button type="button" id="uf-oauth-btn" class="admin-btn-add" style="font-size:11px">${existing && existing.oauth_provider === 'google' ? 'Reconnect with Google' : 'Connect with Google'}</button>
|
||||||
|
</div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>IMAP (Receiving)</div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>IMAP (Receiving)</div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
|
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
|
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password). For Migadu and Fastmail, your mailbox password usually works. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
<div class="uf-password-section"><div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password). For Migadu and Fastmail, your mailbox password usually works. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div></div>
|
||||||
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
|
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
|
||||||
@@ -4491,6 +4556,16 @@ async function initUnifiedIntegrations() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show/hide the OAuth section and password fields based on provider selection.
|
||||||
|
function _syncOauthUI(providerKey) {
|
||||||
|
const p = PROVIDERS[providerKey];
|
||||||
|
const isOauth = !!(p && p.oauth);
|
||||||
|
el('uf-oauth-section').style.display = isOauth ? '' : 'none';
|
||||||
|
formEl.querySelectorAll('.uf-password-section').forEach(r => {
|
||||||
|
r.style.display = isOauth ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Custom dropdown wire-up — the native <select> stays in the DOM as the
|
// Custom dropdown wire-up — the native <select> stays in the DOM as the
|
||||||
// data source and accessibility target, but the visible UI is a button +
|
// data source and accessibility target, but the visible UI is a button +
|
||||||
// popup so each provider row can render with its SVG logo. Selecting an
|
// popup so each provider row can render with its SVG logo. Selecting an
|
||||||
@@ -4547,6 +4622,7 @@ async function initUnifiedIntegrations() {
|
|||||||
el('uf-email-provider').addEventListener('change', (e) => {
|
el('uf-email-provider').addEventListener('change', (e) => {
|
||||||
const key = e.target.value;
|
const key = e.target.value;
|
||||||
_renderProviderNote(key);
|
_renderProviderNote(key);
|
||||||
|
_syncOauthUI(key);
|
||||||
const p = PROVIDERS[key];
|
const p = PROVIDERS[key];
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
el('uf-imap-host').value = p.imap.host;
|
el('uf-imap-host').value = p.imap.host;
|
||||||
@@ -4562,6 +4638,23 @@ async function initUnifiedIntegrations() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Init OAuth UI for accounts already connected via OAuth.
|
||||||
|
if (existing && existing.oauth_provider === 'google') _syncOauthUI('google_workspace');
|
||||||
|
|
||||||
|
// "Connect with Google" — save the account first, then redirect to OAuth.
|
||||||
|
el('uf-oauth-btn').addEventListener('click', async () => {
|
||||||
|
const body = _collectBody();
|
||||||
|
if (!body.name) body.name = body.from_address;
|
||||||
|
if (!body.name) { el('uf-email-msg').textContent = 'Enter a Name or Email first'; el('uf-email-msg').style.color = 'var(--red)'; return; }
|
||||||
|
const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!(d.ok || d.id)) { el('uf-email-msg').textContent = d.error || 'Save failed'; el('uf-email-msg').style.color = 'var(--red)'; return; }
|
||||||
|
const accId = isEdit ? editId : d.id;
|
||||||
|
window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`;
|
||||||
|
});
|
||||||
|
|
||||||
// "Same as IMAP" toggle — hide the SMTP creds rows when on.
|
// "Same as IMAP" toggle — hide the SMTP creds rows when on.
|
||||||
const _syncSmtpSame = () => {
|
const _syncSmtpSame = () => {
|
||||||
const same = el('uf-smtp-same').checked;
|
const same = el('uf-smtp-same').checked;
|
||||||
@@ -4574,6 +4667,7 @@ async function initUnifiedIntegrations() {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
el('uf-email-name').value = existing.name || '';
|
el('uf-email-name').value = existing.name || '';
|
||||||
el('uf-email-from').value = existing.from_address || '';
|
el('uf-email-from').value = existing.from_address || '';
|
||||||
|
el('uf-display-name').value = existing.display_name || '';
|
||||||
el('uf-imap-host').value = existing.imap_host || '';
|
el('uf-imap-host').value = existing.imap_host || '';
|
||||||
el('uf-imap-port').value = existing.imap_port || 993;
|
el('uf-imap-port').value = existing.imap_port || 993;
|
||||||
el('uf-imap-user').value = existing.imap_user || '';
|
el('uf-imap-user').value = existing.imap_user || '';
|
||||||
@@ -4622,6 +4716,7 @@ async function initUnifiedIntegrations() {
|
|||||||
const body = {
|
const body = {
|
||||||
name: el('uf-email-name').value.trim(),
|
name: el('uf-email-name').value.trim(),
|
||||||
from_address: el('uf-email-from').value.trim(),
|
from_address: el('uf-email-from').value.trim(),
|
||||||
|
display_name: el('uf-display-name').value.trim(),
|
||||||
imap_host: el('uf-imap-host').value.trim(),
|
imap_host: el('uf-imap-host').value.trim(),
|
||||||
imap_port: parseInt(el('uf-imap-port').value) || 993,
|
imap_port: parseInt(el('uf-imap-port').value) || 993,
|
||||||
imap_user: el('uf-imap-user').value.trim(),
|
imap_user: el('uf-imap-user').value.trim(),
|
||||||
@@ -5650,6 +5745,40 @@ export function close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
||||||
|
(function _handleOauthRedirect() {
|
||||||
|
const sp = new URLSearchParams(window.location.search);
|
||||||
|
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||||
|
// Strip params from URL without a page reload.
|
||||||
|
const clean = window.location.pathname + window.location.hash;
|
||||||
|
window.history.replaceState(null, '', clean);
|
||||||
|
const success = sp.has('email_oauth_success');
|
||||||
|
const errMsg = sp.get('email_oauth_error') || '';
|
||||||
|
// Open settings → integrations after the app has initialised.
|
||||||
|
function _tryOpen() {
|
||||||
|
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
|
||||||
|
window.settingsModule.open('integrations');
|
||||||
|
// Brief toast-style banner.
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.textContent = success
|
||||||
|
? '✓ Google account connected — email is ready'
|
||||||
|
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||||
|
Object.assign(banner.style, {
|
||||||
|
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||||
|
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||||
|
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
|
});
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
setTimeout(() => banner.remove(), 4000);
|
||||||
|
} else {
|
||||||
|
setTimeout(_tryOpen, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_tryOpen();
|
||||||
|
})();
|
||||||
|
|
||||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
|
||||||
|
|
||||||
|
export function topToolWindowZ(options = {}) {
|
||||||
|
const {
|
||||||
|
exclude = null,
|
||||||
|
root = globalThis.document,
|
||||||
|
getStyle = globalThis.getComputedStyle,
|
||||||
|
floor = 250,
|
||||||
|
} = options;
|
||||||
|
let top = floor;
|
||||||
|
if (!root || typeof root.querySelectorAll !== 'function' || typeof getStyle !== 'function') return top;
|
||||||
|
root.querySelectorAll(TOOL_WINDOW_SELECTOR).forEach(el => {
|
||||||
|
if (!el || el === exclude) return;
|
||||||
|
if (el.classList?.contains('hidden') || el.classList?.contains('modal-minimized')) return;
|
||||||
|
const cs = getStyle(el);
|
||||||
|
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
||||||
|
const z = parseInt(cs.zIndex, 10);
|
||||||
|
if (Number.isFinite(z)) top = Math.max(top, z);
|
||||||
|
});
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextToolWindowZ(options = {}) {
|
||||||
|
const { current = null } = options;
|
||||||
|
const top = topToolWindowZ(options);
|
||||||
|
const currentZ = parseInt(current, 10);
|
||||||
|
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
||||||
|
return top + 1;
|
||||||
|
}
|
||||||
+21
-6
@@ -8,6 +8,7 @@ import themeModule from './theme.js';
|
|||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
let toastEl = null;
|
let toastEl = null;
|
||||||
let autoScrollEnabled = true;
|
let autoScrollEnabled = true;
|
||||||
@@ -1088,14 +1089,22 @@ if ('ontouchstart' in window) {
|
|||||||
|
|
||||||
// ---- Bring modal to front on click ----
|
// ---- Bring modal to front on click ----
|
||||||
{
|
{
|
||||||
let topModalZ = 250;
|
const raiseModalToFront = (modal, floor = 250) => {
|
||||||
|
const z = nextToolWindowZ({
|
||||||
|
exclude: modal,
|
||||||
|
current: getComputedStyle(modal).zIndex,
|
||||||
|
floor,
|
||||||
|
});
|
||||||
|
modal.style.setProperty('z-index', String(z), 'important');
|
||||||
|
return z;
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', (e) => {
|
document.addEventListener('mousedown', (e) => {
|
||||||
const modalContent = e.target.closest('.modal-content');
|
const modalContent = e.target.closest('.modal-content');
|
||||||
if (!modalContent) return;
|
if (!modalContent) return;
|
||||||
const modal = modalContent.closest('.modal');
|
const modal = modalContent.closest('.modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
topModalZ += 1;
|
raiseModalToFront(modal);
|
||||||
modal.style.zIndex = topModalZ;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Backdrop tap to close — delegated for all modals
|
// Backdrop tap to close — delegated for all modals
|
||||||
@@ -1190,9 +1199,15 @@ if (!window._odyEscExpandGuard) {
|
|||||||
// Re-entry guard: setting style.zIndex itself fires the observer that
|
// Re-entry guard: setting style.zIndex itself fires the observer that
|
||||||
// calls us back. Skip if this element is already pinned to the top
|
// calls us back. Skip if this element is already pinned to the top
|
||||||
// (matches the current counter) so we don't spin into an infinite loop.
|
// (matches the current counter) so we don't spin into an infinite loop.
|
||||||
const cur = parseInt(m.style.zIndex, 10) || 0;
|
const cur = parseInt(getComputedStyle(m).zIndex, 10) || 0;
|
||||||
if (cur === _zCounter) return;
|
if (cur === _zCounter && cur > topToolWindowZ({ exclude: m })) return;
|
||||||
m.style.zIndex = String(++_zCounter);
|
const z = nextToolWindowZ({
|
||||||
|
exclude: m,
|
||||||
|
current: cur,
|
||||||
|
floor: _zCounter,
|
||||||
|
});
|
||||||
|
_zCounter = Math.max(_zCounter, z);
|
||||||
|
if (z !== cur) m.style.setProperty('z-index', String(z), 'important');
|
||||||
};
|
};
|
||||||
new MutationObserver((muts) => {
|
new MutationObserver((muts) => {
|
||||||
for (const m of muts) {
|
for (const m of muts) {
|
||||||
|
|||||||
+18
-5
@@ -328,6 +328,7 @@
|
|||||||
|
|
||||||
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
||||||
let signupAllowed = false;
|
let signupAllowed = false;
|
||||||
|
let policy = { password_min_length: 8, reserved_usernames: [] };
|
||||||
|
|
||||||
const rememberToggle = document.getElementById('rememberToggle');
|
const rememberToggle = document.getElementById('rememberToggle');
|
||||||
|
|
||||||
@@ -360,10 +361,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auth status
|
// 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);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||||
const data = await res.json();
|
const data = await statusRes.json();
|
||||||
if (data.authenticated) {
|
if (data.authenticated) {
|
||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
return;
|
return;
|
||||||
@@ -374,6 +377,10 @@
|
|||||||
} else {
|
} else {
|
||||||
setMode('login');
|
setMode('login');
|
||||||
}
|
}
|
||||||
|
const policyRes = await policyPromise;
|
||||||
|
if (policyRes && policyRes.ok) {
|
||||||
|
policy = await policyRes.json();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMode('login');
|
setMode('login');
|
||||||
}
|
}
|
||||||
@@ -426,8 +433,14 @@
|
|||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
if (password.length < policy.password_min_length) {
|
||||||
errEl.textContent = 'Password must be at least 8 characters';
|
errEl.textContent = `Password must be at least ${policy.password_min_length} characters`;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (policy.reserved_usernames.includes(username.toLowerCase())) {
|
||||||
|
errEl.textContent = 'This username is reserved';
|
||||||
errEl.style.display = 'block';
|
errEl.style.display = 'block';
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ Read-only checks, run from the repo root on this branch. Note the real API is
|
|||||||
```bash
|
```bash
|
||||||
# Compute the area_cli set and confirm test_backup_cli_security.py is
|
# Compute the area_cli set and confirm test_backup_cli_security.py is
|
||||||
# area_security. Expected: 28 files, then "security".
|
# area_security. Expected: 28 files, then "security".
|
||||||
.venv/bin/python - <<'PY'
|
./venv/bin/python - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests._taxonomy import classify_test_path
|
from tests._taxonomy import classify_test_path
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ rg -n "TestClient|FastAPI|create_app|SessionLocal|sqlite|dependency_overrides" \
|
|||||||
tests/test_*cli*.py tests/test_sessions_cli.py
|
tests/test_*cli*.py tests/test_sessions_cli.py
|
||||||
|
|
||||||
# Hard-coded flat paths to the exact CLI files outside tests/. Expected: no matches.
|
# Hard-coded flat paths to the exact CLI files outside tests/. Expected: no matches.
|
||||||
.venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
./venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests._taxonomy import classify_test_path
|
from tests._taxonomy import classify_test_path
|
||||||
|
|
||||||
@@ -158,26 +158,26 @@ tokens only (plus the `tests/helpers/` directory rule), so the markers of the
|
|||||||
|
|
||||||
## Validation for the future move PR
|
## Validation for the future move PR
|
||||||
|
|
||||||
Run with the project venv (`.venv/bin/python`); system `python3` may miss
|
Run with the project venv (`./venv/bin/python`); system `python3` may miss
|
||||||
pinned deps. Before the move, record the baseline; after, compare:
|
pinned deps. Before the move, record the baseline; after, compare:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Selection must match the 28 files before and after the move.
|
# Selection must match the 28 files before and after the move.
|
||||||
.venv/bin/python tests/run_focus.py --dry-run --area cli
|
./venv/bin/python tests/run_focus.py --dry-run --area cli
|
||||||
.venv/bin/python -m pytest -m area_cli -q
|
./venv/bin/python -m pytest -m area_cli -q
|
||||||
|
|
||||||
# Moved files pass when targeted directly.
|
# Moved files pass when targeted directly.
|
||||||
.venv/bin/python -m pytest tests/cli/ -q
|
./venv/bin/python -m pytest tests/cli/ -q
|
||||||
|
|
||||||
# Whole-suite collection still succeeds (catches import/path breakage).
|
# Whole-suite collection still succeeds (catches import/path breakage).
|
||||||
.venv/bin/python -m pytest --collect-only -q
|
./venv/bin/python -m pytest --collect-only -q
|
||||||
|
|
||||||
# Taxonomy/runner infrastructure is unaffected.
|
# Taxonomy/runner infrastructure is unaffected.
|
||||||
.venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
|
./venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
|
||||||
|
|
||||||
# No stale flat-path references to the moved files. Expected: no matches
|
# No stale flat-path references to the moved files. Expected: no matches
|
||||||
# outside tests/cli/ itself.
|
# outside tests/cli/ itself.
|
||||||
.venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
./venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tests._taxonomy import classify_test_path
|
from tests._taxonomy import classify_test_path
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
# Oversized Test File Split Plan
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document plans future oversized test-file splits using current repo data.
|
||||||
|
It does not move files, rewrite assertions, extract helpers, or change CI.
|
||||||
|
|
||||||
|
## Roadmap context
|
||||||
|
|
||||||
|
- Issue: #3983
|
||||||
|
- Parent tracker: #2523
|
||||||
|
- Follows #3973 / #3982, the report-only order-sensitivity diagnostics slice.
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
Metrics were generated from the current test tree using:
|
||||||
|
|
||||||
|
- physical line counts for every recursive `test_*.py` file under `tests/`;
|
||||||
|
- AST counts for `test_*` functions and `Test*` classes;
|
||||||
|
- one `pytest --collect-only -q tests` run to count collected items per file;
|
||||||
|
- current taxonomy classification from `tests._taxonomy.classify_test_path`; and
|
||||||
|
- static setup-signal scans for route/API, DB/session, import-state, security, filesystem, subprocess/script, async/threading, and UI/static indicators.
|
||||||
|
|
||||||
|
Static signals are not proof of risk. They are review prompts.
|
||||||
|
Future split PRs must still inspect each file manually before editing.
|
||||||
|
|
||||||
|
## Current summary
|
||||||
|
|
||||||
|
- test files scanned: 583
|
||||||
|
- collected pytest items counted: 3586
|
||||||
|
- large-file threshold: 300 lines
|
||||||
|
- large-collected threshold: 20 collected items
|
||||||
|
|
||||||
|
Area distribution:
|
||||||
|
|
||||||
|
| Value | Files |
|
||||||
|
|---|---:|
|
||||||
|
| cli | 28 |
|
||||||
|
| helpers | 1 |
|
||||||
|
| js | 39 |
|
||||||
|
| routes | 23 |
|
||||||
|
| security | 77 |
|
||||||
|
| services | 144 |
|
||||||
|
| uncategorized | 234 |
|
||||||
|
| unit | 37 |
|
||||||
|
|
||||||
|
Sub-area distribution:
|
||||||
|
|
||||||
|
| Value | Files |
|
||||||
|
|---|---:|
|
||||||
|
| api | 6 |
|
||||||
|
| atomic | 3 |
|
||||||
|
| auth | 9 |
|
||||||
|
| calendar | 10 |
|
||||||
|
| cli | 28 |
|
||||||
|
| confinement | 7 |
|
||||||
|
| cookbook | 13 |
|
||||||
|
| document | 11 |
|
||||||
|
| email | 12 |
|
||||||
|
| embedding | 3 |
|
||||||
|
| gallery | 5 |
|
||||||
|
| history | 3 |
|
||||||
|
| js | 39 |
|
||||||
|
| llm | 16 |
|
||||||
|
| mcp | 8 |
|
||||||
|
| memory | 15 |
|
||||||
|
| nondict | 7 |
|
||||||
|
| nonstring | 22 |
|
||||||
|
| owner | 14 |
|
||||||
|
| owner_scope | 23 |
|
||||||
|
| parse | 4 |
|
||||||
|
| provider | 6 |
|
||||||
|
| research | 16 |
|
||||||
|
| route | 6 |
|
||||||
|
| routes | 9 |
|
||||||
|
| scheduler | 3 |
|
||||||
|
| scope | 5 |
|
||||||
|
| security | 9 |
|
||||||
|
| session | 16 |
|
||||||
|
| ssrf | 3 |
|
||||||
|
| webhook | 3 |
|
||||||
|
| xss | 5 |
|
||||||
|
|
||||||
|
Values below 2 files: 244 values covering 244 files.
|
||||||
|
|
||||||
|
## Top files by collected pytest items
|
||||||
|
|
||||||
|
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
||||||
|
|---|---:|---:|---:|---:|---|---|---|
|
||||||
|
| `tests/test_model_routes.py` | 1778 | 139 | 116 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
||||||
|
| `tests/test_security_regressions.py` | 1224 | 92 | 68 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
||||||
|
| `tests/test_provider_classification.py` | 188 | 67 | 21 | 4 | services | provider | - |
|
||||||
|
| `tests/test_cookbook_helpers.py` | 912 | 65 | 65 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
|
||||||
|
| `tests/test_shell_routes.py` | 481 | 63 | 48 | 8 | routes | routes | route/api, import-state, filesystem |
|
||||||
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
||||||
|
| `tests/test_provider_endpoints.py` | 241 | 58 | 18 | 1 | services | provider | subprocess/script |
|
||||||
|
| `tests/test_agent_loop.py` | 469 | 52 | 52 | 5 | uncategorized | agent_loop | db/session, import-state |
|
||||||
|
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
|
||||||
|
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
||||||
|
| `tests/test_llm_core_temperature.py` | 196 | 41 | 17 | 0 | services | llm | - |
|
||||||
|
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
||||||
|
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | 6 | 0 | services | llm | db/session |
|
||||||
|
| `tests/test_chat_helpers.py` | 264 | 31 | 18 | 0 | uncategorized | chat_helpers | route/api |
|
||||||
|
| `tests/test_provider_detection.py` | 148 | 31 | 31 | 5 | services | provider | - |
|
||||||
|
| `tests/test_model_context.py` | 251 | 30 | 30 | 4 | uncategorized | model_context | db/session, import-state |
|
||||||
|
| `tests/test_endpoint_resolver.py` | 148 | 30 | 30 | 6 | uncategorized | endpoint_resolver | - |
|
||||||
|
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
|
||||||
|
| `tests/test_upload_limits_centralized.py` | 110 | 29 | 5 | 0 | uncategorized | upload_limits_centralized | import-state, filesystem |
|
||||||
|
| `tests/test_email_oauth.py` | 580 | 28 | 25 | 0 | services | email | route/api, db/session, security, async/threading |
|
||||||
|
| `tests/test_review_regressions.py` | 930 | 26 | 26 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
||||||
|
| `tests/test_taxonomy.py` | 145 | 26 | 16 | 0 | uncategorized | taxonomy | security, ui/static |
|
||||||
|
| `tests/test_tool_path_confinement.py` | 282 | 24 | 24 | 0 | security | confinement | import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_copilot.py` | 170 | 23 | 16 | 0 | uncategorized | copilot | - |
|
||||||
|
| `tests/test_research_utils.py` | 97 | 23 | 23 | 2 | services | research | - |
|
||||||
|
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_tool_support_heuristic.py` | 166 | 22 | 22 | 3 | uncategorized | tool_support_heuristic | - |
|
||||||
|
| `tests/test_platform_compat.py` | 318 | 21 | 21 | 0 | uncategorized | platform_compat | import-state, filesystem, subprocess/script |
|
||||||
|
|
||||||
|
## Top files by physical line count
|
||||||
|
|
||||||
|
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
||||||
|
|---|---:|---:|---:|---:|---|---|---|
|
||||||
|
| `tests/test_model_routes.py` | 1778 | 139 | 116 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
||||||
|
| `tests/test_security_regressions.py` | 1224 | 92 | 68 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
||||||
|
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
|
||||||
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
||||||
|
| `tests/test_review_regressions.py` | 930 | 26 | 26 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_cookbook_helpers.py` | 912 | 65 | 65 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
|
||||||
|
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_email_oauth.py` | 580 | 28 | 25 | 0 | services | email | route/api, db/session, security, async/threading |
|
||||||
|
| `tests/test_api_token_routes.py` | 578 | 17 | 17 | 0 | routes | api_routes | route/api, db/session, import-state, async/threading |
|
||||||
|
| `tests/test_shell_routes.py` | 481 | 63 | 48 | 8 | routes | routes | route/api, import-state, filesystem |
|
||||||
|
| `tests/test_email_owner_scope.py` | 474 | 9 | 9 | 0 | security | owner_scope | route/api, db/session, filesystem, async/threading |
|
||||||
|
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
|
||||||
|
| `tests/test_agent_loop.py` | 469 | 52 | 52 | 5 | uncategorized | agent_loop | db/session, import-state |
|
||||||
|
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | 8 | 0 | uncategorized | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading |
|
||||||
|
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
||||||
|
| `tests/test_endpoint_owner_scope_followup.py` | 414 | 11 | 11 | 0 | security | owner_scope | route/api, db/session, filesystem |
|
||||||
|
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
||||||
|
| `tests/test_imap_leak_fixes.py` | 404 | 15 | 15 | 0 | uncategorized | imap_leak_fixes | route/api, db/session, security, filesystem |
|
||||||
|
| `tests/test_companion_readonly.py` | 402 | 17 | 17 | 0 | uncategorized | companion_readonly | db/session, import-state |
|
||||||
|
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | 9 | 0 | uncategorized | upload_handler_atomicity | filesystem, async/threading |
|
||||||
|
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
||||||
|
| `tests/test_auth_regressions.py` | 375 | 15 | 15 | 0 | security | auth | route/api, db/session, import-state, async/threading |
|
||||||
|
| `tests/test_calendar_owner_scope.py` | 345 | 7 | 7 | 0 | security | owner_scope | route/api, db/session, import-state, filesystem, async/threading, ui/static |
|
||||||
|
| `tests/test_null_owner_gates.py` | 342 | 20 | 20 | 0 | security | owner | route/api, db/session, import-state |
|
||||||
|
| `tests/test_agent_migration_manifest.py` | 340 | 15 | 15 | 0 | uncategorized | agent_migration_manifest | import-state, filesystem |
|
||||||
|
| `tests/test_calendar_recurrence.py` | 338 | 19 | 19 | 0 | services | calendar | - |
|
||||||
|
| `tests/test_tool_policy.py` | 330 | 13 | 13 | 0 | uncategorized | tool_policy | import-state, async/threading |
|
||||||
|
| `tests/test_workspace_confine.py` | 328 | 18 | 18 | 0 | uncategorized | workspace_confine | route/api, filesystem, subprocess/script, async/threading |
|
||||||
|
| `tests/test_diffusion_server_security.py` | 325 | 14 | 14 | 0 | security | security | route/api, import-state, security, filesystem, async/threading, ui/static |
|
||||||
|
|
||||||
|
## Split planning candidates
|
||||||
|
|
||||||
|
This section is generated from metrics, not from manual judgement.
|
||||||
|
Files are included when they meet at least one threshold:
|
||||||
|
|
||||||
|
- at least 300 physical lines; or
|
||||||
|
- at least 20 collected pytest items.
|
||||||
|
|
||||||
|
These are planning candidates only. A later split PR still needs a focused manual review of each file before moving tests.
|
||||||
|
|
||||||
|
| File | Why included | Setup/risk signals | Suggested handling |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tests/test_model_routes.py` | 1778 lines, 139 collected tests | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_security_regressions.py` | 1224 lines, 92 collected tests | route/api, db/session, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_provider_classification.py` | 67 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_cookbook_helpers.py` | 912 lines, 65 collected tests | route/api, filesystem, subprocess/script, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_shell_routes.py` | 481 lines, 63 collected tests | route/api, import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_pr_blocker_audit.py` | 964 lines, 58 collected tests | import-state, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_provider_endpoints.py` | 58 collected tests | subprocess/script | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_agent_loop.py` | 469 lines, 52 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_service_health.py` | 472 lines, 47 collected tests | async/threading | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_run_focus.py` | 399 lines, 47 collected tests | security, filesystem, subprocess/script, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_llm_core_temperature.py` | 41 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_endpoint_probing.py` | 411 lines, 34 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_llm_core_anthropic_temp_omit.py` | 32 collected tests | db/session | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_chat_helpers.py` | 31 collected tests | route/api | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_provider_detection.py` | 31 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_model_context.py` | 30 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_endpoint_resolver.py` | 30 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_embedding_lanes.py` | 1104 lines, 29 collected tests | filesystem | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_upload_limits_centralized.py` | 29 collected tests | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_email_oauth.py` | 580 lines, 28 collected tests | route/api, db/session, security, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_review_regressions.py` | 930 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_rename_user_owner_sync.py` | 686 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_helpers_import_state.py` | 426 lines, 26 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_taxonomy.py` | 26 collected tests | security, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_tool_path_confinement.py` | 24 collected tests | import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_copilot.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_research_utils.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_api_chat_security.py` | 401 lines, 22 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_tool_support_heuristic.py` | 22 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_platform_compat.py` | 318 lines, 21 collected tests | import-state, filesystem, subprocess/script | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_context_compactor.py` | 21 collected tests | db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_prompt_security.py` | 21 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_null_owner_gates.py` | 342 lines, 20 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_youtube_handler_consolidation.py` | 20 collected tests | route/api, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_calendar_recurrence.py` | 338 lines | No obvious setup signals from static scan. | Plan split boundaries before editing. |
|
||||||
|
| `tests/test_workspace_confine.py` | 328 lines | route/api, filesystem, subprocess/script, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_api_token_routes.py` | 578 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_companion_readonly.py` | 402 lines | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_set_admin.py` | 317 lines | route/api, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_imap_leak_fixes.py` | 404 lines | route/api, db/session, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_auth_regressions.py` | 375 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_agent_migration_manifest.py` | 340 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_diffusion_server_security.py` | 325 lines | route/api, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_tool_policy.py` | 330 lines | import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_endpoint_owner_scope_followup.py` | 414 lines | route/api, db/session, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_upload_routes_owner_scope.py` | 315 lines | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_email_owner_scope.py` | 474 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_upload_handler_atomicity.py` | 401 lines | filesystem, async/threading | Plan split boundaries before editing. |
|
||||||
|
| `tests/test_kv_cache_invalidation_2927.py` | 463 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_calendar_owner_scope.py` | 345 lines | route/api, db/session, import-state, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_skills_manager_owner_isolation.py` | 306 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
|
||||||
|
## Taxonomy coverage gaps among split candidates
|
||||||
|
|
||||||
|
`uncategorized` is a current taxonomy area, not a builder failure.
|
||||||
|
This plan does not reclassify tests because taxonomy changes should be reviewed separately from oversized-file split planning.
|
||||||
|
|
||||||
|
Before using any of these files as a split target, first decide whether the taxonomy should be refined in a separate focused issue/PR.
|
||||||
|
|
||||||
|
| File | Lines | Collected tests | Sub-area | Signals | Suggested follow-up |
|
||||||
|
|---|---:|---:|---|---|---|
|
||||||
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | pr_blocker_audit | import-state, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_agent_loop.py` | 469 | 52 | agent_loop | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_service_health.py` | 472 | 47 | service_health | async/threading | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_run_focus.py` | 399 | 47 | run_focus | security, filesystem, subprocess/script, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_endpoint_probing.py` | 411 | 34 | endpoint_probing | route/api, db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_chat_helpers.py` | 264 | 31 | chat_helpers | route/api | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_model_context.py` | 251 | 30 | model_context | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_endpoint_resolver.py` | 148 | 30 | endpoint_resolver | - | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_upload_limits_centralized.py` | 110 | 29 | upload_limits_centralized | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_review_regressions.py` | 930 | 26 | review_regressions | route/api, db/session, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_taxonomy.py` | 145 | 26 | taxonomy | security, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_copilot.py` | 170 | 23 | copilot | - | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_tool_support_heuristic.py` | 166 | 22 | tool_support_heuristic | - | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_platform_compat.py` | 318 | 21 | platform_compat | import-state, filesystem, subprocess/script | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_context_compactor.py` | 233 | 21 | context_compactor | db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_youtube_handler_consolidation.py` | 104 | 20 | youtube_handler_consolidation | route/api, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_workspace_confine.py` | 328 | 18 | workspace_confine | route/api, filesystem, subprocess/script, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_companion_readonly.py` | 402 | 17 | companion_readonly | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_set_admin.py` | 317 | 17 | set_admin | route/api, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_imap_leak_fixes.py` | 404 | 15 | imap_leak_fixes | route/api, db/session, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_agent_migration_manifest.py` | 340 | 15 | agent_migration_manifest | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_tool_policy.py` | 330 | 13 | tool_policy | import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | upload_handler_atomicity | filesystem, async/threading | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
|
||||||
|
## Suggested first manual-review candidates
|
||||||
|
|
||||||
|
These are not automatic split approvals. They are categorized candidates with enough size/collection value and no route/API, DB/session, import-state, or security signal from the static scan.
|
||||||
|
|
||||||
|
Files still in the `uncategorized` taxonomy area are listed separately below so taxonomy review does not get mixed into the first split decision.
|
||||||
|
|
||||||
|
| File | Lines | Collected tests | Area | Sub-area | Signals | Why this is a candidate |
|
||||||
|
|---|---:|---:|---|---|---|---|
|
||||||
|
| `tests/test_provider_classification.py` | 188 | 67 | services | provider | - | 67 collected tests |
|
||||||
|
| `tests/test_provider_endpoints.py` | 241 | 58 | services | provider | subprocess/script | 58 collected tests |
|
||||||
|
| `tests/test_llm_core_temperature.py` | 196 | 41 | services | llm | - | 41 collected tests |
|
||||||
|
| `tests/test_provider_detection.py` | 148 | 31 | services | provider | - | 31 collected tests |
|
||||||
|
| `tests/test_embedding_lanes.py` | 1104 | 29 | services | embedding | filesystem | 1104 lines, 29 collected tests |
|
||||||
|
| `tests/test_research_utils.py` | 97 | 23 | services | research | - | 23 collected tests |
|
||||||
|
| `tests/test_prompt_security.py` | 203 | 21 | security | security | - | 21 collected tests |
|
||||||
|
| `tests/test_calendar_recurrence.py` | 338 | 19 | services | calendar | - | 338 lines |
|
||||||
|
|
||||||
|
## High-risk candidates to defer first
|
||||||
|
|
||||||
|
These files may still be split later, but not as the first implementation slice without a separate manual boundary review.
|
||||||
|
|
||||||
|
| File | Lines | Collected tests | High-risk signals |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `tests/test_model_routes.py` | 1778 | 139 | db/session, import-state, route/api |
|
||||||
|
| `tests/test_security_regressions.py` | 1224 | 92 | db/session, import-state, route/api, security |
|
||||||
|
| `tests/test_cookbook_helpers.py` | 912 | 65 | route/api |
|
||||||
|
| `tests/test_shell_routes.py` | 481 | 63 | import-state, route/api |
|
||||||
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | import-state, security |
|
||||||
|
| `tests/test_agent_loop.py` | 469 | 52 | db/session, import-state |
|
||||||
|
| `tests/test_run_focus.py` | 399 | 47 | security |
|
||||||
|
| `tests/test_endpoint_probing.py` | 411 | 34 | db/session, import-state, route/api |
|
||||||
|
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | db/session |
|
||||||
|
| `tests/test_chat_helpers.py` | 264 | 31 | route/api |
|
||||||
|
| `tests/test_model_context.py` | 251 | 30 | db/session, import-state |
|
||||||
|
| `tests/test_upload_limits_centralized.py` | 110 | 29 | import-state |
|
||||||
|
| `tests/test_email_oauth.py` | 580 | 28 | db/session, route/api, security |
|
||||||
|
| `tests/test_review_regressions.py` | 930 | 26 | db/session, import-state, route/api |
|
||||||
|
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | db/session, import-state, route/api |
|
||||||
|
|
||||||
|
## Rules for future split PRs
|
||||||
|
|
||||||
|
- One file or one coherent file-family per PR.
|
||||||
|
- No assertion rewrites mixed with file moves.
|
||||||
|
- No helper extraction mixed with file moves.
|
||||||
|
- No production code changes.
|
||||||
|
- No CI workflow changes.
|
||||||
|
- Preserve existing markers and taxonomy unless the split issue explicitly says otherwise.
|
||||||
|
- Validate the original file's collected tests before and after the split.
|
||||||
|
- Validate any neighboring taxonomy/focused-runner behavior if paths change.
|
||||||
|
- Treat files with route/API, DB/session, import-state, or security signals as higher-risk until manually reviewed.
|
||||||
|
|
||||||
|
## Suggested next step
|
||||||
|
|
||||||
|
Use this plan to choose the first actual oversized-file split issue.
|
||||||
|
The first split should prefer a file with high review value and low setup risk.
|
||||||
|
Do not start a split PR from this planning issue alone if the file's boundaries are still ambiguous.
|
||||||
|
|
||||||
|
## Reproduction command
|
||||||
|
|
||||||
|
This document was generated with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Freshness check
|
||||||
|
|
||||||
|
After editing the builder or rebasing the branch, regenerate the plan and confirm no unexpected plan drift:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
||||||
|
git diff --exit-code -- tests/OVERSIZED_TEST_SPLIT_PLAN.md
|
||||||
|
```
|
||||||
+26
-26
@@ -22,8 +22,8 @@ markers only - it moves no files and changes no test behavior. Use them to run a
|
|||||||
focused slice:
|
focused slice:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m pytest -m area_security
|
./venv/bin/python -m pytest -m area_security
|
||||||
python3 -m pytest -m "area_services and sub_cookbook"
|
./venv/bin/python -m pytest -m "area_services and sub_cookbook"
|
||||||
```
|
```
|
||||||
|
|
||||||
Areas are `security`, `routes`, `services`, `cli`, `js`, `helpers`, `unit`, and
|
Areas are `security`, `routes`, `services`, `cli`, `js`, `helpers`, `unit`, and
|
||||||
@@ -38,13 +38,13 @@ sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
|
|||||||
extra pytest arguments after `--`:
|
extra pytest arguments after `--`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tests/run_focus.py --area security
|
./venv/bin/python tests/run_focus.py --area security
|
||||||
python3 tests/run_focus.py --area services --sub-area cookbook
|
./venv/bin/python tests/run_focus.py --area services --sub-area cookbook
|
||||||
python3 tests/run_focus.py --sub-area sub_cookbook
|
./venv/bin/python tests/run_focus.py --sub-area sub_cookbook
|
||||||
python3 tests/run_focus.py --keyword taxonomy
|
./venv/bin/python tests/run_focus.py --keyword taxonomy
|
||||||
python3 tests/run_focus.py --last-failed
|
./venv/bin/python tests/run_focus.py --last-failed
|
||||||
python3 tests/run_focus.py --dry-run --area services --sub-area cookbook
|
./venv/bin/python tests/run_focus.py --dry-run --area services --sub-area cookbook
|
||||||
python3 tests/run_focus.py --area services -- --maxfail=1 -q
|
./venv/bin/python tests/run_focus.py --area services -- --maxfail=1 -q
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fast lane and duration visibility
|
### Fast lane and duration visibility
|
||||||
@@ -61,15 +61,15 @@ so you can see where time goes. They are reporting only and do not count as a
|
|||||||
focus selector, so `--durations` must be combined with a real selector
|
focus selector, so `--durations` must be combined with a real selector
|
||||||
(`--area`, `--sub-area`, `--keyword`, `--last-failed`, or `--fast`).
|
(`--area`, `--sub-area`, `--keyword`, `--last-failed`, or `--fast`).
|
||||||
|
|
||||||
Activate or otherwise use the project Python environment before running these
|
Use the project Python environment before running these commands. The examples
|
||||||
commands. The examples use `python3` intentionally to avoid hard-coding a local
|
use the repo's documented `./venv/bin/python` path so they do not accidentally
|
||||||
venv path.
|
fall back to system Python.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tests/run_focus.py --fast
|
./venv/bin/python tests/run_focus.py --fast
|
||||||
python3 tests/run_focus.py --area services --fast
|
./venv/bin/python tests/run_focus.py --area services --fast
|
||||||
python3 tests/run_focus.py --area services --durations 25
|
./venv/bin/python tests/run_focus.py --area services --durations 25
|
||||||
python3 tests/run_focus.py --area services --fast --durations 25 --durations-min 0.05
|
./venv/bin/python tests/run_focus.py --area services --fast --durations 25 --durations-min 0.05
|
||||||
```
|
```
|
||||||
|
|
||||||
The `slow` marker is opt-in. Mark a test `slow` only with duration evidence
|
The `slow` marker is opt-in. Mark a test `slow` only with duration evidence
|
||||||
@@ -79,8 +79,8 @@ replace the full suite before merge. A `slow` mark only excludes a test from the
|
|||||||
fast lane; the test stays runnable directly, e.g.:
|
fast lane; the test stays runnable directly, e.g.:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m pytest tests/test_auth_config_lock_concurrency.py
|
./venv/bin/python -m pytest tests/test_auth_config_lock_concurrency.py
|
||||||
python3 -m pytest -m slow
|
./venv/bin/python -m pytest -m slow
|
||||||
```
|
```
|
||||||
|
|
||||||
## Order-sensitivity reporting (report-only)
|
## Order-sensitivity reporting (report-only)
|
||||||
@@ -93,8 +93,8 @@ ordering - the shuffle exists only inside this runner. The seed is always
|
|||||||
printed, and pytest targets/options go after a literal `--`:
|
printed, and pytest targets/options go after a literal `--`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tests/run_order_report.py --seed 123 -- tests/cli/ -q
|
./venv/bin/python tests/run_order_report.py --seed 123 -- tests/cli/ -q
|
||||||
python3 tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
|
./venv/bin/python tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
|
||||||
```
|
```
|
||||||
|
|
||||||
The same seed reproduces the same order when the reported working directory,
|
The same seed reproduces the same order when the reported working directory,
|
||||||
@@ -108,7 +108,7 @@ A generated-seed run starts with output like:
|
|||||||
[order-report] working directory: /path/to/odysseus
|
[order-report] working directory: /path/to/odysseus
|
||||||
[order-report] shuffling test order with seed 284734921
|
[order-report] shuffling test order with seed 284734921
|
||||||
[order-report] reproduce from this working directory with the same test environment:
|
[order-report] reproduce from this working directory with the same test environment:
|
||||||
[order-report] reproduce with: /path/to/odysseus/.venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
|
[order-report] reproduce with: /path/to/odysseus/venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the printed command from the reported working directory to reproduce the
|
Run the printed command from the reported working directory to reproduce the
|
||||||
@@ -118,7 +118,7 @@ same fixed-seed order:
|
|||||||
[order-report] working directory: /path/to/odysseus
|
[order-report] working directory: /path/to/odysseus
|
||||||
[order-report] shuffling test order with seed 284734921
|
[order-report] shuffling test order with seed 284734921
|
||||||
[order-report] reproduce from this working directory with the same test environment:
|
[order-report] reproduce from this working directory with the same test environment:
|
||||||
[order-report] reproduce with: /path/to/odysseus/.venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
|
[order-report] reproduce with: /path/to/odysseus/venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Pytest output remains visible between the report header and footer. A failing
|
Pytest output remains visible between the report header and footer. A failing
|
||||||
@@ -237,10 +237,10 @@ helpers:
|
|||||||
Run validation locally before opening or approving a PR. Practical checks:
|
Run validation locally before opening or approving a PR. Practical checks:
|
||||||
|
|
||||||
- `git diff --check` - catch whitespace and conflict-marker errors.
|
- `git diff --check` - catch whitespace and conflict-marker errors.
|
||||||
- `python3 -m py_compile <changed files>` - confirm changed files compile.
|
- `./venv/bin/python -m py_compile <changed files>` - confirm changed files compile.
|
||||||
- Focused `pytest` on the changed test files.
|
- Focused `./venv/bin/python -m pytest` on the changed test files.
|
||||||
- `pytest` on neighboring or order-sensitive test groups that share import
|
- `./venv/bin/python -m pytest` on neighboring or order-sensitive test groups
|
||||||
state with the changed files.
|
that share import state with the changed files.
|
||||||
- `grep` for the old boilerplate when replacing it, to confirm no stragglers
|
- `grep` for the old boilerplate when replacing it, to confirm no stragglers
|
||||||
remain.
|
remain.
|
||||||
- A fresh audit worktree when changing the helpers themselves, so stale
|
- A fresh audit worktree when changing the helpers themselves, so stale
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The goal is not only to reorganize `tests/`. The goal is for the suite to be a
|
|||||||
reliable foundation for future development: deterministic, modular, informative,
|
reliable foundation for future development: deterministic, modular, informative,
|
||||||
behavior-focused, and complete enough to replace manual QA wherever practical.
|
behavior-focused, and complete enough to replace manual QA wherever practical.
|
||||||
|
|
||||||
Run tests with the project virtualenv interpreter (`.venv/bin/python -m pytest`).
|
Run tests with the project virtualenv interpreter (`./venv/bin/python -m pytest`).
|
||||||
The system `python3` may be missing pinned dependencies (e.g. `nh3`), which
|
The system `python3` may be missing pinned dependencies (e.g. `nh3`), which
|
||||||
shows up as import/collection errors that are environmental, not real failures.
|
shows up as import/collection errors that are environmental, not real failures.
|
||||||
|
|
||||||
@@ -172,10 +172,10 @@ Prefer tests that exercise real behavior over tests that inspect source code.
|
|||||||
Run locally before opening or approving a refactor PR:
|
Run locally before opening or approving a refactor PR:
|
||||||
|
|
||||||
- `git diff --check` - whitespace and conflict-marker errors.
|
- `git diff --check` - whitespace and conflict-marker errors.
|
||||||
- `python3 -m py_compile <changed .py files>` - changed files compile.
|
- `./venv/bin/python -m py_compile <changed .py files>` - changed files compile.
|
||||||
- Focused `pytest` on the changed files (use `.venv/bin/python -m pytest`).
|
- Focused `./venv/bin/python -m pytest` on the changed files.
|
||||||
- `pytest` on neighboring / order-sensitive groups that share import state with
|
- `./venv/bin/python -m pytest` on neighboring / order-sensitive groups that
|
||||||
the changed files.
|
share import state with the changed files.
|
||||||
- When replacing boilerplate, `grep` for the old pattern to confirm no stragglers.
|
- When replacing boilerplate, `grep` for the old pattern to confirm no stragglers.
|
||||||
- When changing a helper itself, validate in a fresh worktree so stale
|
- When changing a helper itself, validate in a fresh worktree so stale
|
||||||
`__pycache__` or import state cannot mask a regression.
|
`__pycache__` or import state cannot mask a regression.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from src import ai_interaction
|
||||||
|
|
||||||
|
|
||||||
|
class _GenerationResponse:
|
||||||
|
status_code = 200
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
def __init__(self, image_url):
|
||||||
|
self._image_url = image_url
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"data": [{"url": self._image_url}]}
|
||||||
|
|
||||||
|
|
||||||
|
class _DownloadResponse:
|
||||||
|
status_code = 503
|
||||||
|
content = b""
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_generation(monkeypatch, image_url):
|
||||||
|
async def _post(self, url, json, headers):
|
||||||
|
return _GenerationResponse(image_url)
|
||||||
|
|
||||||
|
class _AsyncClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
post = _post
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import src.settings as settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: {})
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _AsyncClient)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_interaction,
|
||||||
|
"_resolve_model",
|
||||||
|
lambda model_spec, owner=None: (
|
||||||
|
"https://api.openai.example/v1/chat/completions",
|
||||||
|
"dall-e-3",
|
||||||
|
{"Authorization": "Bearer test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_image_validates_provider_url_before_download(monkeypatch):
|
||||||
|
import httpx
|
||||||
|
import src.url_safety as url_safety
|
||||||
|
|
||||||
|
provider_url = "https://images.example.com/generated.png?sig=abc"
|
||||||
|
events = []
|
||||||
|
_patch_generation(monkeypatch, provider_url)
|
||||||
|
|
||||||
|
def _check_outbound_url(url, *, block_private=False):
|
||||||
|
events.append(("check", url, block_private))
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
def _get(url, *, timeout):
|
||||||
|
events.append(("get", url, timeout))
|
||||||
|
return _DownloadResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(url_safety, "check_outbound_url", _check_outbound_url)
|
||||||
|
monkeypatch.setattr(httpx, "get", _get)
|
||||||
|
|
||||||
|
result = await ai_interaction.do_generate_image("draw a chair\ndall-e-3")
|
||||||
|
|
||||||
|
assert result["image_url"] == provider_url
|
||||||
|
assert events == [
|
||||||
|
("check", provider_url, False),
|
||||||
|
("get", provider_url, 60),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_image_rejects_unsafe_provider_url_without_download(monkeypatch):
|
||||||
|
import httpx
|
||||||
|
import src.url_safety as url_safety
|
||||||
|
|
||||||
|
unsafe_url = "http://169.254.169.254/latest/meta-data"
|
||||||
|
events = []
|
||||||
|
_patch_generation(monkeypatch, unsafe_url)
|
||||||
|
|
||||||
|
def _check_outbound_url(url, *, block_private=False):
|
||||||
|
events.append(("check", url, block_private))
|
||||||
|
return False, "link-local address blocked (SSRF metadata risk): 169.254.169.254"
|
||||||
|
|
||||||
|
def _get(url, *, timeout):
|
||||||
|
raise AssertionError("unsafe provider image URL must not be downloaded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(url_safety, "check_outbound_url", _check_outbound_url)
|
||||||
|
monkeypatch.setattr(httpx, "get", _get)
|
||||||
|
|
||||||
|
result = await ai_interaction.do_generate_image("draw a chair\ndall-e-3")
|
||||||
|
|
||||||
|
assert result["error"] == (
|
||||||
|
"Image API returned unsafe image URL: "
|
||||||
|
"link-local address blocked (SSRF metadata risk): 169.254.169.254"
|
||||||
|
)
|
||||||
|
assert events == [("check", unsafe_url, False)]
|
||||||
@@ -3,6 +3,7 @@ import inspect
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import ai_interaction
|
from src import ai_interaction
|
||||||
|
from src.agent_tools import model_interaction_tools
|
||||||
|
|
||||||
|
|
||||||
def _source(fn) -> str:
|
def _source(fn) -> str:
|
||||||
@@ -18,7 +19,8 @@ def test_model_resolver_applies_owner_filter():
|
|||||||
|
|
||||||
|
|
||||||
def test_model_listing_and_image_fallback_are_owner_scoped():
|
def test_model_listing_and_image_fallback_are_owner_scoped():
|
||||||
list_body = _source(ai_interaction.do_list_models)
|
# list_models moved to agent_tools.model_interaction_tools (#3629).
|
||||||
|
list_body = _source(model_interaction_tools.list_models)
|
||||||
image_body = _source(ai_interaction.do_generate_image)
|
image_body = _source(ai_interaction.do_generate_image)
|
||||||
|
|
||||||
assert "owner: Optional[str] = None" in list_body
|
assert "owner: Optional[str] = None" in list_body
|
||||||
@@ -28,12 +30,13 @@ def test_model_listing_and_image_fallback_are_owner_scoped():
|
|||||||
assert "_resolve_model(model_spec, owner=owner)" in image_body
|
assert "_resolve_model(model_spec, owner=owner)" in image_body
|
||||||
|
|
||||||
|
|
||||||
|
# chat_with_model, list_models and ask_teacher moved to the registry (#3629)
|
||||||
|
# and no longer route through dispatch_ai_tool; their owner threading is covered
|
||||||
|
# by tests/test_model_interaction_registry.py. The remaining model-ish tools
|
||||||
|
# still dispatched here:
|
||||||
@pytest.mark.parametrize("tool,content", [
|
@pytest.mark.parametrize("tool,content", [
|
||||||
("chat_with_model", "gpt-test\nhello"),
|
|
||||||
("pipeline", "gpt-test | summarize this"),
|
("pipeline", "gpt-test | summarize this"),
|
||||||
("list_models", ""),
|
|
||||||
("ui_control", "switch_model gpt-test"),
|
("ui_control", "switch_model gpt-test"),
|
||||||
("ask_teacher", "gpt-test\nhelp me"),
|
|
||||||
])
|
])
|
||||||
async def test_dispatch_passes_owner_to_model_tools(monkeypatch, tool, content):
|
async def test_dispatch_passes_owner_to_model_tools(monkeypatch, tool, content):
|
||||||
seen = {}
|
seen = {}
|
||||||
@@ -42,31 +45,16 @@ async def test_dispatch_passes_owner_to_model_tools(monkeypatch, tool, content):
|
|||||||
seen[name] = {"content": content, "session_id": session_id, "owner": owner}
|
seen[name] = {"content": content, "session_id": session_id, "owner": owner}
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
ai_interaction,
|
|
||||||
"do_chat_with_model",
|
|
||||||
lambda content, session_id=None, owner=None: capture("chat_with_model", content, session_id, owner),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
ai_interaction,
|
ai_interaction,
|
||||||
"do_pipeline",
|
"do_pipeline",
|
||||||
lambda content, session_id=None, owner=None: capture("pipeline", content, session_id, owner),
|
lambda content, session_id=None, owner=None: capture("pipeline", content, session_id, owner),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
|
||||||
ai_interaction,
|
|
||||||
"do_list_models",
|
|
||||||
lambda content, session_id=None, owner=None: capture("list_models", content, session_id, owner),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
ai_interaction,
|
ai_interaction,
|
||||||
"do_ui_control",
|
"do_ui_control",
|
||||||
lambda content, session_id=None, owner=None: capture("ui_control", content, session_id, owner),
|
lambda content, session_id=None, owner=None: capture("ui_control", content, session_id, owner),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
|
||||||
ai_interaction,
|
|
||||||
"do_ask_teacher",
|
|
||||||
lambda content, session_id=None, owner=None: capture("ask_teacher", content, session_id, owner),
|
|
||||||
)
|
|
||||||
|
|
||||||
_desc, result = await ai_interaction.dispatch_ai_tool(tool, content, session_id="sid1", owner="alice")
|
_desc, result = await ai_interaction.dispatch_ai_tool(tool, content, session_id="sid1", owner="alice")
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ class _WebhookManager:
|
|||||||
async def fire(self, event, payload):
|
async def fire(self, event, payload):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def fire_and_forget(self, event, payload):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _install_sync_chat_stubs(monkeypatch):
|
def _install_sync_chat_stubs(monkeypatch):
|
||||||
# FastAPI checks for python_multipart at import time when Form is used;
|
# FastAPI checks for python_multipart at import time when Form is used;
|
||||||
|
|||||||
@@ -502,3 +502,77 @@ def test_delete_token_owner_check_skipped_when_auth_disabled(monkeypatch, token_
|
|||||||
resp = delete_token(request=req, token_id="tok123")
|
resp = delete_token(request=req, token_id="tok123")
|
||||||
assert resp == {"status": "deleted"}
|
assert resp == {"status": "deleted"}
|
||||||
fake_session.delete.assert_called_once_with(fake_token)
|
fake_session.delete.assert_called_once_with(fake_token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. PATCH /api/tokens/{id} — non-object JSON bodies must not 500
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_token_with_array_body_does_not_500(monkeypatch, token_routes_mod):
|
||||||
|
"""PATCH body of [] must be normalised to {} and not raise."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
mod = token_routes_mod
|
||||||
|
|
||||||
|
token = SimpleNamespace(
|
||||||
|
id="tok123", name="original", owner="alice",
|
||||||
|
token_prefix="ody_orig", scopes="email:read", is_active=True,
|
||||||
|
)
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||||
|
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||||
|
|
||||||
|
invalidator = MagicMock()
|
||||||
|
req = _patch_request(invalidator, [])
|
||||||
|
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||||
|
resp = asyncio.run(update_token(request=req, token_id="tok123"))
|
||||||
|
|
||||||
|
# Name and scopes must be unchanged — payload was normalised to {}
|
||||||
|
assert token.name == "original"
|
||||||
|
assert token.scopes == "email:read"
|
||||||
|
assert resp["name"] == "original"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_token_with_null_body_does_not_500(monkeypatch, token_routes_mod):
|
||||||
|
"""PATCH body of null must be normalised to {} and not raise."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
mod = token_routes_mod
|
||||||
|
|
||||||
|
token = SimpleNamespace(
|
||||||
|
id="tok123", name="original", owner="alice",
|
||||||
|
token_prefix="ody_orig", scopes="chat", is_active=True,
|
||||||
|
)
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||||
|
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||||
|
|
||||||
|
invalidator = MagicMock()
|
||||||
|
req = _patch_request(invalidator, None)
|
||||||
|
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||||
|
resp = asyncio.run(update_token(request=req, token_id="tok123"))
|
||||||
|
|
||||||
|
assert token.name == "original"
|
||||||
|
assert token.scopes == "chat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_token_normal_object_still_works(monkeypatch, token_routes_mod):
|
||||||
|
"""Normal dict payload continues to update fields as before."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
mod = token_routes_mod
|
||||||
|
|
||||||
|
token = SimpleNamespace(
|
||||||
|
id="tok123", name="original", owner="alice",
|
||||||
|
token_prefix="ody_orig", scopes="email:read", is_active=True,
|
||||||
|
)
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||||
|
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||||
|
|
||||||
|
invalidator = MagicMock()
|
||||||
|
req = _patch_request(invalidator, {"name": "updated"})
|
||||||
|
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||||
|
resp = asyncio.run(update_token(request=req, token_id="tok123"))
|
||||||
|
|
||||||
|
assert token.name == "updated"
|
||||||
|
assert resp["name"] == "updated"
|
||||||
|
invalidator.assert_called_once()
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Tests for auth policy endpoint and password length validation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from tests.helpers.import_state import clear_module
|
||||||
|
|
||||||
|
|
||||||
|
def _real_core_package():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
core_path = str(root / "core")
|
||||||
|
core = sys.modules.get("core")
|
||||||
|
if core is None:
|
||||||
|
core = types.ModuleType("core")
|
||||||
|
sys.modules["core"] = core
|
||||||
|
core.__path__ = [core_path]
|
||||||
|
clear_module("core.auth")
|
||||||
|
return core
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_module():
|
||||||
|
_real_core_package()
|
||||||
|
return importlib.import_module("core.auth")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manager(tmp_path):
|
||||||
|
auth_mod = _auth_module()
|
||||||
|
auth_mod._hash_password = lambda password: f"hash:{password}"
|
||||||
|
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
|
||||||
|
auth_path = tmp_path / "auth.json"
|
||||||
|
mgr = auth_mod.AuthManager(str(auth_path))
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
async def _immediate_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ── AuthManager.policy() ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_password_min_length(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert policy["password_min_length"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_reserved_usernames(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert "internal-tool" in policy["reserved_usernames"]
|
||||||
|
assert "api" in policy["reserved_usernames"]
|
||||||
|
assert "demo" in policy["reserved_usernames"]
|
||||||
|
assert "system" in policy["reserved_usernames"]
|
||||||
|
assert isinstance(policy["reserved_usernames"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_signup_enabled(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert policy["signup_enabled"] is False # default
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_session_days(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert policy["session_days"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/auth/policy endpoint ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/policy":
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("policy route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_endpoint_returns_dict(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint = _policy_endpoint(mgr)
|
||||||
|
result = asyncio.run(endpoint())
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "password_min_length" in result
|
||||||
|
assert "reserved_usernames" in result
|
||||||
|
assert "signup_enabled" in result
|
||||||
|
assert "session_days" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_endpoint_values_match_manager(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint = _policy_endpoint(mgr)
|
||||||
|
result = asyncio.run(endpoint())
|
||||||
|
assert result == mgr.policy()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password length validation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import SetupRequest, setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/setup":
|
||||||
|
return route.endpoint, SetupRequest
|
||||||
|
raise AssertionError("setup route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _signup_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import SignupRequest, setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/signup":
|
||||||
|
return route.endpoint, SignupRequest
|
||||||
|
raise AssertionError("signup route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _change_password_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import ChangePasswordRequest, setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/change-password":
|
||||||
|
return route.endpoint, ChangePasswordRequest
|
||||||
|
raise AssertionError("change-password route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_short_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SetupRequest(username="admin", password="short")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "8 characters" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_rejects_short_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
mgr.create_user("admin", "admin-password", is_admin=True)
|
||||||
|
mgr.signup_enabled = True
|
||||||
|
endpoint, SignupRequest = _signup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SignupRequest(username="newuser", password="short")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "8 characters" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_rejects_short_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
mgr.create_user("alice", "old-password", is_admin=False)
|
||||||
|
endpoint, ChangePasswordRequest = _change_password_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
cookies={"odysseus_session": "current-token"},
|
||||||
|
client=SimpleNamespace(host="127.0.0.1"),
|
||||||
|
)
|
||||||
|
# Mock get_username_for_token to return alice
|
||||||
|
mgr.get_username_for_token = MagicMock(return_value="alice")
|
||||||
|
body = ChangePasswordRequest(current_password="old-password", new_password="short")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "8 characters" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_accepts_exactly_min_length_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SetupRequest(username="admin", password="12345678")
|
||||||
|
|
||||||
|
result = asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert result == {"ok": True, "message": "Admin account created"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_seven_char_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SetupRequest(username="admin", password="1234567")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user