From ac449612af9aa0aa1844caf2845fe49a0c52025a Mon Sep 17 00:00:00 2001 From: PrinceThummar011 Date: Thu, 1 Oct 2026 17:57:58 +0530 Subject: [PATCH 01/53] fix(mcp): handle expired/unknown OAuth state in callback with dedicated expired page (#5598) --- routes/mcp/mcp_routes.py | 34 ++++++- tests/test_mcp_oauth.py | 186 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/routes/mcp/mcp_routes.py b/routes/mcp/mcp_routes.py index 0e2b30276..385cf09e3 100644 --- a/routes/mcp/mcp_routes.py +++ b/routes/mcp/mcp_routes.py @@ -487,19 +487,32 @@ def setup_mcp_routes(mcp_manager: McpManager): db.close() @router.get("/oauth/callback") - async def oauth_callback(code: str, state: str, request: Request): + async def oauth_callback(code: str = "", state: str = "", request: Request = None): """Handle OAuth callback. Generic MCP OAuth flows resolve via the pending-state registry; Google flows fall through to the legacy path.""" - require_admin(request) from src.mcp_oauth import resolve_pending - if resolve_pending(state, code): + if state and code and resolve_pending(state, code): return HTMLResponse(_oauth_result_page( "Authorization Successful", "The MCP server is connecting. You can close this window and return to Odysseus.", success=True, )) + # Legacy Google path: state is the server_id - return await _exchange_and_connect(state, code, request) + if state: + db = SessionLocal() + try: + srv = db.query(McpServer).filter(McpServer.id == state).first() + if srv and srv.oauth_config: + require_admin(request) + return await _exchange_and_connect(state, code, request) + finally: + db.close() + + return HTMLResponse(_oauth_result_page( + "Authorization Expired", + "This authorization round has expired. Press Reconnect in Odysseus and try again.", + ), status_code=400) @router.post("/oauth/exchange/{server_id}") async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)): @@ -525,6 +538,19 @@ def setup_mcp_routes(mcp_manager: McpManager): success=True, )) + db = SessionLocal() + try: + srv = db.query(McpServer).filter(McpServer.id == server_id).first() + if not srv: + return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404) + if not srv.oauth_config: + return HTMLResponse(_oauth_result_page( + "Authorization Expired", + "This authorization round has expired. Press Reconnect in Odysseus and try again.", + ), status_code=400) + finally: + db.close() + return await _exchange_and_connect(server_id, code, request) async def _exchange_and_connect(server_id: str, code: str, request: Request): diff --git a/tests/test_mcp_oauth.py b/tests/test_mcp_oauth.py index 6a8e21876..24f52820e 100644 --- a/tests/test_mcp_oauth.py +++ b/tests/test_mcp_oauth.py @@ -264,3 +264,189 @@ def test_launchers_export_the_port_they_serve_on(): text = (_repo_root() / name).read_text(encoding="utf-8") assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}" assert export in text, f"{name}: serves on a port the app cannot read back" + + +# ── Callback & Exchange Expired State Handling ─────────────────── + +def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch): + """An unknown or expired generic MCP OAuth state in the callback must return + the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only' + or a 404 'Server not found'.""" + from unittest.mock import MagicMock + from fastapi import FastAPI, HTTPException + from fastapi.testclient import TestClient + from routes.mcp import mcp_routes + + # Simulate unauthenticated request that would fail admin check if invoked + def _deny_admin(request): + raise HTTPException(403, "Admin only") + + monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin) + + # Empty DB / no matching server + class FakeQuery: + def filter(self, *a): + return self + + def first(self): + return None + + class FakeSession: + def query(self, *a): + return FakeQuery() + + def close(self): + pass + + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession()) + + app = FastAPI() + manager = MagicMock() + router = mcp_routes.setup_mcp_routes(manager) + app.include_router(router) + client = TestClient(app) + + # Both code and state are provided, but state is not in _pending (expired/unknown) + resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123") + assert resp.status_code == 400 + assert "Authorization Expired" in resp.text + assert ( + "This authorization round has expired. Press Reconnect in Odysseus and try again." + in resp.text + ) + # Ensure it did not return the bare 403 "Admin only" + assert "Admin only" not in resp.text + + +def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch): + """An active pending generic MCP OAuth state resolves without requiring admin auth.""" + from unittest.mock import MagicMock + from fastapi import FastAPI, HTTPException + from fastapi.testclient import TestClient + from routes.mcp import mcp_routes + + # Admin check fails if invoked + def _deny_admin(request): + raise HTTPException(403, "Admin only") + + monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin) + + app = FastAPI() + manager = MagicMock() + router = mcp_routes.setup_mcp_routes(manager) + app.include_router(router) + client = TestClient(app) + + async def go(): + fut = mcp_oauth.register_pending("pending-round-1") + resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1") + assert resp.status_code == 200 + assert "Authorization Successful" in resp.text + code, state = await asyncio.wait_for(fut, timeout=1) + assert code == "auth-code-xyz" + assert state == "pending-round-1" + + asyncio.run(go()) + + +def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch): + """Legacy Google OAuth callbacks (where state is server_id of a server with + oauth_config) still enforce require_admin.""" + import json + from unittest.mock import MagicMock + from fastapi import FastAPI, HTTPException + from fastapi.testclient import TestClient + from routes.mcp import mcp_routes + from core.database import McpServer + + # Server exists in DB with Google oauth_config + google_srv = McpServer( + id="google-srv-1", + name="Google Workspace", + oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}), + ) + + class FakeQuery: + def filter(self, *a): + return self + + def first(self): + return google_srv + + class FakeSession: + def query(self, *a): + return FakeQuery() + + def close(self): + pass + + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession()) + + # When require_admin denies, 403 is raised for the legacy Google path + def _deny_admin(request): + raise HTTPException(403, "Admin only") + + monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin) + + app = FastAPI() + manager = MagicMock() + router = mcp_routes.setup_mcp_routes(manager) + app.include_router(router) + client = TestClient(app) + + resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1") + assert resp.status_code == 403 + assert "Admin only" in resp.text + + +def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch): + """Remote paste-back flow for a generic MCP server returns the dedicated expired + page when the state is no longer pending, instead of 'No OAuth config.'.""" + from unittest.mock import MagicMock + from fastapi import FastAPI + from fastapi.testclient import TestClient + from routes.mcp import mcp_routes + from core.database import McpServer + + monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None) + + # Server exists in DB without oauth_config (generic MCP server) + generic_srv = McpServer( + id="http-srv-1", + name="Remote HTTP MCP", + oauth_config=None, + ) + + class FakeQuery: + def filter(self, *a): + return self + + def first(self): + return generic_srv + + class FakeSession: + def query(self, *a): + return FakeQuery() + + def close(self): + pass + + monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession()) + + app = FastAPI() + manager = MagicMock() + router = mcp_routes.setup_mcp_routes(manager) + app.include_router(router) + client = TestClient(app) + + resp = client.post( + "/api/mcp/oauth/exchange/http-srv-1", + data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"}, + ) + assert resp.status_code == 400 + assert "Authorization Expired" in resp.text + assert ( + "This authorization round has expired. Press Reconnect in Odysseus and try again." + in resp.text + ) + assert "No OAuth config." not in resp.text From 35e3b298d8f84a68f147053e8367121b06768b09 Mon Sep 17 00:00:00 2001 From: ShahabAhmed01 <192491541+ShahabAhmed01@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:23:36 +0500 Subject: [PATCH 02/53] fix(scheduler): fall back to defaults for non-numeric research settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduled-research task reads research_max_tokens, research_extraction_timeout_seconds, and research_extraction_concurrency with bare int() casts and no guard. A hand-edited or agent-written data/settings.json value like {"research_max_tokens": "invalid"} raised ValueError and killed every scheduled research run until settings.json was fixed by hand (#5049). src/research_handler.py already guards the same reads defensively (_bounded_int); this mirrors that for the scheduler. Wrap the three reads in try/except (TypeError, ValueError) falling back to the documented defaults 8192 / 90 / 3. Valid values (numeric strings like "4096") still parse; the change is limited to the scheduler's reads (agent_loop sites are tracked separately in #5051). Child of maintainer subtracker #5335 (RaresKeY) whose acceptance criteria state: "Non-numeric agent/scheduler settings fall back safely instead of crashing or silently degrading." Prior PR #5050 attempted the same fix but was closed unmerged in the 2026-07-23 repo-transfer bulk close and never resubmitted — redoing it with credit. Fixes #5049. --- src/task_scheduler.py | 36 ++++++++- .../test_scheduler_research_settings_guard.py | 79 +++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 tests/test_scheduler_research_settings_guard.py diff --git a/src/task_scheduler.py b/src/task_scheduler.py index f2f59d65e..89c9217fd 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -342,6 +342,36 @@ def _normalize_chat_endpoint(url: str) -> str: return url +def _resolve_research_settings(get_setting_fn): + """Resolve the scheduled-research settings with crash-proof fallbacks. + + A hand-edited or agent-written data/settings.json can hold non-numeric + values for the research_* keys; the bare int() reads previously raised + inside TaskScheduler._execute_research_task and killed every scheduled + research run until settings.json was fixed by hand (#5049). + src/research_handler.py already guards the same reads defensively — + this mirrors that for the scheduler. Valid values pass through + unchanged (numeric strings like "4096" still parse). + """ + try: + max_tokens = int(get_setting_fn("research_max_tokens", 8192)) + except (TypeError, ValueError): + max_tokens = 8192 + try: + extraction_timeout = int( + get_setting_fn("research_extraction_timeout_seconds", 90) or 90 + ) + except (TypeError, ValueError): + extraction_timeout = 90 + try: + extraction_concurrency = int( + get_setting_fn("research_extraction_concurrency", 3) or 3 + ) + except (TypeError, ValueError): + extraction_concurrency = 3 + return max_tokens, extraction_timeout, extraction_concurrency + + class TaskScheduler: def __init__(self, session_manager): self._session_manager = session_manager @@ -2068,9 +2098,9 @@ class TaskScheduler: except Exception: pass - max_tokens = int(get_setting("research_max_tokens", 8192)) - extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90) - extraction_concurrency = int(get_setting("research_extraction_concurrency", 3) or 3) + max_tokens, extraction_timeout, extraction_concurrency = ( + _resolve_research_settings(get_setting) + ) researcher = DeepResearcher( llm_endpoint=endpoint_url, diff --git a/tests/test_scheduler_research_settings_guard.py b/tests/test_scheduler_research_settings_guard.py new file mode 100644 index 000000000..0cdfa2b87 --- /dev/null +++ b/tests/test_scheduler_research_settings_guard.py @@ -0,0 +1,79 @@ +"""Guarded resolution of scheduled-research settings (#5049). + +The scheduler reads ``research_max_tokens`` / +``research_extraction_timeout_seconds`` / ``research_extraction_concurrency`` +from settings.json with bare ``int()`` casts. A hand-edited value like +``{"research_max_tokens": "invalid"}`` used to raise inside +``TaskScheduler._execute_research_task`` and kill every scheduled research +run until settings.json was fixed by hand. + +``_resolve_research_settings`` must fall back to the documented defaults +(8192 / 90 / 3) for non-numeric input while preserving valid-value behavior +(numeric strings still parse). ``src/research_handler.py`` already guards the +same reads defensively; this is the scheduler's equivalent. +""" + +from __future__ import annotations + +from src.task_scheduler import _resolve_research_settings + + +def _fake_get_setting(values): + """A get_setting stand-in backed by a plain dict.""" + + def get(key, default=None): + return values.get(key, default) + + return get + + +def test_non_numeric_values_fall_back_to_documented_defaults(): + get = _fake_get_setting( + { + "research_max_tokens": "invalid", + "research_extraction_timeout_seconds": "bogus", + "research_extraction_concurrency": "not-a-number", + } + ) + + assert _resolve_research_settings(get) == (8192, 90, 3) + + +def test_none_values_fall_back_to_documented_defaults(): + get = _fake_get_setting( + { + "research_max_tokens": None, + "research_extraction_timeout_seconds": None, + "research_extraction_concurrency": None, + } + ) + + assert _resolve_research_settings(get) == (8192, 90, 3) + + +def test_numeric_strings_still_parse(): + get = _fake_get_setting( + { + "research_max_tokens": "4096", + "research_extraction_timeout_seconds": "45", + "research_extraction_concurrency": "2", + } + ) + + assert _resolve_research_settings(get) == (4096, 45, 2) + + +def test_missing_keys_keep_documented_defaults(): + assert _resolve_research_settings(_fake_get_setting({})) == (8192, 90, 3) + + +def test_valid_ints_pass_through(): + get = _fake_get_setting( + { + "research_max_tokens": 2048, + "research_extraction_timeout_seconds": 120, + "research_extraction_concurrency": 6, + } + ) + + assert _resolve_research_settings(get) == (2048, 120, 6) From b49f66cb7a48af8531111fed395867ed8c835ebc Mon Sep 17 00:00:00 2001 From: Boody <69832947+bitboody@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:03:50 +0300 Subject: [PATCH 03/53] Add community and growth section to README Updated the README to include community and growth section with Trendshift and star history badges. --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4ec557b11..06d4b5f1e 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,33 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled Deployment details are in the [setup guide](website/setup.md#security-notes). -## Star History +## Community & Growth - - - - - Star History Chart - - +

+ + odysseus-dev%2Fodysseus | Trendshift + +

+ +

+ + + + + Odysseus GitHub star history + + +

## License From 8feb50e89468235cd3e0986da80edbd3acaf2c40 Mon Sep 17 00:00:00 2001 From: Beyrem Weslati <74115035+beyremweslati@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:57:19 +0100 Subject: [PATCH 04/53] feat(notes): attach selected Notes/Todos as Agent context (#6495) * feat: add Notes and Todos as agent chat context - Send selected Note/Todo IDs from the Notes UI - Load selected notes with owner scoping in chat routes - Format note content and checklist items for the model - Inject selected notes as untrusted chat context - Add a selected-context indicator beside Shell Access - Keep selected context associated with the active chat session * Improved selected notes context handling and discouragin redundant note lookup and permission requests * Fix selected notes UI when switching chats * fix(notes): harden selected context lifecycle and validation --------- Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> --- routes/chat_helpers.py | 18 ++++++ routes/chat_routes.py | 83 +++++++++++++++++++++++++++- src/agent_loop.py | 4 +- static/index.html | 15 +++++ static/js/chat.js | 68 +++++++++++++++++++++++ static/js/notes.js | 14 +++++ static/js/sessions.js | 15 +++++ tests/selectedNotesContext.test.mjs | 81 +++++++++++++++++++++++++++ tests/test_selected_notes_context.py | 28 ++++++++++ 9 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 tests/selectedNotesContext.test.mjs create mode 100644 tests/test_selected_notes_context.py diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 472128eaf..7debf70b6 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -1010,6 +1010,7 @@ async def build_chat_context( chat_processor, message: str, session_id: str, + selected_notes_context: str | None = None, preset_id=None, att_ids: list = None, use_web=None, @@ -1182,6 +1183,23 @@ async def build_chat_context( if search_context and allow_tool_preprocessing and not casual_low_signal: preface.append(untrusted_context_message("prefetched search context", search_context)) + # Inject selected notes as context + if selected_notes_context: + selected_notes_context = ( + "The following selected notes and todos are reference context explicitly chosen " + "by the user. Use them directly when relevant to the user's request. Do not " + "ask for permission to use this context, and do not call note-management " + "tools just to retrieve the same notes; use those tools only for a requested " + "change or when this context is insufficient.\n\n" + + selected_notes_context + ) + preface.append( + untrusted_context_message( + "Selected notes and todos", + selected_notes_context, + ) + ) + # YouTube transcripts for transcript in preprocessed.youtube_transcripts: preface.append(untrusted_context_message("youtube transcript", transcript)) diff --git a/routes/chat_routes.py b/routes/chat_routes.py index e3f7bfa02..5a42d3351 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -58,7 +58,7 @@ from src.auth_helpers import ( from routes.session_routes import _verify_session_owner from routes.document_helpers import _owner_session_filter from core.database import SessionLocal, get_session_mode, set_session_mode -from core.database import Session as DBSession, ChatMessage as DBChatMessage +from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note from core.database import Document as DBDocument, ModelEndpoint from core.log_safety import redact_url from routes.research_routes import _resolve_research_endpoint @@ -2153,6 +2153,68 @@ def _set_user_time_from_request(request: Request) -> None: except Exception: pass +def _parse_selected_note_ids(raw) -> list[str]: + """Validate user-selected Note IDs before passing them to SQLAlchemy.""" + if raw is None or raw == "": + return [] + if not isinstance(raw, str) or len(raw) > 8192: + raise HTTPException(400, "Invalid selected_note_ids payload") + try: + parsed = json.loads(raw) + except (TypeError, ValueError) as exc: + raise HTTPException(400, "Invalid selected_note_ids JSON") from exc + if ( + not isinstance(parsed, list) + or len(parsed) > 50 + or any( + not isinstance(note_id, str) + or not note_id.strip() + or len(note_id) > 128 + for note_id in parsed + ) + ): + raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs") + return list(dict.fromkeys(parsed)) + + +def _format_selected_notes_context(notes) -> str: + """Format selected Notes/Todos as reference context for the model.""" + sections = [] + + for index, note in enumerate(notes, start=1): + lines = [ + f"--- Selected Note {index} ---", + f"Title: {note.title or '(untitled)'}", + f"Type: {note.note_type or 'note'}", + ] + + if note.content: + lines.extend([ + "Content:", + str(note.content), + ]) + + if note.items: + try: + items = json.loads(note.items) + except (TypeError, json.JSONDecodeError): + items = [] + + if isinstance(items, list): + lines.append("Items:") + for item in items: + if not isinstance(item, dict): + continue + + status = "done" if item.get("done") else "pending" + text = str(item.get("text") or "").strip() + + if text: + lines.append(f"- [{status}] {text}") + + sections.append("\n".join(lines)) + + return "\n\n".join(sections) def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager): """Use an explicit request override, then fall back to the active preset.""" @@ -2423,6 +2485,8 @@ def setup_chat_routes( form_data = await request.form() message = form_data.get("message") session = form_data.get("session") + selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids")) + attachments = form_data.get("attachments") use_web = form_data.get("use_web") use_research = form_data.get("use_research") @@ -2730,6 +2794,22 @@ def setup_chat_routes( current_rejected=workspace_rejected, ) owner = effective_user(request) + selected_notes = [] + if selected_note_ids: + notes_db = SessionLocal() + try: + selected_notes = ( + notes_db.query(Note) + .filter( + Note.id.in_(selected_note_ids), + Note.owner == owner, + ) + .all() + ) + finally: + notes_db.close() + selected_notes_context = _format_selected_notes_context(selected_notes) + if tool_approval_id: _reject_delegated_tool_approval(request) from src.agent_runtime.authority import require_user_approval_request @@ -3001,6 +3081,7 @@ def setup_chat_routes( sess, request, chat_handler, chat_processor, message=message, session_id=session, + selected_notes_context=selected_notes_context, preset_id=preset_id, att_ids=att_ids, use_web=use_web, diff --git a/src/agent_loop.py b/src/agent_loop.py index f3609b41b..6301e86a5 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -6933,6 +6933,7 @@ _AGENT_RULES = """\ - If the user asks for multiple specific emails and you call `read_email` more than once, your final answer MUST include every successfully read email, clearly separated and linked by UID. Do not answer with only the last email you read. - Multiple email accounts: if tool output says "Other accounts" or the user asks "my Gmail?", "other inbox?", "work mail?", "custom domain mail?", or names any mailbox/account, DO NOT answer from memory. Call `list_email_accounts` if needed, then call `list_emails`/`read_email`/`bulk_email` with the exact `account` value for that mailbox. Account names are user-defined labels; if the user typo-matches a known account, use the closest listed account instead of claiming it does not exist. NEVER use `app_api` or `/api/email/accounts` to discover email accounts; that route is owner-filtered in tool context and can falsely return empty. - User identity facts/preferences ("my name is ", "I live in ", "I prefer concise replies", "call me ") → use `manage_memory` with action=add. NEVER use `manage_contact` for facts about the user unless the user explicitly says to create/update a contact and provides contact details such as an email or phone. +- When the prompt contains "Selected notes and todos", treat that content as already-provided reference context. Use it directly when it answers the request; do not call `manage_notes` to list/search/view the same notes, and do not ask for permission merely to use the selected context. Call `manage_notes` only when the user requests a note change or the selected context is insufficient and a fresh lookup is needed. - "Create/add/write a note" / "notes" / "todos" / "remind me to X at