Compare commits

..
Author SHA1 Message Date
pewdiepie-archdaemon 7b9ef95b60 Stabilize auth session revocation tests 2026-06-02 06:02:49 +09:00
LohinthandLohinth a8d9a180d9 Scope document tools to caller owner
Co-authored-by: Lohinth <lohinth25@proton.me>
2026-06-02 06:00:02 +09:00
Ernest Hysa d42e6a7acc Scope skill mutations to caller owner
SkillsManager.update_skill walks every SKILL.md on disk and matches by
slug only; the 'owner' key in its scalar_keys whitelist meant a caller
could pass updates={'owner': 'attacker', 'description': 'pwned'} and the
first matching file on disk got silently re-owned. Two users with the
same slug under different category directories (which is supported by
the on-disk layout <category>/<name>/SKILL.md) could each stomp the
other's skill via the manage_skills tool or the in-process callers in
tool_implementations.py (edit, patch, publish, delete).

update_skill and delete_skill now require the caller's owner and only
match a file whose parsed owner field matches. The default of None
means 'no scope' and only matches ownerless skills, so an unsafe call
without an explicit owner is now a no-op. 'owner' is also removed from
scalar_keys so the updates dict cannot be used to reassign ownership
even when the manager is called from an in-process path that didn't
supply the owner argument.

The in-process callers in tool_implementations.py are updated to pass
owner=owner (which was already in scope at every call site) so the
HTTP and agent paths both go through the scoped check. The HTTP route
at routes/skills_routes.py:1499 was already owner-scoped via
sm.load(owner=user); the fix brings the in-process path up to the
same standard.
2026-06-02 05:59:43 +09:00
Alexandre Teixeira 5dd5847d4b Revoke stale sessions after password change
After a successful password change, revoke all browser sessions for the
same user except the one that submitted the request. This prevents stale
sessions on other devices from remaining valid after credentials are
updated.

Keep API-token behavior unchanged. The current browser session is
preserved so the user can continue from the tab that changed the
password.

Add focused regression tests for preserving the current session, revoking
other sessions, persisting revocation, and avoiding revocation when the
current password is incorrect.
2026-06-02 05:59:22 +09:00
SurprisedDuckandClaude 7d10fb6260 Reserve internal sentinel usernames
`core.middleware.require_admin` grants admin to any request whose
`request.state.current_user == "internal-tool"` — the sentinel meant only
for the in-process tool-loopback path. But the normal cookie auth path
(app.py) sets `current_user` to the raw username, and neither `create_user`
nor the signup route reserved that name. As a result an account literally
named "internal-tool" was silently treated as admin by every
`require_admin`-gated route. With self-service signup enabled this is an
anonymous -> admin privilege escalation.

Reserve the full synthetic-owner set the codebase already special-cases —
"internal-tool", "api", "demo", "system" (see `_SYNTHETIC_OWNERS` in
routes/assistant_routes.py and the matching guards in src/task_scheduler.py
and routes/research_routes.py). "api" collides with the bearer-token owner
sentinel; "demo"/"system" would leave a real account denied an assistant
and inconsistently owner-scoped.

Refuse to create or rename into any reserved name (case/space-normalized),
and reject empty usernames while we're here. Adds a regression test.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:58:58 +09:00
SurprisedDuckandClaude b70ae56ffa Sanitize preserved markdown HTML
`mdToHtml` deliberately stashes literal <details> blocks and <a> tags from
the source text *before* the global HTML-escape pass and restores them
verbatim into the string callers assign to `innerHTML` (e.g. chatRenderer's
`b.innerHTML = ...processWithThinking(text)`). Nothing scrubbed those
fragments, so message/agent content containing
`<details><img src=x onerror=...></details>` or
`<a href="javascript:..." onmouseover=...>` executed arbitrary script in
the authenticated page.

Route both stashed fragments through `sanitizeAllowedHtml()`, which parses
them in an inert <template> (no resource loads, no script execution),
removes script-capable elements, and strips event-handler attributes plus
javascript:/vbscript:/data: URL schemes. Hardening details:

- Compare tag names case-insensitively and drop the SVG/MathML foreign-
  content roots. An SVG-namespaced <script> has the lower-case tagName
  'script', so an HTML-only upper-case check would miss it — a real bypass.
- Sanitize to a fixpoint (re-parse + re-clean until stable) to blunt
  mutation-XSS, where re-serializing/re-parsing reshapes the tree.

Benign anchors and <details> blocks are preserved unchanged.

Verified under jsdom against the obvious vectors plus mutation-XSS probes
(svg/math-namespaced <script>, foreignObject, ns-confusion, comment
breakout, template smuggling): no script/iframe element, event handler, or
javascript:/data: URL survives, and benign markup is kept.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:58:38 +09:00
ghreprimandandghreprimand 491a8a5480 Harden backup restore tar extraction
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-02 05:55:03 +09:00
Alexandre Teixeira 26483661da Restrict provider discovery to admins
Require admin access before serving provider discovery data from
GET /api/providers. This prevents normal authenticated users from
triggering provider discovery or receiving cached provider host data.

Keep GET /api/models available to normal users and leave the existing
admin-only GET /api/discover behavior unchanged.

Add a focused regression test to ensure unauthorized callers cannot
trigger discovery and cannot receive cached provider data.
2026-06-02 05:54:40 +09:00
SurprisedDuck 7268c49992 Make LLM host health maps thread-safe
The synchronous llm_call() runs in FastAPI's threadpool (sync route
handlers such as POST /sessions/auto-sort), while llm_call_async() runs
on the event loop. Both mutate the module-level _response_cache,
_host_fails and _dead_hosts dicts, so these are touched from multiple OS
threads concurrently. Two races result:

- _set_cached_response() snapshots 64 keys then deletes them with
  `del _response_cache[key]`; if another thread evicts the same key
  first, the del raises KeyError mid-eviction. Switched to
  pop(key, None).
- _mark_host_dead() does get()+1+set() on _host_fails with no lock, so
  concurrent connect failures lose increments and a genuinely dead host
  can stay under its cooldown threshold. Guarded the host-health maps
  with a threading.Lock (also applied to _is_host_dead / _clear_host_dead
  for consistent reads).

Adds tests/test_llm_core_concurrency.py with deterministic regression
tests (phantom snapshot key for the eviction race; a slow-read dict that
forces the lost-update window for the counter). Both fail on the
unpatched code and pass with the fix.
2026-06-02 05:54:23 +09:00
ooovenenosoandKevin cd6041477c Refresh local model context after restart
Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-02 05:54:06 +09:00
Prakhya a96593a99b Improve Ollama endpoint error messages 2026-06-02 05:53:50 +09:00
SurprisedDuckandClaude 7a830e504d Escape email fold summary metadata
The email reader folds quoted history into <details> summaries via
`_foldSummary()` (static/js/emailLibrary/signatureFold.js), which builds a
sender/date "meta" chip into the summary HTML and assigns it to innerHTML.
The server-side thread parser (`_extract_quote_meta`,
src/email_thread_parser.py) strips tags but then un-escapes HTML entities
and preserves `<...>` patterns, and that raw meta reaches `_foldSummary`
unescaped via `_renderTurnsFromServer` (`t.meta`) — so an inbound email
whose quoted attribution contains `From: &lt;img src=x onerror=...&gt;`
runs script when the victim merely opens the message (stored XSS).

Make `_foldSummary` the single escaping chokepoint: escape `primary` and
`subMeta` with the module's existing `_esc`. The client-side
`_extractQuoteMeta` previously pre-escaped its output, and every consumer
of it routes through `_foldSummary`, so drop that now-redundant escaping to
avoid double-encoding (e.g. "Ben & Jerry" -> "Ben &amp;amp; Jerry").

Verified (jsdom): server-raw and client-extracted malicious metas yield 0
live elements and 0 event-handler attributes; benign "Ben & Jerry" renders
single-escaped.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:50:53 +09:00
Yatsuiii 63d93ff211 Normalize stored usernames on auth load
verify_password() and create_session() both call .strip().lower() on
the incoming username, but _load() stored keys verbatim from auth.json.
Any mixed-case key (e.g. written by manual edit or a future migration)
would never match, producing a permanent 'Invalid credentials' error.

Fix: lowercase all keys at load time so the in-memory dict always
matches what the login path expects.

Fixes #423
2026-06-02 05:50:36 +09:00
Afonso Coutinho 5da662441c Validate slash command time minutes
* fix: reject hour > 23 in 'today/tomorrow' reminder time parsing

* fix: reject minute > 59 in reminder time parsing
2026-06-02 05:50:19 +09:00
Elle d885c70462 Treat Docker host gateway as local
When running Odysseus in Docker and connecting to a local LLM on the host machine (e.g. `llama.cpp` or `Ollama`), the standard endpoint `http://host.docker.internal` is used to breach the container network. 

Because `host.docker.internal` was missing from `_LOCAL_HOSTS`, Odysseus incorrectly treated local self-hosted models as cloud APIs. This triggered the fallback behavior where actual API-reported context limits were being ignored and overridden by hardcoded fallbacks in `KNOWN_CONTEXT_WINDOWS`.

**Changes**
- Added `"host.docker.internal"` to the `_LOCAL_HOSTS` whitelist in `src/model_context.py` so that Dockerized deployments correctly trust and respect the context limits of locally hosted models.

**Checks Ran**
- [x] Syntax check (`python -m py_compile src/model_context.py`)
- [x] Tested manually in Docker (`docker compose up -d --build`) on a Windows host using `llama-server`. The correct API context length is now correctly reported in the UI instead of falling back to the 131k hardcode.
2026-06-02 05:49:59 +09:00
3ef88fc7ff Recognize Gemma as tool-capable
Gemma models (gemma-2/3/4) support OpenAI-style function calling, but
"gemma" was missing from the _model_supports_tools heuristic in
stream_agent_loop(). On a non-allowlisted endpoint (e.g. a self-hosted
OpenAI-compatible server), a Gemma-backed agent therefore never receives
native tool schemas and falls back to the prompt-text tool-call
convention — which Gemma does not follow. The result is that tool calls
are emitted as raw text and never execute.

Add "gemma" to the capability keyword list alongside the other
tool-capable families.

Co-authored-by: 2revoemag <2revoemag@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 05:49:43 +09:00
pewdiepie-archdaemon e03491664a Stabilize security regression tests 2026-06-02 05:48:59 +09:00
Collin 70a71f603c Scope email calendar extraction to account owner
The email auto-calendar pass (settings.email_auto_calendar / the
extract_email_events task) scans recently received mail and lets an LLM
create / update / cancel calendar events. Two problems made it a cross-tenant,
remotely triggerable hole:

