diff --git a/README.md b/README.md index a320f0052..a0dde96a9 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ To expose Odysseus on a local network or Tailscale with HTTPS: | Package | Feature unlocked | |---------|-----------------| | `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. | -| `duckduckgo-search` | DuckDuckGo as a search provider option. | +| `ddgs` | DuckDuckGo as a search provider option. | | `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) | | `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). | diff --git a/app.py b/app.py index cfd73e83f..365eee94a 100644 --- a/app.py +++ b/app.py @@ -56,7 +56,7 @@ from core.constants import ( ) from core.database import SessionLocal, ApiToken from core.middleware import SecurityHeadersMiddleware, is_cors_preflight -from core.auth import AuthManager +from core.auth import AuthManager, normalize_known_username from core.exceptions import ( SessionNotFoundError, InvalidFileUploadError, LLMServiceError, WebSearchError, @@ -228,8 +228,16 @@ if AUTH_ENABLED: try: rows = db.query(ApiToken).filter(ApiToken.is_active == True).all() for r in rows: + owner_key = normalize_known_username(auth_manager.users, getattr(r, "owner", None)) + if not owner_key: + logger.warning( + "Ignoring active API token '%s' for unknown auth user '%s'", + getattr(r, "id", ""), + getattr(r, "owner", None), + ) + continue scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()] - new_map[r.token_prefix].append((r.id, r.token_hash, getattr(r, "owner", None), scopes)) + new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes)) finally: db.close() _token_cache.clear() @@ -495,6 +503,7 @@ api_key_manager = components["api_key_manager"] preset_manager = components["preset_manager"] chat_processor = components["chat_processor"] research_handler = components["research_handler"] +app.state.research_handler = research_handler chat_handler = components["chat_handler"] model_discovery = components["model_discovery"] skills_manager = components["skills_manager"] @@ -938,16 +947,21 @@ async def _startup_event(): async def _warmup_endpoints(): try: import httpx - endpoints = model_discovery.get_endpoints() if model_discovery else [] - for ep in endpoints[:5]: - url = ep.get("url", "").replace("/chat/completions", "/models") - if url: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - await client.get(url) - logger.info(f"Warmup ping OK: {url}") - except Exception as e: - logger.debug(f"Warmup ping failed for endpoint: {e}") + # model_discovery has no get_endpoints(); that call raised + # AttributeError every run and silently disabled warmup/keepalive. + # Resolve the /models probe URLs via the real discovery API, off the + # event loop since discovery does a blocking port scan. + urls = ( + await asyncio.to_thread(model_discovery.warmup_ping_urls) + if model_discovery else [] + ) + for url in urls: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.get(url) + logger.info(f"Warmup ping OK: {url}") + except Exception as e: + logger.debug(f"Warmup ping failed for endpoint: {e}") except Exception as e: logger.debug(f"Warmup ping skipped: {e}") diff --git a/core/auth.py b/core/auth.py index 5db2fed4c..2f9fd4e51 100644 --- a/core/auth.py +++ b/core/auth.py @@ -67,6 +67,14 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"}) +def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]: + """Return a normalized username only when it exists in the auth user map.""" + key = str(username or "").strip().lower() + if not key or key not in users: + return None + return key + + def _hash_password(password: str) -> str: return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") @@ -96,6 +104,7 @@ class AuthManager: self._load() self._load_sessions() self._migrate_single_user() + self._drop_reserved_loaded_users() self._migrate_legacy_admin_role() def _load(self): @@ -148,7 +157,13 @@ class AuthManager: def _migrate_single_user(self): """Migrate old single-user format to multi-user format.""" if "password_hash" in self._config and "users" not in self._config: - old_user = self._config.get("username", "admin") + old_user = str(self._config.get("username", "admin") or "admin").strip().lower() + if old_user in RESERVED_USERNAMES: + logger.warning( + "Migrating legacy single-user reserved username '%s' to 'admin'", + old_user, + ) + old_user = "admin" old_hash = self._config["password_hash"] self._config = { "users": { @@ -162,6 +177,30 @@ class AuthManager: self._save() logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})") + def _drop_reserved_loaded_users(self): + """Fail closed for legacy/manual auth rows that collide with sentinels.""" + users = self._config.get("users") + if not isinstance(users, dict): + return + normalized = {} + removed = [] + for username, data in users.items(): + key = str(username or "").strip().lower() + if not key: + continue + if key in RESERVED_USERNAMES: + removed.append(key) + continue + normalized[key] = data + if removed or normalized != users: + self._config["users"] = normalized + self._save() + if removed: + logger.warning( + "Removed reserved username(s) from auth config: %s", + ", ".join(sorted(set(removed))), + ) + def _migrate_legacy_admin_role(self): """Normalize setup.py's old role='admin' marker to is_admin=True.""" changed = False @@ -244,6 +283,22 @@ class AuthManager: return False if not self.users.get(requesting_user, {}).get("is_admin"): return False + # Revoke API bearer tokens before removing the auth row. The bearer + # path authenticates from ApiToken rows and does not require the + # owner to still exist, so a successful delete must not leave active + # rows behind. If the token store is unavailable, fail closed and + # keep the user/session state intact so the admin can retry. + try: + from core.database import get_db_session, ApiToken + with get_db_session() as db: + removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete() + if removed_tokens: + logger.info( + f"Revoked {removed_tokens} API token(s) owned by deleted user '{username}'" + ) + except Exception: + logger.warning(f"Failed to revoke API tokens for deleted user '{username}'") + return False del self._config["users"][username] self._save() # Purge all sessions belonging to this user. validate_token doesn't @@ -258,18 +313,6 @@ class AuthManager: revoked += 1 if revoked: self._save_sessions() - # Also revoke API bearer tokens owned by this user. The bearer auth - # path authenticates straight against ApiToken rows and never - # re-checks that the owner still exists, so leaving the rows behind - # would let a deleted user keep full API access indefinitely. - try: - from core.database import get_db_session, ApiToken - with get_db_session() as db: - removed = db.query(ApiToken).filter(ApiToken.owner == username).delete() - if removed: - logger.info(f"Revoked {removed} API token(s) owned by deleted user '{username}'") - except Exception: - logger.warning(f"Failed to revoke API tokens for deleted user '{username}'") logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)") return True diff --git a/core/database.py b/core/database.py index ee365c30c..6eec48d11 100644 --- a/core/database.py +++ b/core/database.py @@ -688,6 +688,7 @@ def _migrate_add_last_message_at_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -713,10 +714,14 @@ def _migrate_add_last_message_at_column(): "ON sessions(archived, last_message_at)" ) conn.commit() - conn.close() logging.getLogger(__name__).info("Migrated: added + backfilled 'last_message_at' on sessions") except Exception as e: logging.getLogger(__name__).warning(f"last_message_at migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_document_archived_column(): """Add `archived` to documents (soft-archive flag). Guarded + idempotent.""" @@ -724,6 +729,7 @@ def _migrate_add_document_archived_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(documents)") @@ -732,9 +738,13 @@ def _migrate_add_document_archived_column(): conn.execute("ALTER TABLE documents ADD COLUMN archived BOOLEAN DEFAULT 0") conn.commit() logging.getLogger(__name__).info("Migrated: added 'archived' to documents") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"documents.archived migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_owner_column(): @@ -743,6 +753,7 @@ def _migrate_add_owner_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -752,9 +763,13 @@ def _migrate_add_owner_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_sessions_owner ON sessions(owner)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'owner' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_model_endpoints(): """Recreate model_endpoints table if schema changed (url->base_url).""" @@ -762,6 +777,7 @@ def _migrate_model_endpoints(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -770,9 +786,13 @@ def _migrate_model_endpoints(): conn.execute("DROP TABLE IF EXISTS model_endpoints") conn.commit() logging.getLogger(__name__).info("Migrated: dropped old model_endpoints table (schema change)") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints migration check failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_hidden_models_column(): """Add hidden_models column to model_endpoints if it doesn't exist.""" @@ -780,6 +800,7 @@ def _migrate_add_hidden_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -788,9 +809,13 @@ def _migrate_add_hidden_models_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN hidden_models TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'hidden_models' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"hidden_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_endpoint_owner_column(): """Add owner column to model_endpoints if it doesn't exist. @@ -805,6 +830,7 @@ def _migrate_add_model_endpoint_owner_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -814,9 +840,13 @@ def _migrate_add_model_endpoint_owner_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_owner ON model_endpoints(owner)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'owner' column + index to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints.owner migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_provider_auth_id_column(): @@ -825,6 +855,7 @@ def _migrate_add_provider_auth_id_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -834,9 +865,13 @@ def _migrate_add_provider_auth_id_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_provider_auth_id ON model_endpoints(provider_auth_id)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'provider_auth_id' column + index to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints.provider_auth_id migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_type_column(): @@ -845,6 +880,7 @@ def _migrate_add_model_type_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -853,9 +889,13 @@ def _migrate_add_model_type_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_type TEXT DEFAULT 'llm'") conn.commit() logging.getLogger(__name__).info("Migrated: added 'model_type' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_type migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_endpoint_refresh_columns(): """Add endpoint classification / refresh policy columns if missing.""" @@ -863,6 +903,7 @@ def _migrate_add_model_endpoint_refresh_columns(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -876,9 +917,13 @@ def _migrate_add_model_endpoint_refresh_columns(): if columns and "model_refresh_timeout" not in columns: conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_refresh_timeout INTEGER") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints refresh-policy migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_task_run_model_column(): """Add model column to task_runs if it doesn't exist (records which model ran).""" @@ -886,6 +931,7 @@ def _migrate_add_task_run_model_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(task_runs)") @@ -894,9 +940,13 @@ def _migrate_add_task_run_model_column(): conn.execute("ALTER TABLE task_runs ADD COLUMN model TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'model' column to task_runs") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"task_runs model migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_supports_tools_column(): """Add supports_tools column to model_endpoints if it doesn't exist.""" @@ -904,6 +954,7 @@ def _migrate_add_supports_tools_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -912,9 +963,13 @@ def _migrate_add_supports_tools_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN supports_tools BOOLEAN") conn.commit() logging.getLogger(__name__).info("Migrated: added 'supports_tools' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"supports_tools migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_cached_models_column(): @@ -923,6 +978,7 @@ def _migrate_add_cached_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -930,9 +986,13 @@ def _migrate_add_cached_models_column(): if columns and "cached_models" not in columns: conn.execute("ALTER TABLE model_endpoints ADD COLUMN cached_models TEXT") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"cached_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_pinned_models_column(): """Add pinned_models column to model_endpoints if it doesn't exist.""" @@ -940,6 +1000,7 @@ def _migrate_add_pinned_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -948,9 +1009,13 @@ def _migrate_add_pinned_models_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN pinned_models TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'pinned_models' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"pinned_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_notes_sort_order(): """Add sort_order, image_url, repeat columns to notes if they don't exist.""" @@ -958,6 +1023,7 @@ def _migrate_add_notes_sort_order(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(notes)") @@ -975,9 +1041,13 @@ def _migrate_add_notes_sort_order(): if columns and "agent_session_id" not in columns: conn.execute("ALTER TABLE notes ADD COLUMN agent_session_id TEXT") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"notes migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_mode_column(): """Add mode column to sessions table if it doesn't exist.""" @@ -985,6 +1055,7 @@ def _migrate_add_mode_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -993,9 +1064,13 @@ def _migrate_add_mode_column(): conn.execute("ALTER TABLE sessions ADD COLUMN mode TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'mode' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for mode failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_folder_column(): """Add folder column to sessions table if it doesn't exist.""" @@ -1003,6 +1078,7 @@ def _migrate_add_folder_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -1011,9 +1087,13 @@ def _migrate_add_folder_column(): conn.execute("ALTER TABLE sessions ADD COLUMN folder TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'folder' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for folder failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_token_columns(): """Add cumulative token tracking columns to sessions table.""" @@ -1021,6 +1101,7 @@ def _migrate_add_token_columns(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -1030,9 +1111,13 @@ def _migrate_add_token_columns(): conn.execute("ALTER TABLE sessions ADD COLUMN total_output_tokens INTEGER DEFAULT 0") conn.commit() logging.getLogger(__name__).info("Migrated: added token tracking columns to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for token columns failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_owner_to_table(table_name: str, index_name: str): """Generic helper: add owner TEXT column + index to a table if missing.""" @@ -1040,6 +1125,7 @@ def _migrate_add_owner_to_table(table_name: str, index_name: str): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute(f"PRAGMA table_info({table_name})") @@ -1049,9 +1135,13 @@ def _migrate_add_owner_to_table(table_name: str, index_name: str): conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name}(owner)") conn.commit() logging.getLogger(__name__).info(f"Migrated: added 'owner' column to {table_name}") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration owner column for {table_name} failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_multiuser_owner_columns(): """Add owner column to memories, gallery_images, user_tools, comparisons.""" @@ -1076,6 +1166,7 @@ def _migrate_add_api_token_scopes_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) columns = [row[1] for row in conn.execute("PRAGMA table_info(api_tokens)").fetchall()] @@ -1084,9 +1175,13 @@ def _migrate_add_api_token_scopes_column(): conn.execute("UPDATE api_tokens SET scopes = 'chat' WHERE scopes IS NULL OR scopes = ''") conn.commit() logging.getLogger(__name__).info("Migrated: added scopes column to api_tokens") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"api_tokens.scopes migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_assign_legacy_owner(): """Assign all null-owner data to the first (admin) user. @@ -1128,6 +1223,7 @@ def _migrate_assign_legacy_owner(): return logger = logging.getLogger(__name__) + conn = None try: conn = sqlite3.connect(db_path) # Every table with an `owner` column. New tables added later will be @@ -1152,9 +1248,13 @@ def _migrate_assign_legacy_owner(): except Exception as e: logger.warning(f"Legacy owner assignment for {table} failed: {e}") conn.commit() - conn.close() except Exception as e: logger.warning(f"Legacy owner migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass # Also migrate memory.json mem_path = MEMORY_FILE @@ -1773,6 +1873,7 @@ def _migrate_add_email_smtp_security(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(email_accounts)") @@ -1788,9 +1889,13 @@ def _migrate_add_email_smtp_security(): ) conn.commit() logging.getLogger(__name__).info("Migrated: added smtp_security column to email_accounts") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"smtp_security migration skipped: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_encrypt_endpoint_keys(): @@ -1891,6 +1996,7 @@ def _migrate_add_calendar_is_utc(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1899,9 +2005,13 @@ def _migrate_add_calendar_is_utc(): conn.execute("ALTER TABLE calendar_events ADD COLUMN is_utc BOOLEAN DEFAULT 0 NOT NULL") conn.commit() logging.getLogger(__name__).info("Migrated: added 'is_utc' column to calendar_events") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"is_utc migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_origin(): @@ -1912,6 +2022,7 @@ def _migrate_add_calendar_origin(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1921,9 +2032,13 @@ def _migrate_add_calendar_origin(): conn.execute("CREATE INDEX IF NOT EXISTS ix_calendar_events_origin ON calendar_events(origin)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'origin' column to calendar_events") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_account_id(): @@ -1933,6 +2048,7 @@ def _migrate_add_calendar_account_id(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendars)") @@ -1942,9 +2058,13 @@ def _migrate_add_calendar_account_id(): conn.execute("CREATE INDEX IF NOT EXISTS ix_calendars_account_id ON calendars(account_id)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'account_id' column to calendars") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendars.account_id migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_metadata(): @@ -1953,6 +2073,7 @@ def _migrate_add_calendar_metadata(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1964,9 +2085,13 @@ def _migrate_add_calendar_metadata(): if columns and "last_pinged" not in columns: conn.execute("ALTER TABLE calendar_events ADD COLUMN last_pinged DATETIME") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendar_events migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def get_db(): """ diff --git a/core/platform_compat.py b/core/platform_compat.py index 3eda4a107..b3b157111 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -366,6 +366,10 @@ def _ssh_exec_argv( strict_host_key_checking: bool | None = None, ) -> list[str]: """Build a consistent ssh argv for remote command execution.""" + remote_value = str(remote or "").strip() + remote_host = remote_value.rsplit("@", 1)[-1] + if not remote_value or remote_value.startswith("-") or not remote_host or remote_host.startswith("-"): + raise ValueError("Invalid SSH remote host") argv = ["ssh"] if connect_timeout is not None: argv.extend(["-o", f"ConnectTimeout={int(connect_timeout)}"]) diff --git a/requirements-optional.txt b/requirements-optional.txt index eeb57c151..b4b654232 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -15,7 +15,7 @@ faster-whisper # DuckDuckGo as a search provider option. # Install if you want DDG in the search-provider dropdown. # Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE. -duckduckgo-search +ddgs # PDF form-filling feature (fillable AcroForm detection, field extraction, # value/annotation/signature stamping, page rendering for the form overlay). diff --git a/routes/_validators.py b/routes/_validators.py new file mode 100644 index 000000000..aa4cf00cc --- /dev/null +++ b/routes/_validators.py @@ -0,0 +1,31 @@ +import re + +from fastapi import HTTPException + + +_REMOTE_HOST_RE = re.compile( + r"^(?:[A-Za-z0-9][A-Za-z0-9._-]*@)?[A-Za-z0-9][A-Za-z0-9._-]*$" +) +_SSH_PORT_RE = re.compile(r"^\d{1,5}$") + + +def validate_remote_host(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _REMOTE_HOST_RE.match(v): + raise HTTPException( + 400, + "Invalid remote_host — must be host or user@host, no SSH option syntax", + ) + return v + + +def validate_ssh_port(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _SSH_PORT_RE.fullmatch(str(v)): + raise HTTPException(400, "Invalid ssh_port") + port = int(v) + if port < 1 or port > 65535: + raise HTTPException(400, "Invalid ssh_port") + return str(port) diff --git a/routes/auth_routes.py b/routes/auth_routes.py index c20860892..b9158c93a 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -305,6 +305,19 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: if not ok: raise HTTPException(400, "Cannot rename user") + def _rollback_auth_rename() -> bool: + # On self-rename the admin session has already moved to the new + # username, so the rollback must authenticate as the new user. + rollback_user = new_username if user == old_username else user + try: + return bool(auth_manager.rename_user(new_username, old_username, rollback_user)) + except Exception as rollback_err: + logger.error( + "Failed to roll back auth rename %s -> %s after owner migration failure: %s", + new_username, old_username, rollback_err, + ) + return False + # Usernames are ownership keys for user data. Rename the common # owner-scoped DB rows so the account keeps access to its sessions, # docs, email accounts, tasks, etc. @@ -330,6 +343,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: db.close() except Exception as e: logger.error("Failed to rename owner references %s -> %s: %s", old_username, new_username, e) + if not _rollback_auth_rename(): + logger.error( + "Auth rename %s -> %s could not be rolled back after owner migration failure", + old_username, new_username, + ) raise HTTPException(500, "Failed to rename user data") # Per-user prefs are JSON-backed, not SQL-backed. @@ -349,6 +367,20 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: except Exception as e: logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e) + # In-flight deep-research tasks live in the process-local + # ResearchHandler registry. They are not covered by the persisted JSON + # migration above, but the research routes filter and cancel by this + # owner field while the job is running. Do this before sweeping + # completed JSON files so a job that finishes during the rename saves + # with the new owner or is caught by the disk sweep below. + try: + rh = getattr(request.app.state, "research_handler", None) + rename_owner = getattr(rh, "rename_owner", None) + if callable(rename_owner): + rename_owner(old_username, new_username) + except Exception as e: + logger.warning("Failed to rename active research tasks %s -> %s: %s", old_username, new_username, e) + # deep_research: each completed report is a standalone JSON file with # an `owner` field. research_routes filters by d.get("owner") == user, # so a stale owner makes every report invisible to the renamed user. @@ -391,7 +423,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: skills_root = Path(SKILLS_DIR) if skills_root.is_dir(): _owner_re = re.compile( - r'(?m)^(owner:\s*)' + re.escape(old_username) + r'\s*$' + r'(?m)^(owner:\s*)' + re.escape(old_username) + r'\s*$', + re.IGNORECASE, ) for p in skills_root.rglob("SKILL.md"): try: @@ -406,12 +439,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: try: usage = json.loads(usage_path.read_text(encoding="utf-8")) if isinstance(usage, dict): - prefix = old_username + "::" new_usage = {} changed = False for k, v in usage.items(): - if k.startswith(prefix): - new_usage[new_username + "::" + k[len(prefix):]] = v + owner_part, sep, skill_part = k.partition("::") + if sep and owner_part.lower() == old_username: + new_usage[new_username + "::" + skill_part] = v changed = True else: new_usage[k] = v @@ -473,7 +506,23 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") - ok = auth_manager.delete_user(body.username, user) + + def _invalidate_api_token_cache(): + try: + invalidator = getattr(request.app.state, "invalidate_token_cache", None) + if invalidator: + invalidator() + except Exception: + pass + + try: + ok = auth_manager.delete_user(body.username, user) + except Exception: + # delete_user can touch ApiToken rows before a later auth-store write + # fails. Dirty the bearer cache anyway so a partial token purge does + # not leave already-cached tokens authenticating until restart. + _invalidate_api_token_cache() + raise if not ok: raise HTTPException(400, "Cannot delete user") # delete_user removes the user's ApiToken rows, but the bearer-auth @@ -481,12 +530,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: # rebuilds when flagged dirty. Without this, a deleted user's already # cached token keeps authenticating until some other token op or a # restart clears the cache. Mirror what the token routes do. - try: - invalidator = getattr(request.app.state, "invalidate_token_cache", None) - if invalidator: - invalidator() - except Exception: - pass + _invalidate_api_token_cache() return {"ok": True} # ---- Feature visibility (admin-managed) ---- diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py index e4e8ce759..58a57a1e1 100644 --- a/routes/contacts_routes.py +++ b/routes/contacts_routes.py @@ -729,8 +729,11 @@ def setup_contacts_routes(): @router.post("/import") async def import_vcf(data: dict, _admin: str = Depends(require_admin)): """Import contacts from .vcf or CSV. Body: {"vcf": "..."} or {"csv": "..."}.""" - text = data.get("vcf") or data.get("text") or "" - csv_text = data.get("csv") or "" + # Coerce defensively: a non-string vcf/text/csv (e.g. a number or list + # in the JSON body) would otherwise reach .strip() and 500 with an + # AttributeError instead of degrading to a clean "no data" response. + text = str(data.get("vcf") or data.get("text") or "") + csv_text = str(data.get("csv") or "") if text.strip(): if "BEGIN:VCARD" not in text.upper(): return {"success": False, "error": "No vCard data found"} diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index 709245287..53bdde80e 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -11,6 +11,7 @@ import shlex from fastapi import HTTPException from pydantic import BaseModel +from routes._validators import validate_remote_host, validate_ssh_port from core.platform_compat import _ssh_exec_argv logger = logging.getLogger(__name__) @@ -30,16 +31,12 @@ _LOCAL_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _OLLAMA_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$") # Include pattern is a glob: allow typical safe glyphs only. _INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$") -# Remote host: either `user@host` or plain `host` (alias is allowed), where host -# is a safe DNS-like token or a short SSH config alias. -_REMOTE_HOST_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$") # HF tokens and API tokens are url-safe base64-like. _TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$") # Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef". # Anything beyond plain alphanumerics + dash + underscore could break out # of the shell/PowerShell contexts the value lands in. _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -_SSH_PORT_RE = re.compile(r"^\d{1,5}$") _GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$") # A download target directory. Absolute or ~-relative path; safe path glyphs # only (no quotes or shell metacharacters). Spaces are allowed because command @@ -85,14 +82,6 @@ def _validate_include(v: str | None) -> str | None: return v -def _validate_remote_host(v: str | None) -> str | None: - if v is None or v == "": - return None - if not _REMOTE_HOST_RE.match(v): - raise HTTPException(400, "Invalid remote_host — must be host or user@host, no SSH option syntax") - return v - - def _validate_token(v: str | None) -> str | None: if v is None or v == "": return None @@ -120,17 +109,6 @@ def _validate_local_dir(v: str | None) -> str | None: return v -def _validate_ssh_port(v: str | None) -> str | None: - if v is None or v == "": - return None - if not _SSH_PORT_RE.fullmatch(str(v)): - raise HTTPException(400, "Invalid ssh_port") - port = int(v) - if port < 1 or port > 65535: - raise HTTPException(400, "Invalid ssh_port") - return str(port) - - def _validate_gpus(v: str | None) -> str | None: if v is None or v == "": return None diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 4a4764232..36f98aeae 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -19,6 +19,7 @@ from src.constants import COOKBOOK_STATE_FILE from pydantic import BaseModel from core.middleware import require_admin +from routes._validators import validate_remote_host, validate_ssh_port from core.platform_compat import ( IS_WINDOWS, detached_popen_kwargs, @@ -33,9 +34,8 @@ from routes.shell_routes import TMUX_LOG_DIR logger = logging.getLogger(__name__) from routes.cookbook_helpers import ( - _SSH_PORT_RE, _REMOTE_HOST_RE, _SESSION_ID_RE, - _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_remote_host, _validate_token, - _validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path, + _SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token, + _validate_local_dir, _validate_gpus, _shell_path, _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, _safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, @@ -407,8 +407,8 @@ def setup_cookbook_routes() -> APIRouter: else: _validate_repo_id(req.repo_id) _validate_include(req.include) - _validate_remote_host(req.remote_host) - req.ssh_port = _validate_ssh_port(req.ssh_port) + validate_remote_host(req.remote_host) + req.ssh_port = validate_ssh_port(req.ssh_port) req.local_dir = _validate_local_dir(req.local_dir) req.hf_token = "" if is_ollama_download else (req.hf_token or _load_stored_hf_token()) _validate_token(req.hf_token) @@ -739,9 +739,8 @@ def setup_cookbook_routes() -> APIRouter: # Validate shell-bound inputs, matching the sibling list_gpus endpoint — # `host`/`ssh_port` are interpolated into an ssh command below, so an # unvalidated value (e.g. "x'; rm -rf ~ #") would be command injection. - host = _validate_remote_host(host) - if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(host) + ssh_port = validate_ssh_port(ssh_port) TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) model_dirs = [] @@ -890,11 +889,16 @@ def setup_cookbook_routes() -> APIRouter: # listening" check without requiring ss/netstat/nmap. ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"] if ssh_port and str(ssh_port) != "22": - if not _SSH_PORT_RE.match(str(ssh_port)): + try: + ssh_port = validate_ssh_port(ssh_port) + except HTTPException: return None ssh_base.extend(["-p", str(ssh_port)]) - host_arg = remote - if not _REMOTE_HOST_RE.match(host_arg): + try: + host_arg = validate_remote_host(remote) + except HTTPException: + return None + if not host_arg: return None probe_ports = " ".join(str(start_port + i) for i in range(max_offset + 1)) script = ( @@ -1197,8 +1201,8 @@ def setup_cookbook_routes() -> APIRouter: """ require_admin(request) # Defence-in-depth: reject values that could break out of shell contexts. - _validate_remote_host(req.remote_host) - req.ssh_port = _validate_ssh_port(req.ssh_port) + validate_remote_host(req.remote_host) + req.ssh_port = validate_ssh_port(req.ssh_port) req.gpus = _validate_gpus(req.gpus) req.hf_token = req.hf_token or _load_stored_hf_token() _validate_token(req.hf_token) @@ -1638,12 +1642,11 @@ def setup_cookbook_routes() -> APIRouter: async def server_setup(request: Request, req: SetupRequest): """Install required dependencies on a remote server via SSH.""" require_admin(request) - host = _validate_remote_host(req.host) + host = validate_remote_host(req.host) if not host: raise HTTPException(400, "host is required") port = req.ssh_port - if port is not None and port != "" and not re.fullmatch(r"\d{1,5}", port): - raise HTTPException(400, "Invalid ssh_port") + port = validate_ssh_port(port) pf = f"-p {port} " if port and port != "22" else "" # Detect platform: Windows first (echo %OS% → Windows_NT), then Termux, then Linux @@ -1887,9 +1890,8 @@ def setup_cookbook_routes() -> APIRouter: `busy` is True when free_mb/total_mb < 0.5. """ require_admin(request) - host = _validate_remote_host(host) - if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(host) + ssh_port = validate_ssh_port(ssh_port) gpu_query = "nvidia-smi --query-gpu=index,name,memory.free,memory.total,memory.used,utilization.gpu,uuid --format=csv,noheader,nounits" nvidia_error = None try: @@ -2046,9 +2048,8 @@ def setup_cookbook_routes() -> APIRouter: sig = (req.signal or "TERM").upper() if sig not in ("TERM", "KILL", "INT"): raise HTTPException(400, "signal must be TERM, KILL, or INT") - host = _validate_remote_host(req.host) - if req.ssh_port and not _SSH_PORT_RE.fullmatch(req.ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(req.host) + req.ssh_port = validate_ssh_port(req.ssh_port) kill_cmd = f"kill -{sig} {req.pid}" try: if host: @@ -2382,14 +2383,19 @@ def setup_cookbook_routes() -> APIRouter: host = (srv.get("host") or "").strip() if not host: continue # local-only entry; the /proc scan handles it - if not _REMOTE_HOST_RE.match(host): + try: + host = validate_remote_host(host) + except HTTPException: continue sport = str(srv.get("port") or "").strip() ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"] if sport and sport != "22": - if not _SSH_PORT_RE.match(sport): + try: + sport = validate_ssh_port(sport) + except HTTPException: continue - ssh_base.extend(["-p", sport]) + if sport != "22": + ssh_base.extend(["-p", sport]) try: ls = subprocess.run( @@ -2743,12 +2749,18 @@ def setup_cookbook_routes() -> APIRouter: if not _SESSION_ID_RE.match(session_id): logger.warning(f"Skipping task with unsafe session_id: {session_id!r}") continue - if remote and not _REMOTE_HOST_RE.match(remote): - logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") - continue - if _tport and not _SSH_PORT_RE.match(str(_tport)): - logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") - continue + if remote: + try: + remote = validate_remote_host(remote) + except HTTPException: + logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") + continue + if _tport: + try: + _tport = validate_ssh_port(str(_tport)) + except HTTPException: + logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") + continue if task_platform == "windows" and remote: # Windows: check PID file + Get-Process, read log tail sd = "$env:TEMP\\odysseus-sessions" diff --git a/routes/hwfit_routes.py b/routes/hwfit_routes.py index eb408ac9d..45c209b0b 100644 --- a/routes/hwfit_routes.py +++ b/routes/hwfit_routes.py @@ -1,7 +1,9 @@ import re from copy import deepcopy -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException + +from routes._validators import validate_remote_host, validate_ssh_port # Backends the manual hardware simulator accepts. Must stay a subset of what @@ -11,6 +13,14 @@ from fastapi import APIRouter _MANUAL_BACKENDS = {"cuda", "rocm", "metal", "cpu_x86", "cpu_arm"} +def _validate_detection_target(host: str = "", ssh_port: str = "") -> tuple[str, str]: + host_value = validate_remote_host(host) or "" + port_value = validate_ssh_port(ssh_port) or "" + if port_value and not host_value: + raise HTTPException(400, "ssh_port requires host") + return host_value, port_value + + def _apply_manual_hardware(system, manual_mode="", manual_gpu_count="", manual_vram_gb="", manual_ram_gb="", manual_backend=""): """Manual hardware is a "what if I had this setup" simulator — REPLACES the detected hardware entirely instead of adding to it. @@ -105,6 +115,7 @@ def setup_hwfit_routes(): """Detect and return current system hardware info. Pass host=user@server for remote. fresh=true bypasses the per-host cache (the Rescan button).""" from services.hwfit.hardware import detect_system + host, ssh_port = _validate_detection_target(host, ssh_port) return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) @router.get("/models") @@ -118,6 +129,7 @@ def setup_hwfit_routes(): from services.hwfit.hardware import detect_system from services.hwfit.fit import rank_models from services.hwfit.models import get_models, model_catalog_path + host, ssh_port = _validate_detection_target(host, ssh_port) system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)) if system.get("error"): return {"system": system, "models": [], "error": system["error"]} @@ -165,8 +177,14 @@ def setup_hwfit_routes(): system["gpu_name"] = g["name"] system["active_group"] = {**g, "use_count": n} - if gpu_count != "": - n = int(gpu_count) + # Parse the optional count defensively (matches the gpu_group guard + # above): a non-numeric query param previously raised ValueError -> + # HTTP 500. A malformed value is ignored, same as omitting it. + try: + n = int(gpu_count) if gpu_count != "" else None + except ValueError: + n = None + if n is not None: if n == 0: # RAM-only mode: rank against system memory, offload allowed. system["has_gpu"] = False @@ -229,6 +247,7 @@ def setup_hwfit_routes(): from services.hwfit.hardware import detect_system from services.hwfit.models import get_models from services.hwfit.profiles import compute_serve_profiles + host, ssh_port = _validate_detection_target(host, ssh_port) system = detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) if system.get("error"): return {"system": system, "profiles": [], "error": system["error"]} @@ -279,6 +298,7 @@ def setup_hwfit_routes(): """Rank image generation models against detected hardware.""" from services.hwfit.hardware import detect_system from services.hwfit.image_models import rank_image_models + host, ssh_port = _validate_detection_target(host, ssh_port) system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)) if system.get("error"): return {"system": system, "models": [], "error": system["error"]} diff --git a/routes/session_routes.py b/routes/session_routes.py index 811a40bbe..1fb2a487a 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -11,7 +11,7 @@ from core.session_manager import SessionManager from core.models import ChatMessage from src.request_models import SessionResponse from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive -from src.auth_helpers import get_current_user, effective_user, _auth_disabled +from src.auth_helpers import get_current_user, effective_user, _auth_disabled, owner_filter from src.session_actions import is_session_recently_active @@ -258,7 +258,9 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ last_msg_map = {} mode_map = {} msg_count_map = {} - rows = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False, DbSession.owner == user).all() + q = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False) + q = owner_filter(q, DbSession, user) + rows = q.all() for row in rows: folder_map[row.id] = row.folder token_map[row.id] = (row.total_input_tokens or 0) + (row.total_output_tokens or 0) @@ -277,17 +279,19 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ # Sessions with active documents that have content from sqlalchemy import func doc_session_ids = set( - r[0] for r in db.query(Document.session_id) - .filter(Document.is_active == True, - Document.current_content != None, - func.trim(Document.current_content) != "", - Document.owner == user) + r[0] for r in owner_filter( + db.query(Document.session_id) + .filter(Document.is_active == True, + Document.current_content != None, + func.trim(Document.current_content) != ""), + Document, user) .distinct().all() ) img_session_ids = set( - r[0] for r in db.query(GalleryImage.session_id) - .filter(GalleryImage.session_id != None, - GalleryImage.owner == user) + r[0] for r in owner_filter( + db.query(GalleryImage.session_id) + .filter(GalleryImage.session_id != None), + GalleryImage, user) .distinct().all() ) finally: diff --git a/services/search/providers.py b/services/search/providers.py index 1f8097ad8..b913e1c6f 100644 --- a/services/search/providers.py +++ b/services/search/providers.py @@ -417,7 +417,7 @@ def duckduckgo_search(query: str, count: Optional[int] = None, time_filter: Opti return [] try: - from duckduckgo_search import DDGS + from ddgs import DDGS except ImportError: logger.warning("duckduckgo-search package not installed; using HTML fallback") return _html_fallback() diff --git a/src/builtin_actions.py b/src/builtin_actions.py index b48ed94fa..1ea7cd8a4 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -579,6 +579,24 @@ def _classify_event_heuristic(summary: str) -> tuple: return etype, None +def _memory_context_lines(mems, limit: int = 40) -> list: + """Render Memory rows into short personal-context bullets for event classify. + + Reads the Memory ORM `text` column. The previous inline code read a + non-existent `content` attribute, so it raised AttributeError on the first + row, the surrounding except swallowed it, and the classifier ran with no + personal context at all. getattr keeps it robust to future schema drift. + """ + lines: list = [] + for m in mems: + c = (getattr(m, "text", "") or "").strip() + if c: + lines.append(f"- {c[:200]}") + if len(lines) >= limit: + break + return lines + + async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: """Hybrid classification of upcoming calendar events: fast heuristic for obvious cases, LLM fallback for ambiguous ones. Assigns event_type + @@ -614,16 +632,11 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: try: from core.database import Memory as _Mem _mems = db.query(_Mem).filter(_Mem.owner == owner).limit(60).all() if owner else [] - if _mems: - _lines = [] - for m in _mems: - c = (m.content or "").strip() - if c: - _lines.append(f"- {c[:200]}") - if _lines: - _memory_context = "USER CONTEXT (relationships, work, life):\n" + "\n".join(_lines[:40]) + "\n\n" + _lines = _memory_context_lines(_mems) + if _lines: + _memory_context = "USER CONTEXT (relationships, work, life):\n" + "\n".join(_lines) + "\n\n" except Exception as _me: - logger.debug(f"Could not load memory for classify: {_me}") + logger.warning(f"Could not load memory for classify: {_me}") classified_h = 0 classified_llm = 0 diff --git a/src/model_discovery.py b/src/model_discovery.py index 68b402d25..506fcb6c4 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -223,6 +223,25 @@ class ModelDiscovery: ) return {"hosts": hosts, "items": items} + def warmup_ping_urls(self, limit: int = 5) -> List[str]: + """The ``/models`` URLs of up to ``limit`` discovered endpoints. + + Used by the startup warmup / keepalive loop to prime connections. Each + discovered item already carries a ``/v1/chat/completions`` url; swap the + suffix for the cheap ``/models`` probe. Failures degrade to an empty list + so warmup never crashes the caller. + """ + try: + items = (self.discover_models() or {}).get("items", []) + except Exception: + return [] + urls: List[str] = [] + for ep in items[:limit]: + url = (ep.get("url") or "").replace("/chat/completions", "/models") + if url: + urls.append(url) + return urls + def get_providers(self) -> Dict[str, Any]: """Get all available providers""" discovery = self.discover_models() diff --git a/src/research_handler.py b/src/research_handler.py index b996f089f..f1d120ef2 100644 --- a/src/research_handler.py +++ b/src/research_handler.py @@ -221,6 +221,22 @@ class ResearchHandler: # Task registry — background research with persistence # ------------------------------------------------------------------ + def rename_owner(self, old_owner: str, new_owner: str) -> int: + """Move in-flight research tasks from one owner key to another.""" + old_key = str(old_owner or "").strip().lower() + new_key = str(new_owner or "").strip().lower() + if not old_key or not new_key: + return 0 + + changed = 0 + for entry in list(self._active_tasks.values()): + if not isinstance(entry, dict): + continue + if str(entry.get("owner", "")).strip().lower() == old_key: + entry["owner"] = new_key + changed += 1 + return changed + def start_research( self, session_id: str, @@ -390,7 +406,6 @@ class ResearchHandler: def get_status(self, session_id: str) -> Optional[dict]: """Get current research status for a session.""" - avg = self.get_avg_duration() if session_id in self._active_tasks: entry = self._active_tasks[session_id] result = { @@ -399,6 +414,14 @@ class ResearchHandler: "query": entry["query"], "started_at": entry["started_at"], } + # avg_duration is a historical figure over completed reports on + # disk; get_avg_duration() globs and JSON-parses the whole research + # dir, so compute it at most once per active stream (memoized on the + # entry) instead of on every ~1s SSE poll. The disk branch below + # never used it, so it no longer pays that cost at all. + if "_avg_duration" not in entry: + entry["_avg_duration"] = self.get_avg_duration() + avg = entry["_avg_duration"] if avg is not None: result["avg_duration"] = round(avg, 1) return result diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 494795037..27c05f139 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -1453,6 +1453,42 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} + # ── Batch normalization ── + # Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]} + # instead of individual create_event calls. Iterate and create each. + if isinstance(args.get("events"), list) and not args.get("action"): + results = [] + for ev in args["events"]: + if not isinstance(ev, dict): + continue + # Normalize start/end from {dateTime: "..."} object to flat string + for field, target in [("start", "dtstart"), ("end", "dtend")]: + val = ev.pop(field, None) + if val and target not in ev: + ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val + ev.setdefault("action", "create_event") + r = await do_manage_calendar(json.dumps(ev), owner=owner) + results.append(r) + created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")] + failed = [r for r in results if r.get("error")] + + if not results: + return {"error": "No events to create", "exit_code": 1} + + # Surface both successes and failures + parts = [] + if created: + summaries = [r.get("response", "") for r in created] + parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries)) + if failed: + first_error = failed[0].get("error", "Unknown error") + parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}") + + response = "\n\n".join(parts) + # Non-zero exit code for partial or total failure + exit_code = 0 if not failed else 1 + return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)} + # Normalize action — some models emit hyphens ("list-calendars") instead # of underscores. Treat them as equivalent so we don't bounce a # cosmetic typo back to the model and waste a round-trip. Also accept diff --git a/src/tool_security.py b/src/tool_security.py index 82d2c3d67..6b7bc90df 100644 --- a/src/tool_security.py +++ b/src/tool_security.py @@ -162,13 +162,26 @@ def is_public_blocked_tool(tool_name: Optional[str]) -> bool: def owner_is_admin_or_single_user(owner: Optional[str]) -> bool: - """Return True for admins, or when auth is not configured yet.""" + """Return True for admins, or in intentional single-user mode. + + Single-user mode means the operator explicitly disabled auth + (``AUTH_ENABLED=false``) — the local/self-host default where the owner has + full access to their own box. + + The pre-setup window (auth ENABLED but no admin created yet) is treated as + NON-admin: returning True there would hand server-execution tools + (``bash``/``python``) to any caller before setup completes. The auth + middleware already 401s ``/api/`` requests pre-setup, so this is + defense-in-depth for callers that bypass it (e.g. trusted loopback). + """ try: from core.auth import AuthManager auth = AuthManager() if not auth.is_configured: - return True + from src.auth_helpers import _auth_disabled + + return _auth_disabled() return bool(owner and auth.is_admin(owner)) except Exception as exc: logger.warning("Unable to evaluate owner admin status: %s", exc) diff --git a/static/js/chat.js b/static/js/chat.js index 60149d005..7ecefdb7d 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1082,7 +1082,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _lastToolName = ''; const _searchIcon = ''; const _toolLabels = { - 'web_search': _searchIcon + 'Searching', + 'web_search': 'Searching', 'bash': 'Running', 'python': 'Running', 'create_document': 'Writing', @@ -1102,6 +1102,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer 'list_models': 'Browsing', 'ui_control': 'Adjusting', }; + const _toolIcons = { + 'web_search': _searchIcon, + }; function _thinkingLabel() { if (!_lastToolName) { return 'Thinking'; @@ -2049,10 +2052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } threadWrap.classList.add('streaming'); const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool; + const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6'; const node = document.createElement('div') node.className = 'agent-thread-node running'; const cmdHtml = cmd ? `
${esc(cmd)}` : '';
- node.innerHTML = `