222 Commits

Author SHA1 Message Date
Kenny Van de Maele 4371425514 refactor(tools): remove dead workspace-confinement plumbing
Commit e6b1009 removed the workspace feature's entry point (deleted
routes/workspace_routes.py + static/js/workspace.js and dropped the
workspace-param parsing in chat_routes), but left the downstream backend
plumbing dangling: chat_routes passed a hardcoded workspace=None into
stream_agent_loop, which forwarded it to execute_tool_block, so the
workspace value was permanently None and every workspace-gated branch
was unreachable.

Remove the now-dead code (no behavior change, since workspace was always
None):
- src/tool_execution.py: drop _resolve_tool_path_in_workspace and the
  workspace params/branches on execute_tool_block, _direct_fallback,
  _call_mcp_tool, _do_edit_file, and _resolve_search_root; restore the
  bash/python/bg cwd to _AGENT_WORKDIR.
- src/agent_loop.py: drop the workspace param on stream_agent_loop, the
  dead 'ACTIVE WORKSPACE' system-prompt block, and the workspace forward.
- routes/chat_routes.py: drop the hardcoded workspace=None arg and var.
- tests: delete test_workspace_confine.py (tested the removed feature) and
  the workspace assertion in test_tool_policy.py.

Full suite: 2903 passed, 1 skipped.
2026-06-09 08:27:07 +02:00
Afonso Coutinho fbed9027b0 fix: backup import dropping a user's skill on cross-tenant title/id collision (#2057)
* Fix backup import dropping a user's skill on cross-tenant title/id collision

The skills block of import_data deduped incoming skills against
skills_manager.load_all(), which returns EVERY tenant's skills. So when
a user imports their own backup, any skill whose id or title collides
with another user's skill was silently skipped — the importing user
lost their own data. This is the same cross-tenant bug already fixed
for the memories block just above (#1743); the skills block was left
with the old pattern. Filter the dedup sets to the importing user's own
skills (owner == user); the full store is still saved back, preserving
other users' skills.

* Restore sys.modules after stubbing so backup test does not break collection of later src.* test modules

* Patch backup_routes auth helpers via monkeypatch instead of sys.modules stubs so the test is import-order robust

* Give FakeSkillsManager an add_skill method matching the disk-backed skills API
2026-06-09 08:04:22 +02:00
Disorder AA d9141c6e56 fix(cookbook): allow spaces and non-ASCII characters in model directory paths (#3473)
* fix(cookbook): allow spaces in model directory paths

Allow POSIX external-drive paths and Windows drive paths with spaces while keeping shell metacharacters rejected.

* fix(cookbook): also allow non-ASCII (Unicode) characters in model dir paths

The ASCII-only allowlist that rejected spaces also rejected Cyrillic,
accented Latin and CJK folder names (e.g. /Volumes/Модели,
D:\AI Models\Модели) with 400 Invalid local_dir. Switch the path
character class from [A-Za-z0-9._ -] to [\w. -] (\w is Unicode-aware on
Python 3 str patterns) so localized folder names validate, while shell
metacharacters (; & | ` $ quotes newlines) stay rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cookbook): reject local_dir path segments starting with '-'

The local_dir allowlist includes '-', so a directory like /models/-rf
(or D:\models\-rf) could be parsed as a CLI flag by hf/etc. (option
injection) — and quoting does not stop a value from being read as an
option. Guard against it inside the validator so the safety stays fully
self-contained there rather than depending on consumers' quoting.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:58:38 +02:00
onemorethan0 8ae2b5f58c fix(llm): suppress thinking mode for qwen3/gemma4 on Ollama /v1 endpoint (#3228)
* fix(llm): suppress thinking for qwen3/gemma4 on Ollama /v1 compat endpoint

When using qwen3, QwQ, gemma4, or other thinking models via Ollama's
OpenAI-compatible /v1 endpoint, the model routes all output into its
<think>...</think> reasoning block. Since Odysseus strips thinking
content from round_response and only accumulates native tool_calls,
this produces a round with 0 chars, 0 native calls, 0 tool blocks —
the agent appears to silently do nothing.

Root cause: Odysseus classifies the /v1 endpoint as provider="openai"
(not "ollama"), so the payload is built as a standard OpenAI payload
without any Ollama-specific options. Ollama's /v1 endpoint accepts
"think": false as a top-level parameter to suppress extended thinking,
but this was never sent.

Fix:
- Add _is_ollama_openai_compat_url() to detect local Ollama /v1 URLs
- Inject "think": false in both stream_llm and llm_call_async for
  thinking models (qwen3, QwQ, gemma4, DeepSeek-R1, etc.) on this
  endpoint

Verified with qwen3:14b on Ollama 0.24: with think=False the model
correctly emits native tool_calls in a single streaming chunk and
the agent executes bash/file/web tools as expected.

* fix(llm): extend _is_ollama_openai_compat_url to match localhost on any port

Per reviewer feedback on PR #3228:

1. Generalize host detection to mirror _is_ollama_native_url: match any
   localhost/127.0.0.1/0.0.0.0/::1 host (not just port 11434) so that
   custom OLLAMA_HOST ports and container remaps are also covered.

2. Add tests/test_llm_core_ollama_thinking.py covering:
   - _is_ollama_openai_compat_url for all positive/negative URL cases
     including IPv6, non-default port, native /api path, and real OpenAI
   - Payload injection: think:false set for Ollama /v1 thinking model,
     not set for non-thinking model, not set for real OpenAI endpoint,
     and set for localhost on a non-default port (the new case)
2026-06-09 07:35:15 +02:00
pewdiepie-archdaemon 637a34515d Merge remote-tracking branch 'origin/main' into dev 2026-06-09 10:41:48 +09:00
pewdiepie-archdaemon d397b3db2f Restore dropped regression fixes 2026-06-09 10:31:43 +09:00
pewdiepie-archdaemon 1a529d63d9 Fix remaining CI regressions 2026-06-09 10:21:56 +09:00
Boody f605bb3864 fix: Enforce dynamic custom search result limits in backend (#2359)
* 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

* Add run script for Uvicorn with dotenv integration

* Refactor server runner to use argparse for host and port configuration

* Remove captured output print statement from server runner

* Fix server runner to ensure cross-platform compatibility and improve log handling

* removed run.py to match original repo

* Fixing custom search not working properly

* Refactor search settings event listeners for improved functionality and clarity

* Update search function signatures to use Optional for count parameter

* revert changes

* fixed broken merge issue

* Delete services/chat_data_scraper.py

added by mistake

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-09 02:20:59 +01:00
pewdiepie-archdaemon 37c573d865 Fix model endpoint route test regressions 2026-06-09 10:16:38 +09:00
pewdiepie-archdaemon 6f29b287f6 Remove stale plan slash toggle 2026-06-09 09:54:46 +09:00
pewdiepie-archdaemon 4715a5505d Fix duplicate cookbook server helper export 2026-06-09 09:53:41 +09:00
pewdiepie-archdaemon 84ca74f04b Restore cookbook server key exports 2026-06-09 09:51:53 +09:00
pewdiepie-archdaemon e6b1009b89 Remove non-merge-ready workspace and terminal agent hooks 2026-06-09 09:48:59 +09:00
pewdiepie-archdaemon fa8c93ec0a Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.

Highlights:
- Ollama as a first-class backend in the Cookbook:
  * Download input accepts ollama-style names (name:tag) → backend=ollama
  * /api/cookbook/ollama/library (cached scrape of ollama.com + curated
    fallback so classic models like qwen2.5 stay reachable)
  * "Browse Ollama library" toggle below Download with size chips
  * Engine=Ollama in hwfit toolbar merges the Ollama library into the
    main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
    columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
  loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
  checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
  pulled up 8px to align with the row's checkboxes, GGUF File dropdown
  exposed for Ollama backend, GPU re-render on Edit serve restore,
  _forceBackend flag so saved serveState wins over backend detection,
  cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
  Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
  Clear-offline buttons in Added Models toolbar, offline-pill removed
  (opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
  reachable (vLLM returns 404 on bare /v1, fully working endpoints
  were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
  serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
  Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
  serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
  ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
  auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-09 09:46:19 +09:00
pewdiepie-archdaemon 646f8bd2a9 Remove remaining plan mode frontend code 2026-06-09 09:44:22 +09:00
pewdiepie-archdaemon 2a2a93d845 Remove plan mode from merge-ready UI 2026-06-09 09:40:20 +09:00
pewdiepie-archdaemon 06a04efc59 Merge branch 'dev'
# Conflicts:
#	routes/task_routes.py
#	src/caldav_sync.py
2026-06-09 09:36:01 +09:00
pewdiepie-archdaemon 3b01760e95 Prepare tested main sync cleanup 2026-06-09 09:34:42 +09:00
Ocean Bennett db1bbfe588 fix(sessions): keep fresh chats during auto tidy (#1871)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-09 01:06:20 +01:00
Kenny Van de Maele 2404b00f18 refactor(uploads): centralize upload byte-limits in upload_limits.py (#3364) (#3518)
Move every per-route upload byte-limit into src/upload_limits.py as a
validated, env-overridable constant via read_byte_limit_env:

- Add GALLERY_UPLOAD_MAX_BYTES, GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
  MEMORY_IMPORT_MAX_BYTES, PERSONAL_UPLOAD_MAX_BYTES,
  EMAIL_COMPOSE_UPLOAD_MAX_BYTES, STT_MAX_AUDIO_BYTES, ICS_MAX_BYTES.
- Routes import their constant instead of defining it locally: replaces 4
  raw int(os.getenv(...)) and removes 3 hardcoded literals.
- The 3 previously-hardcoded limits (email compose, STT audio, calendar
  ICS) are now env-overridable with the same ODYSSEUS_*_MAX_BYTES naming.
- Defaults unchanged, so behavior is unchanged unless an env var is set;
  an invalid value now fails fast with a clear message instead of a bare
  int() ValueError.
- Document all env vars in .env.example and the README.

Fixes #3364
2026-06-09 01:24:30 +02:00
Alexandre Teixeira a240f28af9 test(taxonomy): auto-mark tests by area and sub-area (#3491) 2026-06-09 01:13:28 +02:00
Ocean Bennett e7c1d75884 fix(models): query v1 models for llama-server endpoints (#3380)
* fix(models): query v1 models for llama-server endpoints

* test(models): accept owner kwargs in llama-server regression
2026-06-09 01:09:02 +02:00
Mateus Oliveira f7ae85590b refactor(tools): consolidate duplicated _truncate and get_mcp_manager into src/tool_utils (#3478)
* refactor(tools): consolidate duplicated _truncate and get_mcp_manager into src/tool_utils

Move all copies of _truncate(), get_mcp_manager(), and set_mcp_manager()
into a single leaf module (src/tool_utils.py) that imports only from
src.constants. This eliminates the lazy-import hack
('from src import agent_tools' inside function bodies) in tool_execution.py
and tool_implementations.py, and fixes a latent bug: the _truncate copy in
tool_execution.py was missing the isinstance guard and would crash on None.

Also deletes mcp_servers/_common.py — it was dead code with zero callers
anywhere in the codebase, containing its own copy of truncate() and
constants that already exist in src/constants.py.

* fix(tools): route remaining get_mcp_manager imports to src.tool_utils

The maintainer's feedback flagged src/task_scheduler.py:1857 and
routes/task_routes.py:977. A project-wide search found a third call site
in src/agent_loop.py that also imported get_mcp_manager from
src.agent_tools instead of src.tool_utils.

All three are now sourced from the canonical location in src.tool_utils.

---------

Co-authored-by: mcnoliveira <mcnoliveira@gmail.com>
2026-06-09 01:05:30 +02:00
Ocean Bennett 62ffcb6236 fix(cookbook): preserve same-host ssh profile selection (#3373)
* fix(cookbook): preserve same-host ssh profile selection

* fix(cookbook): resolve same-host ssh profiles in running tab and port lookups
2026-06-09 00:36:10 +02:00
Wes Huber 85c6056c87 test(models): add regression coverage for Z.AI coding endpoint probing (#2244)
Add focused tests for the z.ai/api/coding path override:
- _match_provider_curated: 5 tests verifying coding vs base key
- _probe_endpoint: 3 tests verifying model preservation, curated
  append on partial response, and base-zai exclusion

Rebased onto dev per reviewer request.

Fixes #2230

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-08 23:07:29 +01:00
Rohith Matam 049833e309 fix: skip malformed document tool call items (#3494) 2026-06-08 23:25:31 +02:00
Cookiejunky 4e497f4878 fix(cookbook): guard break-system-packages pip flag (#3510) 2026-06-08 23:10:20 +02:00
Lucas Daniel 5462030cde fix(auth): per-user allowed-models checklist ignores cache, [None] doesn't block (#3355)
Three issues combined to make the per-user 'Allowed models' checklist
unreliable (#3032):

1. admin.js _loadModelsForUser fetched /api/models, which is backed by
   cached_models — endpoints that haven't been probed yet (e.g. a
   freshly-added DeepSeek API endpoint) simply didn't show up in the
   checklist. Switched to /api/model-endpoints, which always reflects
   every configured endpoint regardless of cache state.

2. _saveModels sent allowed_models: [] both when the admin clicked
   [All] (no restriction) and [None] (block everything) — the backend
   had no way to distinguish the two.

3. _enforce_chat_privileges treated an empty allowed_models list as
   'no restriction' (falsy -> skip the check), so [None] had no effect.

Added an explicit block_all_models privilege flag (defaulting to False,
and forced to False for admins) that admin.js now sets when zero models
are checked. _enforce_chat_privileges checks it first and 403s
regardless of allowed_models contents.
2026-06-08 22:52:39 +02:00
Lucas Daniel 0a324f20d2 fix(agent): stop treating illustrative Markdown fences as tool calls for native function-calling models (#3356)
* fix(agent): stop executing illustrative Markdown fences as tool calls for native function-calling models

_resolve_tool_blocks fell back to the textual parse_tool_blocks() fenced-block
parser whenever a model produced no native tool_calls, regardless of whether
that model has a reliable native function-calling channel. Native models
(GPT/Claude/Grok/Qwen3/DeepSeek-V, etc. - _is_api_model true) commonly write
illustrative ```bash/```python/```json examples in guide-only prose; the
fallback parser matched these and executed them as real commands, sometimes
looping for several rounds as the model tried to clarify with more examples
(#3222).

Restrict the textual fenced-block fallback to non-native models, which rely
on it as their only tool-invocation channel. Native models are trusted to use
their structured tool_calls channel for real invocations; when they don't
emit one, a bare fence in their response is prose, not an action. The native
tool_calls path itself is untouched.

This sits one layer below #3088's guide-only policy enforcement: that PR
blocks tool exposure/execution on explicit no-tools requests, while this fixes
the parser so ordinary illustrative fences are never misread as calls in the
first place, on any turn.

* fix(agent): gate only the fenced-example pattern for native models, preserve DSML/invoke recovery and persistence

_resolve_tool_blocks previously short-circuited the entire textual parser
(tool_blocks = [] if is_api_model else parse_tool_blocks(...)) for native
function-calling models with no native tool_calls. That also dropped Patterns
2-5 (explicit [TOOL_CALL]/<invoke>/<tool_code>/DSML markup leaked into content
as text), which are real calls a model couldn't emit on its structured channel
(e.g. DeepSeek-V falling back to DSML), not illustrative examples.

parse_tool_blocks/strip_tool_blocks now take a skip_fenced flag that gates ONLY
Pattern 1 (the fenced ```bash/```python/```json block matcher). _resolve_tool_blocks
passes skip_fenced=is_api_model so fenced examples stop being executed for
native models while [TOOL_CALL]/<invoke>/<tool_code>/DSML stay fully active and
recoverable. cleaned_round mirrors the same gate when persisting round text, so
an illustrative fence that wasn't executed isn't stripped from saved/reloaded
history either (it was streaming once and then disappearing on reload).
2026-06-08 22:25:28 +02:00
Mazen Tamer Salah 8e494cc1c4 fix(chat): keep balanced trailing ')' when extracting URLs (#3406)
extract_urls() stripped any trailing ')' unconditionally via
`re.sub(r'[.,;:!?\)]+$', '', url)`. That corrupts URLs that legitimately
end in a parenthesis — most commonly Wikipedia disambiguation links like
https://en.wikipedia.org/wiki/Python_(programming_language), which became
...Python_(programming_language and then 404 when fetched by the web/research
tools.

Strip trailing sentence punctuation as before, but only drop a ')' when it is
unbalanced (more ')' than '('), so a prose-glued "(see https://example.com)"
still loses its closing paren while balanced URLs keep theirs.

Added tests/test_extract_urls.py covering balanced, unbalanced, nested, and
trailing-punctuation cases.
2026-06-08 21:33:29 +02:00
nubs 932b7f2446 fix(email): close IMAP socket when connect/login fails (#3174) (#3363)
* fix(email): close IMAP socket when connect/login fails (#3174)

_imap_connect opened a live socket via _open_imap_connection and then
called conn.login() with no try/finally, and _open_imap_connection called
conn.starttls() unguarded. When auth fails (e.g. an Office 365 app password
on an MFA-enabled tenant, #3174) or STARTTLS is rejected, the already-open
socket was orphaned. Every IMAP caller funnels through _imap_connect,
including the 30-minute _auto_summarize_poller, so a persistently
misconfigured account leaked one descriptor per pass toward FD exhaustion.

The previously merged leak fixes (#1325/#1330/#1423/#1530) only guard the
post-connect body and monkeypatch _imap_connect to succeed, so this
connect-time path was uncovered. Wrap login() and starttls() so a failure
calls conn.shutdown() (low-level close; logout() can't run pre-auth) before
re-raising. Adds two regression tests that fail without the guard.

* fix(email): guard MCP IMAP+SMTP connect-time leaks too (#3174)

Folds in the sibling connect-time leaks vdmkenny flagged on #3363, so the
whole connect-then-step leak class is closed in one place:

- mcp_servers/email_server.py::_imap_connect — guard starttls() and login();
  close pre-auth with conn.shutdown() before re-raising.
- mcp_servers/email_server.py::_smtp_connect — guard starttls() and login();
  SMTP has no shutdown(), so close with conn.close() (socket close, no QUIT).

Routes SMTP (_send_smtp_message) is already safe via 'with smtplib.SMTP(...)'.
Adds four regression tests (one per guard), verified to fail without the fix.
2026-06-08 21:21:41 +02:00
Alex Little a58f526992 fix(presets): scope expand-prompt model resolution to owner (#3477)
* fix(presets): scope expand-prompt model resolution to owner

/api/presets/expand resolved its model endpoint with no owner, so in a
multi-user setup it could match another user's endpoint and use its URL
and decrypted api_key. Pass effective_user(request) to _resolve_model so
resolution is owner-scoped. Adds a regression test.

* fix(presets): scope teacher and audit model resolution to owner

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Alex Little <alexwilliamlittle@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Kenny Van de Maele <kenny@kvandemaele.be>
2026-06-08 21:12:02 +02:00
nopoz ed6cc88974 ci: harden existing workflows for the security gate (#3498)
Pin actions to commit SHAs, set persist-credentials: false on every
checkout, and scope token permissions to the jobs that use them. Suppress
the two findings that are safe by design: the description bot's
pull_request_target trigger (no fork code runs) and an intentional
word-split in the docker manifest step.

Clears actionlint and zizmor against dev so the blocking gate from #1314
can pass once both land.
2026-06-08 20:58:59 +02:00
Mazen Tamer Salah 5198516979 fix(sessions): copy message metadata when forking a session (#3409)
fork_session passed each source message's metadata dict by reference into the
new session. add_message() -> _persist_message() stamps _db_id (and timestamp)
onto that dict in place, so persisting the fork overwrote the SOURCE messages'
_db_id with the forked rows' ids — silently breaking edit/delete-by-id on the
original conversation.

Copy the metadata dict per message so the fork and source no longer alias.

Adds tests/test_fork_session_metadata.py asserting the source session's
message metadata is unchanged after a fork.
2026-06-08 20:49:15 +02:00
Giuseppe Castelluccio 095c74b985 fix(security): fail closed in /api/models auth gate on unexpected errors (#3489)
GET /api/models swallowed any non-HTTPException raised while checking
whether the caller is authenticated (bare except Exception: pass), so a
broken auth_manager or an exception from get_current_user silently
granted the full model list to an anonymous caller instead of rejecting
the request. Now any unexpected exception logs and returns HTTP 500.

Split out of #2360 per reviewer request to keep the deny-list and the
auth-gate fix as separate, single-purpose PRs.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:23:39 +02:00
CorVous 34a3f8637a fix(memory): make auto-memory extraction reliable for reasoning models (#3190)
* fix(memory): auto-memory extracted nothing — flatten window so the prompt ends on a user turn

extract_and_store appended the recent window as raw alternating role messages
after the system prompt. Since the window is the last N messages, the prompt
usually ENDED on an assistant turn — and a chat model given a prompt ending on
an assistant turn returns an empty completion (nothing to answer). The result
was facts=[] → "Auto memory extraction ran: 0 candidates" on every run, so no
memories were ever stored, while skill extraction (which flattens the transcript
into a single user message) worked fine.

Flatten the window into one user message ending with an explicit instruction,
mirroring the skill extractor, so the model always responds. Also harden parsing
for reasoning models, matching the audit path which already does this:
- raise max_tokens 500 → 4096 (a reasoning model spends the budget on <think>
  before emitting JSON; 500 truncated it before any JSON appeared);
- strip <think>/prose preambles via strip_think and slice the embedded JSON
  array before json.loads, instead of bombing on char 0.

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

* chore: tighten memory-extraction-empty-completion — clarify JSON-slice comment re prior strip steps

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

* docs(memory): reframe the comment to the accurate root cause (raw-chat framing)

The earlier comment leaned on "ends on an assistant turn -> empty completion",
which is only one failure mode. The dominant cause, confirmed by a controlled
repro (0/6 old vs 6/6 new on this model), is that passing the window as raw chat
messages makes the model treat it as a conversation to continue rather than a
transcript to analyze, so it returns [] even when durable facts are present.

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

* test(memory): cover extraction JSON parsing + slice trailing commentary unconditionally

Factor the strip/fence/slice/json.loads logic out of extract_and_store into
a pure module-level helper _parse_extraction_json(raw) -> list and drop the
'text[0] != "["' guard so the array is sliced whenever both brackets exist
(fixes trailing commentary like '[...] Done!' reaching json.loads).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:57:44 +02:00
Mazen Tamer Salah 8449baea80 fix(api-tokens): preserve scopes on a partial token update (#3407)
PATCH /api/tokens/{id} unconditionally recomputed scopes from
payload.get("scopes"). On a rename — body {"name": "..."} with no "scopes"
key — that is None, so _normalize_scopes(None) returned the default ["chat"]
and the handler overwrote token.scopes, silently dropping every scope the
token had been granted (e.g. email:read, calendar:write).

Only write scopes when the request actually includes them, and return the
token's real stored scopes in the response (matching the GET /tokens display
shape) instead of the recomputed default.

tests/test_api_token_routes.py: add rename-preserves-scopes,
explicit-scopes-applied, and missing-token-404 cases for the PATCH handler.
2026-06-08 19:37:31 +02:00
Mazen Tamer Salah d58202d10e fix(presets): persist presets atomically to avoid corruption on crash (#2169)
PresetManager.save() used a plain open("w") + json.dump, which truncates
presets.json before writing the new content. A crash, power loss, or
serialization error mid-write leaves the file truncated/empty and every
saved preset is lost.

Route the write through core.atomic_io.atomic_write_json (tmp file +
os.replace), matching how the rest of the codebase persists JSON state.
The helper is imported lazily so this module stays free of the heavy core
package import graph at module load time.

Adds tests/test_preset_atomic_save.py covering the source contract, a
failed-write leaving the existing file intact, and a round trip.
2026-06-08 19:16:37 +02:00
Mazen Tamer Salah 1209f258d7 fix(caldav): skip the prune when any object fails to parse (#3454)
* fix(caldav): don't prune the whole window when no objects could be parsed

The post-sync prune deletes local origin=="caldav" rows in the window whose UID
the server didn't just return. With an empty seen_uids it falls back to
`uid.isnot(None)` — a match-all delete. That's right when the calendar is
genuinely empty, but when the server returns objects and every one fails to
parse (malformed iCal / an icalendar error), seen_uids is empty only because
nothing could be read, so the match-all branch silently deletes every local
event in the 90-day-back/365-day-forward window.

Track whether any object failed to parse and gate the prune with a small pure
helper `_should_prune_window(seen_uids, parse_failed)`: prune when something was
read, or when the calendar is genuinely empty (no objects, no parse errors), but
never when objects came back unreadable.

Adds tests/test_caldav_prune_parse_failure.py for the three cases.

* fix(caldav): skip the prune on any parse failure, not just total

Review follow-up (#3454): _should_prune_window returned True whenever seen_uids
was non-empty, so a partial parse failure (say 48 of 50 objects parse) still
pruned the 2 unreadable-but-still-upstream events, because their UIDs were absent
from seen_uids. Any parse failure makes seen_uids an incomplete view of the
server, so pruning against it is unsafe whether the failure is total or partial.

Skip the prune on any parse failure (return not parse_failed); only prune on a
clean read (a genuinely empty window is still safe to prune). Tradeoff: one
permanently-unparseable event pauses deletion mirroring until it is fixed, which
is the safe direction (false-keep beats false-delete).

Replace the now-incorrect "partial failure still prunes" assertion with a
partial-failure regression: one object parses, one fails, so the prune is
skipped and the unparsed event's local copy is not deleted.

---------

Co-authored-by: Kenny Van de Maele <kenny@kvandemaele.be>
2026-06-08 18:59:14 +02:00
Mazen Tamer Salah d71284194b fix(memory): only delete memories the model explicitly drops in tidy (#3455)
* fix(memory): only delete memories the model explicitly drops in tidy

The AI memory-tidy path computed deletions as the complement of the model's
`keep` list (`if mid not in keep_ids: continue`). When the model returned a
valid response that simply omitted some existing ids — a common LLM lapse — every
omitted memory was silently deleted, even though it was neither a duplicate nor
listed in `drop`.

Honor the explicit `drop` set instead: delete only ids the model dropped (minus
any it saw only truncated), and preserve everything else, still applying cleaned
text/category from `keep`.

Adds tests/test_consolidate_memory_explicit_drops.py: a memory the model omits
from both keep and drop survives; an explicitly dropped one is removed.

* refactor(memory): remove now-dead keep_ids from tidy

After deletion switched to drop_ids and text/category rewrites to cleaned_by_id,
keep_ids was written but never read. Remove the init, the .add(mid) in the keep
loop, and the truncated .update() (its truncated-protection is already covered by
`drop_ids -= truncated_ids`). Pure deletion, no behavior change; tests stay green.

Addresses review feedback on #3455.

---------

Co-authored-by: Kenny Van de Maele <kenny@kvandemaele.be>
2026-06-08 18:54:45 +02:00
Aman Tewary d458cade98 docs(email): clarify Outlook password auth failures
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-08 15:32:16 +01:00
PewDiePie fe19d072e3 Revert "fix: expose supports_tools toggle for local endpoints in UI (#3195)" (#3438)
This reverts commit 7b68413433.

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Kenny Van de Maele <kenny@kvandemaele.be>
2026-06-08 14:46:01 +02:00
PewDiePie 09565acc1e Revert "feat(model-picker): add remove-from-recent button to Recent section rows (#2894)" (#3437)
This reverts commit 2a422c00ec.

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
2026-06-08 14:41:25 +02:00
Mostafa Eid d6882a895e feat(chat): recall last user message on empty composer ArrowUp (#1175)
Pressing ArrowUp on an empty #message composer restores the last sent user text, matching common chat-app UX (Slack, Discord, ChatGPT).

- Read from #chat-history .msg-user dataset.raw (same path as resend/regenerate), not session sidebar metadata

- Literal empty check (whitespace-only drafts are preserved); ignore Shift/Alt/Ctrl/Meta and IME composition

- Extract wiring to composerArrowUpRecall.js; rAF + 250ms retry only (no global MutationObserver)

- Add tests/test_composer_arrow_up_recall_js.py

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:06:05 +02:00
Vykos 4a9085d252 fix(endpoint): scope secondary endpoint lookups by owner
* Scope secondary endpoint lookups by owner

* Reject unregistered image endpoint URLs for non-admins

* Adjust owner-scope tests for rebased routes

* Allow non-admins to compare endpoints they own

The compare owner-scope guard called _reject_raw_endpoint_url_for_non_admin
with endpoint_id=None, so it rejected every signed-in non-admin
/api/compare/start request — even for endpoints the caller owns — because
compare resolves endpoints by URL and carries no endpoint_id. That locked
non-admins out of compare entirely.

Resolve the owned ModelEndpoint first and pass its id, so a registered
endpoint the caller owns is allowed while only truly raw, unregistered URLs
are rejected (mirrors the gallery inpaint/harmonize checks in this PR).
Replace the source-only reject test with deterministic reject + allow
regressions that no longer depend on the dev DB contents.

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

* Bind compare sessions to the resolved owner-scoped endpoint

/api/compare/start created the [CMP] helper sessions with the raw
caller-supplied endpoint URL and only used the owner-scoped lookup to
decide whether to copy an API key. That stopped key borrowing but still
let a non-admin inject an arbitrary raw endpoint URL into the compare
session path.

Now, when the supplied URL resolves to a registered endpoint visible to
the caller, the session binds to that row's own normalized base URL
(build_chat_url(normalize_base(ep.base_url))) plus its headers — the same
registered-endpoint shape session_routes uses. The raw URL survives only
when ep is None, which non-admins already hit a 403 on, leaving raw URLs
reachable solely for admins / single-user mode with no borrowed key.

Adds compare-specific behavior tests: another user's private endpoint is
rejected (nothing created), the session binds to the stored URL rather
than the raw input, and an admin raw URL is allowed but carries no
inherited key.

Addresses the review on #1511.

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

* Validate both compare endpoints before creating any session

start_comparison resolved + created each [CMP] session inside one loop,
so a request pairing a valid owned endpoint A with an unregistered raw
endpoint B raised 403 only after A's session was already created — and
its Authorization header copied in. The rejected request left a partial
compare session with that header behind.

Split the flow into two phases: phase 1 resolves and owner-validates
both endpoints (running the raw-URL reject helper) and stashes the
session URL + headers; phase 2 creates the two sessions only once both
passed. A 403 on either endpoint now aborts with nothing created and no
header copied.

Adds a regression test: owned endpoint A + unregistered/raw endpoint B
-> 403 with no sessions created.

Addresses the follow-up review on #1511.

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

* Resolve compare credentials by endpoint id, not URL alone

Two endpoints visible to a caller can share a base_url but hold different
api_keys. _owned_endpoint_by_url returned whichever row sorted first, so
/api/compare/start could copy the wrong key into the [CMP] session.

Add _owned_endpoint_by_id (same owner scoping) and optional endpoint_a_id/
endpoint_b_id form fields. The id pins the exact registered endpoint; URL
resolution remains only for legacy/admin raw-URL callers. An id the caller
can't see 404s instead of falling back to a same-URL row.

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

* Loosen research-routes owner-scope assertion to the stable substring

The rebased _resolve_research_endpoint generalized its owner derivation to
honor an explicit owner arg first (owner = owner or getattr(sess, ...)), so
the exact-line assertion broke CI. Assert the stable session-derivation
substring instead of the full line.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:51:55 +01:00
Kenny Van de Maele aab203cf51 fix(ci): correct malformed expression in docker-publish Inspect step (#3425)
The Inspect step had `${{ github.ref == ''refs/heads/main'' ... }}` with
doubled single quotes (YAML-scalar escaping) inside a `run: |` block, which
GitHub's expression parser rejects, failing the whole workflow at startup
(no jobs run). Replace with a plain shell conditional on $GITHUB_REF.
2026-06-08 12:06:00 +02:00
Kenny Van de Maele ab2f7cffca ci: publish multi-arch Odysseus image to GHCR (dev + stable) (#3423)
* ci: build and publish multi-arch Odysseus image to GHCR

Push to main publishes :latest and :X.Y.Z; push to dev publishes :dev and
an immutable :X.Y.Z-dev.<sha>. Multi-arch (linux/amd64 + linux/arm64) via
per-arch native runners building by digest, merged into one manifest list.
Uses the in-repo GITHUB_TOKEN (packages: write), actions pinned by SHA.

* ci(docker): pin actions to latest major releases

checkout v6.0.3 (matches the PR-checks workflow), setup-buildx v4.1.0,
login v4.2.0, build-push v7.2.0, metadata v6.1.0, upload-artifact v7.0.1,
download-artifact v8.0.1 — all by commit SHA.
2026-06-08 12:02:06 +02:00
Kenny Van de Maele fe8d8cd020 fix(issue-template): validate bug reports against dev, not main (#3420)
Cloners default to the dev branch (CONTRIBUTING: main is the curated
release, dev is where fixes land). The bug template required ticking
'latest code from main', so reporters confirm a stale branch and bugs
already fixed on dev get re-filed. Ask them to reproduce on latest dev.
2026-06-08 11:40:41 +02:00
michaelxer 233390546c fix: hide shell access and plan mode buttons in chat mode (#3417)
When in chat mode, the shell access and plan mode buttons should not be
visible. These buttons are only relevant in agent mode where the AI can
use shell commands and planning features.

Changes:
- Modified applyModeToToggles() to hide bash-toggle-btn and plan-toggle-btn
  when mode is 'chat'
- Added immediate hiding on page load to prevent flash of buttons
- Buttons are shown again when switching to agent mode

Fixes #3411

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-08 11:32:37 +02:00
stocky789 1e0d9b92af feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features

- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.

* refactor: share provider device-flow setup

- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI

* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX

Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
  shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
  applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
  (centralized behind _is_discovery_only_provider) — the Codex/Responses API
  has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
  table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
  backing them is deleted (_delete_orphaned_provider_auth), surfaced via
  cleared_provider_auth in the delete response.

Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
  panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
  into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
  Copy works in non-secure (HTTP/LAN) contexts.

* fix: harden chatgpt subscription provider

* chore: remove PR media from branch

* Fix chatgpt subscription recovery and token handling

---------

Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 10:19:18 +02:00
Mike ac94885c84 refactor(constants): single source of truth for data dir (#3368)
* refactor(constants): single source of truth for data dir + merge core/src constants

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(contributing): use named src.constants for data paths, drop core/constants references

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:58:52 +02:00
Lucas Daniel adc6ac9394 fix(compare): stream Compare panes directly to stop upstream promptly
The previous approach polled request.is_disconnected() inside the
async-for body of the chat/agent streaming loops. That happens too
late: by the time the poll runs, __anext__() has already awaited and
consumed the next upstream chunk, so a slow or silent generation could
still run for a full round-trip (or until a read timeout) after the
client disconnected. It was also unconditional, which would have made
ordinary chat navigation/refresh/tab-close stop a run that the
detached-run design intentionally keeps going server-side.

Both problems trace back to the same root cause: chat_stream always
wraps its generator in agent_runs (the detached-run manager), which
decouples the generator's lifetime from the SSE response on purpose so
normal chat/agent streams survive the client going away. Polling
disconnection inside a detached generator can never be "prompt" — the
generator isn't tied to that request anymore — and doing so defeats the
whole point of detaching it.

Compare panes don't need (or want) that: each pane's session exists
only to drive that one generation, there's nothing meaningful to
/resume, and the user expects the pane's Stop button — which aborts the
fetch and closes the SSE — to cancel the upstream call right away. So
route compare-mode requests around the agent_runs wrapper entirely and
stream the generator directly as the SSE body. Starlette already
cancels a streaming response's body iterator (raising
CancelledError/GeneratorExit into it) the instant it notices the client
disconnected — including while the generator is mid-await on the next
upstream chunk — and the existing except (CancelledError, GeneratorExit)
handlers in both the chat-mode and agent-mode loops already save the
partial response exactly once. No polling needed; the redesign just
stops getting in its own way.

Normal (non-Compare) chat and agent streams are untouched and keep
going through agent_runs, preserving detached-run semantics (surviving
tab close / navigation / refresh, reconnect via /api/chat/resume).

Replaces the source-text assertions in
tests/test_compare_stop_disconnect_poll.py with runtime tests that
actually exercise the cancellation contract: a Compare-shaped generator
is cancelled mid-await (not after the next chunk arrives) and saves its
partial exactly once; a normal completion still saves exactly once via
the completion path; agent_runs keeps a detached run alive when its
subscriber disconnects and only stops it on an explicit stop()/cancel
(also saving the partial exactly once); and the cancellation contract
is pinned for both chat-mode- and agent-mode-shaped chunk sequences.
2026-06-08 01:13:45 +01:00
Lucas Daniel fa7c4f8ea9 fix(search): catch HTTPStatusError so 403/404 URLs degrade gracefully instead of 500 (#2203)
raise_for_status() raises httpx.HTTPStatusError for 4xx/5xx responses,
but the surrounding try/except only caught httpx.RequestError (network
errors) and RateLimitError (429). Any other HTTP error code propagated
uncaught up through chat_processor -> chat_helpers -> chat_routes and
surfaced as a 500 Internal Server Error.

Added an explicit except httpx.HTTPStatusError clause that logs a warning
and returns an empty result, matching the behaviour already in place for
network errors.

Also adds focused regression tests that exercise the real
fetch_webpage_content() path with a mocked _get_public_url:
- 403/404 responses return the standard empty-result shape instead of
  raising, proving the new HTTPStatusError handling works end to end.
- 429 responses still take their own dedicated rate-limit branch (the
  status_code == 429 check runs before raise_for_status() is reached),
  keeping that behaviour distinct from the new generic HTTPStatusError
  handling.

Dropped the unrelated builtin_mcp.py change that had been carried over
from a rebase; that fix is tracked separately in #2018 and this branch
should stay scoped to the search content fetch path.

Closes #2148
2026-06-08 01:09:21 +01:00
Alexandre Teixeira 77b75ca97e docs(tests): define testing standard and taxonomy (#3372) 2026-06-08 01:15:47 +02:00
Kenny Van de Maele 505d8bae5a fix(cookbook): locate cookbook_state.json via DATA_DIR, not hardcoded /app/data (#3332)
Three call sites hardcoded Path("/app/data/cookbook_state.json"), which only
exists in Docker; on a native run the real path is <repo>/data, so the state
file looked missing and cookbook serve-state was silently ignored. Two others
used os.environ.get("DATA_DIR", "data") (a relative fallback, since DATA_DIR is
never set as an env var). Route all five through core.constants.DATA_DIR so the
path is consistent and absolute on both Docker and native.

Part of #3331.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:13:47 +01:00
horribleCodes 9c90f62657 fix(platform): Improve WSL SSH remote compatibility (#3316)
* fix(platform): add WSL compatibility functions and path translation
fix(cookbook): enhance model scan script to support additional HuggingFace cache paths
fix(hardware): improve cache key generation for remote SSH context
test(tests): add tests for WSL detection and path translation functionality

* fix(cookbook): prefer prebuilt wheels for llama-cpp-python and normalize package aliases

* fix: enable StrictHostKeyChecking in nvidia probe
refactor: consolidate ssh & powershell command execution to utility functions in core module
refactor: consolidate nvidia path candidates in to single variables in core module
tests: add tests for new utility functions

* fix: correct wrong variable name
2026-06-08 00:33:50 +02:00
Lucas Daniel 73315e6ddc fix(skill-extractor): walk all brace candidates so stray braces in prose do not swallow valid JSON (#2205)
* fix(skill-extractor): walk all brace candidates so stray braces in prose do not swallow valid JSON

The extractor sliced from the FIRST brace to the LAST brace to recover
JSON embedded in surrounding commentary. When the model emits stray
braces before the JSON object, the slice produces invalid JSON,
json.loads raises, and the exception is swallowed -- the skill is
silently lost.

Fix: walk each brace candidate left-to-right and attempt json.loads on
each slice. The first candidate that parses successfully wins. If none
parse, json.loads on the original text raises and the existing
JSONDecodeError handler logs and returns None as before.

Tested locally -- 8/8 tests passed:
  tests/test_extract_skill_json_nonstring.py (2 passed)
  tests/test_skill_extractor_rows.py (1 passed)
  tests/test_search_content_extraction_parity.py (2 passed)
  tests/test_deep_research_search_error.py (3 passed)

Closes #2199

* test(skill-extractor): add focused repro for stray-brace JSON recovery

* test(skill-extractor): add regression test for leading invalid-brace fragment

Addresses the remaining edge case from review: a response that *starts*
with a brace but the leading fragment isn't valid JSON (e.g.
'{not json}\n{"title": "Valid later", ...}') still needs to recover
the valid skill object that follows.

_extract_json_object (already on dev) handles this correctly — it tries
the whole de-fenced string first, then walks each '{' candidate left-to-
right regardless of whether the response begins with '{', so the leading
invalid fragment no longer short-circuits recovery of the real object.
Updates the comment at the call site to call this out explicitly and adds
a regression test covering exactly the scenario described in review.
2026-06-07 23:31:12 +01:00
michaelxer 7b68413433 fix: expose supports_tools toggle for local endpoints in UI (#3195)
* fix: expose supports_tools toggle for local endpoints in UI

Local endpoints (Ollama, vLLM, etc.) default to fenced tool blocks
when supports_tools is not set, which breaks tool calling for models
that support native function calling. The backend already supports
per-endpoint supports_tools overrides via the PATCH API, but there
was no UI to set it.

Add a 'Tools: Auto/On/Off' toggle button for local endpoints that
cycles through the three states:
- Auto (null): use the existing heuristic
- On (true): always use native function calling
- Off (false): always use fenced tool blocks

Fixes #3141

* docs: add screenshot of supports_tools toggle showing Auto/On/Off states

* Add Tools toggle screenshot for PR #3195

* refactor: convert Tools toggle to select dropdown per review feedback

Replace cycle-through button with a <select> dropdown for the
supports_tools tri-state setting. Options: Auto / On / Off with
explicit labels. Uses existing admin select styling. Fires PATCH
on change event. Same API contract (Auto=null, On=true, Off=false).

* Update Tools toggle screenshot (now dropdown select)

* fix: remove orphan screenshot and move Tools dropdown below button row

- Remove docs/screenshots/tools-toggle-three-states.png (unreferenced image causing test_no_orphan_images_in_docs to fail)
- Move Tools dropdown to its own line below Disable/Delete buttons, aligned right
- Keep Disable and Delete buttons grouped together per maintainer feedback

* fix: move Tools select onto same row left of Disable/Delete, use CSS class

Per vdmkenny feedback: move the Tools dropdown select from its own row below
the button group onto the same row, to the left of the Disable and Delete
buttons (which stay adjacent on the right). Replace inline style on the
button row with the existing .admin-ep-actions CSS class, adding
align-items:center for proper vertical alignment.

* chore: remove committed screenshots from tree

Screenshots should be in PR description/comments, not in repo history.

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-08 00:29:06 +02:00
Kenny Van de Maele 3557a3f495 fix(ci): restore pull-requests:write for PR label/comment writes (#3367)
#3336 reduced the PR-checks workflow to pull-requests:read on the
assumption that PR labels/comments only need issues:write (the REST path
is /issues/{n}/...). They do not: modifying a pull request's labels or
comments requires the pull-requests scope, so issues:write alone returns
403 and crashed the description check on every PR. Restore
pull-requests:write, and fail soft in swapLabel so a label-permission
error can never mask the description verdict.
2026-06-08 00:26:30 +02:00
Kenny Van de Maele c46ea44f43 ci(pr-checks): conventional-commit title check, unmergeable-PR flagging, pin actions by SHA (#3336)
* ci(pr-checks): add Conventional Commits PR-title check, pin actions by SHA

Add a check-title job that fails the PR when the title is not Conventional
Commits format (type(scope): summary), via an inline github-script regex.
Pin the workflow's actions to their latest release commit SHAs:
actions/checkout v6.0.3 and actions/github-script v9.0.0.

* ci(pr-checks): flag unmergeable PRs in the PR-checks workflow

Add a check-mergeable job to the (renamed) PR checks workflow: on PR events,
poll the PR's mergeable state and, when it conflicts with the base, remove
'ready for review', add a red 'merge conflict' label (auto-created), and
comment; clear the label once mergeable again. Single-PR, no push trigger.
Add ready_for_review to the trigger types.

* ci(pr-checks): drop the comment from check-mergeable, label swap only

* ci(pr-checks): least-privilege workflow permissions

contents:read for base-ref checkout, pull-requests:read for pulls.get
mergeability, issues:write for label + comment management. Drops the
unused pull-requests:write (labels and PR comments go through the issues
API).
2026-06-08 00:00:51 +02:00
Alexandre Teixeira a017108d41 refactor(tests): add temp sqlite helper (#2930) 2026-06-07 23:44:16 +02:00
Alexandre Teixeira 9ad6a2809e test(diffusion-server): exercise security middleware wiring (#3214) 2026-06-07 23:42:11 +02:00
Kenny Van de Maele 92300b5d67 fix(search): write cache under DATA_DIR, guard mkdir against read-only path (#3334)
services/search/cache.py set CACHE_DIR = services/cache (the source tree) and
mkdir'd it at import, unguarded. In Docker services/ is the read-only image
layer, so the mkdir fails at import (same class as the analytics bug #2366).
Move the cache under DATA_DIR/cache (writable on Docker and native) and wrap
the mkdir so an unwritable path disables disk cache instead of crashing import.

Part of #3331.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:37:12 +01:00
Kenny Van de Maele dd4cdaf251 docs(contributing): require constants/helpers over hardcoded paths and URLs (#3335)
* docs(contributing): require constants/helpers over hardcoded paths and URLs

Add a Code conventions section: don't hardcode filesystem paths or loopback
URLs, use DATA_DIR / internal_api_base() from core.constants, guard dir
creation, and add a constant when a repeated literal has none. Codifies the
class of bug behind #2366, #2752, and #3331.

Part of #3331.

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

* docs(contributing): add Conventional Commits to code conventions

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:27:43 +01:00
nubs 1a0e1c5d69 fix(documents): restore PDF library metadata and preview (#2483)
PDF uploads are stored as markdown wrappers with pdf_source or pdf_form_source markers so the editor can preserve extracted text, form fields, and annotations. The library exposed that internal wrapper: auto-created PDF documents used the hashed storage filename as the title, and row/facet language reported markdown instead of pdf.

Derive chat-upload PDF titles from the original upload name, derive document-library display language from the PDF source marker for rows, filters, and facets, and keep markdown wrappers excluded from the markdown facet when they represent PDFs.

The expanded library card already renders PDF-backed documents through /api/document/{id}/render-pdf. Allow only that inline PDF preview endpoint to be framed by same-origin app pages while leaving normal routes on X-Frame-Options: DENY and frame-ancestors none.

Also tighten the existing PDF marker regression assertion so it matches the actual historical corruption signature instead of contradicting the preserved [Page 1 text]: marker.

Fixes #2468
2026-06-07 23:23:27 +02:00
Kenny Van de Maele 76c1f42ab0 fix: route all agent loopback calls through internal_api_base() helper (#3322)
#2753 made the agent loopback base port-configurable but only for
_COOKBOOK_BASE in tool_implementations. Several other in-process loopback
calls still hardcoded http://localhost:7000 and broke off port 7000:
cookbook_serve_lifecycle (model-endpoints x2, shell/exec), builtin_actions
(model/serve), task_routes (calendar x3), and the gallery/email calls in
tool_implementations.

Extract the resolution (ODYSSEUS_INTERNAL_BASE / APP_PORT / 7000 fallback,
127.0.0.1 to avoid IPv6 ambiguity) into core.constants.internal_api_base()
and route every call site through it. Rename the now-misnamed _COOKBOOK_BASE
to _INTERNAL_BASE since it serves gallery/email/calendar/serve too. Adds a
test for the resolver plus a regression guard against reintroducing the
literal.

Part of #2752.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:22:09 +01:00
Gunnar Arias d85c5e335e fix(security): harden untrusted_context_message against delimiter spoofing (#3086)
* fix(security): harden untrusted_context_message against delimiter spoofing

Root cause: untrusted_context_message() did not sanitise content before
interpolating it into the <<<UNTRUSTED_SOURCE_DATA>>> / <<<END_UNTRUSTED_SOURCE_DATA>>>
delimited sandbox block. Malicious content embedding the literal delimiter
strings could prematurely close the sandbox and inject instructions that
the LLM treats as trusted.

Fix: add _escape_guard_markers() helper that replaces the guard marker
strings with structurally inert tokens (<<<_UNTRUSTED_DATA>>> and
<<<_END_UNTRUSTED_DATA>>>) before the content is wrapped. The function is
applied in untrusted_context_message() after casting content to str.

The existing ~13 call sites (chat_processor.py, agent_loop.py,
deep_research.py, chat_helpers.py, chat_routes.py) are unaffected because
they pass content through without inspecting the output delimiters.

Regression tests added in tests/test_prompt_security.py covering:
- _escape_guard_markers unit tests (open, close, both, benign passthrough)
- untrusted_context_message integration tests (delimiter spoofing
  neutralisation, type coercion, None handling, metadata preservation)

Resolves #3056

* fix(security): sanitize label for newlines and guard markers

Addresses reviewer feedback on PR #3086:
- Normalize label: strip CR/LF to prevent pre-guard line injection
- Escape guard marker literals in label via _escape_guard_markers()
- Add regression tests for label-based newline injection, GUARD_OPEN
  and GUARD_CLOSE in label, and exactly-one-structural-guard assertion

* fix(security): move Source label inside GUARD_OPEN block

The reviewer correctly identified that even after sanitizing the label,
any user-derived label text (e.g. `f"web page: {url}"`) still appeared
before GUARD_OPEN in the trusted framing zone, where the LLM treats it
as trusted instructions.

Fix: move the 'Source: {label}' line to inside the guarded block so
only the hardcoded UNTRUSTED_CONTEXT_HEADER sits before GUARD_OPEN.
The raw label is still kept in metadata["source"] for traceability.
_sanitize_label() and _escape_guard_markers() are kept for defence-in-
depth on the label stored inside the block.

Update test_label_newline_injection_is_blocked to assert no label-
derived instruction text appears before GUARD_OPEN (pre-guard zone is
now empty of any user-derived content).
2026-06-07 22:15:50 +01:00
Syed Ali Jaseem f939cb65ce refactor(tests): replace local function copies in test_endpoint_resolver with real imports (#3359)
* refactor(tests): replace local function copies in test_endpoint_resolver with real imports

The test file carried 9 verbatim copies of src/endpoint_resolver.py functions
to avoid import-pollution concerns, but these copies are a drift hazard — PR #3343
had to update both in parallel.  Replace them with direct imports so future changes
to endpoint_resolver are automatically exercised by the test suite.

Also fixes _ollama_api_root in endpoint_resolver.py: the bare-URL Ollama case
(e.g. http://nas:11434 with empty path) was already handled correctly in the test
copy but was missing from the real function, which would return /chat instead of
/api/chat for native Ollama endpoints without an explicit /api prefix.

Closes #3351

* refactor: import _ollama_api_root from llm_core instead of duplicating it

endpoint_resolver already imports _detect_provider and _host_match from
llm_core. Add _ollama_api_root to that import and remove the local copy,
collapsing two implementations to one source of truth.

llm_core's version is a superset (also strips /api/chat|tags|generate paths),
and since normalize_base already removes those suffixes upstream the result
is identical for every input used here.
2026-06-07 22:47:57 +02:00
nubs 865e61450e fix(upload): configure chat attachment size limit (#2439) 2026-06-07 22:42:24 +02:00
nubs 8746c9c0df fix(documents): discard pending AI diff before switching active doc (#2484)
The document editor stores the AI-edit diff state (_diffModeActive,
_diffOldContent, _diffNewContent, _diffChunks) as a module-global
singleton bound to whatever document was active when the diff opened,
and every document shares one #doc-editor-textarea. When the active
document is switched while an unapproved diff is open, the stale diff
must be discarded first or a later exitDiffMode (tab switch /
Accept-Reject-All) flushes the old document's content into the new
active document and overwrites it (issue #2467).

Guard both paths that switch the active document for an AI update,
while activeDocId still points at the previously-active doc:
- handleDocUpdate(): a doc_update targeting a different document.
- streamDocOpen(): the AI streaming a NEW document — this runs first on
  that path, so a guard only in handleDocUpdate would fire too late and
  still overwrite the streamed document.

Both reuse the exact `if (_diffModeActive) exitDiffMode(true);` guard
switchToDoc() and enterDiffMode() already use.

Fixes #2467
2026-06-07 22:35:35 +02:00
nubs f7c0b3f23b fix document preview refresh after AI edits (#2259) 2026-06-07 22:33:01 +02:00
Syed Ali Jaseem e3e37ce526 fix(sessions): scope enrichment queries by owner, add LIMIT to auto_sort (#3350)
GET /api/sessions fired full-table scans against sessions, documents, and
gallery_images on every call. Added DbSession.owner == user (line 265),
Document.owner == user (line 283), GalleryImage.owner == user (line 289),
and .limit(2000) to auto_sort_sessions (line 1013). All follow the existing
owner-scoping pattern at lines 700 and 1230. No behaviour change — the
response was already correct; this eliminates the over-fetch.
2026-06-07 21:32:21 +02:00
adabarbulescu a8859bb25c fix(llm): Properly detect remote Ollama bare URLs as native endpoints (fixes #3252) (#3343) 2026-06-07 21:19:19 +02:00
Giuseppe Castelluccio 6c9a16a7a8 fix: search analytics FileHandler crashes on startup writing to read-only image layer (#2366)
* fix: move search analytics log to writable /app/logs volume

services/search/analytics.py opened a FileHandler at module import
time pointing to /app/services/search_engine_error.log — inside the
container image's read-only layer. The process runs as non-root so
the open() fails with PermissionError, crashing uvicorn before it
ever binds. ANALYTICS_FILE had the same problem.

Both paths now point to /app/logs (bind-mounted from the host data
directory). The FileHandler creation is wrapped in try/except so a
missing mount doesn't hard-crash on import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: derive log dir from DATA_DIR instead of hardcoded /app/logs

Fixes reviewer feedback on #2366: /app/logs only exists inside Docker,
so native runs couldn't write the analytics file. DATA_DIR resolves to
the repo's data/ directory on native and /app/data (writable mount) in
Docker, making both the error log handler and ANALYTICS_FILE work on
every platform.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:26:22 +02:00
lekt8 accdc4fc53 Add hover tooltips for clipped model names (#1982) (#1985)
Long model names are truncated with ellipsis in two places with no way to see
the full name: the model-picker dropdown items and the chat-header model
indicator. Add a native title tooltip carrying the full name to both — the
dropdown item's name span (nameSpan.title = m.display) and the header label
(label.title = the full model id; empty for the 'Select model' placeholder).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:23:44 +02:00
RaresKeY 3a91c11ff8 fix: block app_api access to Cookbook host controls (#3231) 2026-06-07 19:20:11 +02:00
Ashvin 00e8084969 fix(notes): handle time-first due_date phrases in parse_due_for_user (#3319)
parse_due_for_user only matched day-first format ('today at 3pm').
Time-first strings like '3pm today' or '11pm today' — which the tool
schema and tool_index both advertise as valid examples — fell through
all branches, hit dateutil or the legacy _parse_dt fallback, and in
many cases raised ValueError. do_manage_notes then stored the raw
string verbatim, and the ISO-only reminder scanner (action_ping_notes)
never fired the note.

Add a time-first regex branch immediately after the day-first branch
to handle '<time> today|tonight|tomorrow|tmrw|yesterday'. Existing
day-first parsing is unchanged.

Fixes #3302
2026-06-07 19:15:38 +02:00
PewDiePie c9198baa2e fix: make agent loopback base port env-configurable (#2752) (#2753)
_COOKBOOK_BASE was hardcoded to http://localhost:7000 with no env-var
override anywhere in the codebase. Tools that do an internal HTTP
loopback (app_api, trigger_research, cookbook state read/write) silently
fail with "All connection attempts failed" whenever the running uvicorn
isn't on port 7000 — which is most non-default deployments and any
side-by-side multi-instance setup. The misleading "Task triggered"
message from manage_tasks during a research request hides that the
underlying research never starts.

Resolution order, lowest to highest priority:
  1. Fallback http://127.0.0.1:7000 (preserves legacy default).
  2. APP_PORT — derive http://127.0.0.1:$APP_PORT (matches docker-compose
     which already reads APP_PORT).
  3. ODYSSEUS_INTERNAL_BASE — explicit override (e.g. behind a TLS proxy
     where loopback isn't 127.0.0.1).

127.0.0.1 instead of "localhost" avoids IPv6/DNS ambiguity for a
strictly-local call.

No API or schema change. Defaults preserved: existing setups on port
7000 are unaffected.

Caught by #2752.

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
2026-06-07 18:47:47 +02:00
Ruben G. 55343e89fb fix(setup): clear error when setup runs under x86/Rosetta Python (#941)
Add a check_arch() guard that fails fast with actionable guidance when
setup runs on Apple Silicon under an Intel (x86_64) Python via Rosetta —
otherwise compiled deps (bcrypt, pydantic-core, …) load as the wrong
architecture and crash later with a cryptic "incompatible architecture"
import error. Also catch that specific error around the bcrypt import and
print rebuild steps.

Rebased onto current main: the start-macos.sh venv-Python changes that
were part of this branch are dropped, since they're already on main via
PR #978.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:28:37 +02:00
ooovenenoso 681a2a3f2a fix(cookbook): scan persisted HF cache paths (#3189) 2026-06-07 18:19:47 +02:00
michaelxer d7ece5b4a9 fix: show backend error detail in context-popup compact button (#2721)
When the context-popup compact button receives a non-OK response (e.g.
409 for active-run), the error detail from the backend was being
discarded in favor of a generic 'Compaction failed' message.

Now parses the JSON response body for non-OK responses and prefers the
detail field when present, matching the behavior of the /compact slash
command. Uses textContent for safe rendering.

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-07 18:16:58 +02:00
Dividesbyzer0 5dff35ba03 fix(cookbook): don't 500 the packages panel when an optional package crashes on import (#2618)
list_packages() probes each optional package with importlib.import_module() but
only caught ImportError / PackageNotFoundError. A package that is installed yet
raises a different exception on import took down the whole panel with a 500,
surfaced in the UI as "Error loading packages: Unexpected token 'I', ...".

Concrete Windows case: a CUDA build of llama-cpp-python runs
os.add_dll_directory(r"...\CUDA\v12.3\bin") at import and raises FileNotFoundError
when that toolkit dir is absent. Catch any exception during the import probe and
report the package as not-installed instead of failing the entire request.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:14:43 +02:00
Bipin Mishra b22c2b280c fix(hwfit): detect NVIDIA GPU on WSL and other minimal-PATH environments (#3306)
The nvidia-smi absolute-path fallback in _detect_nvidia() was gated
on _remote_host, so it never ran for local detection. On systems
where nvidia-smi is not in the default PATH (e.g. WSL: /usr/lib/wsl/lib/),
this caused the Cookbook to report 'No GPU' even when nvidia-smi works
from an interactive shell.

Two issues fixed:
1. Removed the _remote_host gate so the absolute-path scan runs for
   local detection too.
2. For local execution, pass arguments as a list instead of a string
   so subprocess.run() resolves the absolute path correctly. Remote
   (SSH) execution keeps the string form, which the SSH command builder
   handles.

Co-authored-by: Bipin Mishra <bipin.mishra@atlascopco.com>
2026-06-07 17:53:49 +02:00
Alan Met a6bc1addd2 fix(settings): correct Add User username placeholder (#3296)
Fixes #3292
2026-06-07 17:50:18 +02:00
Zen0-99 2a422c00ec feat(model-picker): add remove-from-recent button to Recent section rows (#2894)
* feat(model-picker): add remove-from-recent button to Recent section rows

* fix(model-picker): restore original browse-mode section logic, keep remove button only
2026-06-07 17:45:59 +02:00
Kevin Fiddick 8cfc5bb28f Fix mobile markdown table layout (#3198) 2026-06-07 17:43:51 +02:00
Sebastian Andres El Khoury Seoane 8d9d4ec9c6 feat(platform): Add support for APFEL as part of the dependencies and models for the Cookbook. (#2657)
* feat(platform): add support for Apple Silicon detection in platform compatibility

test(tests): enhance shell_routes tests for Apple Silicon compatibility

* fix issues with missing import

* fix: correct package name in package-lock.json and enhance package installation commands in shell_routes.py and cookbook.js

* feat: add Apfel startup and health checks on macOS

- bootstrap Apfel via Homebrew on arm64 macOS
- start `apfel --serve --port 11435` detached for Odysseus
- verify readiness via `/health`
- clean up the Apfel process on exit or Ctrl+C

* fix: duplicate variable declaration post-merge conflict
- Should fix `node` CI issues.

* fix: issues with the update status of the APFEL dependency.
- fixed by changing the main conditional that determines the update.

* Fix: Remove unnecessary whitespaces and formatting for the model_routes.py file.

* Fix: whitespace issues with the model_routes file

* Fix: Remove unnecessary whitespaces and formatting for the model_routes.py file. Final

* Fix: Fixed updates using PIP for APFEL instead of custom cmd
2026-06-07 17:28:02 +02:00
Kenny Van de Maele 8f2c8d2dc8 fix(test): tolerate owner kwarg in compaction summary resolve_endpoint mock (#3304)
#2996 made context_compactor call resolve_endpoint('utility', owner=owner),
but the mock added by #2174 stubbed it as lambda which: ..., which rejects the
owner kwarg. Each PR passed alone; merged on dev the two compaction tests fail
with TypeError and the pytest job goes red. Widen the mock to lambda *a, **k.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 17:23:06 +02:00
Kenny Van de Maele 613bbb0dba fix: port main-only fixes to dev (#2761 sharpen auth, #2762 doc version 404) (#3303)
* fix(gallery): add auth check to /api/image/sharpen endpoint (#2761)

Every other image-processing endpoint (denoise, upscale, remove-bg,
enhance-face, inpaint, harmonize) calls require_privilege(request,
"can_generate_images"). The sharpen endpoint was missing this check,
allowing unauthenticated users to trigger CPU-intensive image processing.

* fix(document): add 404 guard to version list/get endpoints (#2762)

list_versions and get_version used a soft 'if doc:' guard that skipped
ownership verification when the Document row was missing (e.g. after
hard delete). Orphaned DocumentVersion rows would be returned to any
caller without auth. Now raises 404 when the parent document is gone,
matching the pattern already used in restore_version.

---------

Co-authored-by: Ernest Hysa <59969602+ErnestHysa@users.noreply.github.com>
2026-06-07 17:19:24 +02:00
Steve 8f5b7210cc added if condition (line 4351) to resetWindowsPlacement(); (#2198) 2026-06-07 17:12:42 +02:00
Muhammad Ikhwan Fathulloh 2a6921a455 Fix logical bugs in event bus and bulk session deletion (#3139) 2026-06-07 17:08:50 +02:00
SurprisedDuck b8463e3ac2 fix(email): decode headers without injected spaces (#2433)
routes.email_helpers._decode_header joined the runs from
email.header.decode_header() with " ". Those runs carry their own
surrounding whitespace (e.g. (b"Re: ", None)), and RFC 2047 §6.2 requires
the whitespace between two adjacent encoded-words to be dropped, so the
join produced a double space after an ASCII prefix ("Re:  Jóse"), a
spurious space in "Name <addr>" senders, and a stray space between two
adjacent encoded-words ("Café 日本"). _decode_header backs the inbox list,
message read, search, and the background pollers, so the corruption hit
essentially every non-ASCII subject/sender.

Use email.header.make_header(...) for RFC-correct concatenation, keeping
the existing lossy per-part fallback for malformed/unknown MIME charsets
(make_header raises LookupError there) so the unknown-charset contract in
tests/test_email_decode_header.py still holds.

The sibling mcp_servers.email_server._decode_header was already fixed the
same way (commit 46999de); this brings the routes.email_helpers copy in
line, with regression coverage.

Supported by Claude Opus 4.8

Co-authored-by: SurprisedDuck <288741682+SurprisedDuck@users.noreply.github.com>
2026-06-07 16:56:20 +02:00
Mazen Tamer Salah 92ef01d4fa fix(skills): tolerate a stray brace before the JSON in skill extraction (#2200)
maybe_extract_skill() sliced the LLM response from the first '{' to the
last '}'. When a model emits a stray brace in prose before the real
object (e.g. "uses {placeholder} then {...}"), the slice starts at the
prose brace, json.loads fails, and a valid skill is silently dropped.

Factor parsing into _extract_json_object(), which tries the whole
(de-fenced) string first and then each '{' start position, returning the
first candidate that parses to a JSON object.

Adds tests/test_skill_extractor_json.py.
2026-06-07 16:54:36 +02:00
Rudra Sarker c5ac89f01f fix: preserve partial deep research findings on non-timeout errors (#2189)
* fix: preserve partial deep research findings on non-timeout errors

* fix: preserve partial deep research findings on non-timeout errors
2026-06-07 16:53:14 +02:00
Wes Huber b9a96bca1a fix(research): avoid double split() call and potential IndexError (#2229)
cat.split()[0] was called in the condition and again in the body,
wasting a second split. More importantly, if cat were ever
whitespace-only, split() returns [] and [0] raises IndexError.
Assign to a local variable and guard with a truthiness check.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 16:46:21 +02:00
Wes Huber 706ea6a7b7 fix: TOCTOU race in personal file delete + IndexError on whitespace cmd (#2228)
1. routes/personal_routes.py: os.path.exists() then os.remove() is a
   classic TOCTOU race — another request or cleanup can delete the
   file between the check and the remove, raising FileNotFoundError.
   Replace with try/except FileNotFoundError.

2. src/tool_implementations.py: cmd.split()[0] crashes with IndexError
   when cmd is a non-empty whitespace-only string (split() returns []).
   Guard with (cmd.split() or [''])[0].

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 16:44:26 +02:00
M57 12cb39cbd9 feat: add OpenCode Zen and Go as provider options (#26)
- Add OpenCode Zen (https://opencode.ai/zen/v1) and Go (https://opencode.ai/zen/go/v1)
- Add provider detection via _host_match() in llm_core.py
- Add curated model list entries in model_routes.py
- Add webhook provider URLs
- Add provider icon (providers.js) and dropdown options (index.html)
- Add auto-detection patterns and setup URLs (slashCommands.js)
- Whitelist opencode.ai in URL validation (admin.js)
- Rebased on main to fix merge conflicts with _HOST_TO_CURATED refactor

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
2026-06-07 16:43:00 +02:00
max-freddyfire 43c16fc7e4 fix(context_compactor): return original messages when compaction summary fails (#2174)
On summary LLM call failure, maybe_compact was returning system_msgs+recent
(dropping the older half) with was_compacted=False, misleading the caller into
thinking the list was unchanged. Return the original messages list unchanged so
no history is lost; the next trim_for_context call handles length if needed.

Fixes #2160

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:40:16 +02:00
SurprisedDuck c75d3e1975 fix(memory): record dislikes as dislikes, not preferences (#2435)
_fallback_memory_candidates matched both positive (prefer/like/love) and
negative (hate / do not like / don't like) sentiment verbs in one regex
alternation, then formatted every hit as "User prefers {X}.". So
"I hate cilantro" was stored as "User prefers cilantro." -- the inverse of
what the user said. These fallback facts are persisted to memory and later
re-injected into the model's context, so the inverted preference actively
misleads the assistant.

Capture the matched verb and branch on it: negatives become
"User dislikes {X}.", positives stay "User prefers {X}." (still filed under
the existing "preference" category).

Supported by Claude Opus 4.8

Co-authored-by: SurprisedDuck <288741682+SurprisedDuck@users.noreply.github.com>
2026-06-07 16:36:07 +02:00
Maruf Hasan 3c924b8dee fix: hide Select buttons in Memory/Skills tabs when list is empty (#2906)
* fix: hide Select buttons in memory/skills tabs when list is empty

* fix: disable Select buttons instead of hiding them when list is empty

* fix: dim disabled Select button and remove focus outline

* fix: reload skills after single deletion so count and toolbar stay in sync

* fix: lower minimized-dock z-index from 10020 to 100 so modals stack above it

* Revert "fix: lower minimized-dock z-index from 10020 to 100 so modals stack above it"

This reverts commit 5b092ee6cd.
2026-06-07 16:29:04 +02:00
YotamPeled adbcb3763f fix(agent): don't abort legitimate tool batches as runaway loops (#3183)
The loop-breaker's runaway backstop counted per-tool-type call totals and
tripped whenever any tool was used >=15 times — treating 15+ DISTINCT calls
to one tool as a stuck loop. A real batch (e.g. "add these 18 birthdays to my
calendar" emits 18 distinct manage_calendar create_event calls in one round)
got flagged "calling manage_calendar over and over", the calls were discarded
(next round tools_sent=0), and 0 events were created.

Count IDENTICAL repeated call signatures instead (same tool AND args), via a
small, unit-testable _detect_runaway_call() helper. Genuine batches pass; a
model truly stuck repeating one call still trips the backstop. Adds a
regression test.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:16:17 +02:00
michaelxer bdf4ec8b24 fix: fall back to /models probe when base URL returns 404 (#3205)
_ping_endpoint() probes the bare base URL for non-Ollama endpoints.
OpenAI-compatible servers like llama-swap return 404 on the /v1 prefix
but 200 on /v1/models, causing endpoints to appear offline despite being
fully functional.

Add a /models fallback when the base URL returns a non-auth 4xx.
Auth failures (401/403) are treated as definitive — probing /models
would just repeat the same rejection.

Fixes #3181

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-07 16:09:33 +02:00
danielroytel 5d3e3c7053 feat(tasks): assign folder='Tasks' at creation + backfill migration (#2834)
* feat: assign folder='Tasks' to task sessions at creation

Task sessions (LLM, action, research) now set folder='Tasks' on their
DbSession row, matching the pattern used by the Assistant folder. This
enables sidebar lens filtering without changing existing session
behaviour.

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

* feat: add backfill script for task session folders

One-shot script to set folder='Tasks' on existing [Task]/[Research]
sessions that predate the folder assignment in task_scheduler.py.

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

* refactor: replace standalone backfill script with automatic migration

Convert scripts/backfill_task_folders.py into _migrate_backfill_task_folders()
in core/database.py, called from init_db(). The migration is idempotent (only
touches rows where folder IS NULL/empty) and runs automatically on upgrade,
so operators no longer need a manual step to tag pre-existing task sessions.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 15:33:17 +02:00
Marius 04d6a5ccaa Fix: CORS preflight 401'd by AuthMiddleware before CORSMiddleware (#3262)
AuthMiddleware is the outermost middleware, so a credential-less CORS preflight
(OPTIONS + Access-Control-Request-Method) was rejected with 401 before
CORSMiddleware could answer it. That blocks every cross-origin browser/WebView
client: the preflight fails, so the real request is never sent.

Let a genuine preflight through at the top of AuthMiddleware.dispatch via a pure,
unit-tested predicate (core.middleware.is_cors_preflight). Precise -- only
OPTIONS carrying Access-Control-Request-Method; a credentialed request is never
matched -- and no data access.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:23:23 +02:00
RaresKeY a3784da172 fix: block app_api access to shell routes (#3225) 2026-06-07 15:19:08 +02:00
Ashvin cbbb41dfb1 fix: avoid double bcrypt on login by using create_session_trusted (#3236)
* fix: avoid double bcrypt on login by adding create_session_trusted

* fix: update test to expect create_session_trusted instead of create_session
2026-06-07 15:10:53 +02:00
Vykos 83b0ab7cd3 Scope auxiliary LLM endpoints by owner (#2996)
* fix(auth): scope auxiliary llm endpoints by owner

* fix(auth): scope auxiliary llm fallbacks by owner
2026-06-07 14:47:44 +02:00
Ashvin 12a7e741d0 fix: redirect /login to / when AUTH_ENABLED=false (#3235) 2026-06-07 14:17:21 +02:00
Léo 573d431399 fix(cookbook): don't infer server OS from the browser's user-agent (#3223)
_getPlatform('local') fell back to navigator.userAgent to decide the
*server's* platform. On a Mac/Linux homeserver opened from a Windows
browser this returned 'windows', so the GGUF serve builder emitted the
Windows python-only shape (`python -m llama_cpp.server`, no
`llama-server ||` fallback). That command fails on the Unix host with
"No module named llama_cpp" even though native llama-server is installed,
and the diagnosis then misleadingly tells the user to pip-install
llama-cpp-python.

Trust the server-side hardware probe over the user-agent: a non-empty
probe backend (metal/cuda/rocm/cpu_*) means a Unix server; local Windows
instead carries platform:"windows" which already sets _envState.platform
and short-circuits. Only fall back to the browser hint when there is no
server-side signal at all. Keeps #1389/#2961's local-Windows path intact.

Fixes #3221

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:20:05 +02:00
Vykos 2149f0fb67 fix(rag): forward owner through manager wrapper (#2991) 2026-06-07 12:56:57 +02:00
Vykos 83fca6ac62 fix(personal): require document privilege for rag upload (#2990) 2026-06-07 12:56:53 +02:00
Vykos 000932a6d9 fix(auth): gate api tokens from user routes (#2992) 2026-06-07 12:55:01 +02:00
Vykos 299538ea4e Harden note reminder dispatch ownership (#2999) 2026-06-07 12:52:27 +02:00
Vykos 67aeea4f8b Scope gallery image endpoints by owner (#3001) 2026-06-07 12:51:21 +02:00
Vykos f2a79aaf5c Tighten manage notes owner checks (#3002) 2026-06-07 12:50:10 +02:00
Vykos a6490ffb1b Harden gallery album assignment scope (#3004) 2026-06-07 12:49:03 +02:00
Vykos 06d28e23ac Scope document session links by owner (#3005) 2026-06-07 12:47:20 +02:00
Vykos 7b4e6c4c1b Enforce task chain owner scope (#3006) 2026-06-07 12:43:43 +02:00
Vykos 3cff06781e Scope model helper endpoint resolution (#3007) 2026-06-07 12:40:23 +02:00
Vykos ff4508d396 Scope vision model resolution by owner (#3009) 2026-06-07 12:39:02 +02:00
ooovenenoso c11ce66e0e docs: note dev branch status in README (#3196) 2026-06-07 12:16:14 +02:00
Lucas Daniel 34bd8f0491 fix(email): guarantee IMAP conn.logout() on all exception paths (#1530)
Three IMAP connection leaks were recently fixed via try/finally
(#1325, #1330, #1423). This commit applies the same pattern to the
remaining callsites that still used inline logout-only cleanup.

routes/email_helpers.py:
- _fetch_sender_thread_context: conn was uninitialized when the outer
  try/except returned early on connect failure, causing the finally
  block to crash on conn.close()/conn.logout(). Merged the two
  separate try blocks into one and added conn=None guard.
- _pre_retrieve_context: ctx_conn.logout() was inside the loop body
  with no finally, so any exception in the folder/search loop leaked
  the socket. Moved cleanup into a finally block with ctx_conn=None
  guard.

mcp_servers/email_server.py:
- _list_emails: multiple inline conn.logout() calls on early-return
  paths; exception between them leaked the socket. Wrapped in
  try/finally.
- _read_email: same pattern — four separate logout() calls replaced
  by a single finally block.
- _reply_to_email: logout() called before the error check, so an
  exception in conn.select() leaked the socket. Wrapped in
  try/finally.
- _download_attachment: same pattern as _reply_to_email.

Also adds tests/test_imap_leak_fixes.py with 9 regression tests (one
per function/failure-mode) that monkeypatch _imap_connect and assert
conn.logout() is called exactly once even when IMAP operations raise.
2026-06-07 05:09:28 +01:00
Joeseph Grey f78539ba15 fix(caldav): disable redirects on the sync/write-back DAVClient (SSRF) (#2663)
validate_caldav_url resolves and vets the initial host, but caldav's
niquests session follows 3xx redirects by default, so a validated public
URL can be redirected at request time to loopback/link-local/private
space, re-opening the SSRF the host check closes. The existing redirect
guard only covered the settings test-connection path.

Add a shared _build_dav_client helper that pins the session to zero
redirects (any 3xx then raises instead of silently following an
attacker-chosen Location), and route both the pull (_sync_blocking) and
write-back (_writeback_blocking) paths through it. Mirrors the
follow_redirects=False already used on the test-connection path.

Tests exercise the real DAVClient request path (a 302 toward an internal
host is refused, the sink is never contacted; the PROPFIND is asserted to
reach the public server first so the check can't pass vacuously), confirm
the helper disables redirects on the installed client, guard against a
raw DAVClient creeping back in, cover mixed public/internal DNS results
in both orderings, and add the resolves-to-no-usable-records fail-closed
branch.
2026-06-07 05:05:24 +01:00
Giuseppe 95c2dca4b5 fix(security): add HSTS and Permissions-Policy to SecurityHeadersMiddleware (#3081)
* fix(security): add HSTS and Permissions-Policy headers to SecurityHeadersMiddleware

Strict-Transport-Security is sent only when the connection is HTTPS
(detected via request.url.scheme or X-Forwarded-Proto: https), so
plain-HTTP dev deployments behind a reverse proxy are unaffected.

Permissions-Policy disables camera, microphone, and geolocation APIs
unconditionally — Odysseus does not use them, and this prevents a
successful XSS from requesting browser-native sensor access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): scope Permissions-Policy microphone directive to same-origin

Reviewers on PR #3081 (alteixeira20, NubsCarson) flagged that
microphone=() blocks mic access for same-origin (self) too, breaking
Odysseus's own voice/STT flow (getUserMedia({audio: true}) in
static/js/voiceRecorder.js). Scope it to microphone=(self) so
third-party origins stay locked out while the app's own UI keeps mic
access; camera and geolocation remain fully disabled as unused.

Adds focused middleware tests covering HSTS scoping (HTTPS direct,
X-Forwarded-Proto, absent on plain HTTP) and the Permissions-Policy
same-origin microphone contract.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 04:58:33 +01:00
Karandeep Bhardwaj 3940297655 fix(webhooks): redact IPv6 addresses in sanitized error messages (#3038)
* fix(webhooks): redact IPv6 addresses in sanitized error messages

sanitize_error() only stripped IPv4 literals, so a failed webhook
delivery to an internal IPv6 host (::1, fe80::/fc00:: ...) leaked the
address into Webhook.last_error, which is surfaced in the UI. The module
already treats internal IPv6 as sensitive (see _PRIVATE_NETWORKS and
src/url_safety.py); the scrubber just didn't keep up.

Add an IPv6 redaction pass covering bracketed, full 8-group, and
::-compressed forms. The pattern is scoped to leave clock times
("12:34:56"), MAC addresses, and C++ "::" tokens untouched, and the
::-branch uses a lookahead over a flat character class so there is no
nested quantifier to backtrack on (no ReDoS on long colon/hex runs).

Adds tests/test_webhook_sanitize_error_ipv6.py.

* webhook: validate IPv6 candidates with ipaddress, not a regex grammar

Per review on #3038: instead of hand-rolling the IPv6 grammar in a regex
(brittle, and easy to over-match colon-heavy text), use a loose regex to
find candidate tokens and let ipaddress.ip_address() decide. Only tokens
it parses as IPv6 are redacted, so the false-positive guards (clock times,
MACs, "std::vector") now come from the stdlib instead of a custom pattern.

This also covers cases the old pattern missed -- zone ids (fe80::1%eth0)
and IPv4-mapped addresses -- and no longer partially mangles invalid
colon strings (a 9-group token is preserved whole rather than losing its
first 8 groups). The bracketed branch is a single greedy class with no
X*:X* backtracking; verified ~1ms on 40k-char adversarial input.

Extends the test file with zone-id, IPv4-mapped, and invalid-token cases.

* webhook: redact bracketed/scoped/IPv4-mapped IPv6 as one unit

Review on #3038 found a few IP forms left partially redacted or malformed
by sanitize_error():

  [fe80::1%eth0]:8080        -> [[redacted]]:8080
  [::ffff:192.168.0.1]:8080  -> [[redacted][redacted]]:8080
  ::ffff:192.168.0.1         -> [redacted][redacted]

Two causes: the bracketed branch's character class dropped zone ids, so
scoped addresses fell through to the bare branch and left the brackets and
port behind; and the IPv4 pass ran first, stripping the embedded v4 of an
IPv4-mapped address so the v6 pass then redacted the "::ffff:" remnant
separately.

Fix:
- run the IP-candidate pass before the IPv4 pass, so IPv4-mapped forms are
  matched and redacted whole
- match the full bracketed authority ([...] + optional %zone + :port) as a
  single token, and redact a v4-or-v6 literal inside [ ] as one [redacted]
- extend the bare branch with a bounded (exactly-3) dotted-quad tail for
  IPv4-mapped forms; exactly-3 so it can't swallow a partial suffix and
  accidentally preserve an otherwise-valid address

Each form now collapses to a single [redacted]; the candidate finder stays
linear (~1.3ms on 40k-char adversarial input). Adds regression tests for
the three reported forms and keeps the timestamp/MAC/std::vector coverage.
2026-06-07 04:55:33 +01:00
Nicholai a3cb15d0a1 fix(agent): enforce guide-only tool policy (#3088) 2026-06-06 18:48:24 -06:00
@aaronjmars 108ee1e32b fix(security): close DNS-rebinding hole on diffusion_server (wildcard CORS + missing Host check) (#347)
* fix(security): close DNS-rebinding hole on diffusion_server

scripts/diffusion_server.py used to ship `allow_origins=["*"]` with the
default `--host=127.0.0.1` bind. Combined, that left the OpenAI-compatible
image API reachable from any browser tab via DNS-rebinding: an attacker page
resolves its own domain to 127.0.0.1 mid-fetch, the browser forwards the
request to the loopback server, the server processes it (no Host check), and
the wildcard CORS reply lets the attacker page read the result + drive the
GPU. CWE-346 + CWE-942 + CWE-352 (DNS-rebinding bridge).

Fix:
  - Drop the wildcard CORS at module load (default-deny).
  - Install `TrustedHostMiddleware` with a loopback allowlist so DNS-rebound
    requests are rejected by the middleware before any route runs.
  - Add additive `--allowed-host` / `--allowed-origin` CLI flags so operators
    who need browser access on a specific origin can opt in explicitly without
    re-introducing the wildcard.

Tests: tests/test_diffusion_server_security.py (9 cases) pin the allowlist
helpers, the default-deny CORS behavior, and the live middleware paths via
Starlette's TestClient.

Detected by Aeon + semgrep + manual review.
Severity: medium.
CWE-346 / CWE-942 / CWE-352.

* test(diffusion-server): drive ASGI app via httpx, not TestClient portal

The TrustedHost/CORS integration tests used `with TestClient(app) as
client:`, whose context-manager form spins up an anyio blocking portal to
run the app lifespan. Under the repo's pytest setup (anyio plugin active, a
stray asyncio_mode option, no pytest-asyncio) that portal deadlocks —
`test_trusted_host_middleware_rejects_attacker_host` hung indefinitely in
review before emitting any assertion output.

Replace the TestClient usage with a tiny _asgi_get() helper that drives the
ASGI app over httpx.ASGITransport on a fresh event loop (asyncio.run). No
portal, no lifespan, no dependency on the host project's async test plugins.
Host is taken from the request URL so TrustedHostMiddleware sees the exact
hostname under test; Origin goes through headers. Assertions are unchanged.

Focused test now passes in 0.12s; full file 9 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: aeonframework <aeonframework@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:34:39 +01:00
muhamed hamed b03d934ec6 fix: restore backup import after skills migration (#2980) 2026-06-06 21:46:32 +01:00
Lucas Daniel eb840459f5 ci: skip pytest smoke on documentation-only changes (#2768)
* ci: skip pytest smoke on documentation-only changes

Adding paths-ignore for **.md and docs/** so that PRs that touch only
markdown files do not trigger the full pytest suite. Runner minutes are
spent only when Python or config files change.

Closes #2646.

* ci: detect docs-only changes inside the job instead of paths-ignore

Previously paths-ignore on the pull_request trigger caused the entire
workflow to be skipped, which can leave required checks pending and block
merging. Instead, keep the workflow always-triggered and detect docs-only
changes inside python-tests with a git diff step; if every changed file
is a .md or docs/ path, the step reports success without running pytest.

The syntax jobs (python-syntax, node-syntax) are cheap enough to always run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:00:46 +01:00
Mohammed Riaz 6ccd4500d7 fix(chat): show requested and actual reply models
Show requested and actual reply models in chat labels when fallback or provider routing changes the responding model.
2026-06-06 04:30:16 -06:00
Merajul Arefin 2e37d72155 fix(chat): stop code-block button flicker during streaming (#3023)
Render streamed markdown incrementally (freeze finalized blocks,
re-render only the growing tail) instead of re-rendering the whole
message every token, which recreated every <pre> and dropped CSS :hover.
2026-06-06 04:08:54 -06:00
Ocean Bennett fb9c7cf3da fix(calendar): accept list event range aliases 2026-06-06 03:47:18 -06:00
Nicholai 33edc40eae fix: route misfenced web lookups to web tools
Fixes #3067
2026-06-06 03:46:31 -06:00
Giuseppe e87a1ad8d2 fix(deep-research): wrap fetched webpage content in untrusted-context sandbox
The goal-based extractor passed raw fetched webpage content straight
into the LLM prompt via string substitution, bypassing the
prompt-injection hardening layer in src/prompt_security.py.

Split EXTRACTOR_PROMPT into EXTRACTOR_SYSTEM (task instructions +
goal, trusted) and a second message built with untrusted_context_message()
(raw page content, sandboxed with <<<UNTRUSTED_SOURCE_DATA>>> guards).
This aligns the extractor with every other external-content injection
site in the codebase (agent_loop, chat_processor, chat_routes).

Fixes #3044

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 03:37:10 -06:00
Giuseppe 893cb8254f fix(sessions): retry resumeStream in poll loop when chatModule loads late
sessions.js executes before chat.js in ES module order, so
window.chatModule is not yet set when _checkServerStream runs on page
load. The resumeStream guard evaluates false and the spinner fallback
kicks in; that fallback only polls stream_status and never retries the
live-resume path, leaving the user with a dead spinner for the entire
duration of the detached agent run.

Fix: add a one-shot retry in the polling loop. On the first tick where
window.chatModule.resumeStream is available, attempt to attach. If it
succeeds, clear the interval and remove the spinner — live SSE streaming
takes over. If the run has already finished (404), the loop continues to
poll status and calls selectSession on completion.

Fixes #3048

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 03:36:30 -06:00
Maruf Hasan 870ae2823f fix: lower minimized-dock z-index so modals stack above it 2026-06-06 03:35:48 -06:00
Nicholai 86abcb75d0 fix: split Chroma embedding lanes (#3046) 2026-06-06 03:17:19 -06:00
Nicholai 463713c2c6 feat(search): unify session transcript search (#2877) 2026-06-05 18:08:31 -06:00
Mateus Oliveira c2017fa089 Phase 1: consolidate tool output constants into src/constants.py (#2989)
MAX_OUTPUT_CHARS, MAX_READ_CHARS, and MAX_DIFF_LINES are now
defined once in src/constants.py and imported by the three files
that previously duplicated them (tool_execution.py,
tool_implementations.py, agent_tools.py). agent_tools.py re-exports
them for backward compatibility.

Co-authored-by: mcnoliveira <mcnoliveira@gmail.com>
2026-06-05 23:05:02 +02:00
michaelxer 53fd856ea8 fix: raise imaplib line limit for large mailboxes (#2895)
Python's imaplib._MAXLINE defaults to 1 MB. Mailboxes with tens of
thousands of messages exceed this on UID SEARCH ALL, crashing with
'got more than 1000000 bytes'.

Set _MAXLINE to 50 MB after opening the connection so large mailboxes
work without error.

Fixes #2883

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-05 22:59:35 +02:00
Fijar Lazuardy 66599b02a2 allow user who disable auth to use chat (#2548)
* allow user who disable auth to use chat

* only check non user on verify session owner

* fix import source

* rollback 401 to 403 for unauthorized error due to unit test

* change unauthenticated http code error to 401 and fix unit tests
2026-06-05 22:54:19 +02:00
n2b12 fb3e89b011 VRAM detection under native Windows install (#1610)
* Convert to different style of comment to make it easier to work with, fix formatting inside Powershell script.

* Grab VRAM amount from driver's registry keys.

* Fixed regression on NVIDIA GPUs
2026-06-05 22:49:47 +02:00
Logan Davis f72e1bd412 feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.

- `src/settings.py` — reminder_webhook_integration_id +
  reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
  template rendering, auth wiring; built-in preset defaults so
  discord_webhook works out of the box without a configured template;
  settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
  example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
  payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
  syncChannelRows, hints, load/save, auto-fill preset templates,
  test-button override payload, hide auth/key for URL-auth presets

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:47:57 +02:00
ooovenenoso 2bdf43b74d feat(cookbook): add Gemma4 thinking chat template (#2955)
* feat(cookbook): add Gemma4 thinking chat template

* fix(cookbook): place Gemma4 thinking token in system turn
2026-06-05 22:43:31 +02:00
horribleCodes c8b4cd24e0 fix: Add WSL paths to hardware detection fallback (#2933)
This change extends both the `PATH` variable and the list of absolute paths used to locate the `nvidia-smi` package to include `/usr/lib/wsl/lib`.
This path is a candidate for the default location of nvidia-smi for WSL machines (tested on WSL Ubuntu 22.04.5).
2026-06-05 21:34:41 +02:00
Paweł Drużyński f4aa661502 fix ambiguous naming, remove redundant json imports, fix _MCP_ARG_PARSERS type annotations (#2874) 2026-06-05 21:30:22 +02:00
Ocean Bennett 5911b8c0dc fix(models): allow same endpoint URL with different keys (#2758)
* fix(models): allow same endpoint URL with different keys

* fix(models): show endpoint key fingerprints
2026-06-05 21:12:14 +02:00
nubs 08e543d1ff fix(tool-parsing): don't ship unconvertible <invoke> fence content to the code executor (#2926) 2026-06-05 21:08:54 +02:00
nubs 47a47bf71d fix(llm): guard against null arguments in streaming tool-call accumulator (#2923) 2026-06-05 20:57:36 +02:00
michaelxer 71dda5b106 fix: respect user round count in deep research (#2896)
The STOP_PROMPT did not include the target round count, so the LLM
could decide to stop after 2-3 rounds even when the user requested 8.
Additionally, min_rounds was capped at 3 regardless of max_rounds.

- Add max_rounds to STOP_PROMPT so the LLM knows the target
- Change min_rounds from min(3, max_rounds) to max(2, max_rounds - 2)

Fixes #2863

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-05 20:49:42 +02:00
Logan Davis ad82ee1c83 feat(calendar): support multiple CalDAV accounts (#2942)
* feat(calendar): support multiple CalDAV accounts

Replaces the single CalDAV credential slot with a named account list so
users can sync both a personal and work calendar simultaneously.

- Add `account_id` column to `CalendarCal` + startup migration
- `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts`
  list from prefs, auto-migrating the legacy single `caldav` key on
  first use (no user action required)
- `sync_caldav()` iterates all accounts and aggregates counts/errors
- `writeback_event()` resolves credentials via `CalendarCal.account_id`,
  falling back to the first account for legacy rows
- New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts`
- Legacy GET/POST `/api/calendar/config` preserved for backward compat
- Settings UI: one card per account with Label, URL, Username, Password
  fields; Test button works for both unsaved (inline creds) and saved
  (by account_id) accounts; delete removes only that account
- Update test_caldav_url_hardening.py mock to include `_save_for_user`
  and updated `_sync_blocking` signature

* fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation

Two regressions introduced by the multi-account refactor:

1. PK collision (#2765): _stable_cal_id was back to hashing only the URL,
   so two users — or one user with two accounts on the same server — would
   collide on the primary key. Restore owner+account_id in the hash key
   (format: "{owner}\n{account_id}\n{url}") and thread both values through
   _sync_blocking → _writeback_blocking → push_event → find_remote_calendar
   so the hash round-trips correctly on write-back.

2. URL validation dropped (#2819): _load_caldav_accounts imported
   _save_for_user at function scope, causing an ImportError on test mocks
   that only provide _load_for_user, which prevented writeback_event from
   reaching the validate_caldav_url call. Move the import inside the
   migration branch and wrap in try/except (best-effort save; next call
   re-migrates from the still-present legacy key).

Update fake_writeback_blocking in test_caldav_writeback.py to accept the
new owner/account_id optional params.
2026-06-05 20:32:50 +02:00
ghreprimand 545e692565 fix(auth): distinguish empty model allowlists (#2938)
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-05 20:27:10 +02:00
nubs fa9f62b44c fix(compactor): shrink oversized tool_calls arguments so trim_for_context can fit a tool-only turn (#2949) 2026-06-05 20:23:38 +02:00
Giulio Zelante b448119919 feat(skills): import SKILL.md bundles from public GitHub URLs (#2576)
* feat(skills): import SKILL.md bundles from public GitHub URLs

Supports GitHub tree/blob/raw links and skills.sh pages that resolve to GitHub.
Installs SKILL.md plus sibling text assets under data/skills/imported/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): admin-gate URL import and validate redirect hosts

- require_admin on POST /api/skills/import-from-url (matches other skill admin routes)
- reject cross-host redirects after httpx follow_redirects
- test for redirect host validation

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): match Brain Add panel import/submit button styles

- Skill URL Import: theme-io-btn + download icon (same as memory Import)
- Add Skill submit: confirm-btn confirm-btn-primary

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): allow api.github.com during directory import

Real imports hit the GitHub contents API after redirects; whitelist
api.github.com and add regression tests. Shrink Import button with flex:none.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): align skill Import button with URL input row

Match memory-add-input height (28px) in memory-add-row and center the
download icon with flexbox instead of vertical-align hacks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): cancel modal-body margin on skill Import button

The skill Import button sits in .memory-add-row beside an input; the
global .modal-body button { margin-top: 6px } rule only affected buttons,
pushing Import down and misaligning the download icon. Reset margin-top
and match Memory Import SVG markup at 28px row height.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): surface GitHub API errors on URL import

Pass through GitHub response messages (especially 403 rate limits) as
SkillImportError instead of a generic download failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 19:48:23 +02:00
Enes Öz 977daf0643 Improve edge-docked window behavior (#2779)
* Make edge-docked windows resizable

Add draggable resize seams for left and right docked windows.

Keep the main chat area from getting too narrow and remember each window's dock width.

* Show emoji shortcodes as icons by default

Keep text-only emoji mode opt-in so model output like 😊 goes through the normal emoji renderer.

* Fix dock resize seams and left dock layout

Hide the resize seam when another floating modal is open, and keep the left-docked window from covering the chat area.

* Keep narrow modal tabs usable

* Fix split layout with both edge docks

* Fix left snap after right dock

* Enable left edge snap for all windows

* Tighten dock resize handle observers

* Use edge docking for settings window
2026-06-05 17:07:08 +02:00
Kenny Van de Maele 8ce945d338 feat: Add plan mode to the chat agent (#638)
* feat: Add plan mode to the chat agent

Adds a plan mode: the agent investigates read-only, proposes a checklist, and
waits for approval before changing anything. On approval it runs with full
tools and checks items off as it goes. Enforcement reuses the existing
disabled_tools gate.

Includes a slash command: `/plan [on|off]` (and `/toggle plan`) to flip the
plan toggle from the chat input.

- src/tool_security.py, src/mcp_manager.py: read-only allowlist (tools + MCP).
- src/agent_loop.py, routes/chat_routes.py: union the disabled set, prepend the
  plan directive, force agent mode.
- static/: plan toggle pill, Approve & Run, dockable plan window, task-list
  checkboxes, and the /plan slash command.
- tests/test_plan_mode.py.

* Plan mode: persistent re-referenceable plan + agent write-back

Three improvements so a long plan survives a weak model and stays in reach:

1. Re-reference the plan (out-of-context fix). On the execution turn the frontend
   sends the approved checklist back (`approved_plan`); the backend pins it as a
   top-of-context `## ACTIVE PLAN` system note (kept by the context trimmer), so
   the agent can always re-read the plan instead of losing the thread on a long
   run. New `build_active_plan_note()` (unit-tested).

2. Re-open / dock the plan anytime. The plan checklist is stored per-session
   (localStorage). When a plan exists, the plan-mode button opens a small menu
   ("Show plan" / "Plan mode: On/Off") that re-opens the side-dockable plan
   window — so it can stay docked while the agent works. The window live-refreshes
   as the plan changes.

3. Agent write-back: new `update_plan` tool. The agent calls it to tick steps
   `- [x]` after finishing them, or to revise steps when the user asks. Marker
   tool (no I/O) → `plan_update` SSE event → the stored plan + docked window
   update live. The ACTIVE PLAN note instructs the agent to use it.

Backend: src/agent_loop.py (param + pin + note builder + emit + prompt blurb),
src/tool_execution.py (update_plan handler), routes/chat_routes.py (parse
`approved_plan`, relay `plan_update`), registration in tool_schemas / agent_tools
/ tool_index (always-available, not admin-gated).
Frontend: static/js/chat.js (plan store, send `approved_plan`, handle
`plan_update`, capture restated checklists), static/app.js (plan-button menu),
static/js/planWindow.js (`isPlanWindowOpen`), static/js/storage.js (PLAN key).
Tests: tests/test_plan_mode.py (plan-note), tests/test_update_plan_tool.py.

* Plan mode: drop bash/python, rely on read-only discovery tools

Shell can mutate (write files, hit the network) and can't be constrained to
read-only at the tool layer, so plan mode no longer relies on a prompt to keep
it well-behaved — bash/python are removed from the read-only allowlist and added
to the fail-closed block set. Discovery is covered by the dedicated read-only
tools (read_file, grep, glob, ls) instead.

Rewrites the plan-mode directive to state shell is disabled and lists the
available read-only tools positively. Addresses review feedback on #638.

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

* Comment: note _MCP_READONLY_VERBS are prefixes not whole words

Clarifies that entries like "summar" are intentional stems matched via
startswith (covers summarise/summarize/summary), not typos. Addresses review
feedback on #638.

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

* Plan mode: clarify why gating inverts the allowlist into a denylist

Rename _PLAN_MODE_FALLBACK_BLOCK -> _PLAN_MODE_KNOWN_MUTATORS and rewrite the
comments. The tool gate is a denylist (disabled_tools); plan mode's policy is an
allowlist, so it returns the inverse (all known tool names minus the allowlist).
The static mutator set is a backstop for the schema-derived name list, which
misses XML-only tools and can fail to import. Addresses review feedback on #638.

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

* Plan mode: stop hardcoding the read-only tool list in the directive

The model is already shown its available (read-only) tools by _assemble_prompt,
which removes every disabled tool. Enumerating them again in the directive only
duplicated that list and would drift as tools change. Point at the tools listed
below instead. Addresses review feedback on #638.
2026-06-05 16:32:25 +02:00
nubs 2e207fc315 fix(notes): track + remove the select-mode Esc keydown listener so it doesn't leak per open (#2792) 2026-06-05 16:25:05 +02:00
Greg Stevenson 01f1278811 fix: Settings now correctly displays CalDAV integrations when more than one isconfigured (#2901)
* fix(calendar): expose source in calendar list and add per-calendar delete

- GET /api/calendar/calendars now includes source field so the frontend
  can distinguish CalDAV collections from local calendars
- Add DELETE /api/calendar/calendars/{cal_id} to remove a specific
  calendar and its events by owner-scoped ID

* fix(settings): show all CalDAV calendars in integrations list

Previously one card was shown for the CalDAV server connection regardless
of how many calendar collections had been synced. The Calendars page showed
them all; Settings did not.

- Fetch /api/calendar/calendars alongside existing requests and render
  one card per source=caldav collection, falling back to the single
  server-level card if nothing has synced yet
- Delete now targets the specific calendar by ID rather than clearing
  the whole server config
- Confirm dialog shows the calendar name so the user can verify before
  removing
2026-06-05 16:11:08 +02:00
ooovenenoso 4bfe0c690a fix(calendar): cap RRULE expansion (#2902) 2026-06-05 16:05:14 +02:00
ooovenenoso c9d0c6db18 fix: quote IMAP mailbox arguments (#2170)
* fix: quote IMAP mailbox arguments

* fix: quote MCP move destinations

---------

Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-05 16:00:20 +02:00
nubs 6973c5427c fix(model-context): count tool_calls in estimate_tokens so compaction sees real size (#2751) 2026-06-05 15:56:54 +02:00
nubs 8354948a1c fix(llm): route harmony thinking streams (#2449) 2026-06-05 15:22:08 +02:00
L1 8159733c6c fix(caldav): pull Google Calendar events from the events collection, not the /user principal (#2531)
* fix(caldav): pull Google Calendar events from the events collection, not the /user principal

Google serves its CalDAV principal at .../caldav/v2/<id>/user but events live
under .../caldav/v2/<id>/events. The caldav library's principal->home-set
discovery does not reliably enumerate calendars from Google's /user endpoint,
so _sync_blocking fell into its 'treat the URL as a single calendar' fallback
and ran every calendar-query REPORT against the principal URL. /user holds no
VEVENTs, so the REPORT returned a clean but empty 200 for every date range:
auth succeeded, the calendar stayed empty (Apple Calendar works because iCloud
exposes standard discovery at the pasted URL).

Add _google_caldav_events_url() to map a recognised Google principal URL to its
events collection, and route both discovery-less fallbacks through
_open_url_as_calendar() so Google syncs hit /events while other servers' URLs
are used unchanged.

Fixes #2507

* fix(caldav): also map Google's legacy www.google.com/calendar/dav principal URL

Some Google accounts authenticate against the older CalDAV endpoint
(https://www.google.com/calendar/dav/<id>/user) rather than the newer
apidata.googleusercontent.com/caldav/v2 form (reported on #2507). Both have the
same principal-vs-events split, so map the legacy /user URL to its /events
collection as well. The legacy branch is gated on the /calendar/dav/ path so an
unrelated www.google.com URL ending in /user is left untouched.
2026-06-05 15:18:16 +02:00
Ernest Hysa 7367325819 fix(caldav): include owner in calendar ID hash to prevent PK collision (#2765)
_stable_cal_id hashed only the remote URL, producing the same calendar
ID for all users syncing the same CalDAV endpoint. The second user would
get an IntegrityError on the primary key. Now includes owner in the
hash so each user gets a distinct calendar row.
2026-06-05 15:12:54 +02:00
Ernest Hysa 3738df3b93 fix(tasks): validate then_task_id belongs to same owner on create/update (#2764)
then_task_id was stored without checking the target task's owner. A user
could chain their task to execute any other user's task on success via the
scheduler's _run_chained path. Now verifies the target task exists and
belongs to the requesting user before storing.
2026-06-05 15:12:47 +02:00
Ernest Hysa f5c9095222 fix(document): add 404 guard to version list/get endpoints (#2762)
list_versions and get_version used a soft 'if doc:' guard that skipped
ownership verification when the Document row was missing (e.g. after
hard delete). Orphaned DocumentVersion rows would be returned to any
caller without auth. Now raises 404 when the parent document is gone,
matching the pattern already used in restore_version.
2026-06-05 15:12:40 +02:00
Ernest Hysa d4ff7fce81 fix(gallery): add auth check to /api/image/sharpen endpoint (#2761)
Every other image-processing endpoint (denoise, upscale, remove-bg,
enhance-face, inpaint, harmonize) calls require_privilege(request,
"can_generate_images"). The sharpen endpoint was missing this check,
allowing unauthenticated users to trigger CPU-intensive image processing.
2026-06-05 15:12:33 +02:00
Wes Huber 05f047b188 fix: prevent document link click from resetting active session (#2055)
* fix: prevent document link click from resetting active session

Clicking a #document-<uuid> link in chat caused the session to reset
because of two issues:

1. chatRenderer.js: clicking on the text inside an <a> yields a Text
   node target whose .closest() is undefined, so preventDefault never
   fires and the browser performs a default hash-navigation

2. sessions.js: the hashchange handler treated the entity hash
   (document-<uuid>) as a session lookup, found no match, and the
   subsequent loadSessions created a new default-model chat

Fix: walk past Text nodes before calling .closest(), and skip
entity-prefixed hashes in the hashchange handler.

Fixes #2035

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(documents): move isOpen=true after container check in openPanel

isOpen was set to true before the #chat-container existence check.
If the container was missing during a race, the function returned
early but isOpen stayed true, preventing the panel from ever
reopening on subsequent calls.

Move isOpen=true to after the container guard so a failed open
doesn't leave the flag stuck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 15:05:30 +02:00
Alexandre Teixeira e9ff6cde77 docs(tests): document helper conventions
Documentation-only PR continuing #2523. Adds tests/README.md to document helper conventions, validation expectations, and the next test-suite refactor phase.
2026-06-05 14:04:10 +01:00
nubs 747d005645 fix(gallery): validate target album owner on image PATCH + owner-scope album count/cover (#2755) 2026-06-05 15:01:01 +02:00
Zen0-99 bec594904d Fix/windows llama cpp serve and test upstream (#2669)
* fix: code runner base64, Windows serve paths, endpoint cache clear, copy-log guards, model-picker remove-recent

* Revert model-picker 'remove from recent' feature and remove stray PR_DRAFT.md
2026-06-05 14:53:33 +02:00
Yiğit Egemen ec8fbf5d8f Add support for EMBEDDING_API_KEY (#2691)
* feat: support for embedding API key

* feat: encrypt and decrypt embedding API key

* test: add unit tests for EmbeddingClient authorization header behavior
2026-06-05 14:47:24 +02:00
the_peaceful b5c45326e4 Fix Windows Cookbook background tasks, exit statuses, and empty SSH logs wrapper (#1389)
This commit consolidates all Windows Cookbook background fixes into a single comprehensive commit based on the latest main branch.

Key fixes included:
1. React looksSuccessful Mismatch: Append 'DOWNLOAD_OK' for pip install commands in routes/cookbook_routes.py.
2. Local Windows SSH Wrapper & Log Directory Mismatch: Bypassed ssh wrappers and dynamically selected odysseus-tmux logs for local tasks in static/js/cookbookRunning.js.
3. WSL Bash Filtration: Filtered out the WSL bash stub at C:\Windows\System32\bash.exe in core/platform_compat.py.
4. Drive-Colon Path Normalization: Replaced .as_posix() with git_bash_path() in routes/shell_routes.py and src/bg_jobs.py.
5. GGUF-Only Hardware Fitting: Restructured local Windows recommendations to rank GGUF only in services/hwfit/fit.py.
6. Safe Win32 Process Liveness Probe: Replaced os.kill(pid, 0) with a safe Win32 API probe using GetExitCodeProcess in core/platform_compat.py.
7. Prebuilt llama-cpp-python Wheels: Supply the CPU extra index during compilation failure fallback.
8. Enforce UTF-8 log encoding: Set PYTHONIOENCODING=utf-8 on Windows bootstrap runners.
9. Fix Linux Llama.cpp Build script syntax error in routes/cookbook_helpers.py.
10. Page Reload Status Check: Run sys.executable instead of 'python3' to bypass Microsoft Store execution stubs on local Windows hosts.
11. Llama.cpp serve build bypass: Bypassed cmake compilation checks on local Windows and verified python bindings directly.
12. Serve Command Path Validation: Masked safe GGUF path printf subshells '' inside the serve command validator.
13. CPU Mismatch Diagnostics: Intercepted AVX2-lacking '0xc000001d' (Illegal Instruction) crashes in static/js/cookbook-diagnosis.js and guided users to Ollama.
14. Windows Pytest stability: Fixed stub import leakage in test files.
2026-06-05 14:41:07 +02:00
Alexandre Teixeira 452a94fb1b refactor(tests): centralize fake endpoint resolver cleanup
Test-only refactor continuing #2523. Centralizes the final repeated fake src.endpoint_resolver cleanup pattern into a focused import-state helper.
2026-06-05 13:23:46 +01:00
Alexandre Teixeira 301d1109b5 refactor(tests): centralize fake database import-state cleanup
Test-only refactor continuing #2523. Centralizes the repeated guarded fake core.database/src.database import-state cleanup into a focused helper.
2026-06-05 12:27:44 +01:00
Vykos 370ae5d451 Harden DAV outbound URL validation (#2819) 2026-06-05 13:22:21 +02:00
Vykos 6d64055328 Constrain research handler JSON paths (#2846) 2026-06-05 13:20:02 +02:00
Vykos 0b0d747f1c Constrain signature uploads to PNG data (#2844) 2026-06-05 13:17:43 +02:00
Vykos 688194113b Constrain upload paths to upload root (#2825) 2026-06-05 13:15:23 +02:00
Ocean Bennett 2a1febdeef fix(actions): scope scheduled model resolution to owner (#2773) 2026-06-05 13:13:13 +02:00
nsgds 0f8d12363a fix(images): render agent-generated images in chat (#2809)
* fix(images): render agent-generated images in chat

When a chat model calls generate_image mid-conversation (agentic flow), the image does
not display — it survives only as a URL the model echoes in prose. generate_image runs
as a text-only MCP server, so result['image_url'] is never populated and the existing
buildImageBubble render path never fires. Promote the image URL out of the tool's stdout
in tool_execution so the agent loop's existing forwarding renders it via buildImageBubble
— deterministically, no dependence on the model echoing the URL. Backend-only; reuses
dev's image bubble, forwarding, and the tool's existing parseable output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(images): fully-qualified, valid generated-image links

The chat model often mangled the generated-image URL it echoed in prose (relative path,
or copying the 'image_url:' label into the link href). Build a fully-qualified link by
prefixing the existing app_public_url setting (empty default keeps relative paths), and
present it as a clean 'Direct link:' the model can echo verbatim (the frontend auto-links
bare https URLs). One file; independent of how the image is rendered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(images): cover _promote_image_fields; make exit-code guard self-contained

Adds the unit tests requested in review on #2809: absolute URL, relative URL,
no URL (result unchanged), and non-zero exit_code (not promoted). Moves the
dict/exit_code==0 guard from the call site into _promote_image_fields so the
function is self-contained and the failure case is unit-testable; call-site
behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:04:33 +02:00
Nicholai 201e207b56 fix(memory): let manual add specify memory category
fix for #2784 and part of #2788: Add a category selector (same options as inline edit) and include category in
the /api/memory/add JSON payload.
2026-06-05 04:57:13 -06:00
Alexandre Teixeira 65231f2ba1 refactor(tests): reuse import-state helper in auth manager tests
Test-only refactor continuing #2523. Replaces inline core.auth cache eviction in two _fresh_auth_manager tests with clear_module, preserving behavior.
2026-06-05 11:24:55 +01:00
Alexandre Teixeira 4f0133b8c3 refactor(tests): reuse import-state helper in auth tests
Test-only refactor continuing #2523. Replaces a repeated core.auth cache eviction pattern in three auth tests with the shared clear_module helper, preserving behavior.
2026-06-05 11:10:41 +01:00
spooky f9e1d38cc2 fix: diagnose vllm serve runtime issues (#1198) 2026-06-05 11:03:04 +01:00
Kenny Van de Maele 0a2adc9c96 Add ask_user tool: agent-posed multiple-choice questions (#2111)
Let the agent pause and ask the user a multiple-choice question when a
task is genuinely ambiguous and the answer changes what it does next —
choosing between approaches, confirming an assumption, picking a target —
instead of guessing.

Modeled on the existing `ui_control` marker pattern: the `ask_user` tool
returns an `ask_user` payload that the agent loop emits as an SSE event
and then ends the turn. The frontend renders the question with clickable
option buttons, a free-text "Other" input, and an x to dismiss; the user's
choice is sent as the next message and the agent resumes with it in
context.

- src/tool_execution.py: `ask_user` handler — pure UI marker, no I/O.
  Validates a non-empty question + 2..6 options, normalizes string/object
  options, returns the payload.
- src/agent_loop.py: emit the `ask_user` event and break the round loop so
  the turn ends and waits for the user's selection. Stream the question as
  assistant text so it persists/replays (prevents a re-ask loop).
- Registration: TOOL_TAGS, ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS,
  FUNCTION_TOOL_SCHEMAS, the system-prompt blurb. Not admin-gated (any
  user can be asked); the structured args serialize via the default
  json.dumps path.
- routes/chat_routes.py: relay the `ask_user` event to the client.
- static/js/chat.js + static/style.css: render the question card (options +
  free-text Other + dismiss x; removed once answered). Reuses CSS vars and
  the .modal-close button; emoji go through the monochrome-SVG pipeline.
  Bump chat.js cache pin.
- tests/test_ask_user_tool.py: payload, multi flag, string options, option
  cap, validation errors, serializer round-trip, registration.
2026-06-05 11:49:11 +02:00
Alexandre Teixeira 621885ac06 fix(tests): restore Python CI baseline regressions
Test-only fix continuing #2523. Updates two stale regression tests so the current broad Python pytest baseline is restored without changing production code.
2026-06-05 10:31:38 +01:00
Alexandre Teixeira 30173f3909 fix(tests): make archived session filter test multipart-independent
Test-only fix continuing #2523. Makes the archived-session model-filter test independent of optional multipart packages. The red broad pytest status was classified as unrelated current dev baseline drift before merge.
2026-06-05 10:12:47 +01:00
Lucas Daniel f5d834b0c5 fix(cookbook): surface backend diagnosis when serve fails in background (#1636)
* refactor(cookbook): move _diagnose_serve_output to module level in cookbook_helpers

Extracts the nested _diagnose_serve_output function from inside
setup_cookbook_routes() and moves it to module level in cookbook_helpers.py,
alongside the other helper functions it logically belongs with.

No behaviour change — the function is now importable directly for testing
and by other callers without going through the route factory closure.

* fix(cookbook): surface backend diagnosis when serve fails in background

The background poll (_pollBackgroundStatus) already received `diagnosis`
and `cmd` from /api/cookbook/tasks/status but discarded both. When a serve
job died while the Cookbook modal was closed, reopening it showed only a
red error badge with no context.

- Persist live.diagnosis into task._backendDiagnosis in localStorage so it
  survives modal close/reopen and page refresh
- Persist live.cmd into task.payload._cmd for agent-spawned tasks so the
  crash report includes the actual command
- After _renderRunningTab(), walk rendered cards and call _showDiagnosis()
  for any that have a stored _backendDiagnosis but no panel yet
- In _renderTaskCard(), use _backendDiagnosis as a fallback when the
  client-side _terminalServeDiagnosis() finds nothing

* test(cookbook): add coverage for _diagnose_serve_output error patterns

10 tests verifying the 16 serve-failure patterns:
- CUDA OOM, port-in-use, vLLM missing, gated model
- Traceback fallback fires without startup success marker
- Traceback suppressed when server actually started
- Clean/empty output returns None
- trust-remote-code and no-GGUF patterns
2026-06-05 09:52:07 +01:00
Kenny Van de Maele 367858a587 Merge branch 'main' into dev
Bring main's maintainer-curated work (cookbook scheduler, calendar rendering/sync, settings polish, agent debug loop) into dev so dev is a superset of main (resolves the dev/main drift, #2543).
2026-06-05 10:50:51 +02:00
Vykos b19e5693af Constrain embedding model cache paths (#2849) 2026-06-05 10:46:48 +02:00
Vykos 11ba46505b Constrain generated-image paths to image root (#2837) 2026-06-05 10:33:47 +02:00
Vykos d4d168f972 Harden emoji SVG proxy responses (#2842) 2026-06-05 10:31:58 +02:00
Vykos 194985b5e1 Constrain gallery filenames to image root (#2828) 2026-06-05 10:29:11 +02:00
Alexandre Teixeira 0dc051dea3 refactor(tests): reuse import-state helper in session tests
Test-only refactor continuing #2523. Reuses the shared import-state helper in session-related tests, removes duplicated local save/restore logic, and preserves existing test behavior.
2026-06-05 09:25:52 +01:00
nubs 8b386a172e fix(calendar): route read requests to agent (#2452) 2026-06-05 09:24:04 +01:00
Vykos 2cae5a681d Sanitize calendar export filenames (#2840) 2026-06-05 10:18:09 +02:00
Alexandre Teixeira 46f128b9df fix(tests): make conftest DB import clean-worktree safe
Test-only fix continuing #2523. Sets an in-memory DATABASE_URL default before tests/conftest.py imports core.database, preserving explicit DATABASE_URL values and avoiding ./data artifacts in clean worktrees.
2026-06-05 09:14:51 +01:00
Nicholai 4df4cfeaff Merge pull request #2387 from cirim-au/fix/manage-memory-always-available
fix(tool_index): add manage_memory to ALWAYS_AVAILABLE
2026-06-05 02:14:10 -06:00
pewdiepie-archdaemon e0e250d023 Calendar: cross-session delete sync — 404 = success, refetch on tab focus
A stale event deleted on one device stayed undeletable on every other
session: the cached row showed up, the DELETE call returned 404 (server
already removed it), the optimistic catch-block restored the row, and
the user could never clear it.

- Treat HTTP 404 on DELETE as success — the event is already gone,
  which is the state we wanted. Skip the optimistic restore.
- Re-fetch the visible range on document `visibilitychange` (mobile
  app returns to foreground) and on window `focus` (desktop alt-tab),
  throttled to once per 10s so rapid tab-flipping doesn't hammer the
  API. Without a focus refresh, mobile only got fresh server state at
  page-load and lived on stale data until a full reload.
2026-06-05 17:05:04 +09:00
Isak ec7691956b fix: add threading lock to AuthManager config mutations (#1226) 2026-06-05 10:04:37 +02:00
Ali Arfa 04df7255fb fix(start-macos): skip pip install when requirements.txt is unchanged (#2503)
Hash requirements.txt on each launch and skip pip install if the hash
matches the last recorded value. Cuts 10-20s from warm starts with no
change to what gets installed.

The hash file lives in venv/.requirements_hash (already gitignored).
Deleting venv/ or changing requirements.txt triggers a full reinstall.
2026-06-05 08:59:56 +01:00
1jsjs 3ef73013eb Fix session cleanup cutoff timezone (#2488) 2026-06-05 09:52:34 +02:00
tanmayraut45 17b62a3dba Research CLI: alias --status complete to the stored done value (#2515)
`odysseus-research list --status complete` returns an empty result on
any real corpus. The CLI accepts `complete` as a `--status` choice (the
user-facing label), but the writer in
`services/research/research_handler.py` stores `status="done"` when a
run finishes (and the legacy `src/research_handler.py` copy does the
same). The list filter at `scripts/odysseus-research` was a literal
string compare:

    if args.status and (data.get("status") or "") != args.status:
        continue

so `--status complete` filtered every finished record out, and the user
saw nothing — even though `odysseus-research list` (no filter) listed
them fine and `show RP_ID` worked on the same files. The other
documented choices — `running`, `cancelled`, `error` — are stored
verbatim by the writer, so the surface mismatch is just on `complete`.

Add a small `_STATUS_CLI_TO_STORED = {"complete": "done"}` map and run
`data.get("status")` through `_status_matches(...)` before comparing.
The other CLI choices fall through unchanged, so the filter still
matches them verbatim. A `None` or non-string `status` (corrupt JSON)
is coerced to `""` and never matches `complete`, so a half-written
record can't sneak past the filter.

`tests/test_research_cli_status_filter.py` covers all four documented
choices, the non-string / missing status case, and pins that the
verbatim choices are NOT rewritten — a blanket mapping that turned
every CLI choice into a stored variant would just re-introduce the
empty-result bug on the running/cancelled/error paths.

Part of #2122.
2026-06-05 08:50:33 +01:00
ghreprimand e0097c9c48 Strip tz in _parse_dt dateutil fallback (naive-datetime contract) (#2557)
_parse_dt documents that it returns naive datetimes (CalendarEvent.dtstart is
naive) and every return path strips tz — except the last-resort dateutil
fallback, which returned dateutil's value verbatim. An offset-bearing non-ISO
input (e.g. RFC-2822 'Mon, 05 Jan 2026 14:00:00 +0900', which fromisoformat
rejects but dateutil parses) leaked a tz-aware datetime into the naive dtstart
column via create_event/update_event -> _parse_dt_pair. On read-back,
_expand_rrule compares ev.dtstart against naive window bounds and raised
'can't compare offset-naive and offset-aware datetimes' (500 / no events).

Normalize the fallback to UTC-naive, mirroring the fromisoformat branch. Naive
inputs are unchanged.

(cherry picked from commit b03b6b91df)

Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-05 08:18:26 +01:00
Alexandre Teixeira 9ffa87e394 fix(tests): make webhook SSRF test clean-worktree deterministic
Test-only fix continuing #2523. Makes the webhook SSRF test deterministic in clean worktrees without creating ./data or repo-local DB artifacts.
2026-06-05 08:16:28 +01:00
ghreprimand cfb2d17a2d Word-boundary match for snippet and subject-term ranking (#1473 follow-up) (#2556)
#1473 converted the title and sports-hint matches in services/search/ranking.py
to word boundaries but left two raw substring tests:

  - snippet_score: 'term in snippet.lower()' — query term 'port' hits
    'transport'/'support', inflating a result's relevance.
  - news_quality_adjustment: 't in text or t in netloc' for the subject term —
    query 'us' substring-matches 'business'/'music', so an off-topic page
    wrongly escapes the off-topic penalty on a country/subject news query.

Add a _has_word helper (the same \b...\b pattern title_score already used) and
route all three word checks (title, snippet, subject) through it, so the file
stays consistent and a future partial fix can't reintroduce the same bug class.
Pure ranking refinement: scores change only for spurious substring matches; no
API or schema change.

(cherry picked from commit 22bd23f044)

Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-05 08:04:31 +01:00
nubs 5271d529d6 fix(tool-schemas): preserve web_search time_filter through native tool-call conversion (#2757) 2026-06-05 08:00:59 +01:00
Alexandre Teixeira a9c1c698b0 refactor(tests): add import-state isolation helper
Test-only refactor continuing #2523. Adds a shared import-state isolation helper with focused coverage and migrates two pilot tests that manually preserved sys.modules and parent package attributes.
2026-06-05 07:30:14 +01:00
joi-lightyears 88c9f1fa74 fix(memory): let manual add specify memory category
Add a category selector on the Brain Add tab and include it in the
/api/memory/add JSON payload instead of always defaulting to fact.
Fixes #2784
2026-06-05 13:17:14 +07:00
pewdiepie-archdaemon 2ba77e3aa3 Settings polish: /setup provider subs, Add API defaults to api kind, picker shows offline endpoints, doc library tracks sub-tab
- /setup gains explicit provider subcommands (deepseek, openai,
  anthropic, openrouter, groq, gemini, xai, ollama, copilot, local,
  endpoint) so the autocomplete popup surfaces "/setup de…" suggestions
  with format hints, and bare-provider invocations still prompt for
  the key.
- Add API endpoint defaults to kind=api (auto-refresh /v1/models)
  instead of kind=proxy. Proxy was a frequent footgun for OpenAI-
  compatible endpoints that DO serve /v1/models — the user got an
  empty model list and had to flip the dropdown.
- Model picker now includes offline endpoints with stale:true so a
  briefly-down local server doesn't vanish from the picker (it dims
  and shows the offline pill, clickable anyway). Dedup prefers the
  online entry when the same model is exposed by both.
- Document library modal header reflects the active sub-tab via
  _TAB_HEADERS so it no longer shows the wrong section name when
  switching between Documents / Skills / Templates.
2026-06-05 14:41:54 +09:00
pewdiepie-archdaemon fbd34334a5 Calendar overnight-event rendering + clickable [View note] link from chat
- Calendar overnight events render proportionally across day boundaries
  via --start-frac / --end-frac CSS vars instead of bleeding as full-day
  on day 2.
- Recurring-event delete strips the master uid + all master::* sibling
  instances optimistically so the row clears immediately instead of
  waiting for the next sync re-render.
- manage_notes(create) now returns note_id + open_url, and agent_loop
  appends a markdown [View note](#note-<id>) link mirroring the
  deep-research pattern.
- chatRenderer's hash-link router (already wired for #note-id) reaches
  the new notes.openNote(id) helper, which force-closes/reopens the
  Notes panel, polls for the target card, and runs a brief outline
  flash so the user can locate it on long lists.
2026-06-05 14:41:48 +09:00
pewdiepie-archdaemon e2f449f4ef Cookbook scheduler + serve: schedule via Tasks, Stop verifies kill, Ollama auto port-pick
- Schedule cookbook serves through the existing ScheduledTask system: the
  serve preset gets a ^ button next to Launch that opens a daily/hourly/
  weekly form mirroring the admin-switch style; the schedule action runs
  action_cookbook_serve, which delegates to /api/model/serve and stamps
  the resulting task with _scheduledStopAtMs. A background
  cookbook_serve_lifecycle loop ticks every 60s and kills any serve
  whose window has ended, also dropping the auto-registered endpoint
  so the model picker doesn't keep pointing at a dead server.
- Stop and remove on a Running serve now awaits the SSH/tmux kill,
  re-checks tmux has-session, and surfaces an error toast (leaving the
  row) when the kill failed. Previously fire-and-forget, so a failed
  SSH/tmux call silently left the live serve running while the row
  vanished from the UI.
- Cookbook tasks/status orphan-adoption sweep no longer requires the
  serve-/cookbook- session-id prefix; any tmux session whose pane is
  running a known model-server process gets auto-pulled into Running.
  Without this loosening, a cookbook-launched serve whose tmux id
  fell back to a bare number was invisible — you couldn't see it,
  let alone stop it.
- Ollama serve always launches a fresh process under cookbook's tmux
  (no more monitor-mode reattach to a systemd/Docker ollama Stop can't
  reach). The handler pre-picks a free port by probing the target
  host over SSH and mutates req.cmd's OLLAMA_HOST so the runner script
  AND the auto-registered endpoint agree on the same bind port.
- Auto-register uses host.docker.internal (when running inside Docker)
  instead of localhost, matching the URL /setup adds for Ollama by
  hand. Local cookbook serves now produce a chat-reachable endpoint
  on first launch.
- Cascade-delete: removing a scheduled cookbook task also deletes any
  linked calendar event (cookbook_task_id marker in the description).
- Tasks list groups cookbook_serve under a "Cookbook" category that
  sorts above the rest, so scheduler-launched serves are easy to find.
2026-06-05 14:41:43 +09:00
Alexandre Teixeira 43a101d305 refactor(tests): finish shared CLI loader adoption
Test-only refactor continuing #2523. Replaces remaining obvious CLI/script loader boilerplate with tests.helpers.cli_loader.load_script while preserving existing stubs and assertions.
2026-06-05 06:00:05 +01:00
pewdiepie-archdaemon f8aaeab245 Merge remote-tracking branch 'origin/dev' 2026-06-05 12:14:34 +09:00
pewdiepie-archdaemon f19ac6ed03 Merge branch 'main' of github.com:pewdiepie-archdaemon/odysseus
# Conflicts:
#	static/js/cookbookRunning.js
2026-06-05 11:23:15 +09:00
pewdiepie-archdaemon a260e0abd4 Revert calendar-based cookbook scheduler
Reverts b98ee04 + 4ed48ba + a19b6d2.

Calendar events turned out to be the wrong abstraction for scheduling model serve windows. Pivoting to the existing ScheduledTask infrastructure (cron / daily / weekly recurrence, next_run tracking, edit-from-Tasks-tab UI) in a follow-up commit. The ScheduledTask path:

  - reuses dispatch logic the rest of the app already understands
  - drops the calendar dependency entirely (no auto-created "Cookbook" calendar, no calendar.js hook)
  - shows up in the Tasks UI that already exists for everything else

What this revert removes:
  - src/cookbook_scheduler.py — calendar reconciler
  - routes/cookbook_schedule_routes.py — /api/cookbook/schedule/* endpoints
  - static/js/cookbookSchedule.js — Schedule modal / settings card
  - cookbook_scheduler_enabled + cookbook_schedule_calendar_href settings keys
  - The window.cookbookOpenScheduleForm hook in calendar.js
  - The Schedule button + paired-button CSS in cookbookServe.js + style.css
2026-06-05 06:57:21 +09:00
pewdiepie-archdaemon b98ee04e2f Cookbook scheduler: reuse the standard calendar event card + auto-create Cookbook calendar
Drop the custom Schedule modal in favor of opening the calendar's existing event-creation form pre-filled with the model's name + cookbook YAML in the description. The user lands in the same event editor they already know from regular calendar use, just pointed at the auto-created "Cookbook" calendar.

Backend:
  - POST /api/cookbook/schedule/ensure-calendar — idempotent: creates a calendar named "Cookbook" if one doesn't exist for the current user, saves its href into cookbook_schedule_calendar_href, flips cookbook_scheduler_enabled on. Verifies the saved href against /api/calendar/calendars on every call so a manually-deleted calendar self-heals.

Frontend:
  - calendar.js: expose window.cookbookOpenScheduleForm(draft) which opens the calendar modal (if not open), calls _showEventForm, then pre-fills summary / description / rrule / calendar dropdown. Force-expands the "Add details" section so the user can see which calendar it's heading into.
  - cookbookSchedule.js: Schedule-button click now calls ensure-calendar, builds the cookbook: YAML block, and routes to window.cookbookOpenScheduleForm instead of openModal(). The legacy custom modal stays as a fallback for the case where calendar.js hasn't loaded.

UX tweak:
  - cookbookServe.js: replace the standalone "Schedule…" text button with a small icon-only button (clock SVG) glued to the right edge of Launch. The pair forms one visual unit — Launch on the left, schedule-now on the right — sharing a thin divider. CSS handles the rounded corners + divider.
2026-06-05 02:52:07 +09:00
pewdiepie-archdaemon 4ed48baf68 Cookbook scheduler: inline settings card at the top of the Cookbook tab
The earlier scheduler commit shipped the backend + Schedule modal but left the feature dormant — no way to toggle it from the UI. This adds the missing knob:

* DEFAULT_SETTINGS gains `cookbook_scheduler_enabled` (False) and `cookbook_schedule_calendar_href` ("") so `/api/auth/settings` POST will actually persist them. Without this, the POST silently dropped unknown keys.

* cookbookSchedule.js gains a self-contained settings card injected at the top of the Cookbook tab body whenever the cookbook modal opens. Card contents:
  - Enable toggle (writes cookbook_scheduler_enabled)
  - Calendar dropdown populated from /api/calendar/calendars (writes cookbook_schedule_calendar_href)
  - Status line: off / pick-a-calendar / N scheduled in next 24h · M running now · K skipped
  - "Reconcile now" button that POSTs /api/cookbook/schedule/reconcile-now

* The same module reveals/hides the Schedule… buttons on serve panels whenever the feature flag changes, so toggling on immediately surfaces the schedule UI without a refresh.

Settings UI lives in cookbookSchedule.js (not settings.js) so the entire scheduler surface — backend, reconciler, modal, settings — collapses cleanly: delete src/cookbook_scheduler.py + routes/cookbook_schedule_routes.py + static/js/cookbookSchedule.js, drop the two DEFAULT_SETTINGS keys, and the two app.py registration lines, and the feature is gone.
2026-06-05 02:40:35 +09:00
pewdiepie-archdaemon a19b6d2d4d Cookbook scheduler: calendar events drive model serve windows (experimental, feature-flagged)
Add a calendar-driven scheduler so a user can pick a model in Cookbook, click "Schedule…" instead of "Launch", choose time windows + days of the week + (optional) end date, and have Odysseus auto-launch the serve when the window starts and hard-kill it when the window ends. The calendar IS the source of truth — events on a designated calendar are interpreted as serve schedules, so editing the event in the calendar UI immediately changes the schedule.

Whole feature is gated by setting `cookbook_scheduler_enabled` (default False). Disabling the setting silences the reconciler and the API refuses requests; setting + three new files = entire surface, easy to revert.

New files:
  - src/cookbook_scheduler.py — background reconciler: ticks every 60s, reads next ±90s of calendar events on the designated calendar, launches/kills serves to match. Honors "refuse if GPUs busy" (skips with reason, no retry). Adopts pre-existing manual serves matching the event's model so window-end cleanup still applies. Tags scheduler-owned tasks with `_scheduledBy: <event_uid>` so it never kills serves it doesn't own.
  - routes/cookbook_schedule_routes.py — POST /api/cookbook/schedule/from-cookbook builds RRULE+ICS events from the modal's input (model, slots[], days[], until). GET /upcoming returns the next 24h with per-event status (scheduled / running / adopted / skipped / failed / ended) for the UI. POST /reconcile-now manually kicks the reconciler.
  - static/js/cookbookSchedule.js — Schedule button click handler + modal. Daily/hourly time slot picker, multi-slot ("+ add another time slot"), weekday chips with Weekdays/Weekend/Every-day quicksets, optional Until date. Calls /from-cookbook on save. Whole module is a single IIFE; deleting the file plus its <script> tag removes the UI surface.

Existing files touched (minimal):
  - app.py: register the new router + add the reconcile loop as a startup task (~10 lines, all in one block). Reconcile loop checks the feature flag on every tick, so leaving it running with the flag off costs ~one settings lookup per minute.
  - static/index.html: one new <script> tag for cookbookSchedule.js.
  - static/js/cookbookServe.js: add a "Schedule…" button next to the existing Launch button. Hidden by default; cookbookSchedule.js reveals it after confirming the feature flag is on.
  - static/style.css: ~80 lines for the modal styles (mobile-aware via @media).

User choices baked in:
  - Calendar events are the source of truth.
  - Refuse to launch if GPUs busy (skip + log reason in scheduler.events[uid].reason).
  - Hard kill at event end.
  - No retry on a skipped event within the window.
  - Multi-slot per day supported (one calendar event per slot, shared RRULE).
  - Pre-existing manual serves get adopted at window start so they're killed at end.

Known follow-ups (not in this commit):
  - Settings UI to pick the schedule calendar + toggle the feature flag.
  - Calendar event color/badge for status (running/skipped/failed).
  - "Lazy launch on first request" — currently launches at event start. Replacing _launch_serve with a proxy that defers vllm until the first chat request is a contained future change.
2026-06-05 02:35:23 +09:00
pewdiepie-archdaemon 9112861d8e cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity
Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner:

* Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file.
* `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400.
* `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt).

Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll.

`serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes.

Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop.

Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles.

`adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd.

Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
Dan (cirim) 911fd61100 fix(tool_index): add manage_memory to ALWAYS_AVAILABLE 2026-06-04 14:04:32 +10:00
368 changed files with 33644 additions and 4675 deletions
+25
View File
@@ -56,6 +56,13 @@ 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
# ============================================================
@@ -112,6 +119,9 @@ 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
@@ -144,6 +154,21 @@ 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)
# ============================================================
# GPU support (Docker Compose)
# ============================================================
+1 -1
View File
@@ -23,7 +23,7 @@ body:
required: true
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).)
required: true
- label: I am running the latest code from `main`.
- 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: dropdown
+9 -2
View File
@@ -103,14 +103,21 @@ module.exports = async ({ github, context, core }) => {
async function swapLabel(num, add, remove) {
if (await labelExists(add)) {
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
} 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) throw e;
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`);
}
} else {
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
} catch (e) {
if (e.status !== 404 && e.status !== 410) throw e;
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
}
}
+34
View File
@@ -20,6 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
@@ -31,6 +33,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
@@ -51,10 +55,40 @@ jobs:
continue-on-error: true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
# Detect whether this PR only touches documentation files.
# 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
# List all changed files; if every file matches docs/markdown patterns, skip pytest.
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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
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'
+140
View File
@@ -0,0 +1,140 @@
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/**'
- '.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.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 }}
@@ -14,10 +14,11 @@ jobs:
# Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@v7
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
+93 -12
View File
@@ -1,28 +1,109 @@
name: ci / PR description check
name: ci / PR checks
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
# 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]
# pull_request_target runs in the base-repo context (has secrets).
# The checkout below pins to the base branch so no fork code is executed.
# The script only reads context.payload and calls the GitHub API.
permissions:
issues: write
pull-requests: write
# 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
# Skip bots — they open PRs programmatically and have their own process.
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@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts
persist-credentials: false
- uses: actions/github-script@v7
- 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
runs-on: ubuntu-latest
permissions:
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/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(() => {});
}
}
+12
View File
@@ -94,6 +94,18 @@ Before submitting any change that affects what the app looks like — buttons, i
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:
+18
View File
@@ -1,5 +1,7 @@
# Odysseus
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
```
───────────────────────────────────────────────
⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
@@ -331,6 +333,12 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
### Outlook / Office 365 email
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
current limitation and the planned integration direction.
## 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.
@@ -394,6 +402,16 @@ Key settings:
| `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 |
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
### Built-in MCP servers (optional setup)
+32 -15
View File
@@ -51,10 +51,10 @@ from starlette.middleware.base import BaseHTTPMiddleware
# Core imports
from core.constants import (
BASE_DIR, STATIC_DIR, SESSIONS_FILE,
REQUEST_TIMEOUT, OPENAI_API_KEY,
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
)
from core.database import SessionLocal, ApiToken
from core.middleware import SecurityHeadersMiddleware
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
from core.auth import AuthManager
from core.exceptions import (
SessionNotFoundError, InvalidFileUploadError,
@@ -64,6 +64,7 @@ from core.exceptions import (
import bcrypt as _bcrypt
from src.app_helpers import abs_join
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from starlette.responses import RedirectResponse
# ========= LOGGING =========
@@ -252,6 +253,15 @@ if AUTH_ENABLED:
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
# carries no credentials by design and must reach CORSMiddleware to be
# answered. AuthMiddleware is the outermost middleware, so gating the
# preflight on auth 401s it before CORS can respond -- which blocks
# every cross-origin browser/WebView client before the real request
# is sent. Let real preflights through (only OPTIONS w/ the ACRM
# header; never a credentialed request).
if is_cors_preflight(request.method, request.headers):
return await call_next(request)
if _is_auth_exempt(path):
return await call_next(request)
# In-process internal-tool token bypass. Used by the agent
@@ -387,13 +397,7 @@ app.mount("/static", _RevalidatingStatic(directory="static"), name="static")
@app.get("/api/generated-image/{filename}")
async def serve_generated_image(filename: str, request: Request):
"""Serve generated images from the data directory."""
from pathlib import Path
import re
if not re.match(r'^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$', filename):
raise HTTPException(status_code=400, detail="Invalid filename")
img_path = Path("data/generated_images") / filename
if not img_path.exists():
raise HTTPException(status_code=404, detail="Image not found")
img_path = resolve_generated_image_path(filename)
# SECURITY: filename is the only key, so anyone who knows / guesses a
# 12-hex content hash could pull another user's image bytes. Require
# auth and verify ownership via the gallery row (when one exists).
@@ -429,7 +433,7 @@ async def serve_generated_image(filename: str, request: Request):
return FileResponse(
str(img_path),
media_type=mime,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
headers=GENERATED_IMAGE_HEADERS,
)
# ========= YOUTUBE INIT =========
@@ -525,9 +529,6 @@ upload_cleanup_task = None
from routes.emoji_routes import setup_emoji_routes
app.include_router(setup_emoji_routes())
from routes.workspace_routes import setup_workspace_routes
app.include_router(setup_workspace_routes())
# Sessions
from routes.session_routes import setup_session_routes
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
@@ -594,6 +595,10 @@ app.include_router(setup_model_routes(model_discovery))
from routes.copilot_routes import setup_copilot_routes
app.include_router(setup_copilot_routes())
# ChatGPT Subscription device-flow login
from routes.chatgpt_subscription_routes import setup_chatgpt_subscription_routes
app.include_router(setup_chatgpt_subscription_routes())
# TTS
from routes.tts_routes import setup_tts_routes
app.include_router(setup_tts_routes(tts_service))
@@ -789,6 +794,8 @@ async def serve_backgrounds(request: Request):
@app.get("/login")
async def serve_login(request: Request):
if not AUTH_ENABLED:
return RedirectResponse(url="/", status_code=302)
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
@app.get("/api/version")
@@ -948,7 +955,7 @@ async def _startup_event():
owners = set()
try:
import json as _json
auth_path = "data/auth.json"
auth_path = AUTH_FILE
with open(auth_path, encoding="utf-8") as f:
users = _json.load(f).get("users", {})
owners.update(users.keys())
@@ -995,7 +1002,7 @@ async def _startup_event():
# does not make an existing library look empty after auth/account changes.
try:
import json as _json
auth_path = "data/auth.json"
auth_path = AUTH_FILE
with open(auth_path, encoding="utf-8") as f:
users = _json.load(f).get("users", {})
primary_owner = None
@@ -1067,6 +1074,16 @@ async def _startup_event():
logger.warning(f"Nightly skill audit failed: {e}")
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
# Cookbook serve lifecycle — kills scheduler-launched serves whose
# window-end has passed. Paired with the cookbook_serve builtin
# action; both are no-ops unless a scheduled task actually launches
# something with end_after_min set. Removing this line + the
# cookbook_serve entry in BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py
# removes the feature.
from src.cookbook_serve_lifecycle import cookbook_serve_lifecycle_loop
_startup_tasks.append(asyncio.create_task(cookbook_serve_lifecycle_loop()))
logger.info("Application startup complete")
async def _shutdown_event():
+3 -1
View File
@@ -14,6 +14,8 @@ import uuid
import bcrypt
from src.constants import AUTH_FILE
PAIRING_VERSION = 1
COMPANION_SCOPE = "chat"
@@ -61,7 +63,7 @@ def lan_ip_candidates() -> list[str]:
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 = os.path.join("data", "auth.json")
auth_path = AUTH_FILE
try:
with open(auth_path, "r", encoding="utf-8") as f:
data = json.load(f)
+92 -62
View File
@@ -30,14 +30,24 @@ 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
DEFAULT_AUTH_PATH = os.path.join(
Path(__file__).parent.parent, "data", "auth.json"
)
from src.constants import AUTH_FILE
DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
@@ -76,6 +86,10 @@ 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()
@@ -172,8 +186,9 @@ class AuthManager:
@signup_enabled.setter
def signup_enabled(self, value: bool):
self._config["signup_enabled"] = value
self._save()
with self._config_lock:
self._config["signup_enabled"] = value
self._save()
@property
def is_configured(self) -> bool:
@@ -198,17 +213,18 @@ class AuthManager:
if username in RESERVED_USERNAMES:
logger.warning("Refused to create reserved username '%s'", username)
return False
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()
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()
logger.info(f"Created user '{username}' (admin={is_admin})")
return True
@@ -221,14 +237,15 @@ class AuthManager:
their cookie expired naturally (default ~30 days).
"""
username = username.strip().lower()
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()
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
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.
@@ -266,14 +283,15 @@ class AuthManager:
if new_username in RESERVED_USERNAMES:
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
return False
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()
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()
renamed_sessions = 0
with self._sessions_lock:
@@ -311,17 +329,18 @@ 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()
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()
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()
logger.info(f"Updated privileges for '{username}': {current}")
return True
@@ -331,8 +350,9 @@ class AuthManager:
return False
if not _verify_password(current_password, self.users[username]["password_hash"]):
return False
self._config["users"][username]["password_hash"] = _hash_password(new_password)
self._save()
with self._config_lock:
self._config["users"][username]["password_hash"] = _hash_password(new_password)
self._save()
return True
# ------------------------------------------------------------------
@@ -350,8 +370,9 @@ class AuthManager:
if username not in self.users:
return None
secret = pyotp.random_base32()
self._config["users"][username]["totp_secret_pending"] = secret
self._save()
with self._config_lock:
self._config["users"][username]["totp_secret_pending"] = secret
self._save()
return secret
def totp_get_provisioning_uri(self, username: str, secret: str) -> str:
@@ -370,13 +391,14 @@ class AuthManager:
if not totp.verify(code, valid_window=1):
return False
# Enable 2FA
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()
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()
logger.info(f"2FA enabled for '{username}'")
return True
@@ -395,9 +417,10 @@ class AuthManager:
# Check backup codes first
backup = user.get("totp_backup_codes", [])
if code in backup:
backup.remove(code)
self._config["users"][username]["totp_backup_codes"] = backup
self._save()
with self._config_lock:
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)
@@ -408,11 +431,12 @@ class AuthManager:
username = username.strip().lower()
if not self.verify_password(username, password):
return False
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()
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()
logger.info(f"2FA disabled for '{username}'")
return True
@@ -431,6 +455,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) -> 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._sessions_lock:
self._sessions[token] = {
+11 -39
View File
@@ -1,40 +1,12 @@
# src/constants.py
"""Application-wide constants and configuration values."""
import os
# core/constants.py
"""Backward-compatible shim — the single source of truth is src/constants.py.
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
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)
+168 -7
View File
@@ -29,8 +29,9 @@ class TimestampMixin:
def updated_at(cls):
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
# Get database URL from environment, default to SQLite
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
# Get database URL from environment, default to SQLite in DATA_DIR
from src.constants import DATA_DIR, AUTH_FILE, MEMORY_FILE, USER_PREFS_FILE, SETTINGS_FILE
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR}/app.db")
# Create engine
engine = create_engine(
@@ -360,6 +361,24 @@ class ModelEndpoint(TimestampMixin, Base):
# is the historical default. When non-null, the model picker only shows
# the endpoint to that user (admins always see everything).
owner = Column(String, nullable=True, index=True)
# Optional OAuth/session-backed credential row. Used by subscription-backed
# providers that need refresh tokens instead of a static API key.
provider_auth_id = Column(String, nullable=True, index=True)
class ProviderAuthSession(TimestampMixin, Base):
"""Encrypted OAuth/session credentials for refresh-aware model providers."""
__tablename__ = "provider_auth_sessions"
id = Column(String, primary_key=True, index=True)
provider = Column(String, nullable=False, index=True)
owner = Column(String, nullable=True, index=True)
label = Column(String, nullable=True)
base_url = Column(String, nullable=False)
access_token = Column(EncryptedText, nullable=True)
refresh_token = Column(EncryptedText, nullable=True)
last_refresh = Column(DateTime, nullable=True)
auth_mode = Column(String, nullable=True)
class McpServer(TimestampMixin, Base):
"""Admin-configured MCP (Model Context Protocol) tool servers."""
@@ -800,6 +819,26 @@ def _migrate_add_model_endpoint_owner_column():
logging.getLogger(__name__).warning(f"model_endpoints.owner migration failed: {e}")
def _migrate_add_provider_auth_id_column():
"""Add provider_auth_id column to model_endpoints if it doesn't exist."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA table_info(model_endpoints)")
columns = [row[1] for row in cursor.fetchall()]
if columns and "provider_auth_id" not in columns:
conn.execute("ALTER TABLE model_endpoints ADD COLUMN provider_auth_id VARCHAR")
conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_provider_auth_id ON model_endpoints(provider_auth_id)")
conn.commit()
logging.getLogger(__name__).info("Migrated: added 'provider_auth_id' column + index to model_endpoints")
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"model_endpoints.provider_auth_id migration failed: {e}")
def _migrate_add_model_type_column():
"""Add model_type column to model_endpoints if it doesn't exist."""
import sqlite3
@@ -1065,7 +1104,7 @@ def _migrate_assign_legacy_owner():
# fell through to "first user" every time.
auth_path = os.path.join(os.path.dirname(DATABASE_URL.replace("sqlite:///", "")), "auth.json")
if not os.path.isabs(auth_path):
auth_path = os.path.join("data", "auth.json")
auth_path = AUTH_FILE
admin_user = None
try:
with open(auth_path, "r", encoding="utf-8") as f:
@@ -1118,7 +1157,7 @@ def _migrate_assign_legacy_owner():
logger.warning(f"Legacy owner migration failed: {e}")
# Also migrate memory.json
mem_path = os.path.join("data", "memory.json")
mem_path = MEMORY_FILE
try:
if os.path.exists(mem_path):
with open(mem_path, "r", encoding="utf-8") as f:
@@ -1136,7 +1175,7 @@ def _migrate_assign_legacy_owner():
logger.warning(f"memory.json legacy migration failed: {e}")
# Also migrate user_prefs.json to per-user format
prefs_path = os.path.join("data", "user_prefs.json")
prefs_path = USER_PREFS_FILE
try:
if os.path.exists(prefs_path):
with open(prefs_path, "r", encoding="utf-8") as f:
@@ -1458,7 +1497,11 @@ class CalendarCal(TimestampMixin, Base):
owner = Column(String, nullable=True, index=True)
name = Column(String, nullable=False)
color = Column(String, default="#5b8abf")
source = Column(String, default="local") # "local" or "timetree"
source = Column(String, default="local") # "local" or "caldav"
# UUID of the CalDAV account in user prefs that owns this calendar.
# NULL for local calendars and for CalDAV calendars created before
# multi-account support was added (treated as "use any configured account").
account_id = Column(String, nullable=True, index=True)
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -1526,7 +1569,7 @@ def _migrate_seed_email_account():
import json as _json
import uuid as _uuid
from pathlib import Path
settings_file = Path("data/settings.json")
settings_file = Path(SETTINGS_FILE)
if not settings_file.exists():
return
try:
@@ -1594,6 +1637,7 @@ def init_db():
_migrate_add_model_type_column()
_migrate_add_model_endpoint_refresh_columns()
_migrate_add_model_endpoint_owner_column()
_migrate_add_provider_auth_id_column()
_migrate_add_supports_tools_column()
_migrate_add_task_run_model_column()
_migrate_add_owner_column()
@@ -1622,9 +1666,105 @@ def init_db():
_migrate_add_calendar_metadata()
_migrate_add_calendar_is_utc()
_migrate_add_calendar_origin()
_migrate_add_calendar_account_id()
_migrate_chat_messages_fts()
_migrate_encrypt_email_passwords()
_migrate_encrypt_signatures()
_migrate_encrypt_endpoint_keys()
_migrate_backfill_task_folders()
def _migrate_backfill_task_folders():
"""Backfill folder='Tasks' on pre-existing task/research sessions.
Sessions created by the task scheduler (LLM tasks, action tasks, research
runs) now set folder='Tasks' at creation time. This migration tags any
older sessions that predate that assignment. Idempotent — only touches
rows where folder is NULL or empty and the title matches known prefixes.
"""
try:
with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(sessions)"))]
if "folder" not in cols:
return
res = conn.execute(text(
"UPDATE sessions SET folder = 'Tasks' "
"WHERE (folder IS NULL OR folder = '') "
"AND (name LIKE '[Task] %' OR name LIKE '[Research] %')"
))
conn.commit()
if res.rowcount:
logging.getLogger(__name__).info(
f"Backfilled folder='Tasks' on {res.rowcount} task/research sessions")
except Exception as e:
logging.getLogger(__name__).warning(f"task folder backfill: {e}")
def _migrate_chat_messages_fts():
"""Create and backfill the session transcript FTS index for SQLite."""
if not DATABASE_URL.startswith("sqlite"):
return
db_path = DATABASE_URL.replace("sqlite:///", "")
if db_path == ":memory:":
return
conn = None
try:
conn = sqlite3.connect(db_path)
try:
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS migration skipped; FTS5 unavailable: {e}")
return
conn.executescript(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
content,
message_id UNINDEXED,
session_id UNINDEXED,
role UNINDEXED
);
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
AFTER INSERT ON chat_messages BEGIN
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
END;
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
AFTER DELETE ON chat_messages BEGIN
DELETE FROM chat_messages_fts WHERE message_id = old.id;
END;
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_au
AFTER UPDATE ON chat_messages BEGIN
DELETE FROM chat_messages_fts WHERE message_id = old.id;
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
END;
"""
)
conn.execute(
"""
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
SELECT COALESCE(cm.content, ''), cm.id, cm.session_id, cm.role
FROM chat_messages cm
WHERE NOT EXISTS (
SELECT 1 FROM chat_messages_fts fts
WHERE fts.message_id = cm.id
)
"""
)
conn.commit()
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
finally:
try:
conn.close()
except Exception:
pass
def _migrate_add_email_smtp_security():
@@ -1786,6 +1926,27 @@ def _migrate_add_calendar_origin():
logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}")
def _migrate_add_calendar_account_id():
"""Add `account_id` to calendars so each CalDAV-backed calendar knows which
credential set (from caldav_accounts in user prefs) owns it. Idempotent."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA table_info(calendars)")
columns = [row[1] for row in cursor.fetchall()]
if columns and "account_id" not in columns:
conn.execute("ALTER TABLE calendars ADD COLUMN account_id TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS ix_calendars_account_id ON calendars(account_id)")
conn.commit()
logging.getLogger(__name__).info("Migrated: added 'account_id' column to calendars")
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"calendars.account_id migration failed: {e}")
def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3
+26
View File
@@ -17,6 +17,15 @@ INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
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
@@ -58,11 +67,22 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Tool render endpoints are served inside iframes — allow framing by self
is_tool_render = path.startswith("/api/tools/") and path.endswith("/render")
# PDF previews are embedded by the in-app document library. Keep the
# exception route-scoped so normal app pages remain unframeable.
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"] = (
@@ -79,6 +99,12 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# 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.
+205 -3
View File
@@ -18,10 +18,22 @@ 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 ────────────────────────────────────────────────────────
@@ -53,9 +65,8 @@ 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}
@@ -150,6 +161,29 @@ _WINDOWS_BASH_RELATIVE_PATHS = (
("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] = []
@@ -180,6 +214,21 @@ def _is_windows_bash_stub(path: str) -> bool:
)
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]:
"""Locate a real ``bash`` interpreter, or None.
@@ -242,3 +291,156 @@ 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") 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."""
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,
)
+2 -2
View File
@@ -14,7 +14,7 @@ import logging
from datetime import datetime, timezone, timedelta
from typing import Dict, Optional
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
from .models import Session, ChatMessage
logger = logging.getLogger(__name__)
@@ -619,7 +619,7 @@ class SessionManager:
try:
all_sessions = db.query(DbSession).all()
cutoff_date = datetime.now(timezone.utc) - timedelta(days=auto_archive_days)
cutoff_date = utcnow_naive() - timedelta(days=auto_archive_days)
for db_session in all_sessions:
stats['total_checked'] += 1
+2
View File
@@ -52,12 +52,14 @@ services:
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- 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_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+2
View File
@@ -51,12 +51,14 @@ services:
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- 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_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+2
View File
@@ -40,12 +40,14 @@ services:
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- 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_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+17
View File
@@ -0,0 +1,17 @@
# Outlook / Office 365 email accounts
Odysseus email accounts currently use IMAP and SMTP with username/password
authentication. That works for providers that still allow app passwords or
mailbox passwords for IMAP/SMTP.
Microsoft disables basic authentication for Outlook and Microsoft 365 in most
modern accounts and tenants. If you try to add an Outlook account with a normal
password, Microsoft may return errors such as:
- `IMAP: AUTHENTICATE failed`
- `SMTP: 535 5.7.139 Authentication unsuccessful, basic authentication is disabled`
This is expected. Odysseus does not support Microsoft OAuth or Graph Mail yet,
so Outlook / Office 365 accounts cannot currently be added through the password
form. Use another email provider with app-password support, or track the future
Microsoft Graph OAuth integration.
+44 -1
View File
@@ -1,6 +1,6 @@
---
name: odysseus
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
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
@@ -105,6 +105,49 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
- `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.
@@ -17,6 +17,15 @@ def _usage() -> int:
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 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
@@ -72,6 +81,61 @@ def main() -> int:
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()
@@ -17,6 +17,15 @@ def _usage() -> int:
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 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
@@ -72,6 +81,61 @@ def main() -> int:
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()
+32 -1
View File
@@ -1,6 +1,6 @@
---
name: odysseus
description: Use when the user asks Codex to read or write Odysseus data from a terminal Codex session through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
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
@@ -105,6 +105,37 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
- `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.
-22
View File
@@ -1,22 +0,0 @@
"""
_common.py
Shared constants and helpers for built-in MCP servers.
"""
MAX_OUTPUT_CHARS = 10_000
MAX_READ_CHARS = 20_000
SHELL_TIMEOUT = 60
PYTHON_TIMEOUT = 30
SEARCH_TIMEOUT = 30
def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
"""Truncate text to *limit* characters with a suffix note."""
if not isinstance(text, str):
# Tool output is occasionally None or a non-string; len(None) would
# raise. Coerce so this shared helper never crashes a tool response.
text = "" if text is None else str(text)
if len(text) > limit:
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
return text
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -16,6 +16,8 @@ from mcp.types import Tool, TextContent
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.constants import GENERATED_IMAGES_DIR
server = Server("image_gen")
@@ -115,14 +117,18 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
img = images[0]
image_url = None
# Prefix the instance's public base URL (existing app_public_url setting) so the
# link is fully-qualified and clickable when the model echoes it. Empty = relative
# same-origin path (unchanged default).
_pub_base = (get_setting("app_public_url", "") or "").rstrip("/")
if img.get("b64_json"):
img_dir = Path("data/generated_images")
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(base64.b64decode(img["b64_json"]))
image_url = f"/api/generated-image/{filename}"
image_url = f"{_pub_base}/api/generated-image/{filename}"
# Save to gallery
try:
@@ -146,7 +152,13 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
else:
return [TextContent(type="text", text="Error: Unexpected image API response format")]
result = f"Generated image for: {prompt[:100]}\nimage_url: {image_url}\nmodel: {model_id}\nsize: {size}"
# "Direct link:" rather than an "image_url:" label — small models copied the
# label token ("image_url") into the link href, producing a broken link.
result = (
f"Generated image for: {prompt[:100]}\n"
f"Direct link: {image_url}\n"
f"model: {model_id}\nsize: {size}"
)
return [TextContent(type="text", text=result)]
except httpx.TimeoutException:
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "odysseus-ui",
"name": "odysseus",
"lockfileVersion": 3,
"requires": true,
"packages": {
+15
View File
@@ -1,3 +1,18 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
# Test-taxonomy markers added at collection time by tests/conftest.py. The
# stable area_* markers are declared here; the dynamic sub_<filename-token>
# markers are registered before collection by pytest_configure in
# tests/conftest.py, so unknown-mark warnings still flag genuine typos outside
# the taxonomy. See tests/_taxonomy.py and tests/README.md.
markers = [
"area_security: tests covering auth, owner-scope, SSRF, XSS, confinement, redaction",
"area_routes: tests covering HTTP route / API behavior",
"area_services: tests covering service-layer behavior (llm, cookbook, email, calendar, ...)",
"area_cli: tests covering CLI / script behavior",
"area_js: JavaScript / Node-backed tests",
"area_helpers: self-tests for the shared test helpers in tests/helpers/",
"area_unit: pure parser / utility tests that do not clearly belong elsewhere",
"area_uncategorized: tests not yet matched by the taxonomy (fallback)",
]
+5 -5
View File
@@ -31,7 +31,7 @@ from core.database import (
CalendarEvent,
CalendarCal,
)
from src.constants import DATA_DIR
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
logger = logging.getLogger(__name__)
@@ -107,7 +107,7 @@ def setup_admin_wipe_routes(session_manager):
# Skills live as SKILL.md files under data/skills/. Drop
# the entire directory; the SkillsManager re-creates the
# tree on next write.
skills_dir = os.path.join(DATA_DIR, "skills")
skills_dir = SKILLS_DIR
count = 0
if os.path.isdir(skills_dir):
# Count SKILL.md files for the response — quick walk.
@@ -115,7 +115,7 @@ def setup_admin_wipe_routes(session_manager):
count += sum(1 for f in files if f == "SKILL.md")
_rmtree_quiet(skills_dir)
# Legacy fallback file
legacy = os.path.join(DATA_DIR, "skills.json")
legacy = SKILLS_FILE
if os.path.exists(legacy):
try:
os.remove(legacy)
@@ -151,8 +151,8 @@ def setup_admin_wipe_routes(session_manager):
db.query(GalleryAlbum).delete()
db.commit()
# Also drop the upload dir so disk doesn't keep orphans.
_rmtree_quiet(os.path.join(DATA_DIR, "gallery"))
_rmtree_quiet(os.path.join(DATA_DIR, "gallery_uploads"))
_rmtree_quiet(GALLERY_DIR)
_rmtree_quiet(GALLERY_UPLOADS_DIR)
return {"status": "deleted", "kind": kind, "count": count}
if kind == "calendar":
+14 -4
View File
@@ -25,6 +25,8 @@ ALLOWED_SCOPES = {
"calendar:write",
"memory:read",
"memory:write",
"cookbook:read",
"cookbook:launch",
}
TOKEN_PROFILES = {
"chat": ["chat"],
@@ -155,22 +157,30 @@ def setup_api_token_routes() -> APIRouter:
payload = await request.json()
except Exception:
payload = {}
scope_list = _normalize_scopes(payload.get("scopes"))
scopes_value = ",".join(scope_list)
with get_db_session() as db:
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
if not token:
raise HTTPException(404, "Token not found")
if isinstance(payload.get("name"), str) and payload["name"].strip():
token.name = payload["name"].strip()[:MAX_NAME_LEN]
token.scopes = scopes_value
# Only touch scopes when the caller actually sent them. A partial
# update such as a rename ({"name": ...} with no "scopes" key) must
# not silently reset the token to the default scope — that dropped
# every previously granted scope.
if "scopes" in payload:
token.scopes = ",".join(_normalize_scopes(payload.get("scopes")))
db.add(token)
current_scopes = [
s.strip()
for s in (getattr(token, "scopes", "") or DEFAULT_SCOPES).split(",")
if s.strip()
]
response = {
"id": token_id,
"name": getattr(token, "name", ""),
"owner": getattr(token, "owner", None),
"token_prefix": getattr(token, "token_prefix", ""),
"scopes": scope_list,
"scopes": current_scopes,
}
_invalidate_cache(request)
return response
+23 -4
View File
@@ -131,10 +131,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
return {"ok": False, "requires_totp": True, "username": username}
if not auth_manager.totp_verify(username, body.totp_code):
raise HTTPException(401, "Invalid 2FA code")
# All checks passed — create session
token = await asyncio.to_thread(auth_manager.create_session, username, body.password)
if not token:
raise HTTPException(401, "Invalid credentials")
# All checks passed — create session (password already verified above)
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
cookie_kwargs = dict(
key=SESSION_COOKIE,
value=token,
@@ -585,6 +583,27 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
hint = " If this is Docker Compose ntfy, set NTFY_BIND to that host/Tailscale IP and NTFY_BASE_URL to the same server URL in .env, then recreate ntfy."
return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}.{hint}"[:500]}
if preset == "discord_webhook":
import httpx
webhook_url = (integ.get("base_url") or "").strip()
if not webhook_url:
return {"ok": False, "message": "No webhook URL set — paste the full Discord webhook URL into the Base URL field."}
payload = {
"embeds": [{
"title": "Odysseus connectivity test",
"description": "If you see this, your Discord Webhook integration is wired up correctly.",
"color": 5793266,
}]
}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.post(webhook_url, json=payload)
if r.is_success:
return {"ok": True, "message": "Test embed sent — check your Discord channel to confirm it arrived."}
return {"ok": False, "message": f"Discord returned HTTP {r.status_code}: {r.text[:200]}"}
except Exception as e:
return {"ok": False, "message": f"Request failed: {e}"[:400]}
# All other presets: GET against a known health endpoint.
# Fall back to detecting from name if preset is missing.
health_paths = {
+62 -12
View File
@@ -101,24 +101,74 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
# ── Skills ──
if "skills" in body and isinstance(body["skills"], list):
existing = skills_manager.load_all()
existing_ids = {s.get("id") for s in existing}
existing_titles = {s.get("title", "").strip().lower() for s in existing}
# Dedup against THIS user's own skills only. Using every tenant's
# rows (load_all) meant a skill whose id/name/title matched any
# other user's was silently skipped, so the importing user lost
# their own data — same cross-tenant bug fixed for memories above.
# The full store is still saved back below.
own = [s for s in existing if s.get("owner") == user]
existing_names = {s.get("name") for s in own if s.get("name")}
existing_ids = {s.get("id") for s in own if s.get("id")}
existing_titles = {
(s.get("title") or s.get("description") or "").strip().lower()
for s in own
}
added = 0
for skill in body["skills"]:
if not isinstance(skill, dict) or not skill.get("title"):
if not isinstance(skill, dict):
continue
# Skip if same id or same title already exists
if skill.get("id") in existing_ids:
title = (
skill.get("title") or skill.get("description")
or skill.get("name") or ""
).strip()
if not title:
continue
if skill["title"].strip().lower() in existing_titles:
sid = skill.get("id") or skill.get("name")
if sid and sid in existing_ids:
continue
if user and not skill.get("owner"):
skill["owner"] = user
existing.append(skill)
existing_ids.add(skill.get("id"))
existing_titles.add(skill["title"].strip().lower())
nm = skill.get("name")
if nm and nm in existing_names:
continue
if title.lower() in existing_titles:
continue
owner = skill.get("owner")
if user and not owner:
owner = user
# Skills live on disk as SKILL.md files; the old JSON-era
# skills_manager.save() no longer exists. Write each new skill
# via add_skill (source="user" skips auto-dedup — this is an
# explicit backup restore).
result = skills_manager.add_skill(
title=title,
name=skill.get("name"),
description=skill.get("description"),
problem=skill.get("problem", ""),
solution=skill.get("solution", ""),
steps=skill.get("steps"),
tags=skill.get("tags"),
source="user",
teacher_model=skill.get("teacher_model"),
confidence=skill.get("confidence", 0.8),
owner=owner,
category=skill.get("category", "general"),
when_to_use=skill.get("when_to_use"),
procedure=skill.get("procedure"),
pitfalls=skill.get("pitfalls"),
verification=skill.get("verification"),
platforms=skill.get("platforms"),
requires_toolsets=skill.get("requires_toolsets"),
fallback_for_toolsets=skill.get("fallback_for_toolsets"),
status=skill.get("status", "draft"),
version=skill.get("version", "1.0.0"),
)
if result.get("_deduped"):
continue
if result.get("name"):
existing_names.add(result["name"])
if result.get("id"):
existing_ids.add(result["id"])
existing_titles.add(title.lower())
added += 1
skills_manager.save(existing)
imported.append(f"{added} skills")
# ── Presets ──
+254 -69
View File
@@ -1,6 +1,7 @@
"""Calendar routes — local SQLite-backed calendar CRUD."""
import logging
import re
import uuid
from datetime import datetime, date, timedelta
from typing import Optional, List
@@ -12,7 +13,7 @@ from dateutil.rrule import rrulestr
from core.database import SessionLocal, CalendarCal, CalendarEvent
from src.auth_helpers import require_user
from src.upload_limits import read_upload_limited
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
logger = logging.getLogger(__name__)
@@ -100,6 +101,15 @@ def _ics_escape(text: str) -> str:
)
def _safe_ics_filename(name: str) -> str:
"""Return a conservative .ics filename safe for Content-Disposition."""
stem = name if isinstance(name, str) else ""
stem = re.sub(r"[^A-Za-z0-9._-]", "_", stem).strip("._-")
if not stem:
stem = "calendar"
return f"{stem[:128]}.ics"
def _resolve_base_uid(uid: str) -> str:
"""Extract the base series UID from a compound occurrence UID.
@@ -248,6 +258,17 @@ def parse_due_for_user(s: str) -> str:
if t is not None:
return base.replace(hour=t[0], minute=t[1]).isoformat()
# Time-first: "3pm today", "11pm today", "9am tomorrow"
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
if m:
time_part, word = m.group(1).strip(), m.group(2)
base = today
if word in ("tomorrow", "tmrw"): base = today + _td(days=1)
elif word == "yesterday": base = today - _td(days=1)
t = _parse_time(time_part)
if t is not None:
return base.replace(hour=t[0], minute=t[1]).isoformat()
m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower)
if m:
n = int(m.group(1)); unit = m.group(2)
@@ -399,7 +420,17 @@ def _parse_dt(s: str) -> datetime:
# Last resort: dateutil's fuzzy parser
try:
from dateutil import parser as _du
return _du.parse(s)
parsed = _du.parse(s)
# Strip tz like every other return path above — this function's
# contract is naive datetimes (CalendarEvent.dtstart is naive). An
# offset-bearing non-ISO input (e.g. RFC-2822 "Mon, 05 Jan 2026
# 14:00:00 +0900") otherwise leaked tz-aware into the naive column and
# crashed read-back comparisons in _expand_rrule with "can't compare
# offset-naive and offset-aware datetimes".
if parsed.tzinfo is not None:
from datetime import timezone as _tz
return parsed.astimezone(_tz.utc).replace(tzinfo=None)
return parsed
except Exception:
raise ValueError(f"could not parse datetime: {s!r}")
@@ -440,6 +471,9 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
# ── Recurrence expansion ──
_RRULE_EXPANSION_LIMIT = 1000
def _expand_rrule(
ev: CalendarEvent, start: datetime, end: datetime
) -> List[dict]:
@@ -462,6 +496,7 @@ def _expand_rrule(
d = _event_to_dict(ev)
d["is_recurrence"] = False
d["series_uid"] = ev.uid
d["truncated"] = False
return [d]
# Parse the rrule, applying it to the base dtstart.
@@ -487,6 +522,7 @@ def _expand_rrule(
d = _event_to_dict(ev)
d["is_recurrence"] = False
d["series_uid"] = ev.uid
d["truncated"] = False
# Malformed RRULE rows are fetched by the recurring SQL branch
# with only dtstart < end_dt — the base event may not actually
# overlap the window. Only return if it does.
@@ -499,22 +535,26 @@ def _expand_rrule(
# (matching non-recurring overlap semantics: dtstart < end AND
# dtend > start).
expand_start = start - duration
occurrences = rule.between(expand_start, end, inc=True)
if not occurrences:
return []
results = []
truncated = False
base = _event_to_dict(ev)
for occ_start in occurrences:
for occ_start in rule.xafter(expand_start, inc=True):
if occ_start >= end:
break
occ_end = occ_start + duration
# Overlap filter: occurrence must intersect [start, end).
# This enforces exclusive-end semantics (occ_start >= end is
# excluded) and includes multi-day crossings (occ_end > start).
if occ_start >= end or occ_end <= start:
if occ_end <= start:
continue
if len(results) >= _RRULE_EXPANSION_LIMIT:
truncated = True
break
# Build the compound uid: {base_uid}::{date} or ::{datetime}
if ev.all_day:
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
@@ -525,6 +565,7 @@ def _expand_rrule(
d["uid"] = occ_uid
d["series_uid"] = ev.uid
d["is_recurrence"] = True
d["truncated"] = False
if ev.all_day:
d["dtstart"] = occ_start.strftime("%Y-%m-%d")
@@ -537,6 +578,10 @@ def _expand_rrule(
results.append(d)
if truncated:
for d in results:
d["truncated"] = True
return results
@@ -545,72 +590,178 @@ def _expand_rrule(
def setup_calendar_routes() -> APIRouter:
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
# CalDAV connect form (Integrations → Calendar). Storage is local
# SQLite; sync (src/caldav_sync.py) pulls remote events into it on
# calendar open and periodically via the scheduler.
# ── CalDAV multi-account helpers ─────────────────────────────────────────
def _get_caldav_accounts(owner: str) -> list:
from src.caldav_sync import _load_caldav_accounts
return _load_caldav_accounts(owner)
def _save_caldav_accounts(owner: str, accounts: list) -> None:
from routes.prefs_routes import _load_for_user, _save_for_user
prefs = _load_for_user(owner) or {}
prefs["caldav_accounts"] = accounts
prefs.pop("caldav", None)
_save_for_user(owner, prefs)
# ── CalDAV config routes (backward-compat single-account API) ────────────
@router.get("/config")
async def get_config(request: Request):
"""Legacy single-account endpoint — returns the first configured account."""
owner = _require_user(request)
from routes.prefs_routes import _load_for_user
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
caldav_password = cfg.get("password") or ""
if caldav_password:
accounts = _get_caldav_accounts(owner)
if not accounts:
return {"url": "", "username": "", "password": "", "has_password": False, "local": True}
first = accounts[0]
pw = first.get("password") or ""
has_pw = False
if pw:
try:
from src.secret_storage import decrypt
caldav_password = decrypt(caldav_password)
has_pw = bool(decrypt(pw))
except Exception:
pass
# Surface url+username but never hand the password back to the
# client — saved-state UI shouldn't leak the credential.
has_pw = bool(pw)
return {
"url": cfg.get("url", "") or "",
"username": cfg.get("username", "") or "",
"url": first.get("url", "") or "",
"username": first.get("username", "") or "",
"password": "",
"has_password": bool(caldav_password),
"local": not bool(cfg.get("url")),
"has_password": has_pw,
"local": not bool(first.get("url")),
}
@router.post("/config")
async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request)
from routes.prefs_routes import _load_for_user, _save_for_user
try:
body = await request.json()
except Exception:
body = {}
prefs = _load_for_user(owner) or {}
cfg = dict(prefs.get("caldav") or {})
# Empty url => clear the whole entry (treat as "remove integration").
accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip():
prefs.pop("caldav", None)
_save_for_user(owner, prefs)
_save_caldav_accounts(owner, [])
return {"ok": True, "cleared": True}
from src.caldav_sync import validate_caldav_url
try:
cfg["url"] = validate_caldav_url(body.get("url", ""))
validated_url = validate_caldav_url(body.get("url", ""))
except ValueError as e:
raise HTTPException(400, str(e))
cfg["username"] = (body.get("username") or "").strip()
# Preserve the stored password when the client sends an empty
# one (edit form re-submitted without re-typing the password).
# cfg already holds the existing (already-encrypted) password from
# prefs, so we only touch it when a new password is supplied —
# re-encrypting the stored value would double-encrypt it.
if accounts:
acc = dict(accounts[0])
else:
import uuid as _uuid
acc = {"id": str(_uuid.uuid4()), "label": "CalDAV"}
acc["url"] = validated_url
acc["username"] = (body.get("username") or "").strip()
if body.get("password"):
from src.secret_storage import encrypt
cfg["password"] = encrypt(body["password"])
prefs["caldav"] = cfg
_save_for_user(owner, prefs)
acc["password"] = encrypt(body["password"])
new_accounts = [acc] + (accounts[1:] if len(accounts) > 1 else [])
_save_caldav_accounts(owner, new_accounts)
return {"ok": True}
# ── CalDAV multi-account CRUD ─────────────────────────────────────────────
@router.get("/config/accounts")
async def list_caldav_accounts(request: Request):
"""Return all configured CalDAV accounts (passwords never returned)."""
owner = _require_user(request)
accounts = _get_caldav_accounts(owner)
safe = []
for acc in accounts:
pw = acc.get("password") or ""
has_pw = False
if pw:
try:
from src.secret_storage import decrypt
has_pw = bool(decrypt(pw))
except Exception:
has_pw = bool(pw)
safe.append({
"id": acc.get("id", ""),
"label": acc.get("label", "") or acc.get("url", ""),
"url": acc.get("url", "") or "",
"username": acc.get("username", "") or "",
"has_password": has_pw,
})
return {"accounts": safe}
@router.post("/config/accounts")
async def add_caldav_account(request: Request):
"""Add a new CalDAV account."""
import uuid as _uuid
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(body.get("url", ""))
except ValueError as e:
raise HTTPException(400, str(e))
if not body.get("password"):
raise HTTPException(400, "Password is required")
from src.secret_storage import encrypt
new_acc = {
"id": str(_uuid.uuid4()),
"label": (body.get("label") or "").strip() or "CalDAV",
"url": url,
"username": (body.get("username") or "").strip(),
"password": encrypt(body["password"]),
}
accounts = _get_caldav_accounts(owner)
accounts.append(new_acc)
_save_caldav_accounts(owner, accounts)
return {"ok": True, "id": new_acc["id"]}
@router.put("/config/accounts/{account_id}")
async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None:
raise HTTPException(404, "Account not found")
acc = dict(accounts[idx])
if body.get("url"):
from src.caldav_sync import validate_caldav_url
try:
acc["url"] = validate_caldav_url(body["url"])
except ValueError as e:
raise HTTPException(400, str(e))
if body.get("label") is not None:
acc["label"] = (body.get("label") or "").strip() or "CalDAV"
if body.get("username") is not None:
acc["username"] = (body.get("username") or "").strip()
if body.get("password"):
from src.secret_storage import encrypt
acc["password"] = encrypt(body["password"])
accounts[idx] = acc
_save_caldav_accounts(owner, accounts)
return {"ok": True}
@router.delete("/config/accounts/{account_id}")
async def delete_caldav_account(account_id: str, request: Request):
"""Remove a CalDAV account by id."""
owner = _require_user(request)
accounts = _get_caldav_accounts(owner)
new_accounts = [a for a in accounts if a.get("id") != account_id]
if len(new_accounts) == len(accounts):
raise HTTPException(404, "Account not found")
_save_caldav_accounts(owner, new_accounts)
return {"ok": True}
@router.post("/test")
async def test_connection(request: Request):
"""Actually probe the configured CalDAV server with a PROPFIND
request (the same handshake every CalDAV client uses). Accepts
an optional {url, username, password} body so the user can test
a configuration BEFORE saving it; falls back to the stored
creds otherwise. Returns {ok, error?} with a useful message on
failure (status code, auth issue, network error)."""
"""Probe a CalDAV server with a PROPFIND. Accepts an optional body:
{url, username, password} to test before saving, or {account_id} to
test an already-saved account. Falls back to the first saved account
when nothing is provided."""
owner = _require_user(request)
try:
body = await request.json()
@@ -620,19 +771,24 @@ def setup_calendar_routes() -> APIRouter:
user = (body.get("username") or "").strip()
pw = body.get("password") or ""
if not (url and user and pw):
# Fall back to saved settings for this user.
from routes.prefs_routes import _load_for_user
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
url = url or (cfg.get("url") or "")
user = user or (cfg.get("username") or "")
if not pw:
pw = cfg.get("password") or ""
if pw:
try:
from src.secret_storage import decrypt
pw = decrypt(pw)
except Exception:
pass
# Look up a saved account: by id if supplied, else first account.
accounts = _get_caldav_accounts(owner)
acc = None
if body.get("account_id"):
acc = next((a for a in accounts if a.get("id") == body["account_id"]), None)
if acc is None and accounts:
acc = accounts[0]
if acc:
url = url or (acc.get("url") or "")
user = user or (acc.get("username") or "")
if not pw:
pw = acc.get("password") or ""
if pw:
try:
from src.secret_storage import decrypt
pw = decrypt(pw)
except Exception:
pass
if not (url and user and pw):
return {"ok": False, "error": "Missing URL, username, or password"}
from src.caldav_sync import validate_caldav_url
@@ -695,6 +851,28 @@ def setup_calendar_routes() -> APIRouter:
from src.caldav_sync import sync_caldav
return await sync_caldav(owner)
@router.delete("/calendars/{cal_id}")
async def delete_calendar(cal_id: str, request: Request):
owner = _require_user(request)
db = SessionLocal()
try:
cal = db.query(CalendarCal).filter(
CalendarCal.id == cal_id,
CalendarCal.owner == owner,
).first()
if not cal:
raise HTTPException(404, "Calendar not found")
db.delete(cal)
db.commit()
return {"ok": True}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete calendar %s: %s", cal_id, e)
raise HTTPException(500, "Failed to delete calendar")
finally:
db.close()
@router.get("/calendars")
async def list_calendars(request: Request):
owner = _require_user(request)
@@ -703,7 +881,7 @@ def setup_calendar_routes() -> APIRouter:
_ensure_default_calendar(db, owner)
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
return {"calendars": [
{"name": c.name, "href": c.id, "color": c.color}
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
for c in cals
]}
except HTTPException:
@@ -766,8 +944,12 @@ def setup_calendar_routes() -> APIRouter:
expanded.extend(_expand_rrule(e, start_dt, end_dt))
# Sort by occurrence start time for consistent frontend ordering.
truncated = any(e.get("truncated") for e in expanded)
expanded.sort(key=lambda d: d["dtstart"])
return {"events": expanded}
response: dict = {"events": expanded}
if truncated:
response["truncated"] = True
return response
except HTTPException:
raise
except Exception as e:
@@ -988,9 +1170,9 @@ def setup_calendar_routes() -> APIRouter:
finally:
db.close()
# 10 MB hard cap on ICS upload. Loading the whole file into memory is
# unavoidable with python-icalendar, so an unbounded upload would OOM.
_ICS_MAX_BYTES = 10 * 1024 * 1024
# Hard cap on ICS upload (ICS_MAX_BYTES, default 10 MB). Loading the whole
# file into memory is unavoidable with python-icalendar, so an unbounded
# upload would OOM.
@router.post("/import")
async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""):
@@ -1000,7 +1182,7 @@ def setup_calendar_routes() -> APIRouter:
owner = _require_user(request)
db = SessionLocal()
try:
content = await read_upload_limited(file, _ICS_MAX_BYTES, "ICS file")
content = await read_upload_limited(file, ICS_MAX_BYTES, "ICS file")
try:
cal_data = iCal.from_ical(content)
except Exception as e:
@@ -1168,11 +1350,14 @@ def setup_calendar_routes() -> APIRouter:
lines.append("END:VCALENDAR")
ics_data = "\r\n".join(lines)
safe_name = cal.name.replace(" ", "_").replace("/", "_")
download_name = _safe_ics_filename(cal.name)
return Response(
content=ics_data,
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
headers={
"Content-Disposition": f'attachment; filename="{download_name}"',
"X-Content-Type-Options": "nosniff",
},
)
except HTTPException:
raise
@@ -1194,7 +1379,7 @@ def setup_calendar_routes() -> APIRouter:
"tomorrow", "next Tuesday", "in 30 minutes" resolve correctly.
Uses the "utility" endpoint (small / fast model) to keep latency low.
"""
_require_user(request)
owner = _require_user(request)
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
from src.text_helpers import strip_think
@@ -1220,9 +1405,9 @@ def setup_calendar_routes() -> APIRouter:
if tz_hint:
set_user_tz_name(tz_hint)
url, model, headers = resolve_endpoint("utility")
url, model, headers = resolve_endpoint("utility", owner=owner or None)
if not url:
url, model, headers = resolve_endpoint("default")
url, model, headers = resolve_endpoint("default", owner=owner or None)
if not url or not model:
return {"ok": False, "error": "No LLM endpoint configured"}
+130 -38
View File
@@ -75,7 +75,7 @@ def _enforce_chat_privileges(request, sess) -> None:
allowlist, or HTTPException(429) if the user has hit their daily message
cap. No-op for unauthenticated callers or when auth_manager is absent
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
which means empty allowed_models / zero cap no-op for them.
which means unrestricted allowed_models / zero cap -> no-op for them.
"""
try:
user = get_current_user(request)
@@ -88,8 +88,18 @@ def _enforce_chat_privileges(request, sess) -> None:
return
privs = auth_manager.get_privileges(user) or {}
allowed = privs.get("allowed_models") or []
if allowed and sess.model and sess.model not in allowed:
# Explicit "block everything" sentinel takes precedence over the
# allowlist — it's the only way to distinguish "user clicked [None]"
# (block all) from "user clicked [All]" (no restriction), since both
# otherwise produce an empty `allowed_models` list.
if privs.get("block_all_models"):
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
if restricted and sess.model and sess.model not in allowed:
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
cap = int(privs.get("max_messages_per_day") or 0)
@@ -194,14 +204,26 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
"""
import requests as _req
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
from src.endpoint_resolver import (
build_chat_url,
build_headers,
build_models_url,
normalize_base,
resolve_endpoint_runtime,
)
from src.chatgpt_subscription import is_chatgpt_subscription_base
current_url = sess.endpoint_url or ""
owner = getattr(sess, "owner", None)
db = SessionLocal()
try:
endpoints = db.query(ModelEndpoint).filter(
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True
).all()
)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
finally:
db.close()
@@ -210,26 +232,33 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
# Skip current endpoint
if current_url and base in current_url:
continue
# Quick ping
ping_url = build_models_url(base)
headers = build_headers(ep.api_key, base)
try:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
ping_url = build_models_url(base)
headers = build_headers(api_key, base)
try:
if ping_url:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
models = json.loads(ep.cached_models or "[]")
if not models:
continue
# Found a working endpoint — update session
new_model = models[0]
chat_url = build_chat_url(base)
new_headers = build_headers(ep.api_key, base)
new_headers = build_headers(api_key, base)
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
sess.model = new_model
sess.endpoint_url = chat_url
@@ -241,7 +270,7 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
_db.query(DBSession).filter(DBSession.id == session_id).update({
"model": new_model,
"endpoint_url": chat_url,
"headers": json.dumps(new_headers),
"headers": persisted_headers,
})
_db.commit()
finally:
@@ -275,11 +304,16 @@ def extract_preset(chat_handler, preset_id) -> PresetInfo:
async def preprocess(
chat_handler, message, att_ids, sess,
auto_opened_docs: Optional[list] = None,
allow_tool_preprocessing: bool = True,
) -> PreprocessedMessage:
"""Run chat_handler.preprocess_message and wrap the result."""
enhanced, user_content, text_ctx, yt_transcripts, att_meta = (
await chat_handler.preprocess_message(
message, att_ids, sess, auto_opened_docs=auto_opened_docs
message,
att_ids,
sess,
auto_opened_docs=auto_opened_docs,
allow_tool_preprocessing=allow_tool_preprocessing,
)
)
return PreprocessedMessage(
@@ -329,16 +363,26 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
return False
def _has_auth_keys(headers) -> bool:
"""True if a headers dict carries an Authorization/x-api-key entry."""
return isinstance(headers, dict) and any(
k.lower() in ('authorization', 'x-api-key') for k in headers
)
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
has_auth = sess.headers and isinstance(sess.headers, dict) and any(
k.lower() in ('authorization', 'x-api-key') for k in sess.headers
)
if has_auth:
try:
from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
except Exception:
is_chatgpt_subscription = False
has_auth = _has_auth_keys(sess.headers)
if has_auth and not is_chatgpt_subscription:
return
try:
from src.endpoint_resolver import build_headers, normalize_base
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
db = SessionLocal()
try:
target_url = getattr(sess, "endpoint_url", "") or ""
@@ -354,10 +398,30 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
for ep in q.all():
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
continue
if not ep.api_key:
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
return
if not api_key:
# No usable key (e.g. ChatGPT Subscription needs re-auth).
return
sess.headers = build_headers(api_key, base)
if is_chatgpt_subscription:
# The bearer is short-lived and re-resolved per request, so it
# stays request-local and is never written to the plaintext
# sessions.headers column. Proactively strip any bearer an
# older code path may have persisted so it does not linger.
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
stale_q = stale_q.filter(DBSession.owner == owner)
stored = stale_q.first()
if stored is not None and _has_auth_keys(stored.headers):
stale_q.update({"headers": {}})
db.commit()
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
return
base = normalize_base(ep.base_url or "")
sess.headers = build_headers(ep.api_key, base)
update_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
update_q = update_q.filter(DBSession.owner == owner)
@@ -401,7 +465,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
db = SessionLocal()
try:
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
owner = getattr(sess, "owner", None)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
for ep in endpoints:
try:
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
@@ -448,6 +517,7 @@ async def build_chat_context(
webhook_manager=None,
use_enhanced_message: bool = False,
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -465,6 +535,7 @@ async def build_chat_context(
preprocessed = await preprocess(
chat_handler, message, att_ids or [], sess,
auto_opened_docs=auto_opened_docs,
allow_tool_preprocessing=allow_tool_preprocessing,
)
# Add user message to history
@@ -483,6 +554,9 @@ async def build_chat_context(
# Skills injection respects its own enable toggle (mirrors memory_enabled).
# When off, the "Available skills" index is not added to the prompt.
skills_enabled = not incognito and uprefs.get("skills_enabled", True)
if not allow_tool_preprocessing:
mem_enabled = False
skills_enabled = False
logger.debug(
"Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)",
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
@@ -490,11 +564,11 @@ async def build_chat_context(
# Use RAG?
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
if incognito:
if incognito or not allow_tool_preprocessing:
use_rag_val = False
# If pre-fetched search context was provided (compare mode), skip live web search
skip_web = bool(search_context)
skip_web = bool(search_context) or not allow_tool_preprocessing
# Build context preface
# The stream path uses enhanced_message (with CoT/preprocessing applied),
@@ -521,7 +595,7 @@ async def build_chat_context(
used_memories = getattr(chat_processor, '_last_used_memories', [])
# Inject pre-fetched search context (compare mode)
if search_context:
if search_context and allow_tool_preprocessing:
preface.append(untrusted_context_message("prefetched search context", search_context))
# YouTube transcripts
@@ -530,7 +604,11 @@ async def build_chat_context(
# Normalize model ID. Prefer cached endpoint models so group chat does not
# re-hit slow local /models endpoints on every participant turn.
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(sess.endpoint_url, sess.model)
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(
sess.endpoint_url,
sess.model,
owner=getattr(sess, "owner", None),
)
if norm:
sess.model = norm
@@ -539,7 +617,7 @@ async def build_chat_context(
# Auto-compact
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers,
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
messages = trim_for_context(messages, context_length)
@@ -772,7 +850,19 @@ def save_assistant_response(
):
"""Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence."""
md = dict(last_metrics) if last_metrics else {}
md["model"] = sess.model
def _model_value(value) -> str:
if value is None:
return ""
if not isinstance(value, str):
value = str(value)
return value.strip()
requested_model = _model_value(md.get("requested_model") or md.get("selected_model") or getattr(sess, "model", ""))
actual_model = _model_value(md.get("model") or md.get("actual_model") or requested_model)
if requested_model:
md["requested_model"] = requested_model
if actual_model:
md["model"] = actual_model
if character_name:
md["character_name"] = character_name
if web_sources:
@@ -841,12 +931,13 @@ def run_post_response_tasks(
skills_manager=None,
owner: str = None,
extract_skills: bool = True,
allow_background_extraction: bool = True,
):
"""Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction."""
# Memory extraction — only every 4th message pair to avoid excess LLM calls
_msg_count = len(sess.history) if hasattr(sess, 'history') else 0
_should_extract = (_msg_count >= 4) and (_msg_count % 4 == 0)
if not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True):
if allow_background_extraction and not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True):
from services.memory.memory_extractor import extract_and_store
from src.task_endpoint import resolve_task_endpoint
t_url, t_model, t_headers = resolve_task_endpoint(
@@ -873,6 +964,7 @@ def run_post_response_tasks(
)
if (
extract_skills
and allow_background_extraction
and auto_skills_enabled
and not incognito
and not compare_mode
+207 -79
View File
@@ -20,6 +20,7 @@ from src import agent_runs
from src.model_context import estimate_tokens
from src.chat_helpers import coerce_message_and_session
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
from src.auth_helpers import get_current_user
@@ -39,6 +40,7 @@ from routes.chat_helpers import (
_enforce_chat_privileges,
)
from src.action_intents import classify_tool_intent as _classify_tool_intent
from src.tool_policy import build_effective_tool_policy
logger = logging.getLogger(__name__)
@@ -167,13 +169,20 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
Covers the window between endpoint setup and the first chat send: the
picker showed a model in the dropdown but the session record never got
written (Issue #587 — UI uses the cached endpoint list, not s.model).
Without this, we'd POST the upstream with model="" and get a generic
401/503 instead of using the model the user already picked.
Returns True iff sess.model was repaired.
For ChatGPT Subscription, also repairs stale OpenAI API model names such as
``gpt-5`` that are not accepted by the Codex-backed ChatGPT account route.
"""
if getattr(sess, "model", None):
return False
current_model = (getattr(sess, "model", "") or "").strip()
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
is_chatgpt_subscription = False
if current_model:
try:
from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(endpoint_url)
if not is_chatgpt_subscription:
return False
except Exception:
return False
db = SessionLocal()
try:
# Prefer the endpoint whose base URL matches the session — we know the
@@ -192,16 +201,51 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
break
if not ep:
return False
if not is_chatgpt_subscription:
try:
from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(ep, "base_url", "") or endpoint_url)
except Exception:
is_chatgpt_subscription = False
try:
cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or [])
except Exception:
cached = []
if not cached:
visible = []
else:
try:
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception:
visible = cached
if current_model and current_model in {str(item).strip() for item in visible}:
return False
try:
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception:
visible = cached
if is_chatgpt_subscription:
live_models = []
if getattr(ep, "provider_auth_id", None):
try:
from src.chatgpt_subscription import fetch_available_models
from src.endpoint_resolver import resolve_endpoint_runtime
_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
if api_key:
live_models = fetch_available_models(api_key)
if live_models:
ep.cached_models = json.dumps(live_models)
db.commit()
except Exception:
live_models = []
# ChatGPT Subscription recovery must use the live Codex catalog.
# Cached rows are only trusted above to avoid revalidating a model
# that is already present in the visible picker list.
cached = live_models
if not cached:
return False
try:
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception:
visible = cached
if current_model and current_model in {str(item).strip() for item in visible}:
return False
if not visible:
return False
model = visible[0]
@@ -211,14 +255,17 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
# Persist so the next request, websocket reconnect, or page reload
# picks up the same model (we'd otherwise re-pick on every send
# and silently switch on the user if the cached order shifts).
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
db_session_q = db.query(DBSession).filter(DBSession.id == session_id)
if owner:
db_session_q = db_session_q.filter(DBSession.owner == owner)
db_session = db_session_q.first()
if db_session:
db_session.model = model
db_session.updated_at = datetime.utcnow()
db.commit()
sess.model = model
logger.info(
"Recovered empty session model for %s — picked %r from endpoint %s",
"Recovered session model for %s — picked %r from endpoint %s",
session_id, model, ep.id,
)
return True
@@ -304,8 +351,13 @@ def setup_chat_routes(
# non-streaming path can't be used to bypass).
_enforce_chat_privileges(request, sess)
tool_policy = build_effective_tool_policy(last_user_message=message)
allow_tool_preprocessing = not tool_policy.block_all_tool_calls
# Inline memory command
memory_response = await chat_handler.handle_memory_command(sess, message)
memory_response = None
if not tool_policy.blocks("manage_memory"):
memory_response = await chat_handler.handle_memory_command(sess, message)
if memory_response:
return {"response": memory_response}
@@ -319,10 +371,15 @@ def setup_chat_routes(
use_web=use_web,
time_filter=time_filter,
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
)
# Research injection
if use_research:
research_blocked_by_policy = (
tool_policy.blocks("trigger_research")
or tool_policy.blocks("manage_research")
)
if use_research and not research_blocked_by_policy:
try:
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
research_ctx = await research_handler.call_research_service(
@@ -357,6 +414,7 @@ def setup_chat_routes(
ctx.uprefs, memory_manager, memory_vector, webhook_manager,
character_name=ctx.preset.character_name,
owner=ctx.user,
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
return {"response": reply}
@@ -394,13 +452,21 @@ def setup_chat_routes(
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
incognito = str(form_data.get("incognito", "")).lower() == "true"
# Plan mode is not part of the merge-ready UI. Ignore stale clients or
# manual form posts that still send plan_mode=true.
plan_mode = False
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
# Workspace: confine the agent's file/shell tools to this folder. Validate
# it's a real directory; ignore (no confinement) otherwise.
workspace = (form_data.get("workspace") or "").strip()
if workspace:
_ws_real = os.path.realpath(os.path.expanduser(workspace))
workspace = _ws_real if os.path.isdir(_ws_real) else ""
# Plan mode is a modifier on agent mode — it only makes sense with tools.
if plan_mode:
chat_mode = "agent"
# An approved plan being EXECUTED: the frontend sends the checklist back
# on each turn so we can pin it in context. This way a long plan on a
# weak model survives history truncation — the agent can always re-read
# the plan. Ignored while still proposing (plan_mode on). Capped so a
# huge plan can't blow the prompt.
approved_plan = ""
if not plan_mode:
approved_plan = (form_data.get("approved_plan") or "").strip()[:8192]
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
# below). Skill extraction should only learn from real agent sessions,
# not chats we quietly promoted for a notes/calendar intent.
@@ -479,11 +545,6 @@ def setup_chat_routes(
do_research = True
logger.info(f"Session {session} in research_pending — auto-triggering research")
# Persist session mode (research > agent > chat)
_effective_mode = 'research' if do_research else (chat_mode or 'chat')
if _effective_mode in ('agent', 'research', 'chat'):
set_session_mode(session, _effective_mode)
att_ids = []
if body and isinstance(body.get("attachments"), list):
att_ids = [str(x) for x in body["attachments"]]
@@ -494,6 +555,10 @@ def setup_chat_routes(
pass
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
pre_context_tool_policy = build_effective_tool_policy(
last_user_message=message,
)
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
# Build shared context (stream path uses enhanced_message for context preface)
ctx = await build_chat_context(
@@ -515,6 +580,7 @@ def setup_chat_routes(
# manage_skills (agent mode). In plain chat or incognito the
# index would be useless / unwanted noise.
agent_mode=(chat_mode == "agent"),
allow_tool_preprocessing=allow_tool_preprocessing,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -659,6 +725,32 @@ def setup_chat_routes(
if chat_mode == 'chat':
disabled_tools.update({"bash", "python", "read_file", "write_file", "web_search", "web_fetch", "search_chats", "manage_tasks"})
# Plan mode: investigate read-only, propose a plan, don't mutate. Block
# every tool not on the read-only allowlist. (stream_agent_loop enforces
# this again + drops MCP, so this is belt-and-suspenders.)
if plan_mode:
from src.tool_security import plan_mode_disabled_tools
disabled_tools.update(plan_mode_disabled_tools())
tool_policy = build_effective_tool_policy(
disabled_tools=disabled_tools,
last_user_message=message,
)
disabled_tools = tool_policy.all_disabled_names()
research_blocked_by_policy = bool(
tool_policy.blocks("trigger_research")
or tool_policy.blocks("manage_research")
)
effective_do_research = bool(
do_research and _research_flags["do"] and not research_blocked_by_policy
)
# Persist session mode after policy/privilege gates so blocked research
# turns remain ordinary chat/agent streams and saved messages.
_effective_mode = 'research' if effective_do_research else (chat_mode or 'chat')
if _effective_mode in ('agent', 'research', 'chat'):
set_session_mode(session, _effective_mode)
async def stream_with_save() -> AsyncGenerator[str, None]:
# _effective_mode is read-only here; closure captures it from
# the outer scope. (Was `nonlocal` but never reassigned.)
@@ -666,7 +758,7 @@ def setup_chat_routes(
web_sources = ctx.web_sources
# Register active stream for partial-save safety net
_active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": do_research, "mode": _effective_mode}
_active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": effective_do_research, "mode": _effective_mode}
if ctx.preprocessed.attachment_meta:
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
@@ -690,7 +782,7 @@ def setup_chat_routes(
yield f"data: {json.dumps({'type': 'memories_used', 'data': ctx.used_memories})}\n\n"
# Run research as a background task (survives page refresh)
if do_research and _research_flags["do"]:
if effective_do_research:
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
_auth_keys = list(_r_headers.keys()) if _r_headers else []
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
@@ -829,7 +921,7 @@ def setup_chat_routes(
_fallback_candidates = []
# Send model name early so the frontend can show it during streaming
_model_suffix = "Research" if do_research else None
_model_suffix = "Research" if effective_do_research else None
_model_info = {"type": "model_info", "model": sess.model}
if _model_suffix:
_model_info["suffix"] = _model_suffix
@@ -839,6 +931,12 @@ def setup_chat_routes(
if _is_image_generation_session(sess, owner=_user):
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
yield f'data: {json.dumps({"delta": _blocked_msg})}\n\n'
yield "data: [DONE]\n\n"
_active_streams.pop(session, None)
return
if not get_setting("image_gen_enabled", True):
yield f'data: {json.dumps({"delta": "Image generation is disabled by the administrator."})}\n\n'
yield "data: [DONE]\n\n"
@@ -873,6 +971,8 @@ def setup_chat_routes(
elif chat_mode == "chat":
_chat_start = time.time()
_answered_by = None # set if the selected model failed and a fallback answered
_requested_model = sess.model
_actual_model = None
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
try:
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
@@ -905,10 +1005,18 @@ def setup_chat_routes(
# Selected model failed; a fallback answered.
# Forward the notice and remember the real model.
_answered_by = data.get("answered_by") or _answered_by
_actual_model = _actual_model or _answered_by
data["selected_model"] = data.get("selected_model") or _requested_model
yield chunk
elif data.get("type") == "model_actual":
_actual_model = data.get("model") or _actual_model
data["requested_model"] = _requested_model
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "usage":
last_metrics = data.get("data", {})
last_metrics["model"] = _answered_by or sess.model
_reported_model = last_metrics.get("model")
last_metrics["requested_model"] = _requested_model
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
if ctx.context_length and last_metrics.get("input_tokens"):
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
last_metrics["context_percent"] = pct
@@ -945,7 +1053,8 @@ def setup_chat_routes(
"tokens_per_second": _tps,
"context_percent": _ctx_pct,
"context_length": ctx.context_length,
"model": sess.model,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"usage_source": "estimated",
}
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
@@ -957,7 +1066,7 @@ def setup_chat_routes(
rag_sources=ctx.rag_sources,
research_sources=research_sources,
used_memories=ctx.used_memories,
do_research=do_research,
do_research=effective_do_research,
incognito=incognito,
)
if _saved_id:
@@ -967,14 +1076,22 @@ def setup_chat_routes(
last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
owner=_user,
owner=_user,
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
_stream_set(session, status="done")
yield chunk
except (asyncio.CancelledError, GeneratorExit):
if full_response:
logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response))
_stopped_content, _stopped_md = clean_thinking_for_save(full_response, {"stopped": True, "model": sess.model})
_stopped_content, _stopped_md = clean_thinking_for_save(
full_response,
{
"stopped": True,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
},
)
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
if not incognito:
session_manager.save_sessions()
@@ -986,6 +1103,8 @@ def setup_chat_routes(
_agent_rounds = 0
_agent_tool_calls = 0
_answered_by = None # set if the selected model failed and a fallback answered
_requested_model = sess.model
_actual_model = None
try:
from src.settings import get_setting
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
@@ -1012,9 +1131,11 @@ def setup_chat_routes(
active_document=active_doc,
session_id=session,
disabled_tools=disabled_tools if disabled_tools else None,
tool_policy=tool_policy,
owner=_user,
fallbacks=_fallback_candidates,
workspace=workspace or None,
plan_mode=plan_mode,
approved_plan=approved_plan or None,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -1035,6 +1156,8 @@ def setup_chat_routes(
"doc_stream_open", "doc_stream_delta",
"doc_update", "doc_suggestions", "ui_control",
"rounds_exhausted",
"ask_user",
"plan_update",
):
if data.get("type") == "agent_step":
_agent_rounds = max(_agent_rounds, data.get("round", 1))
@@ -1047,10 +1170,18 @@ def setup_chat_routes(
# model so metrics reflect it, not the masked
# selected model.
_answered_by = data.get("answered_by") or _answered_by
_actual_model = _actual_model or _answered_by
data["selected_model"] = data.get("selected_model") or _requested_model
yield chunk
elif data.get("type") == "model_actual":
_actual_model = data.get("model") or _actual_model
data["requested_model"] = _requested_model
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "metrics":
last_metrics = data.get("data", {})
last_metrics["model"] = _answered_by or sess.model
_reported_model = last_metrics.get("model")
last_metrics["requested_model"] = last_metrics.get("requested_model") or _requested_model
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
except json.JSONDecodeError:
yield chunk
@@ -1078,6 +1209,7 @@ def setup_chat_routes(
skills_manager=skills_manager,
owner=_user,
extract_skills=user_requested_agent,
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
_stream_set(session, status="done")
yield chunk
@@ -1091,7 +1223,14 @@ def setup_chat_routes(
try:
if full_response:
logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response))
_stopped_content2, _stopped_md2 = clean_thinking_for_save(full_response, {"stopped": True, "model": sess.model})
_stopped_content2, _stopped_md2 = clean_thinking_for_save(
full_response,
{
"stopped": True,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
},
)
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
if not incognito:
session_manager.save_sessions()
@@ -1110,11 +1249,29 @@ def setup_chat_routes(
finally:
_active_streams.pop(session, None)
# Run the stream as a DETACHED background task so it survives the client
# closing the tab / navigating away (true terminal-agent behavior). The
# SSE response just subscribes (replay buffered output + live); dropping
# the SSE only removes a subscriber — the run keeps going and saves the
# assistant message on completion regardless. Reconnect via /api/chat/resume.
# Compare panes are short-lived, single-shot generations whose sessions
# exist only to drive that one pane — there's nothing to "resume" and
# the user expects the pane's Stop button (which aborts the fetch,
# closing this SSE) to promptly cancel the upstream LLM call. Detaching
# them would keep burning upstream tokens/compute after the pane is
# stopped or the comparison is abandoned, and would surface a stale
# "still streaming" /resume target for a session nobody will revisit.
#
# So: stream them directly (no agent_runs wrapping). Starlette cancels
# the underlying async generator (raising CancelledError/GeneratorExit
# inside it) as soon as it notices the client disconnected — which the
# mode-specific except blocks above already handle by saving the
# partial response exactly once. This stops the upstream call promptly
# without waiting on the next streamed chunk.
#
# Normal chat/agent streams keep the DETACHED behavior below: they
# survive the client closing the tab / navigating away. The SSE response just subscribes (replay
# buffered output + live); dropping the SSE only removes a subscriber —
# the run keeps going and saves the assistant message on completion
# regardless. Reconnect via /api/chat/resume.
if compare_mode:
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
agent_runs.start(session, _safe_stream())
return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream")
@@ -1185,45 +1342,16 @@ def setup_chat_routes(
return []
_user = get_current_user(request)
query_term = q.strip()
db = SessionLocal()
try:
base_q = (
db.query(DBChatMessage, DBSession.name)
.join(DBSession, DBChatMessage.session_id == DBSession.id)
.filter(
DBSession.archived == False,
DBChatMessage.content.ilike(f"%{query_term}%"),
DBChatMessage.role.in_(["user", "assistant"]),
)
return [
result.to_dict()
for result in search_session_messages(
q,
limit=limit,
owner=_user,
restrict_owner=_user is not None,
include_legacy_owner=False,
)
if _user:
base_q = base_q.filter(DBSession.owner == _user)
rows = base_q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all()
results = []
for msg, session_name in rows:
content = msg.content or ""
lower_content = content.lower()
idx = lower_content.find(query_term.lower())
if idx == -1:
snippet = content[:120]
else:
start = max(0, idx - 50)
end = min(len(content), idx + len(query_term) + 50)
snippet = ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
results.append({
"session_id": msg.session_id,
"session_name": session_name or "Untitled",
"role": msg.role,
"content_snippet": snippet,
"timestamp": msg.timestamp.isoformat() if msg.timestamp else None,
})
return results
finally:
db.close()
]
# ------------------------------------------------------------------ #
# POST /api/rewrite — lightweight rewrite of last AI message (no tools)
+170
View File
@@ -0,0 +1,170 @@
"""ChatGPT Subscription device-flow setup routes."""
import json
import logging
import uuid
from typing import Dict, Optional
from fastapi import HTTPException, Request
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
from routes.device_flow import (
DeviceFlowPoll,
DeviceFlowStart,
PendingDeviceFlowStore,
create_device_flow_router,
)
from src.auth_helpers import get_current_user
from src import chatgpt_subscription
logger = logging.getLogger(__name__)
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token or not refresh_token:
raise ValueError("ChatGPT token response was missing access_token or refresh_token")
base = chatgpt_subscription.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL
models = chatgpt_subscription.fetch_available_models(access_token)
if not models:
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
db = SessionLocal()
try:
auth = (
db.query(ProviderAuthSession)
.filter(
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
ProviderAuthSession.owner == owner,
)
.first()
)
if auth is None:
auth = ProviderAuthSession(
id=str(uuid.uuid4())[:8],
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
owner=owner,
label="ChatGPT Subscription",
base_url=base,
auth_mode="chatgpt",
)
db.add(auth)
auth.base_url = base
auth.access_token = access_token
auth.refresh_token = refresh_token
auth.last_refresh = utcnow_naive()
auth.auth_mode = "chatgpt"
ep = (
db.query(ModelEndpoint)
.filter(
ModelEndpoint.base_url == base,
ModelEndpoint.provider_auth_id == auth.id,
ModelEndpoint.owner == owner,
)
.first()
)
if ep is None:
ep = ModelEndpoint(
id=str(uuid.uuid4())[:8],
name="ChatGPT Subscription",
base_url=base,
model_type="llm",
endpoint_kind="api",
owner=owner,
)
db.add(ep)
ep.name = "ChatGPT Subscription"
ep.base_url = base
ep.api_key = None
ep.provider_auth_id = auth.id
ep.is_enabled = True
ep.supports_tools = False
ep.model_type = "llm"
ep.endpoint_kind = "api"
ep.model_refresh_mode = "manual"
ep.cached_models = json.dumps(models)
db.commit()
result = {
"id": ep.id,
"name": ep.name,
"base_url": ep.base_url,
"models": models,
}
finally:
db.close()
try:
from routes.model_routes import _invalidate_models_cache
_invalidate_models_cache()
except Exception:
pass
return result
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
try:
data = chatgpt_subscription.request_device_code()
except Exception as exc:
raise chatgpt_subscription.to_http_exception(exc)
device_auth_id = data.get("device_auth_id")
user_code = data.get("user_code")
if not device_auth_id or not user_code:
raise HTTPException(502, "ChatGPT did not return a complete device code")
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
return DeviceFlowStart(
pending={
"device_auth_id": device_auth_id,
"user_code": user_code,
"owner": get_current_user(request) or None,
},
response={
"user_code": user_code,
"verification_uri": verification_uri,
},
interval=int(data.get("interval") or 5),
expires_in=int(data.get("expires_in") or 900),
)
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
try:
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
except Exception as exc:
logger.debug("ChatGPT device poll failed: %s", exc)
return DeviceFlowPoll.pending(str(exc))
authorization_code = data.get("authorization_code")
code_verifier = data.get("code_verifier")
if authorization_code and code_verifier:
try:
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
result = _provision_endpoint(tokens, pending["owner"])
except Exception as exc:
logger.exception("ChatGPT Subscription endpoint provisioning failed")
raise chatgpt_subscription.to_http_exception(exc)
return DeviceFlowPoll.authorized(result)
err = data.get("error") or data.get("status")
if err in ("authorization_pending", "pending", None):
return DeviceFlowPoll.pending()
if err == "slow_down":
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
if err in ("expired_token", "access_denied", "denied"):
return DeviceFlowPoll.failed(err)
return DeviceFlowPoll.pending(err or "unknown")
def setup_chatgpt_subscription_routes():
return create_device_flow_router(
prefix="/api/chatgpt-subscription",
tags=["chatgpt-subscription"],
store=_DEVICE_FLOW_STORE,
start_flow=_start_device_flow,
poll_flow=_poll_device_flow,
)
+388 -3
View File
@@ -15,10 +15,13 @@ from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from src.auth_helpers import require_user
from src.auth_helpers import require_authenticated_request, require_user
from src.tool_implementations import do_manage_notes
from src.constants import COOKBOOK_STATE_FILE
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
COOKBOOK_LAUNCH_SCOPES = {"cookbook:launch"}
TODO_READ_SCOPES = {"todos:read", "todos:write"}
TODO_WRITE_SCOPES = {"todos:write"}
EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"}
@@ -39,7 +42,9 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
Restores the original value when done. Works for sync and async handlers."""
orig = getattr(request.state, "current_user", None)
orig_api_token = getattr(request.state, "api_token", None)
request.state.current_user = owner
request.state.api_token = False
try:
result = fn(*args, **kwargs)
if asyncio.iscoroutine(result):
@@ -47,6 +52,13 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
return result
finally:
request.state.current_user = orig
if orig_api_token is None:
try:
delattr(request.state, "api_token")
except AttributeError:
pass
else:
request.state.api_token = orig_api_token
def _scope_owner(request: Request, allowed: set[str]) -> str:
@@ -130,6 +142,11 @@ def setup_codex_routes(
"actions": ["library", "read", "create", "delete"],
"available": documents_library_endpoint is not None,
},
"cookbook": {
"read": scoped(COOKBOOK_READ_SCOPES),
"launch": scoped(COOKBOOK_LAUNCH_SCOPES),
"actions": ["tasks", "servers", "output", "serve", "stop"],
},
},
"safety": {
"email_send_requires_confirmation": True,
@@ -139,7 +156,7 @@ def setup_codex_routes(
@router.get("/plugin.zip")
def plugin_zip(request: Request):
require_user(request)
require_authenticated_request(request)
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
if not root.exists():
raise HTTPException(404, "Codex plugin bundle not found")
@@ -373,6 +390,374 @@ def setup_codex_routes(
raise HTTPException(400, f"Invalid document payload: {exc}")
return await _as_owner(request, owner, documents_create_endpoint, request, req)
# ── Cookbook surface ──
# Lets the agent run the same launch / monitor / kill loop the user
# would do by hand in the Cookbook UI: read the current task list +
# tmux output, launch a serve task, stop one. Two scopes:
# cookbook:read — list tasks + tail output + list servers
# cookbook:launch — also start/stop serves (host shell exec)
# `cookbook:launch` is genuinely powerful: /api/model/serve runs SSH'd
# commands on the user's hosts. The existing _validate_serve_cmd
# allowlist (vllm/python3/sglang/llama-server/etc., no shell metachars)
# keeps the agent inside the same sandbox the UI uses.
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
"""Run a shell command, return {exit_code, stdout, stderr}."""
import asyncio as _asyncio
try:
proc = await _asyncio.create_subprocess_shell(
cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await _asyncio.wait_for(proc.communicate(), timeout=timeout)
except _asyncio.TimeoutError:
proc.kill()
return {"exit_code": -1, "stdout": "", "stderr": "timed out"}
return {
"exit_code": proc.returncode,
"stdout": stdout_b.decode(errors="replace"),
"stderr": stderr_b.decode(errors="replace"),
}
except Exception as exc:
return {"exit_code": -1, "stdout": "", "stderr": str(exc)}
def _read_cookbook_state() -> dict:
from pathlib import Path as _Path
import json as _json
p = _Path(COOKBOOK_STATE_FILE)
if not p.exists():
return {}
try:
return _json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
def _redact_task(t: dict) -> dict:
"""Strip secrets before returning to the agent."""
clean = {k: v for k, v in t.items() if k not in ("hf_token", "_secrets")}
if isinstance(clean.get("payload"), dict):
pl = clean["payload"]
clean["payload"] = {k: v for k, v in pl.items()
if k not in ("hf_token", "_secrets")}
return clean
@router.get("/cookbook/tasks")
async def codex_cookbook_tasks(request: Request):
_scope_owner(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state()
tasks = state.get("tasks") or []
return {"tasks": [_redact_task(t) for t in tasks]}
@router.get("/cookbook/servers")
async def codex_cookbook_servers(request: Request):
_scope_owner(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state()
servers = state.get("env", {}).get("servers") or []
# Strip ssh creds / passwords; keep only what's needed to pick a host.
cleaned = []
for s in servers:
cleaned.append({
"name": s.get("name"),
"host": s.get("host"),
"port": s.get("port"),
"env": s.get("env"),
"envPath": s.get("envPath"),
"platform": s.get("platform"),
"modelDirs": s.get("modelDirs"),
})
return {"servers": cleaned}
@router.get("/cookbook/output/{session_id}")
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
_scope_owner(request, COOKBOOK_READ_SCOPES)
# Defensive: session_id must be the tmux-style id we issue
# (`serve-XXXX` / `cookbook-XXXX` / `queue-XXXX`); anything else
# would let the agent run arbitrary `tmux capture-pane` targets.
import re as _re
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
raise HTTPException(400, "Invalid session id")
tail = max(20, min(int(tail or 400), 4000))
# Resolve the task's host (if any) from cookbook state so we can
# ssh to the right box, exactly as the UI does in _reconnectTask.
state = _read_cookbook_state()
tasks = state.get("tasks") or []
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
if task is None:
raise HTTPException(404, "task not found")
host = (task.get("remoteHost") or "").strip()
ssh_port = (task.get("sshPort") or "").strip()
# Prefer the persisted log file over the tmux pane. The pane gets
# overwritten by the post-crash neofetch banner + bash prompt the
# moment vllm exits; the log file is the raw stdout/stderr and
# survives unchanged. Falls back to pane for older tasks predating
# the tee-to-log runner change.
log_path = f"/tmp/odysseus-tmux/{session_id}.log"
inner = (
f"if [ -s {log_path} ]; then tail -n {tail} {log_path}; "
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
)
if host:
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
import shlex
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
else:
cmd = inner
result = await _run_shell(cmd, timeout=15)
return {
"session_id": session_id,
"host": host or "local",
"exit_code": result.get("exit_code"),
"output": result.get("stdout", ""),
"task": _redact_task(task),
}
@router.post("/cookbook/serve")
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
# Wraps /api/model/serve with the SAME validation the UI uses.
# _validate_serve_cmd (called inside model_serve) rejects shell
# metachars and requires the leading binary to be in the
# cookbook allowlist (vllm / python3 / sglang / llama-server / ...).
from routes.cookbook_helpers import ServeRequest
# Accept friendly aliases agents naturally reach for. Without these,
# passing `host` silently maps to nothing and the serve runs LOCAL
# instead of on the intended remote — exactly the bug an agent
# would never debug on its own.
norm = dict(body or {})
if "host" in norm and "remote_host" not in norm:
norm["remote_host"] = norm.pop("host")
if "model" in norm and "repo_id" not in norm:
norm["repo_id"] = norm.pop("model")
if "ssh_port" not in norm and "port" in norm and (str(norm.get("port") or "").isdigit() and int(norm["port"]) >= 1000):
# Heuristic: if `port` looks like an SSH port (≥1000) and there's
# no explicit ssh_port, treat it as such. UI ports (8000, 8001,
# 30000) belong inside the cmd string, not here.
pass # leave as-is — user's `port` here is ambiguous; skip remap.
try:
req = ServeRequest(**norm)
except Exception as exc:
raise HTTPException(400, f"Invalid serve payload: {exc}")
serve_endpoint = _find_endpoint(None, "POST", "/api/model/serve")
# Fall back to importing from the cookbook router registered on app.
if serve_endpoint is None:
from fastapi import FastAPI
app: FastAPI = request.app
for route in app.routes:
if getattr(route, "path", None) == "/api/model/serve" and "POST" in getattr(route, "methods", set()):
serve_endpoint = route.endpoint
break
if serve_endpoint is None:
raise HTTPException(503, "model serve endpoint unavailable")
return await serve_endpoint(request, req)
@router.post("/cookbook/stop/{session_id}")
async def codex_cookbook_stop(request: Request, session_id: str):
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
import re as _re
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
raise HTTPException(400, "Invalid session id")
state = _read_cookbook_state()
tasks = state.get("tasks") or []
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
host = ((task or {}).get("remoteHost") or "").strip()
ssh_port = ((task or {}).get("sshPort") or "").strip()
if host:
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
else:
cmd = f"tmux kill-session -t {session_id}"
result = await _run_shell(cmd, timeout=10)
return {"session_id": session_id, "exit_code": result.get("exit_code"), "host": host or "local"}
@router.get("/cookbook/cached")
async def codex_cookbook_cached(request: Request, host: str | None = None):
"""List cached models on a configured server (or local if host is omitted).
Mirrors `list_cached_models` from the chat agent so external agents have
the same inventory view before deciding what to serve/download."""
_scope_owner(request, COOKBOOK_READ_SCOPES)
# Hit /api/model/cached internally, with the same modelDirs the chat
# agent's list_cached_models would resolve from cookbook state.
state = _read_cookbook_state()
env = state.get("env") if isinstance(state, dict) else {}
servers = (env.get("servers") if isinstance(env, dict) else None) or []
HF_DEFAULTS = {"~/.cache/huggingface/hub", "~/.cache/huggingface"}
def _dirs_for(srv: dict) -> str:
mds = srv.get("modelDirs") if isinstance(srv, dict) else None
if isinstance(mds, list):
extras = [d for d in mds if isinstance(d, str) and d.strip() and d.strip() not in HF_DEFAULTS]
return ",".join(extras)
if isinstance(mds, str) and mds.strip() not in HF_DEFAULTS:
return mds
return ""
# Resolve friendly host name → real host (matches list_cached_models flow).
resolved_host = host or ""
srv: dict[str, Any] = {}
if host:
srv = next(
(s for s in servers if isinstance(s, dict)
and (s.get("name") == host or s.get("host") == host)),
{},
)
if srv and srv.get("host"):
resolved_host = srv["host"]
else:
srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
params: dict[str, str] = {}
if resolved_host:
params["host"] = resolved_host
md = _dirs_for(srv)
if md:
params["model_dir"] = md
if srv.get("port"):
params["ssh_port"] = str(srv["port"])
if srv.get("platform"):
params["platform"] = srv["platform"]
cached_endpoint = _find_endpoint(None, "GET", "/api/model/cached")
if cached_endpoint is None:
from fastapi import FastAPI
app: FastAPI = request.app
for route in app.routes:
if getattr(route, "path", None) == "/api/model/cached" and "GET" in getattr(route, "methods", set()):
cached_endpoint = route.endpoint
break
if cached_endpoint is None:
raise HTTPException(503, "model cached endpoint unavailable")
# The endpoint reads host/model_dir/ssh_port/platform as kwargs.
return await cached_endpoint(
request,
host=params.get("host") or None,
model_dir=params.get("model_dir") or None,
ssh_port=params.get("ssh_port") or None,
platform=params.get("platform") or None,
)
@router.get("/cookbook/presets")
async def codex_cookbook_presets(request: Request):
"""List saved serve presets (model + host + port + launch cmd).
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
body the user's saved preset usually has the working cmd already."""
_scope_owner(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state()
presets = state.get("presets") or []
out = []
for p in presets:
if not isinstance(p, dict):
continue
out.append({
"name": p.get("name"),
"model": p.get("model") or p.get("modelId"),
"host": p.get("host") or p.get("remoteHost"),
"port": p.get("port"),
"cmd": p.get("cmd"),
})
return {"presets": out, "default_host": (state.get("env") or {}).get("defaultServer", "")}
@router.post("/cookbook/preset/{name}")
async def codex_cookbook_serve_preset(request: Request, name: str):
"""Launch a saved preset by name. Reuses the working cmd + host the
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9 _.:@\-]+", name):
raise HTTPException(400, "Invalid preset name")
state = _read_cookbook_state()
presets = state.get("presets") or []
lname = name.lower().strip()
chosen = next(
(p for p in presets if isinstance(p, dict) and (p.get("name") or "").lower() == lname),
None,
)
if chosen is None:
chosen = next(
(p for p in presets if isinstance(p, dict) and lname in (p.get("name") or "").lower()),
None,
)
if chosen is None:
raise HTTPException(404, f"No preset matching {name!r}")
repo_id = chosen.get("model") or chosen.get("modelId") or ""
cmd = (chosen.get("cmd") or "").strip()
host = chosen.get("host") or chosen.get("remoteHost") or ""
if not repo_id or not cmd or cmd.startswith("(adopted"):
raise HTTPException(400, f"Preset {chosen.get('name')!r} has no launchable cmd "
"(adopted from external launch). Use POST /cookbook/serve "
"with the actual cmd instead.")
# Reuse the serve handler we already validated.
from routes.cookbook_helpers import ServeRequest
body = {"repo_id": repo_id, "cmd": cmd}
if host:
body["remote_host"] = host
try:
req = ServeRequest(**body)
except Exception as exc:
raise HTTPException(400, f"Preset payload invalid: {exc}")
serve_endpoint = _find_endpoint(None, "POST", "/api/model/serve")
if serve_endpoint is None:
from fastapi import FastAPI
app: FastAPI = request.app
for route in app.routes:
if getattr(route, "path", None) == "/api/model/serve" and "POST" in getattr(route, "methods", set()):
serve_endpoint = route.endpoint
break
if serve_endpoint is None:
raise HTTPException(503, "model serve endpoint unavailable")
return await serve_endpoint(request, req)
@router.post("/cookbook/adopt")
async def codex_cookbook_adopt(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
"""Adopt an existing tmux session (one started via raw ssh+tmux) into
cookbook tracking. Needed when serve_model rejects a cmd and the
agent falls back to direct ssh without adoption the session is
invisible to the UI. Body: {tmux_session, model, host?, port?}."""
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
norm = dict(body or {})
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
model = (norm.get("model") or norm.get("repo_id") or "").strip()
host = (norm.get("host") or norm.get("remote_host") or "").strip()
port = norm.get("port") or 8000
import re as _re
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
raise HTTPException(400, "tmux_session required, [a-zA-Z0-9_-]+ only")
if not model:
raise HTTPException(400, "model required")
# Verify the tmux session exists on the target host before adopting.
import shlex
if host:
check = f"ssh {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)}'"
else:
check = f"tmux has-session -t {shlex.quote(sess)}"
chk = await _run_shell(check, timeout=8)
if chk.get("exit_code") not in (0, None):
raise HTTPException(404, f"tmux session {sess!r} not found on {host or 'local'}")
# Write into cookbook_state.json.
import time as _t, json as _json
from core.atomic_io import atomic_write_json
from pathlib import Path as _Path
cookbook_state_path = _Path(COOKBOOK_STATE_FILE)
try:
state = _json.loads(cookbook_state_path.read_text(encoding="utf-8"))
except Exception:
state = {}
tasks = state.setdefault("tasks", [])
if any(isinstance(t, dict) and t.get("sessionId") == sess for t in tasks):
return {"ok": True, "already_tracked": True, "session_id": sess}
tasks.append({
"id": sess, "sessionId": sess,
"name": model.split("/")[-1] if "/" in model else model,
"type": "serve", "status": "running",
"output": f"Adopted externally-launched session {sess!r} on {host or 'local'}.",
"ts": int(_t.time() * 1000),
"payload": {"repo_id": model, "remote_host": host, "_cmd": "(adopted — launched outside cookbook)", "port": int(port)},
"remoteHost": host, "sshPort": "", "platform": "linux",
"_serveReady": False, "_endpointAdded": False, "_adoptedExternally": True,
})
try:
atomic_write_json(cookbook_state_path, state)
except Exception as exc:
raise HTTPException(500, f"state write failed: {exc}")
return {"ok": True, "session_id": sess, "host": host or "local"}
return router
@@ -387,7 +772,7 @@ def setup_claude_routes() -> APIRouter:
@router.get("/plugin.zip")
def plugin_zip(request: Request):
require_user(request)
require_authenticated_request(request)
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
# README.md or other bundle metadata into the user's claude config dir.
skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills"
+110 -22
View File
@@ -12,6 +12,7 @@ import logging
from core.database import Comparison, SessionLocal
from core.session_manager import SessionManager
from src.auth_helpers import get_current_user
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
logger = logging.getLogger(__name__)
@@ -38,6 +39,24 @@ def _owned_endpoint_by_url(db, base_url, owner):
return owner_filter(q, ModelEndpoint, owner).first()
def _owned_endpoint_by_id(db, endpoint_id, owner):
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
own rows + legacy null-owner "shared" rows); None otherwise.
Preferred over _owned_endpoint_by_url for credential resolution: two visible
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
accounts on the same provider). A base_url-only match returns whichever row
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
An id pins the exact registered endpoint, so /api/compare/start prefers it and
only falls back to URL matching for legacy / admin raw-URL callers. Owner
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
"""
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
return owner_filter(q, ModelEndpoint, owner).first()
class RecordVoteRequest(BaseModel):
prompt: str
models: List[str]
@@ -54,8 +73,10 @@ def setup_compare_routes(session_manager: SessionManager):
prompt: str = Form(...),
model_a: str = Form(...),
model_b: str = Form(...),
endpoint_a: str = Form(...),
endpoint_b: str = Form(...),
endpoint_a: str = Form(""),
endpoint_b: str = Form(""),
endpoint_a_id: str = Form(""),
endpoint_b_id: str = Form(""),
is_blind: str = Form("true"),
):
"""Create two ephemeral sessions and a comparison record.
@@ -63,10 +84,10 @@ def setup_compare_routes(session_manager: SessionManager):
Returns the comparison ID and the two session IDs so the client
can fire two independent SSE streams to /api/chat_stream.
"""
user = getattr(request.state, 'current_user', None)
comp_id = str(uuid.uuid4())
sid_a = str(uuid.uuid4())
sid_b = str(uuid.uuid4())
user = getattr(request.state, 'current_user', None)
# Blind mapping: randomly assign left/right
blind = str(is_blind).lower() == "true"
@@ -87,31 +108,94 @@ def setup_compare_routes(session_manager: SessionManager):
# de-anonymizing the comparison before the user votes (issue #1285).
slot_name = {session_left: "Model A", session_right: "Model B"}
# Create ephemeral sessions (prefixed [CMP])
for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]:
# SECURITY: resolve and validate BOTH endpoints before creating any
# session. Compare copies a registered endpoint's Authorization header
# into the [CMP] session, so validating one endpoint while creating its
# session, then rejecting the other, would leave a partial compare
# session behind with that header attached. Doing all the owner-scope
# resolution + raw-URL rejection up front means a 403 on either endpoint
# aborts the whole request with nothing created and no header copied.
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
resolved = []
db = SessionLocal()
try:
for sid, model, endpoint, endpoint_id in [
(sid_a, model_a, endpoint_a, endpoint_a_id),
(sid_b, model_b, endpoint_b, endpoint_b_id),
]:
# Prefer an explicit endpoint id: it pins the EXACT registered
# endpoint (and its api_key), even when two endpoints visible to
# the caller share a base_url with different keys — a URL-only
# match would copy whichever row sorts first, i.e. possibly the
# wrong key. Fall back to URL resolution only for legacy / admin
# raw-URL callers that don't send an id.
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
if eid:
ep = _owned_endpoint_by_id(db, eid, user)
if ep is None:
# An id the caller can't see (wrong owner / deleted) must
# NOT silently fall back to a same-URL row with a different
# key — that's exactly the mix-up ids exist to prevent.
raise HTTPException(404, "Model endpoint not found")
# The id already resolved the endpoint; ignore any raw URL the
# caller also sent and dial the stored config instead.
endpoint = ep.base_url
elif not endpoint:
raise HTTPException(
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
)
else:
# Resolve the supplied URL to a ModelEndpoint the caller owns
# (their own rows + legacy null-owner shared rows), scoped so a
# comparison can't borrow another user's private endpoint key.
base = normalize_base(endpoint)
ep = _owned_endpoint_by_url(db, base, user)
# Reject *unregistered* raw URLs for signed-in non-admins; a
# matched registered endpoint supplies an id so the caller can
# still compare endpoints they own. Blanket-rejecting here (the
# earlier `endpoint_id=None` call) locked non-admins out of
# compare entirely, since compare resolves endpoints by URL with
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
# Raised here (phase 1), before any session exists.
_reject_raw_endpoint_url_for_non_admin(
request, user, str(ep.id) if ep is not None else None, endpoint
)
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
# caller-supplied string. When the URL matches a registered
# endpoint visible to the caller, use that row's own normalized
# base URL (the same value owner scoping + endpoint validation
# already vetted) so the session dials exactly where the stored
# config points. The raw `endpoint` only survives for callers
# allowed to pass one — admins / single-user mode, where
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
# is None. Mirrors the registered-endpoint path in session_routes.
session_endpoint_url = (
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
)
# Headers come only from a matched endpoint's key; None when
# `ep` is None (raw admin URL or no match), so a comparison can
# never inherit another user's key/headers.
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
resolved.append((sid, model, session_endpoint_url, headers))
finally:
db.close()
# Both endpoints validated — only now create the ephemeral [CMP]
# sessions and copy any resolved headers.
for sid, model, session_endpoint_url, headers in resolved:
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
session_manager.create_session(
session_id=sid,
name=name,
endpoint_url=endpoint,
endpoint_url=session_endpoint_url,
model=model,
rag=False,
owner=user,
)
# Copy API key from endpoint config
db = SessionLocal()
try:
from src.endpoint_resolver import build_headers, normalize_base
# Find matching endpoint by URL, scoped to the caller so a
# comparison can't borrow another user's private endpoint key.
base = normalize_base(endpoint)
ep = _owned_endpoint_by_url(db, base, user)
if ep and ep.api_key:
s = session_manager.sessions.get(sid)
if s:
s.headers = build_headers(ep.api_key, ep.base_url)
finally:
db.close()
if headers:
s = session_manager.sessions.get(sid)
if s:
s.headers = headers
# Store comparison record
db = SessionLocal()
@@ -121,8 +205,12 @@ def setup_compare_routes(session_manager: SessionManager):
prompt=prompt,
model_a=model_a,
model_b=model_b,
endpoint_a=endpoint_a,
endpoint_b=endpoint_b,
# Record the URL the session actually dials. For URL callers this
# is their raw input; for id-only callers (empty endpoint_a/_b)
# fall back to the resolved endpoint URL so the column stays
# meaningful and non-null. resolved is in [a, b] order.
endpoint_a=endpoint_a or resolved[0][2],
endpoint_b=endpoint_b or resolved[1][2],
is_blind=blind,
blind_mapping=json.dumps(mapping),
owner=user,
+53 -18
View File
@@ -11,20 +11,24 @@ import uuid
import json
import csv
import io
import os
import httpx
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Query, Depends, Response
from urllib.parse import urljoin, urlparse, urlunparse
from fastapi import APIRouter, Query, Depends, Response, HTTPException
from typing import List, Dict, Optional
from src.auth_helpers import require_user
from core.middleware import require_admin
from src.url_safety import check_outbound_url
logger = logging.getLogger(__name__)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
SETTINGS_FILE = DATA_DIR / "settings.json"
LOCAL_CONTACTS_FILE = DATA_DIR / "contacts.json"
from src.constants import DATA_DIR as _DATA_DIR, SETTINGS_FILE as _SETTINGS_FILE, CONTACTS_FILE as _CONTACTS_FILE
DATA_DIR = Path(_DATA_DIR)
SETTINGS_FILE = Path(_SETTINGS_FILE)
LOCAL_CONTACTS_FILE = Path(_CONTACTS_FILE)
def _load_settings():
@@ -53,6 +57,21 @@ def _carddav_configured(cfg: Optional[Dict] = None) -> bool:
return bool((cfg.get("url") or "").strip())
def _validate_carddav_url(url: str) -> str:
cleaned = (url if isinstance(url, str) else "").strip().rstrip("/")
ok, reason = check_outbound_url(
cleaned,
block_private=os.getenv("CARDDAV_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
raise ValueError(f"Rejected CardDAV URL: {reason}")
return cleaned
def _carddav_base_url(cfg: Dict) -> str:
return _validate_carddav_url(cfg.get("url") or "")
def _normalize_contact(contact: Dict) -> Dict:
emails = []
for e in contact.get("emails") or ([] if not contact.get("email") else [contact.get("email")]):
@@ -219,14 +238,18 @@ _contact_cache = {"contacts": [], "fetched_at": None}
def _abs_url(href: str) -> str:
"""Combine a multistatus <href> (an absolute path like
/user/contacts/x.vcf) with the configured CardDAV server origin so we
get a fully-qualified URL to PUT/DELETE. If href is already absolute
(http...), return it as-is."""
from urllib.parse import urlparse, urlunparse
if href.startswith("http://") or href.startswith("https://"):
return href
get a fully-qualified URL to PUT/DELETE. Absolute hrefs are accepted only
for the configured origin; a cross-origin href is treated as a path on the
configured server so a malicious CardDAV response cannot redirect later
writes/deletes to cloud metadata or another host."""
cfg = _get_carddav_config()
p = urlparse(cfg["url"])
return urlunparse((p.scheme, p.netloc, href, "", "", ""))
base = _carddav_base_url(cfg)
base_p = urlparse(base)
joined = urljoin(base.rstrip("/") + "/", href or "")
joined_p = urlparse(joined)
if (joined_p.scheme, joined_p.netloc) != (base_p.scheme, base_p.netloc):
joined = urlunparse((base_p.scheme, base_p.netloc, joined_p.path or "/", "", joined_p.query, ""))
return _validate_carddav_url(joined)
# CardDAV REPORT body — pull every card's etag + raw vCard in ONE request,
@@ -297,6 +320,7 @@ def _fetch_contacts(force=False):
return contacts
try:
cfg["url"] = _carddav_base_url(cfg)
auth = None
if cfg["username"]:
auth = (cfg["username"], cfg["password"])
@@ -353,8 +377,8 @@ def _create_contact(name: str, email: str) -> bool:
contact_uid = str(uuid.uuid4())
vcard = _build_vcard(name, email, contact_uid)
url = cfg["url"].rstrip("/") + "/" + contact_uid + ".vcf"
try:
url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf"
auth = None
if cfg["username"]:
auth = (cfg["username"], cfg["password"])
@@ -382,7 +406,7 @@ def _vcard_url(uid: str) -> str:
escape the collection and target an arbitrary CardDAV resource."""
from urllib.parse import quote
cfg = _get_carddav_config()
return cfg["url"].rstrip("/") + "/" + quote(uid, safe="") + ".vcf"
return _carddav_base_url(cfg) + "/" + quote(uid, safe="") + ".vcf"
def _import_vcards(text: str) -> Dict:
@@ -413,6 +437,11 @@ def _import_vcards(text: str) -> Dict:
if imported:
_save_local_contacts(contacts)
return {"imported": imported, "failed": 0, "total": len(parsed)}
try:
base_url = _carddav_base_url(cfg)
except ValueError as e:
logger.warning("CardDAV import URL rejected: %s", e)
return {"imported": 0, "failed": 0, "total": 0, "error": str(e)}
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
# Split into individual cards. re.split drops the BEGIN line, so we
# re-add it. Normalize CRLF.
@@ -441,7 +470,7 @@ def _import_vcards(text: str) -> Dict:
elif not re.search(r"^VERSION:", block, re.MULTILINE):
block = block.replace("BEGIN:VCARD", "BEGIN:VCARD\nVERSION:4.0", 1)
vcard = block.replace("\n", "\r\n") + "\r\n"
url = cfg["url"].rstrip("/") + "/" + quote(uid, safe="") + ".vcf"
url = base_url + "/" + quote(uid, safe="") + ".vcf"
try:
r = httpx.put(
url, data=vcard.encode("utf-8"),
@@ -601,8 +630,8 @@ def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones)
# Use the real resource href (handles externally-created contacts whose
# filename != UID); falls back to the <uid>.vcf guess.
url = _resolve_resource_url(uid)
try:
url = _resolve_resource_url(uid)
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
r = httpx.put(
url,
@@ -630,8 +659,8 @@ def _delete_contact(uid: str) -> bool:
_save_local_contacts(remaining)
return True
url = _resolve_resource_url(uid)
try:
url = _resolve_resource_url(uid)
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
r = httpx.delete(url, auth=auth, timeout=10)
if r.status_code in (200, 204):
@@ -747,7 +776,13 @@ def setup_contacts_routes():
settings = _load_settings()
for key in ("carddav_url", "carddav_username", "carddav_password"):
if key in data:
settings[key] = data[key]
if key == "carddav_url" and str(data[key] or "").strip():
try:
settings[key] = _validate_carddav_url(data[key])
except ValueError as e:
raise HTTPException(400, str(e))
else:
settings[key] = data[key]
_save_settings(settings)
# Force re-fetch
_contact_cache["fetched_at"] = None
+353 -24
View File
@@ -11,6 +11,8 @@ import shlex
from fastapi import HTTPException
from pydantic import BaseModel
from core.platform_compat import _ssh_exec_argv
logger = logging.getLogger(__name__)
@@ -28,8 +30,9 @@ _LOCAL_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_OLLAMA_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$")
# Include pattern is a glob: allow typical safe glyphs only.
_INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$")
# Remote host: user@host (optionally with :port-free hostname parts).
_REMOTE_HOST_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$")
# Remote host: either `user@host` or plain `host` (alias is allowed), where host
# is a safe DNS-like token or a short SSH config alias.
_REMOTE_HOST_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$")
# HF tokens and API tokens are url-safe base64-like.
_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$")
# Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef".
@@ -39,9 +42,16 @@ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_SSH_PORT_RE = re.compile(r"^\d{1,5}$")
_GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$")
# A download target directory. Absolute or ~-relative path; safe path glyphs
# only (no quotes, shell metacharacters, or spaces) since it lands in a shell
# command. A leading ~ is expanded to $HOME at command-build time.
_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$")
# only (no quotes or shell metacharacters). Spaces are allowed because command
# builders pass the value through quoted shell/Python contexts. The character
# class uses ``\w`` — Unicode word characters under Python 3's default str
# matching — so non-ASCII folder names pass validation too: Cyrillic, accented
# Latin, CJK, e.g. ``/Volumes/Модели`` or ``D:\AI Models\Модели``. This stays
# shell-safe: none of ``; & | ` $ '' "" () {}`` newlines etc. are in ``[\w. -]``,
# so injection vectors remain rejected. A leading ~ is expanded to $HOME at
# command-build time. (Drive letters stay ASCII: ``[A-Za-z]:``.)
_LOCAL_DIR_RE = re.compile(r"^~?(?:/[\w. -]*)+$|^~$")
_WINDOWS_LOCAL_DIR_RE = re.compile(r"^[A-Za-z]:[\\/](?:[\w. -]+(?:[\\/][\w. -]+)*[\\/]?)?$")
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
@@ -79,7 +89,7 @@ def _validate_remote_host(v: str | None) -> str | None:
if v is None or v == "":
return None
if not _REMOTE_HOST_RE.match(v):
raise HTTPException(400, "Invalid remote_host — must be user@host, no SSH option syntax")
raise HTTPException(400, "Invalid remote_host — must be host or user@host, no SSH option syntax")
return v
@@ -94,9 +104,19 @@ def _validate_token(v: str | None) -> str | None:
def _validate_local_dir(v: str | None) -> str | None:
if v is None or v == "":
return None
if len(v) >= 2 and v[0] == v[-1] and v[0] in {"'", '"'}:
v = v[1:-1]
v = v.rstrip("/") or "/"
if not _LOCAL_DIR_RE.match(v):
raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no spaces or shell metacharacters")
if not (_LOCAL_DIR_RE.match(v) or _WINDOWS_LOCAL_DIR_RE.match(v)):
raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no shell metacharacters")
# Reject path segments that start with '-' (option injection). '-' is in the
# allowlist, so a dir like ``/models/-rf`` or ``D:\models\-rf`` could be read
# as a CLI flag by hf/etc. — and quoting does NOT stop a value from being
# parsed as an option. This is the one residual that command-build-time
# quoting can't cover, so the guard lives here, keeping the safety wholly
# inside the validator rather than relying on consumers.
if any(seg.startswith("-") for seg in re.split(r"[\\/]", v) if seg):
raise HTTPException(400, "Invalid local_dir — path segments cannot start with '-'")
return v
@@ -122,7 +142,7 @@ def _validate_gpus(v: str | None) -> str | None:
def _shell_path(p: str) -> str:
"""Render a validated path for a double-quoted shell context, expanding a
leading ~ to $HOME (single quotes wouldn't expand it). Safe because
_validate_local_dir already restricts the charset."""
_validate_local_dir already rejects quotes and shell metacharacters."""
if p == "~":
return '"$HOME"'
if p.startswith("~/"):
@@ -195,6 +215,20 @@ def _pip_install_attempt(pip_cmd: str) -> str:
)
def _pip_command(python_cmd: str) -> str:
"""Return a pip command for either a pip executable or a Python executable."""
cmd = python_cmd.strip()
if " -m pip" in cmd or cmd in {"pip", "pip3"}:
return python_cmd
if cmd in {"python", "python3", "python.exe"} or cmd.endswith(("/python", "/python3", "\\python.exe")):
return f"{python_cmd} -m pip"
return python_cmd
def _pip_break_system_packages_check(pip_cmd: str) -> str:
return f"{pip_cmd} install --help 2>/dev/null | grep -q -- --break-system-packages"
def _pip_install_fallback_chain(package: str, *, python_cmd: str = "python3 -m pip", upgrade: bool = False) -> str:
"""Build a bash pip install fallback chain that surfaces errors.
@@ -206,33 +240,44 @@ def _pip_install_fallback_chain(package: str, *, python_cmd: str = "python3 -m p
exit code is preserved (no ``| tail`` masking) and the last 5 lines of
pip output appear in the Cookbook log on failure.
"""
from core.platform_compat import IS_WINDOWS
upgrade_flag = " -U" if upgrade else ""
# Shell-quote the package spec: an extras spec like ``llama-cpp-python[server]``
# contains brackets that bash would treat as a glob, so it must be quoted
# before being embedded in the install command. Plain names (e.g.
# ``huggingface_hub``) are returned unchanged by ``shlex.quote``.
pkg = shlex.quote(package)
base = _pip_install_attempt(f"{python_cmd} install -q{upgrade_flag} {pkg}")
user = _pip_install_attempt(f"{python_cmd} install --user --break-system-packages -q{upgrade_flag} {pkg}")
# llama-cpp-python source builds are brittle on older distro pip/packaging
# stacks (common on WSL images). Prefer the prebuilt wheel index whenever
# this package is requested so dependency-install tasks are reliable.
if "llama-cpp-python" in package:
pkg += " --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu"
pip_cmd = _pip_command(python_cmd)
base = _pip_install_attempt(f"{pip_cmd} install -q{upgrade_flag} {pkg}")
user = _pip_install_attempt(f"{pip_cmd} install --user -q{upgrade_flag} {pkg}")
user_break_system = _pip_install_attempt(f"{pip_cmd} install --user --break-system-packages -q{upgrade_flag} {pkg}")
user_fallback = f"( {user} || {{ {_pip_break_system_packages_check(pip_cmd)} && {user_break_system}; }} )"
# Derive the python executable for the venv detection check.
# Must use the same interpreter that pip belongs to; hardcoding
# python3 breaks when pip lives in a venv that only has "python".
if " -m pip" in python_cmd:
python_exe = python_cmd.replace(" -m pip", "")
elif python_cmd.strip() == "pip":
if " -m pip" in pip_cmd:
python_exe = pip_cmd.replace(" -m pip", "")
elif pip_cmd.strip() == "pip":
python_exe = "python"
elif python_cmd.strip() == "pip3":
elif pip_cmd.strip() == "pip3":
python_exe = "python3"
else:
python_exe = "python3"
venv_check = f'{python_exe} -c "import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)"'
# Negated: `! venv_check` succeeds (exit 0) when NOT in a venv `&&` tries
# --user. When IN a venv `! venv_check` fails `&&` skips --user and the
# Negated: `! venv_check` succeeds (exit 0) when NOT in a venv -> `&&` tries
# --user. When IN a venv `! venv_check` fails -> `&&` skips --user and the
# group exits non-zero, propagating the base-install failure instead of
# masking it as success (the `|| { venv_check || … }` shape from #903
# swallowed the exit code because venv_check's exit-0 became the group's
# result).
return f"{base} || {{ ! {venv_check} && {user}; }}"
# result). `--break-system-packages` is only attempted when the active pip
# supports it; older pip versions abort with "no such option" otherwise.
return f"{base} || {{ ! {venv_check} && {user_fallback}; }}"
def _venv_safe_local_pip_install_cmd(cmd: str, *, local: bool, in_venv: bool) -> str:
@@ -263,6 +308,55 @@ def _venv_safe_local_pip_install_cmd(cmd: str, *, local: bool, in_venv: bool) ->
return shlex.join(stripped)
def _pip_install_command_without_break_system_packages(cmd: str) -> str:
try:
parts = shlex.split(cmd)
except ValueError:
return cmd
stripped = [part for part in parts if part != "--break-system-packages"]
return shlex.join(stripped)
def _pip_install_help_check_from_cmd(cmd: str) -> str | None:
try:
parts = shlex.split(cmd)
except ValueError:
return None
try:
install_index = parts.index("install")
except ValueError:
return None
if install_index <= 0:
return None
pip_prefix = parts[:install_index]
return f"{shlex.join(pip_prefix + ['install', '--help'])} 2>/dev/null | grep -q -- --break-system-packages"
def _append_pip_install_runner_lines(runner_lines: list[str], cmd: str) -> None:
"""Append a pip install command, guarding --break-system-packages support.
The Dependencies UI may submit ``python3 -m pip install --user
--break-system-packages ...`` for non-venv installs. That flag is useful on
PEP-668-locked distros, but older pip (including Ubuntu 22.04's apt pip in
the NVIDIA CUDA base image) aborts with "no such option". Branch at runner
time so stale browser JS and remote targets are handled by the server too.
"""
if "--break-system-packages" not in (cmd or ""):
runner_lines.append(cmd)
return
help_check = _pip_install_help_check_from_cmd(cmd)
without_break = _pip_install_command_without_break_system_packages(cmd)
if not help_check or without_break == cmd:
runner_lines.append(cmd)
return
runner_lines.append(f"if {help_check}; then")
runner_lines.append(f" {cmd}")
runner_lines.append("else")
runner_lines.append(' echo "[odysseus] pip does not support --break-system-packages; installing without it."')
runner_lines.append(f" {without_break}")
runner_lines.append("fi")
def _user_shell_path_bootstrap() -> list[str]:
return [
'ODYSSEUS_USER_SHELL="${SHELL:-}"',
@@ -271,11 +365,14 @@ def _user_shell_path_bootstrap() -> list[str]:
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
'fi',
'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }',
'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }',
]
def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
"""Build the standalone Python scanner used by /api/model/cached."""
def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache: str | None = None) -> str:
"""Build the standalone Python scanner used by /api/model/cached.
Allows for an additional HuggingFace cache path to be scanned (i.e. Windows HF cache for local WSL envs.)
"""
lines = [
"import json, os, re, shutil, subprocess, urllib.request",
"models = []",
@@ -306,6 +403,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
" for root, dirs, fns in safe_walk(base):",
" for fn in sorted(fns):",
" if not fn.lower().endswith('.gguf'): continue",
" if fn.startswith('._'): continue # macOS AppleDouble sidecar, not a real GGUF",
" fp = os.path.join(root, fn)",
" try: size = os.path.getsize(fp)",
" except Exception: size = 0",
@@ -338,6 +436,15 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')",
" # Windows HF cache stores files directly in snapshots/; blobs/ may be empty.",
" # Fallback: scan snapshots for real files when blobs yielded nothing.",
" if sz == 0 and os.path.isdir(snap):",
" for sd in os.listdir(snap):",
" sf = os.path.join(snap, sd)",
" if not os.path.isdir(sf): continue",
" for f in os.scandir(sf):",
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" is_diffusion = False; gguf_files = []",
" if os.path.isdir(snap):",
" for sd in os.listdir(snap):",
@@ -346,6 +453,21 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
"def hf_cache_paths():",
" candidates = []",
" def add(p):",
" if not p: return",
" p = os.path.expanduser(p)",
" if p not in candidates: candidates.append(p)",
" add(os.environ.get('HUGGINGFACE_HUB_CACHE'))",
" hf_home = os.environ.get('HF_HOME')",
" if hf_home: add(os.path.join(hf_home, 'hub'))",
" add('~/.cache/huggingface/hub')",
" # Docker images mount ./data/huggingface at /app/.cache/huggingface.",
" # When HOME is /root, expanduser() misses that persisted cache.",
" add('/app/.cache/huggingface/hub')",
f" add({add_hf_cache!r})" if add_hf_cache else "",
" return candidates",
"def scan_dir(p):",
" if not os.path.isdir(p) or not safe_path(p): return",
" for d in sorted(os.listdir(p)):",
@@ -409,7 +531,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
" seen.add(name)",
" models.append({'repo_id':name,'size_bytes':size_bytes,'nb_files':1,'has_incomplete':False,'path':'ollama','backend':'ollama','is_ollama':True})",
" return",
"scan_hf(os.path.expanduser('~/.cache/huggingface/hub'))",
"for _hf_cache in hf_cache_paths(): scan_hf(_hf_cache)",
"scan_ollama()",
"scan_ollama_api()",
]
@@ -525,6 +647,7 @@ def _validate_serve_cmd(v: str | None) -> str | None:
# Backticks and raw newlines are never legitimate here.
if any(c in v for c in ("`", "\n", "\r")):
raise HTTPException(400, "Invalid characters in cmd")
# Known GGUF launcher prelude → validate the serve invocation(s) it guards.
m = _GGUF_PRELUDE_RE.match(v)
if m:
@@ -533,9 +656,19 @@ def _validate_serve_cmd(v: str | None) -> str | None:
for part in rest.split("||"):
_check_serve_binary(part.strip())
return v
# Otherwise: a single invocation — no shell metacharacters allowed.
# Temporarily replace safe $(printf %s ...) expressions with a placeholder
# to avoid triggering the metacharacter/command-injection checks.
cleaned_v = v
printf_matches = list(re.finditer(r"\$\(\s*printf\s+%s\s+([^\n()]*?)\)", v))
for match in printf_matches:
inner = match.group(1)
if not any(c in inner for c in (";", "&&", "||", "$(", "`")):
cleaned_v = cleaned_v.replace(match.group(0), "/placeholder/safe/path.gguf")
# (`$(` was the original intent; bare `$` is fine for shell-safe paths.)
if any(c in v for c in (";", "&&", "||", "$(")):
if any(c in cleaned_v for c in (";", "&&", "||", "$(")):
raise HTTPException(400, "Invalid characters in cmd")
_check_serve_binary(v)
return v
@@ -546,12 +679,34 @@ def _append_serve_preflight_exit_lines(runner_lines: list[str], *, keep_shell_op
runner_lines.append('if [ -n "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
runner_lines.append(' echo ""; echo "=== Process exited with code $ODYSSEUS_PREFLIGHT_EXIT ==="')
if keep_shell_open:
# Decouple the post-crash interactive shell from the persistent log
# file. fds 3/4 were saved BEFORE the tee redirect at the top of
# the runner; restoring them here means the neofetch banner the
# user's .zshrc prints lands on the tmux pane only, not in the
# log file the agent's tail_serve_output reads.
runner_lines.append(' exec 1>&3 2>&4 3>&- 4>&- 2>/dev/null || true')
runner_lines.append(' sleep 0.2 # let tee child flush + exit')
runner_lines.append(' exec "${SHELL:-/bin/bash}"')
else:
runner_lines.append(' exit "$ODYSSEUS_PREFLIGHT_EXIT"')
runner_lines.append('fi')
def _append_vllm_linux_preflight_lines(runner_lines: list[str]) -> None:
"""Append Linux vLLM readiness lines that identify the runtime being used."""
# Keep the user install bin visible for Odysseus-managed `pip install --user`
# installs, but then report the actual CLI path so external runtimes are clear.
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
runner_lines.append('ODYSSEUS_VLLM_BIN="$(command -v vllm 2>/dev/null || true)"')
runner_lines.append('if [ -z "$ODYSSEUS_VLLM_BIN" ]; then')
runner_lines.append(' echo "ERROR: vLLM is not installed."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('else')
runner_lines.append(' echo "[odysseus] vLLM CLI: $ODYSSEUS_VLLM_BIN"')
runner_lines.append(' ODYSSEUS_VLLM_VERSION="$("$ODYSSEUS_VLLM_BIN" --version 2>&1 | head -n 1 || true)"')
runner_lines.append(' if [ -n "$ODYSSEUS_VLLM_VERSION" ]; then echo "[odysseus] vLLM version: $ODYSSEUS_VLLM_VERSION"; fi')
runner_lines.append('fi')
def _append_serve_exit_code_lines(
runner_lines: list[str],
*,
@@ -563,7 +718,11 @@ def _append_serve_exit_code_lines(
if is_pip_install:
runner_lines.append('if [ $ODYSSEUS_CMD_EXIT -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; fi')
if keep_shell_open:
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="; exec "${SHELL:-/bin/bash}"')
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="')
# See preflight branch above for the rationale on restoring fds 3/4.
runner_lines.append('exec 1>&3 2>&4 3>&- 4>&- 2>/dev/null || true')
runner_lines.append('sleep 0.2 # let tee child flush + exit')
runner_lines.append('exec "${SHELL:-/bin/bash}"')
else:
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="')
runner_lines.append('exit "$ODYSSEUS_CMD_EXIT"')
@@ -647,6 +806,7 @@ def _llama_cpp_rebuild_cmd() -> str:
class ModelDownloadRequest(BaseModel):
repo_id: str
backend: str | None = None # "hf" (default) or "ollama"
include: str | None = None # glob pattern e.g. "*Q4_K_M*"
hf_token: str | None = None
env_prefix: str | None = None # e.g. "source ~/venv/bin/activate"
@@ -793,3 +953,172 @@ def _ssh_ps(host, script_path, port=None):
# Windows session dir — stored in user's temp on the remote
WIN_SESSION_DIR = "$env:TEMP\\\\odysseus-sessions"
def _diagnose_serve_output(text: str) -> dict | None:
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
the agent/tool path the same structured signal so it can retry with an
adjusted command instead of guessing from raw tmux output.
"""
if not text:
return None
tail = text[-6000:]
patterns = [
(
r"No available memory for the cache blocks|Available KV cache memory:.*-",
"No GPU memory left for KV cache after loading model.",
[
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
],
),
(
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
"GPU ran out of memory during startup or warmup.",
[
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
],
),
(
r"not divisib|must be divisible|attention heads.*divisible",
"Tensor parallel size is incompatible with the model.",
[
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
],
),
(
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
"Context length is too large for available GPU memory.",
[
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
],
),
(
r"enable-auto-tool-choice requires --tool-call-parser",
"Auto tool choice requires an explicit tool call parser.",
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
),
(
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
"Model requires custom code or newer model support.",
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
),
(
r"There is no module or parameter named ['\"]lm_head\.input_scale['\"]|lm_head\.input_scale|weight_scale_2",
"vLLM cannot load this ModelOpt LM-head quantized checkpoint with the current runtime.",
[
{
"label": "upgrade vLLM through the environment that provides this CLI, or use a compatible checkpoint",
"op": "manual",
}
],
),
(
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
"vLLM/Transformers kernel package mismatch.",
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
),
(
r"Address already in use|bind.*address.*in use",
"Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
),
(
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.",
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
),
(
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
"vLLM could not find a supported GPU (CUDA or ROCm). "
"This machine may have integrated or unsupported graphics only.",
[
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
],
),
(
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
"vLLM is not installed or not in PATH on this server.",
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
),
(
r"sglang.*command not found|No module named sglang|SGLang is not installed",
"SGLang is not installed or not in PATH on this server.",
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
),
(
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'|git: command not found|cmake: command not found",
"llama.cpp / llama-cpp-python dependencies are missing.",
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
),
(
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
"Diffusion serving requires PyTorch and diffusers.",
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
),
(
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
"Model access is gated or unauthorized.",
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
),
]
for pattern, message, suggestions in patterns:
if re.search(pattern, tail, re.I):
return {"message": message, "suggestions": suggestions}
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
):
return {
"message": "Python traceback detected during serve startup.",
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
}
return None
async def run_ssh_command_async(
remote: str,
ssh_port: str | None,
remote_cmd: str,
*,
timeout: float,
connect_timeout: int | None = None,
strict_host_key_checking: bool | None = None,
stdin_data: bytes | None = None,
) -> tuple[int, bytes, bytes]:
"""Run an ssh command with centralized timeout and stderr/stdout capture.
Async version of core.platform_compat.run_ssh_command_sync.
"""
import asyncio
proc = await asyncio.create_subprocess_exec(
*_ssh_exec_argv(
remote,
ssh_port,
remote_cmd=remote_cmd,
connect_timeout=connect_timeout,
strict_host_key_checking=strict_host_key_checking,
),
stdin=asyncio.subprocess.PIPE if stdin_data is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=stdin_data), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
raise
return proc.returncode or 0, stdout, stderr
+895 -148
View File
File diff suppressed because it is too large Load Diff
+67 -117
View File
@@ -20,39 +20,26 @@ All routes are admin-gated (endpoint/provider management is an admin action).
"""
import json
import time
import uuid
import logging
import threading
from typing import Dict, Optional
import httpx
from fastapi import APIRouter, Request, Form, HTTPException
from fastapi import HTTPException, Request
from core.database import SessionLocal, ModelEndpoint
from core.middleware import require_admin
from routes.device_flow import (
DeviceFlowPoll,
DeviceFlowStart,
PendingDeviceFlowStore,
create_device_flow_router,
)
from src.auth_helpers import get_current_user
from src import copilot
logger = logging.getLogger(__name__)
# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a
# bearer-like secret, so it lives here (server memory) rather than in the
# browser. Entries expire with the GitHub device code.
#
# NOTE: this is per-process state. The device flow assumes a single worker
# (Odysseus' default): with multiple uvicorn workers, the poll request can land
# on a worker that never saw the start, returning "Unknown or expired login
# session". Move this to a shared store (DB/Redis) if running multi-worker.
_PENDING: Dict[str, Dict] = {}
_PENDING_LOCK = threading.Lock()
def _prune_expired() -> None:
now = time.time()
with _PENDING_LOCK:
for k in [k for k, v in _PENDING.items() if v.get("expires_at", 0) < now]:
_PENDING.pop(k, None)
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
@@ -112,112 +99,75 @@ def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
return result
def setup_copilot_routes() -> APIRouter:
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
host = copilot.GITHUB_HOST
ent = str(form.get("enterprise_url") or "").strip()
if ent:
host = copilot.normalize_domain(ent)
try:
data = copilot.request_device_code(host)
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else "unknown"
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
except Exception as e:
raise HTTPException(502, f"GitHub device-code request failed: {e}")
@router.post("/device/start")
def device_start(request: Request, enterprise_url: str = Form("")):
require_admin(request)
_prune_expired()
host = copilot.GITHUB_HOST
ent = (enterprise_url or "").strip()
if ent:
host = copilot.normalize_domain(ent)
try:
data = copilot.request_device_code(host)
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else "unknown"
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
except Exception as e:
raise HTTPException(502, f"GitHub device-code request failed: {e}")
device_code = data.get("device_code")
if not device_code:
raise HTTPException(502, "GitHub did not return a device code")
device_code = data.get("device_code")
if not device_code:
raise HTTPException(502, "GitHub did not return a device code")
interval = int(data.get("interval") or 5)
expires_in = int(data.get("expires_in") or 900)
poll_id = uuid.uuid4().hex
with _PENDING_LOCK:
_PENDING[poll_id] = {
"device_code": device_code,
"host": host,
"enterprise_url": ent,
"interval": interval,
"owner": get_current_user(request) or None,
"expires_at": time.time() + expires_in,
"next_poll_at": 0.0,
}
# verification_uri_complete embeds the user code, so the browser tab we
# open lands the user straight on GitHub's "Authorize" screen with the
# code pre-filled — one click, no manual code entry.
return {
"poll_id": poll_id,
# verification_uri_complete embeds the user code, so the browser tab we
# open lands the user straight on GitHub's "Authorize" screen with the
# code pre-filled — one click, no manual code entry.
return DeviceFlowStart(
pending={
"device_code": device_code,
"host": host,
"enterprise_url": ent,
"owner": get_current_user(request) or None,
},
response={
"user_code": data.get("user_code"),
"verification_uri": data.get("verification_uri"),
"verification_uri_complete": data.get("verification_uri_complete"),
"interval": interval,
"expires_in": expires_in,
}
},
interval=int(data.get("interval") or 5),
expires_in=int(data.get("expires_in") or 900),
)
@router.post("/device/poll")
def device_poll(request: Request, poll_id: str = Form(...)):
require_admin(request)
_prune_expired()
with _PENDING_LOCK:
pending = _PENDING.get(poll_id)
if not pending:
raise HTTPException(404, "Unknown or expired login session")
# Enforce GitHub's polling interval server-side so a chatty client
# can't trip slow_down.
now = time.time()
if now < pending.get("next_poll_at", 0):
return {"status": "pending"}
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
try:
data = copilot.poll_access_token(pending["host"], pending["device_code"])
except Exception as e:
return DeviceFlowPoll.pending(f"poll error: {e}")
token = data.get("access_token")
if token:
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
try:
data = copilot.poll_access_token(pending["host"], pending["device_code"])
result = _provision_endpoint(token, base, pending["owner"])
except Exception as e:
return {"status": "pending", "detail": f"poll error: {e}"}
logger.exception("Copilot endpoint provisioning failed")
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
return DeviceFlowPoll.authorized(result)
token = data.get("access_token")
if token:
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
try:
result = _provision_endpoint(token, base, pending["owner"])
except Exception as e:
logger.exception("Copilot endpoint provisioning failed")
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "authorized", "endpoint": result}
err = data.get("error")
if err == "authorization_pending":
return DeviceFlowPoll.pending()
if err == "slow_down":
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
if err in ("expired_token", "access_denied"):
return DeviceFlowPoll.failed(err)
# Unknown error — surface but keep the session for another try.
return DeviceFlowPoll.pending(err or "unknown")
err = data.get("error")
if err == "authorization_pending":
with _PENDING_LOCK:
if poll_id in _PENDING:
_PENDING[poll_id]["next_poll_at"] = now + pending["interval"]
return {"status": "pending"}
if err == "slow_down":
new_interval = int(data.get("interval") or (pending["interval"] + 5))
with _PENDING_LOCK:
if poll_id in _PENDING:
_PENDING[poll_id]["interval"] = new_interval
_PENDING[poll_id]["next_poll_at"] = now + new_interval
return {"status": "pending"}
if err in ("expired_token", "access_denied"):
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "failed", "error": err}
# Unknown error — surface but keep the session for another try.
return {"status": "pending", "detail": err or "unknown"}
@router.post("/device/cancel")
def device_cancel(request: Request, poll_id: str = Form(...)):
require_admin(request)
with _PENDING_LOCK:
_PENDING.pop(poll_id, None)
return {"status": "cancelled"}
return router
def setup_copilot_routes():
return create_device_flow_router(
prefix="/api/copilot",
tags=["copilot"],
store=_DEVICE_FLOW_STORE,
start_flow=_start_device_flow,
poll_flow=_poll_device_flow,
)
+193
View File
@@ -0,0 +1,193 @@
"""Shared OAuth/device-flow route scaffolding for provider setup."""
from __future__ import annotations
import inspect
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Iterable, Mapping, Optional
from fastapi import APIRouter, Form, HTTPException, Request
from core.middleware import require_admin
@dataclass(frozen=True)
class DeviceFlowStart:
"""Provider-specific start result consumed by the shared route wrapper."""
pending: Mapping[str, Any]
response: Mapping[str, Any]
interval: int = 5
expires_in: int = 900
@dataclass(frozen=True)
class DeviceFlowPoll:
"""Normalized provider poll outcome."""
status: str
endpoint: Optional[Mapping[str, Any]] = None
error: Optional[str] = None
detail: Optional[str] = None
interval: Optional[int] = None
@classmethod
def pending(cls, detail: Optional[str] = None) -> "DeviceFlowPoll":
return cls(status="pending", detail=detail)
@classmethod
def slow_down(cls, interval: Optional[int] = None, detail: Optional[str] = None) -> "DeviceFlowPoll":
return cls(status="slow_down", interval=interval, detail=detail)
@classmethod
def authorized(cls, endpoint: Mapping[str, Any]) -> "DeviceFlowPoll":
return cls(status="authorized", endpoint=endpoint)
@classmethod
def failed(cls, error: str) -> "DeviceFlowPoll":
return cls(status="failed", error=error)
class PendingDeviceFlowStore:
"""Thread-safe in-memory pending device-flow store.
Device codes and provider-side secrets stay inside this process. Each entry
stores provider payload separately from poll metadata so provider callbacks
only receive the fields they created.
"""
def __init__(self, *, time_func: Callable[[], float] = time.time):
self._pending: dict[str, dict[str, Any]] = {}
self._lock = threading.Lock()
self._time = time_func
def _now(self) -> float:
return float(self._time())
def prune_expired(self) -> None:
now = self._now()
with self._lock:
for key in [k for k, v in self._pending.items() if v.get("expires_at", 0) < now]:
self._pending.pop(key, None)
def add(self, payload: Mapping[str, Any], *, interval: int, expires_in: int) -> str:
self.prune_expired()
poll_id = uuid.uuid4().hex
with self._lock:
self._pending[poll_id] = {
"payload": dict(payload),
"interval": max(int(interval or 5), 1),
"expires_at": self._now() + max(int(expires_in or 900), 1),
"next_poll_at": 0.0,
}
return poll_id
def get_payload(self, poll_id: str) -> Optional[dict[str, Any]]:
self.prune_expired()
with self._lock:
entry = self._pending.get(poll_id)
if entry is None:
return None
return dict(entry.get("payload") or {})
def is_throttled(self, poll_id: str) -> bool:
with self._lock:
entry = self._pending.get(poll_id)
return bool(entry and self._now() < float(entry.get("next_poll_at") or 0))
def schedule_next(self, poll_id: str) -> None:
now = self._now()
with self._lock:
entry = self._pending.get(poll_id)
if entry is not None:
entry["next_poll_at"] = now + int(entry.get("interval") or 5)
def slow_down(self, poll_id: str, interval: Optional[int] = None) -> None:
now = self._now()
with self._lock:
entry = self._pending.get(poll_id)
if entry is not None:
new_interval = int(interval or (int(entry.get("interval") or 5) + 5))
entry["interval"] = max(new_interval, 1)
entry["next_poll_at"] = now + entry["interval"]
def pop(self, poll_id: str) -> None:
with self._lock:
self._pending.pop(poll_id, None)
async def _maybe_await(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
def _pending_response(detail: Optional[str] = None) -> dict[str, Any]:
response: dict[str, Any] = {"status": "pending"}
if detail:
response["detail"] = detail
return response
def create_device_flow_router(
*,
prefix: str,
tags: Iterable[str],
store: PendingDeviceFlowStore,
start_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowStart],
poll_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowPoll],
) -> APIRouter:
"""Create standard `/device/start|poll|cancel` routes for a provider."""
router = APIRouter(prefix=prefix, tags=list(tags))
@router.post("/device/start")
async def device_start(request: Request):
require_admin(request)
form = await request.form()
start = await _maybe_await(start_flow(request, form))
interval = int(start.interval or 5)
expires_in = int(start.expires_in or 900)
poll_id = store.add(start.pending, interval=interval, expires_in=expires_in)
response = dict(start.response)
response.update({"poll_id": poll_id, "interval": interval, "expires_in": expires_in})
return response
@router.post("/device/poll")
async def device_poll(request: Request, poll_id: str = Form(...)):
require_admin(request)
payload = store.get_payload(poll_id)
if payload is None:
raise HTTPException(404, "Unknown or expired login session")
if store.is_throttled(poll_id):
return {"status": "pending"}
try:
outcome = await _maybe_await(poll_flow(request, payload))
except Exception:
store.pop(poll_id)
raise
if outcome.status == "authorized":
store.pop(poll_id)
return {"status": "authorized", "endpoint": dict(outcome.endpoint or {})}
if outcome.status == "failed":
store.pop(poll_id)
return {"status": "failed", "error": outcome.error or "denied"}
if outcome.status == "slow_down":
store.slow_down(poll_id, outcome.interval)
return _pending_response(outcome.detail)
store.schedule_next(poll_id)
return _pending_response(outcome.detail)
@router.post("/device/cancel")
def device_cancel(request: Request, poll_id: str = Form(...)):
require_admin(request)
store.pop(poll_id)
return {"status": "cancelled"}
return router
+72 -40
View File
@@ -7,14 +7,24 @@ from typing import Dict, Any, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
from sqlalchemy import func
from sqlalchemy import case, func, or_
from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import get_current_user
from src.constants import MAIL_ATTACHMENTS_DIR
logger = logging.getLogger(__name__)
def _get_session_or_404(db, session_id: str, user: Optional[str]):
session = db.query(DbSession).filter(DbSession.id == session_id).first()
if not session:
raise HTTPException(404, "Session not found")
if user and session.owner != user:
raise HTTPException(404, "Session not found")
return session
def _aggregate_language_facets(lang_rows):
"""Sum document counts per display language for the library facet.
@@ -30,6 +40,19 @@ def _aggregate_language_facets(lang_rows):
return out
def _library_language_for_document(doc: Document) -> str:
"""Return the display language used by the document library.
PDF documents are stored as markdown wrappers so the editor can preserve
extracted text, form fields, and annotations. The library should still
identify them as PDFs instead of exposing that internal wrapper format.
"""
from src.pdf_form_doc import find_source_upload_id
if find_source_upload_id(doc.current_content or ""):
return "pdf"
return doc.language or "text"
from routes.document_helpers import (
DocumentCreate, DocumentUpdate, DocumentPatch,
@@ -69,17 +92,12 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# the doc is owner-stamped, so it lives in the library on its own.
session = None
if req.session_id:
session = db.query(DbSession).filter(DbSession.id == req.session_id).first()
if not session:
raise HTTPException(404, "Session not found")
# Match the lenient ownership model the rest of the app uses
# (see _owner_filter): only block when an AUTHENTICATED user is
# writing into a DIFFERENT user's session. In single-user /
# unconfigured / localhost-bypass mode the middleware leaves
# current_user unset (None), and those sessions are already
# served freely everywhere else.
if user and session.owner and session.owner != user:
raise HTTPException(403, "Cannot create document in another user's session")
# unconfigured / localhost-bypass mode, falsey users preserve
# the existing lenient path.
session = _get_session_or_404(db, req.session_id, user)
doc_id = str(uuid.uuid4())
ver_id = str(uuid.uuid4())
@@ -171,11 +189,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
if session_id:
db = SessionLocal()
try:
sess = db.query(DbSession).filter(DbSession.id == session_id).first()
if not sess:
raise HTTPException(404, "Session not found")
if user and sess.owner and sess.owner != user:
raise HTTPException(403, "Cannot import into another user's session")
_get_session_or_404(db, session_id, user)
finally:
db.close()
@@ -198,7 +212,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
title = os.path.splitext(meta.get("original_name") or meta.get("name") or upload_id)[0]
try:
body_text = strip_pdf_content_marker(_process_pdf(pdf_path))
body_text = strip_pdf_content_marker(_process_pdf(pdf_path, owner=user))
except Exception:
body_text = None
@@ -260,18 +274,29 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
db = SessionLocal()
try:
from sqlalchemy import or_
pdf_marker_cond = or_(
Document.current_content.like('%<!-- pdf_source upload_id="%'),
Document.current_content.like('%<!-- pdf_form_source upload_id="%'),
)
library_language_expr = case(
(pdf_marker_cond, "pdf"),
(Document.language.is_(None), "text"),
else_=Document.language,
)
# Archived view shows ONLY archived docs; the default view excludes
# them (NULL = legacy rows that predate the column = not archived).
_arch_cond = (Document.archived == True) if archived else or_(
Document.archived == False, Document.archived.is_(None))
# Language facet counts (owner-filtered)
# Language facet counts (owner-filtered). PDF documents are stored
# as markdown wrappers, so group by the library display language
# instead of the raw stored language.
lang_q = (
db.query(Document.language, func.count(Document.id))
db.query(library_language_expr, func.count(Document.id))
.outerjoin(DbSession, Document.session_id == DbSession.id)
.filter(Document.is_active == True).filter(_arch_cond)
)
lang_q = _owner_session_filter(lang_q, user)
lang_rows = lang_q.group_by(Document.language).all()
lang_rows = lang_q.group_by(library_language_expr).all()
languages = _aggregate_language_facets(lang_rows)
# Session count (owner-filtered)
@@ -303,12 +328,17 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
Document.title.ilike(term) | Document.current_content.ilike(term)
)
# Language filter
# Language filter. "pdf" is a display language derived from the
# source marker; "markdown" excludes those wrappers.
if language:
if language == "text":
q = q.filter((Document.language == None) | (Document.language == "text"))
elif language == "pdf":
q = q.filter(pdf_marker_cond)
else:
q = q.filter(Document.language == language)
if language == "markdown":
q = q.filter(~pdf_marker_cond)
# Total before pagination
total = q.count()
@@ -332,7 +362,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
"session_id": doc.session_id,
"session_name": session_name,
"title": doc.title,
"language": doc.language or "text",
"language": _library_language_for_document(doc),
"preview": (doc.current_content or "")[:500],
"version_count": doc.version_count,
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
@@ -359,18 +389,17 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
try:
if not user:
raise HTTPException(403, "Authentication required")
session = db.query(DbSession).filter(DbSession.id == session_id).first()
# v2 review HIGH-9: raise 403 explicitly when the caller
# can't see this session, instead of returning [] which the
# UI treats identically to "no docs" and silently masks
# auth failures.
if not session:
raise HTTPException(404, "Session not found")
if user and session.owner and session.owner != user:
raise HTTPException(403, "Access denied")
docs = db.query(Document).filter(
_get_session_or_404(db, session_id, user)
q = db.query(Document).filter(
Document.session_id == session_id
).order_by(Document.created_at.desc()).all()
)
if user:
q = q.filter(or_(Document.owner == user, Document.owner.is_(None)))
docs = q.order_by(Document.created_at.desc()).all()
return [_doc_to_dict(d) for d in docs]
finally:
db.close()
@@ -437,7 +466,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
raise HTTPException(404, "Source PDF could not be located")
try:
body_text = strip_pdf_content_marker(_process_pdf(pdf_path))
body_text = strip_pdf_content_marker(_process_pdf(pdf_path, owner=user))
except Exception as e:
logger.error(f"extract_pdf_text failed for {pdf_path}: {e}")
raise HTTPException(500, f"Extraction failed: {e}")
@@ -606,6 +635,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
doc.language = req.language
if req.session_id is not None:
# Empty string = unlink from session
if req.session_id:
_get_session_or_404(db, req.session_id, user)
doc.session_id = req.session_id if req.session_id else None
if not req.session_id:
# Tab closed / doc detached from its session — drop the
@@ -663,8 +694,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
try:
# Verify ownership before listing versions
doc = db.query(Document).filter(Document.id == doc_id).first()
if doc:
_verify_doc_owner(db, doc, user)
if not doc:
raise HTTPException(404, "Document not found")
_verify_doc_owner(db, doc, user)
versions = db.query(DocumentVersion).filter(
DocumentVersion.document_id == doc_id
).order_by(DocumentVersion.version_number.desc()).all()
@@ -687,8 +719,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
try:
# Verify ownership
doc = db.query(Document).filter(Document.id == doc_id).first()
if doc:
_verify_doc_owner(db, doc, user)
if not doc:
raise HTTPException(404, "Document not found")
_verify_doc_owner(db, doc, user)
ver = db.query(DocumentVersion).filter(
DocumentVersion.document_id == doc_id,
DocumentVersion.version_number == num,
@@ -853,10 +886,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
from src.llm_core import llm_call_async
user = get_current_user(request)
url, model, headers = resolve_task_endpoint()
url, model, headers = resolve_task_endpoint(owner=user or None)
if not url or not model:
# Fall back to default endpoint
url, model, headers = resolve_endpoint("default")
url, model, headers = resolve_endpoint("default", owner=user or None)
if not url or not model:
raise HTTPException(500, "No endpoint configured for AI tidy")
@@ -1156,7 +1189,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
settings = _load_vl_settings()
vl_model = settings.get("vision_model", "")
try:
url, model_id, headers = _resolve_vl_model(vl_model)
url, model_id, headers = _resolve_vl_model(vl_model, owner=user)
except Exception as e:
raise HTTPException(503, f"No vision model available: {e}")
@@ -1510,10 +1543,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# don't import from a routes file (cycle-prone). Same env override
# as email_routes (ODYSSEUS_MAIL_ATTACHMENTS_DIR).
from pathlib import Path as _Path
import os as _os
_DATA_DIR = _Path(__file__).resolve().parent.parent / "data"
_BASE = _os.environ.get("ODYSSEUS_MAIL_ATTACHMENTS_DIR", str(_DATA_DIR / "mail-attachments"))
_COMPOSE_DIR = _Path(_BASE) / "_compose"
_COMPOSE_DIR = _Path(MAIL_ATTACHMENTS_DIR) / "_compose"
_COMPOSE_DIR.mkdir(parents=True, exist_ok=True)
user = get_current_user(request)
@@ -1629,9 +1659,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# context (To/Subject/In-Reply-To/References).
try:
from routes.email_routes import _imap, _decode_header
from routes.email_helpers import _q
except Exception:
_imap = None
_decode_header = lambda x: x or ""
_q = lambda x: x or ""
to_addr = ""
from_name = ""
@@ -1641,7 +1673,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
if _imap:
try:
with _imap(doc.source_email_account_id or None) as conn:
conn.select(doc.source_email_folder, readonly=True)
conn.select(_q(doc.source_email_folder), readonly=True)
status, data = conn.fetch(doc.source_email_uid.encode(), "(RFC822.HEADER)")
if status == "OK" and data and data[0]:
raw_hdr = data[0][1]
+98 -33
View File
@@ -71,6 +71,38 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
smtp.sendmail(from_addr, recipients, message)
def _friendly_email_auth_error(protocol: str, host: str, error: object) -> str:
"""Return a clearer setup error for known provider auth policies."""
raw = str(error or "")
lower = raw.lower()
host_lower = (host or "").lower()
microsoft_host = any(
marker in host_lower
for marker in (
"outlook.office365.com",
"smtp.office365.com",
"office365.com",
"outlook.com",
"hotmail.com",
"live.com",
)
)
microsoft_basic_auth_failure = (
"5.7.139" in lower
or "basic authentication is disabled" in lower
or ("authenticate failed" in lower and microsoft_host)
or ("authentication unsuccessful" in lower and microsoft_host)
)
if microsoft_basic_auth_failure:
return (
"Microsoft no longer accepts normal mailbox passwords for "
"Outlook/Office 365 IMAP/SMTP in most accounts. Odysseus "
"does not support Microsoft OAuth/Graph mail yet, so Outlook "
"accounts cannot be added with this password form."
)
return raw[:200]
def _strip_think(text: str) -> str:
"""Email-flavored think strip — thin wrapper over the central helper.
@@ -254,16 +286,17 @@ def _cleanup_compose_uploads(tokens) -> None:
pass
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
SETTINGS_FILE = DATA_DIR / "settings.json"
from src.constants import DATA_DIR as _DATA_DIR, MAIL_ATTACHMENTS_DIR, SETTINGS_FILE as _SETTINGS_FILE, SCHEDULED_EMAILS_DB
DATA_DIR = Path(_DATA_DIR)
SETTINGS_FILE = Path(_SETTINGS_FILE)
# Override at deploy time via ODYSSEUS_MAIL_ATTACHMENTS_DIR. Defaults to a
# subdir of the install's data/ tree so the app works out-of-the-box without
# a hardcoded /home/<user>/ path.
ATTACHMENTS_DIR = Path(os.environ.get("ODYSSEUS_MAIL_ATTACHMENTS_DIR", str(DATA_DIR / "mail-attachments")))
ATTACHMENTS_DIR = Path(MAIL_ATTACHMENTS_DIR)
ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
COMPOSE_UPLOADS_DIR = ATTACHMENTS_DIR / "_compose"
COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
SCHEDULED_DB = DATA_DIR / "scheduled_emails.db"
SCHEDULED_DB = Path(SCHEDULED_EMAILS_DB)
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
@@ -705,7 +738,16 @@ def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int
port = int(port or 993)
if starttls:
conn = imaplib.IMAP4(host, port, timeout=timeout)
conn.starttls()
try:
conn.starttls()
except Exception:
# Don't leak the open plain socket if the STARTTLS upgrade is
# rejected; close it before propagating. (#3174)
try:
conn.shutdown()
except Exception:
pass
raise
elif port == 993:
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout)
else:
@@ -714,6 +756,10 @@ def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int
conn.sock.settimeout(timeout)
except Exception:
pass
# Raise the IMAP line-length limit from the default 1 MB to 50 MB so that
# large mailboxes (tens of thousands of messages) don't crash with
# "got more than 1000000 bytes" on UID SEARCH ALL. (#2883)
imaplib._MAXLINE = 50_000_000
return conn
def _imap_connect(account_id: str | None = None, owner: str = ""):
@@ -734,7 +780,18 @@ def _imap_connect(account_id: str | None = None, owner: str = ""):
starttls=bool(cfg.get("imap_starttls")),
timeout=_IMAP_TIMEOUT_SECONDS,
)
conn.login(cfg["imap_user"], cfg["imap_password"])
try:
conn.login(cfg["imap_user"], cfg["imap_password"])
except Exception:
# A failed AUTHENTICATE (e.g. an Office 365 app password on an
# MFA-enabled tenant, #3174) otherwise orphans the already-connected
# socket; close it before propagating so a misconfigured account
# can't leak one descriptor per retry / background poller pass.
try:
conn.shutdown()
except Exception:
pass
raise
return conn
@@ -798,20 +855,28 @@ def _imap(account_id: str | None = None, owner: str = ""):
def _decode_header(raw):
if not raw:
return ""
parts = email.header.decode_header(raw)
decoded = []
for data, charset in parts:
if isinstance(data, bytes):
try:
decoded.append(data.decode(charset or "utf-8", errors="replace"))
except (LookupError, ValueError):
# Unknown/invalid MIME charset (e.g. a malformed or spam header
# like =?x-unknown-charset?B?...?=). errors="replace" only covers
# byte-decode errors, not codec lookup, so fall back to utf-8.
decoded.append(data.decode("utf-8", errors="replace"))
else:
decoded.append(data)
return " ".join(decoded)
try:
# make_header concatenates per RFC 2047: no spurious space between an
# encoded-word and adjacent plain text (plain runs keep their own
# whitespace), and the whitespace between two adjacent encoded-words is
# dropped. The old " ".join produced "Re: Jose"-style double spaces on
# every non-ASCII subject or sender.
return str(email.header.make_header(email.header.decode_header(raw)))
except Exception:
# Malformed header or unknown/invalid MIME charset (e.g. a spam header
# like =?x-unknown-charset?B?...?=) makes make_header raise LookupError;
# fall back to a lossy per-part decode. errors="replace" only covers
# byte-decode errors, not codec lookup, hence the explicit utf-8 retry.
decoded = []
for data, charset in email.header.decode_header(raw):
if isinstance(data, bytes):
try:
decoded.append(data.decode(charset or "utf-8", errors="replace"))
except (LookupError, ValueError):
decoded.append(data.decode("utf-8", errors="replace"))
else:
decoded.append(data)
return "".join(decoded)
def _detect_sent_folder(conn):
@@ -1136,13 +1201,9 @@ def _fetch_sender_thread_context(sender_addr: str,
if exclude_uid:
seen_uids.add((exclude_folder or "INBOX", str(exclude_uid)))
conn = None
try:
conn = _imap_connect(account_id, owner=owner)
except Exception as e:
logger.warning(f"sender-thread-context: imap connect failed: {e}")
return ""
try:
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
if len(blocks) >= limit:
break
@@ -1209,11 +1270,14 @@ def _fetch_sender_thread_context(sender_addr: str,
if atts_text:
lines.append(atts_text)
blocks.append("\n".join(lines))
except Exception as e:
logger.warning(f"sender-thread-context: imap failed: {e}")
finally:
try: conn.close()
except Exception: pass
try: conn.logout()
except Exception: pass
if conn:
try: conn.close()
except Exception: pass
try: conn.logout()
except Exception: pass
if not blocks:
return ""
@@ -1316,6 +1380,7 @@ def _pre_retrieve_context(
if not terms_list:
return context_snippets, terms_list
ctx_conn = None
try:
ctx_conn = _imap_connect(account_id, owner=owner)
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
@@ -1352,12 +1417,12 @@ def _pre_retrieve_context(
except Exception as _e:
logger.warning(f" search {folder} {term!r} failed: {_e}")
continue
try:
ctx_conn.logout()
except Exception:
pass
except Exception as _e:
logger.warning(f"IMAP context search failed: {_e}")
finally:
if ctx_conn:
try: ctx_conn.logout()
except Exception: pass
try:
from routes.contacts_routes import _fetch_contacts
+2 -2
View File
@@ -210,7 +210,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if auto_cal:
for sent_name in ("Sent", "INBOX/Sent", "Sent Items", "[Gmail]/Sent Mail"):
try:
st, _ = conn.select(sent_name, readonly=True)
st, _ = conn.select(_q(sent_name), readonly=True)
if st == "OK":
folders_to_scan.append(sent_name)
break
@@ -1046,7 +1046,7 @@ def _scheduled_poll_once() -> dict:
try:
with _imap(row_account_id, owner=row_owner) as imap:
sent_folder = _detect_sent_folder(imap)
imap.append(sent_folder, "\\Seen", None, outer.as_bytes())
imap.append(_q(sent_folder), "\\Seen", None, outer.as_bytes())
except Exception as e:
logger.warning(f"Failed to append scheduled {sid} to Sent: {e}")
+6 -5
View File
@@ -32,9 +32,10 @@ from email.mime.multipart import MIMEMultipart
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
from fastapi.responses import FileResponse
from src.constants import DATA_DIR
from src.llm_core import llm_call_async
from src.upload_limits import read_upload_limited
from src.upload_limits import read_upload_limited, EMAIL_COMPOSE_UPLOAD_MAX_BYTES
from routes.email_helpers import (
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account,
@@ -47,6 +48,7 @@ from routes.email_helpers import (
_extract_attachment_to_disk, _extract_html, _extract_text,
_fetch_sender_thread_context, _pre_retrieve_context,
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
_friendly_email_auth_error,
SendEmailRequest, ExtractStyleRequest,
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
attachment_extract_dir, _email_cache_owner_clause,
@@ -56,7 +58,6 @@ from routes.email_pollers import _start_poller
logger = logging.getLogger(__name__)
ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
EMAIL_COMPOSE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
@@ -2904,7 +2905,7 @@ def setup_email_routes():
from pathlib import Path as _P
import json as _json
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
path = _P(f"data/email_urgency_state_{_slug}.json")
path = _P(DATA_DIR) / f"email_urgency_state_{_slug}.json"
if not path.exists():
return {"total_unread": 0, "total_urgent": 0, "max_score": 0, "per_uid": {}}
try:
@@ -3162,7 +3163,7 @@ def setup_email_routes():
try: conn.logout()
except Exception: pass
except Exception as e:
imap_result = {"ok": False, "error": str(e)[:200]}
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
smtp_host = (body.get("smtp_host") or "").strip()
if smtp_host:
@@ -3184,7 +3185,7 @@ def setup_email_routes():
try: smtp.quit()
except Exception: pass
except Exception as e:
smtp_result = {"ok": False, "error": str(e)[:200]}
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
return {
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
+65 -22
View File
@@ -7,12 +7,12 @@ import logging
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException, Form, Depends
from core.constants import BASE_DIR
from core.constants import EMBEDDING_ENDPOINT_FILE, FASTEMBED_CACHE_DIR
from core.middleware import require_admin
logger = logging.getLogger(__name__)
_ENDPOINT_FILE = os.path.join(BASE_DIR, "data", "embedding_endpoint.json")
_ENDPOINT_FILE = EMBEDDING_ENDPOINT_FILE
# Track in-progress downloads
_downloading: dict = {}
@@ -35,13 +35,7 @@ def _cache_dir() -> str:
default lived in /tmp, which many systems wipe on reboot forcing a
full re-download of the embedding model after every restart.
"""
env = os.environ.get("FASTEMBED_CACHE_PATH")
if env:
return env
return os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "fastembed_cache",
)
return FASTEMBED_CACHE_DIR
def _model_cache_name(hf_source: str) -> str:
@@ -49,19 +43,35 @@ def _model_cache_name(hf_source: str) -> str:
return "models--" + hf_source.replace("/", "--")
def _model_cache_path(hf_source: str) -> Path:
"""Return a confined cache path for a fastembed HF source."""
root = Path(_cache_dir()).expanduser().resolve()
raw_path = root / _model_cache_name(hf_source)
if raw_path.is_symlink():
raise ValueError("Model cache path must not be a symlink")
path = raw_path.resolve(strict=False)
try:
path.relative_to(root)
except ValueError:
raise ValueError("Model cache path escapes cache root")
return path
def _is_downloaded(hf_source: str) -> bool:
"""Check if a model is already cached."""
cache = _cache_dir()
model_dir = os.path.join(cache, _model_cache_name(hf_source))
if not os.path.isdir(model_dir):
try:
model_dir = _model_cache_path(hf_source)
except ValueError:
return False
if not model_dir.is_dir():
return False
# Check for actual model files (not just empty dir)
snapshots = os.path.join(model_dir, "snapshots")
if os.path.isdir(snapshots):
return any(os.listdir(snapshots))
snapshots = model_dir / "snapshots"
if snapshots.is_dir():
return any(snapshots.iterdir())
# Also check for blobs (older cache format)
blobs = os.path.join(model_dir, "blobs")
return os.path.isdir(blobs) and any(os.listdir(blobs))
blobs = model_dir / "blobs"
return blobs.is_dir() and any(blobs.iterdir())
def _active_model() -> str:
@@ -119,8 +129,10 @@ def setup_embedding_routes():
cached_size = None
if downloaded and hf_src:
model_path = os.path.join(_cache_dir(), _model_cache_name(hf_src))
cached_size = _dir_size_mb(model_path)
try:
cached_size = _dir_size_mb(str(_model_cache_path(hf_src)))
except ValueError:
cached_size = None
result.append({
"model": m["model"],
@@ -217,8 +229,11 @@ def setup_embedding_routes():
if not hf_src:
raise HTTPException(400, "No cache source for this model")
model_path = os.path.join(_cache_dir(), _model_cache_name(hf_src))
if not os.path.isdir(model_path):
try:
model_path = _model_cache_path(hf_src)
except ValueError as e:
raise HTTPException(400, str(e))
if not model_path.is_dir():
return {"deleted": False, "message": "Model not cached"}
shutil.rmtree(model_path)
@@ -237,7 +252,7 @@ def setup_embedding_routes():
}
@router.post("/endpoint")
def set_endpoint(url: str = Form(...), model: str = Form("")):
def set_endpoint(url: str = Form(...), model: str = Form(""), api_key: str = Form("")):
"""Save a custom embedding endpoint URL."""
url = url.strip()
if not url:
@@ -261,6 +276,7 @@ def setup_embedding_routes():
resp = httpx.post(
url,
json={"input": ["test"], "model": model or "test"},
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
timeout=10,
)
resp.raise_for_status()
@@ -271,10 +287,16 @@ def setup_embedding_routes():
data = {"url": url}
if model:
data["model"] = model
if api_key:
from src.secret_storage import encrypt
data["api_key"] = encrypt(api_key)
_save_custom_endpoint(data)
os.environ["EMBEDDING_URL"] = url
if model:
os.environ["EMBEDDING_MODEL"] = model
if api_key:
os.environ["EMBEDDING_API_KEY"] = api_key
# Reset the RAG singleton so it picks up the new endpoint
import src.rag_singleton as _rs
@@ -288,6 +310,16 @@ def setup_embedding_routes():
reset_http_embed_state()
except Exception:
pass
try:
from src.embedding_lanes import reset_embedding_lane_state
reset_embedding_lane_state()
except Exception:
pass
try:
from src.tool_index import reset_tool_index
reset_tool_index()
except Exception:
pass
# Reset ChromaDB client (collections will be recreated with new embeddings)
try:
@@ -308,6 +340,7 @@ def setup_embedding_routes():
# Remove from environment
os.environ.pop("EMBEDDING_URL", None)
os.environ.pop("EMBEDDING_MODEL", None)
os.environ.pop("EMBEDDING_API_KEY", None)
# Reset the RAG singleton so it falls back to fastembed
import src.rag_singleton as _rs
@@ -318,6 +351,16 @@ def setup_embedding_routes():
reset_http_embed_state()
except Exception:
pass
try:
from src.embedding_lanes import reset_embedding_lane_state
reset_embedding_lane_state()
except Exception:
pass
try:
from src.tool_index import reset_tool_index
reset_tool_index()
except Exception:
pass
# Reset ChromaDB client
try:
+45 -6
View File
@@ -16,22 +16,54 @@ from pathlib import Path
import httpx
from fastapi import APIRouter
from fastapi.responses import FileResponse, Response
from fastapi.responses import Response
from src.constants import EMOJI_CACHE_DIR
logger = logging.getLogger(__name__)
_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "emoji_cache"
_CACHE_DIR = Path(EMOJI_CACHE_DIR)
# OpenMoji "black" set = monochrome line-art SVGs. Filenames are the codepoints
# in UPPERCASE (FE0F dropped, same as we compute), '-' joined.
_OPENMOJI_BASE = "https://cdn.jsdelivr.net/npm/openmoji@15.0.0/black/svg"
# codepoints like "1f600" or "1f468-200d-1f469-200d-1f467" (lowercase hex, '-' joined)
_CODE_RE = re.compile(r"^[0-9a-f]{2,6}(?:-[0-9a-f]{2,6})*$")
_SVG_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
_MAX_SVG_BYTES = 256 * 1024
_BLOCKED_SVG_RE = re.compile(
br"<\s*(?:script|foreignObject|iframe|object|embed|image)\b|"
br"\bon[a-z0-9_-]+\s*=",
re.IGNORECASE,
)
_EXTERNAL_REF_RE = re.compile(
br"\b(?:href|xlink:href)\s*=\s*['\"](?:https?:|//|data:|javascript:)",
re.IGNORECASE,
)
_SVG_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox",
"Cross-Origin-Resource-Policy": "same-origin",
}
_SVG_HEADERS = {
"Cache-Control": "public, max-age=31536000, immutable",
**_SVG_SECURITY_HEADERS,
}
# Returned when a codepoint is unknown/unreachable: an empty (transparent) SVG,
# so the CSS mask renders nothing instead of a solid box. Not cached, so a later
# request can still pick up the real glyph once the CDN is reachable.
_BLANK_SVG = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
_BLANK_HEADERS = {"Cache-Control": "no-store"}
_BLANK_HEADERS = {"Cache-Control": "no-store", **_SVG_SECURITY_HEADERS}
def _is_safe_svg(content: bytes) -> bool:
if not isinstance(content, bytes) or not content:
return False
if len(content) > _MAX_SVG_BYTES:
return False
if b"<svg" not in content[:256].lower():
return False
if _BLOCKED_SVG_RE.search(content) or _EXTERNAL_REF_RE.search(content):
return False
return True
def setup_emoji_routes() -> APIRouter:
@@ -49,14 +81,21 @@ def setup_emoji_routes() -> APIRouter:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
fp = _CACHE_DIR / f"{code}.svg"
if fp.exists():
return FileResponse(fp, media_type="image/svg+xml", headers=_SVG_HEADERS)
try:
content = fp.read_bytes()
if _is_safe_svg(content):
return Response(content, media_type="image/svg+xml", headers=_SVG_HEADERS)
fp.unlink(missing_ok=True)
except Exception as e:
logger.warning("emoji cache read %s failed: %s", code, e)
return _blank()
# First time we've seen this emoji — fetch the OpenMoji black SVG + cache
# it. OpenMoji filenames are the codepoints uppercased.
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(f"{_OPENMOJI_BASE}/{code.upper()}.svg")
if r.status_code == 200 and b"<svg" in r.content[:256]:
if r.status_code == 200 and _is_safe_svg(r.content):
try:
fp.write_bytes(r.content)
except Exception:
+145 -54
View File
@@ -12,8 +12,13 @@ from fastapi import APIRouter, HTTPException, Query, Request
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession
from src.auth_helpers import get_current_user, require_privilege
from src.upload_limits import read_upload_limited
from src.auth_helpers import get_current_user, owner_filter, require_privilege
from src.upload_limits import (
read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES,
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
)
from src.constants import GENERATED_IMAGES_DIR
from routes.gallery_helpers import (
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
@@ -21,17 +26,88 @@ from routes.gallery_helpers import (
logger = logging.getLogger(__name__)
GALLERY_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024)))
def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user:
return False
auth_mgr = getattr(request.app.state, "auth_manager", None)
is_admin = getattr(auth_mgr, "is_admin", None)
if not callable(is_admin):
return False
try:
return bool(is_admin(user))
except Exception:
return False
def _sanitize_gallery_filename(filename: str) -> str:
"""Return a local filename safe to join under generated_images."""
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename or "").name)[:128]
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(str(filename or "")).name)[:128]
if not safe_name or safe_name in {".", ".."}:
safe_name = uuid.uuid4().hex[:12]
return safe_name
GALLERY_IMAGE_DIR = Path(GENERATED_IMAGES_DIR)
def _gallery_image_path(filename: str) -> Path:
"""Resolve a stored gallery filename without leaving generated_images."""
if not isinstance(filename, str):
raise HTTPException(400, "Unsafe gallery filename")
safe_name = _sanitize_gallery_filename(filename)
original = str(filename or "")
root = GALLERY_IMAGE_DIR.resolve()
path = (GALLERY_IMAGE_DIR / safe_name).resolve()
try:
if os.path.commonpath([str(root), str(path)]) != str(root):
raise ValueError
except Exception:
raise HTTPException(400, "Unsafe gallery filename")
if safe_name != original:
raise HTTPException(400, "Unsafe gallery filename")
return path
def _normalize_image_endpoint_base(url: str) -> str:
base = (url or "").strip().rstrip("/")
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
return base
def _visible_image_endpoint_query(db, owner: str | None):
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(
ModelEndpoint.model_type == "image",
ModelEndpoint.is_enabled == True, # noqa: E712
)
return owner_filter(q, ModelEndpoint, owner)
def _first_visible_image_endpoint(db, owner: str | None):
endpoints = _visible_image_endpoint_query(db, owner).all()
if owner:
for ep in endpoints:
if getattr(ep, "owner", None) == owner:
return ep
return endpoints[0] if endpoints else None
def _visible_image_endpoint_for_base(db, base: str, owner: str | None):
target = _normalize_image_endpoint_base(base)
if not target:
return None
fallback = None
for ep in _visible_image_endpoint_query(db, owner).all():
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
if owner and getattr(ep, "owner", None) == owner:
return ep
if fallback is None:
fallback = ep
return fallback
def setup_gallery_routes() -> APIRouter:
router = APIRouter(tags=["gallery"])
@@ -55,6 +131,9 @@ def setup_gallery_routes() -> APIRouter:
file_hash = hashlib.sha256(content).hexdigest()
db = SessionLocal()
try:
if album_id and user is not None:
_get_or_404_album(db, album_id, user)
# SECURITY: scope the dup-detect to THIS user — otherwise a
# caller can probe whether someone else uploaded the same
# file (the response leaks the existing row's id+filename).
@@ -69,7 +148,7 @@ def setup_gallery_routes() -> APIRouter:
return {"ok": False, "duplicate": True, "filename": existing.filename,
"id": existing.id, "message": "Duplicate photo skipped"}
img_dir = Path("data/generated_images")
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "png"
@@ -135,7 +214,7 @@ def setup_gallery_routes() -> APIRouter:
raise HTTPException(400, "No image provided")
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
img_dir = Path("data/generated_images")
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
img_path = img_dir / _sanitize_gallery_filename(img.filename)
img_path.write_bytes(content)
@@ -211,7 +290,7 @@ def setup_gallery_routes() -> APIRouter:
if not user or img.owner != user:
raise HTTPException(403, "Not your image")
img_path = Path("data/generated_images") / img.filename
img_path = _gallery_image_path(img.filename)
if not img_path.exists():
raise HTTPException(404, "Image file not found")
@@ -248,7 +327,7 @@ def setup_gallery_routes() -> APIRouter:
"""AI upscale using img2img with the diffusion server."""
import base64, httpx
require_privilege(request, "can_generate_images")
user = require_privilege(request, "can_generate_images")
form = await request.form()
file = form.get("image")
if not file: raise HTTPException(400, "No image")
@@ -260,7 +339,7 @@ def setup_gallery_routes() -> APIRouter:
# Find image endpoint
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.model_type == "image", ModelEndpoint.is_enabled == True).first()
ep = _first_visible_image_endpoint(db, user)
finally:
db.close()
@@ -291,7 +370,7 @@ def setup_gallery_routes() -> APIRouter:
"""Style transfer using img2img with the diffusion server."""
import base64, httpx
require_privilege(request, "can_generate_images")
user = require_privilege(request, "can_generate_images")
form = await request.form()
file = form.get("image")
prompt = form.get("prompt", "")
@@ -303,7 +382,7 @@ def setup_gallery_routes() -> APIRouter:
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.model_type == "image", ModelEndpoint.is_enabled == True).first()
ep = _first_visible_image_endpoint(db, user)
finally:
db.close()
@@ -505,18 +584,24 @@ def setup_gallery_routes() -> APIRouter:
albums = q.order_by(GalleryAlbum.created_at.desc()).all()
result = []
for a in albums:
count = db.query(GalleryImage).filter(
_count_q = db.query(GalleryImage).filter(
GalleryImage.album_id == a.id, GalleryImage.is_active == True
).count()
)
if user:
_count_q = _count_q.filter(GalleryImage.owner == user)
count = _count_q.count()
cover_url = None
if a.cover_id:
cover = db.query(GalleryImage).filter(GalleryImage.id == a.cover_id).first()
if cover:
cover_url = f"/api/generated-image/{cover.filename}"
elif count > 0:
first = db.query(GalleryImage).filter(
_cover_q = db.query(GalleryImage).filter(
GalleryImage.album_id == a.id, GalleryImage.is_active == True
).order_by(GalleryImage.created_at.desc()).first()
)
if user:
_cover_q = _cover_q.filter(GalleryImage.owner == user)
first = _cover_q.order_by(GalleryImage.created_at.desc()).first()
if first:
cover_url = f"/api/generated-image/{first.filename}"
result.append({
@@ -649,7 +734,14 @@ def setup_gallery_routes() -> APIRouter:
if req.favorite is not None:
img.favorite = req.favorite
if req.album_id is not None:
img.album_id = req.album_id if req.album_id else None
if req.album_id:
# Validate the target album belongs to the caller before
# moving the image into it — mirrors add_to_album, so you
# cannot file your image into another user's album.
_get_or_404_album(db, req.album_id, user)
img.album_id = req.album_id
else:
img.album_id = None
db.commit()
db.refresh(img)
return _image_to_dict(img)
@@ -692,11 +784,11 @@ def setup_gallery_routes() -> APIRouter:
used = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for img in imgs:
src = os.path.join("data", "generated_images", img.filename)
if not os.path.exists(src):
src = _gallery_image_path(img.filename)
if not src.exists():
continue
ext = os.path.splitext(img.filename)[1] or ".png"
base = (img.prompt or "").strip() or os.path.splitext(img.filename)[0]
ext = src.suffix or ".png"
base = (img.prompt or "").strip() or src.stem
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or img.id
name = f"{base}{ext}"
i = 1
@@ -818,9 +910,9 @@ def setup_gallery_routes() -> APIRouter:
img_filename = img.filename
# Remove the file from disk
img_path = os.path.join("data", "generated_images", img_filename)
if os.path.exists(img_path):
os.remove(img_path)
img_path = _gallery_image_path(img_filename)
if img_path.exists():
img_path.unlink()
# Soft-delete the record
img.is_active = False
@@ -923,7 +1015,7 @@ def setup_gallery_routes() -> APIRouter:
the request for /v1/images/edits (multipart, inverted mask). Otherwise
proxy through to a self-hosted diffusion server's /v1/images/inpaint."""
import httpx
require_privilege(request, "can_generate_images")
user = require_privilege(request, "can_generate_images")
body = await request.json()
# Use endpoint from request body (editor dropdown) or fall back to DB lookup
base = (body.pop("_endpoint", "") or "").rstrip("/")
@@ -942,14 +1034,11 @@ def setup_gallery_routes() -> APIRouter:
if not base:
db = SessionLocal()
try:
eps = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.model_type == "image",
).all()
if not eps:
ep = _first_visible_image_endpoint(db, user)
if not ep:
raise HTTPException(400, "No image generation endpoint configured. Serve a diffusion model via Cookbook first.")
base = eps[0].base_url.rstrip("/")
api_key = eps[0].api_key
base = ep.base_url.rstrip("/")
api_key = ep.api_key
finally:
db.close()
else:
@@ -966,10 +1055,12 @@ def setup_gallery_routes() -> APIRouter:
_target = _norm_url(base)
db = SessionLocal()
try:
for ep in db.query(ModelEndpoint).all():
if _norm_url(ep.base_url) == _target:
api_key = ep.api_key
break
ep = _visible_image_endpoint_for_base(db, _target, user)
if ep:
base = (ep.base_url or base).rstrip("/")
api_key = ep.api_key
elif user and not _current_user_is_admin(request, user):
raise HTTPException(403, "Choose a registered image endpoint")
finally:
db.close()
@@ -1121,7 +1212,7 @@ def setup_gallery_routes() -> APIRouter:
you get edge blending + lighting unification while keeping the
composition recognisable."""
import httpx, base64 as _b64
require_privilege(request, "can_generate_images")
user = require_privilege(request, "can_generate_images")
body = await request.json()
image_b64 = body.get("image")
@@ -1148,23 +1239,22 @@ def setup_gallery_routes() -> APIRouter:
if not base:
db = SessionLocal()
try:
eps = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.model_type == "image",
).all()
if not eps:
ep = _first_visible_image_endpoint(db, user)
if not ep:
raise HTTPException(400, "No image generation endpoint configured.")
base = eps[0].base_url.rstrip("/")
api_key = eps[0].api_key
base = ep.base_url.rstrip("/")
api_key = ep.api_key
finally:
db.close()
else:
db = SessionLocal()
try:
for ep in db.query(ModelEndpoint).all():
if ep.base_url.rstrip("/").removesuffix("/v1").rstrip("/") == base.rstrip("/").removesuffix("/v1").rstrip("/"):
api_key = ep.api_key
break
ep = _visible_image_endpoint_for_base(db, base, user)
if ep:
base = (ep.base_url or base).rstrip("/")
api_key = ep.api_key
elif user and not _current_user_is_admin(request, user):
raise HTTPException(403, "Choose a registered image endpoint")
finally:
db.close()
@@ -1316,6 +1406,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/image/sharpen")
async def sharpen_image(request: Request):
"""Apply unsharp-mask sharpening to an image."""
require_privilege(request, "can_generate_images")
body = await request.json()
image_b64 = body.get("image")
amount = body.get("amount", 50) / 100.0
@@ -1635,9 +1726,10 @@ def setup_gallery_routes() -> APIRouter:
db = SessionLocal()
try:
album = _get_or_404_album(db, album_id, user)
db.query(GalleryImage).filter(GalleryImage.album_id == album_id).update(
{"album_id": None}, synchronize_session=False
)
q = db.query(GalleryImage).filter(GalleryImage.album_id == album_id)
if user is not None:
q = q.filter(GalleryImage.owner == user)
q.update({"album_id": None}, synchronize_session=False)
db.delete(album)
db.commit()
return {"ok": True}
@@ -1708,7 +1800,7 @@ def setup_gallery_routes() -> APIRouter:
try:
img = _get_or_404_image(db, image_id, user)
img_path = Path("data/generated_images") / img.filename
img_path = _gallery_image_path(img.filename)
if not img_path.exists():
raise HTTPException(404, "Image file not found")
@@ -1726,7 +1818,7 @@ def setup_gallery_routes() -> APIRouter:
return {"error": "Vision is disabled — enable it in Settings → Vision"}
configured = vl_settings.get("vision_model", "")
try:
chat_url, model_name, headers = _resolve_vl_model(configured)
chat_url, model_name, headers = _resolve_vl_model(configured, owner=user)
except ValueError:
return {"error": "No vision model configured — set one in Settings → Vision"}
if not chat_url:
@@ -1807,4 +1899,3 @@ def setup_gallery_routes() -> APIRouter:
db.close()
return router
+10 -2
View File
@@ -490,7 +490,13 @@ def setup_history_routes(session_manager) -> APIRouter:
# Copy messages up to keep_count
msgs_to_copy = source.history[:keep_count]
for msg in msgs_to_copy:
new_session.add_message(ChatMessage(msg.role, msg.content, msg.metadata))
# Copy the metadata dict. Sharing it would let the fork's
# persistence (add_message -> _persist_message stamps
# _db_id/timestamp onto the dict) mutate the SOURCE session's
# in-memory messages, corrupting their _db_id and breaking
# edit/delete-by-id on the original conversation.
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
try:
from src.event_bus import fire_event
fire_event("session_created", getattr(source, 'owner', None))
@@ -522,6 +528,8 @@ def setup_history_routes(session_manager) -> APIRouter:
async def compact_session(request: Request, session_id: str):
"""Manually trigger context compaction for a session."""
_verify_session_owner(request, session_id)
from src.auth_helpers import effective_user
owner = effective_user(request)
try:
session = session_manager.get_session(session_id)
except KeyError:
@@ -555,7 +563,7 @@ def setup_history_routes(session_manager) -> APIRouter:
)
# Use utility model if available
util_url, util_model, util_headers = resolve_endpoint("utility")
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
compact_url = util_url or session.endpoint_url
compact_model = util_model or session.model
compact_headers = util_headers if util_url else session.headers
+18 -1
View File
@@ -196,7 +196,24 @@ def setup_hwfit_routes():
if target_context is not None:
target_context = max(1024, min(target_context, 1000000))
results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context, fit_only=fit_only)
rank_kwargs = {
"use_case": use_case or None,
"limit": limit,
"search": search or None,
"sort": sort,
"quant": quant or None,
"fit_only": fit_only,
}
if target_context is not None:
rank_kwargs["target_context"] = target_context
try:
import inspect
supported = set(inspect.signature(rank_models).parameters)
rank_kwargs = {k: v for k, v in rank_kwargs.items() if k in supported}
except Exception:
rank_kwargs.pop("target_context", None)
rank_kwargs.pop("fit_only", None)
results = rank_models(system, **rank_kwargs)
return {"system": system, "models": results}
@router.get("/profiles")
+2 -2
View File
@@ -13,7 +13,7 @@ import httpx
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from src.constants import DATA_DIR
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _mcp_oauth_base_dir() -> Path:
"""Directory that may contain OAuth files managed by Odysseus."""
return (Path(DATA_DIR) / "mcp_oauth").resolve(strict=False)
return Path(MCP_OAUTH_DIR).resolve(strict=False)
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
+2 -3
View File
@@ -29,11 +29,10 @@ from src.llm_core import llm_call_async
from services.memory.memory_extractor import audit_memories
from src.auth_helpers import get_current_user, require_user
from src.endpoint_resolver import resolve_endpoint
from src.upload_limits import read_upload_limited
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
logger = logging.getLogger(__name__)
MEMORY_IMPORT_MAX_BYTES = int(os.getenv("ODYSSEUS_MEMORY_IMPORT_MAX_BYTES", str(10 * 1024 * 1024)))
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
"""Set up memory-related routes."""
@@ -371,7 +370,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
tmp.write(content)
tmp_path = tmp.name
try:
text = _process_pdf(tmp_path)
text = _process_pdf(tmp_path, owner=_owner(request))
finally:
os.unlink(tmp_path)
else:
+162 -17
View File
@@ -4,6 +4,7 @@ import os
import re
import uuid
import json
import hashlib
import socket
import time as _time
import logging
@@ -502,9 +503,71 @@ def _is_chat_model(model_id: str) -> bool:
return True
def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Optional[str] = None) -> bool:
"""Delete a ProviderAuthSession once no endpoint still references it."""
if not auth_id:
return False
from core.database import ProviderAuthSession
still_referenced = db.query(ModelEndpoint.id).filter(
ModelEndpoint.provider_auth_id == auth_id,
ModelEndpoint.id != exclude_ep_id,
).first()
if still_referenced is not None:
return False
auth_row = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == auth_id).first()
if auth_row is None:
return False
db.delete(auth_row)
return True
def _safe_detect_provider(base_url: str) -> str:
"""Best-effort provider detection that must not break endpoint probing."""
try:
return _detect_provider(base_url)
except Exception as exc:
logger.debug("Provider detection failed for %s: %s", base_url, exc)
return ""
def _safe_build_models_url(base_url: str) -> str:
"""Build a /models URL without letting optional provider imports break probes."""
try:
return build_models_url(base_url)
except Exception as exc:
logger.debug("Model URL detection failed for %s: %s", base_url, exc)
return f"{(base_url or '').rstrip('/')}/models"
def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
"""Build auth headers without letting optional provider imports break probes."""
try:
return build_headers(api_key, base_url)
except Exception as exc:
logger.debug("Header detection failed for %s: %s", base_url, exc)
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
def _is_discovery_only_provider(provider: str) -> bool:
return provider == "chatgpt-subscription"
def _resolve_probe_key(ep) -> Optional[str]:
"""API key/bearer to probe an endpoint with."""
try:
from src.endpoint_resolver import resolve_endpoint_runtime
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
return key
except Exception as exc:
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc)
return None
def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 10, with_tools: bool = False) -> dict:
"""Send a realistic completion request to a single model. Returns {status, latency_ms, error?}."""
provider = _detect_provider(base)
provider = _safe_detect_provider(base)
if _is_discovery_only_provider(provider):
return {"status": "ok", "latency_ms": 0, "skipped": True}
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say OK"},
@@ -523,12 +586,12 @@ def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 1
elif provider == "ollama":
from src.llm_core import _build_ollama_payload
target_url = build_chat_url(base)
h = build_headers(api_key, base)
h = _safe_build_headers(api_key, base)
h["Content-Type"] = "application/json"
payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools)
else:
target_url = build_chat_url(base)
h = build_headers(api_key, base)
h = _safe_build_headers(api_key, base)
h["Content-Type"] = "application/json"
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
_max_key = "max_completion_tokens" if _uses_max_completion_tokens(model_id) else "max_tokens"
@@ -618,9 +681,15 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
from src.endpoint_resolver import resolve_url
base = resolve_url(_normalize_base(base_url))
if _detect_provider(base) == "anthropic":
provider = _safe_detect_provider(base)
if provider == "chatgpt-subscription":
from src.chatgpt_subscription import fetch_available_models
if api_key:
return fetch_available_models(api_key, timeout=timeout)
return []
if provider == "anthropic":
# Try Anthropic's /v1/models endpoint first
url = build_models_url(base)
url = _safe_build_models_url(base)
headers = {"anthropic-version": "2023-06-01"}
if api_key:
headers["x-api-key"] = api_key
@@ -643,8 +712,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
return []
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
return list(ANTHROPIC_MODELS)
url = build_models_url(base)
headers = build_headers(api_key, base)
url = _safe_build_models_url(base)
headers = _safe_build_headers(api_key, base)
try:
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
r.raise_for_status()
@@ -702,7 +771,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
"""Reachability probe that does not require installed/listed models."""
from src.endpoint_resolver import resolve_url
base = resolve_url(_normalize_base(base_url))
headers = build_headers(api_key, base)
headers = _safe_build_headers(api_key, base)
# Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version,
# /api/tags. Probe native paths for Ollama-style endpoints, but avoid using
@@ -754,7 +823,22 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
try:
r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify())
return _result_from_response(r)
result = _result_from_response(r)
if result["reachable"]:
return result
sc = result.get("status_code") or 0
if 400 <= sc < 500 and sc not in (401, 403):
models_url = _safe_build_models_url(base)
try:
r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify())
result2 = _result_from_response(r2)
if result2["reachable"]:
return result2
except Exception:
pass
if sc:
return result
last_error = result.get("error") or last_error
except Exception as e:
last_error = str(e)[:120]
@@ -850,6 +934,14 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
return [m for m in merged if m not in hidden]
def _api_key_fingerprint(api_key: Optional[str]) -> str:
"""Stable, non-secret label for distinguishing same-URL credentials."""
key = (api_key or "").strip()
if not key:
return ""
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
def setup_model_routes(model_discovery):
router = APIRouter(prefix="/api")
@@ -1028,7 +1120,7 @@ def setup_model_routes(model_discovery):
for ep in endpoints:
base = _normalize_base(ep.base_url)
provider = _detect_provider(base)
provider = _safe_detect_provider(base)
# Merge cached + pinned models, then filter out hidden ones
ep_model_type = getattr(ep, "model_type", None) or "llm"
model_ids = _visible_models(
@@ -1104,8 +1196,9 @@ def setup_model_routes(model_discovery):
raise HTTPException(401, "Not authenticated")
except HTTPException:
raise
except Exception:
pass
except Exception as e:
logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
raise HTTPException(status_code=500, detail="Internal error")
# Admins see every endpoint (they manage the global pool); regular
# users get the owner-scoped view.
_is_admin = False
@@ -1169,7 +1262,14 @@ def setup_model_routes(model_discovery):
t0 = _time.time()
try:
import asyncio as _asyncio
ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 1.5)
# Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping
# local vLLM endpoints on Tailscale links where the model
# server is still loading (Qwen3.5-122B takes 23 min to
# warm); /v1/models can take 5002500 ms on a busy box,
# which pushed _ping_endpoint's full path-discovery sweep
# past the cap and marked the row offline despite the
# user actively chatting with it.
ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 3.5)
lat = round((_time.time() - t0) * 1000)
return {
"alive": bool(ping.get("reachable")),
@@ -1207,7 +1307,7 @@ def setup_model_routes(model_discovery):
results = []
for ep in endpoints:
base = _normalize_base(ep.base_url)
provider = _detect_provider(base)
provider = _safe_detect_provider(base)
kind = _effective_endpoint_kind(ep, base)
cached_count = len(_cached_model_ids(ep))
entry = {
@@ -1386,10 +1486,35 @@ def setup_model_routes(model_discovery):
# admin-pinned IDs that a probe would never surface.
status = "online" if (all_models or pinned) else "offline"
ping = None
# When cached_models is empty, do a quick reachability probe.
# Bumped 1.0s → 3.5s because the user reported endpoints they
# were ACTIVELY chatting with showed "offline" — the previous
# 1s timeout was clipping live cloud endpoints (DeepSeek can
# take 1.52.5s on /v1/models when their region is under load,
# vLLM on a remote GPU box behind SSH can also push past 1s).
# 3.5s still keeps the picker render snappy in the common
# "everything's already cached" path because this branch only
# runs for endpoints with an empty cached_models.
if not all_models and not pinned and r.is_enabled:
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
ping = _ping_endpoint(r.base_url, r.api_key, timeout=3.5)
if ping.get("reachable"):
status = "empty"
# Best-effort: if the probe came back reachable, try
# to populate cached_models in the background so the
# NEXT picker load shows "online" instead of "empty".
# Failure here is silent — we already returned the
# "empty" status, and the existing background refresh
# path will eventually fill it in too.
try:
probed = _probe_endpoint(r.base_url, r.api_key, timeout=5)
if probed:
r.cached_models = json.dumps(probed)
db.commit()
all_models = probed
visible = _visible_models(all_models, r.hidden_models, pinned)
status = "online"
except Exception as _refill_err:
logger.debug(f"opportunistic cached_models refill failed for {r.id}: {_refill_err!r}")
base = _normalize_base(r.base_url)
kind = _effective_endpoint_kind(r, base)
results.append({
@@ -1397,6 +1522,7 @@ def setup_model_routes(model_discovery):
"name": r.name,
"base_url": r.base_url,
"has_key": bool(r.api_key),
"api_key_fingerprint": _api_key_fingerprint(r.api_key),
"is_enabled": r.is_enabled,
"models": visible,
"pinned_models": pinned,
@@ -1469,15 +1595,27 @@ def setup_model_routes(model_discovery):
# re-adding manually-added endpoints under their host:port name.
from src.auth_helpers import get_current_user as _gcu_dedup
_caller = _gcu_dedup(request) or None
_incoming_api_key = api_key.strip()
_db_dedup = SessionLocal()
try:
existing = (
_same_url_rows = (
_db_dedup.query(ModelEndpoint)
.filter(ModelEndpoint.base_url == base_url)
.filter((ModelEndpoint.owner.is_(None)) | (ModelEndpoint.owner == _caller))
.order_by(ModelEndpoint.owner.desc()) # prefer owned over shared
.first()
.all()
)
existing = None
_empty_key_existing = None
for _candidate in _same_url_rows:
_candidate_key = (getattr(_candidate, "api_key", None) or "").strip()
if _candidate_key == _incoming_api_key:
existing = _candidate
break
if _incoming_api_key and not _candidate_key and _empty_key_existing is None:
_empty_key_existing = _candidate
if existing is None and _incoming_api_key and _empty_key_existing is not None:
existing = _empty_key_existing
if existing:
changed = False
# Persist any incoming pinned IDs onto the existing row. An
@@ -1526,6 +1664,8 @@ def setup_model_routes(model_discovery):
"id": existing.id,
"name": existing.name,
"base_url": existing.base_url,
"has_key": bool(existing.api_key),
"api_key_fingerprint": _api_key_fingerprint(existing.api_key),
"models": _visible_models(
existing_models,
getattr(existing, "hidden_models", None),
@@ -1599,6 +1739,8 @@ def setup_model_routes(model_discovery):
"id": ep_id,
"name": name.strip(),
"base_url": base_url,
"has_key": bool(api_key.strip()),
"api_key_fingerprint": _api_key_fingerprint(api_key),
"models": _merge_model_ids(model_ids, _pinned),
"pinned_models": _pinned,
"online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")),
@@ -2049,7 +2191,9 @@ def setup_model_routes(model_discovery):
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
auth_id = getattr(ep, "provider_auth_id", None)
db.delete(ep)
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
db.commit()
_invalidate_models_cache()
_local_probe_cache["data"] = None
@@ -2059,6 +2203,7 @@ def setup_model_routes(model_discovery):
"cleared_user_preferences": cleared_user_preferences,
"cleared_sessions": cleared_sessions,
"cleared_loaded_sessions": cleared_loaded_sessions,
"cleared_provider_auth": cleared_provider_auth,
}
finally:
db.close()
+161 -16
View File
@@ -11,6 +11,7 @@ from pydantic import BaseModel
from core.database import SessionLocal, Note
from src.auth_helpers import get_current_user
from src.constants import DATA_DIR
from sqlalchemy.orm.attributes import flag_modified
logger = logging.getLogger(__name__)
@@ -95,6 +96,32 @@ def _note_to_dict(note: Note) -> Dict[str, Any]:
}
def _reminder_text_from_note(note: Note) -> tuple[str, str]:
"""Return the reminder title/body from a stored note row."""
title = (note.title or "Note reminder").strip() or "Note reminder"
if note.items:
try:
items = json.loads(note.items)
except (json.JSONDecodeError, TypeError):
items = None
if isinstance(items, list):
pending: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
if item.get("done") or item.get("checked"):
continue
text = str(item.get("text") or "").strip()
if text:
pending.append(text)
if pending:
shown = "\n".join(f"- {text}" for text in pending[:8])
extra = f"\n...and {len(pending) - 8} more" if len(pending) > 8 else ""
return title, f"Pending ({len(pending)}):\n{shown}{extra}"
return title, f"{len(items)} item{'s' if len(items) != 1 else ''}"
return title, (note.content or "").strip()[:400]
# ---------------------------------------------------------------------------
# Reminder dispatch — module-level so background tasks (built-in actions)
@@ -114,8 +141,9 @@ async def dispatch_reminder(
note_id: str,
owner: str = "",
queue_browser: bool = True,
settings_override: dict | None = None,
) -> dict:
"""Fire a reminder via the configured channel (browser/email/ntfy).
"""Fire a reminder via the configured channel (browser/email/ntfy/webhook).
Args:
title: short headline shown to the user
@@ -129,7 +157,7 @@ async def dispatch_reminder(
nothing is "sent" synchronously for it the channel just routes there.
"""
from src.settings import load_settings
settings = load_settings()
settings = {**load_settings(), **(settings_override or {})}
channel = settings.get("reminder_channel", "browser")
llm_on = bool(settings.get("reminder_llm_synthesis", False))
title = (title or "").strip()
@@ -143,7 +171,7 @@ async def dispatch_reminder(
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
from pathlib import Path as _P
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
cache_path = _P(f"data/note_pings_{_slug}.json")
cache_path = _P(DATA_DIR) / f"note_pings_{_slug}.json"
if cache_path.exists():
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
last = cache.get(cache_key)
@@ -160,13 +188,14 @@ async def dispatch_reminder(
# Treat those as browser-only dedupe so email reminders can be
# retried by the backend scanner after a failed frontend path.
should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25)
if should_skip and channel in ("email", "ntfy"):
if should_skip and channel in ("email", "ntfy", "webhook"):
should_skip = last_channel == channel
if should_skip:
return {
"synthesis": None,
"email_sent": False,
"ntfy_sent": False,
"webhook_sent": False,
"browser_sent": True,
"skipped": True,
}
@@ -179,9 +208,9 @@ async def dispatch_reminder(
try:
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
url, model, headers = resolve_endpoint("utility")
url, model, headers = resolve_endpoint("utility", owner=owner or None)
if not url:
url, model, headers = resolve_endpoint("default")
url, model, headers = resolve_endpoint("default", owner=owner or None)
if url and model:
raw = await llm_call_async(
url=url, model=model,
@@ -360,6 +389,76 @@ async def dispatch_reminder(
email_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder email send failed: {e}")
webhook_sent = False
webhook_error = ""
if channel == "webhook":
try:
import httpx
import json as _wjson
from src.integrations import load_integrations
# Built-in payload defaults for known presets so users don't have
# to configure a template just to use a standard service.
_PRESET_TEMPLATE_DEFAULTS = {
"discord_webhook": '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
}
intg_id = settings.get("reminder_webhook_integration_id", "").strip()
template = settings.get("reminder_webhook_payload_template", "").strip()
if not intg_id:
webhook_error = "No webhook integration selected"
else:
intg = next(
(i for i in load_integrations()
if i.get("id") == intg_id and i.get("base_url")),
None,
)
if not intg:
webhook_error = f"Integration {intg_id!r} not found or missing base URL"
else:
# Fall back to a built-in default for known presets so
# users don't have to configure a template for standard
# services like Discord.
if not template:
template = _PRESET_TEMPLATE_DEFAULTS.get(intg.get("preset", ""), "")
if not template:
webhook_error = "No payload template configured"
else:
# Render template: JSON-escape the values so the result
# is always valid JSON regardless of special characters.
# dumps() returns `"value"` — strip outer quotes.
msg = (synthesis or note_body or title or "Reminder")[:4000]
_t = _wjson.dumps(title or "Reminder")[1:-1]
_m = _wjson.dumps(msg)[1:-1]
rendered = template.replace("{{title}}", _t).replace("{{message}}", _m)
hdrs = {"Content-Type": "application/json"}
api_key = intg.get("api_key", "")
auth_type = (intg.get("auth_type") or "none").lower()
if api_key:
if auth_type == "bearer":
hdrs["Authorization"] = f"Bearer {api_key}"
elif auth_type == "header":
hdrs[intg.get("auth_header") or "Authorization"] = api_key
url = intg["base_url"].rstrip("/")
# SSRF guard — matches the pattern used by webhook_routes,
# CalDAV, search, and embeddings. Blocks link-local / metadata
# addresses (169.254.x.x) by default; set
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true to also block
# RFC-1918 ranges for locked-down deployments.
import os as _os
from src.url_safety import check_outbound_url as _chk
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
_ok, _reason = _chk(url, block_private=_block)
if not _ok:
webhook_error = f"Webhook URL rejected: {_reason}"
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, content=rendered.encode(), headers=hdrs)
webhook_sent = resp.is_success
if not webhook_sent:
webhook_error = f"Webhook returned HTTP {resp.status_code}"
except Exception as e:
webhook_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder webhook send failed: {e}")
ntfy_sent = False
ntfy_error = ""
if channel == "ntfy":
@@ -415,7 +514,7 @@ async def dispatch_reminder(
# second send for the same note within 25 min. Without this, a note
# whose due_date fires while the user has the app open got TWO emails
# (frontend-fired here + background-fired by ping_notes 05 min later).
if (email_sent or ntfy_sent or browser_sent or local_browser_sent) and note_id:
if (email_sent or ntfy_sent or webhook_sent or browser_sent or local_browser_sent) and note_id:
try:
import json as _json
from datetime import datetime as _dt, timezone as _tz
@@ -425,13 +524,13 @@ async def dispatch_reminder(
_STATE = cache_path
if _STATE is None:
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
_STATE = _P(f"data/note_pings_{_slug}.json")
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
_STATE.parent.mkdir(parents=True, exist_ok=True)
try:
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
except Exception:
_cache = {}
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "browser"
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "webhook" if webhook_sent else "browser"
_cache[cache_key or str(note_id)] = {
"at": _dt.now(_tz.utc).isoformat(),
"channel": sent_channel,
@@ -441,11 +540,14 @@ async def dispatch_reminder(
logger.debug(f"dispatch_reminder: cache write failed: {_e}")
return {
"channel": channel,
"synthesis": synthesis,
"email_sent": email_sent,
"email_error": email_error,
"ntfy_sent": ntfy_sent,
"ntfy_error": ntfy_error,
"webhook_sent": webhook_sent,
"webhook_error": webhook_error,
"browser_sent": browser_sent or local_browser_sent,
}
@@ -467,6 +569,23 @@ def setup_note_routes(task_scheduler=None):
def _owner(request: Request) -> Optional[str]:
return get_current_user(request)
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
if user == "internal-tool":
return True
if not user:
# require_user() already admitted this request, which only happens
# for auth-disabled, loopback-bypass, or unconfigured single-user
# modes. There is no separate non-admin account boundary there.
return True
try:
from core.auth import AuthManager
auth_mgr = getattr(request.app.state, "auth_manager", None) or AuthManager()
if not getattr(auth_mgr, "is_configured", True):
return True
return bool(auth_mgr.is_admin(user))
except Exception:
return False
# --- LIST ---
@router.get("")
def list_notes(
@@ -684,20 +803,46 @@ def setup_note_routes(task_scheduler=None):
"""
# Gate against anonymous callers — LLM synthesis can burn tokens.
from src.auth_helpers import require_user as _ru
_ru(request)
user = _ru(request)
body = await request.json()
note_id = body.get("note_id")
title = (body.get("title") or "").strip()
note_body = (body.get("body") or "").strip()
note_id = str(body.get("note_id") or "").strip()
if not note_id:
raise HTTPException(400, "note_id required")
# Delegate to the module-level helper so background tasks can reuse
# the same dispatch without an HTTP roundtrip + auth cookie.
caller = _owner(request)
is_test = note_id.startswith("test-")
is_admin = _is_admin_or_single_user(request, user or caller)
_override: dict = {}
if is_test:
if not is_admin:
raise HTTPException(403, "Admin only")
title = (body.get("title") or "Test Reminder").strip() or "Test Reminder"
note_body = (body.get("body") or "").strip()
# Optional overrides let the admin settings test button pass the
# current UI values directly so it never races a pending save.
if body.get("channel"):
_override["reminder_channel"] = body["channel"]
if body.get("webhook_integration_id"):
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
if body.get("webhook_payload_template"):
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
else:
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
if caller is not None and note.owner != caller:
raise HTTPException(404, "Note not found")
title, note_body = _reminder_text_from_note(note)
finally:
db.close()
return await dispatch_reminder(
title=title, note_body=note_body, note_id=note_id,
owner=_owner(request) or "",
owner=caller or "",
queue_browser=False,
settings_override=_override or None,
)
# --- REORDER NOTES ---
+13 -12
View File
@@ -6,16 +6,14 @@ import uuid
from typing import List, Tuple
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
from src.request_models import DirectoryRequest
from core.constants import BASE_DIR, PERSONAL_DIR
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
from src.rag_singleton import get_rag_manager
from src.auth_helpers import get_current_user, require_user
from src.auth_helpers import require_privilege, require_user
from core.middleware import require_admin
from src.upload_handler import secure_filename
from src.upload_limits import PERSONAL_UPLOAD_MAX_BYTES
UPLOADS_DIR = os.path.join(BASE_DIR, "data", "personal_uploads")
MAX_PERSONAL_UPLOAD_BYTES = int(
os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024))
)
UPLOADS_DIR = PERSONAL_UPLOADS_DIR
logger = logging.getLogger(__name__)
@@ -194,7 +192,7 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
@router.post("/upload")
async def upload_files_to_rag(request: Request, files: List[UploadFile] = File(...)):
"""Upload files directly into RAG. Supports text and PDF."""
user = get_current_user(request)
user = require_privilege(request, "can_use_documents")
rag = _rag()
if not rag:
raise HTTPException(503, "RAG system is not available — is the embedding service running?")
@@ -208,8 +206,8 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
for upload in files:
try:
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
content_bytes = await upload.read(MAX_PERSONAL_UPLOAD_BYTES + 1)
if len(content_bytes) > MAX_PERSONAL_UPLOAD_BYTES:
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1
continue
@@ -286,9 +284,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs and os.path.exists(abs_target):
os.remove(abs_target)
deleted_from_disk = True
if in_uploads and abs_target != base_abs:
try:
os.remove(abs_target)
deleted_from_disk = True
except FileNotFoundError:
pass # already gone — race with another request or cleanup
# Exclude the file from the listing (persists across restarts)
personal_docs_manager.exclude_file(filepath)
+2 -1
View File
@@ -4,8 +4,9 @@ import os
from typing import Optional
from fastapi import APIRouter, Request
from src.auth_helpers import get_current_user
from src.constants import USER_PREFS_FILE
PREFS_FILE = os.path.join("data", "user_prefs.json")
PREFS_FILE = USER_PREFS_FILE
def _load():
+3 -1
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
from src.request_models import PresetUpdateRequest
from core.middleware import require_admin
from src.auth_helpers import effective_user
logger = logging.getLogger(__name__)
@@ -100,7 +101,8 @@ def setup_preset_routes(preset_manager) -> APIRouter:
try:
model_spec = data.get("model") or ""
url, model, headers = _resolve_model(model_spec)
user = effective_user(request)
url, model, headers = _resolve_model(model_spec, owner=user)
result = await llm_call_async(url, model, messages, temperature=0.8, max_tokens=500, headers=headers)
return {"success": True, "prompt": result.strip()}
except Exception as e:
+61 -46
View File
@@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field
from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from src.constants import DEEP_RESEARCH_DIR
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -37,13 +38,15 @@ def _first_chat_model(models) -> str:
return (models[0] if models else "")
def _resolve_research_endpoint(sess) -> tuple:
def _resolve_research_endpoint(sess, owner: Optional[str] = None) -> tuple:
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
owner = owner or getattr(sess, "owner", None) or None
url, model, headers = resolve_endpoint(
"research",
fallback_url=sess.endpoint_url,
fallback_model=sess.model,
fallback_headers=sess.headers,
owner=owner,
)
return url, model, headers
@@ -72,6 +75,38 @@ def _owned_enabled_endpoint(db, owner, endpoint_id=None):
return owner_filter(q, ModelEndpoint, owner).first()
def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
"""Resolve a ModelEndpoint row into (chat_url, model, headers).
Mirrors endpoint_resolver.resolve_endpoint's provider-auth handling for
panel-selected research endpoints. ChatGPT Subscription endpoints keep
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
"""
from src.endpoint_resolver import (
build_chat_url,
build_headers,
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
)
try:
base, api_key = resolve_model_endpoint_runtime(ep, owner=owner)
except Exception as e:
logger.warning("Could not resolve endpoint credentials for research: %s", e)
return None
ep_model = (model or "").strip()
if not ep_model:
try:
models = json.loads(ep.cached_models) if ep.cached_models else []
if models:
ep_model = _first_chat_model(models)
except Exception:
pass
if not ep_model:
return None
return build_chat_url(base), ep_model, build_headers(api_key, base)
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
router = APIRouter(tags=["research"])
@@ -98,7 +133,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
if entry is not None:
return entry.get("owner", "") == user
# Task no longer in memory — check the persisted JSON.
path = Path("data/deep_research") / f"{session_id}.json"
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
if not path.exists():
return False
try:
@@ -162,7 +197,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
def _assert_owns_research(session_id: str, user: str) -> None:
"""404-not-403 ownership gate for a research session's on-disk JSON.
Use BEFORE returning any data or mutating the file."""
path = Path("data/deep_research") / f"{session_id}.json"
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
if not path.exists():
raise HTTPException(404, "Research not found")
try:
@@ -225,7 +260,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
):
user = _require_user(request)
"""List all completed research for the Library panel."""
data_dir = Path("data/deep_research")
data_dir = Path(DEEP_RESEARCH_DIR)
items = []
for p in data_dir.glob("*.json"):
try:
@@ -275,7 +310,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
summary, stats used by the Library preview panel."""
user = _require_user(request)
_validate_session_id(session_id)
path = Path("data/deep_research") / f"{session_id}.json"
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
if not path.exists():
raise HTTPException(404, "Research not found")
try:
@@ -292,7 +327,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
user = _require_user(request)
_validate_session_id(session_id)
path = Path("data/deep_research") / f"{session_id}.json"
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
if not path.exists():
raise HTTPException(404, "Research not found")
try:
@@ -312,7 +347,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
"""Delete a research result from disk."""
user = _require_user(request)
_validate_session_id(session_id)
data_dir = Path("data/deep_research")
data_dir = Path(DEEP_RESEARCH_DIR)
json_path = data_dir / f"{session_id}.json"
deleted = False
if json_path.exists():
@@ -368,7 +403,6 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
if body.endpoint_id:
from src.database import SessionLocal
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
db = SessionLocal()
try:
# Owner-scoped: never resolve another user's private endpoint
@@ -377,35 +411,26 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
if not ep:
raise HTTPException(404, "Endpoint not found or disabled")
base = normalize_base(ep.base_url)
ep_url = build_chat_url(base)
ep_headers = build_headers(ep.api_key, base)
ep_model = body.model or ""
if not ep_model:
try:
import json as _json
models = _json.loads(ep.cached_models) if ep.cached_models else []
if models:
ep_model = _first_chat_model(models)
except Exception:
pass
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
if not resolved:
raise HTTPException(400, "Endpoint is not configured with a usable model.")
ep_url, ep_model, ep_headers = resolved
finally:
db.close()
else:
ep_url, ep_model, ep_headers = resolve_endpoint("research")
ep_url, ep_model, ep_headers = resolve_endpoint("research", owner=user)
if not ep_url:
ep_url, ep_model, ep_headers = resolve_endpoint("utility")
ep_url, ep_model, ep_headers = resolve_endpoint("utility", owner=user)
# When neither research nor utility is configured, use the user's
# configured DEFAULT model (default_endpoint_id/default_model) rather
# than arbitrarily grabbing the first enabled endpoint's first model
# (which surfaced gpt-3.5). "Default" should mean the default model.
if not ep_url:
ep_url, ep_model, ep_headers = resolve_endpoint("default")
ep_url, ep_model, ep_headers = resolve_endpoint("default", owner=user)
if not ep_url:
ep_url, ep_model, ep_headers = resolve_endpoint("chat")
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
if not ep_url:
from src.database import SessionLocal
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
db = SessionLocal()
try:
# Owner-scoped first-enabled fallback: the caller's own rows
@@ -414,18 +439,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
ep = _owned_enabled_endpoint(db, user)
if ep:
base = normalize_base(ep.base_url)
ep_url = build_chat_url(base)
ep_headers = build_headers(ep.api_key, base)
ep_model = ""
if ep.cached_models:
try:
import json as _json
models = _json.loads(ep.cached_models)
if models:
ep_model = _first_chat_model(models)
except Exception:
pass
resolved = _resolve_endpoint_runtime(ep, owner=user)
if resolved:
ep_url, ep_model, ep_headers = resolved
finally:
db.close()
if not ep_url:
@@ -494,7 +510,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
raise HTTPException(404, "No research found for this session")
result = research_handler.get_result(session_id)
if result is None:
p = Path("data/deep_research") / f"{session_id}.json"
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
if p.exists():
d = json.loads(p.read_text(encoding="utf-8"))
return {
@@ -534,7 +550,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
sources = research_handler.get_sources(session_id) or []
query = ""
path = Path("data/deep_research") / f"{session_id}.json"
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
if path.exists():
try:
disk = json.loads(path.read_text(encoding="utf-8"))
@@ -572,19 +588,18 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
ep_headers = dict(r_headers)
if not ep_url or not ep_model:
_merge(*resolve_endpoint("chat"))
_merge(*resolve_endpoint("chat", owner=user))
if not ep_url or not ep_model:
_merge(*resolve_endpoint("research"))
_merge(*resolve_endpoint("research", owner=user))
if not ep_url or not ep_model:
_merge(*resolve_endpoint("utility"))
_merge(*resolve_endpoint("utility", owner=user))
if not ep_url or not ep_model:
# Last resort: any enabled endpoint
# Last resort: this user's enabled endpoint, plus legacy shared rows.
from src.database import SessionLocal
from src.database import ModelEndpoint
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).first()
ep = _owned_enabled_endpoint(db, user)
if ep:
base = normalize_base(ep.base_url)
fallback_url = build_chat_url(base)
@@ -594,7 +609,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
try:
models = json.loads(ep.cached_models)
if models:
fallback_model = models[0]
fallback_model = _first_chat_model(models)
except Exception:
pass
_merge(fallback_url, fallback_model, fallback_headers)
+48 -33
View File
@@ -10,8 +10,9 @@ import logging
from core.session_manager import SessionManager
from core.models import ChatMessage
from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage
from src.auth_helpers import get_current_user, effective_user
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
from src.auth_helpers import get_current_user, effective_user, _auth_disabled
from src.session_actions import is_session_recently_active
def _sanitize_export_filename(name: str) -> str:
@@ -92,35 +93,30 @@ def _reject_compact_during_active_run(session_id: str) -> None:
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
"""Verify the current user owns the session. Raises 404 if not.
"""Verify the current user owns the session, honoring single-user modes.
Ownership is checked against the DB row when one exists (unchanged). If
there is no DB row but the caller owns an in-memory "ghost" session one
that lives only in ``session_manager`` because it was never persisted, or
its DB row was removed out-of-band fall back to the in-memory owner so the
user can still manage and delete it. Without this fallback such sessions are
listed by ``/api/sessions`` (they come from the in-memory manager) yet every
per-session operation 404s, making them impossible to delete (issue #1044).
``session_manager`` is optional and defaults to ``None`` so existing callers
that only care about persisted sessions keep their exact prior behavior.
Authenticated requests must match the stored DB or in-memory owner. When
auth is disabled and no user is present, treat the app as single-user mode:
verify that the session exists, but do not compare its stored owner. This
keeps QA/dev instances with AUTH_ENABLED=false from rejecting owner-stamped
rows created while auth was previously enabled.
"""
user = effective_user(request)
if not user:
raise HTTPException(403, "Authentication required")
if not user and not _auth_disabled():
raise HTTPException(401, "Authentication required")
db = SessionLocal()
try:
row = db.query(DbSession.owner).filter(DbSession.id == session_id).first()
finally:
db.close()
if row is not None:
if row.owner != user:
if user and row.owner != user:
raise HTTPException(404, f"Session {session_id} not found")
return
# No DB row — allow the caller to act on an in-memory ghost they own.
if session_manager is not None:
ghost = getattr(session_manager, "sessions", {}).get(session_id)
if ghost is not None and getattr(ghost, "owner", None) == user:
if ghost is not None and (not user or getattr(ghost, "owner", None) == user):
return
raise HTTPException(404, f"Session {session_id} not found")
@@ -262,7 +258,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
last_msg_map = {}
mode_map = {}
msg_count_map = {}
rows = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False).all()
rows = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False, DbSession.owner == user).all()
for row in rows:
folder_map[row.id] = row.folder
token_map[row.id] = (row.total_input_tokens or 0) + (row.total_output_tokens or 0)
@@ -284,12 +280,14 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
r[0] for r in db.query(Document.session_id)
.filter(Document.is_active == True,
Document.current_content != None,
func.trim(Document.current_content) != "")
func.trim(Document.current_content) != "",
Document.owner == user)
.distinct().all()
)
img_session_ids = set(
r[0] for r in db.query(GalleryImage.session_id)
.filter(GalleryImage.session_id != None)
.filter(GalleryImage.session_id != None,
GalleryImage.owner == user)
.distinct().all()
)
finally:
@@ -370,8 +368,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
pass
elif not model_to_use:
from src.llm_core import list_model_ids
ids = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
headers=validation_headers)
ids = list_model_ids(
endpoint_url,
timeout=REQUEST_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
if not ids:
raise HTTPException(400, "Cannot reach /v1/models")
# Default to the first CHAT model — endpoints often list embedding/
@@ -385,8 +388,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
from src.llm_core import list_model_ids
import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
headers=validation_headers)
avail = list_model_ids(
endpoint_url,
timeout=REQUEST_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
if not avail:
raise HTTPException(400, "Cannot reach /v1/models")
if model_to_use not in avail:
@@ -543,22 +551,25 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
ids = body.get("ids", [])
except Exception:
ids = []
deleted_count = 0
for sid in ids:
try:
_verify_session_owner(request, sid, session_manager)
session_manager.delete_session(sid)
# Enforce "starred" protection consistent with single-session delete
db = SessionLocal()
try:
db.query(_CM).filter(_CM.session_id == sid).delete()
db.query(DbSession).filter(DbSession.id == sid).delete()
db.commit()
except Exception:
db.rollback()
db_sess = db.query(DbSession).filter(DbSession.id == sid).first()
if db_sess and db_sess.is_important:
continue
finally:
db.close()
if session_manager.delete_session(sid):
deleted_count += 1
except Exception:
pass
return {"deleted": len(ids)}
return {"deleted": deleted_count}
@router.delete("/session/{sid}")
def delete_session(request: Request, sid: str):
@@ -924,7 +935,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
url, model, headers = resolve_endpoint("utility", owner=get_current_user(request))
owner = getattr(session, "owner", None) or effective_user(request)
url, model, headers = resolve_endpoint("utility", owner=owner)
if not url or not model:
url, model, headers = session.endpoint_url, session.model, session.headers
if not url or not model:
@@ -1006,7 +1018,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
}
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
try:
rows = db.query(DbSession).filter(DbSession.archived == False, DbSession.owner == user).all()
rows = db.query(DbSession).filter(DbSession.archived == False, DbSession.owner == user).limit(2000).all()
folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in TWO aggregate queries
# instead of 13 queries PER session — with many chats the per-row
@@ -1017,6 +1029,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
)
cleanup_now = utcnow_naive()
for row in rows:
# Never delete important sessions
if getattr(row, 'is_important', False):
@@ -1029,6 +1042,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
continue
if is_session_recently_active(row, now=cleanup_now):
continue
msg_count = _counts.get(row.id, 0)
should_delete = False
if msg_count == 0:
+279 -58
View File
@@ -13,6 +13,7 @@ import tempfile
from collections import namedtuple
from pathlib import Path
from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there
@@ -37,6 +38,7 @@ from core.platform_compat import (
IS_WINDOWS,
detached_popen_kwargs,
find_bash,
git_bash_path,
)
@@ -92,6 +94,7 @@ def _venv_activate_prefix(venv: str | None) -> str:
act = venv if venv.endswith("/bin/activate") else venv.rstrip("/") + "/bin/activate"
return f". {act} && "
logger = logging.getLogger(__name__)
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid")
@@ -169,7 +172,10 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "hf_transfer":
return bool(dists.get("hf-transfer") or modules.get("hf_transfer", {}).get("real_module"))
return bool(
dists.get("hf-transfer")
or modules.get("hf_transfer", {}).get("real_module")
)
return bool(dists.get(name) or modules.get(name, {}).get("real_module"))
@@ -194,8 +200,14 @@ def _package_status_note(name: str, probe: dict) -> str:
if binaries.get("llama-server"):
parts.append(f"native llama-server: {binaries['llama-server']}")
if dists.get("llama-cpp-python"):
parts.append(f"python package: llama-cpp-python {dists['llama-cpp-python']}")
return "; ".join(parts) if parts else "No native llama-server or llama-cpp-python server package found."
parts.append(
f"python package: llama-cpp-python {dists['llama-cpp-python']}"
)
return (
"; ".join(parts)
if parts
else "No native llama-server or llama-cpp-python server package found."
)
if name == "diffusers":
if _package_installed_from_probe(name, probe):
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
@@ -205,7 +217,9 @@ def _package_status_note(name: str, probe: dict) -> str:
return ""
def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageUpdateStatus:
def _package_pip_update_status(
pkg: dict, probe: dict | None = None
) -> PackageUpdateStatus:
"""Return whether the Dependencies UI should offer a generic pip update.
"Installed" means Cookbook can use the dependency. It does not always mean
@@ -213,12 +227,28 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU
native llama-server can come from a package manager/source build, and a CLI
may be on PATH without matching Python package metadata.
"""
if pkg.get("name") == "APFEL":
return PackageUpdateStatus(
False,
"", # Note is empty because IT DOES allow for updates outside of PIP.
)
if pkg.get("kind") == "system" or not pkg.get("pip"):
return PackageUpdateStatus(False, "Update this system dependency outside Odysseus.")
return PackageUpdateStatus(
False, "Update this system dependency outside Odysseus."
)
name = pkg.get("name")
binaries = probe.get("binaries") if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict) else {}
dists = probe.get("dists") if isinstance(probe, dict) and isinstance(probe.get("dists"), dict) else {}
binaries = (
probe.get("binaries")
if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict)
else {}
)
dists = (
probe.get("dists")
if isinstance(probe, dict) and isinstance(probe.get("dists"), dict)
else {}
)
if name == "llama_cpp" and binaries.get("llama-server"):
return PackageUpdateStatus(
@@ -231,7 +261,9 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU
"Using a vLLM CLI on PATH without Python package metadata; update it outside Odysseus.",
)
return PackageUpdateStatus(True, "Update uses pip in the selected Python environment.")
return PackageUpdateStatus(
True, "Update uses pip in the selected Python environment."
)
def _prepend_user_install_bins_to_path() -> None:
@@ -250,7 +282,9 @@ def _prepend_user_install_bins_to_path() -> None:
candidates = []
candidates.append(os.path.expanduser("~/.local/bin"))
parts = os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
parts = (
os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
)
changed = False
for path in reversed([p for p in candidates if p]):
if path not in parts:
@@ -357,9 +391,11 @@ PTY_UNSUPPORTED_ERROR = "pty_unsupported"
class ShellExecRequest(BaseModel):
command: str
timeout: int | None = None # optional override; 0 = no timeout (run until client disconnects)
use_pty: bool = False # use pseudo-TTY (for progress bars)
use_tmux: bool = False # run in tmux session (survives browser disconnect)
timeout: int | None = (
None # optional override; 0 = no timeout (run until client disconnects)
)
use_pty: bool = False # use pseudo-TTY (for progress bars)
use_tmux: bool = False # run in tmux session (survives browser disconnect)
async def _create_shell(command: str, **kwargs):
@@ -368,8 +404,16 @@ async def _create_shell(command: str, **kwargs):
POSIX: /bin/sh via create_subprocess_shell (unchanged behaviour).
Windows: prefer a real bash (Git Bash/WSL) so bash-syntax commands behave
the same as on Linux; fall back to cmd.exe when no bash is installed.
Powershell commands are executed directly via cmd.exe /c to avoid quoting
and env variable expansion errors under Git Bash.
"""
if IS_WINDOWS:
# PowerShell commands (used by the frontend for Windows log-file polling
# and session management) must run directly — passing them through
# bash -c mangles $env:VAR syntax and breaks the command.
cmd_trim = command.strip()
if cmd_trim.startswith("powershell") or cmd_trim.startswith("cmd "):
return await asyncio.create_subprocess_shell(command, **kwargs)
bash = find_bash()
if bash:
return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs)
@@ -386,9 +430,7 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
stderr=asyncio.subprocess.PIPE,
cwd=str(Path.home()),
)
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
stdout = stdout_b.decode(errors="replace")[:MAX_OUTPUT]
stderr = stderr_b.decode(errors="replace")[:MAX_OUTPUT]
return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
@@ -399,7 +441,11 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
await proc.wait()
except ProcessLookupError:
pass
return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
return {
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
}
except Exception as e:
return {"stdout": "", "stderr": str(e), "exit_code": -1}
@@ -481,7 +527,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
if idx == -1:
break
line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:]
buf = buf[idx + sep_len :]
if line:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
@@ -503,7 +549,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
if idx == -1:
break
line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:]
buf = buf[idx + sep_len :]
if line:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
if buf:
@@ -534,6 +580,7 @@ def _pty_read(fd: int) -> bytes | None:
"""Blocking read from PTY fd. Called via run_in_executor.
Returns bytes on data, None on timeout (no data yet)."""
import select
r, _, _ = select.select([fd], [], [], 1.0)
if r:
try:
@@ -557,10 +604,10 @@ async def _generate_tmux(cmd: str, request: Request):
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
script_path.write_text(
f"#!/bin/bash\n"
f"ODYSSEUS_USER_SHELL=\"${{SHELL:-}}\"\n"
f"if [ -n \"$ODYSSEUS_USER_SHELL\" ] && [ -x \"$ODYSSEUS_USER_SHELL\" ]; then\n"
f" ODYSSEUS_USER_PATH=\"$(\"$ODYSSEUS_USER_SHELL\" -ic 'printf \"__ODYSSEUS_PATH__%s\\n\" \"$PATH\"' 2>/dev/null | sed -n 's/^__ODYSSEUS_PATH__//p' | tail -n 1 || true)\"\n"
f" if [ -n \"$ODYSSEUS_USER_PATH\" ]; then export PATH=\"$ODYSSEUS_USER_PATH:$PATH\"; fi\n"
f'ODYSSEUS_USER_SHELL="${{SHELL:-}}"\n'
f'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then\n'
f' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"\n'
f' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi\n'
f"fi\n"
f"{cmd} 2>&1 | tee '{log_path}'\n"
f"EC=${{PIPESTATUS[0]}}\n"
@@ -570,7 +617,9 @@ async def _generate_tmux(cmd: str, request: Request):
encoding="utf-8",
)
script_path.chmod(0o755)
logger.info("tmux wrapper script created: session=%s path=%s", session_id, script_path)
logger.info(
"tmux wrapper script created: session=%s path=%s", session_id, script_path
)
tmux_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(script_path))}"
@@ -602,7 +651,9 @@ async def _generate_tmux(cmd: str, request: Request):
# Read new lines from log
try:
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
new_lines = lines[lines_sent:]
for line in new_lines:
if line.startswith(":::EXIT_CODE:::"):
@@ -630,7 +681,9 @@ async def _generate_tmux(cmd: str, request: Request):
# Session ended — do one final read
await asyncio.sleep(0.5)
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
for line in lines[lines_sent:]:
if line.startswith(":::EXIT_CODE:::"):
try:
@@ -672,8 +725,8 @@ async def _generate_win_detached(cmd: str, request: Request):
if bash:
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
script_path.write_text(
f"{cmd} > {shlex.quote(str(log_path))} 2>&1\n"
f"echo $? > {shlex.quote(str(exit_path))}\n",
f"{cmd} > {shlex.quote(git_bash_path(log_path))} 2>&1\n"
f"echo $? > {shlex.quote(git_bash_path(exit_path))}\n",
encoding="utf-8",
)
argv = [bash, str(script_path)]
@@ -711,7 +764,9 @@ async def _generate_win_detached(cmd: str, request: Request):
return
try:
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
for line in lines[lines_sent:]:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
lines_sent = len(lines)
@@ -723,11 +778,18 @@ async def _generate_win_detached(cmd: str, request: Request):
await asyncio.sleep(0.3)
try:
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
for line in lines[lines_sent:]:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
lines_sent = len(lines)
exit_code = int((exit_path.read_text(encoding="utf-8", errors="replace").strip() or "0"))
exit_code = int(
(
exit_path.read_text(encoding="utf-8", errors="replace").strip()
or "0"
)
)
except Exception:
exit_code = 0
break
@@ -753,7 +815,9 @@ def setup_shell_routes() -> APIRouter:
return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
logger.info("User shell exec requested: length=%d", len(cmd))
result = await _exec_shell(cmd, timeout=EXEC_TIMEOUT)
result = await _exec_shell(
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
)
return result
@router.post("/api/shell/stream")
@@ -762,9 +826,11 @@ def setup_shell_routes() -> APIRouter:
_require_admin(request)
cmd = req.command.strip()
if not cmd:
async def empty():
yield f"data: {json.dumps({'stream': 'stderr', 'data': 'No command provided'})}\n\n"
yield f"data: {json.dumps({'exit_code': 1})}\n\n"
return StreamingResponse(empty(), media_type="text/event-stream")
timeout = req.timeout if req.timeout is not None else STREAM_TIMEOUT
@@ -781,7 +847,11 @@ def setup_shell_routes() -> APIRouter:
if use_tmux:
# tmux is POSIX-only; Windows uses a detached-process + logfile tail
# that preserves the "survives disconnect" behaviour.
gen = _generate_win_detached(cmd, request) if IS_WINDOWS else _generate_tmux(cmd, request)
gen = (
_generate_win_detached(cmd, request)
if IS_WINDOWS
else _generate_tmux(cmd, request)
)
return StreamingResponse(gen, media_type="text/event-stream")
if use_pty and not IS_WINDOWS:
@@ -813,7 +883,12 @@ def setup_shell_routes() -> APIRouter:
chunk = await stream.read(4096)
if not chunk:
if buf:
await q.put((name, buf.decode(errors="replace").rstrip("\r\n")))
await q.put(
(
name,
buf.decode(errors="replace").rstrip("\r\n"),
)
)
break
buf += chunk
while True:
@@ -821,7 +896,7 @@ def setup_shell_routes() -> APIRouter:
if idx == -1:
break
line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:]
buf = buf[idx + sep_len :]
if line:
await q.put((name, line))
finally:
@@ -880,7 +955,12 @@ def setup_shell_routes() -> APIRouter:
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/api/cookbook/packages")
async def list_packages(request: Request, host: str | None = None, ssh_port: str | None = None, venv: str | None = None):
async def list_packages(
request: Request,
host: str | None = None,
ssh_port: str | None = None,
venv: str | None = None,
):
"""Check which optional packages are installed.
Local-target packages are checked in-process. Remote-target packages
@@ -890,7 +970,13 @@ def setup_shell_routes() -> APIRouter:
"""
_require_admin(request)
_reject_cross_site(request)
import importlib, importlib.metadata as importlib_metadata, shlex, json as _json, site, sys
import importlib
import importlib.metadata as importlib_metadata
import shlex
import json as _json
import site
import sys
_prepend_user_install_bins_to_path()
importlib.invalidate_caches()
try:
@@ -905,26 +991,115 @@ def setup_shell_routes() -> APIRouter:
raise HTTPException(400, "Invalid ssh_port")
packages = [
# ── System ── OS binaries, not pip packages
{"name": "tmux", "pip": "", "desc": "Required for Linux/Termux Cookbook background downloads and serves", "category": "System", "target": "remote", "kind": "system", "install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper."},
{"name": "docker", "pip": "", "desc": "Required only for Docker-backed launch commands", "category": "System", "target": "remote", "kind": "system", "install_hint": "Install Docker on the selected server and allow this user to run docker."},
{
"name": "tmux",
"pip": "",
"desc": "Required for Linux/Termux Cookbook background downloads and serves",
"category": "System",
"target": "remote",
"kind": "system",
"install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper.",
},
{
"name": "docker",
"pip": "",
"desc": "Required only for Docker-backed launch commands",
"category": "System",
"target": "remote",
"kind": "system",
"install_hint": "Install Docker on the selected server and allow this user to run docker.",
},
# ── LLM ── installs on GPU servers for model serving/downloading
{"name": "hf_transfer", "pip": "hf_transfer", "desc": "Fast model downloads from HuggingFace", "category": "LLM", "target": "remote"},
{"name": "llama_cpp", "pip": "llama-cpp-python[server]", "desc": "Serve GGUF models via llama.cpp", "category": "LLM", "target": "remote"},
{"name": "sglang", "pip": "sglang[all]", "desc": "Serve HF safetensors models via SGLang", "category": "LLM", "target": "remote"},
{"name": "vllm", "pip": "vllm", "desc": "High-throughput LLM serving engine", "category": "LLM", "target": "remote"},
{
"name": "hf_transfer",
"pip": "hf_transfer",
"desc": "Fast model downloads from HuggingFace",
"category": "LLM",
"target": "remote",
},
{
"name": "llama_cpp",
"pip": "llama-cpp-python[server]",
"desc": "Serve GGUF models via llama.cpp",
"category": "LLM",
"target": "remote",
},
{
"name": "sglang",
"pip": "sglang[all]",
"desc": "Serve HF safetensors models via SGLang",
"category": "LLM",
"target": "remote",
},
{
"name": "vllm",
"pip": "vllm",
"desc": "High-throughput LLM serving engine",
"category": "LLM",
"target": "remote",
},
{
"name": "APFEL",
"pip": "",
"desc": "OpenAI-compatible API for Apple Foundational Models on Apple Silicon",
"category": "LLM",
"target": "local",
"kind": "system",
"install_cmd": "brew install apfel",
"update_cmd": "brew upgrade apfel",
"install_hint": "Requires a native Apple Silicon Mac with Apple Foundational Models support. Installable via Homebrew on supported Macs.",
},
# ── Image ── editor + diffusion model serving
{"name": "diffusers", "pip": "diffusers[torch]", "desc": "Image generation pipelines (SD, Flux) with PyTorch", "category": "Image", "target": "remote"},
{"name": "rembg", "pip": "rembg[gpu]", "desc": "AI background removal for image editor", "category": "Image", "target": "local"},
{"name": "realesrgan", "pip": "realesrgan", "desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.", "category": "Image", "target": "local"},
{
"name": "diffusers",
"pip": "diffusers[torch]",
"desc": "Image generation pipelines (SD, Flux) with PyTorch",
"category": "Image",
"target": "remote",
},
{
"name": "rembg",
"pip": "rembg[gpu]",
"desc": "AI background removal for image editor",
"category": "Image",
"target": "local",
},
{
"name": "realesrgan",
"pip": "realesrgan",
"desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.",
"category": "Image",
"target": "local",
},
# ── Tools ──
{"name": "playwright", "pip": "playwright", "desc": "Browser automation for web tools", "category": "Tools", "target": "local"},
{
"name": "playwright",
"pip": "playwright",
"desc": "Browser automation for web tools",
"category": "Tools",
"target": "local",
},
]
# Most packages should not be installed through external means. Hence, set the default of the
# install_cmd and update_cmd to None, which indicates that the recommended way to install/update is through the Cookbook # server setup or pip. Only system packages, should have explicit install/update commands provided.
for pkg in packages:
pkg.setdefault("install_cmd", None)
pkg.setdefault("update_cmd", None)
# Remote check: for remote-target packages, probe the selected server's
# venv over SSH so a remote `pip install` actually reflects here.
remote_status: dict = {}
remote_details: dict = {}
remote_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") != "system"]
remote_system_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") == "system"]
remote_names = [
p["name"]
for p in packages
if p.get("target") == "remote" and p.get("kind") != "system"
]
remote_system_names = [
p["name"]
for p in packages
if p.get("target") == "remote" and p.get("kind") == "system"
]
if host and remote_names:
try:
py = _package_probe_script(remote_names)
@@ -934,7 +1109,9 @@ def setup_shell_routes() -> APIRouter:
inner = f"{src}python3 -c {shlex.quote(py)}"
argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
txt = out.decode("utf-8", errors="replace").strip()
@@ -958,11 +1135,15 @@ def setup_shell_routes() -> APIRouter:
checks = []
for name in remote_system_names:
qn = shlex.quote(name)
checks.append(f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi")
checks.append(
f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
)
inner = " ; ".join(checks)
argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
txt = out.decode("utf-8", errors="replace").strip()
@@ -987,11 +1168,25 @@ def setup_shell_routes() -> APIRouter:
if note:
pkg["status_note"] = note
elif pkg.get("kind") == "system":
pkg["installed"] = shutil.which(pkg["name"]) is not None
if pkg["name"] == "APFEL":
pkg["applicable"] = IS_APPLE_SILICON
pkg["installed"] = which_tool("apfel") is not None
pkg["status_note"] = (
"Available on Apple Silicon (arm64) devices; exposed through a local OpenAI-compatible API."
if IS_APPLE_SILICON
else "Requires a native Apple Silicon Mac with Apple Foundational Models support."
)
else:
pkg["installed"] = shutil.which(pkg["name"]) is not None
elif pkg["name"] == "llama_cpp" and shutil.which("llama-server"):
pkg["installed"] = True
pkg["status_note"] = f"native llama-server: {shutil.which('llama-server')}"
probe = {"binaries": {"llama-server": shutil.which("llama-server")}, "dists": {}}
pkg["status_note"] = (
f"native llama-server: {shutil.which('llama-server')}"
)
probe = {
"binaries": {"llama-server": shutil.which("llama-server")},
"dists": {},
}
elif pkg["name"] == "vllm":
_vllm_cli = shutil.which("vllm")
pkg["installed"] = _vllm_cli is not None
@@ -1014,6 +1209,12 @@ def setup_shell_routes() -> APIRouter:
pkg["installed"] = False
except importlib_metadata.PackageNotFoundError:
pkg["installed"] = False
except Exception:
# Installed but crashes on import — e.g. a CUDA build of
# llama-cpp-python raising FileNotFoundError when the CUDA
# toolkit dir is absent. One broken optional package must not
# 500 the entire packages panel; report it as not usable.
pkg["installed"] = False
if pkg.get("installed"):
update_status = _package_pip_update_status(pkg, probe)
@@ -1037,15 +1238,30 @@ def setup_shell_routes() -> APIRouter:
"""Install a package via pip. Admin only — pip install is effectively code exec."""
_require_admin(request)
import sys as _sys
body = await request.json()
pip_name = body.get("pip")
if not pip_name:
return {"ok": False, "error": "No package specified"}
# Validate against known packages to prevent arbitrary pip install
known = {
"rembg[gpu]", "hf_transfer", "llama-cpp-python[server]", "sglang[all]", "diffusers", "diffusers[torch]",
"TTS", "bark", "faster-whisper", "playwright", "realesrgan", "gfpgan",
"insightface", "onnxruntime-gpu", "onnxruntime", "hdbscan", "vllm",
"rembg[gpu]",
"hf_transfer",
"llama-cpp-python[server]",
"sglang[all]",
"diffusers",
"diffusers[torch]",
"TTS",
"bark",
"faster-whisper",
"playwright",
"realesrgan",
"gfpgan",
"insightface",
"onnxruntime-gpu",
"onnxruntime",
"hdbscan",
"vllm",
}
if pip_name not in known:
return {"ok": False, "error": f"Unknown package: {pip_name}"}
@@ -1071,6 +1287,7 @@ def setup_shell_routes() -> APIRouter:
"""
_require_admin(request)
from routes.cookbook_helpers import _llama_cpp_rebuild_cmd
body = await request.json()
engine = str(body.get("engine") or "llamacpp").strip()
if engine != "llamacpp":
@@ -1079,7 +1296,11 @@ def setup_shell_routes() -> APIRouter:
ssh_port = body.get("ssh_port")
cmd = _llama_cpp_rebuild_cmd()
try:
argv = (_ssh_base_argv(host, ssh_port) + [cmd]) if host else ["bash", "-lc", cmd]
argv = (
(_ssh_base_argv(host, ssh_port) + [cmd])
if host
else ["bash", "-lc", cmd]
)
except ValueError as e:
raise HTTPException(400, str(e))
try:
+44 -16
View File
@@ -21,10 +21,44 @@ from src.auth_helpers import get_current_user
logger = logging.getLogger(__name__)
_DATA_URL_RE = re.compile(
r'^data:image/(?P<fmt>png|jpeg|jpg);base64,(?P<data>.+)$',
re.IGNORECASE | re.DOTALL,
)
_DATA_URL_RE = re.compile(r"^data:image/png;base64,(?P<data>.+)$", re.IGNORECASE | re.DOTALL)
_ANY_IMAGE_DATA_URL_RE = re.compile(r"^data:image/[^;]+;base64,", re.IGNORECASE)
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
_MAX_SIGNATURE_BYTES = 2 * 1024 * 1024
_MAX_SIGNATURE_B64 = ((_MAX_SIGNATURE_BYTES + 2) // 3) * 4
_MAX_SIGNATURE_DIMENSION = 4096
def _normalize_signature_png(raw: str) -> str:
raw = (raw or "").strip()
m = _DATA_URL_RE.match(raw)
if m:
b64 = m.group("data")
elif _ANY_IMAGE_DATA_URL_RE.match(raw):
raise HTTPException(400, "Signature data must be a PNG image")
else:
b64 = raw
if len(b64) > _MAX_SIGNATURE_B64:
raise HTTPException(400, "Signature PNG is too large")
try:
payload = base64.b64decode(b64, validate=True)
except Exception:
raise HTTPException(400, "Signature data must be base64-encoded PNG bytes")
if not payload:
raise HTTPException(400, "Signature PNG is empty")
if len(payload) > _MAX_SIGNATURE_BYTES:
raise HTTPException(400, "Signature PNG is too large")
if not payload.startswith(_PNG_MAGIC):
raise HTTPException(400, "Signature data must be a PNG image")
return base64.b64encode(payload).decode("ascii")
def _signature_dimension(value: Optional[int]) -> Optional[int]:
if value is None:
return None
if not isinstance(value, int) or value < 1 or value > _MAX_SIGNATURE_DIMENSION:
raise HTTPException(400, "Signature dimensions are invalid")
return value
class SignatureCreate(BaseModel):
@@ -67,24 +101,18 @@ def setup_signature_routes() -> APIRouter:
@router.post("/api/signatures")
async def create_signature(request: Request, req: SignatureCreate) -> Dict[str, Any]:
user = get_current_user(request)
raw = (req.data or "").strip()
m = _DATA_URL_RE.match(raw)
b64 = m.group("data") if m else raw
try:
payload = base64.b64decode(b64, validate=True)
if not payload:
raise ValueError("empty payload")
except Exception:
raise HTTPException(400, "Signature data must be base64-encoded PNG bytes")
b64 = _normalize_signature_png(req.data)
width = _signature_dimension(req.width)
height = _signature_dimension(req.height)
sig = Signature(
id=str(uuid.uuid4()),
owner=user,
name=(req.name or "Signature").strip() or "Signature",
data_png=b64,
width=req.width,
height=req.height,
svg=req.svg,
width=width,
height=height,
svg=None,
)
db = SessionLocal()
try:
+107 -1
View File
@@ -11,6 +11,8 @@ import logging
import re
from typing import List, Optional
import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
@@ -51,6 +53,10 @@ class SkillAddRequest(BaseModel):
steps: List[str] = Field(default_factory=list)
class SkillImportUrlRequest(BaseModel):
url: str = Field(..., min_length=8, max_length=2000)
class SkillUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@@ -1014,7 +1020,7 @@ def _resolve_audit_models(owner=None):
spec = (get_setting("teacher_model", "") or "").strip()
if spec:
from src.ai_interaction import _resolve_model
t_url, t_model, t_headers = _resolve_model(spec)
t_url, t_model, t_headers = _resolve_model(spec, owner=owner)
if t_url and t_model:
teacher = (t_url, t_model, t_headers)
except Exception as e:
@@ -1103,6 +1109,35 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
idx = skills_manager.index_for(owner=user)
return {"index": idx, "count": len(idx)}
@router.get("/slash-catalog")
async def get_slash_catalog(request: Request):
"""Return skills that are available as slash commands.
Mirrors the agent prompt's published-skill index so the UI never offers
a slash command the model would not normally be allowed to discover.
"""
user = _owner(request)
all_skills = {s.get("name"): s for s in skills_manager.load(owner=user)}
entries = []
for s in skills_manager.index_for(owner=user):
name = (s.get("name") or "").strip()
if not name:
continue
full = all_skills.get(name) or {}
category = (s.get("category") or full.get("category") or "general").strip() or "general"
entries.append({
"type": "skill",
"token": f"/{name}",
"name": name,
"category": f"Skills / {category}",
"help": s.get("description") or full.get("description") or "",
"usage": f"/{name} <request>",
"uses": int(full.get("uses") or 0),
"last_used": full.get("last_used"),
})
entries.sort(key=lambda row: row["name"])
return {"skills": entries, "count": len(entries)}
@router.get("/builtin")
async def list_builtin_skills(request: Request):
"""Read-only list of the agent's built-in tool capabilities (research,
@@ -1203,6 +1238,36 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
save_settings(settings)
return {"ok": True, "name": name, "is_overridden": False}
@router.post("/import-from-url")
async def import_skill_from_url(request: Request, body: SkillImportUrlRequest):
"""Install a SKILL.md bundle from a public GitHub URL (skills.sh links supported)."""
require_admin(request)
user = _owner(request)
from services.memory.skill_importer import (
SkillImportError,
fetch_skill_bundle,
)
try:
files, _src = fetch_skill_bundle(body.url.strip())
entry = skills_manager.import_bundle_from_files(
files,
owner=user,
source_url=body.url.strip(),
)
except SkillImportError as e:
raise HTTPException(400, str(e)) from e
except httpx.HTTPError as e:
logger.warning("skill import fetch failed: %s", e)
detail = str(e).strip() or "Could not download skill from URL"
raise HTTPException(502, detail) from e
except Exception as e:
logger.error("skill import failed: %s", e)
raise HTTPException(500, "Skill import failed") from e
_fire_skill_added(user)
return {"ok": True, "skill": entry, "files": len(files)}
@router.post("/add")
async def add_skill(request: Request, body: SkillAddRequest):
user = _owner(request)
@@ -1236,6 +1301,47 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
_fire_skill_added(user)
return {"ok": True, "deduped": bool(entry.get("_deduped")), "skill": entry}
@router.post("/{skill_id}/invoke")
async def invoke_skill(request: Request, skill_id: str):
"""Build a skill-pinned prompt for slash-command invocation.
This is intentionally server-side so availability, ownership, and usage
accounting use the same rules as the SkillsManager.
"""
user = _owner(request)
try:
body = await request.json()
except Exception:
body = {}
request_text = (body.get("request") or "").strip() if isinstance(body, dict) else ""
invokable = {
s.get("name"): s for s in skills_manager.index_for(owner=user)
if (s.get("name") or "").strip()
}
match = invokable.get(skill_id)
if not match:
raise HTTPException(404, "Skill is not available for slash invocation")
name = match.get("name")
md = skills_manager.read_skill_md(name, owner=user)
if md is None:
raise HTTPException(404, "Skill source unavailable")
skills_manager.record_use(name, owner=user)
message = (
"Apply the skill below to my request, following its Procedure / Pitfalls / Verification.\n\n"
f"--- BEGIN SKILL ---\n{md}\n--- END SKILL ---\n\n"
+ (f"Request: {request_text}" if request_text else "Request: (use the skill as appropriate)")
)
return {
"ok": True,
"type": "skill",
"name": name,
"command": f"/{name}",
"message": message,
}
@router.get("/{skill_id}")
async def get_skill(request: Request, skill_id: str):
user = _owner(request)
+1 -3
View File
@@ -4,12 +4,10 @@
from fastapi import APIRouter, HTTPException, UploadFile, File
import logging
from src.upload_limits import read_upload_limited
from src.upload_limits import read_upload_limited, STT_MAX_AUDIO_BYTES
logger = logging.getLogger(__name__)
STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024
def setup_stt_routes(stt_service):
"""Setup STT routes with the provided STT service"""
+162 -11
View File
@@ -11,13 +11,128 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from core.database import SessionLocal, ScheduledTask, TaskRun
from core.constants import internal_api_base
from src.auth_helpers import get_current_user
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
def _maybe_cascade_calendar_event(task) -> None:
"""Delete the linked calendar event when a cookbook_serve task is
removed. Two lookup strategies:
1. PRIMARY `cookbook_event_uid` marker stashed in task.prompt
by cookbookSchedule.js right after creating the event. Direct
UID match, no ambiguity.
2. FALLBACK for tasks created before the marker was wired up
(or when the PATCH to add the marker failed silently), scan
the Cookbook calendar for events whose summary equals the
task name and delete the matches.
Best-effort throughout: errors are logged but never block the task
deletion itself."""
if not task or task.task_type != "action" or task.action != "cookbook_serve":
return
import httpx
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
if task.owner:
headers["X-Odysseus-Owner"] = task.owner
# Strategy 1: explicit UID marker in prompt.
event_uid = ""
if task.prompt:
try:
cfg = json.loads(task.prompt)
if isinstance(cfg, dict):
event_uid = (cfg.get("cookbook_event_uid") or "").strip()
except Exception:
pass
def _try_delete(uid: str) -> bool:
try:
with httpx.Client(timeout=10) as client:
r = client.delete(
f"{internal_api_base()}/api/calendar/events/{uid}",
headers=headers,
)
if r.status_code >= 400:
logger.info(
f"task delete: cascade calendar event {uid} returned "
f"HTTP {r.status_code}"
)
return False
return True
except Exception as e:
logger.warning(f"task delete: cascade calendar event {uid} failed: {e}")
return False
if event_uid:
_try_delete(event_uid)
return
# Strategy 2: scan the Cookbook calendar for matching summaries.
# Only runs for tasks missing the marker (old tasks or PATCH failures).
if not task.name:
return
try:
with httpx.Client(timeout=10) as client:
# Find the Cookbook calendar.
cal_r = client.get(f"{internal_api_base()}/api/calendar/calendars", headers=headers)
if cal_r.status_code >= 400:
return
cals = (cal_r.json() or {}).get("calendars", [])
cookbook_cal = next(
(c for c in cals if (c.get("name") or "").lower() == "cookbook"),
None,
)
if not cookbook_cal:
return
cal_href = cookbook_cal.get("href") or cookbook_cal.get("id") or ""
# List events in a wide window to catch recurring + upcoming.
from datetime import datetime as _dt, timedelta as _td, timezone as _tz
now = _dt.now(_tz.utc)
start = (now - _td(days=30)).isoformat()
end = (now + _td(days=365)).isoformat()
ev_r = client.get(
f"{internal_api_base()}/api/calendar/events",
params={"start": start, "end": end, "calendar": cal_href},
headers=headers,
)
if ev_r.status_code >= 400:
return
events = (ev_r.json() or {}).get("events", [])
# Match by exact summary. Tasks named "Serve: <model>" are
# created from the schedule modal; the event's summary mirrors
# the task name 1:1 by design.
target = (task.name or "").strip()
uids_to_delete = set()
for ev in events:
if (ev.get("summary") or "").strip() != target:
continue
uid = ev.get("uid") or ev.get("id") or ""
# Strip the "::occurrence" suffix on recurring expansions —
# we want to delete the MASTER once, not each instance.
if "::" in uid:
uid = uid.split("::", 1)[0]
if uid:
uids_to_delete.add(uid)
for uid in uids_to_delete:
_try_delete(uid)
if uids_to_delete:
logger.info(
f"task delete: cascade matched {len(uids_to_delete)} calendar event(s) "
f"by summary fallback for task {task.id} ({target!r})"
)
except Exception as e:
logger.warning(f"task delete: cascade fallback scan failed: {e}")
class TaskCreate(BaseModel):
name: Optional[str] = None
prompt: Optional[str] = None
@@ -178,20 +293,24 @@ def setup_task_routes(task_scheduler) -> APIRouter:
def _owner(request: Request):
return get_current_user(request)
async def _generate_task_name(prompt: str) -> str:
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
"""Use LLM to generate a short task name from the prompt."""
try:
from src.llm_core import llm_call_async
from core.database import Session as DbSession
db = SessionLocal()
try:
recent = db.query(DbSession).filter(
q = db.query(DbSession).filter(
DbSession.endpoint_url.isnot(None),
DbSession.model.isnot(None),
).order_by(DbSession.created_at.desc()).first()
)
if owner:
q = q.filter(DbSession.owner == owner)
recent = q.order_by(DbSession.created_at.desc()).first()
if not recent:
return prompt[:50].strip()
url, model = recent.endpoint_url, recent.model
headers = recent.headers or {}
finally:
db.close()
@@ -202,6 +321,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
{"role": "user", "content": prompt[:500]},
],
max_tokens=20,
headers=headers,
timeout=15,
)
title = result.strip().strip('"\'').strip()
@@ -316,6 +436,20 @@ def setup_task_routes(task_scheduler) -> APIRouter:
except Exception:
return False
def _validate_then_task_id(db, then_task_id: Optional[str], user: Optional[str], current_task_id: Optional[str] = None) -> Optional[str]:
target_id = (then_task_id or "").strip()
if not target_id:
return None
if current_task_id and target_id == current_task_id:
raise HTTPException(400, "Task cannot chain to itself")
q = db.query(ScheduledTask).filter(ScheduledTask.id == target_id)
if user:
q = q.filter(ScheduledTask.owner == user)
target = q.first()
if not target:
raise HTTPException(404, "Chained task not found")
return target.id
@router.post("")
async def create_task(request: Request, req: TaskCreate):
user = _owner(request)
@@ -352,7 +486,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
from src.builtin_actions import BUILTIN_ACTION_INFO
name = BUILTIN_ACTION_INFO.get(req.action, req.action or "Action Task")
elif req.prompt:
name = await _generate_task_name(req.prompt)
name = await _generate_task_name(req.prompt, owner=user)
else:
name = "Untitled Task"
@@ -379,11 +513,21 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task_id = str(uuid.uuid4())
db = SessionLocal()
try:
then_task_id = _validate_then_task_id(db, req.then_task_id, user)
notifications_enabled = (
False if req.task_type == "action" and req.notifications_enabled is None
else bool(req.notifications_enabled) if req.notifications_enabled is not None
else True
)
# Validate chained task belongs to same owner
if req.then_task_id:
chain_target = db.query(ScheduledTask).filter(
ScheduledTask.id == req.then_task_id
).first()
if not chain_target:
raise HTTPException(400, "Chained task not found")
if chain_target.owner != user:
raise HTTPException(403, "Cannot chain to another user's task")
task = ScheduledTask(
id=task_id,
owner=user,
@@ -405,7 +549,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
output_target=req.output_target,
model=req.model or None,
endpoint_url=req.endpoint_url or None,
then_task_id=req.then_task_id or None,
then_task_id=then_task_id,
webhook_token=webhook_token,
notifications_enabled=notifications_enabled,
)
@@ -487,7 +631,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
removed_files = 0
if action == "check_email_urgency":
cache_dir = Path("data/email_urgency_cache")
cache_dir = Path(EMAIL_URGENCY_CACHE_DIR)
if cache_dir.exists():
for child in cache_dir.glob("*.json"):
try:
@@ -496,7 +640,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
except Exception:
pass
owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (user or "default"))
for state_path in [Path(f"data/email_urgency_state_{owner_slug}.json")]:
for state_path in [Path(DATA_DIR) / f"email_urgency_state_{owner_slug}.json"]:
try:
if state_path.exists():
state_path.unlink()
@@ -558,7 +702,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.trigger_count is not None:
task.trigger_count = req.trigger_count
if req.then_task_id is not None:
task.then_task_id = req.then_task_id or None
task.then_task_id = _validate_then_task_id(db, req.then_task_id, user, current_task_id=task.id)
if req.notifications_enabled is not None:
task.notifications_enabled = bool(req.notifications_enabled)
if req.cron_expression is not None:
@@ -616,6 +760,12 @@ def setup_task_routes(task_scheduler) -> APIRouter:
raise HTTPException(404, "Task not found")
if user and task.owner != user:
raise HTTPException(403, "Access denied")
# Cascade: cookbook_serve tasks may have a linked calendar
# event (created via the "Create event in calendar" toggle
# in the schedule modal). If so, delete the calendar event
# too so the calendar doesn't end up holding a phantom event
# for a task that no longer exists.
_maybe_cascade_calendar_event(task)
db.delete(task)
db.commit()
return {"ok": True}
@@ -833,7 +983,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
"tag", "label", "move", "archive", "delete", "mark", "schedule",
)
try:
from src.agent_tools import get_mcp_manager
from src.tool_utils import get_mcp_manager
mcp = get_mcp_manager()
if mcp:
for tool in mcp.get_all_tools():
@@ -928,6 +1078,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
desc = (body.get("description") or "").strip()
if not desc:
return {"success": False, "message": "Nothing to parse"}
user = _owner(request)
now = _dt.now()
# Give the model the current date/time + weekday so relative phrasing
@@ -954,9 +1105,9 @@ def setup_task_routes(task_scheduler) -> APIRouter:
"use cron '0 H * * 1-5'. Keep the prompt actionable and self-contained."
)
try:
url, model, headers = resolve_endpoint("utility")
url, model, headers = resolve_endpoint("utility", owner=user or None)
if not url:
url, model, headers = resolve_endpoint("default")
url, model, headers = resolve_endpoint("default", owner=user or None)
if not (url and model):
return {"success": False, "message": "No model endpoint configured"}
raw = await llm_call_async(
+51 -34
View File
@@ -13,9 +13,43 @@ from src.upload_handler import count_recent_uploads
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/upload", tags=["upload"])
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
def setup_upload_routes(upload_handler):
"""Setup upload routes with the provided handler"""
def _upload_root() -> str:
from src.constants import UPLOAD_DIR
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool:
try:
return os.path.commonpath([_upload_root(), os.path.realpath(path)]) == _upload_root()
except Exception:
return False
def _resolve_upload_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
direct = os.path.join(upload_root, file_id)
if os.path.lexists(direct):
if not _path_inside_upload_dir(direct):
raise HTTPException(403, "Access denied")
if os.path.isfile(direct):
return direct
raise HTTPException(404, "File not found")
for root, _dirs, files in os.walk(upload_root, followlinks=False):
if file_id not in files:
continue
path = os.path.join(root, file_id)
if not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
if os.path.isfile(path):
return path
raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found")
@router.post("")
async def api_upload(request: Request, files: List[UploadFile] = File(...)):
@@ -91,23 +125,11 @@ def setup_upload_routes(upload_handler):
client isn't downloading the full-resolution photo just to show it tiny."""
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
# Search upload directories for the file
from src.constants import UPLOAD_DIR
import mimetypes as _mt
path = os.path.join(UPLOAD_DIR, file_id)
if not os.path.exists(path):
for root, dirs, files in os.walk(UPLOAD_DIR):
if file_id in files:
path = os.path.join(root, file_id)
break
else:
raise HTTPException(404, "File not found")
if not upload_handler.inside_base_dir(path):
raise HTTPException(403, "Access denied")
# Look up original filename and owner from uploads.json
original_name = file_id
info = None
uploads_db = os.path.join(UPLOAD_DIR, "uploads.json")
uploads_db = os.path.join(_upload_root(), "uploads.json")
if os.path.exists(uploads_db):
with open(uploads_db, encoding="utf-8") as f:
db = json.load(f)
@@ -123,13 +145,14 @@ def setup_upload_routes(upload_handler):
raise HTTPException(403, "Access denied")
if file_owner != current_user and not auth_mgr.is_admin(current_user):
raise HTTPException(404, "File not found")
mime = _mt.guess_type(path)[0] or "application/octet-stream"
path = _resolve_upload_path(file_id)
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
from fastapi.responses import FileResponse
# Downscaled thumbnail for image previews — generated once and cached.
if thumb and mime.startswith("image/"):
try:
from PIL import Image, ImageOps
thumb_dir = os.path.join(UPLOAD_DIR, ".thumbs")
thumb_dir = os.path.join(_upload_root(), ".thumbs")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
if (not os.path.exists(thumb_path)
@@ -145,17 +168,21 @@ def setup_upload_routes(upload_handler):
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg")
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
return FileResponse(path, media_type=mime, filename=original_name)
return FileResponse(
path,
media_type=mime,
filename=original_name,
headers=UPLOAD_RESPONSE_HEADERS,
)
def _load_upload_info(file_id: str):
"""Look up the uploads.json record for a file_id, with owner/auth checks."""
from src.constants import UPLOAD_DIR
info = None
uploads_db = os.path.join(UPLOAD_DIR, "uploads.json")
uploads_db = os.path.join(_upload_root(), "uploads.json")
if os.path.exists(uploads_db):
with open(uploads_db, encoding="utf-8") as f:
db = json.load(f)
@@ -163,8 +190,7 @@ def setup_upload_routes(upload_handler):
return info
def _vision_cache_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR
cache_dir = os.path.join(UPLOAD_DIR, ".vision")
cache_dir = os.path.join(_upload_root(), ".vision")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, file_id + ".txt")
@@ -175,17 +201,6 @@ def setup_upload_routes(upload_handler):
subsequent loads are instant. Pass force=1 to recompute."""
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
from src.constants import UPLOAD_DIR
path = os.path.join(UPLOAD_DIR, file_id)
if not os.path.exists(path):
for root, dirs, files in os.walk(UPLOAD_DIR):
if file_id in files:
path = os.path.join(root, file_id)
break
else:
raise HTTPException(404, "File not found")
if not upload_handler.inside_base_dir(path):
raise HTTPException(403, "Access denied")
info = _load_upload_info(file_id)
auth_mgr = getattr(request.app.state, "auth_manager", None)
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
@@ -196,8 +211,9 @@ def setup_upload_routes(upload_handler):
raise HTTPException(403, "Access denied")
if file_owner != current_user and not auth_mgr.is_admin(current_user):
raise HTTPException(404, "File not found")
path = _resolve_upload_path(file_id)
import mimetypes as _mt
mime = _mt.guess_type(path)[0] or ""
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or ""
if not mime.startswith("image/"):
raise HTTPException(400, "Not an image")
cache_path = _vision_cache_path(file_id)
@@ -209,7 +225,7 @@ def setup_upload_routes(upload_handler):
logger.warning(f"Vision cache read failed for {file_id}: {e}")
from src.document_processor import analyze_image_with_vl
try:
text = analyze_image_with_vl(path) or ""
text = analyze_image_with_vl(path, owner=current_user) or ""
except Exception as e:
logger.error(f"Vision analysis failed for {file_id}: {e}")
raise HTTPException(500, f"Vision analysis failed: {e}")
@@ -238,6 +254,7 @@ def setup_upload_routes(upload_handler):
raise HTTPException(403, "Access denied")
if file_owner != current_user and not auth_mgr.is_admin(current_user):
raise HTTPException(404, "File not found")
_resolve_upload_path(file_id)
body = await request.json()
text = (body or {}).get("text", "")
if not isinstance(text, str):
+2 -1
View File
@@ -17,10 +17,11 @@ from pydantic import BaseModel
from core.middleware import require_admin
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
from src.constants import VAULT_FILE as _VAULT_FILE
logger = logging.getLogger(__name__)
VAULT_FILE = Path("data/vault.json")
VAULT_FILE = Path(_VAULT_FILE)
def _find_bw() -> str:
+23 -10
View File
@@ -194,6 +194,8 @@ def setup_webhook_routes(
"together": "https://api.together.xyz/v1",
"openrouter": "https://openrouter.ai/api/v1",
"ollama": "https://ollama.com/api",
"opencode-zen": "https://opencode.ai/zen/v1",
"opencode-go": "https://opencode.ai/zen/go/v1",
"fireworks": "https://api.fireworks.ai/inference/v1",
"venice": "https://api.venice.ai/api/v1",
}
@@ -323,22 +325,33 @@ def setup_webhook_routes(
endpoint_url = build_chat_url(base_url)
model = body.model or "auto"
api_key = ep.api_key
if getattr(ep, "provider_auth_id", None):
try:
from src.endpoint_resolver import resolve_endpoint_runtime
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
endpoint_url = build_chat_url(base_url)
except Exception:
raise HTTPException(500, "Could not resolve endpoint credentials")
if model == "auto":
try:
async with httpx.AsyncClient(timeout=5) as client:
models_url = build_models_url(base_url)
hdrs = build_headers(api_key, base_url)
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
data = resp.json()
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not ids:
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
if models_url:
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
data = resp.json()
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not ids:
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
import json as _json
ids = _json.loads(ep.cached_models or "[]")
model = ids[0] if ids else "auto"
except Exception:
raise HTTPException(500, "Could not discover models from endpoint")
-56
View File
@@ -1,56 +0,0 @@
"""Workspace API — browse server directories to pick a tool workspace folder."""
import os
from fastapi import APIRouter, Request, HTTPException, Query
from src.auth_helpers import get_current_user
from src.tool_security import owner_is_admin_or_single_user
def setup_workspace_routes():
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
@router.get("/browse")
def browse(request: Request, path: str = Query(default="")):
"""List subdirectories of `path` (default: home) so the UI can navigate
the server filesystem and pick a workspace folder. Directories only.
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
same way the file/shell tools are (read_file/write_file/bash are in
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
be able to map the host's directory tree either.
"""
owner = get_current_user(request)
if not owner_is_admin_or_single_user(owner):
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
# Resolve symlinks so the reported path is canonical and the UI navigates
# real directories (defends against symlink games in displayed paths).
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
if not os.path.isdir(target):
target = os.path.realpath(os.path.expanduser("~"))
dirs = []
try:
with os.scandir(target) as it:
for entry in it:
try:
# Don't follow symlinks when classifying — a symlinked
# dir is skipped rather than letting the browser wander
# off via a link. Hidden entries are omitted.
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
# Build the child path server-side with os.path.join
# so it's correct on Windows (backslashes) and Linux.
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
except OSError:
continue
except (PermissionError, OSError):
dirs = []
parent = os.path.dirname(target)
return {
"path": target,
"parent": parent if parent and parent != target else None,
"dirs": sorted(dirs, key=lambda d: d["name"].lower()),
}
return router
+4 -2
View File
@@ -13,6 +13,8 @@ import json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.constants import MEMORY_FILE, SKILLS_FILE
def claim_json_entries(entries, owner):
count = 0
@@ -35,8 +37,8 @@ def main():
# 1. Memories (JSON files)
for label, path in [
("memory.json", "data/memory.json"),
("skills.json", "data/skills.json"),
("memory.json", MEMORY_FILE),
("skills.json", SKILLS_FILE),
]:
if not os.path.exists(path):
print(f" {label}: not found, skipping")
+76 -1
View File
@@ -34,6 +34,7 @@ import torch
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO)
@@ -52,7 +53,63 @@ async def lifespan(application):
app = FastAPI(title="Diffusion Server", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# Conservative defaults — server is designed for server-to-server use from
# the Odysseus backend. Wildcard CORS + the 127.0.0.1 default bind used to
# leave the server reachable via DNS-rebinding from any browser tab on the
# same host. The CLI flags below extend these allowlists for operators who
# need browser access; the safe defaults handle the common case.
_DEFAULT_ALLOWED_HOSTS = ["127.0.0.1", "localhost", "::1"]
_DEFAULT_CORS_ORIGINS: list = [] # default-deny
def _compute_allowed_hosts(bind_host: str, extras=None) -> list:
"""Allowed Host header values: the bind address + loopback variants +
any operator-supplied --allowed-host values. Duplicates and empty
strings are dropped; order is stable for predictable middleware setup."""
seen = []
for h in (bind_host, *_DEFAULT_ALLOWED_HOSTS, *(extras or [])):
h = (h or "").strip()
if h and h not in seen:
seen.append(h)
return seen
def _compute_cors_origins(extras=None) -> list:
"""CORS allowlist: default-deny (empty), extended only by explicit
--allowed-origin values. Server-to-server callers don't set an Origin
header so they're unaffected; this only narrows browser access."""
seen = []
for o in (*_DEFAULT_CORS_ORIGINS, *(extras or [])):
o = (o or "").strip()
if o and o not in seen:
seen.append(o)
return seen
def _configure_security_middleware(application, allowed_hosts, allowed_origins):
"""Replace `application`'s user middleware stack with the diffusion server
security middleware: the TrustedHost allowlist and, when origins are
supplied, CORS. Used at module load and by the __main__ CLI path before
serving starts. Raises before mutating if the middleware stack has already
been built. Order is preserved: TrustedHost first, then CORS (added last ->
outermost)."""
if application.middleware_stack is not None:
raise RuntimeError("security middleware must be configured before the app starts serving")
application.user_middleware.clear()
application.add_middleware(TrustedHostMiddleware, allowed_hosts=list(allowed_hosts))
if allowed_origins:
application.add_middleware(
CORSMiddleware,
allow_origins=list(allowed_origins),
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
# Install defaults at module load so importing the app for tests / direct
# uvicorn invocation still benefits from the Host-header allowlist.
_configure_security_middleware(app, _DEFAULT_ALLOWED_HOSTS, _DEFAULT_CORS_ORIGINS)
class ImageRequest(BaseModel):
@@ -1089,7 +1146,25 @@ if __name__ == "__main__":
parser.add_argument("--attention-slicing", action="store_true", help="Enable attention slicing")
parser.add_argument("--vae-slicing", action="store_true", help="Enable VAE slicing")
parser.add_argument("--harmonize-gpu", type=int, default=None, help="GPU index for harmonize/img2img (default: same as main)")
parser.add_argument("--allowed-host", action="append", default=[],
help="Additional Host header value to accept (DNS-rebinding allowlist). "
"Can be repeated. Loopback values are always included.")
parser.add_argument("--allowed-origin", action="append", default=[],
help="Additional CORS origin to allow. Can be repeated. Defaults to "
"no cross-origin access — only pass this if you need a browser "
"on a specific origin to call the server.")
_args = parser.parse_args()
# Replace the module-load middleware stack with the CLI-configured one so
# operator-supplied --allowed-host / --allowed-origin values take effect
# before the first request is served. user_middleware is consulted lazily
# when the middleware stack is built on the first request, so mutating it
# here is safe.
final_hosts = _compute_allowed_hosts(_args.host, _args.allowed_host)
final_origins = _compute_cors_origins(_args.allowed_origin)
_configure_security_middleware(app, final_hosts, final_origins)
logger.info("security middleware: allowed_hosts=%s allowed_origins=%s",
final_hosts, final_origins or "(none — default-deny)")
app.state.model_path = _args.model
uvicorn.run(app, host=_args.host, port=_args.port)
+4 -1
View File
@@ -19,6 +19,9 @@ import sys
from pathlib import Path
from typing import List, Tuple
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.constants import PERSONAL_DIR
# Configure logging for the script
logging.basicConfig(
level=logging.INFO,
@@ -45,7 +48,7 @@ def main():
rag_manager = RAGManager()
# Directory to scan
docs_directory = "data/personal_docs"
docs_directory = PERSONAL_DIR
directory_path = Path(docs_directory)
# Check if directory exists
+3 -3
View File
@@ -63,10 +63,10 @@ def migrate_memories():
"""Migrate memory vectors from FAISS to ChromaDB."""
from src.chroma_client import get_chroma_client
from src.embeddings import get_embedding_client
from src.constants import DATA_DIR
from src.constants import MEMORY_VECTORS_DIR, MEMORY_FILE
ids_path = os.path.join(DATA_DIR, "memory_vectors", "ids.json")
memory_path = os.path.join(DATA_DIR, "memory.json")
ids_path = os.path.join(MEMORY_VECTORS_DIR, "ids.json")
memory_path = MEMORY_FILE
if not os.path.exists(ids_path):
logger.info("No memory FAISS index found, skipping memory migration")
+23 -1
View File
@@ -47,6 +47,9 @@ _STATE_PATH = _DATA_DIR / "cookbook_state.json"
import tempfile
_TMUX_LOG_DIR = Path(tempfile.gettempdir()) / "odysseus-tmux"
from core.platform_compat import NVIDIA_PATH_CANDIDATES, SSH_PATH_OVERRIDE
def fail(msg: str, code: int = 1) -> None:
sys.stderr.write(f"error: {msg}\n")
@@ -160,7 +163,26 @@ def cmd_gpus(args) -> None:
prefix = _ssh_prefix(args.host, args.ssh_port)
cmd = prefix + (query.split() if not prefix else [query])
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if prefix:
candidates = [query]
args_part = query[len("nvidia-smi "):]
candidates.append(
"bash -lc "
+ repr(
f"{SSH_PATH_OVERRIDE}"
f"nvidia-smi {args_part}"
)
)
for nvidia_path in NVIDIA_PATH_CANDIDATES:
candidates.append(f"{nvidia_path} {args_part}")
out = None
for candidate in candidates:
out = subprocess.run(prefix + [candidate], capture_output=True, text=True, timeout=15)
if out.returncode == 0:
break
else:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except FileNotFoundError:
# No nvidia-smi locally → try the Metal fallback before giving up.
if not prefix:
+19 -1
View File
@@ -25,6 +25,24 @@ from pathlib import Path
_DATA_DIR = _REPO_ROOT / "data" / "deep_research"
# The CLI's --status takes the user-facing label "complete", but the writer
# in services/research/research_handler.py stores `status="done"` when a run
# finishes (and the legacy src/research_handler.py does the same). Without
# this alias, --status complete filters every finished record out and the
# user sees an empty list. Map at filter time so the on-disk corpus is the
# source of truth and the CLI surface stays the friendlier word. The other
# choices ("running", "cancelled", "error") are stored verbatim, so they
# fall through unchanged.
_STATUS_CLI_TO_STORED = {"complete": "done"}
def _status_matches(stored, requested: str) -> bool:
stored = (stored or "")
if not isinstance(stored, str):
stored = ""
target = _STATUS_CLI_TO_STORED.get(requested, requested)
return stored == target
def _load_path(path: Path) -> dict | None:
try:
@@ -72,7 +90,7 @@ def cmd_list(args):
data = _load_path(path)
if data is None:
continue
if args.status and (data.get("status") or "") != args.status:
if args.status and not _status_matches(data.get("status"), args.status):
continue
out.append(_summarize(rp_id, data))
out.sort(key=lambda r: r.get("started_at") or "", reverse=True)
+2 -1
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import List, Dict, Any
from src.rag_manager import RAGManager
from src.constants import CHROMA_DIR
@dataclass
@@ -34,7 +35,7 @@ class DocsService:
results = await service.query("what is async await?")
"""
def __init__(self, persist_dir: str = "data/chroma"):
def __init__(self, persist_dir: str = CHROMA_DIR):
self.rag = RAGManager(persist_directory=persist_dir)
async def query(self, query: str, top_k: int = 5) -> List[DocChunk]:
+24 -1
View File
@@ -14036,6 +14036,29 @@
"vision"
]
},
{
"name": "google/gemma-4-12B",
"provider": "Google",
"parameter_count": "12.0B",
"parameters_raw": 12000000000,
"min_ram_gb": 24.0,
"recommended_ram_gb": 32.0,
"min_vram_gb": 24.0,
"quantization": "BF16",
"context_length": 131072,
"use_case": "General purpose, multimodal",
"is_moe": false,
"num_experts": null,
"active_experts": null,
"active_parameters": null,
"architecture": "gemma4",
"pipeline_tag": "image-text-to-text",
"release_date": "2026-04-01",
"gguf_sources": [],
"capabilities": [
"vision"
]
},
{
"name": "google/gemma-4-31B-it",
"provider": "Google",
@@ -19121,4 +19144,4 @@
],
"_discovered": true
}
]
]
+93 -48
View File
@@ -4,6 +4,13 @@ import re
import shutil
import subprocess
import time
import shlex
from core.platform_compat import (
NVIDIA_PATH_CANDIDATES,
SSH_PATH_OVERRIDE,
run_ssh_command,
)
CACHE_TTL = 24 * 3600 # 24 h — hardware probes are user-initiated via the Rescan button; bumped
# from 30 min so changing filters doesn't keep re-probing the rig every
@@ -21,16 +28,17 @@ def _run(cmd):
if _remote_host:
# Run command on remote host via SSH
if isinstance(cmd, list):
cmd_str = " ".join(cmd)
cmd_str = shlex.join(str(c) for c in cmd)
else:
cmd_str = cmd
ssh_cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no"]
if _remote_port and _remote_port != "22":
ssh_cmd += ["-p", _remote_port]
ssh_cmd += [_remote_host, cmd_str]
r = subprocess.run(
ssh_cmd,
capture_output=True, text=True, timeout=15,
r = run_ssh_command(
_remote_host,
_remote_port,
cmd_str,
timeout=15,
connect_timeout=5,
strict_host_key_checking=False,
text=True,
)
else:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
@@ -76,21 +84,29 @@ def _detect_nvidia():
global _last_gpu_error
_last_gpu_error = None
out = _run(["nvidia-smi", "--query-gpu=memory.total,name", "--format=csv,noheader,nounits"])
# Remote fallback: a non-interactive SSH shell often has a minimal PATH
# that omits where nvidia-smi lives (/usr/bin, /usr/local/cuda/bin), so the
# first call silently returns nothing → "No GPU" on hosts that DO have GPUs.
# Fallback: a non-interactive shell (or WSL) often has a minimal PATH
# that omits where nvidia-smi lives (/usr/bin, /usr/local/cuda/bin,
# /usr/lib/wsl/lib), so the first call silently returns nothing →
# "No GPU" on machines that DO have GPUs.
# Retry through a login shell with the common CUDA bin dirs on PATH.
if not out and _remote_host:
out = _run(
"bash -lc 'export PATH=\"$PATH:/usr/bin:/usr/local/bin:/usr/local/cuda/bin\"; "
f"bash -lc '{SSH_PATH_OVERRIDE}"
"nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits'"
)
# Last resort: call nvidia-smi by absolute path. Some hosts have a login
# shell that isn't bash (or a profile that errors), so the bash -lc retry
# above still comes back empty even though the binary is right there.
if not out and _remote_host:
for _p in ("/usr/bin/nvidia-smi", "/usr/local/bin/nvidia-smi", "/usr/local/cuda/bin/nvidia-smi"):
out = _run(f"{_p} --query-gpu=memory.total,name --format=csv,noheader,nounits")
# Also handles WSL where nvidia-smi lives at /usr/lib/wsl/lib/ — a path
# that may not be in the server process's PATH.
if not out:
for _p in NVIDIA_PATH_CANDIDATES:
# Use list form so subprocess.run (local) resolves the absolute path
# correctly instead of treating the whole string as an executable name.
if _remote_host:
out = _run(f"{_p} --query-gpu=memory.total,name --format=csv,noheader,nounits")
else:
out = _run([_p, "--query-gpu=memory.total,name", "--format=csv,noheader,nounits"])
if out:
break
if not out:
@@ -468,39 +484,55 @@ def _detect_windows():
"""
# Single PowerShell command that gathers all hardware info at once
ps_cmd = (
"$r = @{}; "
"$os = Get-CimInstance Win32_OperatingSystem; "
"$r.ram_gb = [math]::Round($os.TotalVisibleMemorySize / 1048576, 1); "
"$r.avail_gb = [math]::Round($os.FreePhysicalMemory / 1048576, 1); "
"$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1; "
"$r.cpu_name = $cpu.Name; "
"$r.cpu_cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum; "
"$r.arch = $cpu.AddressWidth; "
"""
$r = @{}
$os = Get-CimInstance Win32_OperatingSystem
$r.ram_gb = [math]::Round($os.TotalVisibleMemorySize / 1048576, 1)
$r.avail_gb = [math]::Round($os.FreePhysicalMemory / 1048576, 1)
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$r.cpu_name = $cpu.Name
$r.cpu_cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$r.arch = $cpu.AddressWidth
# GPU detection via nvidia-smi (fastest) or WMI fallback
"try { "
" $nv = nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits 2>$null; "
" if ($LASTEXITCODE -eq 0 -and $nv) { "
" $gpus = @(); "
" foreach ($line in $nv -split \"`n\") { "
" $p = $line -split ','; "
" if ($p.Count -ge 2) { $gpus += [pscustomobject]@{name=$p[1].Trim(); vram_mb=[double]$p[0].Trim()} } "
" }; "
" $r.gpu_name = $gpus[0].name; "
" $r.gpu_vram_gb = [math]::Round(($gpus | Measure-Object -Property vram_mb -Sum).Sum / 1024, 1); "
" $r.gpu_count = $gpus.Count; "
" $r.gpu_backend = 'cuda'; "
" } "
"} catch {}; "
"if (-not $r.gpu_name) { "
" $wmiGpu = Get-CimInstance Win32_VideoController | Where-Object { $_.AdapterRAM -gt 0 } | Select-Object -First 1; "
" if ($wmiGpu) { "
" $r.gpu_name = $wmiGpu.Name; "
" $r.gpu_vram_gb = [math]::Round($wmiGpu.AdapterRAM / 1073741824, 1); "
" $r.gpu_count = 1; "
" $r.gpu_backend = 'cpu_x86'; " # WMI doesn't tell us CUDA/ROCm
" } "
"}; "
"$r | ConvertTo-Json -Compress"
try {
$nv = nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits 2>$null
if ($LASTEXITCODE -eq 0 -and $nv) {
$gpus = @()
foreach ($line in $nv -split "`n") {
$p = $line -split ','
if ($p.Count -ge 2) { $gpus += [pscustomobject]@{name = $p[1].Trim(); vram_mb = [double]$p[0].Trim() } }
}
$r.gpu_name = $gpus[0].name
$r.gpu_vram_gb = [math]::Round(($gpus | Measure-Object -Property vram_mb -Sum).Sum / 1024, 1)
$r.gpu_count = $gpus.Count
$r.gpu_backend = 'cuda'
}
}
catch {}
if (-not $r.gpu_name) {
$wmiGpu = Get-CimInstance Win32_VideoController | Where-Object { $_.AdapterRAM -gt 0 } | Select-Object -First 1
$GPUDriverKey = "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\0*"
$GPUDeviceID = $wmiGpu.PNPDeviceID.Split('&')[0..1] -join '&'
$VRAMfromRegistry = Get-ItemProperty -Path $GPUDriverKey |
Where-Object { $_.MatchingDeviceId -like "${GPUDeviceID}*" } |
# Sometimes there happen to be multiple driver classes for the same gpu.
Select-Object -ExpandProperty HardwareInformation.qwMemorySize -ErrorAction SilentlyContinue -First 1
if ($wmiGpu) {
$r.gpu_name = $wmiGpu.Name
# Edge case: driver is broken, otherwise $wmiGpu.AdapterRAM is redundant
if ($VRAMfromRegistry -ge $wmiGpu.AdapterRAM) {
$r.gpu_vram_gb = [math]::Round($VRAMfromRegistry / 1073741824, 1)
}
else {
$r.gpu_vram_gb = [math]::Round($wmiGpu.AdapterRAM / 1073741824, 1)
}
$r.gpu_count = 1
# WMI doesn't tell us CUDA/ROCm
$r.gpu_backend = 'cpu_x86';
}
}
$r | ConvertTo-Json -Compress
"""
)
if _remote_host:
# Remote: ship a single command string over SSH. The remote shell parses
@@ -566,6 +598,19 @@ def _detect_windows():
_cache_by_host = {} # host -> (timestamp, result)
def _cache_key(host: str, ssh_port: str, platform_name: str):
"""Build a stable cache key that isolates remote SSH context.
Same host aliases can have different hardware due to visibility, forwarding etc.
To avoid using the wrong cached hardware info, include the SSH port and platform in the cache key.
"""
return (
host or "_local",
str(ssh_port or ""),
str(platform_name or "").lower(),
)
def detect_system(host="", ssh_port="", platform="", fresh=False):
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely
changes, and probing a remote host over SSH is slow). Pass fresh=True to
@@ -575,7 +620,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"""
global _remote_host, _remote_port, _remote_platform
cache_key = host or "_local"
cache_key = _cache_key(host, ssh_port, platform)
now = time.time()
if not fresh and cache_key in _cache_by_host:
ts, cached = _cache_by_host[cache_key]
+85 -14
View File
@@ -192,11 +192,19 @@ def _fallback_memory_candidates(messages) -> list[dict]:
if place:
add(f"User lives in {place}.", "identity")
m = re.search(r"\bi (?:prefer|like|love|hate|do not like|don't like)\s+([^.!?\n]{4,100})", text, re.I)
m = re.search(r"\bi (prefer|like|love|hate|do not like|don't like)\s+([^.!?\n]{4,100})", text, re.I)
if m:
preference = _clean_memory_value(m.group(1), 100)
preference = _clean_memory_value(m.group(2), 100)
if preference:
add(f"User prefers {preference}.", "preference")
# The same pattern catches likes and dislikes; keep the stored
# sentiment faithful instead of recording every match as a
# preference ("I hate cilantro" must not become "User prefers
# cilantro").
verb = m.group(1).lower()
if verb in ("hate", "do not like", "don't like"):
add(f"User dislikes {preference}.", "preference")
else:
add(f"User prefers {preference}.", "preference")
m = re.search(
r"\bi (?:(?:want|would like|plan|hope) to|wanna) "
@@ -228,6 +236,43 @@ def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) ->
return False
def _parse_extraction_json(raw: str) -> list:
"""Parse the extraction LLM's reply into a list of facts, tolerating
reasoning-model noise.
The model emits <think></think> (and sometimes a prose preamble or a
```json fence) AROUND the JSON array; without stripping it, json.loads
bombs and the run silently yields "0 candidates". Pure str -> list (no
LLM/network); returns [] on any parse failure instead of raising.
"""
text = (raw or "").strip()
try:
from src.text_helpers import strip_think as _strip_think
text = _strip_think(text, prose=True, prompt_echo=True).strip()
except Exception:
pass
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
# JSON may still be embedded in surrounding commentary (leading prose or
# trailing remarks like "[...] Done!") — slice from the first '[' to the
# last ']' whenever both exist. Slice unconditionally: a reply that starts
# with '[' can still carry trailing commentary that breaks json.loads.
_start = text.find("[")
_end = text.rfind("]")
if 0 <= _start < _end:
text = text[_start : _end + 1]
try:
facts = json.loads(text)
except json.JSONDecodeError:
logger.debug("Memory extraction returned non-JSON: %r", (raw or "")[:120])
return []
except Exception:
logger.debug("Memory extraction returned non-JSON: %r", (raw or "")[:120])
return []
return facts if isinstance(facts, list) else []
async def extract_and_store(
session,
memory_manager,
@@ -276,9 +321,34 @@ async def extract_and_store(
fallback_facts = _fallback_memory_candidates(stripped_recent)
# Flatten the window into a SINGLE user message instead of appending the
# raw alternating role messages. Passed as raw chat messages, the model
# treats the window as a conversation to CONTINUE rather than a transcript
# to ANALYZE, so it reliably extracts nothing — typically returning `[]`
# (and, depending on the input, sometimes an empty or <think>-only
# completion when the window ends on an assistant turn). This was the real
# cause of auto-memory logging "0 candidates" on every run. Reframing it as
# one "analyze this transcript, return the JSON array" user message makes
# the model actually extract. Controlled repro on this model: 0/6 trials
# with the old structure vs 6/6 with this one. The skill extractor flattens
# for the same reason.
def _flatten_msg(m):
c = m.get("content", "")
if isinstance(c, list):
c = " ".join(
b.get("text", "") for b in c
if isinstance(b, dict) and b.get("type") == "text"
)
return f"{m.get('role', '?')}: {c}"
transcript = "\n\n".join(_flatten_msg(m) for m in stripped_recent)
extraction_messages = [
{"role": "system", "content": EXTRACT_SYSTEM_PROMPT},
] + stripped_recent
{"role": "user", "content": (
"Conversation to analyze:\n\n" + transcript
+ "\n\nReturn the JSON array of durable facts now (or [] if none)."
)},
]
facts = []
try:
@@ -287,19 +357,20 @@ async def extract_and_store(
model,
extraction_messages,
temperature=0.1,
max_tokens=500,
# A reasoning model spends most of its budget on <think> tokens
# BEFORE emitting the JSON, so the old 500 truncated the response
# before any JSON appeared → every run logged "0 candidates". The
# audit path hit the same wall and raised to 16384; extraction's
# output (a short facts list) is small, so an ample ceiling is
# enough once thinking has room.
max_tokens=4096,
headers=headers,
)
# Parse JSON from response (handle markdown fences if model wraps them)
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
try:
facts = json.loads(text)
except json.JSONDecodeError:
logger.debug("Memory extraction returned non-JSON")
# Parse JSON, tolerating reasoning-model noise (<think> blocks, a
# ```json fence, and leading/trailing commentary). See
# _parse_extraction_json — returns [] rather than raising.
facts = _parse_extraction_json(raw)
except Exception as e:
logger.warning(f"LLM memory extraction failed; using fallback candidates if available: {e}")
+2 -1
View File
@@ -8,6 +8,7 @@ import os
from .memory import MemoryManager
from .memory_vector import MemoryVectorStore
from src.memory_provider import MemoryRecord, NativeMemoryProvider
from src.constants import DATA_DIR
@dataclass
@@ -38,7 +39,7 @@ class MemoryService:
results = await service.recall("preferences")
"""
def __init__(self, data_dir: str = "data"):
def __init__(self, data_dir: str = DATA_DIR):
self.manager = MemoryManager(data_dir)
self.vector_store = MemoryVectorStore(data_dir) if os.path.exists(
os.path.join(data_dir, "memory_vectors")
+63 -15
View File
@@ -63,6 +63,46 @@ def _has_duplicate_title(skills, title: str) -> bool:
return False
def _extract_json_object(text: str) -> Optional[dict]:
"""Best-effort extraction of a JSON object from an LLM response.
The response may be wrapped in code fences or surrounded by prose, and some
models emit a stray brace in the prose before the real object
(e.g. "uses {placeholder} then {...}"). Slicing first-'{' .. last-'}' then
grabs an unparseable span and the skill is silently lost. Try the whole
string first, then each '{' start position in turn, returning the first
candidate that parses to a JSON object (dict). Returns None if none do.
"""
if not text:
return None
s = text.strip()
if s.startswith("```"):
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
end = s.rfind("}")
if end == -1:
return None
def _as_dict(candidate):
try:
obj = json.loads(candidate)
except (json.JSONDecodeError, ValueError):
return None
return obj if isinstance(obj, dict) else None
# The clean, common case: the whole (de-fenced) string is the object.
obj = _as_dict(s)
if obj is not None:
return obj
# Otherwise scan each '{' candidate up to the last '}'.
start = s.find("{")
while 0 <= start < end:
obj = _as_dict(s[start : end + 1])
if obj is not None:
return obj
start = s.find("{", start + 1)
return None
async def maybe_extract_skill(
session,
skills_manager,
@@ -169,21 +209,14 @@ async def maybe_extract_skill(
except Exception:
pass
# Parse JSON
text = response.strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
# After strip_think, the JSON may still be embedded inside surrounding
# commentary — slice from the first '{' to the matching last '}'.
if text and text[0] != "{":
_start = text.find("{")
_end = text.rfind("}")
if 0 <= _start < _end:
text = text[_start : _end + 1]
data = json.loads(text)
if not data or not isinstance(data, dict):
logger.debug("[skill-extract] parsed JSON not a dict, dropping")
# Parse JSON. The object may be wrapped in code fences or surrounded by
# commentary (and may contain a stray/invalid brace fragment before
# the real object — including one that makes the response itself look
# like it starts with '{'), so use a tolerant extractor that tries the
# whole string first and then each '{' candidate left-to-right.
data = _extract_json_object(response)
if not data:
logger.debug("[skill-extract] no JSON object found in response, dropping")
return None
title = data.get("title", "").strip()
@@ -210,6 +243,20 @@ async def maybe_extract_skill(
logger.debug("[skill-extract] '%s' already exists — dropped as duplicate", title)
return None
# Auto-publish gate: if the user has `auto_approve_skills` on, the
# newly-extracted skill is created `published` immediately rather
# than waiting for the next audit batch. The audit still runs later
# and can demote it back to `draft` (or delete) on failure. Default
# ON matches the UI label "Auto-approve skills".
_initial_status = "draft"
try:
from routes.prefs_routes import _load_for_user as _load_prefs
_prefs = _load_prefs(owner) or {}
if _prefs.get("auto_approve_skills", True):
_initial_status = "published"
except Exception:
pass
entry = skills_manager.add_skill(
title=title,
problem=data.get("problem", ""),
@@ -220,6 +267,7 @@ async def maybe_extract_skill(
confidence=data.get("confidence", 0.7),
session_id=getattr(session, "session_id", None),
owner=owner,
status=_initial_status,
)
try:
from src.event_bus import fire_event
+283
View File
@@ -0,0 +1,283 @@
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from urllib.parse import quote, urlparse
import httpx
from src.url_safety import check_outbound_url
logger = logging.getLogger(__name__)
MAX_FILES = 64
MAX_TOTAL_BYTES = 2_000_000
MAX_FILE_BYTES = 400_000
ALLOWED_SUFFIXES = (
".md", ".txt", ".json", ".yaml", ".yml", ".py", ".sh", ".toml",
".js", ".ts", ".css", ".html", ".xml", ".csv",
)
TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
_GITHUB_HOSTS = frozenset({
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
})
def _github_host(url: str) -> str:
return (urlparse(str(url)).hostname or "").lower()
def _assert_github_url(url: str, *, context: str = "URL") -> None:
host = _github_host(url)
if host not in _GITHUB_HOSTS:
raise SkillImportError(
f"{context} must stay on GitHub (got {host or 'unknown host'})"
)
@dataclass
class ResolvedSource:
owner: str
repo: str
ref: str
path: str # directory or file path inside repo (no leading slash)
class SkillImportError(ValueError):
pass
def _safe_relpath(rel: str) -> str:
rel = (rel or "").replace("\\", "/").strip().lstrip("/")
if not rel or rel.startswith("..") or "/../" in f"/{rel}/":
raise SkillImportError(f"unsafe path: {rel!r}")
parts = [p for p in rel.split("/") if p and p != "."]
if any(p == ".." for p in parts):
raise SkillImportError(f"unsafe path: {rel!r}")
return "/".join(parts)
def _is_text_file(name: str) -> bool:
low = name.lower()
if low in TEXT_NAMES:
return True
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
def parse_skill_source(url: str) -> ResolvedSource:
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
raw = (url or "").strip()
if not raw:
raise SkillImportError("URL is required")
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
if "skills.sh" in raw and "github.com" not in raw:
ok, reason = check_outbound_url(raw)
if not ok:
raise SkillImportError(reason)
with httpx.Client(follow_redirects=True, timeout=20.0) as client:
r = client.get(raw)
if r.status_code >= 400:
raise _github_response_error(r)
final = str(r.url)
_assert_github_url(final, context="redirect target")
# Page may embed a github link; prefer final URL if redirected.
if "github.com" in final:
raw = final
else:
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
if m:
raw = m.group(0).rstrip(".,)")
parsed = urlparse(raw)
host = _github_host(raw)
if host not in _GITHUB_HOSTS:
raise SkillImportError(
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
)
if host == "raw.githubusercontent.com":
# /owner/repo/ref/path/to/file
bits = [p for p in parsed.path.split("/") if p]
if len(bits) < 4:
raise SkillImportError("Invalid raw GitHub URL")
owner, repo, ref = bits[0], bits[1], bits[2]
path = "/".join(bits[3:])
return ResolvedSource(owner=owner, repo=repo, ref=ref, path=path)
bits = [p for p in parsed.path.split("/") if p]
if len(bits) < 2:
raise SkillImportError("Invalid GitHub URL")
owner, repo = bits[0], bits[1]
ref = "main"
path = ""
if len(bits) >= 4 and bits[2] in ("tree", "blob"):
ref = bits[3]
path = "/".join(bits[4:])
elif len(bits) == 2:
path = ""
else:
raise SkillImportError("GitHub URL must include /tree/<branch>/... or /blob/<branch>/...")
return ResolvedSource(owner=owner, repo=repo, ref=ref, path=path)
def _raw_url(src: ResolvedSource, rel_path: str) -> str:
rel = _safe_relpath(rel_path)
return f"https://raw.githubusercontent.com/{src.owner}/{src.repo}/{quote(src.ref, safe='')}/{quote(rel, safe='/')}"
def _api_contents_url(src: ResolvedSource, rel_path: str = "") -> str:
rel = _safe_relpath(rel_path) if rel_path else ""
base = f"https://api.github.com/repos/{src.owner}/{src.repo}/contents"
if rel:
base += f"/{quote(rel, safe='/')}"
return f"{base}?ref={quote(src.ref, safe='')}"
def _github_response_error(response: httpx.Response) -> SkillImportError:
"""Turn a failed GitHub HTTP response into a user-visible import error."""
status = response.status_code
detail = ""
try:
body = response.json()
if isinstance(body, dict):
detail = str(body.get("message") or "").strip()
except Exception:
detail = (response.text or "").strip()[:200]
low = detail.lower()
if status == 403 and "rate limit" in low:
return SkillImportError(
"GitHub API rate limit exceeded — try again in a bit"
+ (f" ({detail})" if detail else "")
)
if status == 404:
return SkillImportError("path not found on GitHub")
if detail:
return SkillImportError(f"GitHub request failed ({status}): {detail}")
return SkillImportError(f"GitHub request failed ({status})")
def _fetch_bytes(url: str) -> bytes:
ok, reason = check_outbound_url(url)
if not ok:
raise SkillImportError(reason)
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
r = client.get(url, headers={"Accept": "application/vnd.github+json"})
if r.status_code >= 400:
raise _github_response_error(r)
_assert_github_url(str(r.url), context="redirect target")
if len(r.content) > MAX_FILE_BYTES:
raise SkillImportError(f"file too large: {url}")
return r.content
def _fetch_text(url: str) -> str:
data = _fetch_bytes(url)
try:
return data.decode("utf-8")
except UnicodeDecodeError as e:
raise SkillImportError(f"non-text file: {url}") from e
def _list_github_dir(src: ResolvedSource, rel_dir: str, out: Dict[str, str], *, depth: int = 0) -> None:
if depth > 4 or len(out) >= MAX_FILES:
return
url = _api_contents_url(src, rel_dir)
ok, reason = check_outbound_url(url)
if not ok:
raise SkillImportError(reason)
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
r = client.get(url, headers={"Accept": "application/vnd.github+json"})
if r.status_code >= 400:
raise _github_response_error(r)
_assert_github_url(str(r.url), context="redirect target")
entries = r.json()
if not isinstance(entries, list):
raise SkillImportError("expected a directory on GitHub")
total = sum(len(v.encode("utf-8")) for v in out.values())
for ent in entries:
if len(out) >= MAX_FILES or total >= MAX_TOTAL_BYTES:
break
if not isinstance(ent, dict):
continue
name = ent.get("name") or ""
ent_type = ent.get("type")
rel = _safe_relpath(f"{rel_dir}/{name}" if rel_dir else name)
if ent_type == "dir":
_list_github_dir(src, rel, out, depth=depth + 1)
total = sum(len(v.encode("utf-8")) for v in out.values())
continue
if ent_type != "file" or not _is_text_file(name):
continue
dl = ent.get("download_url")
if not dl:
continue
_assert_github_url(dl, context="download URL")
text = _fetch_text(dl)
total += len(text.encode("utf-8"))
if total > MAX_TOTAL_BYTES:
raise SkillImportError("skill bundle exceeds size limit")
out[rel] = text
def fetch_skill_bundle(url: str) -> Tuple[Dict[str, str], ResolvedSource]:
"""Download SKILL.md and sibling text assets. Returns relative_path → content."""
src = parse_skill_source(url)
files: Dict[str, str] = {}
path = _safe_relpath(src.path) if src.path else ""
if path.lower().endswith("skill.md"):
files[path] = _fetch_text(_raw_url(src, path))
parent = "/".join(path.split("/")[:-1])
if parent:
try:
_list_github_dir(src, parent, files)
except SkillImportError:
pass
return files, src
if path:
try:
_fetch_text(_raw_url(src, f"{path}/SKILL.md"))
_list_github_dir(src, path, files)
return files, src
except Exception:
pass
try:
text = _fetch_text(_raw_url(src, path))
if path.lower().endswith(".md"):
files[path] = text
return files, src
except Exception:
pass
_list_github_dir(src, path, files)
else:
_list_github_dir(src, "", files)
if not any(p.lower().endswith("skill.md") for p in files):
# Flat repo root with SKILL.md only
try:
files["SKILL.md"] = _fetch_text(_raw_url(src, "SKILL.md"))
except Exception as e:
raise SkillImportError(
"No SKILL.md found — link to a skill folder or SKILL.md on GitHub"
) from e
return files, src
def pick_skill_md(files: Dict[str, str]) -> Tuple[str, str]:
for rel, content in files.items():
if rel.lower().endswith("skill.md"):
return rel, content
raise SkillImportError("bundle has no SKILL.md")
def default_category_from_source(src: ResolvedSource) -> str:
return "imported"
+48
View File
@@ -381,6 +381,54 @@ class SkillsManager:
return sk.to_dict()
def import_bundle_from_files(
self,
files: Dict[str, str],
*,
owner: Optional[str] = None,
source_url: str = "",
category: str = "imported",
) -> Dict:
"""Install a fetched skill bundle (relative path → text) under skills/."""
from .skill_importer import SkillImportError, pick_skill_md, _safe_relpath
from core.atomic_io import atomic_write_text
if not files:
raise SkillImportError("empty bundle")
_rel, skill_md = pick_skill_md(files)
sk = Skill.from_markdown(skill_md)
nm = slugify(sk.name or _rel.split("/")[-2] or "skill")
cat = slugify(category or sk.category or "imported", fallback="imported")
existing = {s["name"] for s in self.load_all()}
base = nm
i = 2
while nm in existing:
nm = f"{base}-{i}"
i += 1
skill_dir = self._skill_dir(cat, nm)
os.makedirs(skill_dir, exist_ok=True)
# Preserve bundle layout (templates/, references/, etc.) under the skill dir.
for rel, content in files.items():
safe = _safe_relpath(rel)
dest = os.path.join(skill_dir, safe)
os.makedirs(os.path.dirname(dest), exist_ok=True)
atomic_write_text(dest, content)
sk.name = nm
sk.category = cat
sk.owner = owner
sk.source = "imported"
if source_url:
extra = (sk.body_extra or "").strip()
note = f"Imported from {source_url}"
sk.body_extra = f"{extra}\n\n{note}".strip() if extra else note
atomic_write_text(self._skill_file(cat, nm), sk.to_markdown())
sk.path = self._skill_file(cat, nm)
return sk.to_dict()
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
"""`skill_id` is the slug name. Allows updating any field plus
renames if `name` changes (file is moved on disk).
+2 -1
View File
@@ -15,10 +15,11 @@ from pathlib import Path
from typing import Optional, Dict
from src.research_utils import is_low_quality
from src.constants import DEEP_RESEARCH_DIR
logger = logging.getLogger(__name__)
RESEARCH_DATA_DIR = Path("data/deep_research")
RESEARCH_DATA_DIR = Path(DEEP_RESEARCH_DIR)
class ResearchHandler:
+16 -8
View File
@@ -6,21 +6,29 @@ from collections import Counter
from pathlib import Path
from typing import Dict, Any
from core.constants import DATA_DIR
from .cache import cache_metrics
logger = logging.getLogger(__name__)
# Dedicated error logger with file handler
_error_log_path = Path(__file__).resolve().parent.parent / "search_engine_error.log"
_error_handler = logging.FileHandler(_error_log_path, encoding="utf-8")
_error_handler.setLevel(logging.WARNING)
_error_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
# Dedicated error logger — write to the data logs directory (writable on both
# native runs and Docker, where DATA_DIR resolves to the bind-mounted volume).
_log_dir = Path(DATA_DIR) / "logs"
_error_log_path = _log_dir / "search_engine_error.log"
error_logger = logging.getLogger("search_engine_error")
error_logger.addHandler(_error_handler)
error_logger.propagate = False
try:
_log_dir.mkdir(parents=True, exist_ok=True)
_error_handler = logging.FileHandler(_error_log_path, encoding="utf-8")
_error_handler.setLevel(logging.WARNING)
_error_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
error_logger.addHandler(_error_handler)
except Exception as _e:
logging.getLogger(__name__).warning("search_engine_error log handler unavailable: %s", _e)
# Analytics file
ANALYTICS_FILE = Path(__file__).resolve().parent.parent / "search_analytics.json"
# Analytics file — also in the writable logs volume.
ANALYTICS_FILE = _log_dir / "search_analytics.json"
# ----------------------------------------------------------------------
+10 -4
View File
@@ -6,17 +6,23 @@ from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict
from core.constants import DATA_DIR
logger = logging.getLogger(__name__)
# Cache directories
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
CACHE_DIR = Path(DATA_DIR) / "cache"
SEARCH_CACHE_DIR = CACHE_DIR / "search"
CONTENT_CACHE_DIR = CACHE_DIR / "content"
CACHE_MAX_ENTRIES = 1000
# Create cache directories
SEARCH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
CONTENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# Create cache directories. Guarded so an unwritable path (e.g. a read-only
# mount) degrades to no-disk-cache instead of crashing module import.
try:
SEARCH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
CONTENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
except OSError as _e:
logger.warning("Search cache directory unavailable (%s); disk cache disabled", _e)
# Track cache size for LRU eviction
search_cache_index: Dict[str, datetime] = {}
+3
View File
@@ -259,6 +259,9 @@ def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) ->
raise RateLimitError(f"Rate limit hit for {url} (attempt {retry_attempt})")
response.raise_for_status()
except httpx.HTTPStatusError as e:
error_logger.warning(f"HTTP {e.response.status_code} fetching {url}: {e}")
return _empty_result(url, f"HTTP {e.response.status_code}: {e}")
except httpx.RequestError as e:
error_logger.error(f"NetworkError fetching {url} (attempt {retry_attempt}): {e}")
return _empty_result(url, f"NetworkError: {e}")
+12 -7
View File
@@ -134,9 +134,10 @@ _NEWS_HINTS = ("news", "nyheter", "headlines", "breaking", "latest", "today", "i
_GENERAL_ENGINES = os.environ.get("SEARXNG_GENERAL_ENGINES", "bing,mojeek,presearch")
def searxng_search_api(query: str, count: int = 10, categories: str = "general",
def searxng_search_api(query: str, count: Optional[int] = None, categories: str = "general",
time_filter: Optional[str] = None) -> List[dict]:
"""Search using SearXNG JSON API. Returns list of {title, url, snippet}."""
count = count if count is not None else _get_result_count()
instance = _get_search_instance()
api_key = ""
headers = {"User-Agent": "Mozilla/5.0"}
@@ -282,8 +283,9 @@ def searxng_search(query, max_results=10):
# ── Brave ──
def brave_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
def brave_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
"""Search using Brave API with key from admin settings or env var."""
count = count if count is not None else _get_result_count()
api_key = _get_provider_key("brave") or os.environ.get("DATA_BRAVE_API_KEY") or ""
return _brave_search_impl(query, count, time_filter, search_config={"brave_api_key": api_key})
@@ -381,9 +383,9 @@ def _resolve_ddg_redirect(raw: str) -> str:
return resolved
def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
def duckduckgo_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
"""Search using DuckDuckGo via the duckduckgo-search library. No API key needed."""
count = count if count is not None else _get_result_count()
def _html_fallback() -> List[dict]:
try:
response = httpx.get(
@@ -452,7 +454,7 @@ def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] =
# ── Google Programmable Search Engine ──
def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
def google_pse_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
"""Search using Google PSE (Custom Search JSON API).
Requires two keys in settings:
@@ -460,6 +462,7 @@ def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] =
- google_pse_cx: Programmable Search Engine ID (cx)
Or env vars GOOGLE_API_KEY and GOOGLE_PSE_CX.
"""
count = count if count is not None else _get_result_count()
settings = _get_search_settings()
api_key = _get_provider_key("google_pse") or os.environ.get("GOOGLE_API_KEY", "")
cx = (settings.get("google_pse_cx") or "").strip() or os.environ.get("GOOGLE_PSE_CX", "")
@@ -522,8 +525,9 @@ def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] =
# ── Tavily ──
def tavily_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
def tavily_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
"""Search using Tavily API. Requires search_api_key or TAVILY_API_KEY env var."""
count = count if count is not None else _get_result_count()
api_key = _get_provider_key("tavily") or os.environ.get("TAVILY_API_KEY", "")
if not api_key:
logger.warning("Tavily: no API key configured")
@@ -580,8 +584,9 @@ def tavily_search(query: str, count: int = 10, time_filter: Optional[str] = None
# ── Serper.dev ──
def serper_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
def serper_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
"""Search using Serper.dev API. Requires search_api_key or SERPER_API_KEY env var."""
count = count if count is not None else _get_result_count()
api_key = _get_provider_key("serper") or os.environ.get("SERPER_API_KEY", "")
if not api_key:
logger.warning("Serper: no API key configured")
+16 -3
View File
@@ -76,6 +76,19 @@ def _domain(url: str) -> str:
return ""
def _has_word(text: str, term: str) -> bool:
"""True if ``term`` appears in ``text`` as a whole word.
Query terms are matched on word boundaries so a short term doesn't match
inside an unrelated word: "us" must not match "business"/"music", "port"
must not match "transport"/"support". This mirrors the tokenization used to
build ``query_terms`` (``\\b\\w+\\b``). #1473 converted the title and sports
checks to word boundaries; the snippet and subject-term checks below use
the same helper so the whole file stays consistent.
"""
return re.search(rf"\b{re.escape(term)}\b", text) is not None
def rank_search_results(query: str, results: List[dict]) -> List[dict]:
"""Rank search results by title relevance, snippet quality, domain authority, and recency."""
query_terms = [t.lower() for t in re.findall(r"\b\w+\b", query)]
@@ -87,14 +100,14 @@ def rank_search_results(query: str, results: List[dict]) -> List[dict]:
if not title:
return 0.0
title_lc = title.lower()
matches = sum(1 for term in query_terms if re.search(rf"\b{re.escape(term)}\b", title_lc))
matches = sum(1 for term in query_terms if _has_word(title_lc, term))
return matches / len(query_terms) if query_terms else 0.0
def snippet_score(snippet: str) -> float:
if not snippet:
return 0.0
length_factor = min(len(snippet), 200) / 200
term_hits = sum(1 for term in query_terms if term in snippet.lower())
term_hits = sum(1 for term in query_terms if _has_word(snippet.lower(), term))
term_factor = term_hits / len(query_terms) if query_terms else 0.0
return (length_factor + term_factor) / 2
@@ -127,7 +140,7 @@ def rank_search_results(query: str, results: List[dict]) -> List[dict]:
# A country/news query should not rank a page whose title/snippet barely
# mentions the country above actual news pages for that country.
subject_terms = [t for t in query_terms if t not in _NEWS_HINTS]
if subject_terms and not any(t in text or t in netloc for t in subject_terms):
if subject_terms and not any(_has_word(text, t) or _has_word(netloc, t) for t in subject_terms):
adjustment -= 1.0
return adjustment
+3 -1
View File
@@ -9,6 +9,8 @@ import httpx
from pathlib import Path
from typing import Optional, Dict, Any
from src.constants import TTS_CACHE_DIR
logger = logging.getLogger(__name__)
@@ -35,7 +37,7 @@ class TTSService:
"endpoint:<id>" OpenAI-compatible /audio/speech via ModelEndpoint
"""
def __init__(self, cache_dir: str = "data/tts_cache"):
def __init__(self, cache_dir: str = TTS_CACHE_DIR):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._kokoro = None # lazy-init
+71 -12
View File
@@ -6,23 +6,30 @@ initial admin user. Safe to re-run (skips what already exists).
"""
import os
import platform
import shutil
import subprocess
import sys
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
sys.path.insert(0, BASE_DIR)
from src.constants import (
DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR,
TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR,
RAG_DIR, MEMORY_VECTORS_DIR,
)
DIRS = [
DATA_DIR,
os.path.join(DATA_DIR, "uploads"),
os.path.join(DATA_DIR, "personal_docs"),
os.path.join(DATA_DIR, "personal_uploads"),
os.path.join(DATA_DIR, "tts_cache"),
os.path.join(DATA_DIR, "generated_images"),
os.path.join(DATA_DIR, "deep_research"),
os.path.join(DATA_DIR, "chroma"),
os.path.join(DATA_DIR, "rag"),
os.path.join(DATA_DIR, "memory_vectors"),
UPLOAD_DIR,
PERSONAL_DIR,
PERSONAL_UPLOADS_DIR,
TTS_CACHE_DIR,
GENERATED_IMAGES_DIR,
DEEP_RESEARCH_DIR,
CHROMA_DIR,
RAG_DIR,
MEMORY_VECTORS_DIR,
os.path.join(BASE_DIR, "logs"),
]
@@ -72,7 +79,7 @@ def _prompt_admin_credentials():
def create_default_admin():
"""Create an initial admin user if none exists."""
auth_path = os.path.join(DATA_DIR, "auth.json")
auth_path = AUTH_FILE
if os.path.exists(auth_path):
print(" [skip] auth.json already exists")
return "exists"
@@ -117,7 +124,16 @@ def create_default_admin():
print(f" Temporary password: {password}")
print(f" ** Change it after first login. Set ODYSSEUS_ADMIN_PASSWORD to choose your own. **")
return "created"
except ImportError:
except ImportError as e:
if "incompatible architecture" in str(e).lower():
# bcrypt is present but built for the wrong CPU architecture — the
# same Apple Silicon mismatch check_arch() guards against, caught here
# for the rarer case of an x86 wheel inside an arm64 venv.
print(" [error] bcrypt loaded with the wrong CPU architecture.")
print(" Rebuild the venv with an arm64 Python:")
print(" rm -rf venv && /opt/homebrew/bin/python3.11 -m venv venv")
print(" ./venv/bin/pip install -r requirements.txt")
return "skipped"
print(" [warn] bcrypt not installed — skipping admin user creation")
print(" Run: pip install bcrypt")
return "skipped"
@@ -167,9 +183,52 @@ def check_deps():
print(" [ok] tmux installed")
def check_arch():
"""Stop early, with guidance, if we're on Apple Silicon but running an
Intel (x86_64) Python through Rosetta.
A venv built with such an interpreter installs and loads compiled packages
(bcrypt, pydantic-core, onnxruntime, ) for the wrong CPU architecture, then
dies deep inside an import with a cryptic
"(mach-o file, but is an incompatible architecture)" error. Catching it here
turns that into one clear, actionable message.
"""
if sys.platform != "darwin" or platform.machine() == "arm64":
return # Not macOS, or already an arm64-native interpreter — nothing to do.
# platform.machine() == "x86_64": either a genuine Intel Mac (fine) or an x86
# interpreter running under Rosetta on Apple Silicon (the case we must catch).
try:
translated = subprocess.run(
["sysctl", "-n", "sysctl.proc_translated"],
capture_output=True, text=True, timeout=5,
).stdout.strip()
except Exception:
translated = ""
if translated != "1":
return # Genuine Intel Mac — carry on.
print("\n [error] This is an Apple Silicon Mac, but setup is running under an")
print(" Intel (x86_64) Python through Rosetta. Compiled packages would")
print(' load as the wrong architecture and crash with "incompatible')
print(' architecture" later on.')
print("\n Rebuild the environment with Homebrew's arm64 Python:")
print(" brew install python@3.11 # if you don't have it yet")
print(" rm -rf venv")
print(" /opt/homebrew/bin/python3.11 -m venv venv")
print(" ./venv/bin/pip install -r requirements.txt")
print(" ./venv/bin/python setup.py")
print("\n Tip: ./start-macos.sh does all of this with the right Python.\n")
sys.exit(1)
def main():
print("\n=== Odysseus Setup ===\n")
# Fail fast with a clear message if the CPU architecture is wrong (Apple
# Silicon under an x86/Rosetta Python) before importing anything native.
check_arch()
print("1. Creating directories...")
create_dirs()
+9
View File
@@ -35,6 +35,7 @@ _CALENDAR_ACTION = (
r"delete|deleting|remove|removing|cancel|cancelling|canceling)"
)
_CALENDAR_THING = r"(?:calendar|calendar\s+(?:entry|item)|event|meeting|appointment|entry|call)"
_CALENDAR_READ_THING = r"(?:calendar|schedule|events?|meetings?|appointments?|classes?)"
_EXPLANATORY_PREFIX = re.compile(
r"^\s*(?:how\s+(?:do|can)\s+i|can\s+you\s+explain|what\s+about|tell\s+me\s+how|show\s+me\s+how)\b",
re.I,
@@ -59,6 +60,14 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
("calendar", "calendar target action request", rf"\b{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"),
("calendar", "put item on calendar request", r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b"),
# Calendar/event lookup. A question such as "Do I have Taekwondo
# classes this week?" needs the calendar tool; plain chat cannot know.
("calendar", "calendar lookup request", rf"\b(?:list|show|check|find)\b.{{0,120}}\b(?:my\s+|the\s+)?(?:upcoming|next|today'?s?|tomorrow'?s?|this\s+week'?s?)\b.{{0,120}}\b{_CALENDAR_READ_THING}\b"),
("calendar", "calendar lookup question", rf"\b(?:what|which)\b.{{0,120}}\b(?:upcoming|next|today'?s?|tomorrow'?s?|this\s+week'?s?)\b.{{0,120}}\b{_CALENDAR_READ_THING}\b"),
("calendar", "calendar availability question", rf"\bdo\s+i\s+have\b.{{0,120}}\b(?:upcoming|next|today|tomorrow|this\s+week)\b.{{0,120}}\b{_CALENDAR_READ_THING}\b"),
("calendar", "calendar agenda question", r"\bwhat(?:'s| is)\s+on\s+(?:my\s+)?calendar\b"),
("calendar", "next calendar item question", r"\bwhen\s+(?:is|are)\s+(?:my\s+)?next\s+(?:event|meeting|appointment|class)\b"),
# Notes, todos, checklists, and reminders.
("notes", "reminder request", r"\bremind\s+me\b"),
("notes", "assistant note/todo action request", rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b"),
+754 -216
View File
File diff suppressed because it is too large Load Diff
+6 -32
View File
@@ -14,16 +14,17 @@ Sub-modules:
import logging
from collections import namedtuple
from src.tool_utils import _truncate, get_mcp_manager, set_mcp_manager
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants (kept here — sub-modules import from here)
# Constants (re-exported for backward compatibility — single source of truth
# is src.constants; always prefer importing from there for new code)
# ---------------------------------------------------------------------------
MAX_AGENT_ROUNDS = 20
MAX_AGENT_ROUNDS = 50
SHELL_TIMEOUT = 60
PYTHON_TIMEOUT = 30
MAX_OUTPUT_CHARS = 10_000
MAX_READ_CHARS = 20_000
# Tool types that trigger execution
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
@@ -34,7 +35,7 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
"send_to_session",
"pipeline",
"manage_session", "manage_memory", "list_models",
"ui_control", "generate_image",
"ui_control", "generate_image", "ask_user", "update_plan",
"manage_tasks", "api_call", "ask_teacher", "manage_skills",
"suggest_document",
"manage_endpoints", "manage_mcp", "manage_webhooks",
@@ -63,33 +64,6 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
# ---------------------------------------------------------------------------
# MCP Manager (kept here — used by execution and agent_loop)
# ---------------------------------------------------------------------------
_mcp_manager = None
def set_mcp_manager(manager):
"""Set the global MCP manager instance."""
global _mcp_manager
_mcp_manager = manager
def get_mcp_manager():
"""Get the global MCP manager instance."""
return _mcp_manager
# ---------------------------------------------------------------------------
# Helpers (kept here — used by sub-modules)
# ---------------------------------------------------------------------------
def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
# Callers treat the result as text, so always return a string: coerce a
# non-string (None -> "", otherwise str(...)) instead of returning it raw,
# which would just move the crash downstream.
if not isinstance(text, str):
text = "" if text is None else str(text)
if len(text) > limit:
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
return text
# ---------------------------------------------------------------------------
# Re-exports from sub-modules
# ---------------------------------------------------------------------------
+44 -28
View File
@@ -14,6 +14,8 @@ import uuid
import time
from typing import Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
logger = logging.getLogger(__name__)
AI_CHAT_TIMEOUT = 120 # seconds for a single LLM call
@@ -55,7 +57,7 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
# Model resolution
# ---------------------------------------------------------------------------
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, build_models_url
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
@@ -96,9 +98,12 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
(f" matching '{target_endpoint_name}'" if target_endpoint_name else ""))
for ep in endpoints:
base = _normalize_base(ep.base_url)
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
provider = _detect_provider(base)
headers = build_headers(ep.api_key, base)
headers = build_headers(api_key, base)
if provider == "anthropic":
# Anthropic: match against hardcoded model list
@@ -112,16 +117,20 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
else:
# OpenAI-compatible and native Ollama: probe the provider's model list.
try:
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not model_ids:
model_ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
models_url = build_models_url(base)
if models_url:
r = httpx.get(models_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not model_ids:
model_ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
model_ids = json.loads(ep.cached_models or "[]")
except Exception:
model_ids = []
@@ -1119,25 +1128,32 @@ async def do_list_models(content: str, session_id: Optional[str] = None, owner:
total_models = 0
for ep in endpoints:
base = _normalize_base(ep.base_url)
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
provider = _detect_provider(base)
headers = build_headers(ep.api_key, base)
headers = build_headers(api_key, base)
model_ids = []
if provider == "anthropic":
model_ids = list(ANTHROPIC_MODELS)
else:
try:
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not model_ids:
model_ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
models_url = build_models_url(base)
if models_url:
r = httpx.get(models_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not model_ids:
model_ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
model_ids = json.loads(ep.cached_models or "[]")
except Exception:
model_ids = ["(endpoint offline)"]
@@ -1268,7 +1284,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
toggle <name> <on|off> Toggle a setting (web, bash, rag, research, incognito, document_editor)
set_mode <agent|chat> Switch between agent and chat mode
switch_model <model> Change the model for the current session
set_theme <preset> Apply a theme preset (dark, light, paper, nord, dracula, gruvbox, gpt, claude, lavender, etc.)
set_theme <preset> Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
open_panel <name> Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] Open a reply draft document for an email; does not send
@@ -1715,7 +1731,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
# GPT image models always return b64_json; DALL-E may return url
if img.get("b64_json"):
img_dir = Path("data/generated_images")
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
@@ -1728,7 +1744,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
try:
dl_resp = httpx.get(img["url"], timeout=60)
if dl_resp.status_code == 200:
img_dir = Path("data/generated_images")
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
+22 -1
View File
@@ -10,7 +10,7 @@ def get_current_user(request: Request) -> Optional[str]:
return getattr(request.state, 'current_user', None)
def effective_user(request: Request):
def effective_user(request: Request) -> Optional[str]:
"""The real human behind the request, for ownership/attribution.
Cookie sessions resolve to the logged-in username. Bearer ``ody_`` callers
@@ -34,6 +34,24 @@ def effective_user(request: Request):
return get_current_user(request)
def _is_api_token_request(request: Request) -> bool:
"""Return True when middleware authenticated a bearer API token."""
return bool(getattr(request.state, "api_token", False))
def require_authenticated_request(request: Request) -> str:
"""Allow either a browser session or a valid bearer API token.
This is intentionally narrower than :func:`require_user`: use it only for
routes that need authentication but do not read or mutate owner-scoped
user data. Owner-scoped routes should use ``require_user`` for browser
sessions or their own API-token scope/owner gate.
"""
if _is_api_token_request(request):
return effective_user(request) or ""
return require_user(request)
def _auth_disabled() -> bool:
"""True when the operator has explicitly turned off auth via .env.
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
@@ -60,6 +78,9 @@ def require_user(request: Request) -> str:
Use this on routes that touch user data so middleware misconfig can't
open them up.
"""
if _is_api_token_request(request):
raise HTTPException(403, "API tokens must use a scope-aware API route")
u = get_current_user(request)
if u:
return u
+6 -4
View File
@@ -33,13 +33,15 @@ from core.atomic_io import atomic_write_json
from core.platform_compat import (
detached_popen_kwargs,
find_bash,
git_bash_path,
kill_process_tree,
pid_alive,
)
_DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
_JOBS_DIR = _DATA_DIR / "bg_jobs"
_STORE = _DATA_DIR / "bg_jobs.json"
from src.constants import BG_JOBS_DIR, BG_JOBS_FILE
_JOBS_DIR = Path(BG_JOBS_DIR)
_STORE = Path(BG_JOBS_FILE)
# A job that runs longer than this is presumed stuck and reaped (the agent
# still gets a "timed out" follow-up so nothing hangs forever).
@@ -106,7 +108,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
# handles drive paths and spaces correctly.
cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh"
cmd_path.write_text(command + "\n", encoding="utf-8")
lp, xp, cp = (shlex.quote(p.as_posix()) for p in (log_path, exit_path, cmd_path))
lp, xp, cp = (shlex.quote(git_bash_path(p)) for p in (log_path, exit_path, cmd_path))
script_path = _JOBS_DIR / f"{job_id}.sh"
script_path.write_text(
f"bash {cp} > {lp} 2>&1\n"

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