1. No owner scoping. _auto_summarize_pass(account_id=None) fans out over EVERY
   enabled account of EVERY user. For each message it fetched an upcoming-events
   snapshot with NO owner filter (all tenants' events) and handed those uids +
   titles to the extraction LLM, then executed the model's ops via
   do_manage_calendar(...) with owner=None. do_manage_calendar only filters by
   owner when owner is not None, so create/update/delete ran across ALL users'
   calendars. Net: every user's event titles/times were disclosed to the model,
   and the model could cancel/move/duplicate any tenant's events by uid.

2. No prompt-injection wrapping. The raw email From/Subject/body were
   interpolated straight into an instruction-shaped extraction prompt (unlike
   the chat path, which wraps external text via src/prompt_security). Anyone
   who can email a user whose instance has auto-calendar enabled could inject
   operations: create attacker-controlled "meeting" events (the path even
   auto-harvests URLs from the body into the event location/description — a
   phishing primitive) or cancel/modify the victim's real events, with zero
   human in the loop.

Fix:
- Add core.database.get_upcoming_events(owner) and use it for the snapshot, so
  the LLM only ever sees the processed account owner's events.
- Look up the EmailAccount owner in _auto_summarize_pass_single and pass owner=
  to every do_manage_calendar call, so create/update/delete are scoped to that
  user (owner=None stays the single-user / legacy escape hatch).
- Tell the extraction model the email is untrusted data and not to follow
  instructions inside it (defense-in-depth against injection).

Add tests/test_calendar_owner_scope.py: get_upcoming_events returns only the
given owner's events (and everything when owner is None). Fails against the old
unscoped query.
2026-06-01 23:12:32 +09:00
Collin 11c2931efb Run auth password work off the event loop
* fix: run bcrypt off the event loop in auth routes

The auth routes are async, but each bcrypt call ran synchronously on the event
loop. bcrypt (checkpw/hashpw) is intentionally CPU-expensive (~100-300 ms), so
every login / signup / setup / change-password froze the single event loop for
that window, stalling all other in-flight requests (chat streams, polling, ...).

/api/auth/login is the worst case: it is reachable unauthenticated, runs bcrypt
twice (verify_password, then create_session re-verifies), and is rate-limited
only per-IP. A burst of login attempts serializes the whole server — cheap
DoS amplification.

Offload the bcrypt-bearing AuthManager calls (setup, signup/create_user,
login's verify_password + create_session, change_password) via
asyncio.to_thread, matching how the codebase already offloads blocking work
(e.g. src/builtin_actions._run_subprocess, email summarize). The event loop
stays responsive while bcrypt runs on a worker thread.

Add tests/test_auth_event_loop.py: asserts login runs verify_password and
create_session on a worker thread, not the loop thread. Fails if those calls
are awaited inline again.

* test: isolate auth event-loop test from heavy core/* import chain

The regression test imported routes.auth_routes, which pulls in
core.auth and so triggers core/__init__.py — transitively importing
src.llm_core (hangs at import under the project venv) and the SQLAlchemy
declarative models (metaclass error on a bare core.database import / under
the conftest sqlalchemy stubs). Reported by the maintainer: collection
failed on system Python and hung under the venv.

Stub core.auth/core.database before the import, mirroring the existing
_ensure_stub pattern in test_auth_regressions.py and test_null_owner_gates.py.
AuthManager is only a type hint here and the handler is exercised with a
MagicMock, so no real core machinery is needed. Test now imports cleanly
and passes in <0.3s without bcrypt/sqlalchemy installed.
2026-06-01 23:12:12 +09:00
kanaru-devandKanaru92 a51a1fc4fc Deep-scrub secrets from public settings
/api/auth/settings is auth-exempt (the frontend + the pre-login page read it for
keybinds/TTS prefs), so non-admin and unauthenticated callers get a scrubbed
copy. The previous scrub only blanked TOP-LEVEL string values whose key matched a
short suffix list — so a secret nested under a non-secret parent key, or stored
under a key outside the list, would leak. A real exposure when the app is
reachable over a Cloudflare tunnel / reverse proxy.

- src/settings_scrub.py: NEW stdlib-only module with the scrub helpers (deep/
  recursive; broadened secret-key patterns). Kept separate from auth_routes so it
  imports + unit-tests WITHOUT pulling the FastAPI / auth / database chain
  (addresses review: the test no longer fails at collection on the DB import).
- routes/auth_routes.py: import scrub_settings from the module.
- tests/test_settings_scrub.py: import the tiny module directly.

Ran: pytest tests/test_settings_scrub.py (8 passed); verified the test pulls no
db/auth modules into sys.modules; py_compile routes/auth_routes.py.

Co-authored-by: Kanaru92 <107661007+Kanaru92@users.noreply.github.com>
2026-06-01 23:11:50 +09:00
Konstantinos Grontis 35f11f2edc Fix sidebar text clipping on Windows 2026-06-01 23:11:19 +09:00
Ernest HysaandErnest Hysa 47a6b510e1 Preserve system messages during context compaction
The context compactor computed split_point against convo_msgs (system
messages filtered out) but applied it directly to session.history which
includes the system messages. After compaction, the original system
prompt was dropped and replaced by an off-by-N slice of the full history.

This silently dropped the system prompt (preset, persona, RAG context)
from every compacted session — the model would lose persona, RAG, and
preset guidance on the next turn after a long conversation.

The split in maybe_compact does:
  convo_msgs = [m for m in messages if m['role'] != 'system']
  split_point = len(convo_msgs) // 2
so split_point is indexed against the system-stripped list. But the
helper _update_session_history took (session, split_point, summary) and
did session.history[split_point:]. session.history is the full list
including the leading system messages, so this dropped the first
system_msg_count messages.

Fix: pass system_msg_count=len(system_msgs) into _update_session_history
and use session.history[system_msg_count + split_point:] as the recent
slice, with session.history[:system_msg_count] prepended to preserve
persona/preset/RAG system messages.

Validated: tests/test_compactor_data_loss.py both tests now pass (were
failing). tests/test_context_compactor.py 12 pre-existing tests still
pass.

Symptom was: post-compaction history = [summary] + assistant_1 + user_2
+ assistant_2 (system_A was lost).

Co-authored-by: Ernest Hysa <ernest@example.com>
2026-06-01 23:10:58 +09:00
ooovenenosoandKevin 5e47e69e99 Allow serving cached local llama.cpp models
Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-01 23:10:08 +09:00
Afonso Coutinho 9b1acf6612 Fix year extraction in research queries
* fix: extract full year in research query entities, not just the century

* fix: same year capture-group bug in the services search copy

* test: research query extracts the full year
2026-06-01 23:09:41 +09:00
Areon Lundkvist f853a3fc67 Harden streaming deltas against null payloads 2026-06-01 23:09:17 +09:00
Mikael A e7d61c724f Let calendar handle Escape while open 2026-06-01 23:08:57 +09:00
Yizreel Schwartz Sipahutar 42380a8693 Keep Cookbook POSIX paths stable on Windows hosts 2026-06-01 23:08:39 +09:00
Steven French 4bbf82c2ab Fix macOS launcher Python path usage 2026-06-01 23:08:20 +09:00
Strahil Peykov 370fe6b501 Warn when localhost auth bypass is enabled 2026-06-01 23:08:01 +09:00
LittleLlamaandClaude Opus 4.7 74dedcad37 Remove duplicate tool index startup warmup
get_tool_index() calls index_builtin_tools() on first init
(src/tool_index.py:469-470), and _warmup_tool_index then calls it
explicitly right after. Every cold boot embeds all 58 built-in tools
twice and double-upserts them into the ChromaDB collection.

The remaining get_tools_for_query call still pre-warms the query path.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:07:42 +09:00
pewdiepie-archdaemon 7711e14f90 Polish email reply and task controls 2026-06-01 23:02:25 +09:00
spooky 033852ab14 fix: require GGUF sources for llama downloads (#368) 2026-06-01 22:47:47 +09:00
pewdiepie-archdaemon f2d55f8726 Fix cached GGUF model metadata in Cookbook Serve 2026-06-01 22:46:54 +09:00
pewdiepie-archdaemon 743c074b2e Harden Cookbook package SSH probe 2026-06-01 22:44:34 +09:00
pewdiepie-archdaemon e5b927597e Fix Cookbook serve exit code reporting 2026-06-01 22:41:25 +09:00
spooky 15822e91ff fix: keep serve preflight errors visible (#398) 2026-06-01 22:40:06 +09:00
spooky 4b72dd407b fix: report serve dependency readiness (#412) 2026-06-01 22:39:36 +09:00
red person 39cec53284 Normalize setup admin username (#448) 2026-06-01 22:38:56 +09:00
Duarte AntunesandCursor 448401a0fc Harden PDF document markers against cross-owner upload access (#445)
Route PDF lookups through UploadHandler.resolve_upload, reject poisoned pdf_source markers on document create/update, and add regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:38:14 +09:00
red person b2e8d692a4 Scope personal RAG uploads by owner (#446) 2026-06-01 22:36:53 +09:00
red person d36896c5f7 Gate image editor AI endpoints by privilege (#447) 2026-06-01 22:35:24 +09:00
william-napitupulu 758a1824c7 Update Styles.css (#463)
Small update to the styles that bothered me, i noticed in the window/modal for calendar when editing a day the time icons had a mask that overlapped the icon.  I simply added 'background-image: none' prop to it/
2026-06-01 22:34:24 +09:00
red person e1102585bf Fix chat stream recovery and PDF library indexing (#468) 2026-06-01 22:33:35 +09:00
Filip 92a81480f7 feat: allow memory import without session (#493) 2026-06-01 22:32:17 +09:00
Dr-Shadow 7be4ece224 Allow to customize the render GID to match the one on the host (#515) 2026-06-01 22:31:33 +09:00
Carlos Arroyo 00320972dc fix: CUDA/GPU detection for vLLM and llama.cpp in Docker (#479)
Two bugs caused GPU inference to silently fall back to CPU inside the
Odysseus Docker container even when the GPU was correctly passed through.

## entrypoint.sh — CUDA_HOME detection only covered CUDA 13.x wheels

The nvcc glob only searched
vidia/cu13, which matches the

vidia-nvcc-cu13 pip wheel layout. CUDA 12.x wheels install nvcc to

vidia/cuda_nvcc/bin/nvcc (nvidia-cuda-nvcc-cu12) or
vidia/cu12
(nvidia-nvcc-cu12) — completely different paths. The glob found nothing,
so CUDA_HOME was never set.

Worse, VLLM_USE_FLASHINFER_SAMPLER=0 was inside the same if-block, so it
was never set either. vLLM then tried to JIT-compile the FlashInfer
sampler at startup, failed with 'Could not find nvcc', and crashed — even
though the GPU was fully visible to the container.

Fix: expand the search to also check nvidia/cu12 and nvidia/cuda_nvcc.
Move VLLM_USE_FLASHINFER_SAMPLER=0 to an unconditional export after the
loop (it is sampler-only, no impact on the attention path, and the correct
setting for any container where CUDA headers may be incomplete).

## cookbook_routes.py — llama.cpp Linux source build silently fell back to CPU

The cmake invocation was:
  cmake -B build -DGGML_CUDA=ON 2>/dev/null || cmake -B build

2>/dev/null suppressed all configure errors. When nvcc is absent (the
slim base image has no CUDA toolkit — intentional), cmake fails silently,
then the || fallback re-runs without -DGGML_CUDA=ON. A CPU-only binary is
produced with no warning. Additionally, a stale CMakeCache.txt from the
failed CUDA attempt was reused (no rm -rf build), poisoning the next
configure run. The macOS branch already did rm -rf build for exactly this
reason; the Linux branch did not.

Fix: before cmake, detect pip-installed nvcc across the same three path
patterns as entrypoint.sh and expose it via CUDA_HOME/PATH. If nvcc is
found, run a clean CUDA build with full error visibility. If not, fall
back to a CPU build with an explicit warning telling the user how to get
a GPU build (install vLLM via Cookbook -> Dependencies, which brings the
CUDA wheels including nvcc, then re-launch).

## .env.example — document Windows COMPOSE_FILE separator

Added a comment showing the semicolon separator required on Windows
Docker Desktop alongside the existing colon-separator (Linux) example.
2026-06-01 22:30:51 +09:00
Alexander KenleyandAlex Kenley 3c6b084f08 Secure by default uplift (#511)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
2026-06-01 22:30:07 +09:00
roxsand12 766ddcaa99 fix: add _setup_lock to prevent race condition in first-run setup (#508) 2026-06-01 22:29:03 +09:00
Sanjay Davis 508fabcb3b Restore dependency refresh after install AND persist safe download mode on retries. (#499) 2026-06-01 22:28:06 +09:00
Afonso Coutinho c38932e6c6 fix: deep research discards valid sources mentioning cookies/copyright (#481)
* fix: drop over-broad 'cookie'/'copyright' low-quality markers

* fix: detect cookie/copyright boilerplate via phrases, not bare words

* test: keep research findings that merely mention cookies or copyright
2026-06-01 22:26:37 +09:00
Alexander KenleyandAlex Kenley 07d92556a3 Fix visual report chapter navigation (#505)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
2026-06-01 22:26:13 +09:00
vidvuds 6ad617931d Fix import-review list not scrolling in Brain modal (#509)
The memory import-review list (.memory-suggestions) is shown inside the
overflow:hidden .admin-card but, unlike the sibling .memory-list, it had
no scroll bounding of its own (no flex:1 / min-height:0 / overflow-y).
A long review list therefore grew past the card and was clipped, leaving
lower entries and their controls unreachable with no usable scroll area.

Give .memory-suggestions the same flex:1 + min-height:0 + overflow-y:auto
bounding the memories list already uses so the review list scrolls
internally within the modal. Pin the review header (the title and the
save all / back controls) with position:sticky so they stay visible while
the items scroll under them, and add a small scrollbar gutter so the bar
does not sit flush against the item cards.

Fixes #455
2026-06-01 22:25:16 +09:00
Cosmin Enacheandcosminae 04fd963394 Fix duplicate compare modal on repeated clicks (#491)
Co-authored-by: cosminae <cosmin.e@annavas.io>
2026-06-01 22:24:27 +09:00
Afonso Coutinho 1eff46579a fix: ChromaDB unreachable blocks app startup for 30-60s (#326) (#476)
* fix: fail fast when ChromaDB is unreachable instead of blocking startup

* fix: only cache the ChromaDB client after a successful heartbeat

* test: cover ChromaDB fast-fail preflight and no-cache-on-failure
2026-06-01 22:22:41 +09:00
Jamieson O'Reilly 171c29dcf3 Fix email-thread HTML injection, attachment path traversal, and missing authz (#475)
Hardens issues found in a security review of the current tree (separate from
the cookbook SSH PR):

- Email thread rendering (static/js/emailLibrary.js): the flat read path runs
  inbound HTML through the allowlist sanitizer, but the two threaded paths
  (_renderTurnsAsBubbles / _renderTurnsFromServer — the default view) injected
  server-parsed `body_html` raw into the DOM. A crafted inbound email could
  inject arbitrary markup (phishing/form/credential-capture/tracking; full XSS
  if a deployment relaxes the script CSP). Now sanitized on all paths.

- Attachment extraction (routes/email_routes.py, routes/email_helpers.py): the
  on-disk extraction dir was `ATTACHMENTS_DIR / f"{folder}_{uid}"` with
  user-controlled folder/uid and no containment, so a folder like `../../tmp`
  could escape ATTACHMENTS_DIR. New attachment_extract_dir() flattens both to a
  single safe segment and asserts containment.

- Diagnostics routes (routes/diagnostics_routes.py): /api/db/stats,
  /api/rag/stats, /api/test/youtube, /api/test-research relied only on the
  global session check (any logged-in user). Now require_admin-gated.

- Defense-in-depth HTML escaping: session HTML export escapes the session name
  (routes/session_routes.py); the MCP OAuth page escapes the reflected Host
  header / server_id (routes/mcp_routes.py).

- Internal-tool token now compared with secrets.compare_digest (constant time)
  in core/middleware.py and app.py.

Adds regression tests in tests/test_security_regressions.py.
2026-06-01 22:20:17 +09:00
Abhinav 9e8de43f25 fix: clear session headers on endpoint deletion (#477) 2026-06-01 22:19:54 +09:00
pewdiepie-archdaemon 5ed9b74cd0 Polish email tasks and window controls 2026-06-01 20:56:46 +09:00
red person 5c390d6b3e Fix sidebar brand text clipping (#362) 2026-06-01 19:04:08 +09:00
red person fd2ea71cec Clarify first-run admin login 2026-06-01 18:59:24 +09:00
Ryan 5de7afd696 Create search cache directory in Docker image 2026-06-01 18:38:37 +09:00
Sirsyorrz 9955f5bc95 Fix VRAM estimates for pre-quantized HF repos
The Cookbook fit scanner was reporting impossibly low VRAM requirements
for some pre-quantized models — e.g. cyankiwi/Qwen3-Coder-Next-REAM-AWQ-4bit
shown as 7.1 GB ('perfect' on a 12 GB card) when the real load is ~40 GB.

Root cause is in the catalog builder. When _entry_from_modelinfo falls
back to safetensors metadata for the parameter count, it stored
safetensors.total directly. For pre-quantized repos that figure reflects
*packed* element counts: AWQ/GPTQ-Int4 pack 8x 4-bit weights into one
I32, AWQ-8bit/GPTQ-Int8/FP8 pack 4x. The catalog therefore recorded
~1/8 of the real parameter count, and min_vram_gb = packed * bpp
double-applied the quantization.

Fix the safetensors fallback:

* prefer the per-dtype parameters dict when available and unpack only the
  I32/I64 entries (the F16/BF16 scale/zero tensors and embeddings are
  already at their real element counts)
* fall back to total * pack_factor when only total is exposed

Patch the catalog entries that were affected by the old fallback so the
fit ratings reflect reality without waiting for a full catalog rebuild:

* cyankiwi/Qwen3-Coder-Next-REAM-AWQ-4bit  11.4B -> 79.7B (40.8 GB VRAM)
* stelterlab/Qwen3-Coder-30B-A3B-Instruct-AWQ  4.6B -> 30.5B
* stelterlab/NVIDIA-Nemotron-3-Nano-30B-A3B-AWQ  5.1B -> 30.5B
* warshanks/Qwen3-8B-abliterated-AWQ  2.2B -> 8.2B
* QuantTrio/sarvam-30b-AWQ  7B -> 30B
* QuantTrio/sarvam-105b-AWQ  19B -> 105B

Closes #377.
2026-06-01 18:32:58 +09:00
Afonso Coutinho 16d6484492 Keep Cc recipients in reply-all
* fix: populate window._myEmailAddress from the active email account

* fix: keep Cc recipients in reply-all when own address is empty or unknown

* test: cover reply-all recipient building (issue #360)
2026-06-01 18:29:22 +09:00
Afonso Coutinho 3884f2b8b7 Prevent task session delivery NOT NULL crashes
* fix: coerce null endpoint_url when delivering task result to a session

* fix: also coerce null model so the session insert satisfies NOT NULL

* test: cover task session delivery on an empty database
2026-06-01 18:28:48 +09:00
Miles df7d32c70c Require document privilege for PDF imports 2026-06-01 18:28:15 +09:00
red person 2f87dbcfbc Show a clear message when PyMuPDF is missing 2026-06-01 18:27:17 +09:00
Rifqi Akram 5b1e56407b Add SSRF-guarded web fetch agent tool
* feat(web-fetch): add web_fetch tool to read a specific URL's content

* test(web-fetch): add SSRF coverage and fail closed on empty DNS resolution

Add explicit SSRF regression tests for the web_fetch path covering
loopback, private LAN ranges, link-local/metadata, IPv6 private/local,
redirect-into-private, and unsupported schemes. Harden _public_http_url
to fail closed when a hostname resolves to no addresses.
2026-06-01 16:57:28 +09:00
Daniel Grzelak 92c2392fd6 Clarify Docker dependency status inside containers
* fix: show docker as N/A inside the container

* test: cover in-container docker detection

* fix: make the N/A dependency chip legible

* refactor: make remote docker applicability explicit and tested
2026-06-01 16:56:42 +09:00
Boody dea917b23f Clarify setup admin login instructions
* fixed confusing credentials prompt

* fix(setup): return status from create_default_admin function

* fix(setup): initialize admin creation status in main function

* fix(setup): enhance admin creation feedback and status handling

* Enhance admin user login messages with conditional feedback based on creation status

* Refine admin user creation feedback messages for clarity and actionability and formatted code

* Add fallback error message for admin creation failure in setup script
2026-06-01 16:55:42 +09:00
red personandred c9c6b919ff Fix database stubs in regression tests (#301)
* Fix database stubs in regression tests

* Keep regression tests independent of SQLAlchemy

---------

Co-authored-by: red <red@red-MacBook-Air.local>
2026-06-01 16:55:09 +09:00
pewdiepie-archdaemon be260f43e8 Handle incomplete detached agent streams 2026-06-01 16:54:11 +09:00
Duarte Antunes e77d87fa80 Enforce owner checks for upload attachments 2026-06-01 16:47:48 +09:00
Nico Panu 8874a11baf Gate Cookbook quick run on downloaded models
Gate Cookbook "Run" on the model being downloaded
The What-Fits tab's quick "Run" button launched a serve task even when
the model was not downloaded. It POSTed directly to /api/model/serve and switched to the Running tab, so vLLM/SGLang would background-pull at launch (and llama.cpp just errors "No GGUF found") while the task showed as "running" without actually serving anything.
The Configure button and the Serve tab already gate on the cached-model
list; quick-Run did not. Mirror that gate: when the model isn't cached,
honor the button's "Download" half by kicking off the download instead of spawning a phantom serve task, and toast the user to Run again once it finishes.
2026-06-01 16:46:24 +09:00
Shiva Prasad b175acd827 Add repository metadata to package.json 2026-06-01 15:41:33 +09:00
sunnyegg ca6907239c Respect text-only emoji setting after svgification
Follow-up to #271. Skip svgifyEmoji when body.text-emojis is set so
deEmojify can strip Unicode from replies; also unwrap existing .emoji
spans from messages rendered before the setting was applied.

Related to #270
2026-06-01 15:41:27 +09:00
pewdiepie-archdaemon 8f93d44917 Validate internal tool owner attribution 2026-06-01 15:25:15 +09:00
14e8cffa41 Fail closed on untrusted teacher draft confidence
Follow-up to #275. get_relevant_skills() treats a missing/unparseable
confidence as 1.0, so it always clears the injection threshold. For
teacher-escalation drafts -- auto-written from a possibly untrusted trace
and then injected as authoritative guidance -- that means a draft can be
auto-injected regardless of the configured confidence bar.

Require teacher-escalation drafts to carry an explicit, parseable
confidence that meets min_confidence; fail closed otherwise. Hand-authored
legacy drafts keep the lenient "unset -> keep" behavior so they don't
silently vanish, and published skills are unaffected.

Ran: python -m py_compile services/memory/skills.py + a get_relevant_skills
unit check (teacher drafts with None/garbage/0.8 excluded at min=0.85; 0.9
included; legacy + published unaffected; gate-off control unchanged).

Co-authored-by: Fernando Lazzarin <263019791+waitdeadai@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:20:29 +09:00
Strahil Peykov aad43a050b Await character templates before populating group dropdowns 2026-06-01 15:18:32 +09:00
Strahil Peykov 3cbfa98c30 Scope session auto-sort changes to current user 2026-06-01 15:18:25 +09:00
Strahil Peykov 8253422832 Allow installing vLLM from cookbook dependencies 2026-06-01 15:18:17 +09:00
Strahil Peykov 493c536199 Avoid caching failed calendar fetch ranges 2026-06-01 15:17:57 +09:00
pewdiepie-archdaemon 0888a3b3e6 Add native Windows compatibility layer 2026-06-01 15:09:47 +09:00
pewdiepie-archdaemon ead7c01822 Trim README quick start 2026-06-01 15:07:41 +09:00
pewdiepie-archdaemon 30594b4e0b Match task status pills to cookbook style 2026-06-01 15:01:24 +09:00
f1817fd560 Add macOS Apple Silicon Cookbook support
* Add Apple Silicon (Metal) GPU detection and unified-memory fit tuning

hardware.py detects Apple Silicon locally and over SSH, reporting
backend=metal, the chip name, and a RAM-scaled fraction of unified
memory as the usable GPU budget. fit.py gains an M1-M4 memory-bandwidth
table for realistic tok/s and drops vLLM-only formats (AWQ/GPTQ/FP8)
that can't be served on Metal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 32ac81dbc6)

* Generate macOS/Metal serve commands and surface the Metal GPU

cookbook_routes.py adds a macOS serve path (Ollama, Metal-aware
llama.cpp build using `sysctl hw.ncpu` instead of `nproc`, and a clear
error if vLLM is attempted). The frontend defaults Metal serving to
llama.cpp and offers llama.cpp/Ollama instead of vLLM/SGLang. The
odysseus-cookbook CLI's `gpus` command reports the Metal GPU via
sysctl/vm_stat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4ba01ce25d)

* Add launchd LaunchAgent for macOS (systemd equivalent)

com.odysseus.ui.plist + install-service-macos.sh run Odysseus at login
and restart on crash, the macOS counterpart to odysseus-ui.service. The
installer auto-fills paths from the venv, so there's no hand-editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 3d4b6b2c7b)

* Document macOS install (brew, Ollama, AirPlay port, launchd)

README + setup.py cover the Homebrew / Apple Silicon path: brew install
python@3.11 tmux ollama, Metal serving via Ollama/llama.cpp, the launchd
service, and the macOS AirPlay Receiver conflict on ports 7000/5000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 8dc9a3578a)

* Add downloadable macOS launcher app builder

build-macos-app.sh generates dist/Odysseus.app and a drag-to-Applications
dist/Odysseus.dmg. The app starts the local server from this repo's venv and
opens the UI in a chrome-less app window (Chromium --app mode, falling back to
the default browser). It's a launcher wrapper — it drives the venv rather than
bundling Python — so the install path is baked in at build time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 7927940c38)

* Harden macOS Cookbook support: hide MLX, fix Metal build cache

Builds on the adopted PR #213 macOS/Metal work with two fixes and tests:

- fit.py: always drop MLX-quantized models. Odysseus only generates serve
  commands for llama.cpp/Ollama (Metal) and vLLM/SGLang (CUDA); MLX needs the
  mlx_lm runtime and the catalog's MLX repos ship no GGUF alternative, so they
  were surfaced on Apple Silicon but could never be served.
- cookbook_routes.py (macOS branch only): `rm -rf build` before configure so a
  poisoned CMakeCache from a prior failed CUDA attempt can't make every later
  build fail; explicit -DCMAKE_BUILD_TYPE=Release; a clear "brew install cmake"
  hint if cmake is missing. Linux/CUDA path unchanged.
- tests/test_hwfit_macos.py: MLX hidden on metal, MLX still hidden on CUDA
  (regression guard), Metal detection on Apple Silicon, and skipped on
  Linux/Intel (proves non-macOS detection is untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Propagate unified_memory flag and document macOS GPU/Docker caveat

- hardware.py: detect_system now carries the unified_memory flag from GPU
  detection into the system dict (it was set by _detect_apple_silicon / AMD-APU
  detection but dropped during result assembly, so the API always reported
  null). Lets callers distinguish unified from discrete VRAM.
- README: prominent warning that Docker on Apple Silicon can't reach the Metal
  GPU (runs a Linux VM) — Cookbook must run natively for GPU serving; fix stale
  text that said Cookbook recommends MLX models (now hidden as unservable).
- test: detect_system propagates unified_memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Put Odysseus's venv bin on PATH for cookbook runners

Native (non-Docker) installs run from a virtualenv whose bin holds the `hf` CLI
and `python3` the cookbook download/serve tmux scripts shell out to. Those
scripts start in a fresh login shell with the venv NOT activated, so on a native
macOS install `hf download` failed with "hf: command not found" — and the
`pip --user` self-heal missed because macOS has no bare `pip` command.

- cookbook_helpers.py: _local_tooling_path_export() — pure helper returning a
  PATH export for the running interpreter's bin dir (escaped for double quotes).
- cookbook_routes.py: download + serve runners prepend that dir on local runs
  (gated off SSH/Windows); swap the `pip` install fallbacks to `python3 -m pip`.
- tests: helper output for normal and spaced paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Document macOS llama.cpp serving prerequisites

Clarify the two serving paths on Apple Silicon: the recommended zero-build
route (brew install llama.cpp ships a Metal llama-server Cookbook finds on PATH),
and the from-source fallback, which requires cmake + Xcode Command Line Tools.
Without those the build is skipped and serving silently degrades to a slow CPU
build, so new users now know to install them (or use the prebuilt) up front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Recommend only GGUF-servable models on Metal

Apple Silicon's only serving engines are llama.cpp and Ollama, both GGUF-only
(vLLM/SGLang are CUDA/ROCm and don't run on macOS). The catalog tags raw
safetensors repos with a default Q4_K_M quant, so the fit-ranking was
recommending ~397/501 models that have no GGUF and fail to serve on Metal with
"No GGUF found" (e.g. microsoft/Phi-mini-MoE-instruct).

Drop any model without a real GGUF (is_gguf/gguf_sources) on Apple Silicon —
subsumes the previous AWQ/GPTQ/FP8 special-case into one rule. On CUDA these
stay visible since vLLM serves safetensors directly. Metal recommendations go
501 -> 104, all actually servable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove macOS launchd LaunchAgent (cherry-picked extra)

Drop the launchd service from the PR #213 cherry-picks: the
install-service-macos.sh installer, the com.odysseus.ui.plist template, and the
README section documenting them. Tangential to the core Cookbook/Metal support
and not wanted. The build-macos-app.sh launcher is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add one-command macOS quick start (start-macos.sh)

Running Odysseus natively on a Mac previously meant ~7 manual terminal steps
(brew deps, venv, activate, pip, setup.py, uvicorn with the right port) — not
friendly for a generic macOS user, and the native run is required because Docker
on macOS can't reach the Metal GPU.

- start-macos.sh: installs Homebrew deps (python@3.11, tmux, prebuilt Metal
  llama.cpp), creates the venv, installs requirements, runs setup, and launches
  on a non-AirPlay port (7860). Idempotent; re-run to start again.
- README: the Apple Silicon section now leads with this one-command quick start
  and the clickable .app, with engine/port/manual details folded into a
  collapsible block. Added a pointer at the top of the manual-install section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* macOS quick start: auto-open browser when ready

The "open this URL" line scrolled out of view as uvicorn kept logging after it,
so users missed it. Now start-macos.sh waits (in the background) until the
server accepts connections, prints a boxed "ready" banner at that point (i.e.
after the startup burst, not before), and opens the URL in the default browser
automatically. Skippable with ODYSSEUS_NO_OPEN=1 for headless/SSH use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Don't assume/force a specific Python version on macOS

The README claimed "system Python is 3.9" — a machine-specific generalization
that's often wrong (macOS ships no recent Python by default; many users already
have 3.11+). Make it generic, and make start-macos.sh detect an existing
Python 3.11+ and use it, only installing python@3.11 when none is found instead
of forcing it on top of the user's Python.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align start-macos.sh venv path with build-macos-app.sh

start-macos.sh created the environment in .venv/, but build-macos-app.sh and
the manual install steps use venv/ — so the clickable .app wouldn't reuse the
quick-start's environment and would rebuild a second one. Use venv/ everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* README: state clearly that MLX is unsupported on Apple Silicon

Odysseus has no mlx_lm runtime; it serves GGUF (llama.cpp/Ollama) and CUDA
(vLLM/SGLang) only. MLX-only models can't run on a Mac and are hidden from
Cookbook — make that explicit in both the quick start and the details.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* start-macos.sh: build the venv with an arm64 Python on Apple Silicon

A clean-room run surfaced this: with a universal2/x86 Python (e.g. the
python.org installer under /usr/local), the venv's compiled extensions install
as arm64 but get loaded as x86_64 when launched from the .app bundle, so it
crashes with "incompatible architecture (have arm64, need x86_64)". The terminal
run happened to work only because a universal binary defaults to arm64 there.

On Apple Silicon, look only under /opt/homebrew (arm64-only) for the build
Python, and install Homebrew's python@3.11 if none is present — so the venv is
arm64-only and launches correctly from both the terminal and the .app. Intel
and non-mac paths are unchanged. Verified end-to-end in a clean clone: .app now
boots on Metal with no arch error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address dev-exp review: macOS setup robustness + doc/UX fixes

From the voltagent dev-exp review of the branch:
- README: fix broken anchor links (the em-dash heading produced a slug the links
  didn't match); simplify the heading to a stable slug.
- cookbook_routes.py: add /opt/homebrew/bin and /usr/local/bin to the serve PATH
  so a brew-installed llama-server/ollama is found instead of falling back to a
  slow source build.
- start-macos.sh: guard against an empty Python path; fail fast with a clear
  message on port-in-use; ERR trap with a "safe to re-run" message; show pip
  progress (drop --quiet on the slow requirements install); stop the background
  browser-opener cleanly on exit/Ctrl+C (no orphaned poller).
- setup.py: bind hint to 127.0.0.1; suppress the manual run-hint when launched
  by start-macos.sh (ODYSSEUS_SKIP_RUN_HINT) so the URL isn't contradictory.
- build-macos-app.sh: the .app only opens the browser once the server is
  actually ready (not after the readiness timeout).
- cookbookServe.js: drop "Diffusers" from the Metal backend picker —
  diffusion_server.py is CUDA-only, so it was an unservable option on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: yunggilja <yunggilja@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 14:59:19 +09:00
pewdiepie-archdaemon b998c52dd0 Add Deep Research extraction controls 2026-06-01 14:55:33 +09:00
pewdiepie-archdaemon 6872679f31 Make task status pills interactive 2026-06-01 14:54:24 +09:00
pewdiepie-archdaemon ee62acd3f1 Fix library space toggles 2026-06-01 14:50:06 +09:00
pewdiepie-archdaemon ace470b437 Let hovered space toggles override stale focus 2026-06-01 14:45:47 +09:00
pewdiepie-archdaemon e846269d61 Improve space toggles and email warmup 2026-06-01 14:35:38 +09:00
Alexander KenleyandAlex Kenley cb8a0b268d Route calendar action requests to tools
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
2026-06-01 14:32:41 +09:00
LittleLlama 7e7e441fec Re-enable VectorRAG init with lazy retry
Personal Docs (POST /api/personal/add_directory and friends) currently
returns HTTP 503 'RAG system is not available' for every request,
because get_rag_manager() and rag_manager are both hardcoded off. The
disablement was added when chromadb 1.4.1 / pydantic 2.12 were mutually
incompatible at the client init layer.

That compat issue is fixed in the current pins (chromadb 1.5.x +
pydantic 2.13.x). Verified by calling the original lazy initializer
against a running chroma server — VectorRAG instantiates, reports
healthy=True, and indexes successfully.

This change:

1. src/rag_singleton.py — replace the hardcoded `return None` in
   get_rag_manager() with the original lazy init body. Keeps the
   30s retry-throttle so a missing chroma server doesn't busy-retry
   on every request.

2. app.py — replace the parallel `rag_manager = None` /
   `rag_available = False` hardcoding with a get_rag_manager() call.
   Logs the resolved state at startup. If chroma isn't reachable yet,
   rag_manager stays None and personal-doc routes still return 503,
   but the *next* request will hit the retry-throttle path in
   get_rag_manager() and try to init again.

Doesn't touch requirements.txt. Repos using docker-compose get chroma
automatically; manual installs that want Personal Docs to work still
need to either pip install chromadb (full package) and run `chroma run`
or point at an external chroma instance via env. That can be a
follow-up README / requirements-optional note.
2026-06-01 14:32:13 +09:00
93d3cc49c2 harden(teacher): treat escalation trace as untrusted data (#275)
The teacher-escalation loop distills a failed turn's trace into a
persisted skill, but the trace includes raw tool output (web pages,
emails, retrieved documents) that can carry prompt-injection. Skills are
later injected as authoritative "follow step by step" guidance, so an
injected instruction in tool output could be laundered into a skill the
student follows on a later turn -- bypassing the untrusted-content
wrapper that protects the live turn.

Fence the trace in both teacher prompts and add an explicit "this is
data, not instructions" guard so the teacher won't copy directives out
of tool output into a procedure. Additive prompt hardening; no
default-UX change.

Ran: python -m py_compile src/teacher_escalation.py + a format/fencing
smoke test (both templates format; an injected instruction stays fenced
inside the untrusted block).

Co-authored-by: Fernando Lazzarin <263019791+waitdeadai@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:31:39 +09:00
Alexander KenleyandAlex Kenley 2c4b8b57dd feat(ai): add OpenRouter and Ollama Cloud providers (#231)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
2026-06-01 14:26:10 +09:00
pewdiepie-archdaemon 4dbc0fe73a Prewarm email list before first open 2026-06-01 14:25:17 +09:00
Mohammed Efaz 0be870f837 docs: add star history chart to readme (#259) 2026-06-01 14:24:52 +09:00
Tanmay Jain 0532fed989 Pin pydantic to v2 so install doesn't pull v1 without pydantic-core (#139)
Unpinned, pip can resolve pydantic v1, which has no pydantic-core, and
the app fails on import. Pin pydantic and pydantic-settings to v2.
2026-06-01 14:23:50 +09:00
LittleLlama ec43ba83dd Fix NPX MCP server crash (skip if not installed, alternative shape to #242 / #252) (#253)
* Fix NPX MCP server crash by checking install state instead of timing out

When @playwright/mcp (or any future npx-based built-in server) isn't
already cached, npx tries to download and install it on first invoke.
That can take minutes or hang on a fresh install missing Playwright
system deps. The previous code bounded that wait with
asyncio.wait_for(mcp_manager.connect_server(...), timeout=30), but the
cancellation that wait_for fires on timeout propagates into
mcp.client.stdio.stdio_client's internal anyio task group, which
raises:

    RuntimeError: Attempted to exit cancel scope in a different task
    than it was entered in

The error fires in a sibling background task (Task exception was never
retrieved) so the surrounding try/except BaseException doesn't catch
it, and the orphaned cancel scope cascades cancellations into other
tasks in the same event loop. Running requests start failing and the
process needs a restart.

Fix: detect whether the package is already cached before invoking
connect_server, instead of trying to bound the connect with a timeout.
A new _is_npx_package_cached helper runs:

    npx --no-install <pkg> --version

The --no-install flag makes npx fail fast on a cache miss instead of
downloading, so the probe returns in <500ms either way. If the package
isn't cached, we log a warning with the exact command the user can run
to install it, and skip the server. If it is cached, we call
connect_server normally with no wait_for wrapper, so there's no
cancellation that could enter stdio_client's task group.

This removes the entire bug class instead of papering over it. No
asyncio.wait_for around stdio_client, no shielded-task leak, no
shutdown-time RuntimeError. Verified against current versions
(mcp library on Python 3.14, anyio 4.13.0) with the existing
@playwright/mcp@latest cached, and with a deliberately uncached
package spec to exercise the skip path.

* Make first-run setup explicit when NPX MCP package isn't cached

Per @pewdiepie-archdaemon review on #253:

- src/builtin_mcp.py: expand the skip-server warning into a multi-line
  block with Reason/Impact/Fix/Notes lines, so the message stands out
  in startup logs and clearly tells the user what to run.

- README.md: add 'Built-in MCP servers (optional setup)' subsection
  under Configuration, with the install command and a brief note that
  it's optional and skipped if not cached.
2026-06-01 14:23:19 +09:00
Sirsyorrz 09acf955f1 models: dedupe endpoints by base_url on create (#266)
POST /api/model-endpoints always inserted a new row, so Settings -> Add
Models -> Scan for Servers re-added any endpoint a user had already
registered manually — once under its model name (from the earlier
manual add) and again under its host:port (auto-generated when scan
posts without a name). The success toast then misreported the result
as "added N new".

Look up an existing endpoint with the same base_url accessible to the
caller (shared or owned by them) before inserting. If found, return it
with `existing: true` so the client can tell the difference between
an actual add and a dedupe hit. Toast now reads, e.g.,
"Found 1 server with 1 model — 1 already added".

Tested: POSTing the same base_url three times (incl. trailing-slash
variation) returns the same id each time; only one row exists.
2026-06-01 14:22:06 +09:00
pewdiepie-archdaemon 5c142ec34a Keep email reader height stable while loading 2026-06-01 14:19:07 +09:00
pewdiepie-archdaemon c5bbac55c4 Reduce Docker context and fix emoji markdown rendering 2026-06-01 14:18:41 +09:00
pewdiepie-archdaemon c43c995bd2 Hide pending email send toast after delay 2026-06-01 14:09:02 +09:00
pewdiepie-archdaemon d6c4b70507 Clarify slow email send status 2026-06-01 14:01:56 +09:00
Sirsyorrzandkrllus 4c0aadbb5e docker: add NVIDIA/AMD GPU overlays via COMPOSE_FILE (#254)
Opt-in overlays under docker/ that pass the host GPU into the odysseus
container. Pick one in .env:

  COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
  COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml

Non-GPU users are unaffected (no default merge). README now points at
the overlays instead of the old ad-hoc `gpus: all` suggestion.

Each overlay header notes that it only exposes the GPU devices — the
slim image still needs vLLM / llama-cpp-python / etc. installed via
Cookbook -> Dependencies before models can serve on GPU.

Tested on Arch + Docker 29.5.1 + RTX 4090:
  docker compose exec odysseus nvidia-smi -L
  GPU 0: NVIDIA GeForce RTX 4090 (UUID: GPU-...)
Cookbook hardware scan reports the 24 GB GPU and recommends GPU-fit
models. `docker compose config` validates cleanly for all three
COMPOSE_FILE variants (base, +nvidia, +amd).

Builds on the structure proposed in #91 by @krllus with the path /
docs fixes from the review on that PR.

Closes #163.

Co-authored-by: krllus <krllus@users.noreply.github.com>
2026-06-01 14:00:09 +09:00
pewdiepie-archdaemon 2537b80f88 Stabilize email card expansion loading 2026-06-01 13:58:27 +09:00
Collin 0a7de1fdf4 fix: stop leaking DB connections when persisting session mode (#64)
chat_routes.py persisted a session's "mode" in three best-effort spots —
reading the current mode, writing the effective mode, and setting
research_pending on the stream path. Each opened a session with SessionLocal()
and called .close() as the LAST statement inside a try/except, so if anything
before close() raised (e.g. a SQLite "database is locked" under concurrent chat
streams) the except only logged and the connection was never returned to the
pool.

DATABASE_URL defaults to file-backed SQLite, whose engine uses SQLAlchemy's
default QueuePool (5 connections + 10 overflow). Repeated leaks on these hot
paths exhaust the pool; later requests then block for pool_timeout and fail
with "QueuePool limit ... reached", taking the app down until restart.

Move the logic into two best-effort helpers in core.database, next to the
existing session helpers (update_session_last_accessed, get_session_by_id):

  - get_session_mode(session_id) -> Optional[str]
  - set_session_mode(session_id, mode) -> bool

Both route through the existing get_db_session() context manager, which commits
on success, rolls back on error, and always closes in a finally, so the
connection is returned to the pool on every path. chat_routes.py now calls
these instead of hand-rolling sessions, also removing three copies of the same
try/except.

Add tests/test_session_mode_helpers.py: the helpers commit+close on success
and, on a mid-operation DB error, swallow + roll back + close (no leak). The
error-path tests fail against the old close()-inside-try pattern.
2026-06-01 13:57:48 +09:00
pewdiepie-archdaemon 8218421733 Polish email send and card toggles 2026-06-01 13:52:07 +09:00
AzaelMewandClaude Opus 4.8 7023468cea Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170)

Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar
on the year matching DTSTART, not in subsequent years. The list_events
query filtered by , which excludes
recurring events whose original dtend (e.g. 2019-07-22) falls before
the requested window (e.g. 2026).

Fix: split the query into two branches — non-recurring events still
require window overlap, but recurring events (with non-empty RRULE)
are fetched by dtstart < end_dt alone. A new helper,
_expand_rrule_occurrences(), uses dateutil.rrule to expand each
recurring event into individual occurrence dicts within the requested
date range, so YEARLY/WEEKLY/MONTHLY events render correctly across
all years.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* recurrence: compound UIDs, frontend fixes, python-dateutil req, tests

- Replace _expand_rrule_occurrences with _expand_rrule that emits stable
  compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can
  distinguish occurrences from the same series. Non-recurring events
  pass through with is_recurrence=false and series_uid=uid.

- Add _resolve_base_uid() to extract the base series UID from compound
  UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the
  manage_calendar tool so edits/deletes always target the base row.

- Update manage_calendar tool to import and use _resolve_base_uid.

- Frontend _updateEvent / _deleteEvent: detect compound UIDs and
  invalidate localStorage cache after success so stale sibling
  occurrences aren't shown.

- Add python-dateutil to requirements.txt as an explicit dependency.

- Add 14 regression tests in tests/test_calendar_recurrence.py
  covering _resolve_base_uid edge cases, _expand_rrule with
  yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and
  metadata inheritance.

- Merge upstream's cleaner SQLAlchemy or_/and_ query pattern.

* recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings

Fix three edge cases in _expand_rrule:

1. Malformed-RRULE fallback now checks window overlap. list_events
   fetches recurring rows with only dtstart < end_dt, so a broken
   old recurring event could appear in unrelated future windows.
   Now fallback returns [] unless the base event's dtstart/dtend
   actually intersect [start, end).

2. Exclusive end boundary. rule.between(start, end, inc=True) was
   inclusive on end, but the route contract and non-recurring SQL
   filter both use [start, end). Added occ_start >= end guard.

3. Multi-day crossings. A recurring occurrence that starts before
   the window but ends inside it was missed (only occ_start was
   checked). Now expands from start - duration and filters by
   occ_start < end AND occ_end > start, matching non-recurring
   overlap behavior.

Tests: +4 tests for these cases (18 total)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:42:44 +09:00
pewdiepie-archdaemon 8df5ed2a96 Let email sends continue after closing compose tab 2026-06-01 13:42:14 +09:00
pewdiepie-archdaemon a4349f4b29 Clarify contacts integration cards 2026-06-01 13:35:13 +09:00
cryptojiandToji 17fe738659 fix(settings): MCP server add — POST as multipart/form-data, not JSON (#107)
routes/mcp_routes.py declares POST /api/mcp/servers with FastAPI
Form(...) params. The Save handler in static/js/settings.js was
sending application/json, so the Form parser saw no fields and
returned 422 with "Field required" for every input — clicking Save
did nothing visible.

Build a FormData object and let the browser set the multipart
Content-Type. args/env are JSON-stringified per the controller
contract (defaults "[]" / "{}"); bad JSON still falls back to
defaults, same as before.

Also check r.ok and surface non-2xx in the form-status span — the
previous code never checked status, so a 422 looked like success.

Matches the FormData pattern already used in this file (uf-mcp-toggle,
~L4036) for the toggle-enable PATCH against the same controller.

Co-authored-by: Toji <ccryptoji@gmail.com>
2026-06-01 13:23:05 +09:00
pewdiepie-archdaemon 791fd4711b Secure cookbook package probe endpoint 2026-06-01 13:22:37 +09:00
Hasn 490c8e1b91 codeblock copy and styling (#249) 2026-06-01 13:21:57 +09:00
pewdiepie-archdaemon c6bb4d16bb Make email escape close reliable 2026-06-01 13:21:12 +09:00
pewdiepie-archdaemon 26364cc691 Revert "Keep email list mounted between opens"
This reverts commit b8fefe1a82.
2026-06-01 13:20:03 +09:00
pewdiepie-archdaemon b8fefe1a82 Keep email list mounted between opens 2026-06-01 13:17:58 +09:00
pewdiepie-archdaemon 24f73bf539 Fix shell routes on Windows without PTY support 2026-06-01 13:15:40 +09:00
pewdiepie-archdaemon 3aa3f0fbc0 Prefetch adjacent emails while reading 2026-06-01 13:14:47 +09:00
pewdiepie-archdaemon 440373b96d Warm recent email read cache 2026-06-01 13:12:11 +09:00
chrisdvz.ioandClaude Opus 4.8 ff81a22285 perf(ui): hoist esc() lookup table and build option lists once (#160)
Hoist the HTML-escape lookup table in static/js/ui.js out of the
String.replace callback so it is allocated once instead of on every
matched character. esc() is the canonical escaper aliased across 27
modules and runs on essentially every render, so this removes a lot of
short-lived garbage on the hottest text path. Output is byte-identical
(verified across null/undefined/emoji/attribute edge cases).

Also build the <select> option lists in cookbook-hwfit.js and group.js
by accumulating a string and assigning innerHTML once, instead of
`innerHTML +=` inside a forEach (which makes the browser re-parse the
element's markup on every iteration). Final DOM is unchanged.

Pure micro-optimizations; no behavior change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:09:33 +09:00
Håkon Julius Størholt 91d3511580 Recognize local vision models so their images aren't dropped (#185)
An image attachment only got through if the model name was on a short
built-in list. Anything else was treated as text-only and the image was
quietly dropped, so the model never saw it. That left out a lot of the
smaller vision models you can run locally (moondream was the one I hit).

Pulled the check into is_vision_model() in chat_helpers, broadened it to
cover those, and added a test. Models that already worked are unaffected.

Fixes #124.
2026-06-01 13:09:21 +09:00
pewdiepie-archdaemon 32e7cec362 Use stable IMAP UIDs for email actions 2026-06-01 13:08:42 +09:00
pewdiepie-archdaemon 791939014c Move email account management to integrations 2026-06-01 13:01:33 +09:00
pewdiepie-archdaemon 4e79ddcfb7 Add admin user rename 2026-06-01 12:52:58 +09:00
pewdiepie-archdaemon a66f241e21 Preserve large pasted messages in context 2026-06-01 12:38:35 +09:00
pewdiepie-archdaemon 1ce00b5dea Stop auto-adding Ollama endpoints 2026-06-01 11:52:49 +09:00
Alan Met 864e7ad558 Sidebar Chat button Quality of Life improvement. (#155) 2026-06-01 02:52:10 +00:00
Mikael A 411cb872cc Fix Windows startup compatibility issues (#149) 2026-06-01 02:51:31 +00:00
Daniel Grzelak 99ad456adf fix: group cookbook dependencies into Odysseus and Server sections (#144)
* fix: group cookbook dependencies into Odysseus and Server sections

* refactor: tidy dependency render with guard clauses and a section-header class
2026-06-01 02:50:50 +00:00
Sirsyorrzandsirs b4a1d88beb docker: set CUDA_HOME for pip-installed vllm in Cookbook (#228)
When Cookbook installs vllm via `pip install --user vllm`, pip pulls in
nvidia-cuda-* wheels under /app/.local but doesn't set CUDA_HOME or
create /usr/local/cuda. vllm 0.22+ then crashes during engine init:

  RuntimeError: Could not find nvcc and default cuda_home='/usr/local/cuda' doesn't exist

After that, the mixed cuda-nvcc 13.3 / cuda-runtime 13.0 wheel combo
fails FlashInfer's JIT sampler with:

  error: "CUDA compiler and CUDA toolkit headers are incompatible"

Detect the pip-installed nvcc on startup, point CUDA_HOME at it, and
default VLLM_USE_FLASHINFER_SAMPLER=0 (sampler only, no attention
impact) so the engine boots. No-op when vllm isn't installed.

Fixes #214.

Co-authored-by: sirs <sirs@local>
2026-06-01 02:48:25 +00:00
Jasper StubbeandJasper Stubbe 83bab67641 Add explcit docker image source for the podman users (#224)
Co-authored-by: Jasper Stubbe <jasper.stubbe.b@gmail.com>
2026-06-01 02:47:59 +00:00
pewdiepie-archdaemon c97375343d Clarify Cookbook diffusion dependencies 2026-06-01 11:45:26 +09:00
pewdiepie-archdaemon c953c078e5 Improve Cookbook serve reliability 2026-06-01 11:43:08 +09:00
Ranjan Sharma 058d32451c Fix fresh checkout test failures
Make .env optional in tests and prevent endpoint resolver stubs from leaking into model route tests.
2026-06-01 02:22:17 +00:00
pewdiepie-archdaemon 415d115b17 Make Docker web port configurable 2026-06-01 11:20:25 +09:00
pewdiepie-archdaemon 577f2cfc18 Fix chat message history timestamps 2026-06-01 11:18:18 +09:00
Chat Sumlin 178befddd7 Fix duplicate CalDAV sync UIDs
Track uncommitted CalendarEvent rows during a CalDAV sync batch so duplicate UIDs update the pending row instead of inserting twice.
2026-06-01 02:17:43 +00:00
Chris Rowland 6c68631c26 Fix timezone-aware calendar event times
Render timezone-aware calendar timestamps in the browser local timezone while preserving naive wall-clock timestamps.
2026-06-01 02:15:58 +00:00
Juan Pablo Jiménez 4a04068818 Fix vision attachment timeout and stale cache
Increase local vision model timeout and avoid caching transient VL failure placeholders.\n\nCloses #202.
2026-06-01 02:04:46 +00:00
pewdiepie-archdaemon 71d74290f0 Generate SearXNG secret on first boot 2026-06-01 11:03:02 +09:00
pewdiepie-archdaemon 89093d0b9f Add contributing guide 2026-06-01 10:57:31 +09:00
pewdiepie-archdaemon 67f1675130 Restrict credentialed CORS headers 2026-06-01 10:54:08 +09:00
pewdiepie-archdaemon 7dd12fc3ad Fix landing footer snap and uvicorn docs 2026-06-01 10:51:55 +09:00
pewdiepie-archdaemon 0e3734a318 Align SearXNG fallback URL 2026-06-01 10:50:07 +09:00
pewdiepie-archdaemon bf325f6b21 Gate embedding routes behind admin auth 2026-06-01 10:47:11 +09:00
pewdiepie-archdaemon 7e1a9ccc88 Close stale setup and line-ending gaps 2026-06-01 10:43:35 +09:00
pewdiepie-archdaemon df5a4dd954 Clarify Windows install support status 2026-06-01 10:36:08 +09:00
pewdiepie-archdaemon d9d95b4855 Improve OpenRouter and Groq provider requests 2026-06-01 10:32:14 +09:00
pewdiepie-archdaemon 172cc51972 Fix GitHub Pages repository links 2026-06-01 10:30:47 +09:00
pewdiepie-archdaemon d026e13a5a Fix provider setup and strip message metadata 2026-06-01 10:20:18 +09:00
pewdiepie-archdaemon fc7f107b22 Improve Ollama setup and model endpoint handling 2026-06-01 10:00:15 +09:00
pewdiepie-archdaemon 051751adcd Remove mobile fun fact from README 2026-06-01 00:52:10 +09:00
pewdiepie-archdaemon d70e052aa9 Fix mobile preview interaction on landing page 2026-06-01 00:48:07 +09:00
pewdiepie-archdaemon 0b2d6d20c1 Landing page: GitHub URLs point to real repo (pewdiepie-archdaemon/odysseus) 2026-06-01 00:00:14 +09:00
2037 changed files with 58125 additions and 529025 deletions
-13
View File
@@ -9,17 +9,6 @@ __pycache__/
dist/
build/
.env
.env.bak.*
# Secrets: keep plaintext and every transient secrets.env variant out of
# the build context. If an encrypted secrets.env is used, it is mounted
# at runtime — never baked into the image. Mirrored in .gitignore.
secrets.env
secrets.env.*
secrets.env~
.secrets.env.swp
.secrets.env.swo
**/#secrets.env#
!secrets.env.example
/data/
/logs/
.git/
@@ -30,8 +19,6 @@ secrets.env~
.idea/
dev-docs/
docs/
website/
assets/branding/
*.md
*.db
*.sqlite
+1 -167
View File
@@ -16,10 +16,6 @@ LLM_HOST=localhost
# when started with OLLAMA_HOST=0.0.0.0:11434.
# OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
# Optional LM Studio URL. In Docker, host LM Studio is reachable here
# when LM Studio is set to serve on all interfaces (0.0.0.0).
# LM_STUDIO_URL=http://host.docker.internal:1234
# OpenAI API key (only needed if using OpenAI models).
# Do not commit real keys. Keep this commented until needed.
# OPENAI_API_KEY=your_openai_api_key_here
@@ -27,16 +23,6 @@ LLM_HOST=localhost
# Research service LLM endpoint
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
# Extra CA bundle for LLM providers whose TLS chain isn't in the default
# trust store. Layered ON TOP of the system / certifi bundle — verification
# stays on for every host, the trust set just gets larger. Useful for:
# - GigaChat / Sber (Russian Trusted Root CA): without this the endpoint
# shows offline with CERTIFICATE_VERIFY_FAILED — self-signed certificate
# in certificate chain.
# - On-premise / corporate LLM gateways with an internal CA.
# Point at a PEM file containing the missing root(s).
# LLM_CA_BUNDLE=/etc/odysseus/ca/extra-roots.pem
# ============================================================
# Search & Web
# ============================================================
@@ -56,22 +42,10 @@ SEARXNG_INSTANCE=http://localhost:8080
# SQLite database path (default: sqlite:///./data/app.db)
# DATABASE_URL=sqlite:///./data/app.db
# ============================================================
# Data directory
# ============================================================
# Move everything that lives under data/ - settings, sessions, database, auth,
# cache, uploads, etc. - to another path:
# ODYSSEUS_DATA_DIR=C:\path\to\dir
# ============================================================
# Auth & Security
# ============================================================
# Optional backend workspace used automatically by the WebUI when no workspace
# is saved in the browser. This must be a directory visible to the backend;
# with host-workspace mapping, a host path is translated before vetting.
# ODYSSEUS_WORKSPACE_DEFAULT=/workspace/project
# Enable authentication (default: true)
# AUTH_ENABLED=true
@@ -81,34 +55,10 @@ SEARXNG_INSTANCE=http://localhost:8080
# Change this if another local service already uses 7000 (macOS AirPlay often does).
# APP_PORT=7000
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
# when Docker would otherwise advertise a container address or loopback. Use a
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
# name that the phone can reach. HTTPS and public hostnames are not supported
# by the current companion client. Do not include credentials, a path, query,
# or fragment.
# COMPANION_BASE_URL=http://192.168.1.50:7000
# Development-only auth bypass for loopback requests.
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
# LOCALHOST_BYPASS=false
# Skip the external-context exact-approval pause for unattended local agents.
# Keep false for shared or internet-exposed deployments.
# ODYSSEUS_UNATTENDED_MODE=false
# Optional post-external-context tool approval gate. Off by default because it
# can block normal agent work; enable only for deployments that want this fence.
# ODYSSEUS_TOOL_APPROVAL_GATE=0
# Mark session cookies Secure. Left unset, this follows the request scheme:
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
# force it on, or false to force it off while you still serve plain HTTP.
# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false
# from your .env unless you still need that escape hatch — it keeps HTTPS logins
# on a non-Secure cookie.
# SECURE_COOKIES=true
# Optional: pre-seed the first admin password during setup.
# Do not commit a real password.
# ODYSSEUS_ADMIN_PASSWORD=change_me_before_first_boot
@@ -144,9 +94,6 @@ SEARXNG_INSTANCE=http://localhost:8080
# Default: http://{LLM_HOST}:11434/v1/embeddings (ollama)
# EMBEDDING_URL=http://localhost:11434/v1/embeddings
# Embedding API key (if there's one)
# EMBEDDING_API_KEY=embedding_api_key_here
# Embedding model name (must be available at the endpoint above)
# EMBEDDING_MODEL=all-minilm:l6-v2
@@ -155,42 +102,6 @@ SEARXNG_INSTANCE=http://localhost:8080
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
# ============================================================
# Google OAuth2 (Google Workspace / .edu email accounts)
# ============================================================
# Required to use the "Connect with Google" OAuth flow in email account setup.
# Create credentials at: console.cloud.google.com → APIs & Services → Credentials
# 1. Enable the Gmail API for your project.
# 2. Configure the OAuth consent screen (User Type: Internal for Workspace orgs).
# Add scopes: https://mail.google.com/ and email.
# 3. Create an OAuth 2.0 Client ID (type: Web application).
# Add your redirect URI: http://localhost:7000/api/email/oauth/google/callback
# (replace host/port for hosted installs).
# 4. Copy the Client ID and Client Secret below.
#
# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
# GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret
#
# Set this explicitly for HTTPS, reverse-proxy, or hosted deployments. The
# value must exactly match an authorized redirect URI in the Google client.
# Local HTTP setups may use the callback URL inferred by the application.
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP)
# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT,
# which is right only when you reach Odysseus directly on that port. Set it for
# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the
# app always listens on 7000 and cannot see the host port map, so the default is
# wrong there whenever APP_PORT is not 7000.
#
# Not for Google MCP servers. Those use Desktop App credentials, and Google only
# accepts loopback redirect URIs for that client type, so a public origin here is
# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install:
# the loopback default is what Google wants, and remote users finish through the
# paste-back page, which never has to load the redirect.
# https://developers.google.com/identity/protocols/oauth2/native-app
# OAUTH_REDIRECT_BASE_URL=https://your-domain.com
# ============================================================
# Misc
# ============================================================
@@ -215,73 +126,6 @@ SEARXNG_INSTANCE=http://localhost:8080
# if you intentionally want scheduled scripts to run remotely.
# ODYSSEUS_SCRIPT_HOST=localhost
# Chat / agent attachment size cap in bytes (default: 10 MB).
# Raise this for local installs that need larger PDFs or text documents.
# Example: 52428800 = 50 MB.
# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760
# Other per-feature upload size caps in bytes. All are validated and optional;
# defaults shown. An invalid value (non-integer or < 1) fails fast at startup.
# ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=104857600 # gallery image upload (100 MB)
# ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=26214400 # gallery transform input (25 MB)
# ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=10485760 # memory import file (10 MB)
# ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=26214400 # personal document upload (25 MB)
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
# ============================================================
# Host Docker access (explicit opt-in)
# ============================================================
# Default Docker Compose does not mount /var/run/docker.sock. Existing
# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it.
#
# Enable this only for intentional Cookbook/local Docker-daemon management.
# Raw socket access is high-trust and can grant broad control over the host
# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID.
# Put these values in .env, or export them before running docker compose.
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
# DOCKER_GID=963
# docker/host-docker.yml sets this inside the container. Keep it paired
# with the socket overlay; setting it alone is not sufficient.
# ODYSSEUS_ENABLE_HOST_DOCKER=true
#
# Host Docker access can be combined with one GPU overlay:
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
# ============================================================
# Host workspace access (explicit opt-in)
# ============================================================
# Docker installs normally see only the container filesystem and /app/data.
# Enable this when the agent should edit a real host workspace like Codex.
# This is high-trust: the mounted tree is writable by the Odysseus container.
# COMPOSE_FILE=docker-compose.yml:docker/host-workspace.yml
# ODYSSEUS_HOST_WORKSPACE_DIR=/home/you
# ODYSSEUS_HOST_WORKSPACE_MOUNT=/host/workspace
#
# Host workspace access can be combined with host Docker access and GPU overlays:
# COMPOSE_FILE=docker-compose.yml:docker/host-workspace.yml:docker/host-docker.yml
# ============================================================
# Host network access (explicit opt-in, Linux Docker)
# ============================================================
# Docker bridge networking hides some host/LAN/VPN behavior from the agent:
# mDNS, some LAN discovery, local VPN/Tailscale state, and host namespace
# assumptions may differ from native Codex. Enable this only for high-trust
# local installs where the Odysseus container should share the host network.
#
# With host networking, Docker port publishing is disabled and the app listens
# directly on APP_PORT. The bundled SearXNG/Chroma services stay in Docker and
# are reached through their host-published loopback ports.
# COMPOSE_FILE=docker-compose.yml:docker/host-workspace.yml:docker/host-network.yml
# APP_BIND=127.0.0.1
# APP_PORT=7000
# ODYSSEUS_HOST_NETWORK_SEARXNG_INSTANCE=http://127.0.0.1:8080
# ODYSSEUS_HOST_NETWORK_CHROMADB_HOST=127.0.0.1
# ODYSSEUS_HOST_NETWORK_CHROMADB_PORT=8100
# ============================================================
# GPU support (Docker Compose)
# ============================================================
@@ -297,18 +141,8 @@ SEARXNG_INSTANCE=http://localhost:8080
#
# AMD ROCm (requires ROCm drivers on the host and the GID of the render group):
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
# Find the render GID with: getent group render | cut -d: -f3
# RENDER_GID=989
# RENDER_GID=992
#
# These overlays only expose the GPU devices. The slim Odysseus image
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
# llama-cpp-python, etc.) before models can actually serve on GPU.
# ============================================================
# Storage Paths (Docker Compose)
# ============================================================
# APP_DATA_DIR=./data
# APP_LOGS_DIR=./logs
# Maximum serialized layered photo-editor draft size (default: 256 MiB).
ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=268435456
-7
View File
@@ -15,13 +15,6 @@ docker/entrypoint.sh text eol=lf
*.cmd text eol=crlf
*.bat text eol=crlf
# Vendored third-party bundles in static/lib/ are published minified artifacts
# and must stay byte-identical to what npm ships — stripping trailing whitespace
# to satisfy `git diff --check` would desync them from the upstream release. Turn
# the whitespace check off for that tree instead, and keep the bundles out of
# GitHub's language statistics.
static/lib/** -whitespace linguist-vendored
# Binary assets — never normalize.
*.png binary
*.jpg binary
-9
View File
@@ -1,9 +0,0 @@
# Code owners.
#
# Intentionally empty for now. The catch-all rule that mapped every path to a
# single owner froze all merges the moment "Require review from Code Owners"
# was enabled, because no other maintainer's approval could satisfy the gate.
# A per-area ownership map (security/auth, CI, frontend, agent internals, with
# multiple named owners per line) is being worked out in issue #593; once
# agreed it replaces this file. Until then, required reviews and the security
# CI gate (website/security-ci.md) remain in force via branch protection.
-126
View File
@@ -1,126 +0,0 @@
name: Bug Report
description: Report a reproducible bug in Odysseus.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
Duplicate reports slow things down.
For security vulnerabilities, **do not open a public issue** —
use [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new)
and read [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md) first.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/odysseus-dev/odysseus/discussions) and did not find an existing report of this bug.
required: true
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md).)
required: true
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
required: true
- type: input
id: revision
attributes:
label: Odysseus Revision
description: |
From the repository root (on the host when using Docker), run
`git show -s --abbrev=12 --format='%h (%cs)' HEAD`
and paste the output exactly.
placeholder: "1fef4929cf1d (2026-08-11)"
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Install Method
options:
- "-- Please Select --"
- Docker (docker compose up)
- Manual Python install (pip / venv)
- Windows native (launch-windows.ps1)
- macOS app (build-macos-app.sh / start-macos.sh)
- Other (describe in the reproduction steps below)
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- "-- Please Select --"
- Linux
- macOS
- Windows
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Exact steps that reliably trigger the bug. The more specific, the faster this gets fixed.
placeholder: |
1. Go to ...
2. Click / type ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behaviour
description: What should have happened?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behaviour
description: What actually happened? Include the full error message if there is one.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: Paste relevant terminal output or attach screenshots. Remove API keys, passwords, and personal data before pasting.
render: text
- type: input
id: model-backend
attributes:
label: Model / Backend (if relevant)
description: "e.g. Ollama + llama3.2:latest, vLLM + mistral-7b, OpenAI API, Anthropic API"
placeholder: "Ollama + llama3.2:latest"
- type: dropdown
id: willing_to_fix
attributes:
label: Are you willing to submit a fix?
options:
- "-- Please Select --"
- "Yes — I can open a PR"
- "Partially — I can help but need guidance"
- "No — I am only filing the report"
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional Information
description: Anything else that might help — browser console errors, related issues, things you already tried, or environment quirks.
-13
View File
@@ -1,13 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Question / Need Help
url: https://github.com/odysseus-dev/odysseus/discussions/categories/q-a
about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only.
- name: Idea or Suggestion
url: https://github.com/odysseus-dev/odysseus/discussions/categories/ideas
about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request.
- name: Security Vulnerability
url: https://github.com/odysseus-dev/odysseus/security/advisories/new
about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting.
@@ -1,92 +0,0 @@
name: Feature Request
description: Propose a new feature or a concrete improvement to Odysseus.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
Feature requests that duplicate [ROADMAP.md](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md)
or an existing open issue will be closed as duplicates.
If your idea needs community input before it becomes a concrete proposal,
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed.
required: true
- label: I searched [discussions](https://github.com/odysseus-dev/odysseus/discussions) and this is not already being debated there.
required: true
- label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request.
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Which part of the application does this affect?
options:
- "-- Please Select --"
- Chat / Agent
- Email
- Calendar
- Documents / RAG
- Memory
- Cookbook / Local Models / GPU
- Search
- Notes / Editor
- Auth / Security
- Docker / Deployment
- UI / Frontend
- API / Backend
- MCP
- Testing / CI
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or Motivation
description: What problem does this solve, or what use case does it enable? Be specific — "it would be better" is not enough.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the behaviour or change you want to see. Include API shape, UI sketch, or code snippets if that helps make it concrete.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: What other approaches did you consider and why did you rule them out? If there is an existing workaround, describe it.
- type: textarea
id: prior-art
attributes:
label: Prior Art / Related Issues
description: Link any related issues, discussions, or external references that informed this proposal.
- type: dropdown
id: willing_to_implement
attributes:
label: Are you willing to implement this?
options:
- "-- Please Select --"
- "Yes — I can open a PR"
- "Partially — I can help but need guidance"
- "No — I am only filing the request"
validations:
required: true
-48
View File
@@ -1,48 +0,0 @@
# Dependabot keeps dependencies and pinned action versions current.
#
# Why this matters for security: every workflow in this repo pins its GitHub
# Actions to an exact commit (a SHA), which is safe but freezes them in time.
# Dependabot opens a small, reviewable pull request whenever a newer version
# exists -- for Python packages, npm packages, the Docker base image, and the
# pinned Actions themselves -- so staying patched does not require manual work.
# Updates are grouped so a week's bumps arrive as one PR per ecosystem, not a
# flood of separate ones.
version: 2
updates:
# Python dependencies (requirements.txt + requirements-optional.txt).
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
python:
patterns: ["*"]
# Frontend / tooling npm packages (package.json).
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
# The pinned action SHAs used across .github/workflows.
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
patterns: ["*"]
# The Docker base image in the Dockerfile.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
-123
View File
@@ -1,123 +0,0 @@
# Pull Request Review Template
Use this shape as a copyable reference for substantive PR reviews; GitHub does
not auto-apply this file to review comments. Omit sections that do not add
useful signal. Lead with confirmed findings; keep speculative notes out of the
public review unless they are framed as a concrete open question.
## Small PR Path
For narrow docs, typo, test-only, or obvious local fixes, a short review is
enough:
```md
LGTM after checking:
- scope:
- validation:
- residual risk:
```
Use the fuller structure below for larger, risky, multi-finding, or
security-sensitive reviews.
## Findings
**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> issue (test): Short issue title**
- **Problem:** Concrete broken flow, contract, input, or risk.
- **Impact:** Why this matters to users, CI, maintainers, data, security, or scale.
- **Ask:** Smallest practical correction or decision the author should make.
- **Location:** `path:line`
## Open Questions
- **question (scope, non-blocking): Short author question** Ask the concrete
intent, scope, or tradeoff question.
## Validation
- Ran:
- Not run:
- Residual risk:
## PR Hygiene
- Target/template/checks:
- Related, duplicate, or superseding context:
## No Findings Variant
```md
## Findings
none confirmed
## Validation
- Ran:
- Not run:
- Residual risk:
```
## Legend
- **Findings:** Verified, author-actionable issues that should be fixed or
consciously accepted before merge.
- **Priority badges:** The shields.io badges below are optional formatting for
priority labels. Plain `P0`, `P1`, `P2`, or `P3` text is also acceptable when
an external image dependency is undesirable or may not render.
- **P0:** `![P0 Badge](https://img.shields.io/badge/P0-red?style=flat)` -
release-blocking or actively dangerous.
- **P1:** `![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)` -
serious bug, security risk, data-loss risk, or broken primary flow.
- **P2:** `![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)` -
meaningful correctness, test, maintainability, or edge-case issue.
- **P3:** `![P3 Badge](https://img.shields.io/badge/P3-lightgrey?style=flat)` -
minor polish or low-risk cleanup.
- **Intent labels:**
- **`issue`:** A confirmed defect, regression, broken contract, or concrete
risk.
- **`suggestion`:** A non-blocking improvement that would make the PR clearer,
safer, or easier to maintain.
- **`nit`:** A tiny, non-blocking cleanup or style note. Use it only when the
author can safely ignore it without changing the review outcome.
- **`question`:** A real author-facing clarification about intent, scope, or
tradeoffs. Do not use questions to hide an issue that should be stated
directly.
- **`LGTM`:** "Looks good to me." Use only when the review found no blocking
issues, or when any remaining notes are clearly optional.
- **Decorations:** Optional labels in parentheses that clarify the finding type,
scope, or merge impact.
- **`security`:** Auth, authorization, ownership, secrets, SSRF, injection,
unsafe external input, or other trust-boundary concerns.
- **`test`:** Missing, failing, misleading, brittle, or insufficient tests.
- **`scope`:** PR scope, feature boundaries, unrelated churn, or work that
should be split into a separate issue or PR.
- **`ci`:** CI configuration, workflow failures, flaky checks, or validation
signal quality.
- **`api`:** Route, request/response, public function, schema, persistence, or
integration contract changes.
- **`docs`:** User-facing docs, contributor docs, examples, or comments that
need to change with the code.
- **`non-blocking`:** Useful feedback that should not prevent merge by
itself.
- **Finding fields:**
- **Problem:** What is wrong, what contract is ambiguous, or what risk the PR
introduces.
- **Impact:** Why the problem matters in practical terms.
- **Ask:** The smallest concrete fix, test, or decision requested from the PR
author.
- **Location:** The most useful repo-relative file and line reference for the
finding, using `path:line`.
- **Optional sections:**
- **Open Questions:** Genuine scope or intent questions; omit when there are
no real questions.
- **Validation:** What the reviewer ran, what was intentionally not run, and
what risk remains after review.
- **PR Hygiene:** Target-branch, template, CI/check, duplicate, related-work,
or superseding-PR notes.
- **`none confirmed`:** Use only when no review-worthy findings were confirmed;
still list validation gaps or residual risk when relevant.
-58
View File
@@ -1,58 +0,0 @@
## Summary
<!-- One paragraph: what changed and why. "Fixed bug" and "Added feature" are not summaries. -->
## Target branch
- [ ] This PR targets **`dev`**, not `main`. All PRs land in `dev`; `main` is curated by the maintainer at each release. If your PR is on `main` by accident, click "Edit" on this PR and change the base.
## Linked Issue
<!-- Every PR should be linked to an issue.
Use one of: Fixes #NNN | Part of #NNN | Closes #NNN -->
Fixes #
## Type of Change
- [ ] Bug fix (non-breaking — fixes a confirmed issue)
- [ ] New feature (non-breaking — adds new behaviour)
- [ ] Breaking change (changes or removes existing behaviour)
- [ ] Refactor / cleanup (behaviour unchanged)
- [ ] Documentation only
- [ ] CI / tooling / configuration
## Checklist
- [ ] I searched [open issues](https://github.com/odysseus-dev/odysseus/issues) and [open PRs](https://github.com/odysseus-dev/odysseus/pulls) — this is not a duplicate.
- [ ] This PR targets `dev`
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
- [ ] I did not run the app/runtime validation and stated that gap in **How to Test**. Leave this unchecked when the app-run box above is checked.
## How to Test
<!-- Step-by-step instructions a reviewer can follow to verify this works.
Do not leave this empty — a PR without test steps will be sent back. -->
1.
2.
3.
## Visual / UI changes — REQUIRED if you touched anything that renders
**Anything that changes what the UI looks like — buttons, icons, padding, colors, fonts, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — needs all of the following. PRs that change rendering without these WILL be closed.**
- [ ] **Screenshot or short clip** of the change in the running app, attached below. Mobile screenshot too if the change affects mobile.
- [ ] **Style match**: the change uses Odysseus's existing visual language. Specifically:
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, etc.) — do not introduce new color values, font sizes, or spacing units.
- Reuse existing button/input/card/border classes. Don't invent parallel styling.
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
- Dark theme is the default; any light-mode work must be wired through the existing theme system, not hard-coded.
- [ ] **No new component patterns.** If a similar widget already exists in the app, extend it instead of writing a parallel one.
- [ ] **I am not an LLM agent submitting a bulk PR.** If you are, please open an issue describing the problem first — bulk auto-generated PRs that don't match the project's visual style are closed on sight, even when the underlying fix is correct.
### Screenshots / clips
<!-- Drag and drop images or a screen recording here. Required for any UI/visual change. -->
-211
View File
@@ -1,211 +0,0 @@
// @ts-check
'use strict';
/** @param {{ github: import('@octokit/rest').Octokit, context: import('@actions/github').context, core: import('@actions/core') }} */
module.exports = async ({ github, context, core }) => {
const issue = context.payload.issue;
const body = (issue.body || '').trim();
const labels = issue.labels.map(l => l.name);
const owner = context.repo.owner;
const repo = context.repo.repo;
const isBug = labels.includes('bug');
const isFeature = labels.includes('enhancement');
// Extract a Section's text, stripping HTML comments. Matches any heading
// depth (#, ##, ###, …) so a manually-written body isn't penalised for
// using a different number of hashes than the issue form generates.
function section(heading) {
const re = new RegExp(`#+\\s+${heading}\\s*([\\s\\S]*?)(?=\\n#+\\s+|$)`, 'i');
const m = body.match(re);
return m ? m[1].replace(/<!--[\s\S]*?-->/g, '').trim() : '';
}
const failures = [];
// ── Common: body must exist ───────────────────────────────────────────────
if (body.length < 50) {
failures.push(
'**Description** — body is empty or too short. ' +
'Please open the issue using one of the provided templates.',
);
}
// An issue is one or the other — never both. Resolve to a single type so the
// validation can't run two conflicting blocks at once.
const type = isBug && isFeature ? 'conflict' : isBug ? 'bug' : isFeature ? 'feature' : 'untyped';
switch (type) {
case 'conflict':
failures.push('**Labels** — an issue cannot be both `bug` and `enhancement`. Remove one label.');
break;
case 'bug': {
const revisionText = section('Odysseus Revision');
if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) {
failures.push(
'**Odysseus Revision** — paste the 12-character commit SHA and date, ' +
'for example `1fef4929cf1d (2026-08-11)`',
);
}
if (!section('Install Method')) {
failures.push('**Install Method** — select how you installed Odysseus');
}
if (!section('Operating System')) {
failures.push('**Operating System** — select your OS');
}
const stepsText = section('Steps to Reproduce');
if (!stepsText || !/\d+\.|[-*]/.test(stepsText)) {
failures.push('**Steps to Reproduce** — must include at least one numbered or bulleted step');
}
if (section('Expected Behaviour').length < 10) {
failures.push('**Expected Behaviour** — section is empty or too short');
}
if (section('Actual Behaviour').length < 10) {
failures.push('**Actual Behaviour** — section is empty or too short');
}
break;
}
case 'feature':
if (!section('Area')) {
failures.push('**Area** — select which part of the application this affects');
}
if (section('Problem or Motivation').length < 20) {
failures.push(
'**Problem or Motivation** — section is empty or too short ' +
'(explain the concrete problem this solves)',
);
}
if (section('Proposed Solution').length < 20) {
failures.push(
'**Proposed Solution** — section is empty or too short ' +
'(describe the change you want to see)',
);
}
if (!section('Are you willing to implement this\\?')) {
failures.push('**Are you willing to implement this?** — select an option');
}
break;
// 'untyped' → only the common body-length check applies.
}
// ── Unfilled dropdowns ────────────────────────────────────────────────────
// #2068 added a "-- Please Select --" default to every template dropdown, so
// a contributor who never opens the dropdown submits with that literal string
// as the section value. The per-section checks above only verify presence, so
// a placeholder value passes. Scan every section and flag the ones still
// showing the placeholder, as a single comma-separated line item.
const PLACEHOLDER = '-- Please Select --';
const headingRe = /^#+\s+(.+?)\s*$/gm;
const headings = [];
let headingMatch;
while ((headingMatch = headingRe.exec(body)) !== null) {
headings.push({
name: headingMatch[1].trim(),
headStart: headingMatch.index,
contentStart: headingMatch.index + headingMatch[0].length,
});
}
const unfilled = [];
for (let i = 0; i < headings.length; i++) {
const end = i + 1 < headings.length ? headings[i + 1].headStart : body.length;
if (body.slice(headings[i].contentStart, end).includes(PLACEHOLDER)) {
unfilled.push(headings[i].name);
}
}
if (unfilled.length > 0) {
failures.push(
`**Unfilled dropdowns** — please choose a value; these sections still show ` +
`the \`${PLACEHOLDER}\` placeholder: ${unfilled.join(', ')}.`,
);
}
// ── Labels ────────────────────────────────────────────────────────────────
// These labels are expected to already exist in the repo — managing the
// repo's label set is the maintainer's job, not this workflow's. We check a
// label exists before applying it (issues.addLabels would otherwise silently
// create a missing label) and fail soft — warn and skip — if it's absent.
async function labelExists(name) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
return true;
} catch (e) {
if (e.status === 404) return false;
throw e;
}
}
async function addLabel(name) {
if (await labelExists(name)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [name] });
} else {
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
}
async function dropLabel(name) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name });
} catch (e) {
if (e.status !== 404 && e.status !== 410) throw e;
}
}
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
// Closed issues are no longer awaiting review.
// This also prevents later edits to closed issues from restoring the label.
if (issue.state === 'closed') {
await dropLabel(LABEL_GOOD);
return;
}
// ── Find existing bot comment to update in-place ──────────────────────────
const MARKER = '<!-- issue-description-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: issue.number,
});
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
if (failures.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
await dropLabel(LABEL_BAD);
await addLabel(LABEL_GOOD);
} else {
const list = failures.map(f => `- ${f}`).join('\n');
const commentBody = [
MARKER,
'⚠️ **Issue description is incomplete.** Please update the following sections:',
'',
list,
'',
'_This comment is deleted automatically once all sections are complete._',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: commentBody });
}
await dropLabel(LABEL_GOOD);
await addLabel(LABEL_BAD);
core.setFailed(`Issue description has ${failures.length} issue(s) — see bot comment for details.`);
}
};
-240
View File
@@ -1,240 +0,0 @@
// @ts-check
'use strict';
/** @param {{ github: import('@octokit/rest').Octokit, context: import('@actions/github').context, core: import('@actions/core') }} */
module.exports = async ({ github, context, core }) => {
const body = context.payload.pull_request.body || '';
const prNum = context.payload.pull_request.number;
const MARKER = '<!-- pr-description-check-bot -->';
const owner = context.repo.owner;
const repo = context.repo.repo;
// Strip HTML comments so placeholder text does not count as content.
function strip(text) {
return (text ?? '').replace(/<!--[\s\S]*?-->/g, '').trim();
}
// Extract the text content of a Section. Matches any heading depth (#, ##,
// ###, …) so the check doesn't break if the template's heading level changes.
function section(heading) {
const m = body.match(new RegExp(`#+\\s+${heading}[\\s\\S]*?(?=\\n#+\\s+|$)`, 'i'));
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
}
const descriptionProblems = [];
// 1. Summary must be filled in.
if (section('Summary').length < 20) {
descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.');
}
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
// keyword + #NNN, or a full issue URL (e.g. .../issues/123) — the strict
// keyword-prefixed form previously false-flagged correctly-linked PRs.
const linkedSection = section('Linked Issue');
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
if (!linkedSection || !hasIssueRef) {
descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
}
// 3. At least one Type of Change box must be checked.
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
if (!/- \[x\]/i.test(typeBlock)) {
descriptionProblems.push('**Type of Change** — check at least one box.');
}
// 4. Duplicate-search checklist item must be checked.
if (!/- \[x\] I searched/i.test(body)) {
descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
}
// 5. How to Test must contain enough real detail for a reviewer to act on.
// Any format is fine — numbered steps, prose, the commands you ran, or a
// code block — so we only require non-trivial content, not a specific shape.
const howTo = section('How to Test');
if (howTo.length < 30) {
descriptionProblems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
}
// Classify paths from GitHub's API. This workflow runs in the privileged base
// context, so it must never check out or execute code from the PR branch.
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNum, per_page: 100,
});
const changedPaths = changedFiles.map(file => file.filename);
function isUiSensitivePath(filename) {
const path = filename.toLowerCase();
return path.startsWith('static/')
|| path.startsWith('templates/')
|| /\.(?:html?|css|svg)$/.test(path);
}
function isDocsOnlyPath(filename) {
const path = filename.toLowerCase();
return /\.(?:md|mdx|rst|adoc|txt)$/.test(path)
|| (path.startsWith('docs/') && !isUiSensitivePath(path));
}
function isRuntimeSensitivePath(filename) {
const path = filename.toLowerCase();
if (isUiSensitivePath(path)) return false;
if (path.startsWith('tests/') || path.startsWith('.github/')) return false;
return /^(?:app\.py|routes\/|services\/|src\/|core\/|mcp_servers\/|scripts\/|docker\/)/.test(path)
|| /^(?:dockerfile|docker-compose.*\.ya?ml|requirements(?:-optional)?\.txt|pyproject\.toml|setup\.py)$/.test(path)
|| /\.(?:py|sh|ps1|bat)$/.test(path);
}
let classification = 'tooling';
if (changedPaths.some(isUiSensitivePath)) {
classification = 'UI-sensitive';
} else if (changedPaths.some(isRuntimeSensitivePath)) {
classification = 'backend/runtime';
} else if (changedPaths.length > 0 && changedPaths.every(isDocsOnlyPath)) {
classification = 'docs-only';
}
const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body);
const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body);
// Anchor on the wording, not the template's emphasis: a ticked box the author
// retyped without the surrounding ** renders identically on the PR page, so
// treating it as unchecked is invisible from their side. Matches the two
// attestations above, which already ignore formatting.
const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body);
const screenshotSection = section('Screenshots / clips');
const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection);
const evidenceGaps = [];
let needsRuntimeValidation = false;
let needsVisualEvidence = false;
if (classification === 'backend/runtime' || classification === 'UI-sensitive') {
if (appRan && appNotRun) {
needsRuntimeValidation = true;
evidenceGaps.push('The app-run and explicit not-run boxes are both checked. Select the one state that is true.');
} else if (!appRan) {
needsRuntimeValidation = true;
if (appNotRun) {
evidenceGaps.push('The author explicitly reports that app/runtime validation was not performed.');
} else {
evidenceGaps.push('App/runtime validation is not author-attested. Check the run box only after running it, or check the explicit not-run box and describe the gap.');
}
}
}
if (classification === 'UI-sensitive') {
if (!screenshotChecked) {
needsVisualEvidence = true;
evidenceGaps.push('The screenshot/clip checkbox is not checked for this UI-sensitive change.');
}
if (!hasVisualEvidence) {
needsVisualEvidence = true;
evidenceGaps.push('The Screenshots / clips section does not contain an actual attachment or link.');
}
}
// ── Comment ──────────────────────────────────────────────────────────────
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNum, per_page: 100,
});
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
} else {
const commentLines = [MARKER];
if (descriptionProblems.length > 0) {
commentLines.push(
'⚠️ **PR description — action needed**',
'',
'The following required sections are missing or incomplete. Please update the PR description to address them:',
'',
descriptionProblems.map(problem => `- ${problem}`).join('\n'),
);
} else {
commentLines.push(
'⚠️ **PR description is complete; validation evidence is still outstanding**',
'',
`Changed-file classification: **${classification}**.`,
);
}
if (evidenceGaps.length > 0) {
commentLines.push(
'',
'**Author-reported runtime / visual state**',
'',
evidenceGaps.map(gap => `- ${gap}`).join('\n'),
'',
'Checkboxes are author attestations. GitHub Actions results remain the execution evidence for CI; this check does not prove that a local command ran.',
);
}
commentLines.push(
'',
'---',
'_This comment updates automatically when the description or changed files change._',
);
const commentBody = commentLines.join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNum, body: commentBody });
}
}
// ── Labels ────────────────────────────────────────────────────────────────
// These labels are expected to already exist in the repo — managing the
// repo's label set is the maintainer's job, not this workflow's. We check a
// label exists before applying it (issues.addLabels would otherwise silently
// create a missing label) and fail soft — warn and skip — if it's absent.
async function labelExists(name) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
return true;
} catch (e) {
if (e.status === 404) return false;
if (e.status === 403) {
core.warning(`Could not inspect label "${name}" — token lacks label read access; skipping.`);
return false;
}
throw e;
}
}
async function setLabel(name, wanted) {
if (wanted && await labelExists(name)) {
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] });
} catch (e) {
// Fail soft on a token that can't write labels so a label permission
// problem never masks the actual description verdict.
if (e.status !== 403 && e.status !== 404) throw e;
core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`);
}
} else if (wanted) {
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
} else {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNum, name });
} catch (e) {
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
}
}
}
const descriptionComplete = descriptionProblems.length === 0;
const evidenceComplete = evidenceGaps.length === 0;
const isDraft = Boolean(context.payload.pull_request.draft);
await setLabel(
'ready for review',
descriptionComplete && evidenceComplete && !isDraft,
);
await setLabel('needs work', !descriptionComplete);
await setLabel('needs runtime validation', needsRuntimeValidation);
await setLabel('needs visual evidence', needsVisualEvidence);
if (!descriptionComplete) {
core.setFailed(`PR description has ${descriptionProblems.length} issue(s) — see bot comment for details.`);
}
};
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""Report focused pytest guidance for changed paths under tests/."""
from __future__ import annotations
import argparse
import os
import shlex
import subprocess
import sys
from collections.abc import Iterable
from pathlib import PurePosixPath
def parse_paths(raw_paths: bytes) -> list[str]:
"""Decode the NUL-delimited output of ``git diff --name-only -z``."""
return [os.fsdecode(path) for path in raw_paths.split(b"\0") if path]
def changed_paths_from_merge_base(base_sha: str, head_sha: str) -> list[str]:
"""Return changed ``tests/`` paths using GitHub PR three-dot semantics.
GitHub PR changed files are based on the merge base and the PR head, not a
direct endpoint diff between the current base branch tip and the PR head.
Using the direct endpoint diff can include files changed only on the base
branch when the PR branch is stale.
"""
merge_base = subprocess.check_output(
["git", "merge-base", base_sha, head_sha],
stderr=subprocess.DEVNULL,
).strip()
raw_paths = subprocess.check_output(
[
"git",
"diff",
"--name-only",
"--diff-filter=ACMRT",
"-z",
os.fsdecode(merge_base),
head_sha,
"--",
"tests/",
],
)
return parse_paths(raw_paths)
def select_test_paths(paths: Iterable[str]) -> list[str]:
"""Return unique, repository-relative paths contained by tests/."""
selected: set[str] = set()
for raw_path in paths:
path = PurePosixPath(raw_path)
if path.is_absolute() or ".." in path.parts:
continue
parts = tuple(part for part in path.parts if part != ".")
if len(parts) >= 2 and parts[0] == "tests":
selected.add(PurePosixPath(*parts).as_posix())
return sorted(selected)
def is_pytest_file(path: str) -> bool:
"""Return whether a changed path follows this repository's pytest naming."""
name = PurePosixPath(path).name
return name.endswith(".py") and (
name.startswith("test_") or name.endswith("_test.py")
)
def pytest_command(paths: Iterable[str]) -> str:
"""Build a copyable pytest command for changed runnable test files."""
command = ["python3", "-m", "pytest", "-q", *paths]
return shlex.join(command)
def format_report(paths: Iterable[str]) -> str:
"""Format focused guidance for CI logs and the workflow summary."""
changed_paths = select_test_paths(paths)
runnable_paths = [path for path in changed_paths if is_pytest_file(path)]
lines = ["## Focused test guidance (report-only)", ""]
if not changed_paths:
lines.append("No changed paths under `tests/`.")
else:
lines.extend(["Changed paths under `tests/`:", ""])
lines.extend(f"- `{path}`" for path in changed_paths)
lines.extend(["", "Suggested focused validation:", ""])
if runnable_paths:
lines.append(f"```sh\n{pytest_command(runnable_paths)}\n```")
else:
lines.append("No directly runnable pytest files changed.")
lines.extend(
[
"",
"This guidance does not infer tests from source changes. "
"Existing blocking CI remains the source of truth.",
]
)
return "\n".join(lines)
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Report focused pytest guidance for changed tests/ paths.",
)
parser.add_argument("--base-sha", help="Pull request base commit SHA.")
parser.add_argument("--head-sha", help="Pull request head commit SHA.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(sys.argv[1:] if argv is None else argv)
if bool(args.base_sha) != bool(args.head_sha):
raise SystemExit("--base-sha and --head-sha must be provided together")
if args.base_sha and args.head_sha:
paths = changed_paths_from_merge_base(args.base_sha, args.head_sha)
else:
paths = parse_paths(sys.stdin.buffer.read())
print(format_report(paths))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-146
View File
@@ -1,146 +0,0 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
# Least privilege: none of the jobs write to the repo.
permissions:
contents: read
# Cancel superseded runs on the same ref to save Actions minutes.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
focused-test-guidance:
name: Focused test guidance (report-only)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Report changed test paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
report_file="$RUNNER_TEMP/focused-test-guidance.md"
publish_report() {
cat "$report_file"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat "$report_file" >> "$GITHUB_STEP_SUMMARY" || true
fi
return 0
}
report_unavailable() {
{
printf '%s\n\n' '## Focused test guidance unavailable (report-only)'
printf '%s\n\n' "$1"
printf '%s\n' 'Existing blocking CI remains the source of truth.'
} > "$report_file"
publish_report
exit 0
}
if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then
report_unavailable "Pull request base/head metadata is missing."
fi
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
report_unavailable "The pull request base commit is unavailable locally."
fi
if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
report_unavailable "The pull request head commit is unavailable locally."
fi
if ! python3 .github/scripts/focused_test_guidance.py \
--base-sha "$BASE_SHA" \
--head-sha "$HEAD_SHA" > "$report_file"; then
report_unavailable "The focused test guidance helper could not produce a report."
fi
publish_report
python-syntax:
name: Python syntax (compileall)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps.
- run: python -m compileall -q app.py core routes src services scripts tests
node-syntax:
name: JS syntax (node --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib).
- name: node --check
run: |
shopt -s globstar nullglob
for f in static/app.js static/js/**/*.js; do
node --check "$f"
done
python-tests:
name: Python tests (pytest)
runs-on: ubuntu-latest
# Make Python test validation authoritative for the configured scope.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
# Detect whether this PR only touches repository prose outside the Pages site.
# If so, skip the expensive pytest run while still reporting a passing check.
- name: Check for docs-only changes
id: docs-check
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
else
BASE="${{ github.event.before }}"
HEAD="${{ github.sha }}"
fi
# Keep website/ and assets/branding/ out of this bypass: pytest owns
# regression guards for their published-file and orphan-asset contracts.
changed=$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || git diff --name-only HEAD~1 HEAD)
non_docs=$(echo "$changed" | grep -Ev '^(docs/|[^/]+\.md$|\.github/[^/]+\.md$)' || true)
if [ -z "$non_docs" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping pytest."
else
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt
if: steps.docs-check.outputs.docs_only != 'true'
- run: mkdir -p data # sqlite DB lives at ./data/app.db
if: steps.docs-check.outputs.docs_only != 'true'
- run: python -m pytest -q
if: steps.docs-check.outputs.docs_only != 'true'
-41
View File
@@ -1,41 +0,0 @@
name: CodeQL
# Advanced setup so CodeQL also runs on pull requests (including from forks),
# surfacing findings before merge instead of only after a change lands on dev.
on:
push:
branches: [dev, main]
pull_request:
branches: [dev]
schedule:
- cron: "17 3 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [actions, javascript-typescript, python]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
-52
View File
@@ -1,52 +0,0 @@
# Container security: Dockerfile lint
#
# Purpose: the Docker image is how most people run Odysseus, so it is part of
# the attack surface. hadolint lints the Dockerfile for mistakes and insecure
# patterns (running as root longer than needed, unpinned base image, bad apt
# usage). Blocking.
#
# The image vulnerability scan (Trivy, advisory) lives in its own file,
# container-trivy.yml. Keeping it separate lets that advisory scan be
# path-filtered and held to a read-only token on pull requests without
# weakening this blocking gate, which must always report so a required check
# never hangs.
#
# Note: a separate open PR (#120) proposes a local `scripts/scan_image.py`.
# This job is complementary -- it is a CI gate, not a script a contributor has
# to remember to run.
name: Container scan
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions: {}
concurrency:
group: container-scan-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
hadolint:
name: hadolint (Dockerfile lint)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with:
dockerfile: Dockerfile
# DL3008: pinning apt package versions is impractical on a -slim base
# image. Debian purges old package versions from its repos, so a
# pinned version breaks future rebuilds. The base image itself is
# what should be pinned (tracked by Dependabot's docker ecosystem).
ignore: DL3008
-129
View File
@@ -1,129 +0,0 @@
# Container image vulnerability scan (advisory)
#
# Trivy builds the application image and scans it for known-vulnerable OS and
# Python packages. Advisory only -- it reports findings to the repo's Security
# tab without blocking a merge, because the image inevitably contains
# already-known CVEs in upstream packages that are not this project's bug.
#
# Split from the Dockerfile lint (container-scan.yml) for two reasons:
#
# - Least privilege. The image build runs Dockerfile instructions, which on a
# pull request are attacker-influenceable. That path (the `scan` job) is
# held to a read-only token and never publishes results. Only `publish`,
# which runs on push to main (curated, fast-forwarded from reviewed dev),
# gets security-events:write to upload SARIF.
# - Cost. Docs-only changes do not rebuild the image (paths-ignore below),
# matching docker-publish.yml. hadolint stays on the broad trigger in
# container-scan.yml so the blocking gate always reports.
name: Container scan (Trivy)
on:
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
workflow_dispatch:
permissions: {}
concurrency:
group: container-trivy-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Pull requests and manual runs: build and scan under a read-only token.
# The build executes PR-supplied Dockerfile instructions, so this job must
# not hold any write scope, and it does not upload to the Security tab.
scan:
name: Trivy (image scan, advisory)
if: github.event_name != 'push'
runs-on: ubuntu-latest
# Advisory: a CVE in an upstream package must not block a PR.
continue-on-error: true
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned.
- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: false
load: true
tags: odysseus:ci
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: odysseus:ci
format: table
ignore-unfixed: true
env:
# Pin the vuln DB source to GHCR to avoid rate-limited Docker Hub
# mirrors that flake on shared runners.
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
# Push to main only: build, scan, and publish SARIF to the Security tab.
# This is the only path that runs trusted code, so it is the only one granted
# security-events:write.
publish:
name: Trivy (image scan + SARIF upload)
if: github.event_name == 'push'
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
security-events: write # upload SARIF to the Security tab
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: false
load: true
tags: odysseus:ci
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: odysseus:ci
format: sarif
output: trivy-results.sarif
ignore-unfixed: true
env:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-results.sarif
category: trivy-image
-71
View File
@@ -1,71 +0,0 @@
# Supply-chain review
#
# Purpose: defend against "side-chain" / supply-chain attacks -- a pull request
# that adds (or bumps) a dependency to a version with a known vulnerability or a
# disallowed license. Two layers:
#
# - dependency-review: runs ONLY on pull requests. It compares the
# dependencies before and after the PR and blocks the merge if the change
# pulls in a package with a known security advisory. This is the gate.
# - pip-audit: scans the project's current Python requirements against the
# advisory database. Advisory only (it never blocks a merge), because it can
# flag a pre-existing issue in an already-shipped dependency.
name: Dependency review
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
# Default-deny token; jobs grant only read access.
permissions: {}
concurrency:
group: dependency-review-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dependency-review:
name: dependency-review (PR gate)
# Only meaningful on a pull request -- it needs a base..head diff to review.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Review dependency changes
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
# Fail the PR on any newly introduced moderate-or-worse advisory.
fail-on-severity: moderate
pip-audit:
name: pip-audit (advisory)
runs-on: ubuntu-latest
# Advisory: report known-vulnerable Python deps without blocking the merge.
continue-on-error: true
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Run pip-audit on requirements
run: |
set -euo pipefail
pip install pip-audit==2.10.0
pip-audit -r requirements.txt -r requirements-optional.txt --strict
-50
View File
@@ -1,50 +0,0 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/deploy-pages.yml'
workflow_dispatch:
permissions: {}
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Package static site
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
with:
source: website
destination: _site
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
deploy:
name: Deploy static site
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
-142
View File
@@ -1,142 +0,0 @@
name: ci / docker publish
# Build the Odysseus image and publish to GHCR.
# push to main -> :latest, :X.Y.Z (curated release; main is fast-forwarded at releases)
# push to dev -> :dev, :X.Y.Z-dev.<sha> (rolling dev + an immutable, traceable pin)
# Multi-arch (linux/amd64 + linux/arm64): each arch builds on its own native
# runner and pushes by digest, then a merge job stitches the digests into one
# manifest list and applies the tags (faster + cleaner than QEMU emulation).
# Registry: ghcr.io/<owner>/<repo>.
on:
push:
branches: [dev, main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: build (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: merge manifest + tag
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Read APP_VERSION + short sha
id: ver
run: |
v=$(grep -E '^APP_VERSION' src/constants.py | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
[ -n "$v" ] || { echo "APP_VERSION not found"; exit 1; }
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=${{ steps.ver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=raw,value=${{ steps.ver.outputs.version }}-dev.${{ steps.ver.outputs.short }},enable=${{ github.ref == 'refs/heads/dev' }}
- name: Create manifest list + push tags
working-directory: /tmp/digests
run: |
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
digests=$(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *)
# word-splitting is intended: $tags and $digests each expand to multiple args
# shellcheck disable=SC2086
docker buildx imagetools create $tags $digests
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
- name: Inspect
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then ref=latest; else ref=dev; fi
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${ref}"
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
@@ -1,24 +0,0 @@
name: ci / issue description check
on:
issues:
types: [opened, edited, reopened, closed]
permissions:
issues: write
jobs:
check:
name: Check issue description
runs-on: ubuntu-latest
# Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
-115
View File
@@ -1,115 +0,0 @@
name: ci / PR checks
on:
# pull_request_target runs in the base-repo context (has secrets) so the check
# works on fork PRs. Safe here: the checkout pins to the base branch (no fork
# code runs) and the scripts only read context.payload and call the GitHub API.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, synchronize, reopened, ready_for_review, converted_to_draft]
concurrency:
group: pr-description-${{ github.event.pull_request.number }}
cancel-in-progress: true
# Default-deny at the workflow level; each job opts into only the scopes it needs.
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
# REST path is under /issues/{n}/...; issues:write alone returns 403 on PRs.
permissions: {}
jobs:
check-description:
name: Check PR description
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
# Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: return require('./.github/scripts/check-pr-description.js')({github, context, core})
check-title:
name: Check PR title (Conventional Commits)
runs-on: ubuntu-latest
permissions: {}
# Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = context.payload.pull_request.title || "";
// Conventional Commits: type(optional-scope)(optional !): summary
const re = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w .\/-]+\))?!?: .+/;
if (!re.test(title)) {
core.setFailed(
`PR title is not in Conventional Commits format:\n "${title}"\n\n` +
`Expected: type(scope): summary\n` +
`Example: fix(search): handle empty query\n` +
`Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.`
);
} else {
core.info(`PR title OK: ${title}`);
}
check-mergeable:
name: Flag unmergeable PRs
needs: check-description
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
# Run after description validation failures, but never from an obsolete
# workflow run canceled by a newer PR event.
if: ${{ !cancelled() && github.event.pull_request.user.type != 'Bot' }}
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const repo = { owner: context.repo.owner, repo: context.repo.repo };
const number = context.payload.pull_request.number;
const READY = "ready for review";
const CONFLICT = "merge conflict";
// Ensure the conflict label exists (red). Ignore if already present.
try {
await github.rest.issues.getLabel({ ...repo, name: CONFLICT });
} catch {
await github.rest.issues.createLabel({
...repo, name: CONFLICT, color: "B60205",
description: "Conflicts with the base branch; needs a rebase before review.",
}).catch(() => {});
}
// mergeable is computed asynchronously and is often null right after
// an event, so poll a few times until GitHub has resolved it.
let pr = null;
for (let i = 0; i < 5; i++) {
const { data } = await github.rest.pulls.get({ ...repo, pull_number: number });
if (data.mergeable !== null) { pr = data; break; }
await new Promise(r => setTimeout(r, 3000));
}
if (!pr || pr.draft) return;
const labels = pr.labels.map(l => l.name);
if (pr.mergeable === false) {
if (labels.includes(READY)) {
await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: READY }).catch(() => {});
}
if (!labels.includes(CONFLICT)) {
await github.rest.issues.addLabels({ ...repo, issue_number: number, labels: [CONFLICT] });
}
} else if (pr.mergeable === true) {
if (labels.includes(CONFLICT)) {
await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: CONFLICT }).catch(() => {});
}
}
-60
View File
@@ -1,60 +0,0 @@
# Secret scanning
#
# Purpose: stop credentials (API keys, tokens, passwords, private keys) from
# ever living in the Git history. Odysseus deliberately keeps real secrets in
# files that are gitignored (.env, data/), but a slip in a future commit -- or a
# malicious pull request that sneaks one in -- would otherwise go unnoticed.
# This job reads the repository and the full commit history and fails if it
# finds anything that looks like a secret.
#
# It runs the official gitleaks BINARY directly (pinned to an exact version and
# verified against the project's published SHA-256 checksum) rather than the
# gitleaks GitHub Action, because the Action asks for a paid license on
# organization-owned repos. The binary is free and behaves identically.
name: Secret scan
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
# Start with zero permissions; the single job opts back in to read-only.
permissions: {}
concurrency:
group: secret-scan-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
name: gitleaks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git.
fetch-depth: 0
persist-credentials: false
# Pinned version + checksum so a tampered release binary cannot run here.
# Bump VERSION/SHA256 together; the checksum comes from the matching
# gitleaks_<version>_checksums.txt on the GitHub release.
- name: Run gitleaks (pinned, checksum-verified)
env:
GITLEAKS_VERSION: 8.30.1
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
run: |
set -euo pipefail
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -fsSL -o "${TARBALL}" \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}"
echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c -
tar -xzf "${TARBALL}" gitleaks
# Scan the whole history. Findings print to the log and fail the job.
./gitleaks git --no-banner --redact --verbose .
-80
View File
@@ -1,80 +0,0 @@
# Workflow security (CI that audits the CI)
#
# Purpose: the GitHub Actions workflows themselves are an attack surface. A
# poorly written workflow can leak the repository token, run attacker-supplied
# code from a pull request, or pull in a tampered third-party action. These two
# tools check every workflow file in this repo for those mistakes:
#
# - actionlint: catches workflow syntax errors and shell-script bugs inside
# `run:` steps before they reach main.
# - zizmor: a security linter for Actions. Flags template-injection holes,
# unpinned actions, credential persistence, and over-broad token
# permissions -- exactly the patterns the rest of this CI is built to avoid.
#
# Add this early: it then audits every workflow added after it.
name: Workflow security
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
# Default-deny token; each job grants only read access to the code.
permissions: {}
concurrency:
group: workflow-security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Pinned version + checksum so a tampered binary cannot run here.
- name: Run actionlint (pinned, checksum-verified)
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
run: |
set -euo pipefail
TARBALL="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl -fsSL -o "${TARBALL}" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TARBALL}"
echo "${ACTIONLINT_SHA256} ${TARBALL}" | sha256sum -c -
tar -xzf "${TARBALL}" actionlint
./actionlint -color
zizmor:
name: zizmor (Actions SAST)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
# Pinned zizmor release. --offline keeps the audit hermetic (no network
# calls about the actions it inspects); --min-severity=low surfaces
# everything so nothing slips through under the gate.
- name: Run zizmor
run: |
set -euo pipefail
pip install zizmor==1.25.2
zizmor --offline --min-severity=low .github/workflows/
-37
View File
@@ -12,17 +12,7 @@ venv/
# Environment
.env
.env.bak.*
!.env.example
# Local uv lockfile (optional, per-platform — see "Faster installs with uv" in README)
requirements.lock
# SOPS workflow — encrypted `secrets.env` is intentionally committable,
# but every variant (plaintext, manual decrypt copy, editor backup)
# must stay out of git. Mirrored in .dockerignore so the same artifacts
# also cannot enter image build layers.
secrets.env.*
!secrets.env.example
# Data — all user data stays local
data/
@@ -70,38 +60,12 @@ output.txt.txt
*.tiff
*.pdf
# …except shipped static assets
!static/icons/*.png
# …except shipped demo assets in docs/ that the README links to.
!docs/*.jpg
!docs/*.jpeg
!docs/*.png
!docs/*.gif
!docs/*.webp
# …and curated docs/ subfolder assets (e.g. accessibility before/after shots).
!docs/**/*.png
!docs/**/*.jpg
!docs/**/*.gif
!docs/**/*.webp
# …except shipped website and branding media.
!website/**/*.jpg
!website/**/*.jpeg
!website/**/*.png
!website/**/*.gif
!website/**/*.bmp
!website/**/*.webp
!website/**/*.tiff
!website/**/*.pdf
!assets/branding/**/*.jpg
!assets/branding/**/*.jpeg
!assets/branding/**/*.png
!assets/branding/**/*.gif
!assets/branding/**/*.bmp
!assets/branding/**/*.webp
!assets/branding/**/*.tiff
!assets/branding/**/*.pdf
# Reports and temp files
reports/
@@ -119,4 +83,3 @@ docs/windows-port/
compound.config.json
*.error.log
_scratch/
/odysseus/
+5 -18
View File
@@ -33,8 +33,8 @@ The full license texts are kept in [`licenses/`](licenses/).
- **[Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)** by
**Alibaba-NLP / Tongyi Lab** — the multi-step deep-research agent pipeline.
Copyright © Alibaba-NLP / Tongyi Lab. **Apache-2.0.** Adapted for Odysseus's
Deep Research feature (`services/research/`, `src/research_handler.py`,
`routes/research_routes.py`, `services/search/`). Full text in
Deep Research feature (`api/research_*.py`, `routes/research_routes.py`,
`services/search/`). Full text in
[`licenses/DeepResearch-Apache-2.0.txt`](licenses/DeepResearch-Apache-2.0.txt).
---
@@ -47,7 +47,7 @@ just composed.
| Service | Image | Purpose | License |
|---|---|---|---|
| [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:2026.5.31-7159b8aed` (pinned tag; see compose) | Default metasearch backend | AGPL-3.0 |
| [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:latest` | Default metasearch backend | AGPL-3.0 |
| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:latest` | Vector store for memory / RAG | Apache-2.0 |
| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy` | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 |
@@ -65,16 +65,6 @@ Vendored in `static/lib/` and served directly:
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
from `index.html`, so a session that renders no math and no diagram never fetches
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
browser that supports `woff2`. The bundles are the published npm artifacts,
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
so they can stay byte-identical to upstream.
## Front-end libraries loaded at runtime (CDN)
@@ -82,6 +72,8 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
| Library | Purpose | License |
|---|---|---|
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
@@ -94,7 +86,6 @@ Bundled in `static/fonts/`:
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
## Python dependencies
@@ -127,7 +118,6 @@ Core (`requirements.txt`) and optional (`requirements-optional.txt`):
| croniter | MIT |
| pytest / pytest-asyncio | MIT / Apache-2.0 |
| duckduckgo-search (optional) | MIT |
| markitdown (optional — Office/EPUB text extraction) | MIT |
| **PyMuPDF** *(optional — form-filling only)* | **AGPL-3.0** — see note below |
## Companion services (interoperated with, not bundled)
@@ -162,9 +152,6 @@ concerns from earlier are resolved:
deployment (Artifex also sells a commercial PyMuPDF license that lifts this).
- **`caldav`** (Python lib) is **dual-licensed GPL-3.0-or-later OR Apache-2.0**.
Odysseus uses it under **Apache-2.0**, which is permissive and MIT-compatible.
- **`markitdown`** (Microsoft) is **MIT** and used only as an *optional* dependency for Office/EPUB text
extraction (`src/markitdown_runtime.py`), lazy-imported with graceful fallback — the MIT core runs without
it. The cloud `az-doc-intel` extra is deliberately **not** installed, keeping extraction fully local.
---
+3 -46
View File
@@ -2,17 +2,6 @@
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
## Branch model
Odysseus has two branches:
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
- **`main`** — what users run. Curated and tested by the maintainer. Fast-forwarded to a stable `dev` commit at each release.
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
## Before You Start
- Search existing issues and pull requests before opening a new one.
@@ -25,7 +14,7 @@ End-users cloning the repo will land on `dev` by default. To run the curated/sta
Docker is the recommended path for normal testing:
```bash
git clone https://github.com/odysseus-dev/odysseus.git
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
cp .env.example .env
docker compose up -d --build
@@ -37,7 +26,7 @@ Manual development uses Python 3.11+:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python -m uvicorn app:app --host 127.0.0.1 --port 7000
python -m uvicorn app:app --host 0.0.0.0 --port 7000
```
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
@@ -68,44 +57,12 @@ Good pull requests usually include:
- A short explanation of the bug or feature.
- The files or areas changed.
- Manual test steps or automated test results from running the actual app, not just the test suite.
- Manual test steps or automated test results.
- Screenshots or short recordings for UI changes.
- Links to related issues, for example `Fixes #123`.
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
> **Auto-generated PRs.** If you are running an LLM agent (Devin, Cursor, OpenHands, Claude Code, etc.) against this repo: please open an issue describing the problem first instead of opening a PR directly. Bulk agent-generated PRs that don't match the project's visual style or contribution format will be closed without review, even when the underlying fix is correct.
## Style and visual changes
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
Before submitting any change that affects what the app looks like — buttons, icons, fonts, colors, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — please:
1. **Run the app locally** and view the change in a browser. Type-checks and unit tests are not enough.
2. **Attach a screenshot or short clip** of the change in the running app. Add a mobile screenshot too if the change affects mobile.
3. **Match the existing visual language.** Specifically:
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, …). Do not introduce new color values, font sizes, or spacing units.
- Reuse existing button, input, card, and border classes. Don't invent parallel styling for similar widgets.
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
- Dark theme is the default; any light-mode work goes through the existing theme system, not hard-coded.
4. **Don't add parallel components.** If a similar widget already exists in the app, extend it instead of writing a new one.
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
## Code conventions
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
## Issue Reports
For bugs, include:
+5 -87
View File
@@ -1,27 +1,11 @@
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14-slim AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14-slim
FROM python:3.12-slim
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
# downloads, and serves from Docker installs.
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
# launch inside Docker.
# nodejs/npm provide npx for the built-in Browser MCP server.
# chromium provides the actual browser binary used by that MCP server.
# fontconfig + Noto CJK provide real fallback glyphs for multilingual pages;
# Chromium otherwise renders Chinese/Japanese/Korean labels as empty boxes.
# iproute2/iputils-ping/net-tools/dnsutils/nmap give Docker-hosted agents the
# basic network inspection toolkit expected by local LAN/debugging tasks.
# nodejs/npm provide npx for the optional built-in Browser MCP server.
# gosu lets the entrypoint drop privileges cleanly so signals still reach
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -31,82 +15,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
nodejs \
npm \
chromium \
fontconfig \
fonts-noto-cjk \
tmux \
openssh-client \
iproute2 \
iputils-ping \
net-tools \
dnsutils \
nmap \
gosu \
libgl1 \
libglib2.0-0t64 \
libxcb1 \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# Private browser automation wrapper used by the native `private_browser` tool.
# Chromium is installed above, so agent-browser can drive the existing browser
# binary without paying `npx` startup/install overhead on each tool call.
RUN npm install -g agent-browser@0.35.0 --omit=dev --loglevel=error
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
#
# libmagic1 is the shared lib (libmagic.so.1) that python-magic dlopens for
# content-based MIME sniffing in src/upload_handler.py. We install both here
# (libmagic1 + the python-magic wrapper, below) rather than in requirements.txt
# because python-magic resolves libmagic at import time: where the lib is
# absent the import can block or raise, so keeping it image-only avoids
# regressing pip/venv installs on hosts without libmagic. Debian always has the
# lib here, so the import is instant and detection actually works.
# Docker CLI (client only — daemon stays on the host via the
# /var/run/docker.sock mount). The Debian `docker.io` package ships
# dockerd but not the client binary on slim, so grab the static client
# tarball from download.docker.com instead.
ARG DOCKER_CLI_VERSION=29.6.2
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) DARCH=x86_64 ;; \
arm64) DARCH=aarch64 ;; \
*) echo "unsupported arch $ARCH"; exit 1 ;; \
esac \
&& curl -fsSL "https://download.docker.com/linux/static/stable/${DARCH}/docker-${DOCKER_CLI_VERSION}.tgz" \
-o /tmp/docker.tgz \
&& tar -xzf /tmp/docker.tgz -C /tmp \
&& install -m 0755 /tmp/docker/docker /usr/local/bin/docker \
&& rm -rf /tmp/docker /tmp/docker.tgz
WORKDIR /app
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
# are opt-in so the default image stays MIT-core; see requirements-optional.txt.
ARG INSTALL_OPTIONAL=false
COPY requirements.txt requirements-optional.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
# python-magic powers content-based MIME sniffing in src/upload_handler.py.
# Image-only (not in requirements.txt) because it needs the libmagic1 system
# lib installed above; see the apt note near the top of this stage.
RUN pip install --no-cache-dir python-magic==0.4.27
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
# pulled only when realesrgan is actually installed). With these dists already
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
# wheels instead of rebuilding the sdists that fail on Python 3.14.
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
&& rm -rf /tmp/odysseus-wheels
# Install Python deps first (layer cache)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy app code
COPY . .
-1
View File
@@ -1 +0,0 @@
0.20.5
+21 -235
View File
@@ -1,235 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based on the Program.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
MIT License
Copyright (c) 2025 Odysseus Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-45
View File
@@ -1,45 +0,0 @@
# -*- 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',
)
+267 -46
View File
@@ -1,81 +1,302 @@
<p align="center">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
# Odysseus
───────────────────────────────────────────────
⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
───────────────────────────────────────────────
<p align="center">
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
</p>
![Odysseus](docs/odysseus.jpg)
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="website/setup.md">Setup Guide</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="ROADMAP.md">Roadmap</a>
</p>
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
<p align="center">
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
</p>
## Features
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI</sub>
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
- **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!<br> <sub>built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving</sub>
- **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.<br> <sub>adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)</sub>
- **Compare** -- a fun tool to compare models side by side. Test completely blind, no bias!<br> <sub>multi-model · blind test · synthesis</sub>
- **Documents** -- YOU write the text, AI is there to assist, not the opposite.<br> <sub>multi-tab editor · markdown · HTML · CSV · syntax highlighting · AI edits · suggestions</sub>
- **Memory / Skills** -- Persistent memory and skills, your agent evolves over time as it better understands you and your tasks!<br> <sub>ChromaDB · fastembed (ONNX) · vector + keyword retrieval · import/export</sub>
- **Email** -- IMAP/SMTP inbox with AI triage built in: urgency reminders, auto-tag, auto-summary, auto-reply drafts, auto-spam.<br> <sub>IMAP · SMTP · per-account routing · CalDAV-aware</sub>
- **Notes & Tasks** -- Quick notes with reminders, a todo list, and scheduled tasks the agent can act on.<br> <sub>note pings · checklist · cron-style tasks · ntfy / browser / email channels</sub>
- **Calendar** -- Local-first calendar with CalDAV sync to Radicale / Nextcloud / Apple / Fastmail.<br> <sub>CalDAV pull · .ics import/export · per-calendar colors · agent-aware</sub>
- **Works on mobile** -- looks and runs great on your phone, not just desktop.<br> <sub>responsive · installable (PWA) · touch gestures</sub>
- **Extras** -- more to explore, happy if you give it a go!<br> <sub>image editor · theme editor · file uploads (vision + PDF) · web search · presets · sessions · 2FA</sub>
<p align="center">
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
</p>
## Demo
A full, hover-to-play tour lives on the landing page (`docs/index.html`).
---
<details>
<summary>Screenshots / clips</summary>
### Chat & Agents
![Chat & Agents](docs/chat.gif)
### Deep Research
![Deep Research](docs/research.gif)
### Compare
![Compare](docs/compare.gif)
### Documents
![Documents](docs/document.gif)
### Notes & Tasks
![Notes & Tasks](docs/notes.gif)
</details>
## Quick Start
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/odysseus-dev/odysseus/tree/main) if you want the more curated branch.
Defaults work out of the box: clone, run, then configure models/search/email
inside **Settings**. Only edit `.env` for deployment-level overrides like
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
On first setup, Odysseus creates an admin account (`admin` unless
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**.
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
pull request guidelines.
### Docker (recommended)
```bash
git clone https://github.com/odysseus-dev/odysseus.git
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
cp .env.example .env
cp .env.example .env # optional, but recommended for explicit defaults
docker compose up -d --build
```
Open `http://localhost:7000` when the containers are healthy. Docker Compose
binds the web UI to `127.0.0.1` by default. If the port is taken, set
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
only when you intentionally want LAN/reverse-proxy access.
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
### Native Linux / macOS
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
downloads and serves. Use `--host 0.0.0.0` only when you intentionally want
LAN/reverse-proxy access.
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](website/setup.md).
### Apple Silicon
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
M-series Mac, run Odysseus natively:
## Features
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
./start-macos.sh
```
- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory.
- **Cookbook** — hardware-aware model recommendations, downloads, and serving.
- **Deep Research** — multi-step web research with source reading and report generation.
- **Compare** — blind side-by-side model testing and synthesis.
- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting.
- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts.
- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync.
- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA.
It launches at `http://127.0.0.1:7860`. To build a clickable app wrapper:
## Demo
```bash
./build-macos-app.sh
```
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
<details>
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
## Contributing
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
they are reachable from the host but not exposed to your LAN/public internet
unless you opt in.
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
serve engines live in `./data/local` (`~/.local` in the container), so they
survive container recreation.
## Security
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
Odysseus SSH key and add the public key to the remote server's
`~/.ssh/authorized_keys`. From the host you can also run:
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly.
```bash
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
```
**NVIDIA / AMD Docker GPU overlays.** Install the host runtime first, then add
one of these to `.env`:
```bash
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
```
Verify with:
```bash
docker compose exec odysseus nvidia-smi -L
docker compose exec odysseus rocm-smi
```
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
Settings:
```text
http://host.docker.internal:11434/v1
```
Ollama must listen outside its own loopback interface:
```bash
OLLAMA_HOST=0.0.0.0:11434 ollama serve
```
**Useful checks.**
```bash
docker compose ps
docker compose logs --tail=120 odysseus
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
```
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
do not run on macOS. MLX-only models are not served by Odysseus.
</details>
### Native Windows
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
server; safe to re-run):
```powershell
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
```
Or do it by hand:
```powershell
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
python -m venv venv
venv\Scripts\Activate.ps1
pip install -r requirements.txt
python setup.py
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
email, calendar, deep research) runs fully native. For full **Cookbook** background
model downloads and the agent shell tool, also install
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
`http://localhost:11434/v1` in Settings.
Open `http://localhost:7000`, log in with the generated admin password,
and configure everything else inside **Settings**.
## Security Notes
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy.
- Keep `data/`, `.env`, logs, databases, and uploaded/generated media out of Git. They are ignored by default.
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
Deployment details are in the [setup guide](website/setup.md#security-notes).
### Putting it behind HTTPS
Odysseus serves plain HTTP on its port. That's fine for `localhost` and trusted LAN/VPN use, but browsers will warn ("Password fields present on an insecure page") and the login + API tokens travel in cleartext. For anything reachable outside your machine — including a Tailscale IP shared with other devices — put a TLS-terminating reverse proxy in front.
Shortest path with [Caddy](https://caddyserver.com/) (auto-renews Let's Encrypt certs):
```caddy
odysseus.example.com {
reverse_proxy localhost:7000
}
```
For a LAN-only Tailscale deployment, Caddy + [tailscale-cert](https://caddyserver.com/docs/caddyfile/options#auto-https) or the built-in MagicDNS HTTPS feature both work. nginx/Traefik configs are similar — proxy `localhost:7000`, terminate TLS at the proxy. Once that's in place, the browser warning goes away and your login is encrypted.
## Contributing
Help is welcome. The best entry points are fresh-install testing, provider setup
bugs, mobile/editor polish, docs, and small focused refactors. See
[ROADMAP.md](ROADMAP.md) for the current help-wanted list.
## Configuration
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
for deployment-level defaults and secrets you want present before first boot.
Key settings:
| Variable | Default | Description |
|---|---|---|
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
| `AUTH_ENABLED` | `true` | Enable/disable login |
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
### Built-in MCP servers (optional setup)
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
To enable the browser MCP (page navigation, screenshots, vision), run once:
```bash
npx -y @playwright/mcp@latest --version
```
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
## Architecture
```
app.py # FastAPI entry point
core/ auth, database, middleware, constants
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end)
docs/ landing page (index.html) + preview clips
```
## Data
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
## Star History
<a href="https://star-history.dera.page/#odysseus-dev/odysseus&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" />
</picture>
</a>
## License
MIT -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
```
|
|||
|||||
| | | |||||||
)_) )_) )_) ~|~
)___))___))___)\ |
)____)____)_____)\\|
_____|____|____|_____\\\__
\ /
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
~^~ all aboard! ~^~
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
```
+6 -48
View File
@@ -1,6 +1,6 @@
# Roadmap / Help Wanted
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this is ship is moving fast and feedback/help would be appreciated! (I dont know what I'm doing hlep).
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
the codebase, you are probably right to stay away.
@@ -8,67 +8,25 @@ the codebase, you are probably right to stay away.
## High Priority
- SQUASH BUGS
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
and WSL all need coverage.
- Fresh Docker install smoke tests on Linux, macOS, and Windows!!
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps.
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
NVIDIA/AMD hardware paths.
- Deep Research model presets by hardware. Recommend approved model/parameter
profiles for small, medium, and large local setups so people with different
hardware can use Deep Research without guessing. Surface this either in Deep
Research settings or as a Cookbook scan/dropdown suggestion.
- Cookbook model scan/download ranking. Prioritize newer architectures and
better hardware-fit models instead of scoring everything almost the same.
Ranking should account for architecture age, quant format, VRAM/RAM fit,
backend support, vision/mmproj requirements, and likely serve reliability.
- Cookbook error feedback and logging. Failed downloads, dependency installs,
preflights, and serve jobs should show the actual command/output/error in the
UI, with copyable logs and clear next steps instead of just "crashed".
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
tool schemas, skills, memory, documents, and instructions can eat the context
before the user request really starts. We need slimmer prompts, better tool
selection, smaller default tool sets, and clearer guidance for models with
4k/8k/16k context windows.
- Local model speculative decoding support. For Odysseus-tuned local models,
plan to ship or recommend a small same-tokenizer draft model when the serving
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
DSpark conversion performed poorly. Treat this as a supported draft-model lane
first; keep MTP-specific packaging as future work only when the architecture
and runtime support are real. Judge this by time-to-success, tool correctness,
grammar, and unchanged target output, not tokens/sec alone.
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces.
- Tile/window management correctness. I had to brute force my way a bit here, I'm aware, popups, dropdowns, and fixed-position UI inside transformed modals can land in the wrong place.
- Esc button, it's small but a lot of windows that arent still close on esc and alot of them doesnt.
- Skill audit, how does your model respond to skill injection, does it follow? Does its parsing miss?
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
- Email performance audit. Fetching, searching, opening, deleting, and sending
email can feel slow, especially over IMAP/SMTP providers with high latency.
Need someone who knows mail performance to profile the current flow, identify
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
propose safer caching/prefetch/batching without breaking multi-account state.
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
## Refactor Targets
- CSS cleanup. `static/style.css` basically Calypso's island atm.
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
- Modal/window positioning cleanup. Some window controls have improved, but the
underlying popup/dropdown/fixed-position behavior is still too fragile.
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
- Dead code pass for old routes, stale feature flags, and unused UI states.
## Frontend
- Expand the Editor for quicker, more robust everyday use. Better file/document
handling, smoother window behavior, clearer save/export flows, stronger image
editing affordances, and fewer brittle edge cases.
- Better AI integration for Notes and Todos. Notes should be easier for the
agent to read, update, summarize, and turn into actions. Todos should be
assignable to an agent from the UI, possibly through a button, task action,
or dedicated skill/tool flow.
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
- Improve empty states and error messages on fresh installs.
+4 -8
View File
@@ -8,20 +8,16 @@ Security fixes are handled on the default branch until formal releases are cut.
## Deployment Guidance
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
- Keep `AUTH_ENABLED=true`.
- Use HTTPS when exposing the app beyond localhost.
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
- Protect `.env`, `data/`, `logs/`, uploads, generated media, backups, auth/session files, database files, API keys, and model/provider tokens.
- Put the app behind a trusted reverse proxy or private network.
- Protect `.env`, `data/`, logs, uploaded files, generated media, and database files.
- Disable open signup unless you intentionally want new accounts.
- Keep demo/test users non-admin, and remove them entirely on serious deployments.
- Give admin accounts strong passwords and enable 2FA where possible.
- Leave high-risk agent tools restricted to admins: shell, Python, file read/write, email send/read, MCP, app API, task/skill/memory management, settings, tokens, and model serving.
- Rotate API keys, webhook secrets, and Odysseus API tokens if they appear in logs, screenshots, demos, or shared chats.
- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality.
- Common internal-only ports are Odysseus `7000`, SearXNG `8080`, ntfy `8091`, ChromaDB `8100`, Ollama `11434`, and local model/provider APIs such as `8000-8020`.
## Publishing A Fork
@@ -33,7 +29,7 @@ git check-ignore -v .env data/auth.json data/app.db logs/compound.log odysseus.d
git grep -n -I -E "(sk-[A-Za-z0-9_-]{20,}|xox[baprs]-|AIza[0-9A-Za-z_-]{20,}|Bearer [A-Za-z0-9._~+/-]{20,})" -- . ':!static/lib/**' ':!package-lock.json'
```
Only `.env.example`, docs, source, tests, and static assets should be committed. Never commit live `.env` values, `data/` contents, local databases, uploaded files, generated media, logs, backups, auth/session files, API keys, model/provider tokens, password hashes, or personal documents.
Only `.env.example`, docs, source, tests, and static assets should be committed. Never commit live `data/` contents, local databases, uploaded files, generated media, logs, backups, API keys, password hashes, or personal documents.
## Reporting
-81
View File
@@ -1,81 +0,0 @@
# Threat Model
Odysseus is a **self-hosted AI workspace with privileged local access**. This document states the trust boundary so contributors can reason about security decisions without reading through the full auth and middleware stack.
## Trust Boundary
Odysseus is designed for **trusted users on a private network**, not public exposure. The README describes it as "treat it like an admin console" — that framing is accurate. A logged-in admin can execute shell commands, read and write files, send email, and control model serving. This is intentional. The threat model does not try to prevent admins from doing these things. It does try to prevent:
- Unauthenticated access
- Non-admins reaching admin-only capabilities
- The AI agent acting on instructions injected through untrusted content (web results, emails, fetched pages, memories)
- Internal services (ChromaDB, Ollama, SearXNG, etc.) being reachable from outside the host
## Roles and Capabilities
| Capability | Admin | Non-admin (default) |
|---|---|---|
| Chat with agent | ✓ | ✓ |
| Browser tool | ✓ | ✓ |
| Documents | ✓ | ✓ |
| Research mode | ✓ | ✓ |
| Image generation | ✓ | ✓ |
| Memory management | ✓ | ✓ |
| Shell / Python execution | ✓ | ✗ |
| File read / write | ✓ | ✗ |
| Email send / read | ✓ | ✗ |
| MCP tools | ✓ | ✗ |
| Calendar management | ✓ | ✗ |
| Token / webhook management | ✓ | ✗ |
| Model serving | ✓ | ✗ |
| Vault | ✓ | ✗ |
| Settings | ✓ | ✗ |
Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is in `src/tool_security.py:NON_ADMIN_BLOCKED_TOOLS`. Any tool whose name starts with `mcp__` is also blocked for non-admins. Admins always get full access regardless of stored privilege values.
## Authentication
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
- **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
## Internal Tool Loopback
Agent tool calls reach admin-gated HTTP routes over an in-process HTTP loopback. The mechanism:
1. At app startup, `core/middleware.py` generates a random `INTERNAL_TOOL_TOKEN` via `secrets.token_hex(32)`. It is never persisted and never sent to clients.
2. Loopback requests carry `X-Odysseus-Internal-Token: <token>` or have `request.state.current_user` already set to `"internal-tool"` by the auth middleware.
3. `require_admin` recognises either signal and grants access without checking the session user.
The agent may be running in a non-admin user's session, but tool dispatch first calls `src/tool_security.py:owner_is_admin_or_single_user` to verify the session owner is an admin before issuing any loopback call. Non-admin users cannot invoke admin tools even via the agent.
## Prompt-Injection Hardening
External content that reaches the LLM is treated as untrusted via `src/prompt_security.py`:
- `untrusted_context_message(label, content)` wraps the content in a `user`-role message with a header block instructing the model not to follow instructions inside it. Content goes in as data, not as a system instruction.
- `UNTRUSTED_CONTEXT_POLICY` is a system-prompt preamble that states the same policy at the top of every session where untrusted data may appear.
**Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug.
## Security Headers
`core/middleware.py:SecurityHeadersMiddleware` sets headers on every response:
- `X-Frame-Options: DENY` + `frame-ancestors 'none'` on all routes except tool-render iframes (which are sandboxed at the HTML level).
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` everywhere.
- **CSP:** nonce-based `script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`. `style-src 'unsafe-inline'` is intentionally kept — `static/index.html` ships inline `<style>` blocks and JS modules set `style=""` attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
## Known Gaps
These are open, acknowledged, and contributor help is welcome:
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal.
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
3. **`src/search/` partial consolidation.** `src.search.core` and `src.search.providers` correctly alias `services.search` via `sys.modules` replacement. `analytics`, `cache`, `content`, `query`, and `ranking` are still independent copies that can drift. The SSRF regression tests in `tests/test_webhook_ssrf_resilience.py` test `src.webhook_manager` directly (separate from search), so the safety net there is intact. See #1058.
4. **Token scopes are coarse.** There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either `chat` or `admin` scope with no per-capability granularity.
+149 -532
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+5 -13
View File
@@ -27,13 +27,13 @@ echo " port: $PORT"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# ── Icon (best effort) — center-crop the branding image to a square .icns ──
if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
# ── Icon (best effort) — center-crop docs/odysseus.jpg to a square .icns ──
if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
# let sips emit the .icns directly — more robust across macOS versions than
# building an .iconset by hand.
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
sips -c 720 720 "$REPO_DIR/docs/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/docs/odysseus.jpg" "$TMPIMG/sq.png"
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns"
@@ -42,7 +42,7 @@ if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null
fi
rm -rf "$TMPIMG"
else
echo " icon: (skipped — no assets/branding/odysseus.jpg)"
echo " icon: (skipped — no docs/odysseus.jpg)"
fi
# ── Info.plist ──
@@ -73,10 +73,6 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER'
INSTALL_DIR="__INSTALL_DIR__"
PORT="__PORT__"
URL="http://127.0.0.1:${PORT}"
# uvicorn is started with --port below, but APP_PORT is what the app itself
# reads when it needs to build a URL for this instance (internal_api_base(),
# companion pairing, the MCP OAuth callback), so export it as well.
export APP_PORT="$PORT"
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
@@ -123,11 +119,7 @@ fi
notify "Starting…"
cd "$INSTALL_DIR" || die_gui "Install folder not found: $INSTALL_DIR"
if [ "$(uname -m)" = "arm64" ]; then
arch -arm64 "$UVICORN" app:app --host 127.0.0.1 --port "$PORT" >>"$LOG" 2>&1 &
else
"$UVICORN" app:app --host 127.0.0.1 --port "$PORT" >>"$LOG" 2>&1 &
fi
"$UVICORN" app:app --host 127.0.0.1 --port "$PORT" >>"$LOG" 2>&1 &
SERVER_PID=$!
# Quitting the app stops the server it started.
-72
View File
@@ -1,72 +0,0 @@
#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
-28
View File
@@ -1,28 +0,0 @@
# Companion bridge
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
Odysseus server offers and pair to it, without duplicating any LLM logic.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
| GET | `/api/companion/info` | session or token | server identity + capability flags |
| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints |
| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) |
| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) |
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
(same rule as `owner_filter`) and never returns API-key material.
## Pairing CSRF posture
Minting happens **only on POST**. The session cookie is `SameSite=Lax`
(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST —
the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax
cookies ride top-level GET navigations), so `GET /pair` only renders a form.
Minting invalidates the auth middleware's token cache, so a freshly minted token
works on the next request without a restart.
The pairing/scoping rules live in small, tested units (`token_owner`,
`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see
`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`.
-11
View File
@@ -1,11 +0,0 @@
"""Odysseus companion bridge — additive LAN endpoints.
Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN
client can discover what a server offers, plus admin-only pairing
(/api/companion/pair) that mints a one-time chat-scoped token on POST. No new LLM
logic; auth is enforced by the existing AuthMiddleware. See companion/README.md.
"""
from companion.routes import setup_companion_routes
__all__ = ["setup_companion_routes"]
-227
View File
@@ -1,227 +0,0 @@
"""Shared pairing helpers for the companion bridge.
Token minting + LAN discovery + QR rendering, kept here as small, importable
units so the route layer stays thin and the logic is directly testable.
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import secrets
import socket
import uuid
from urllib.parse import urlsplit
import bcrypt
from src.constants import AUTH_FILE
PAIRING_VERSION = 1
COMPANION_SCOPE = "chat"
_COMPANION_IPV4_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
)
)
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
def _valid_companion_client_host(host: str) -> bool:
"""Match the host forms supported by the current v1 Expo client."""
if not host or len(host) > 253 or not host.isascii() or "%" in host:
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
labels = host.split(".")
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
return False
if any(label.startswith("xn--") for label in labels):
return False
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
# as an IPv4 number even though Python's strict ``ipaddress`` parser
# rejects that spelling. The v1 client interpolates this host back
# into a URL, so accepting e.g. ``134744072`` would make the phone send
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
if len(labels) == 1 and (
labels[0].isdigit()
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
):
return False
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
return isinstance(address, ipaddress.IPv4Address) and any(
address in network for network in _COMPANION_IPV4_NETWORKS
)
def parse_companion_base_url(value: str) -> tuple[str, int]:
"""Validate a v1 companion address and return its legacy (host, port).
The deployed client understands only HTTP plus a LAN-style host and port.
Reject anything outside that exact contract instead of advertising a URL
the client would reject, downgrade, or interpret differently.
"""
if not isinstance(value, str) or not value:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if not value.isascii():
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
if any(
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
for char in value
):
raise ValueError(
"COMPANION_BASE_URL contains a forbidden character"
)
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
host = parsed.hostname
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if parsed.username is not None or parsed.password is not None:
raise ValueError("COMPANION_BASE_URL must not contain credentials")
if parsed.path or parsed.query or parsed.fragment:
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
if port is not None and not 1 <= port <= 65535:
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
if not _valid_companion_client_host(host):
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
netloc = f"{host}:{port}" if port is not None else host
origin = f"http://{netloc}"
if value != origin:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
return host, port or 80
def configured_companion_origin() -> tuple[str, int] | None:
"""Return the validated operator-configured v1 address, if any."""
value = os.environ.get("COMPANION_BASE_URL")
if value is None or value == "":
return None
return parse_companion_base_url(value)
def default_port() -> int:
"""Best guess at the port the server is reachable on. Callers that know the
real request port should pass it explicitly."""
try:
return int(os.environ.get("APP_PORT", "7000"))
except ValueError:
return 7000
def lan_ip_candidates() -> list[str]:
"""Likely LAN IPv4 addresses for this host, best candidate first.
The UDP-connect trick reveals the egress interface the OS would use to reach
the default gateway -- i.e. the address a phone on the same Wi-Fi should
target. No packets are actually sent. Loopback is dropped.
"""
candidates: list[str] = []
def _add(ip):
if ip and ip not in candidates and not ip.startswith("127."):
candidates.append(ip)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
_add(s.getsockname()[0])
except OSError:
pass
finally:
s.close()
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
_add(info[4][0])
except OSError:
pass
return candidates
def find_admin_user() -> str | None:
"""Resolve an admin username from data/auth.json (schema uses is_admin),
falling back to the first user."""
auth_path = AUTH_FILE
try:
with open(auth_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
users = data.get("users") or {}
if not isinstance(users, dict):
return None
for uname, udata in users.items():
if isinstance(udata, dict) and udata.get("is_admin") is True:
return uname
return next(iter(users), None)
def mint_token(owner: str, name: str = "companion") -> tuple[str, str]:
"""Create a chat-scoped API token row and return (token_id, raw_token).
The raw token is returned ONCE -- only its bcrypt hash + an 8-char prefix
are persisted. Mirrors routes/api_token_routes.py so cookie- and
companion-minted tokens are indistinguishable to the auth middleware.
"""
from core.database import get_db_session, ApiToken
raw_token = "ody_" + secrets.token_urlsafe(32)
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
token_id = str(uuid.uuid4())[:8]
with get_db_session() as db:
db.add(ApiToken(
id=token_id,
owner=owner,
name=name,
token_hash=token_hash,
token_prefix=raw_token[:8],
scopes=COMPANION_SCOPE,
is_active=True,
))
return token_id, raw_token
def pairing_payload(host: str, port: int, token: str) -> dict:
"""The exact JSON a client scans / accepts. Keep keys stable."""
return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token}
def pairing_qr_png_data_uri(payload: dict) -> str | None:
"""Render the pairing payload as a QR `data:` URI for an <img>. Returns None
if the optional qrcode dep is unavailable."""
try:
import base64
import io
import qrcode
img = qrcode.make(json.dumps(payload, separators=(",", ":")))
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
-265
View File
@@ -1,265 +0,0 @@
"""Companion bridge — /api/companion/*.
A thin, additive layer so a LAN client (e.g. a phone) can discover what a server
offers and pair to it, without duplicating any LLM logic.
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_`
API token. Ping/info accept either credential type, models requires a chat-
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
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
cross-site POST, so an admin's cookie can't be used by a malicious page to mint
a token -- the same protection the existing POST /api/tokens relies on. Minting
on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
/pair only renders a form.
"""
import html
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from core.middleware import require_admin
from src.auth_helpers import _auth_disabled, get_current_user
from companion import pairing as _pairing
def token_owner(request: Request) -> str | None:
"""The real owner to attribute a request to, for read-scoping.
Cookie sessions resolve to the logged-in username via get_current_user.
Bearer-token callers come through as the sandboxed pseudo-user "api"; their
real owner is stamped on request.state.api_token_owner by the auth
middleware. Returns None when no owner can be resolved.
"""
if getattr(request.state, "api_token", False):
return getattr(request.state, "api_token_owner", None)
return get_current_user(request)
def owner_can_see(row_owner, owner) -> bool:
"""Owner-scope rule for read endpoints.
A caller sees a row when it is their own, or when it is a legacy null-owner
("shared") row. A caller must NEVER see another owner's row. Mirrors the
`owner_filter` rule used elsewhere, expressed as a pure predicate so it can
be tested directly and used as a defensive in-Python check alongside the
SQL filter.
"""
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]:
"""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
restart. Returns (token_id, raw_token); the raw token is shown once.
`invalidate` is the app's request.app.state.invalidate_token_cache callable
(passed in so this stays a pure, testable unit).
"""
token_id, raw_token = _pairing.mint_token(owner)
if callable(invalidate):
invalidate()
return token_id, raw_token
def setup_companion_routes() -> APIRouter:
router = APIRouter(prefix="/api/companion", tags=["companion"])
@router.get("/ping")
def ping(request: Request):
"""Cheap, auth-validated health check. A 200 with ok=true confirms the
host/port and credential are valid; middleware returns 401 otherwise."""
from core.constants import APP_VERSION
return {
"ok": True,
"name": "odysseus",
"version": APP_VERSION,
"auth": "token" if getattr(request.state, "api_token", False) else "session",
}
@router.get("/info")
def info(request: Request):
"""Server identity + coarse capability flags. `owner` is the caller's own
identity (the token's owner for bearer callers)."""
from core.constants import APP_VERSION
return {
"name": "odysseus",
"version": APP_VERSION,
"owner": token_owner(request),
"capabilities": {"chat": True, "streaming": True},
}
@router.get("/models")
def models(request: Request):
"""LLM model endpoints the CALLER can use.
The stock /api/models route scopes to get_current_user, which for a
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
scope to the token's real owner instead, plus legacy null-owner shared
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
the stock route's single-user all-endpoints view. Read-only; never
returns api_key material.
"""
require_models_scope(request)
import json as _json
from core.database import SessionLocal, ModelEndpoint
from src.endpoint_resolver import build_chat_url
owner = token_owner(request)
single_user_mode = (
owner is None
and not getattr(request.state, "api_token", False)
and _auth_disabled()
)
out = []
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True, # noqa: E712
(ModelEndpoint.model_type == "llm") | (ModelEndpoint.model_type == None), # noqa: E711
)
if owner:
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
for ep in q.all():
if not single_user_mode and not owner_can_see(ep.owner, owner):
continue
try:
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
except (ValueError, TypeError):
model_ids = []
try:
hidden = set(_json.loads(ep.hidden_models)) if ep.hidden_models else set()
except (ValueError, TypeError):
hidden = set()
model_ids = [m for m in model_ids if m not in hidden]
try:
chat_url = build_chat_url(ep.base_url)
except Exception:
chat_url = ep.base_url
out.append({
"endpoint_id": ep.id,
"name": ep.name,
"endpoint_url": chat_url,
"models": model_ids,
"supports_tools": ep.supports_tools,
})
finally:
db.close()
return {"endpoints": out}
@router.get("/pair")
def pair_page(request: Request):
"""Admin-only pairing page. Renders a form that POSTs to mint a code.
A GET never mints a credential: SameSite=Lax session cookies ride
top-level GET navigations, so minting on GET would be triggerable by a
link or <img> (CSRF). The actual mint is the POST handler below.
"""
require_admin(request)
page = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pair a device</title>
<style>
body{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:48px auto;padding:0 20px;color:#e8e8e8;background:#16161a}
.card{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:28px;text-align:center}
button{background:#7c9cff;color:#0e0e12;border:none;border-radius:10px;padding:12px 20px;font-size:15px;font-weight:600;cursor:pointer}
</style></head>
<body><div class="card">
<h2>Pair a device</h2>
<p>Generate a one-time pairing code (a chat-scoped API token) for a LAN client.</p>
<form method="POST" action="/api/companion/pair">
<button type="submit">Generate pairing code</button>
</form>
<p style="color:#8a8a96;font-size:12px;margin-top:18px">Admin only. Each code mints a new token, shown once. Manage or revoke under Settings &rarr; API tokens.</p>
</div></body></html>"""
return HTMLResponse(page)
@router.post("/pair")
def pair_create(request: Request):
"""Mint a pairing code. Admin-cookie only; CSRF-safe because the
SameSite=Lax session cookie is not sent on a cross-site POST (same
protection as POST /api/tokens). Minting invalidates the token cache so
the code works immediately, no restart. `?format=json` returns the
payload for an in-app pairing screen."""
require_admin(request)
try:
configured_origin = _pairing.configured_companion_origin()
except ValueError as exc:
raise HTTPException(500, str(exc)) from None
owner = get_current_user(request)
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
token_id, raw_token = mint_pairing_token(owner, invalidate)
if configured_origin:
host, port = configured_origin
hosts = [host]
else:
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
payload = _pairing.pairing_payload(host, port, raw_token)
qr = _pairing.pairing_qr_png_data_uri(payload)
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
if (request.query_params.get("format") or "").lower() == "json":
response = {
"host": host,
"port": port,
"token": raw_token,
"token_id": token_id,
"hosts": hosts,
"payload": payload,
"qr": qr if qr_ok else None,
}
return response
import json as _json
payload_json = _json.dumps(payload, separators=(",", ":"))
# Only ever emit a known PNG data-URI into the src; every other value is
# html.escaped.
qr_block = (
f'<img src="{html.escape(qr)}" alt="Pairing QR" width="260" height="260">'
if qr_ok else "<p><em>QR rendering unavailable -- enter the details manually.</em></p>"
)
page = f"""<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pairing code</title>
<style>
body{{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:40px auto;padding:0 20px;color:#e8e8e8;background:#16161a}}
.card{{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:24px;text-align:center}}
code{{background:#0e0e12;padding:2px 6px;border-radius:6px;word-break:break-all}}
.row{{text-align:left;margin:10px 0;font-size:14px;color:#bdbdc7}}
.warn{{color:#e0a85e;font-size:13px;margin-top:18px}}
</style></head>
<body><div class="card">
<h2>Pairing code</h2>
{qr_block}
<div class="row"><strong>Host:</strong> <code>{html.escape(host)}</code></div>
<div class="row"><strong>Port:</strong> <code>{html.escape(str(port))}</code></div>
<div class="row"><strong>Token:</strong> <code>{html.escape(raw_token)}</code></div>
<div class="row"><strong>Payload:</strong> <code>{html.escape(payload_json)}</code></div>
<p class="warn">Shown once. This grants chat access to your Odysseus; revoke it
in Settings &rarr; API tokens (id <code>{html.escape(token_id)}</code>). The
device must be on the same network, and the server must bind to your LAN.</p>
</div></body></html>"""
return HTMLResponse(page)
return router
+14 -38
View File
@@ -15,53 +15,29 @@ from __future__ import annotations
import json
import os
import uuid
from typing import Any, Optional
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
"""Atomically persist `data` as JSON at `path`.
The temp file uses a random suffix so two concurrent writers saving the
same file don't collide on the rename target. A PID suffix does not do
this: the PID is constant for the life of a process, so two writers on
the same path within one process (or one single-process container, where
the PID never changes at all) still race for the same temp file.
The temp file uses the live PID as a suffix so two processes saving the
same file (e.g. unit tests) don't collide on the rename target.
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def atomic_write_text(path: str, text: str) -> None:
if not isinstance(text, str):
raise TypeError("atomic_write_text expects a string")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
+93 -264
View File
@@ -3,7 +3,6 @@ Authentication module — multi-user password hashing, session tokens, config pe
Config stored in data/auth.json. Uses bcrypt directly.
"""
import enum
import json
import os
import secrets
@@ -31,43 +30,31 @@ DEFAULT_PRIVILEGES = {
"can_manage_memory": True,
"max_messages_per_day": 0,
"allowed_models": [],
"allowed_models_restricted": False,
# Explicit "block every model" sentinel. An empty `allowed_models` list is
# ambiguous — it's also what gets sent when the admin clicks "[All]" — so
# we need a dedicated flag to express "this user may use no models at all"
# distinctly from "this user has no restriction".
"block_all_models": False,
}
# Admins get everything
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
# Admins must never be blocked from using models — the generic dict
# comprehension above flips every boolean default to True, which would be
# backwards for this sentinel.
ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE
DEFAULT_AUTH_PATH = os.path.join(
Path(__file__).parent.parent, "data", "auth.json"
)
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# Usernames the auth + middleware layer reserves for request sentinels and
# internal storage owners; they must never belong to a real login account.
# "internal-tool" is the most dangerous because `core.middleware.require_admin`
# treats it as the in-process tool loopback. "api" collides with bearer-token
# attribution. "demo"/"system" are synthetic owners already special-cased by
# scheduler/assistant/research paths. The Default/Local owner is a storage
# bucket for explicit auth-disabled no-login mode, not a login username.
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
"""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
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
# sentinels; they must never belong to a real account. The most dangerous is
# "internal-tool": `core.middleware.require_admin` treats any request whose
# `current_user == "internal-tool"` as the in-process tool loopback and grants
# admin, and because the cookie auth path sets `current_user` to the raw
# username, an account literally named "internal-tool" would be silently
# treated as an admin by every `require_admin`-gated route. "api" collides with
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
# synthetic-owner set the rest of the codebase already special-cases (see
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
# of those names would be denied an assistant and inconsistently owner-scoped.
# Refuse to create or rename into any of them so the sentinels can't be
# impersonated. (Keep this in sync with that synthetic-owner set.)
RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"})
def _hash_password(password: str) -> str:
@@ -78,15 +65,6 @@ def _verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
class SetAdminResult(enum.Enum):
"""Outcome of AuthManager.set_admin, so callers can map each case to a
precise response instead of guessing from a bare bool."""
OK = "ok"
USER_NOT_FOUND = "user_not_found"
NOT_AUTHORIZED = "not_authorized" # requester is not an admin
LAST_ADMIN = "last_admin" # would remove the last remaining admin
class AuthManager:
"""Manages multi-user password + session-token auth system."""
@@ -98,17 +76,12 @@ class AuthManager:
# Guards mutations of self._sessions and the on-disk sessions.json.
# Validate/create/revoke run concurrently from the FastAPI threadpool.
self._sessions_lock = threading.RLock()
# Guards all mutations of self._config and the on-disk auth.json so
# concurrent create/delete/rename/privilege operations don't interleave
# and corrupt the user database.
self._config_lock = threading.Lock()
# Guards the first-run setup check-and-write so concurrent requests
# cannot both observe is_configured==False and both create admin accounts.
self._setup_lock = threading.Lock()
self._load()
self._load_sessions()
self._migrate_single_user()
self._drop_reserved_loaded_users()
self._migrate_legacy_admin_role()
def _load(self):
@@ -161,52 +134,20 @@ 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 = 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_user = self._config.get("username", "admin")
old_hash = self._config["password_hash"]
with self._config_lock:
self._config = {
"users": {
old_user: {
"password_hash": old_hash,
"created": time.time(),
"is_admin": True,
}
self._config = {
"users": {
old_user: {
"password_hash": old_hash,
"created": time.time(),
"is_admin": True,
}
}
self._save()
}
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:
with self._config_lock:
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
@@ -231,23 +172,13 @@ class AuthManager:
@signup_enabled.setter
def signup_enabled(self, value: bool):
with self._config_lock:
self._config["signup_enabled"] = value
self._save()
self._config["signup_enabled"] = value
self._save()
@property
def is_configured(self) -> bool:
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
# ------------------------------------------------------------------
@@ -267,18 +198,17 @@ class AuthManager:
if username in RESERVED_USERNAMES:
logger.warning("Refused to create reserved username '%s'", username)
return False
with self._config_lock:
if username in self.users:
return False
if "users" not in self._config:
self._config["users"] = {}
self._config["users"][username] = {
"password_hash": _hash_password(password),
"created": time.time(),
"is_admin": is_admin,
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
}
self._save()
if username in self.users:
return False
if "users" not in self._config:
self._config["users"] = {}
self._config["users"][username] = {
"password_hash": _hash_password(password),
"created": time.time(),
"is_admin": is_admin,
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
}
self._save()
logger.info(f"Created user '{username}' (admin={is_admin})")
return True
@@ -291,31 +221,14 @@ class AuthManager:
their cookie expired naturally (default ~30 days).
"""
username = username.strip().lower()
with self._config_lock:
if username not in self.users:
return False
if username == requesting_user:
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()
if username not in self.users:
return False
if username == requesting_user:
return False
if not self.users.get(requesting_user, {}).get("is_admin"):
return False
del self._config["users"][username]
self._save()
# Purge all sessions belonging to this user. validate_token doesn't
# cross-check `self.users`, so without this step a deleted user's
# cookie keeps authenticating.
@@ -341,21 +254,19 @@ class AuthManager:
if new_username in RESERVED_USERNAMES:
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
return False
with self._config_lock:
if old_username not in self.users:
return False
if new_username in self.users:
return False
if not self.users.get(requesting_user, {}).get("is_admin"):
return False
self._config.setdefault("users", {})[new_username] = self._config["users"].pop(old_username)
self._save()
if old_username not in self.users:
return False
if new_username in self.users:
return False
if not self.users.get(requesting_user, {}).get("is_admin"):
return False
self._config.setdefault("users", {})[new_username] = self._config["users"].pop(old_username)
self._save()
renamed_sessions = 0
with self._sessions_lock:
for sess in self._sessions.values():
sess_user = str((sess or {}).get("username") or "").strip().lower()
if sess_user == old_username:
if (sess or {}).get("username") == old_username:
sess["username"] = new_username
renamed_sessions += 1
if renamed_sessions:
@@ -387,93 +298,28 @@ class AuthManager:
def set_privileges(self, username: str, privileges: Dict[str, Any]) -> bool:
"""Update privileges for a user. Can't modify admin privileges."""
username = username.strip().lower()
with self._config_lock:
if username not in self.users:
return False
if self.users[username].get("is_admin"):
return False # admins always have full access
# Only allow known privilege keys
current = self.get_privileges(username)
for k, v in privileges.items():
if k in DEFAULT_PRIVILEGES:
current[k] = v
self._config["users"][username]["privileges"] = current
self._save()
if username not in self.users:
return False
if self.users[username].get("is_admin"):
return False # admins always have full access
# Only allow known privilege keys
current = self.get_privileges(username)
for k, v in privileges.items():
if k in DEFAULT_PRIVILEGES:
current[k] = v
self._config["users"][username]["privileges"] = current
self._save()
logger.info(f"Updated privileges for '{username}': {current}")
return True
def set_admin(self, username: str, is_admin: bool,
requesting_user: str) -> SetAdminResult:
"""Promote/demote an existing user to/from admin. Admin only.
Refuses to remove the last remaining admin so the instance can never
be locked out of admin access; self-demotion is allowed as long as
another admin remains. Admin status is re-checked live on every
request, so unlike delete/rename no session or token revocation is
needed — a demoted admin simply fails the next is_admin() gate.
Promotion stashes the user's current privilege map and demotion
restores it, so a temporary admin stint can't silently broaden a
user's non-admin access; users without a stash (created as admin,
or promoted before stashing existed) demote to DEFAULT_PRIVILEGES.
Counting admins and flipping the flag happen in one critical section
so two concurrent demotions can't race the admin count to zero.
"""
username = (username or "").strip().lower()
requesting_user = (requesting_user or "").strip().lower()
is_admin = bool(is_admin)
with self._config_lock:
target = self._config.get("users", {}).get(username)
if target is None:
return SetAdminResult.USER_NOT_FOUND
if not self.users.get(requesting_user, {}).get("is_admin"):
return SetAdminResult.NOT_AUTHORIZED
currently_admin = bool(target.get("is_admin"))
if currently_admin == is_admin:
return SetAdminResult.OK # no-op; leave privileges untouched
if currently_admin and not is_admin:
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
if admin_count <= 1:
return SetAdminResult.LAST_ADMIN
# Write order matters for lock-free readers: get_privileges()
# reads without _config_lock and trusts is_admin, so the admin
# flag must be flipped while the stored map is safe to expose —
# before writing admin privileges on promote, after restoring
# the pre-admin map on demote.
if is_admin:
target["is_admin"] = True
# Stash the pre-admin map so a later demotion can restore it.
# While is_admin is set the stored map is inert: get_privileges
# short-circuits to ADMIN_PRIVILEGES and set_privileges refuses
# admins, so only set_admin ever touches the stash.
target["privileges_before_admin"] = dict(
target.get("privileges") or DEFAULT_PRIVILEGES
)
target["privileges"] = dict(ADMIN_PRIVILEGES)
else:
# Restore the stashed pre-admin map. Fall back to defaults for
# users created as admins (their stored map is ADMIN_PRIVILEGES,
# which must not leak past demotion — e.g. can_use_bash) and
# for admins promoted before the stash existed.
target["privileges"] = dict(
target.pop("privileges_before_admin", None)
or DEFAULT_PRIVILEGES
)
target["is_admin"] = False
self._save()
logger.info("Set is_admin=%s for '%s' (by '%s')", is_admin, username, requesting_user)
return SetAdminResult.OK
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
username = username.strip().lower()
if username not in self.users:
return False
if not _verify_password(current_password, self.users[username]["password_hash"]):
return False
with self._config_lock:
self._config["users"][username]["password_hash"] = _hash_password(new_password)
self._save()
self._config["users"][username]["password_hash"] = _hash_password(new_password)
self._save()
return True
# ------------------------------------------------------------------
@@ -491,9 +337,8 @@ class AuthManager:
if username not in self.users:
return None
secret = pyotp.random_base32()
with self._config_lock:
self._config["users"][username]["totp_secret_pending"] = secret
self._save()
self._config["users"][username]["totp_secret_pending"] = secret
self._save()
return secret
def totp_get_provisioning_uri(self, username: str, secret: str) -> str:
@@ -512,14 +357,13 @@ class AuthManager:
if not totp.verify(code, valid_window=1):
return False
# Enable 2FA
with self._config_lock:
self._config["users"][username]["totp_secret"] = secret
self._config["users"][username]["totp_enabled"] = True
self._config["users"][username].pop("totp_secret_pending", None)
# Generate backup codes
backup = [secrets.token_hex(4) for _ in range(8)]
self._config["users"][username]["totp_backup_codes"] = backup
self._save()
self._config["users"][username]["totp_secret"] = secret
self._config["users"][username]["totp_enabled"] = True
self._config["users"][username].pop("totp_secret_pending", None)
# Generate backup codes
backup = [secrets.token_hex(4) for _ in range(8)]
self._config["users"][username]["totp_backup_codes"] = backup
self._save()
logger.info(f"2FA enabled for '{username}'")
return True
@@ -531,17 +375,13 @@ class AuthManager:
return True # 2FA not enabled, always pass
secret = user.get("totp_secret")
if not secret:
# 2FA is enabled but no secret is stored (corrupt/partially-written
# auth.json). Fail closed — returning True here bypassed the second
# factor entirely.
return False
return True
# Check backup codes first
backup = user.get("totp_backup_codes", [])
if code in backup:
with self._config_lock:
backup.remove(code)
self._config["users"][username]["totp_backup_codes"] = backup
self._save()
backup.remove(code)
self._config["users"][username]["totp_backup_codes"] = backup
self._save()
logger.info(f"Backup code used for '{username}' ({len(backup)} remaining)")
return True
totp = pyotp.TOTP(secret)
@@ -552,12 +392,11 @@ class AuthManager:
username = username.strip().lower()
if not self.verify_password(username, password):
return False
with self._config_lock:
self._config["users"][username].pop("totp_secret", None)
self._config["users"][username].pop("totp_secret_pending", None)
self._config["users"][username].pop("totp_backup_codes", None)
self._config["users"][username]["totp_enabled"] = False
self._save()
self._config["users"][username].pop("totp_secret", None)
self._config["users"][username].pop("totp_secret_pending", None)
self._config["users"][username].pop("totp_backup_codes", None)
self._config["users"][username]["totp_enabled"] = False
self._save()
logger.info(f"2FA disabled for '{username}'")
return True
@@ -576,22 +415,12 @@ class AuthManager:
username = username.strip().lower()
if not self.verify_password(username, password):
return None
return self.create_session_trusted(username)
def create_session_trusted(self, username: str) -> Optional[str]:
"""Issue a session token for an already-verified user.
Call only after verify_password (and TOTP if enabled) have passed."""
username = username.strip().lower()
token = secrets.token_hex(32)
with self._config_lock:
if username not in self.users:
logger.warning("Refused to issue session for missing user '%s'", username)
return None
with self._sessions_lock:
self._sessions[token] = {
"username": username,
"expiry": time.time() + TOKEN_TTL,
}
with self._sessions_lock:
self._sessions[token] = {
"username": username,
"expiry": time.time() + TOKEN_TTL,
}
self._save_sessions()
return token
+39 -11
View File
@@ -1,12 +1,40 @@
# core/constants.py
"""Backward-compatible shim — the single source of truth is src/constants.py.
# src/constants.py
"""Application-wide constants and configuration values."""
import os
Historically there were two copies of this module (this one lagged behind at
APP_VERSION 0.9.1 and was missing the consolidated tool-output constants). To
kill the drift, this now simply re-exports everything from src.constants so
there is exactly one place that defines paths and reads ODYSSEUS_DATA_DIR.
internal_api_base() also lives in src.constants now and is re-exported here so
existing `from core.constants import internal_api_base` callers keep working.
"""
from src.constants import * # noqa: F401,F403
from src.constants import internal_api_base # noqa: F401 (explicit: functions aren't covered by some linters' * checks)
APP_VERSION = "0.9.1"
# Base paths
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
STATIC_DIR = os.path.join(BASE_DIR, "static")
DATA_DIR = os.path.join(BASE_DIR, "data")
# Data file paths
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md")
PERSONAL_DIR = os.path.join(DATA_DIR, "personal_docs")
RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook")
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
FEATURES_FILE = os.path.join(DATA_DIR, "features.json")
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
# API Configuration
MAX_CONTEXT_MESSAGES = 90
REQUEST_TIMEOUT = 20
OPENAI_COMPAT_PATH = "/v1/chat/completions"
# Environment variables with defaults
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
SEARXNG_INSTANCE = os.getenv('SEARXNG_INSTANCE', 'http://localhost:8080')
# Cleanup configuration
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
# Default parameters
DEFAULT_TEMPERATURE = 1.0
DEFAULT_MAX_TOKENS = 0
+101 -1243
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
# core/exceptions.py
# src/exceptions.py
"""Custom exceptions for the application."""
class SessionNotFoundError(Exception):
-27
View File
@@ -1,27 +0,0 @@
"""Helpers for keeping sensitive data out of logs.
Endpoint URLs configured by admins can embed credentials in the userinfo
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
raw leaks those secrets, so route/diagnostic logs run URLs through
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
"""
from urllib.parse import urlparse, urlunparse
def redact_url(url: str) -> str:
"""Return a URL safe for logs by removing userinfo and query/fragment.
Keeps scheme, host, port and path so logs stay useful for debugging.
"""
try:
parsed = urlparse(url or "")
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
host = f"[{host}]"
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
except Exception:
return "<endpoint>"
+7 -58
View File
@@ -3,14 +3,10 @@
import os
import secrets
from collections.abc import Mapping
from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from starlette.routing import get_route_path
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
# Per-process token that lets the in-app tool layer hit admin-gated
@@ -21,39 +17,6 @@ INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
def get_application_route_path(scope: Mapping[str, object]) -> str:
"""Return the application-relative path used by Starlette routing.
Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``;
Starlette removes that prefix before matching routes. Middleware policy
must use the same path form or a deployment prefix can change which policy
applies to an otherwise unchanged application route.
"""
return get_route_path(scope)
def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str:
"""Prefix an application path for a client-facing redirect target."""
root_path = scope.get("root_path", "")
if not isinstance(root_path, str) or not root_path:
return path
return f"{root_path.rstrip('/')}{path}"
def path_is_route_or_child(path: str, prefix: str) -> bool:
"""Return whether ``path`` is exactly ``prefix`` or below that route."""
return path == prefix or path.startswith(prefix + "/")
def is_cors_preflight(method: str, headers) -> bool:
"""True for a genuine CORS preflight: an OPTIONS request carrying the
Access-Control-Request-Method header. Such requests are credential-less by
design and must reach CORSMiddleware to be answered -- gating them on auth
401s the preflight and breaks every cross-origin browser/WebView client.
Pure so it can be unit-tested without standing up the app."""
return method == "OPTIONS" and "access-control-request-method" in headers
def require_admin(request: Request):
"""Raise 403 if the current user isn't an admin.
Allows access when auth is explicitly disabled, or when the request carries
@@ -67,13 +30,13 @@ def require_admin(request: Request):
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
return
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
if getattr(request.state, "current_user", None) == "internal-tool":
return
except Exception:
pass
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_disabled():
if os.getenv("AUTH_ENABLED", "true").lower() == "false":
return
if not auth_mgr or not auth_mgr.is_configured:
raise HTTPException(403, "Admin only")
@@ -93,23 +56,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
path = request.url.path
# Tool render endpoints
# Tool render endpoints are served inside iframes — allow framing by self
is_tool_render = path.startswith("/api/tools/") and path.endswith("/render")
# Document library PDF preview endpoint
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
is_report = path.startswith("/api/research/report/")
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=()"
is_https = (
request.url.scheme == "https"
or request.headers.get("X-Forwarded-Proto") == "https"
)
if is_https:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
if is_report:
response.headers["Content-Security-Policy"] = (
@@ -122,14 +75,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"frame-ancestors 'none'"
)
elif is_tool_render:
# Skip framing headers for tools.
# Tool iframe content: skip all framing headers — the iframe's
# sandbox="allow-scripts" attribute provides isolation.
# Don't overwrite the route's own restrictive CSP either.
pass
elif is_document_pdf_preview:
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = (
"default-src 'none'; "
"frame-ancestors 'self'"
)
else:
response.headers["X-Frame-Options"] = "DENY"
# NOTE: `style-src 'unsafe-inline'` is intentionally retained.
@@ -143,7 +92,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"font-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data: blob: https:; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"connect-src 'self'; "
"frame-src 'self'; "
+15 -136
View File
@@ -8,61 +8,17 @@ These are simple datacontainers. All persistence is handled by SessionManager.
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
CHAT_SESSION_APPROVAL_DECISION,
)
if TYPE_CHECKING:
from .session_manager import SessionManager
# Module-level session manager singleton (single source of truth)
_SESSION_MANAGER_INSTANCE: Optional["SessionManager"] = None
# Module-level session manager reference (set at app startup)
_session_manager: Optional["SessionManager"] = None
def set_session_manager_instance(manager: "SessionManager"):
"""Set the global SessionManager singleton."""
global _SESSION_MANAGER_INSTANCE
_SESSION_MANAGER_INSTANCE = manager
def get_session_manager_instance() -> Optional["SessionManager"]:
"""Get the global SessionManager singleton."""
return _SESSION_MANAGER_INSTANCE
# Keep legacy name for backward compatibility
set_session_manager = set_session_manager_instance
get_session_manager = get_session_manager_instance
def _history_grants_chat_session_approval(
history: List["ChatMessage"],
session_id: str,
) -> bool:
"""Return whether this exact chat has a resolved session-scope grant."""
expected_session = str(session_id or "")
if not expected_session:
return False
for message in reversed(history or []):
metadata = getattr(message, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if (
ask_user.get("kind") == "tool_approval"
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
and str(ask_user.get("session_id") or "") == expected_session
):
return True
return False
def set_session_manager(manager: "SessionManager"):
"""Set the global session manager reference."""
global _session_manager
_session_manager = manager
@dataclass
@@ -86,17 +42,7 @@ class ChatMessage:
@dataclass
class Session:
"""A chat session — pure data container.
``.history`` is the authoritative mutable message list. Callers may
read, append, pop, or reassign it directly — these changes take
effect immediately. ``_history`` remains a compatibility alias that
always resolves to the authoritative ``history`` list.
Each session gets its own unique history list at construction time
(the dataclass default is never shared between instances).
"""
"""A chat session — pure data container."""
id: str
name: str
endpoint_url: str
@@ -108,98 +54,31 @@ class Session:
owner: Optional[str] = None
is_important: bool = False
message_count: int = 0
memory_extraction_enabled: bool = True
skill_injection_enabled: bool = True
thinking_mode: str = "off"
temperature_override: Optional[float] = None
max_tokens_override: Optional[int] = None
cwd: Optional[str] = None
def __post_init__(self):
if self.headers is None:
self.headers = {}
# Ensure each session gets its OWN list (not the shared dataclass default)
if self.history is None:
self.history = []
@property
def _history(self) -> List[ChatMessage]:
"""Compatibility alias for callers that still reference ``_history``."""
return self.history
@_history.setter
def _history(self, messages: List[ChatMessage]):
self.history = messages
if self.headers is None:
self.headers = {}
def add_message(self, message: ChatMessage):
"""
Add a message to this session.
Appends to the authoritative history list and increments
message_count. Delegates to SessionManager for persistence
if available.
Delegates to SessionManager for persistence if available,
otherwise just appends to history.
"""
self.history.append(message)
self.message_count = len(self.history)
# Delegate to session manager for persistence
if _SESSION_MANAGER_INSTANCE:
_SESSION_MANAGER_INSTANCE._persist_message(self.id, message)
if _session_manager:
_session_manager._persist_message(self.id, message)
def get_context_messages(self) -> List[Dict[str, Any]]:
"""Get messages in format for LLM API.
Slash-command / setup replies are persisted to history so they render
in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its
status lines) the user never meant as conversation. They carry
``metadata.source == "slash"``; exclude them here so they never reach
the model. Display/history-load paths use the raw ``history`` and are
unaffected.
"""
messages = [
msg.to_dict()
for msg in self.history
if (msg.metadata or {}).get("source") != "slash"
]
from src.background_tool_jobs import background_result_context
messages = [part for message in messages for part in (
*background_result_context(message.get('metadata')), message,
)]
# Resume an interrupted thinking-only response from its actual model
# reasoning channel. Restrict this to the latest assistant message so
# old traces do not accumulate in context or cause reasoning loops.
for index in range(len(messages) - 1, -1, -1):
message = messages[index]
if message.get("role") != "assistant":
continue
metadata = message.get("metadata") or {}
thinking = str(metadata.get("thinking") or "").strip()
if metadata.get("stopped") and thinking:
resumed = dict(message)
resumed["reasoning_content"] = thinking
messages[index] = resumed
break
if not _history_grants_chat_session_approval(self.history, self.id):
return messages
# Keep the grant close to the latest user request so route-neutral
# compaction/trimming preserves it. Copy the metadata instead of
# mutating the durable transcript object.
for index in range(len(messages) - 1, -1, -1):
if messages[index].get("role") != "user":
continue
message = dict(messages[index])
metadata = dict(message.get("metadata") or {})
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
message["metadata"] = metadata
messages[index] = message
break
return messages
"""Get messages in format for LLM API."""
return [msg.to_dict() for msg in self.history]
def get(self, key: str, default=None):
"""Dict-like access for compatibility."""
return getattr(self, key, default)
def __getitem__(self, key: str):
"""Allow session['field'] syntax."""
return getattr(self, key)
+9 -258
View File
@@ -14,26 +14,13 @@ Design rules:
from __future__ import annotations
import os
import ntpath
import shutil
import subprocess
from pathlib import Path
import sys
from typing import List, Optional
import platform
IS_WINDOWS = os.name == "nt"
IS_POSIX = not IS_WINDOWS
# Allows APFEL support and ARM-native binary recommendations on Apple Silicon Macs.
IS_APPLE_SILICON = (
IS_POSIX
and platform.system() == "Darwin"
and platform.machine().lower()
in {
"arm64",
"aarch64",
}
)
# ── File permissions ────────────────────────────────────────────────────────
@@ -65,8 +52,9 @@ def detached_popen_kwargs() -> dict:
and is detached from any console.
"""
if IS_WINDOWS:
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr(
subprocess, "DETACHED_PROCESS", 0x00000008
flags = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
)
return {"creationflags": flags}
return {"start_new_session": True}
@@ -146,89 +134,11 @@ _BASH_CACHE: Optional[str] = None
_BASH_PROBED = False
# Common Git-for-Windows install locations to probe when bash isn't on PATH.
_WINDOWS_BASH_ROOT_ENV_VARS = (
"ProgramFiles",
"ProgramW6432",
"ProgramFiles(x86)",
"LocalAppData",
_WINDOWS_BASH_FALLBACKS = (
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
)
_WINDOWS_BASH_DEFAULT_ROOTS = (
r"C:\Program Files\Git",
r"C:\Program Files (x86)\Git",
)
_WINDOWS_BASH_RELATIVE_PATHS = (
("bin", "bash.exe"),
("usr", "bin", "bash.exe"),
)
# Paths to add to the remote SSH probe command to find tools like nvidia-smi that may not be on PATH.
_SSH_PATH_MEMBERS = (
"/usr/bin",
"/usr/local/bin",
"/usr/local/cuda/bin",
"/usr/lib/wsl/lib"
)
# Fallback locations for nvidia-smi on WSL and other Linux distros where it may not be on PATH.
NVIDIA_PATH_CANDIDATES = (
"/usr/bin/nvidia-smi",
"/usr/local/bin/nvidia-smi",
"/usr/local/cuda/bin/nvidia-smi",
"/usr/lib/wsl/lib/nvidia-smi",
)
def _ssh_path_override() -> str:
"""Build the PATH export snippet used for remote SSH shell probes."""
return f"export PATH=\"$PATH:{':'.join(_SSH_PATH_MEMBERS)}\"; "
SSH_PATH_OVERRIDE = _ssh_path_override()
def _windows_bash_fallbacks() -> List[str]:
roots: List[str] = []
for env_name in _WINDOWS_BASH_ROOT_ENV_VARS:
base = os.environ.get(env_name)
if base:
roots.append(ntpath.join(base, "Git"))
if env_name == "LocalAppData":
roots.append(ntpath.join(base, "Programs", "Git"))
roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS)
paths: List[str] = []
seen = set()
for root in roots:
for rel in _WINDOWS_BASH_RELATIVE_PATHS:
path = ntpath.join(root, *rel)
key = path.lower()
if key not in seen:
seen.add(key)
paths.append(path)
return paths
def _is_windows_bash_stub(path: str) -> bool:
lowered = path.lower()
return (
"system32\\bash.exe" in lowered
or "sysnative\\bash.exe" in lowered
or "windowsapps\\bash.exe" in lowered
)
def git_bash_path(path: str | Path) -> str:
"""Convert a path to POSIX style suitable for Git Bash on Windows.
Transforms drive letters (e.g., 'C:\\path') to POSIX '/c/path',
and uses forward slashes.
"""
p = Path(path)
p_str = p.as_posix()
if IS_WINDOWS and len(p_str) >= 2 and p_str[1] == ":":
drive = p_str[0].lower()
return f"/{drive}{p_str[2:]}"
return p_str
def find_bash() -> Optional[str]:
@@ -243,11 +153,9 @@ def find_bash() -> Optional[str]:
if _BASH_PROBED:
return _BASH_CACHE
_BASH_PROBED = True
found = which_tool("bash")
if found and IS_WINDOWS and _is_windows_bash_stub(found):
found = None
found = shutil.which("bash")
if not found and IS_WINDOWS:
for cand in _windows_bash_fallbacks():
for cand in _WINDOWS_BASH_FALLBACKS:
if os.path.exists(cand):
found = cand
break
@@ -293,160 +201,3 @@ def run_script_argv(script_path) -> List[str]:
comspec = os.environ.get("ComSpec", "cmd.exe")
return [comspec, "/c", str(script_path)]
return ["sh", str(script_path)]
def is_wsl() -> bool:
"""True if running inside Windows Subsystem for Linux (WSL)."""
import sys
if sys.platform.startswith("linux") or os.name == "posix":
try:
with open("/proc/version", "r", encoding="utf-8", errors="ignore") as f:
if "microsoft" in f.read().lower():
return True
except Exception:
pass
return False
def translate_path(path_str: str) -> str:
"""Translate a path (possibly a Windows path) to the current OS format.
Particularly handles Windows paths (e.g. C:\\foo or C:/foo) when running
under WSL, translating them to /mnt/c/foo.
Also handles standard path normalization to avoid string breakages.
"""
if not path_str:
return path_str
if is_wsl():
path_str = path_str.replace("\\", "/")
import re
m = re.match(r"^([a-zA-Z]):(.*)", path_str)
if m:
drive = m.group(1).lower()
rest = m.group(2)
if not rest.startswith("/"):
rest = "/" + rest
return f"/mnt/{drive}{rest}"
try:
return str(Path(path_str).resolve())
except Exception:
return path_str
def get_wsl_windows_user_profile() -> Optional[str]:
"""Retrieve the Windows host User Profile path from inside WSL."""
if not is_wsl():
return None
try:
r = run_wsl_windows_powershell("Write-Output $env:USERPROFILE", timeout=5)
if r.returncode == 0 and r.stdout.strip():
return translate_path(r.stdout.strip())
except Exception:
pass
try:
users_dir = "/mnt/c/Users"
if os.path.isdir(users_dir):
for entry in os.listdir(users_dir):
if entry not in ("All Users", "Default", "Default User", "desktop.ini", "Public"):
path = os.path.join(users_dir, entry)
if os.path.isdir(path):
return path
except Exception:
pass
return None
def _ssh_exec_argv(
remote: str,
ssh_port: str | None,
*,
remote_cmd: str | None = None,
connect_timeout: int | None = None,
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)}"])
if strict_host_key_checking is not None:
argv.extend(
[
"-o",
"StrictHostKeyChecking=yes"
if strict_host_key_checking
else "StrictHostKeyChecking=no",
]
)
if ssh_port and ssh_port != "22":
argv.extend(["-p", str(ssh_port)])
argv.append(remote)
if remote_cmd is not None:
argv.append(remote_cmd)
return argv
def run_ssh_command(
remote: str,
ssh_port: str | None,
remote_cmd: str,
*,
timeout: float,
connect_timeout: int | None = None,
strict_host_key_checking: bool | None = None,
text: bool = True,
) -> subprocess.CompletedProcess:
"""Run an ssh command with centralized timeout and stderr/stdout capture."""
return subprocess.run(
_ssh_exec_argv(
remote,
ssh_port,
remote_cmd=remote_cmd,
connect_timeout=connect_timeout,
strict_host_key_checking=strict_host_key_checking,
),
timeout=timeout,
capture_output=True,
text=text,
)
def _windows_powershell_argv(
command: str,
*,
no_profile: bool = True,
non_interactive: bool = True,
) -> List[str]:
argv: List[str] = ["powershell.exe"]
if no_profile:
argv.append("-NoProfile")
if non_interactive:
argv.append("-NonInteractive")
argv.extend(["-Command", command])
return argv
def run_wsl_windows_powershell(
command: str,
*,
timeout: float = 5,
) -> subprocess.CompletedProcess[str]:
"""Run a PowerShell command on the Windows host from WSL.
Raises ``RuntimeError`` when called outside WSL.
"""
if not is_wsl():
raise RuntimeError("run_wsl_windows_powershell is only supported in WSL")
return subprocess.run(
_windows_powershell_argv(command),
capture_output=True,
text=True,
timeout=timeout,
)
+45 -237
View File
@@ -14,15 +14,8 @@ import logging
from datetime import datetime, timezone, timedelta
from typing import Dict, Optional
from sqlalchemy import func
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal
from .models import Session, ChatMessage
from src.attachment_refs import persistable_message_content
from src.upload_handler import reserve_message_upload_references
# Re-export singleton accessors from models for convenience
from .models import set_session_manager_instance, get_session_manager_instance
logger = logging.getLogger(__name__)
@@ -36,32 +29,6 @@ def _message_timestamp_iso(value: Optional[datetime]) -> Optional[str]:
return value.isoformat().replace("+00:00", "Z")
def _parse_msg_content(raw):
"""Parse message content from DB — deserialises JSON arrays back to lists
(multimodal content with image/audio attachments)."""
if isinstance(raw, list):
return raw
if isinstance(raw, str) and raw.startswith('[{') and '"type"' in raw:
try:
parsed = json.loads(raw)
# Only treat as serialized multimodal content when EVERY element is
# a dict whose "type" is a recognized content-block kind. Otherwise a
# plain text message that merely *looks* like a JSON array of objects
# (e.g. a user pasting an API schema/sample with a "type" field) was
# silently parsed back into a list, destroying the original string.
_BLOCK_TYPES = {
"text", "image", "image_url", "audio", "input_audio",
"input_image", "document", "file",
}
if (isinstance(parsed, list) and parsed
and all(isinstance(p, dict) and p.get("type") in _BLOCK_TYPES
for p in parsed)):
return parsed
except (json.JSONDecodeError, ValueError):
pass
return raw
class SessionManager:
"""
Manages chat sessions with database persistence.
@@ -76,7 +43,6 @@ class SessionManager:
def __init__(self, sessions_file: str = None):
# sessions_file kept for backward compat, not used
self.sessions: Dict[str, Session] = {}
self.upload_handler = None
self.load_sessions()
# ------------------------------------------------------------------
@@ -94,28 +60,14 @@ class SessionManager:
try:
db_sessions = db.query(DbSession).filter(
DbSession.archived == False,
DbSession.messages.any(),
DbSession.message_count > 0,
).order_by(DbSession.last_accessed.desc()).limit(100).all()
# message_count is derived metadata and can drift after interrupted
# or legacy writes. Count only the bounded discovery set so startup
# remains metadata-only while lazy hydration sees an authoritative
# positive count for every discovered non-empty session.
message_counts = {}
if db_sessions:
message_counts = dict(
db.query(DbChatMessage.session_id, func.count(DbChatMessage.id))
.filter(DbChatMessage.session_id.in_([row.id for row in db_sessions]))
.group_by(DbChatMessage.session_id)
.all()
)
loaded_count = 0
for db_session in db_sessions:
try:
session = self._db_to_session_meta(db_session)
if session is not None:
session.message_count = message_counts[db_session.id]
self.sessions[db_session.id] = session
loaded_count += 1
except Exception as e:
@@ -150,12 +102,6 @@ class SessionManager:
history=[],
owner=getattr(db_session, "owner", None),
is_important=getattr(db_session, "is_important", False) or False,
memory_extraction_enabled=getattr(db_session, "memory_extraction_enabled", True) is not False,
skill_injection_enabled=getattr(db_session, "skill_injection_enabled", True) is not False,
thinking_mode=getattr(db_session, "thinking_mode", "") or "off",
temperature_override=getattr(db_session, "temperature_override", None),
max_tokens_override=getattr(db_session, "max_tokens_override", None),
cwd=getattr(db_session, "cwd", None) or None,
)
session.message_count = getattr(db_session, "message_count", 0) or 0
return session
@@ -173,7 +119,7 @@ class SessionManager:
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
history.append(ChatMessage(
role=db_msg.role,
content=_parse_msg_content(db_msg.content),
content=db_msg.content,
metadata=meta,
))
else:
@@ -188,7 +134,7 @@ class SessionManager:
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
history.append(ChatMessage(
role=db_msg.role,
content=_parse_msg_content(db_msg.content),
content=db_msg.content,
metadata=meta,
))
@@ -214,20 +160,9 @@ class SessionManager:
history=history,
owner=getattr(db_session, 'owner', None),
is_important=getattr(db_session, 'is_important', False) or False,
memory_extraction_enabled=getattr(db_session, 'memory_extraction_enabled', True) is not False,
skill_injection_enabled=getattr(db_session, 'skill_injection_enabled', True) is not False,
thinking_mode=getattr(db_session, "thinking_mode", "") or "off",
temperature_override=getattr(db_session, "temperature_override", None),
max_tokens_override=getattr(db_session, "max_tokens_override", None),
cwd=getattr(db_session, "cwd", None) or None,
)
# The rows just loaded are the whole transcript, so they — not the
# denormalized sessions.message_count column — are the truth for this
# cached object. get_session's hydration gate compares against this
# number; seeding it from a drifted column would ask for a reload that
# can never close the gap.
session.message_count = len(history)
session.message_count = getattr(db_session, 'message_count', len(history))
return session
# ------------------------------------------------------------------
@@ -238,17 +173,12 @@ class SessionManager:
"""
Add a message to a session and persist to database.
Updates the authoritative history list and persists through this
manager directly so tests and temporary managers do not depend on the
process-wide session-manager singleton.
Args:
session_id: Session ID
message: ChatMessage to add
"""
session = self.get_session(session_id)
session.history.append(message)
session._history = session.history
session.message_count = len(session.history)
self._persist_message(session_id, message)
@@ -257,55 +187,30 @@ class SessionManager:
"""Persist a single message to the database."""
db = SessionLocal()
try:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session is None:
# A stream/tool callback can outlive a session delete. Do not
# create a chat_messages row with no parent session; also drop
# any stale cached session so later writes fail closed too.
self.sessions.pop(session_id, None)
logger.warning("Dropping message for deleted session %s", session_id)
return
missing_upload_id = reserve_message_upload_references(
getattr(self, "upload_handler", None),
getattr(db_session, "owner", None),
message.content,
message.metadata,
)
if missing_upload_id:
raise ValueError(
f"Referenced upload is no longer available: {missing_upload_id}"
)
msg_id = str(uuid.uuid4())
msg_time = datetime.utcnow()
if message.metadata is None:
message.metadata = {}
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
# Multimodal content may contain provider data URLs for the live
# model call. Persist only readable text plus attachment references
# so chat_messages/FTS do not duplicate upload bytes.
_content = persistable_message_content(message.content, message.metadata)
db_message = DbChatMessage(
id=msg_id,
session_id=session_id,
role=message.role,
content=_content,
content=message.content,
meta_data=json.dumps(message.metadata) if message.metadata else None,
timestamp=msg_time,
)
db.add(db_message)
if session_id in self.sessions:
db_session.message_count = len(self.sessions[session_id].history)
else:
db_session.message_count = 0
_now = datetime.now(timezone.utc)
db_session.last_accessed = _now
# Clean "last conversation" timestamp — only bumped here on a
# real message persist, so it powers an accurate "Last active"
# sort that ignores renames / model swaps / mere opens.
db_session.last_message_at = _now
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session:
db_session.message_count = len(self.sessions.get(session_id, {}).history) if session_id in self.sessions else 0
_now = datetime.now(timezone.utc)
db_session.last_accessed = _now
# Clean "last conversation" timestamp — only bumped here on a
# real message persist, so it powers an accurate "Last active"
# sort that ignores renames / model swaps / mere opens.
db_session.last_message_at = _now
db.commit()
@@ -340,17 +245,13 @@ class SessionManager:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session:
# keep_count can exceed the real message total (e.g. the AI tool
# defaults to keep_count=10 on a short session); message_count must
# track the rows that actually remain, not the requested cap.
db_session.message_count = min(keep_count, len(db_messages))
db_session.message_count = keep_count
db_session.updated_at = datetime.now(timezone.utc)
db.commit()
# Update in-memory
session.history = session.history[:keep_count]
session._history = session.history
logger.info(f"Truncated session {session_id} to {keep_count} messages")
return True
@@ -367,28 +268,6 @@ class SessionManager:
session = self.get_session(session_id)
db = SessionLocal()
try:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session is None:
logger.warning("Cannot replace history for missing session %s", session_id)
return False
# Reserve every incoming attachment before removing any durable
# message row. reserve_upload() shares the upload lifecycle lock
# with cleanup, so an upload cannot be deleted between this
# ownership check/access touch and the replacement transaction.
# A failed reservation must leave the existing transcript intact.
for message in messages:
missing_upload_id = reserve_message_upload_references(
getattr(self, "upload_handler", None),
getattr(db_session, "owner", None),
message.content,
message.metadata,
)
if missing_upload_id:
raise ValueError(
f"Referenced upload is no longer available: {missing_upload_id}"
)
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
now = datetime.now(timezone.utc)
for i, message in enumerate(messages):
@@ -397,9 +276,7 @@ class SessionManager:
id=msg_id,
session_id=session_id,
role=message.role,
# Mirrors _persist_message: keep raw media bytes out of the
# persisted transcript and search index.
content=persistable_message_content(message.content, message.metadata),
content=message.content,
meta_data=json.dumps(message.metadata) if message.metadata else None,
timestamp=now + timedelta(microseconds=i),
)
@@ -408,14 +285,15 @@ class SessionManager:
message.metadata = {}
message.metadata["_db_id"] = msg_id
db_session.message_count = len(messages)
db_session.updated_at = now
db_session.last_accessed = now
db_session.last_message_at = now
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session:
db_session.message_count = len(messages)
db_session.updated_at = now
db_session.last_accessed = now
db_session.last_message_at = now
db.commit()
session.history = list(messages)
session._history = session.history
session.message_count = len(messages)
logger.info("Replaced session %s history with %d messages", session_id, len(messages))
return True
@@ -431,50 +309,30 @@ class SessionManager:
# ------------------------------------------------------------------
def get_session(self, session_id: str) -> Session:
"""Get a session by ID, loading complete DB history when needed.
"""Get a session by ID, loading from DB if needed.
Sessions seeded by ``load_sessions`` start with empty history, and a
cached session can also become partially stale. Refresh metadata first,
then hydrate whenever the cached transcript is short of the stored rows.
Model-send routes enter through this method before building context,
while paginated display history reads SQLite directly.
The gate compares against ``sync_session_metadata``'s reconciled count
(the real ``chat_messages`` total), never the denormalized column, so a
hydrate always closes the gap and the next read is a cache hit.
Sessions seeded by `load_sessions` start with empty history. The
first read here hydrates them with the message rows.
"""
if session_id not in self.sessions:
self._load_session_from_db(session_id)
else:
cached = self.sessions[session_id]
# Lazy hydrate: metadata-only entries get their messages on first read.
if not cached.history and getattr(cached, "message_count", 0) > 0:
self._load_session_from_db(session_id)
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
# DB row while a session object is still cached in RAM. Refreshing first
# also exposes the authoritative message count before completeness is
# checked.
# DB row while a session object is still cached in RAM.
self.sync_session_metadata(session_id)
cached = self.sessions[session_id]
cached_count = len(cached.history or [])
stored_count = int(getattr(cached, "message_count", 0) or 0)
if cached_count < stored_count:
self._load_session_from_db(session_id)
# Update last_accessed
self._touch_session(session_id)
return self.sessions[session_id]
def sync_session_metadata(self, session_id: str) -> bool:
"""Refresh non-message session fields from the DB into the cached object.
``message_count`` is reconciled against the real ``chat_messages`` rows
rather than copied from the denormalized ``sessions.message_count``
column. That column drifts in normal operation — ``_persist_message``
swallows a failed insert but ``add_message`` has already appended in
memory, so the next successful persist writes rows+1, and a persist for
an uncached session writes 0. Hydration keys off this number: a
drifted-high column would reload the whole transcript on every warm
read, and a drifted-low one would leave the model a truncated one.
"""
"""Refresh non-message session fields from the DB into the cached object."""
session = self.sessions.get(session_id)
if session is None:
return False
@@ -497,12 +355,7 @@ class SessionManager:
session.archived = db_session.archived
session.owner = getattr(db_session, "owner", None)
session.is_important = getattr(db_session, "is_important", False) or False
session.cwd = getattr(db_session, "cwd", None) or None
session.message_count = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.count()
)
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
return True
except Exception as e:
logger.error(f"Error syncing session metadata {session_id}: {e}")
@@ -558,12 +411,9 @@ class SessionManager:
endpoint_url: str,
model: str,
rag: bool = False,
owner: str = None,
cwd: str = None,
headers: Optional[Dict[str, str]] = None,
owner: str = None
) -> Session:
"""Create a new session and save to database."""
session_headers = dict(headers or {})
db = SessionLocal()
try:
db_session = DbSession(
@@ -572,9 +422,8 @@ class SessionManager:
endpoint_url=endpoint_url,
model=model,
rag=rag,
headers=session_headers,
headers={},
owner=owner,
cwd=cwd or None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@@ -587,9 +436,8 @@ class SessionManager:
endpoint_url=endpoint_url,
model=model,
rag=rag,
headers=session_headers,
headers={},
owner=owner,
cwd=cwd or None,
)
self.sessions[session_id] = session
@@ -606,12 +454,6 @@ class SessionManager:
"""Permanently delete a session and all its messages."""
db = SessionLocal()
try:
try:
from src.session_image_cleanup import cleanup_session_images
cleanup_session_images(session_id, db=db)
except Exception as e:
logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
# Detach documents so they survive as orphans in the library
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
{DbDocument.session_id: None}, synchronize_session=False
@@ -624,17 +466,11 @@ class SessionManager:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session:
db.delete(db_session)
# Drop the in-memory copy even when there is no DB row. A "ghost"
# session lives only here (never persisted, or its row was removed
# out-of-band); without this it can never be cleared and keeps
# 404ing on every operation (issue #1044).
removed_in_memory = self.sessions.pop(session_id, None) is not None
if db_session or removed_in_memory:
# Commit the document-detach / message-delete above (a no-op when
# the ghost had no rows) together with the session delete.
db.commit()
if session_id in self.sessions:
del self.sessions[session_id]
logger.info(f"Deleted session {session_id}")
return True
return False
@@ -727,52 +563,24 @@ class SessionManager:
def save_sessions(self):
"""No-op for DB compatibility."""
def ensure_task_session(self, session_id: str, name: str, endpoint_url: str, model: str, owner: str = None, task: object = None) -> Session:
"""Create a task session if it doesn't exist, or return the existing one.
Unlike create_session, this checks the cache first and does NOT
overwrite an existing in-memory session. The task scheduler must
use this instead of direct dict assignment.
"""
if session_id in self.sessions:
return self.sessions[session_id]
session = self.create_session(session_id, name, endpoint_url, model, owner=owner)
if task is not None:
task.session_id = session_id
return session
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
def cleanup_empty_sessions(self, auto_archive_days: int = 30, min_age_hours: int = 1) -> dict:
"""Clean up empty and old sessions.
Args:
auto_archive_days: Age in days before non-important sessions are archived.
min_age_hours: Minimum age in hours before an empty session can be deleted.
Prevents deleting sessions that were just created.
"""
def cleanup_empty_sessions(self, auto_archive_days: int = 30) -> dict:
"""Clean up empty and old sessions."""
db = SessionLocal()
stats = {'deleted_empty': 0, 'archived_old': 0, 'total_checked': 0}
try:
all_sessions = db.query(DbSession).all()
cutoff_date = utcnow_naive() - timedelta(days=auto_archive_days)
min_age = utcnow_naive() - timedelta(hours=min_age_hours)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=auto_archive_days)
for db_session in all_sessions:
stats['total_checked'] += 1
# Delete empty sessions only if older than min_age_hours
# Delete empty sessions
if db_session.message_count == 0:
if db_session.created_at is not None:
created = db_session.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created > min_age:
continue # Too young to delete
if db_session.id in self.sessions:
del self.sessions[db_session.id]
db.delete(db_session)
-200
View File
@@ -1,200 +0,0 @@
# Standalone AMD ROCm GPU Compose file for stack-management UIs (Portainer,
# Coolify, Dockhand, etc.) that accept only a single Compose file and do not
# reliably honor COMPOSE_FILE or multiple `-f` overlays.
#
# This is equivalent to: docker-compose.yml + docker/gpu.amd.yml.
# The base docker-compose.yml plus the docker/gpu.amd.yml overlay remain the
# source of truth — CLI users should keep using the COMPOSE_FILE overlay
# workflow. Keep this file in sync with both when either changes.
#
# Requires ROCm drivers on the host (kfd + DRI devices) and the host user
# running Docker in the `video` and `render` groups. Set RENDER_GID to your
# host's numeric render group id when needed. See docker/gpu.amd.yml for details.
services:
odysseus:
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7011}:7000"
volumes:
- ${APP_DATA_DIR:-./data}:/app/data:z
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
- "host.docker.internal:host-gateway"
environment:
- LLM_HOST=${LLM_HOST:-localhost}
- LLM_HOSTS=${LLM_HOSTS:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- RESEARCH_LLM_ENDPOINT=${RESEARCH_LLM_ENDPOINT:-}
- HF_TOKEN=${HF_TOKEN:-}
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
- SEARXNG_INSTANCE=http://searxng:8080
- CHROMADB_HOST=chromadb
- CHROMADB_PORT=8000
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_UNATTENDED_MODE=${ODYSSEUS_UNATTENDED_MODE:-false}
- ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS=${ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS:-1}
- ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT=${ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT:-0}
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- 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_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
# Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
# running uvicorn (entrypoint also chowns /app/data + /app/logs
# to match, so bind-mounted files stay editable from the host).
# 1000 is the default first user on most Linux installs. If your
# host user has a different id, override here or via .env, e.g.:
# PUID=1001
# PGID=1001
# Find yours with: id -u / id -g
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
depends_on:
searxng:
condition: service_healthy
chromadb:
condition: service_started
restart: unless-stopped
# AMD ROCm overlay (from docker/gpu.amd.yml).
devices:
- /dev/kfd
- /dev/dri
group_add:
- video
- ${RENDER_GID:-render}
chromadb:
image: docker.io/chromadb/chroma:latest
ports:
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
volumes:
- chromadb-data:/chroma/chroma
environment:
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
entrypoint:
- /bin/sh
- -c
- |
set -eu
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
secret="$${SEARXNG_SECRET:-}"
if [ -z "$$secret" ]; then
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
timeout: 6s
retries: 20
start_period: 10s
restart: unless-stopped
ntfy:
image: docker.io/binwiederhier/ntfy
command: serve
ports:
- "${NTFY_BIND:-127.0.0.1}:8091:80"
volumes:
- ntfy-cache:/var/cache/ntfy
environment:
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://localhost:8091}
restart: unless-stopped
volumes:
searxng-data:
chromadb-data:
ntfy-cache:
-203
View File
@@ -1,203 +0,0 @@
# Standalone NVIDIA GPU Compose file for stack-management UIs (Portainer,
# Coolify, Dockhand, etc.) that accept only a single Compose file and do not
# reliably honor COMPOSE_FILE or multiple `-f` overlays.
#
# This is equivalent to: docker-compose.yml + docker/gpu.nvidia.yml.
# The base docker-compose.yml plus the docker/gpu.nvidia.yml overlay remain
# the source of truth — CLI users should keep using the COMPOSE_FILE overlay
# workflow. Keep this file in sync with both when either changes.
#
# Requires the NVIDIA Container Toolkit on the host. See docker/gpu.nvidia.yml
# for setup details.
services:
odysseus:
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7011}:7000"
volumes:
- ${APP_DATA_DIR:-./data}:/app/data:z
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
- "host.docker.internal:host-gateway"
environment:
- LLM_HOST=${LLM_HOST:-localhost}
- LLM_HOSTS=${LLM_HOSTS:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- RESEARCH_LLM_ENDPOINT=${RESEARCH_LLM_ENDPOINT:-}
- HF_TOKEN=${HF_TOKEN:-}
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
- SEARXNG_INSTANCE=http://searxng:8080
- CHROMADB_HOST=chromadb
- CHROMADB_PORT=8000
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_UNATTENDED_MODE=${ODYSSEUS_UNATTENDED_MODE:-false}
- ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS=${ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS:-1}
- ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT=${ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT:-0}
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- 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_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
# Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
# running uvicorn (entrypoint also chowns /app/data + /app/logs
# to match, so bind-mounted files stay editable from the host).
# 1000 is the default first user on most Linux installs. If your
# host user has a different id, override here or via .env, e.g.:
# PUID=1001
# PGID=1001
# Find yours with: id -u / id -g
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
# NVIDIA overlay (from docker/gpu.nvidia.yml).
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
depends_on:
searxng:
condition: service_healthy
chromadb:
condition: service_started
restart: unless-stopped
# NVIDIA overlay (from docker/gpu.nvidia.yml).
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
chromadb:
image: docker.io/chromadb/chroma:latest
ports:
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
volumes:
- chromadb-data:/chroma/chroma
environment:
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
entrypoint:
- /bin/sh
- -c
- |
set -eu
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
secret="$${SEARXNG_SECRET:-}"
if [ -z "$$secret" ]; then
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
timeout: 6s
retries: 20
start_period: 10s
restart: unless-stopped
ntfy:
image: docker.io/binwiederhier/ntfy
command: serve
ports:
- "${NTFY_BIND:-127.0.0.1}:8091:80"
volumes:
- ntfy-cache:/var/cache/ntfy
environment:
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://localhost:8091}
restart: unless-stopped
volumes:
searxng-data:
chromadb-data:
ntfy-cache:
+10 -90
View File
@@ -2,86 +2,30 @@ services:
odysseus:
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7011}:7000"
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
volumes:
- ${APP_DATA_DIR:-./data}:/app/data:z
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
- ./data:/app/data
- ./logs:/app/logs
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
# add the shown public key to each remote server's authorized_keys.
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
- ./data/ssh:/app/.ssh
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
# container, so persist its HuggingFace cache under ./data/huggingface.
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
- ./data/huggingface:/app/.cache/huggingface
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
# land under /app/.local for the odysseus user. Persist them so a
# container recreate does not silently remove installed serve engines.
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
- ./data/local:/app/.local
extra_hosts:
# Lets the container reach local services on the Docker host, including
# Ollama at http://host.docker.internal:11434.
- "host.docker.internal:host-gateway"
env_file:
- .env
environment:
- LLM_HOST=${LLM_HOST:-localhost}
- LLM_HOSTS=${LLM_HOSTS:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
- RESEARCH_LLM_ENDPOINT=${RESEARCH_LLM_ENDPOINT:-}
- HF_TOKEN=${HF_TOKEN:-}
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
- SEARXNG_INSTANCE=http://searxng:8080
- CHROMADB_HOST=chromadb
- CHROMADB_PORT=8000
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
- ODYSSEUS_UNATTENDED_MODE=${ODYSSEUS_UNATTENDED_MODE:-false}
- ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS=${ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS:-1}
- ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT=${ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT:-0}
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- 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_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
# Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
# running uvicorn (entrypoint also chowns /app/data + /app/logs
# to match, so bind-mounted files stay editable from the host).
@@ -110,12 +54,7 @@ services:
restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
image: docker.io/searxng/searxng:latest
entrypoint:
- /bin/sh
- -c
@@ -128,34 +67,15 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env bash
# Build patched wheels for Real-ESRGAN's unmaintained dependencies.
#
# basicsr / gfpgan / facexlib (xinntao, last released 2022) read their version
# in setup.py with:
#
# exec(compile(f.read(), version_file, 'exec'))
# return locals()['__version__']
#
# Python 3.13+ implements PEP 667: locals() inside a function returns an
# independent snapshot that exec() can no longer mutate, so the read raises
# `KeyError: '__version__'` and the sdist build fails. That is why the Cookbook
# "install realesrgan" button dies on the python:3.14 image. The packages have
# no fixed release, so we patch get_version() to exec into an explicit namespace
# dict (works on every Python) and build wheels from the patched source.
#
# Usage: build-realesrgan-wheels.sh [OUTPUT_DIR] (default: /wheels)
set -euo pipefail
OUT="${1:-/wheels}"
mkdir -p "$OUT"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
cd "$work"
# Pinned to the versions Real-ESRGAN 0.3.0 resolves to.
SPECS="basicsr==1.4.2 gfpgan==1.3.8 facexlib==0.3.0"
for spec in $SPECS; do
name="${spec%%==*}"
ver="${spec##*==}"
# pip download builds metadata (and trips the same bug), so fetch the raw
# sdist URL from the PyPI JSON API instead.
url="$(python - "$name" "$ver" <<'PY'
import json, sys, urllib.request
name, ver = sys.argv[1], sys.argv[2]
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
for f in data["urls"]:
if f["packagetype"] == "sdist":
print(f["url"]); break
else:
sys.exit(f"no sdist found for {name}=={ver}")
PY
)"
echo ">> fetching ${name} ${ver}: ${url}"
curl -fsSL "$url" -o "${name}.tar.gz"
tar xzf "${name}.tar.gz"
done
echo ">> patching get_version()"
python - <<'PY'
import pathlib
old_exec = "exec(compile(f.read(), version_file, 'exec'))"
new_exec = "_ver_ns = {}\n exec(compile(f.read(), version_file, 'exec'), _ver_ns)"
old_ret = "return locals()['__version__']"
new_ret = "return _ver_ns['__version__']"
patched = 0
for setup in pathlib.Path(".").glob("*/setup.py"):
s = setup.read_text()
if old_exec in s and old_ret in s:
setup.write_text(s.replace(old_exec, new_exec).replace(old_ret, new_ret))
print(" patched", setup)
patched += 1
assert patched == 3, f"expected to patch 3 setup.py files, patched {patched}"
PY
echo ">> building wheels into ${OUT}"
pip wheel --no-deps -w "$OUT" ./basicsr-* ./gfpgan-* ./facexlib-*
ls -l "$OUT"
+18 -82
View File
@@ -13,8 +13,6 @@ set -e
PUID="${PUID:-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
# corresponds to one in /etc/passwd (e.g. when the image is rebuilt
@@ -26,78 +24,26 @@ if ! getent passwd "$PUID" >/dev/null 2>&1; then
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
fi
ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)"
[ -z "$ODY_USER" ] && ODY_USER=odysseus
# Docker-socket group plumbing for the explicit host-Docker overlay. When
# opted in, the socket is owned by root:<host docker gid>. Add the app user
# to that group and later call gosu by username so supplementary groups are
# retained.
DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}"
if [ "${ODYSSEUS_ENABLE_HOST_DOCKER:-}" = "true" ] && [ -S "$DOCKER_SOCK" ]; then
SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')"
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
groupadd -g "$SOCK_GID" docker_host || true
fi
SOCK_GROUP="$(getent group "$SOCK_GID" | cut -d: -f1)"
if [ -n "$SOCK_GROUP" ]; then
usermod -aG "$SOCK_GROUP" "$ODY_USER" 2>/dev/null || true
fi
fi
fi
mount_root_for() {
awk -v target="$1" '$5 == target { print $4; exit }' /proc/self/mountinfo 2>/dev/null || true
}
is_broad_mount_root() {
case "$1" in
/|/home|/srv|/var|/usr|/opt|/tmp|/mnt|/media)
return 0
;;
esac
return 1
}
repair_tree_ownership() {
dir="$1"
# Repair ownership on every writable path the app touches at runtime.
#
# 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)
# - services/search_analytics.json
# - services/search_engine_error.log
# - services/tts cache, etc.
# These dirs were created as root during `docker build`, so dropping
# to PUID:PGID would otherwise crash on the first import that tries
# to mkdir them. Chown the whole /app tree — fast (<1s on this size)
# and idempotent via the `-not -uid` filter so we only touch files
# that need fixing.
for dir in /app /app/data /app/logs; do
if [ -d "$dir" ]; then
find "$dir" -xdev -not -uid "$PUID" -print0 2>/dev/null \
# `find ... -not -uid` keeps this O(touched-files), not
# 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
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
# Cookbook installs vllm/etc. via `pip install --user`, which pulls
@@ -124,23 +70,13 @@ for cu in \
break
fi
done
# Disable the FlashInfer JIT sampler unconditionally — it is sampler-only
# and has no impact on the attention path, but requires nvcc + matching
# CUDA headers at startup. Without this, vLLM crashes with "Could not find
# nvcc" even when the GPU itself is fully visible to the container.
export VLLM_USE_FLASHINFER_SAMPLER="${VLLM_USE_FLASHINFER_SAMPLER:-0}"
# Make Cookbook-installed Python CLIs visible after `pip install --user`.
# vLLM and helper scripts land here because /app is the non-root user's HOME.
export PATH="/app/.local/bin:$PATH"
# 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.
# || true so a setup failure never prevents the container from starting.
"$GOSU_BIN" "$ODY_USER" "$PYTHON_BIN" /app/setup.py || true
# Drop root and run the actual app. `gosu` is preferred over `su` /
# `sudo` because it cleans up the process tree (no extra shell layer)
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
exec "$GOSU_BIN" "$ODY_USER" "$@"
exec gosu "$PUID:$PGID" "$@"
-1
View File
@@ -1,6 +1,5 @@
# AMD ROCm GPU overlay. Enable by setting COMPOSE_FILE in .env:
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
# RENDER_GID=<numeric output of: getent group render | cut -d: -f3>
#
# Requires ROCm drivers on the host (kfd + DRI devices). The host user
# running Docker must be in the `video` and `render` groups.
-5
View File
@@ -1,11 +1,6 @@
# NVIDIA GPU overlay. Enable by setting COMPOSE_FILE in .env:
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
#
# Use scripts/check-docker-gpu.sh to diagnose GPU passthrough, optionally
# install the NVIDIA Container Toolkit (Ubuntu/Debian), and write COMPOSE_FILE
# to .env. The script is read-only by default — it installs nothing and never
# edits .env unless explicitly asked.
#
# Requires the NVIDIA Container Toolkit on the host.
# Arch: sudo pacman -S nvidia-container-toolkit
# Debian: sudo apt install nvidia-container-toolkit
-12
View File
@@ -1,12 +0,0 @@
# High-trust host Docker access. Enable only when local Docker-daemon
# management from Cookbook is required and you accept that raw socket access
# grants broad control over the host Docker daemon.
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
# DOCKER_GID=<numeric host Docker group id>
services:
odysseus:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
group_add: ["${DOCKER_GID:-963}"]
environment:
- ODYSSEUS_ENABLE_HOST_DOCKER=true
-21
View File
@@ -1,21 +0,0 @@
# High-trust host network access. Enable only when the Odysseus agent needs
# host-native LAN/VPN/mDNS behavior that Docker bridge networking cannot
# provide. Linux only; Docker Desktop does not provide equivalent host
# networking semantics.
# COMPOSE_FILE=docker-compose.yml:docker/host-workspace.yml:docker/host-network.yml
# APP_PORT=7011
services:
odysseus:
network_mode: host
ports: !reset []
environment:
- APP_PORT=${APP_PORT:-7011}
- APP_BIND=${APP_BIND:-0.0.0.0}
- SEARXNG_INSTANCE=${ODYSSEUS_HOST_NETWORK_SEARXNG_INSTANCE:-http://127.0.0.1:8080}
- CHROMADB_HOST=${ODYSSEUS_HOST_NETWORK_CHROMADB_HOST:-127.0.0.1}
- CHROMADB_PORT=${ODYSSEUS_HOST_NETWORK_CHROMADB_PORT:-8100}
- ODYSSEUS_CONTAINER_NETWORK_MODE=host
command:
- sh
- -c
- exec uvicorn app:app --host "$${APP_BIND:-0.0.0.0}" --port "$${APP_PORT:-7011}"
-11
View File
@@ -1,11 +0,0 @@
# High-trust host workspace access. Enable only when the Odysseus agent should
# work on a host directory outside the container's normal /app/data sandbox.
# COMPOSE_FILE=docker-compose.yml:docker/host-workspace.yml
# ODYSSEUS_HOST_WORKSPACE_DIR=/absolute/host/path
# ODYSSEUS_HOST_WORKSPACE_MOUNT=/host/workspace
services:
odysseus:
volumes:
- ${ODYSSEUS_HOST_WORKSPACE_DIR:?set ODYSSEUS_HOST_WORKSPACE_DIR}:${ODYSSEUS_HOST_WORKSPACE_MOUNT:-/host/workspace}:rw,z
environment:
- ODYSSEUS_HOST_WORKSPACE_MOUNT=${ODYSSEUS_HOST_WORKSPACE_MOUNT:-/host/workspace}
-75
View File
@@ -1,75 +0,0 @@
# Agent turn contract
Scope: product Agent turns on 7011. Environment-owned native/TUI bridges retain
their existing execution contract. No model weights or training settings change.
## Boundaries
1. `src/turn_contract.py` classifies capabilities, including explicit compound
requests and referential follow-ups. Classification is selection, not permission.
2. `routes/chat_routes.py` resolves toggles, privileges, global/plan/incognito
restrictions, fixture restrictions and available schema inventory before
freezing the offered set. Web enabled alone does not select web tools.
3. `TurnContract` checks `required <= offered <= executable`, stores immutable
serialized schema copies, and records unavailable requirements. An unavailable
request stops without inference or substitution; unknown actions ask for clarity.
Exact account-discovery requests narrow selection to account metadata only;
compounds retain their declared family scope. Media operations declare their
existing tool dependencies rather than falling back to shell generation.
4. The agent's prompt/schema route and fallback use that same logical scope.
Native versus textual serialization remains model-specific. Answer-only phases
can suppress tool calls without granting a different scope.
Contract turns preserve the already-compacted conversation and tool-call/result
IDs. The standalone specialist prompt's latest-message-only behavior is not used
for these product turns. Prompt domains also come from the contract.
Accepted in-scope calls retain their model-provided arguments and native IDs;
the explicit-intent fallback must not overwrite them with the whole user turn.
5. The context-bound dispatcher checks membership **and** existing runtime policy,
owner restrictions and exact-action approvals. A contract is not authorization
to bypass those gates. Contract work bypasses terminating legacy shortcuts.
6. `_AgentRenderState` explicitly identifies streamed versus canonical output.
Later synthesis transfers ownership with turn-scoped replacement. The frontend
reconciles visible DOM, not just accumulated strings; tool evidence is retained.
Ownership is included in saved metrics and `message_saved` events.
History and resume honor replacement scope. Single-capability turns retain
canonical output: an always-synthesize trial caused a live notes loop and was
reverted. Compound turns cannot terminate after only one capability's result.
## Verification
Use the project's configured Python environment, not an unrelated system Python:
```sh
/home/pewds/odysseus-cookbook-fresh/.venv/bin/python -m pytest -q \
tests/test_turn_contract.py tests/test_turn_contract_integration.py \
tests/test_agent_turn_contract_boundaries.py tests/test_turn_rendering_js.py \
tests/test_contract_prompt_conversation.py tests/test_product_turn_contract_route.py \
tests/test_contract_explicit_fallback.py \
tests/test_history_resume_rendering_js.py \
tests/test_chat_route_tool_policy.py tests/test_tool_policy.py \
tests/test_frontend_module_version_parity.py
node scripts/verify_agent_turn_contract.mjs --max-turns 80 --total-ms 900000
```
The browser verifier uses `sft_alex_creator` and actual 7011 Agent controls. It
captures request toggles, SSE contract/tool events, visible output and persisted
history. Ten families have four initial/follow-up Web-toggle combinations.
Blocked or unrun cases are not passes. Email requires verified fixture isolation;
do not enable global fixture mode on the user's live service to make a test pass.
## Remaining limits
- Classification is deterministic and vocabulary-based, not a proof of semantic
understanding. Add independent behavior examples for confirmed misses.
- Schema registration and policy permission do not guarantee a remote provider
stays healthy throughout a turn. Runtime failure must remain visible.
- Separate tool/argument errors, tool-service failures, rendering failures and
verifier defects in reports. Do not infer model accuracy from routing alone.
- Canonical summaries can still ignore presentation constraints such as a
requested item count. Do not count those as full functional passes. Forcing an
extra model round is not a validated general repair for this deployed model.
- Keep all imports of a local JS module on the same URL identity. Distinct query
versions instantiate separate module state even when source files are identical.
Live baseline and current matrix results are in `reports/agent-turn-contract-*`.
The implementation is not a claim that every family has passed live verification.
-55
View File
@@ -1,55 +0,0 @@
# Background research → originating chat
Chat `trigger_research` calls carry a **dispatcher-supplied** `origin_chat_id`.
The research start route verifies chat ownership before registering a durable
`background_tool_jobs` row and starting the existing research service. Panel
jobs have no origin and never inject a chat reply.
- Chat default: **2 rounds**, 120-second *soft* research budget. Explicit
deeper/Auto rounds regain the normal research time budget. Panel defaults
remain unchanged. This is not a guaranteed two-minute wall-clock deadline.
- A completion callback stores the report and sources. A startup worker also
reconciles missed callbacks and research errors/restarts.
- When the origin has no active foreground/detached run, its model summarizes
the report with thinking off and no tools. An outer 75-second deadline also
bounds model-slot waits. If synthesis is unavailable, deliver an honest
notice plus the report link; preserve the evidence for follow-ups.
- Message and delivery marker commit in one transaction with a deterministic
message ID. Report context is stored in server message metadata and injected
as untrusted evidence in regular and compact model history. Long excerpts
are explicitly marked; the saved full research report remains accessible.
- The browser polls owner-scoped `/api/research/chat-jobs/{chat_id}`, appending
unseen message IDs only when that chat is current and not streaming. No
transcript replacement or forced navigation. Reloaded history deduplicates.
- Chat uses the existing agent-thread rail and expandable rows. The compact
header shows status and a right-aligned BG task label with the shared whirlpool
while running; expanding reveals topic, phase/round, source count and report
link. Rows update in place, preserving expansion/focus while chat streams.
Completed rows remain visible; zero-source runs show a warning, not success.
Progress polling excludes reports and internal fields.
Other tools are **not automatically backgrounded**. The durable handoff can be
reused, but each future producer needs explicit launch/result/permission wiring.
## Verification
```sh
/home/pewds/odysseus-cookbook-fresh/.venv/bin/pytest -q tests/test_background_tool_jobs.py tests/test_research_chat_runtime.py
node --test tests/backgroundToolJobs.test.mjs
node scripts/verify_background_delivery_isolation.mjs
node scripts/verify_background_research_cards.mjs
node scripts/verify_background_research_chat.mjs
```
The last script uses disposable `sft_alex_creator` chats and real research/model
calls, then removes only its own reports/chats. Do not use real-user mutations.
It checks two-round launch, continued chat, automatic arrival, no transcript
rebuild/duplicates, reload, and a follow-up. Inspect retained report excerpts
and generated summary when it fails; do not equate job launch with good research.
Initial live runs verified delivery/navigation/follow-ups but exposed a summary
attempt-count bug (fixed: helper requires **1 attempt**, not `max_retries=0`).
A later full run was interrupted by an inference endpoint outage. The corrected
summary path separately passed a real-model evidence/limitations/citation probe.
All targeted Python tests passed (441); real DOM isolation checks passed. A clean
full live run with useful retrieved evidence remains to be recorded.
-99
View File
@@ -1,99 +0,0 @@
# No-RAG clean loop: first diagnostic
## Setup
No live UI, service configuration, or weights changed. The standalone loop sends
conversation history, native assistant calls and matching tool results directly
to the served pre-Heretic model. It never rewrites queries, invents calls, swaps
families, or strips output. Invalid calls return errors. Six executions per turn
and seven model rounds bound the test.
Both arms use temperature 0, thinking disabled, 768 output tokens, and the
original tool-work evaluator's `tools_for_mode(..., 'compact_contract_v3')`.
This matters: the app's plain compact scrubber deletes descriptions, whereas v3
retains empirically tested micro-hints. Previous plain-compact tests were not
exact reproductions of the passing benchmark setup.
The 76 tools come from the current app's ten-family inventory, transformed by
the original v3 builder. This is not a byte-identical frozen 99-tool benchmark
inventory or proof of training-data identity. The report records schema and
builder hashes. No schemas are invented for this experiment.
- **Stable:** same compact inventory on every turn, irrespective of spelling.
- **Routed:** same loop, but existing `requested_capabilities` chooses inventory
each turn. This isolates that selector; it is not the complete production RAG
or Agent UI path. Other production normalizers are absent in both arms.
- Private records are synthetic. No real private dispatcher is imported.
Only fixture reads and optional public SearXNG calls execute. Other operations
return explicit errors, so this does not validate their functionality.
- Live search sends the exact model query to local SearXNG Bing/Yep, bypassing
app query rewriting/filtering. Source results may vary between arms.
## Observations, not a blind score
| Case | Stable compact inventory | Selector arm |
|---|---|---|
| `whats the current stock mraket` | Selected `web_search`, query `current stock market` | Offered zero tools; declined live lookup |
| Exact seeded failed exchange, then `can you look up` | Searched with corrected query | Also searched with corrected query |
| Summarize search, explicitly no tools | Answered without tools or permission failure | Same |
| Calendar → email → calendar | Recalled second event at 14:30 | Same |
| Notes → second note → what does it say | Correct `view` ID and content | Same after fixture correction |
| Deliberately irrelevant search result | Did not automatically retry | Did not automatically retry |
| User asks for a better source | Refined and executed another search | Proposed search was not offered and was rejected |
| Web-disabled lookup | Attempted network access via bash; sandbox rejected it | Invented unsupported current market news without tools |
The initial stable stock answer listed sources, not current index values. It
does not establish that the market question was fully answered. Its subsequent
`can you look up` elicited clarification after it had already searched. The
separate seeded replay removes that differing-history confound.
The first notes fixture incorrectly accepted `get/read`, not the real `view`
action. Both models selected the correct action, but the fixture rejected it.
Those six original turns are invalid for execution comparison. A corrected
six-turn rerun succeeded in both arms; the failed evidence is retained.
Web-off results are a release blocker: removing named web tools alone does not
enforce network denial across general-purpose tools. The fixture prevented real
execution, but any UI integration must use the real cross-tool permissions and
clearly communicate unavailable capabilities. Neither arm is ready for a live
switch. Source recovery and grounded completion also remain weak.
## What this changes
There is direct evidence that the selector can withhold needed tools, and that
the model can repair the misspelled query itself when offered the tool. Clean
history also supports the tested topic switches without synthetic substitutions.
This supports continuing the clean-path experiment, not retraining or declaring
the UI fixed. Full inventory is slower in these requests; overlapping runs and
different source content prevent a controlled latency conclusion.
Next: integrate the clean loop behind a test-only UI profile with real permission
enforcement and one renderer, preserving the v3 contract. Test live read-only
follow-ups and explicit Web-off behavior before any rollout. Separately compare
a generic evidence-check/retry instruction on the weak-result fixture; do not
manufacture a retry query in the harness.
## Reproduce
Eight boundary tests pass:
```sh
/home/pewds/odysseus-cookbook-fresh/.venv/bin/pytest -q tests/test_clean_tool_loop.py
```
Run with a fresh report filename (existing evidence is never overwritten):
```sh
/home/pewds/odysseus-cookbook-fresh/.venv/bin/python scripts/test_clean_tool_loop.py --live-search --report reports/clean-loop-v3-new-run.json
```
Evidence:
- `reports/clean-loop-v3-20260909.json`: original 24 turns; notes fixture caveat above.
- `reports/clean-loop-v3-stock-seeded-20260909.json`: four matched seeded follow-up turns.
- `reports/clean-loop-v3-notes-fixture-corrected-20260909.json`: corrected six notes turns.
Each report retains model requests, responses, offered inventory and execution
results. The `completed` status means the request loop finished, **not** that
the answer passed functional evaluation. These are synthetic/public traces, not
private user conversations. This test does not measure UI rendering or streaming.
-220
View File
@@ -1,220 +0,0 @@
# Tools v3 — No-RAG preview
Select this endpoint in the 7011 model picker, with model
`odysseus-qwen3.5-tools-pre-heretic`. This endpoint owns its complete tool loop
and enters Agent mode server-side on every turn, including ambiguous follow-ups;
it does not depend on the legacy per-message intent classifier. Start a new chat
for an uncontaminated comparison. Enable Web for searches. Clean routing is
owned by the exact model identity, so both the normal `preheret` endpoint and
the `cleanv3` alias use this runtime. Every other model remains on legacy RAG.
Endpoint ID: `cleanv3`. Its base URL uses the same inference server's Tailscale
DNS name, `http://odysseus.tailb895f4.ts.net:18182/v1`, to distinguish it from
the original IP-address route when existing chats omit endpoint IDs.
## Implementation
- `src/clean_agent_preview.py` is a separate streamed native-tool loop, entered
before legacy routing and substitutions. It uses real authenticated tool
dispatch, the tool-work `compact_contract_v5` builder, temperature 0,
and thinking disabled. No weights change or inference server was started.
- The offered tool inventory is stable except for permissions/toggles. Safe,
explicit personal creates/updates are enabled for notes, tasks, calendar,
memory, skills and documents. Destructive operations, shell/code, outbound
email, browser interaction, deployment/admin changes and unrelated-family
write substitution remain blocked. No tool or argument substitution is
applied by the loop.
- Native calls and matching results persist in `clean_v3_turn` metadata so
follow-ups use actual evidence. History retains at most eight complete turns,
trimming oldest whole turns for size; individual outputs cap at 8000 chars.
- Real search still uses the existing search backend and its provider handling;
this does not claim that provider quality or every backend transform is fixed.
- All routing, privileges and default settings outside this exact Odysseus model
remain unchanged. The loop has six execution/eight-round limits.
- Write completion is evidence-bound: affirmative success text is replaced
unless a private-write tool succeeded during the turn. Proposed call batches
are policy-preflighted atomically, so a batch containing a blocked operation
cannot partially execute before denial.
## Verification
399 focused Python tests passed after route integration. Browser runs r1/r2
accidentally exercised the old loop and are not preview evidence. The runner
now explicitly asserts `selection_mode=clean_compact_v3_preview`.
`reports/clean-v3-live-ui-r3-20260909.json` confirms the preview route, real notes
execution, correct repetition from history, successful search and no-tool
summary, plus visible incremental growth. Its notes assertions were for the
old routed contract: they prohibited offering web tools even with Web enabled,
and required another notes call for a verbatim repeat. The updated preview
checks permit stable offers and accept an exact match to the preceding saved
answer without re-execution; execution permissions are still asserted.
`reports/clean-v3-live-ui-r4-20260909.json` is the corrected four-turn check,
including notes with Web off and search with Web on: **4/4 passed**, with the
preview selection mode explicitly confirmed on every turn.
These are UI smoke tests, not all-family or factual-answer benchmark scores.
## Disable
Disabling only endpoint `cleanv3` removes the duplicate picker alias; it does
not disable this model-owned runtime. To roll back the runtime, revert the exact
model route in `routes/chat_routes.py`. Do not delete weights, adapters, or user
chats. The v3 schema builder dependency is
`/home/pewds/odysseus-tool-work/scripts/eval_alltools_unseen_compare.py` and its
schema-dropout helper; preserve those with this deployment.
## Expanded UI checks — 2026-09-09
24 additional turns completed through the preview: 23 automated passes and one
checker false alarm. The Cookbook follow-up correctly shortened the previous
six-server result to the first three requested names without another call. The
checker required either a fresh call or a verbatim repeat; manual inspection
confirmed the requested subset. Raw failure evidence is retained, not rescored.
Covered notes/misspellings/second-note selection, calendar/second-event time,
tasks, documents, memory, skills, Cookbook listing, misspelled search, and Web
toggle changes. Cross-family flows passed: Germany news → “whats my notes”,
notes → “seach current stock mraket news”, and calendar → “now show my noes”.
The model chose `current stock market news` itself. Every completed turn's
audit confirmed the preview mode. Search source factual accuracy is not graded
by this suite, and successful reads do not establish mutation coverage.
Email was separately attempted but the test guard stopped it because the
stable offered inventory exceeded its metadata-only verified scope. Email
therefore remains unverified in this expanded run; the guard was not weakened.
No production code, service settings or weights changed during these tests.
Evidence under `reports/`:
- `clean-v3-broader-ui-20260909.json`: 16 turns, 15 automatic passes, Cookbook caveat.
- `clean-v3-topic-switch-ui-20260909.json`: 6/6 passed.
- `clean-v3-second-note-ui-20260909.json`: 2/2 passed.
- `clean-v3-email-notes-ui-20260909.json`: blocked email attempt; notes not run in that file.
## Picker route fix
The previous tests selected sessions through the API, missing a real picker
bug: local entries were deduplicated by model ID, hiding alternative endpoints
with the same weights. The picker now uses endpoint+model identity for local
routes too, displays the endpoint name, and scopes its last-picked send override
to the current chat. `/api/sessions` returns owner-filtered endpoint identity
for unambiguous saved URLs, so reload labels do not depend on loading the model
catalog. Ambiguous identical URLs are not guessed.
The user-authorized chat `ec0683a2-015f-41d7-aa1f-34135c9640cb` was switched to
`cleanv3` using the authenticated session PATCH API; no messages were inserted
and no tool actions ran in that chat. Defaults and other chats were unchanged.
The runner's `--picker-route true` starts on the original route, clicks the
preview in the real picker, sends a greeting, reloads the chat permalink, then
asks for notes. Early picker/reload reports are incomplete, not passes: their
label check exposed the unloaded-catalog issue. Focused route/picker/history
tests: 16 passed.
Final picker test: `reports/clean-v3-picker-reload-r5-20260909.json`, **2/2
passed**. Real picker click, greeting, permalink reload, and notes follow-up
all confirmed the preview route. The label survived reload. R4 retained a
history/DOM mismatch from sending before restored history was ready; the final
driver explicitly waits for the saved first answer to render before sending.
This does not claim a general fix for sending during unfinished history loading.
## Native image/VL status
The inference launcher previously set `--limit-mm-per-prompt` to zero images,
so vLLM rejected attachments before the model saw them. The durable Odysseus
launcher now permits up to three images per prompt; video remains disabled.
`reports/clean-v3-vl-live-r4-20260909.json` proves the real 7011 attachment
path, clean compact route, object/color/spatial recognition, permalink reload,
and ambiguous image follow-up. Those checks pass. Exact OCR of the deterministic
`ODYSSEUS 42` heading fails in both the untouched Qwen 3.5 9B base and the
fine-tune, so it remains a base/runtime capability limitation rather than a
fine-tune regression or harness failure.
The same native path also passes JPEG and lossless WebP transport, object
recognition, reload, and follow-up grounding. Evidence:
`reports/clean-v3-vl-jpeg-r1-20260909.json` and
`reports/clean-v3-vl-webp-r1-20260909.json`. Both remain `partial` only because
the shared OCR check fails.
## Reversible write check
`scripts/verify_clean_v3_write.mjs` runs against only `sft_alex_creator`. It
creates one UUID-named note through the real 7011 UI, verifies that exact row,
requests a destructive bulk deletion, verifies the row still exists, and then
deletes only its own test row through the authenticated API. The cleanup is
verified by a 404 lookup.
Final evidence: `reports/clean-v3-write-ui-r8-20260909.json`, **passed**. Both
turns reported `selection_mode=clean_compact_v3_preview`; creation executed via
`manage_notes(action=add)`, the destructive action did not execute, and the
canonical response was “No changes were made.” The earlier r3/r5 files are
startup/placement failures, while r4/r6/r7 retained genuine intermediate
harness and verifier failures; none should be interpreted as passes.
## Stateful, search, and email checks
The reversible stateful runner passes all six mutation families in one run:
calendar, notes, tasks, documents, memory, and skills (**6/6**). Each flow
creates a UUID-only artifact through the real Agent UI, verifies it by
owner-scoped API, applies a noun-free correction, verifies persistence, and
removes only that artifact. A direct database audit found zero active synthetic
calendar, note, task, or document rows afterward.
The document failure was harness-owned. Compact description dropout left a
vague free-form `command` field, error envelopes defaulted to exit code 0, and
the clean loop dropped the active document ID. Compact v5 now exposes only
required structured `edits`, reports errors truthfully, and executes against
the request's explicit active document. Fresh document and combined stateful
runs pass.
Search Web-toggle combinations `00`, `01`, `10`, and `11` pass **8/8** across
two turns. A web question can no longer silently enable Bash because it says
“official source”, and an unavailable Web capability exposes no unrelated
fallback family. The quality suite passes **3/3**: evidence reuse without a
second call, explicit official-page inspection with `web_fetch`, correction of
“stock mraket” in actual search arguments, and a truthful unsupported result
for a synthetic company.
Production-path email reads pass **3/3** through the running email MCP: account
list, latest inbox list, and referential read of the first result. The report
retains no account names, addresses, subjects, bodies, prompts, or answers.
Post-fix representative direct/follow-up coverage also passes for every family:
notes/calendar 4/4, tasks/documents/memory/skills/Cookbook/search/shell 14/14,
and email 3/3 in its privacy-preserving runner. The combined legacy verifier's
metadata-only email guard correctly refused its broader stable inventory; that
stopped report is not counted as a model failure.
Evidence:
- `reports/clean-v3-stateful-all-r3-20260909.json`
- `reports/clean-v3-stateful-documents-r2-20260909.json`
- `reports/clean-v3-search-toggle-final-r6-20260909.json`
- `reports/clean-v3-search-quality-r3-20260909.json`
- `reports/clean-v3-email-read-r1-20260909.json`
- `reports/clean-v3-ten-family-tail-postfix-r1-20260909.json`
These checks verify routing, execution, persistence, follow-up, and selected
answer-quality invariants. They are not yet the sealed all-action ship score.
## Compact v5 and corrected contract evidence
Compact v5 keeps the compact-v3 surface and adds only development-positive
field hints for Email, Search/Hugging Face quant selection, and Shell/files.
A Calendar date hint regressed development and was excluded. The Python tool now
emits one final bare expression, REPL-style, without duplicating explicit
`print(...)`; this turns otherwise correct computation calls into visible tool
evidence for all models.
Under frozen scorer `odysseus.contract.v2.5`, development is 327/344 raw
(95.06%) and 327/336 scorable (97.32%). Sealed blind is 311/344 raw (90.41%)
and 311/336 scorable (92.56%), with zero reasoning leakage. Calendar, Shell,
and Tasks remain below the 90% family ship floor, so the model is not yet a
full benchmark ship candidate.
Fresh post-deploy real-UI evidence passes: stateful flows 6/6, Email 3/3,
Search quality/recovery 3/3, private browser 3/3, and VL workflow 3/3. The
Search check accepts a failed attempt only when a later tool succeeds and the
final answer remains grounded.
-125
View File
@@ -1,125 +0,0 @@
# Regular-model tool compatibility
Last verified: 2026-09-09 through the authenticated 7011 Agent UI as
`sft_alex_creator`.
This is the legacy-RAG track. The exact model
`odysseus-qwen3.5-tools-pre-heretic` is excluded and remains on its model-owned
clean compact runtime.
## Current baseline
| Endpoint | Model | Ten-family result | State |
|---|---|---:|---|
| DeepSeek | `deepseek-v4-flash` | 10/10 | passed |
| DeepSeek | `deepseek-v4-pro` | 10/10 | passed |
| OpenAI | `gpt-5.5` | 10/10 | passed |
| OpenAI | `gpt-5.6-sol` | 10/10 | passed |
| OpenAI | `gpt-5.6-terra` | 10/10 | passed |
| OpenAI | `gpt-5.6-luna` | 10/10 | passed |
| OpenRouter | `moonshotai/kimi-k3` | 10/10 | passed |
| OpenRouter | `x-ai/grok-4.5` | 10/10 | passed |
| OpenRouter | `qwen/qwen3-vl-235b-a22b-instruct` | 10/10 | passed |
| OpenRouter | `openai/gpt-5-image` | n/a | image generation; chat tools unsupported |
| Local `100.69.120.65:8062` | `Qwen/Qwen3.5-9B` | not run | endpoint unavailable |
| Local `100.69.120.65:8062` | `GLM-5.3-Flash-Alis-MLX-4bit` | not run | endpoint unavailable |
The ten-family baseline covers one read-only functional turn each for notes,
calendar, email accounts, tasks, documents, memory, skills, Cookbook/admin,
web search, and shell. It verifies the legacy route, expected native tool call,
execution result, visible UI answer, and absence of reasoning leakage. It is not
yet a claim that every mutation/action variant, typo, or follow-up passes.
## Typo and follow-up profile
The stricter real-UI profile sends one misspelled read-only request to every
family, followed immediately by a noun-free reference to the returned result.
Read-only follow-ups must not call any tool; search follow-ups may either use
the existing evidence or fetch the prior link. Across the nine chat-capable API
models, the composited post-repair result is **178/180 turns (98.89%)**:
| Model | Conversation result |
|---|---:|
| `deepseek-v4-flash` | 20/20 |
| `deepseek-v4-pro` | 20/20 |
| `gpt-5.5` | 20/20 |
| `gpt-5.6-sol` | 20/20 |
| `gpt-5.6-terra` | 20/20 |
| `gpt-5.6-luna` | 18/20 |
| `moonshotai/kimi-k3` | 20/20 |
| `x-ai/grok-4.5` | 20/20 |
| `qwen/qwen3-vl-235b-a22b-instruct` | 20/20 |
Luna's only remaining family miss is a deliberately misspelled Shell request.
The correct-spelling baseline passes. The harness does not auto-execute a shell
command to hide that model-owned limitation.
The shared repair recognizes a uniquely misspelled action verb and family noun,
then seals only declared safe private reads with immutable canonical arguments.
This repaired Tasks/Documents/Memory and adjacent read families across providers
without widening mutation or Shell authority. A compact native-tool instruction
also tells regular API models to map clear typos to a currently offered tool.
Conversation evidence:
- `reports/regular-model-conversation-flash-r3-20260909.json`
- `reports/regular-model-conversation-remaining-r1-20260909.json`
- `reports/regular-model-conversation-repair-r1-20260909.json`
- `reports/regular-model-conversation-shell-r1-20260909.json`
- `reports/regular-model-conversation-qwen-repair-r1-20260909.json`
- `reports/regular-model-conversation-qwen-tail-r1-20260909.json`
- `reports/regular-model-conversation-qwen-search-r1-20260909.json`
Evidence:
- `reports/regular-model-tools-provider-final-r4-20260909.json` — Flash, GPT-5.5, Kimi: 30/30.
- `reports/regular-model-tools-repair-r3-20260909.json` — Pro and Sol: 20/20; retained Qwen pre-final 9/10 miss.
- `reports/regular-qwen-vl-full-r4-20260909.json` — Qwen-VL final family-switch run: 10/10.
- `reports/regular-model-tools-remaining-20260909.json` — Terra, Luna, Grok: 30/30; records unavailable/unsupported models and pre-repair failures.
- `reports/regular-model-tools-postfix-r1-20260909.json` — post-hardening
rerun: nine chat-capable API models passed 90/90 family turns with zero model
failures. Its overall status is non-passing only because the two configured
local endpoints were offline; the image-only model remains unsupported.
## Family switch and page inspection
The six-turn switch/back flow covers notes → calendar → notes from prior
evidence → web search → explicit `web_fetch` → calendar from prior evidence.
All nine API models have a clean 6/6 reproduction (**54/54**). Kimi skipped
search once in the retained first run and passed a fresh reproduction; that
variability remains visible instead of being erased.
Evidence:
- `reports/regular-model-switchback-flash-r2-20260909.json`
- `reports/regular-model-switchback-remaining-r1-20260909.json`
- `reports/regular-model-switchback-kimi-r1-20260909.json`
## Repair that produced the clean baseline
Regular models no longer inherit up to three stale tool families into every
explicit new request. Referential follow-ups still resolve from typed recent
tool evidence, while explicit family switches receive the current family only.
Safe required reads use `active_capabilities`, so stale offered context cannot
disable their immutable operation. The stream layer also stops an exact long
block repeated twice instead of waiting for a provider's full timeout.
The composer no longer treats generic words such as “source”, “system”, “app”,
or “review” as authority to silently enable Bash. Explicit shell, terminal,
repository, code-file, and direct coding requests retain workspace
auto-escalation. This is a shared UI authority fix, not a model-name exception.
Run a bounded subset with:
```sh
MODELS='deepseek-v4-flash,gpt-5.5' \
FAMILIES='notes,calendar' WORKERS=2 \
REPORT_PATH=reports/regular-model-check.json \
node scripts/verify_regular_model_tools.mjs
```
Set `PROFILE=conversation` to run the typo plus follow-up profile.
The runner discovers only enabled pinned models (visible cached local models
when no pins exist), retains no tool outputs or private rows, and deletes only
the exact sessions it creates.
-92
View File
@@ -1,92 +0,0 @@
# Search and compact-tool experiment — 2026-09-09
## Decision
Keep the normal routed profile on 7011. The all-tools compact experiment is
implemented but **disabled**: direct routing success did not translate into a
working Agent UI. Do not retrain or promote a profile on these measurements.
## Changes
- Short public-web lookups on the target model have an execution budget: two
distinct token-normalized searches, one fetch, and up to three browser calls
after the two searches. This bounds attempts, not just recovery prose. Existing
permissions still apply; this does not make unavailable tools executable.
- Failed/weak searches reach the model for evaluation and query refinement,
instead of the earlier unconditional terminal evidence veto. Some legacy
heuristics and official-site shortcuts remain; this is not a completed rewrite.
- Search providers retain query/engine/date provenance. Unconfigured credentialed
fallbacks are skipped. When SearXNG is the sole configured usable provider, Yep
on the same instance is an additional fallback.
- An unavailable warm-only family no longer vetoes an otherwise ordinary reply.
- The all-tools experiment offers the trained compact inventory subject to
permissions. It requires the exact test-owner environment flag and exact model
match. The temporary service flag was removed after failed UI testing.
## Evidence and limits
| Measurement | Result | What it establishes |
|---|---|---|
| Focused Python regression suite | 401 passed | Covered policy, contract, provider and recovery-budget behavior |
| Direct family-only compact schemas | 10/10 tool routing | Small public smoke test, not functional or blind accuracy |
| Direct all-family compact schemas | 10/10 tool routing | Inventory did not break these first calls; roughly 3–4x slower in this run |
| Full compact Agent UI, revision 3 | All eight turns failed one or more checks | Not suitable for activation; leaks, duplicate/incorrect rendering or missing expected calls |
| Normal-profile final UI control | Five passed checks, two product failures, one capture error | Not accepted; suite status incomplete |
| Clean synthetic notes tool-result continuation | Clean answer with both schema sizes | Model can continue correctly on that isolated input, not proof that UI failure is solely harness |
Direct probes used temperature 0; the UI target-model sampling path can cap at
0.2. Prompts, history and tool-result serialization also differ. Match those
before attributing UI failures to weights versus harness. Existing UI checks are
not a grounded factual-answer benchmark. Unit tests are not UI acceptance.
Normal-profile control details: notes initial, both calendar turns, AI search
initial, and history initial passed the automated checks. Notes follow-up hit a
Playwright `Network.getResponseBody` capture error and is inconclusive. AI search
follow-up explicitly requested a summary with no tools, but the contract still
required `search_browser` and returned a permission failure. The history search
follow-up failed the visible-leak check. These are separate from source relevance;
the earlier warm-only fix did not cover classification as an active requirement.
There is no matched pre-change control establishing a net improvement.
Provider isolation bypassed app relevance filters. Bing general often returned
broad or unrelated results despite the full query. Google/Mojeek returned no
results, DDG hit CAPTCHA, and Presearch timed out. Yep returned useful PostgreSQL
documentation, but was weak or empty for several other questions. Engine health
and source quality remain unresolved. Fallback cannot help when an earlier weak
result survives filtering; there is no claim of universal relevance here.
## Reproduce and inspect
- `scripts/audit_search_pipeline.py`: raw provider comparison, no model.
- `scripts/compare_compact_tool_inventory.py`: read-only model schema comparison;
proposed calls are never executed.
- `scripts/verify_agent_turn_contract.mjs`: real 7011 Agent UI and persisted-history
checks. Use the dedicated test account; reports can contain private tool data.
- `reports/search-provider-isolation.json`, `reports/search-yep-isolation.json`:
public provider evidence.
- `reports/compact-inventory-ablation.json`: direct routing probe.
- `reports/full-compact-ui-audit-r3.json`: completed rejected UI experiment.
Earlier experiment reports include an initialization error and an aborted run;
do not combine them into an accuracy score.
- `reports/routed-control-ui-audit-final.json`: normal-profile control replay;
eight attempts, incomplete because of the capture error; failures retained.
Focused suite:
```sh
/home/pewds/odysseus-cookbook-fresh/.venv/bin/pytest -q tests/test_turn_contract.py tests/test_turn_contract_integration.py tests/test_service_search_provider_guards.py tests/test_web_recovery_budget.py tests/test_tool_policy.py
```
UI replay (read-only prompts, creates test chats):
```sh
node scripts/verify_agent_turn_contract.mjs --families notes,calendar,search_ai,search_history --pairs notes:11,calendar:11,search_ai:11,search_history:11 --max-turns 8 --total-ms 360000 --turn-ms 45000 --report reports/routed-control-ui-audit-final.json
```
## Next discriminating test
Replay the same captured UI request directly, preserving sampling, compact
schemas, history and tool results. Then change one layer at a time. Separately
score retrieved-source relevance and supported answers. Replace failing generic
boundaries only when the replay identifies them; do not add rules for individual
user phrasings or treat successful tool routing as successful execution.
-73
View File
@@ -1,73 +0,0 @@
# Typo-tolerant tool routing audit
The 9B SFT model was not retrained. This audit targets the earlier harness
stage that decides which complete tool families the model is allowed to see.
## Method
- Source prompts: real `sft_alex_creator` sessions from `a37dcb3b-...` onward.
- Labels: recorded single-family tool calls, excluding mixed/ambiguous traces.
- Variants: deletion, adjacent transposition, duplicated character,
keyboard-neighbor substitution, and accidental word split.
- Split: deterministic SHA-256 assignment before scoring (75% dev, 25% blind).
- Safety: static routing only; no historical mutation or send action is replayed.
- Acceptance: at least 95% blind exact-family accuracy and below 1% blind
wrong-family authorization. Abstention is measured separately.
## Results
| Router | Dev family supplied | Blind family supplied | Blind exact | Blind wrong-family |
|---|---:|---:|---:|---:|
| Previous exact rules | 63.64% | 65.69% | — | — |
| Conservative fuzzy fallback r4 | 96.31% | 98.31% | 96.62% | 0.00% |
| Final router + safe-read repair | 98.31% | 98.73% | 97.05% | 0.00% |
The fallback runs only for action/lookup-shaped requests, resolves exactly one
nearby family term, and abstains on ambiguity. Conceptual questions remain
tool-free. Complete family schemas are still selected by the immutable turn
contract; fuzzy matching never chooses an individual tool or its arguments.
Authoritative machine reports:
- `reports/typo-tool-routing-baseline-20260909.json`
- `reports/typo-tool-routing-fuzzy-r4-20260909.json`
- `reports/typo-tool-routing-final-20260909.json`
- `reports/post-followup-agent-80-20260909.json`
- `reports/post-typo-routing-agent-80-20260909.json`
- `reports/live-typo-agent-20-20260909.json`
- `reports/live-typo-unresolved-r3-20260909.json`
- `reports/live-typo-agent-final-20-20260909.json`
- `reports/post-typo-safe-read-agent-final-80-20260909.json`
## Live 7011 findings
The post-deployment standard matrix passed 80/80 through the real Agent UI.
The first read-only typo matrix then attempted 17 of 20 planned turns before
its total-time limit. Initial Notes, Calendar, Email, Tasks, Documents, and
Cookbook calls passed. Completed failing turns still had the correct family
and required tool in `turn_contract.offered`; the 9B model sometimes answered
without calling that offered tool. Memory and Search also exposed timeouts.
This separates three failure classes:
1. **Tool injection:** addressed by conservative fuzzy family routing; blind
exact routing is 96.62% with zero blind wrong-family authorizations.
2. **Required read execution:** a correctly offered safe list/refresh tool can
still be skipped by the model, especially after a typo or on “list those
again” follow-ups. This should be handled by the generic deterministic
safe-read path, not additional prompt-specific hints.
3. **Runtime timeout:** Search and one Memory follow-up require loop/backend
diagnosis. A timeout is not counted as a model-accuracy or routing result.
The generic safe-read parser and search-family precedence were then repaired.
The previously unresolved Calendar, Email, Search, and Shell/Files cases passed
8/8. The complete typo matrix passed 20/20, including initial requests and
follow-ups for all ten families. The final standard Agent UI compatibility
matrix passed 80/80 across family, Web-toggle, and follow-up combinations.
The broad routing regression suite passed 458 tests. The model was not
retrained and no DeepSeek API was used: the measured defect was in harness
family selection and deterministic safe-read execution, upstream of the
model. All 1,535 unique labeled historical turns were statically audited to
mine failure categories. Historical write/send/delete actions were not replayed
against live data; live verification used the deduplicated read-only matrices.
View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+7 -14
View File
@@ -25,16 +25,9 @@
--radius: 8px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 60px; }
/* REMOVED: "scroll-snap-type: y proximity"
The idea was: >>Each section is a full-viewport "page" with its content centered,
so only one shows at a time and the snap is obvious.<<
PROBLEM: sections easily grow taller than 100vh IRL
This cause forced jumps mid-read. It's intrusive UX.
The landing-page is not a PowerPoint presentation!
Preserved: CSS snap-points to avoid destroying code meta-data*/
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; scroll-padding-top: 60px; }
/* Each section is a full-viewport "page" with its content centered, so only
one shows at a time and the snap is obvious. */
.hero, section {
scroll-snap-align: start; min-height: 100vh;
display: flex; flex-direction: column; justify-content: center;
@@ -409,7 +402,7 @@
<a href="#testimonials">Testimonials</a>
<a href="#how">How it started</a>
<a href="#start">Get started</a>
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
GitHub
</a>
@@ -437,7 +430,7 @@
</p>
<div class="hero-cta">
<a class="btn primary" href="#start">Get started</a>
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">View on GitHub</a>
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
</div>
</div>
@@ -678,9 +671,9 @@
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/odysseus-dev/odysseus.git &amp;&amp; cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus">Copy</button></div>
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git &amp;&amp; cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus">Copy</button></div>
<div>
<a class="btn primary" href="https://github.com/odysseus-dev/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
</div>
<div class="pill-row">
<span class="pill">Self-hosted</span>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

-26
View File
@@ -1,26 +0,0 @@
# Skills lifecycle
The UI exposes All, Built-in, Approved, and Draft. Draft includes archived
records so they remain inspectable and recoverable. Built-ins are not audited.
Approved means published, passing, at the configured confidence threshold,
and not marked unnecessary. Baseline speed measurements remain evidence, not
an additional hidden UI approval gate.
Automatic audits process at most eight eligible records at a time, oldest first.
New records are eligible immediately; inconclusive checks retry after a day;
failed repairs retry after a week. Passed, duplicate-skipped, and archived records
are excluded. Existing daily Skills Audit tasks drive this queue. Their quiet
window deferrals propagate to the scheduler rather than becoming task failures.
Automatic runs use background model scheduling. Existing self-repair and teacher
repair stages remain in place; failed candidates remain drafts.
The skill index advertises short descriptions; the agent loads a relevant full
procedure on demand and applies already-injected procedures directly. Extraction
prefers verified discoveries and specific workarounds over routine tool usage.
Reference reviewed: NousResearch/hermes-agent, MIT license, commit
cfdbbb6e35010ace89fbe8243ee82fa4de143e10, cloned to
/home/pewds/hermes-skills-reference. In particular tools/skills_tool.py and
agent/prompt_builder.py use progressive disclosure and task-triggered procedure
loading. These changes adapt that approach to Odysseus's existing registry;
no Hermes implementation code was copied.
-36
View File
@@ -1,36 +0,0 @@
# Odysseus Claude Code Integration
This directory contains the Claude Code skill bundle for Odysseus.
## User Flow
1. Open Odysseus Settings > Integrations.
2. Add a Claude Agent.
3. Copy the full setup commands shown after the generated token.
4. Toggle the tools Claude is allowed to use.
5. Configure the terminal Claude Code session:
```bash
export ODYSSEUS_URL=http://your-odysseus-host:7000
export ODYSSEUS_API_TOKEN=ody_generated_token
mkdir -p ~/.claude
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip
python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/
```
Claude Code auto-loads anything under `~/.claude/skills/`, so the `odysseus` skill is
available in any session that has `ODYSSEUS_URL` and `ODYSSEUS_API_TOKEN` in its
environment.
## What's in the bundle
- `skills/odysseus/SKILL.md` — the skill definition Claude Code reads.
- `skills/odysseus/scripts/odysseus_api.py` — small helper that calls the scoped
`/api/codex/*` endpoints (these are the canonical scope-gated agent API; the
`codex` path is historic and shared by all agent integrations).
## Scope enforcement
The token is scope-gated. Every tool surface is checked server-side in Odysseus,
so even if Claude tries to call a forbidden endpoint, it gets `403` until the
user enables the matching toggle in Settings > Integrations > Claude Agent.
@@ -1,154 +0,0 @@
---
name: odysseus
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
---
# Odysseus
Use this skill when a user asks to interact with Odysseus from Claude Code.
## Configuration
Expect these environment variables:
- `ODYSSEUS_URL`: Base URL for the user's Odysseus instance, for example `http://127.0.0.1:7000`.
- `ODYSSEUS_API_TOKEN`: Scoped API token created in Odysseus Settings > Integrations > Add Integration > Claude Agent.
If either value is missing, do not guess credentials. Tell the user to create a Claude Agent token in Odysseus Settings and expose both values to the terminal session.
## When to use what
- **Reminder ("remind me at 5pm to do X")** → TODO with `due_date`. The due_date IS the reminder — it fires a notification automatically via the user's configured channel (browser/email/ntfy). **Do NOT create a calendar event for a reminder.** Creating a calendar event named "Reminder" does NOT trigger a notification — it's just a time block on the calendar.
- **Calendar event ("meeting at 3pm", "dentist Tuesday 10am")** → calendar event. Use for scheduled time blocks, meetings, appointments, recurring schedules. These show up on the calendar grid; reminders for them are configured separately in Odysseus settings.
- **Note / freeform info ("note that the wifi password is ...")** → memory or todo without a due_date (depending on whether it's a fact about the user or an action item).
- **Persistent fact / preference about the user** → memory.
If the user says "reminder" + a time, default to TODO with due_date. Only switch to calendar if the user explicitly says "calendar", "event", "meeting", "appointment", or describes a time *range*.
## Safety
- All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*` (the canonical scope-gated agent API, shared by all agent integrations).
- Check `/api/codex/capabilities` before using a tool surface.
- Treat `403` as an intentional Settings restriction. Do not work around it.
- Do not use SSH, Docker, direct Python imports, SQLite queries, MCP internals, browser cookies, or local files to read/write Odysseus user data.
- Do not call helpers like `do_manage_notes`, email MCP internals, or database sessions directly for user data, even if shell access exists.
- Never send email directly unless the user explicitly asks to send and the token has a send-capable scope.
- Keep actions scoped to the token owner.
## Todos
The scoped agent API supports todos/checklists:
- `GET /api/codex/todos`
- `POST /api/codex/todos`
Use the bundled helper script when available:
```bash
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py todos list
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py todos add "Follow up"
```
Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`.
**Reminders (todos with a due date)** — the backend parses natural language. Send `due_date` in the body via the generic POST so the time becomes a structured reminder, NOT a literal substring inside the title. The `todos add TITLE` shortcut only sets the title, so use the POST form for anything with a time:
```bash
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/todos '{"action":"add","title":"Call dentist","due_date":"tomorrow at 5pm"}'
```
The backend accepts both ISO timestamps and natural language like `"tomorrow 5pm"`, `"next Monday 9am"`, `"in 2 hours"`. It anchors to the user's timezone.
## Email
The scoped agent API supports email reads:
- `GET /api/codex/emails?folder=INBOX&limit=10&offset=0&filter=all`
- `GET /api/codex/emails/{uid}?folder=INBOX`
Use the bundled helper script when available:
```bash
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py emails list 5
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py emails read UID
```
If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Claude Agent settings.
## Memory
- `GET /api/codex/memory` — list memories for the token owner.
- `POST /api/codex/memory` — body `{"text": "...", "category": "fact", "source": "user", "session_id": null}`. Requires `memory:write`.
- `DELETE /api/codex/memory/{memory_id}` — remove a memory entry. Requires `memory:write`.
```bash
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/memory
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory '{"text":"User prefers SI units","category":"preference"}'
```
## Calendar
- `GET /api/codex/calendar/events?start=ISO&end=ISO` — list events in window.
- `POST /api/codex/calendar/events` — body matches `EventCreate` (`summary`, `dtstart`, `dtend`, `all_day`, `description`, `location`, `calendar_href`, `rrule`, `color`). Requires `calendar:write`.
- `DELETE /api/codex/calendar/events/{uid}` — delete event by uid (the value returned in the POST response). Requires `calendar:write`.
## Documents
- `GET /api/codex/documents?search=...&limit=50` — paginated library.
- `GET /api/codex/documents/{doc_id}` — fetch one document.
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
## Email draft + send
- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
## Cookbook serve (debug a failing model launch)
The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).
- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session for that task. Requires `cookbook:launch`.
```bash
# Survey what's running
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook tasks
# Tail the failing one (sessionId from `cookbook tasks`)
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400
# Stop the previous attempt before you try a new flag set
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345
# Relaunch with new flags. cmd MUST begin with one of the allowlisted binaries.
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook serve \
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
pewds@192.168.1.12
```
**Debug loop pattern:** when a serve is failing, the productive sequence is
1. `cookbook tasks` → find the failing sessionId.
2. `cookbook output SID 600` → read the last 600 lines, find the actual root-cause line (often above the visible tail because tmux scrollback rolled — request a larger `tail` if the error references "above").
3. `cookbook stop SID` — kill the previous attempt before relaunching; two serves on the same `--port` collide.
4. `cookbook serve repo "new cmd"` — try the next variation. Wait ~20s, then `cookbook output` on the new sessionId.
**Hard limits this surface enforces:**
- `cookbook serve` cmd allowlist + shell-metacharacter rejection — you cannot run arbitrary shell, only model-server binaries.
- `cookbook stop` only targets task sessionIds matching `[a-zA-Z0-9_-]+`.
- The agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching, and check `cookbook tasks` for collisions on the same `--port` before launching.
## Forbidden Bypass Pattern
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Claude Agent tool toggle instead.
@@ -1,218 +0,0 @@
#!/usr/bin/env python3
"""Small Odysseus scoped API helper for Codex terminal sessions."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
def _usage() -> int:
print("usage:", file=sys.stderr)
print(" odysseus_api.py capabilities", file=sys.stderr)
print(" odysseus_api.py todos list", file=sys.stderr)
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
print(" odysseus_api.py emails read UID", file=sys.stderr)
print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
print(" odysseus_api.py cookbook servers", file=sys.stderr)
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
print(" odysseus_api.py cookbook presets", file=sys.stderr)
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
return 2
def _config() -> tuple[str, str] | None:
base_url = os.environ.get("ODYSSEUS_URL", "").strip().rstrip("/")
token = os.environ.get("ODYSSEUS_API_TOKEN", "").strip()
missing = []
if not base_url:
missing.append("ODYSSEUS_URL")
if not token:
missing.append("ODYSSEUS_API_TOKEN")
if missing:
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
return None
return base_url, token
def main() -> int:
if len(sys.argv) < 2:
return _usage()
command = sys.argv[1].lower()
if command == "capabilities":
method = "GET"
path = "/api/codex/capabilities"
body = None
elif command == "todos":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
path = "/api/codex/todos"
if action == "list":
method = "GET"
body = None
elif action == "add" and len(sys.argv) >= 4:
method = "POST"
body = json.dumps({"action": "add", "title": " ".join(sys.argv[3:])})
else:
return _usage()
elif command == "emails":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "list":
method = "GET"
limit = sys.argv[3] if len(sys.argv) >= 4 else "10"
path = f"/api/codex/emails?folder=INBOX&limit={limit}&offset=0&filter=all"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/emails/{sys.argv[3]}"
body = None
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/emails/draft-document"
body = " ".join(sys.argv[3:])
else:
return _usage()
elif command in ("documents", "docs"):
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "list":
method = "GET"
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
path = f"/api/codex/documents?limit={limit}"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
elif action == "create" and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/documents"
body = " ".join(sys.argv[3:])
elif action == "delete" and len(sys.argv) >= 4:
method = "DELETE"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
else:
return _usage()
elif command == "cookbook":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "tasks":
method = "GET"
path = "/api/codex/cookbook/tasks"
body = None
elif action == "servers":
method = "GET"
path = "/api/codex/cookbook/servers"
body = None
elif action == "output" and len(sys.argv) >= 4:
method = "GET"
sid = sys.argv[3]
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
body = None
elif action == "cached":
method = "GET"
if len(sys.argv) >= 4:
from urllib.parse import quote
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
else:
path = "/api/codex/cookbook/cached"
body = None
elif action == "presets":
method = "GET"
path = "/api/codex/cookbook/presets"
body = None
elif action == "preset" and len(sys.argv) >= 4:
from urllib.parse import quote
method = "POST"
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
body = None
elif action == "adopt" and len(sys.argv) >= 5:
method = "POST"
path = "/api/codex/cookbook/adopt"
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
body = json.dumps(payload)
elif action == "serve" and len(sys.argv) >= 5:
method = "POST"
path = "/api/codex/cookbook/serve"
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
if len(sys.argv) >= 6:
payload["remote_host"] = sys.argv[5]
body = json.dumps(payload)
elif action == "stop" and len(sys.argv) >= 4:
method = "POST"
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
body = None
else:
return _usage()
else:
if len(sys.argv) < 3:
return _usage()
method = sys.argv[1].upper()
path = sys.argv[2]
body = sys.argv[3] if len(sys.argv) > 3 else None
if not path.startswith("/"):
path = "/" + path
if not path.startswith("/api/codex/"):
print("refusing non-/api/codex path; use scoped Odysseus integration endpoints only", file=sys.stderr)
return 2
config = _config()
if config is None:
return 2
base_url, token = config
data = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if body is not None:
try:
parsed = json.loads(body)
except json.JSONDecodeError as exc:
print(f"invalid json body: {exc}", file=sys.stderr)
return 2
data = json.dumps(parsed).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
print(resp.read().decode("utf-8"))
return 0
except urllib.error.HTTPError as exc:
text = exc.read().decode("utf-8", errors="replace")
print(text or f"HTTP {exc.code}", file=sys.stderr)
return 1
except OSError as exc:
print(f"request failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,22 +0,0 @@
{
"name": "odysseus",
"version": "0.1.1",
"description": "Connect Codex to a scoped Odysseus instance.",
"author": {
"name": "Odysseus"
},
"skills": "./skills/",
"interface": {
"displayName": "Odysseus",
"shortDescription": "Use scoped Odysseus tools from Codex.",
"longDescription": "Connects Codex terminal sessions to Odysseus through user-controlled scoped API tokens. Codex must use /api/codex/* endpoints so Odysseus Settings can enforce tool access.",
"developerName": "Odysseus",
"category": "Productivity",
"capabilities": [
"todos",
"email",
"scoped-api"
],
"defaultPrompt": "Use Odysseus only through configured scoped access. Check capabilities before reading or writing data."
}
}
-51
View File
@@ -1,51 +0,0 @@
# Odysseus Codex Integration
This directory contains the Codex plugin/skill bundle for Odysseus.
## User Flow
1. Open Odysseus Settings > Integrations.
2. Add a Codex Agent.
3. Copy the full setup commands shown after the generated token.
4. Toggle the tools Codex is allowed to use.
5. Configure the terminal Codex session:
```bash
export ODYSSEUS_URL=http://your-odysseus-host:7000
export ODYSSEUS_API_TOKEN=ody_generated_token
mkdir -p ~/plugins
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
python3 - <<'PY'
import json
from pathlib import Path
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
data = json.loads(p.read_text())
else:
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
data.setdefault("name", "personal")
data.setdefault("interface", {}).setdefault("displayName", "Personal")
plugins = data.setdefault("plugins", [])
entry = {
"name": "odysseus",
"source": {"source": "local", "path": "./plugins/odysseus"},
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
"category": "Productivity",
}
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
p.write_text(json.dumps(data, indent=2) + "\n")
PY
codex plugin add odysseus@personal
```
6. Verify:
```bash
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities
```
Codex must use `/api/codex/*` endpoints. SSH, Docker, direct Python imports, database queries, and MCP internals bypass Odysseus Settings and must not be used for user data access.
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env python3
"""Small Odysseus scoped API helper for Codex terminal sessions."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
def _usage() -> int:
print("usage:", file=sys.stderr)
print(" odysseus_api.py capabilities", file=sys.stderr)
print(" odysseus_api.py todos list", file=sys.stderr)
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
print(" odysseus_api.py emails read UID", file=sys.stderr)
print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
print(" odysseus_api.py cookbook servers", file=sys.stderr)
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
print(" odysseus_api.py cookbook presets", file=sys.stderr)
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
return 2
def _config() -> tuple[str, str] | None:
base_url = os.environ.get("ODYSSEUS_URL", "").strip().rstrip("/")
token = os.environ.get("ODYSSEUS_API_TOKEN", "").strip()
missing = []
if not base_url:
missing.append("ODYSSEUS_URL")
if not token:
missing.append("ODYSSEUS_API_TOKEN")
if missing:
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
return None
return base_url, token
def main() -> int:
if len(sys.argv) < 2:
return _usage()
command = sys.argv[1].lower()
if command == "capabilities":
method = "GET"
path = "/api/codex/capabilities"
body = None
elif command == "todos":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
path = "/api/codex/todos"
if action == "list":
method = "GET"
body = None
elif action == "add" and len(sys.argv) >= 4:
method = "POST"
body = json.dumps({"action": "add", "title": " ".join(sys.argv[3:])})
else:
return _usage()
elif command == "emails":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "list":
method = "GET"
limit = sys.argv[3] if len(sys.argv) >= 4 else "10"
path = f"/api/codex/emails?folder=INBOX&limit={limit}&offset=0&filter=all"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/emails/{sys.argv[3]}"
body = None
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/emails/draft-document"
body = " ".join(sys.argv[3:])
else:
return _usage()
elif command in ("documents", "docs"):
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "list":
method = "GET"
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
path = f"/api/codex/documents?limit={limit}"
body = None
elif action == "read" and len(sys.argv) >= 4:
method = "GET"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
elif action == "create" and len(sys.argv) >= 4:
method = "POST"
path = "/api/codex/documents"
body = " ".join(sys.argv[3:])
elif action == "delete" and len(sys.argv) >= 4:
method = "DELETE"
path = f"/api/codex/documents/{sys.argv[3]}"
body = None
else:
return _usage()
elif command == "cookbook":
if len(sys.argv) < 3:
return _usage()
action = sys.argv[2].lower()
if action == "tasks":
method = "GET"
path = "/api/codex/cookbook/tasks"
body = None
elif action == "servers":
method = "GET"
path = "/api/codex/cookbook/servers"
body = None
elif action == "output" and len(sys.argv) >= 4:
method = "GET"
sid = sys.argv[3]
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
body = None
elif action == "cached":
method = "GET"
if len(sys.argv) >= 4:
from urllib.parse import quote
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
else:
path = "/api/codex/cookbook/cached"
body = None
elif action == "presets":
method = "GET"
path = "/api/codex/cookbook/presets"
body = None
elif action == "preset" and len(sys.argv) >= 4:
from urllib.parse import quote
method = "POST"
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
body = None
elif action == "adopt" and len(sys.argv) >= 5:
method = "POST"
path = "/api/codex/cookbook/adopt"
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
body = json.dumps(payload)
elif action == "serve" and len(sys.argv) >= 5:
method = "POST"
path = "/api/codex/cookbook/serve"
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
if len(sys.argv) >= 6:
payload["remote_host"] = sys.argv[5]
body = json.dumps(payload)
elif action == "stop" and len(sys.argv) >= 4:
method = "POST"
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
body = None
else:
return _usage()
else:
if len(sys.argv) < 3:
return _usage()
method = sys.argv[1].upper()
path = sys.argv[2]
body = sys.argv[3] if len(sys.argv) > 3 else None
if not path.startswith("/"):
path = "/" + path
if not path.startswith("/api/codex/"):
print("refusing non-/api/codex path; use scoped Odysseus integration endpoints only", file=sys.stderr)
return 2
config = _config()
if config is None:
return 2
base_url, token = config
data = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if body is not None:
try:
parsed = json.loads(body)
except json.JSONDecodeError as exc:
print(f"invalid json body: {exc}", file=sys.stderr)
return 2
data = json.dumps(parsed).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
print(resp.read().decode("utf-8"))
return 0
except urllib.error.HTTPError as exc:
text = exc.read().decode("utf-8", errors="replace")
print(text or f"HTTP {exc.code}", file=sys.stderr)
return 1
except OSError as exc:
print(f"request failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
-142
View File
@@ -1,142 +0,0 @@
---
name: odysseus
description: Use when the user asks Codex to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
---
# Odysseus
Use this skill when a user asks to interact with Odysseus from Codex.
## Configuration
Expect these environment variables:
- `ODYSSEUS_URL`: Base URL for the user's Odysseus instance, for example `http://127.0.0.1:7000`.
- `ODYSSEUS_API_TOKEN`: Scoped API token created in Odysseus Settings > Integrations > Add Integration > Codex Agent.
If either value is missing, do not guess credentials. Tell the user to create a Codex Agent token in Odysseus Settings and expose both values to the terminal session.
## When to use what
- **Reminder ("remind me at 5pm to do X")** → TODO with `due_date`. The due_date IS the reminder — it fires a notification automatically via the user's configured channel (browser/email/ntfy). **Do NOT create a calendar event for a reminder.** Creating a calendar event named "Reminder" does NOT trigger a notification — it's just a time block on the calendar.
- **Calendar event ("meeting at 3pm", "dentist Tuesday 10am")** → calendar event. Use for scheduled time blocks, meetings, appointments, recurring schedules. These show up on the calendar grid; reminders for them are configured separately in Odysseus settings.
- **Note / freeform info ("note that the wifi password is ...")** → memory or todo without a due_date (depending on whether it's a fact about the user or an action item).
- **Persistent fact / preference about the user** → memory.
If the user says "reminder" + a time, default to TODO with due_date. Only switch to calendar if the user explicitly says "calendar", "event", "meeting", "appointment", or describes a time *range*.
## Safety
- All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*`.
- Check `/api/codex/capabilities` before using a tool surface.
- Treat `403` as an intentional Settings restriction. Do not work around it.
- Do not use SSH, Docker, direct Python imports, SQLite queries, MCP internals, browser cookies, or local files to read/write Odysseus user data.
- Do not call helpers like `do_manage_notes`, email MCP internals, or database sessions directly for user data, even if shell access exists.
- Never send email directly unless the user explicitly asks to send and the token has a send-capable scope.
- Keep actions scoped to the token owner.
## Todos
The Codex API supports todos/checklists:
- `GET /api/codex/todos`
- `POST /api/codex/todos`
Use the bundled helper script when available:
```bash
python3 integrations/codex/scripts/odysseus_api.py capabilities
python3 integrations/codex/scripts/odysseus_api.py todos list
python3 integrations/codex/scripts/odysseus_api.py todos add "Follow up"
```
Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`.
**Reminders (todos with a due date)** — the backend parses natural language. Send `due_date` in the body via the generic POST so the time becomes a structured reminder, NOT a literal substring inside the title. The `todos add TITLE` shortcut only sets the title, so use the POST form for anything with a time:
```bash
python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/todos '{"action":"add","title":"Call dentist","due_date":"tomorrow at 5pm"}'
```
The backend accepts both ISO timestamps and natural language like `"tomorrow 5pm"`, `"next Monday 9am"`, `"in 2 hours"`. It anchors to the user's timezone.
## Email
The Codex API supports scoped email reads:
- `GET /api/codex/emails?folder=INBOX&limit=10&offset=0&filter=all`
- `GET /api/codex/emails/{uid}?folder=INBOX`
Use the bundled helper script when available:
```bash
python3 integrations/codex/scripts/odysseus_api.py emails list 5
python3 integrations/codex/scripts/odysseus_api.py emails read UID
```
If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Codex Agent settings.
## Memory
- `GET /api/codex/memory` — list memories for the token owner.
- `POST /api/codex/memory` — body `{"text": "...", "category": "fact", "source": "user", "session_id": null}`. Requires `memory:write`.
- `DELETE /api/codex/memory/{memory_id}` — remove a memory entry. Requires `memory:write`.
```bash
python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/memory
python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"text":"User prefers SI units","category":"preference"}'
```
## Calendar
- `GET /api/codex/calendar/events?start=ISO&end=ISO` — list events in window.
- `POST /api/codex/calendar/events` — body matches `EventCreate` (`summary`, `dtstart`, `dtend`, `all_day`, `description`, `location`, `calendar_href`, `rrule`, `color`). Requires `calendar:write`.
- `DELETE /api/codex/calendar/events/{uid}` — delete event by uid (the value returned in the POST response). Requires `calendar:write`.
## Documents
- `GET /api/codex/documents?search=...&limit=50` — paginated library.
- `GET /api/codex/documents/{doc_id}` — fetch one document.
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
## Email draft + send
- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
## Cookbook serve (debug a failing model launch)
The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).
- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session. Requires `cookbook:launch`.
```bash
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook tasks
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook serve \
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
pewds@192.168.1.12
```
**Debug loop pattern:** `tasks` → `output SID 600` (find root cause; request larger `tail` if it references "above") → `stop SID` → `serve repo "new cmd"` → wait ~20s → `output` on the new sessionId.
**Hard limits this surface enforces:**
- `cookbook serve` cmd allowlist + shell-metacharacter rejection.
- `cookbook stop` requires sessionIds matching `[a-zA-Z0-9_-]+`.
- Agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching.
## Forbidden Bypass Pattern
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead.
+9 -103
View File
@@ -30,100 +30,23 @@ function Fail($msg) {
exit 1
}
function Test-WindowsBashStub($path) {
if (-not $path) { return $false }
$lowered = $path.ToLowerInvariant()
foreach ($stub in @("system32\bash.exe", "sysnative\bash.exe", "windowsapps\bash.exe")) {
if ($lowered.Contains($stub)) { return $true }
}
return $false
}
function Find-GitBash {
$cmd = Get-Command bash -ErrorAction SilentlyContinue
if ($cmd -and -not (Test-WindowsBashStub $cmd.Source)) { return $cmd.Source }
$roots = @()
foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) {
$base = [Environment]::GetEnvironmentVariable($name)
if ($base) {
$roots += (Join-Path $base "Git")
if ($name -eq "LocalAppData") { $roots += (Join-Path $base "Programs\Git") }
}
}
$roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git")
foreach ($root in ($roots | Select-Object -Unique)) {
foreach ($relative in @("bin\bash.exe", "usr\bin\bash.exe")) {
$candidate = Join-Path $root $relative
if (Test-Path $candidate) { return $candidate }
}
}
return $null
}
# 1. Locate a Python interpreter (3.11+ required)
# 1. Locate a Python interpreter (3.11+ recommended)
Write-Step "Checking for Python"
function Get-PythonVersionText($launcher, $launcherArgs) {
try {
return (& $launcher @launcherArgs -c "import sys; print('.'.join(map(str, sys.version_info[:3])))" 2>$null).Trim()
} catch {
return $null
}
}
$pyExe = $null
$pyArgs = @()
$pyVersion = $null
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
if ($pyLauncher) {
foreach ($v in @("-3.13", "-3.12", "-3.11")) {
$ver = Get-PythonVersionText $pyLauncher.Source @($v)
if ($ver) {
$pyExe = $pyLauncher.Source
$pyArgs = @($v)
$pyVersion = $ver
break
}
}
foreach ($c in @("python", "py")) {
$cmd = Get-Command $c -ErrorAction SilentlyContinue
if ($cmd) { $pyExe = $cmd.Source; break }
}
if (-not $pyExe) {
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCmd) {
$ver = Get-PythonVersionText $pythonCmd.Source @()
if ($ver) {
$versionParts = $ver.Split('.')
$major = [int]$versionParts[0]
$minor = [int]$versionParts[1]
if ($major -gt 3 -or ($major -eq 3 -and $minor -ge 11)) {
$pyExe = $pythonCmd.Source
$pyVersion = $ver
}
}
}
Fail "Python not found on PATH. Install Python 3.11+ from https://www.python.org/downloads/ (check 'Add to PATH'), then re-run this script."
}
if ($pyExe -like "*WindowsApps*python.exe") {
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
if ($pyCmd) {
$pyExe = $pyCmd.Source
$pyArgs = @("-3.11")
}
}
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."
}
$pythonLabel = ("Using Python {0}: {1} {2}" -f $pyVersion, $pyExe, ($pyArgs -join ' ')).TrimEnd()
Write-Host $pythonLabel
Write-Host ("Using Python: " + $pyExe)
# 2. Create the virtualenv if missing
$venvPy = Join-Path $PSScriptRoot "venv\Scripts\python.exe"
if (-not (Test-Path $venvPy)) {
Write-Step "Creating virtual environment (venv)"
& $pyExe @pyArgs -m venv venv
& $pyExe -m venv venv
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $venvPy)) { Fail "Failed to create the virtual environment." }
} else {
Write-Host "venv already exists - skipping creation."
@@ -141,7 +64,7 @@ Write-Step "Running first-time setup"
if ($LASTEXITCODE -ne 0) { Fail "setup.py failed." }
# 5. Friendly note about Git Bash (full Cookbook / agent-shell parity)
if (-not (Find-GitBash)) {
if (-not (Get-Command bash -ErrorAction SilentlyContinue)) {
Write-Host ""
Write-Host "NOTE: Git Bash (bash.exe) was not found on PATH." -ForegroundColor Yellow
Write-Host " The core app works without it. For full Cookbook background" -ForegroundColor Yellow
@@ -149,24 +72,7 @@ if (-not (Find-GitBash)) {
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
}
# 6. Point CUDA_PATH at a real CUDA toolkit so GPU llama-cpp-python can import.
$cudaBase = "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA"
if (Test-Path $cudaBase) {
$cudaBest = Get-ChildItem $cudaBase -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName "bin") } |
Sort-Object { try { [version]($_.Name -replace "^v", "") } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($cudaBest) {
$env:CUDA_PATH = $cudaBest.FullName
Write-Host ("Using CUDA_PATH = " + $cudaBest.FullName) -ForegroundColor Cyan
}
}
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
# -Port only reaches uvicorn as a flag. Everything that builds a URL for this
# instance - internal_api_base(), companion pairing, the MCP OAuth callback -
# reads APP_PORT, so set it too or they all assume 7000.
$env:APP_PORT = $Port
# 6. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
Write-Host "Press Ctrl+C to stop."
Write-Host ""
-142
View File
@@ -1,142 +0,0 @@
# 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", "7011"))
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")
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-94
View File
@@ -1,94 +0,0 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More