When an agent turn uses native (OpenAI-style) function calling and the model
returns only tool calls with no prose, _append_tool_results built the follow-up
assistant message with content "" (empty string).
Google Gemini's OpenAI-compatible endpoint and Ollama both reject an assistant
message that carries tool_calls alongside an empty-string content with HTTP 400.
Because that message feeds the tool results back to the model, every tool-using
turn on these providers dies at the second round: the tool runs, but the agent
never produces a result.
Use None (JSON null) instead, which is the spec-correct form the OpenAI SDK
itself emits and which OpenAI and Anthropic accept too. Adds tests covering the
native tool-call content shaping.
The DuckDuckGo HTML fallback returns redirect URLs (//duckduckgo.com/l/?uddg=...)
instead of actual page URLs. This caused fetch_webpage_content() to reject them
instantly because _public_http_url() requires an http/https scheme, making search
results unfetchable in deep research mode.
Added _resolve_url() to:
- Convert protocol-relative URLs to absolute (https:)
- Convert path-relative URLs to absolute
- Extract the real URL from DuckDuckGo's /l/?uddg= redirect parameters
Every research endpoint interpolates session_id into filesystem paths
(Path('data/deep_research') / f'{session_id}.json') without checking
for traversal sequences. A crafted ID like '../../data/auth' reaches
arbitrary JSON files — readable via research_detail (which also leaks
file paths in error messages), writable via research_archive, and
deletable via research_delete.
Add _validate_session_id() which rejects anything outside
[a-zA-Z0-9-]{1,128}. Called before filesystem access in all 12
endpoints that accept a session_id path parameter.
The /api/chat/stream_status handler did a membership test against
_active_streams followed by an indexed read of the same key. Between
those two ops, a sibling stream's finally block (or a stop / cleanup
path) can pop the entry, turning the indexed read into a KeyError that
bubbles up as a 500. The race is the exact one _stream_set was already
written to avoid; the comment on the helper at the top of the module
spells out why a single .get() is the right pattern here too.
Collapse the two-step into a single .get() call so the lookup either
returns the live record or None, and report 'detached' / 404 based on
that single read. No behavior change on the happy path; the failure
mode under concurrent stream cleanup is now handled deterministically.
Closes#658.
A fresh `docker compose up -d` shows the searxng container failing its
healthcheck with permission errors at setup (reported in #721 — the
service comes up under names like `odysseus_searxng_1` and never goes
ready, which then blocks the main odysseus container because of the
`depends_on: searxng: condition: service_healthy` gate).
Root cause: the official `searxng/searxng:latest` image runs as the
non-root `searxng` user but its entrypoint still needs to
1. chown /etc/searxng on first boot so the persisted named volume is
owned by the searxng user inside the container,
2. su-exec to drop / re-assert privileges before launching uwsgi, and
3. let our wrapper entrypoint (which seeds settings.yml into the named
volume on first boot) write the file through the volume mount.
Without explicit `cap_add`, the container has neither CHOWN nor
DAC_OVERRIDE nor SETUID/SETGID, so the entrypoint aborts at the first
chown / su-exec / redirection with EACCES. The upstream searxng-docker
compose file solves this with the standard "drop everything, grant only
what's needed" capability pattern.
Fix: mirror the upstream cap_drop ALL / cap_add CHOWN, SETGID, SETUID,
DAC_OVERRIDE on the searxng service. This grants only the four caps the
entrypoint actually needs, matches what searxng-docker ships with, and
leaves ports, volumes, env, healthcheck, and the wrapper entrypoint
unchanged.
Closes#721.
SkillsManager.update_skill walks every SKILL.md on disk and matches by
slug only; the 'owner' key in its scalar_keys whitelist meant a caller
could pass updates={'owner': 'attacker', 'description': 'pwned'} and the
first matching file on disk got silently re-owned. Two users with the
same slug under different category directories (which is supported by
the on-disk layout <category>/<name>/SKILL.md) could each stomp the
other's skill via the manage_skills tool or the in-process callers in
tool_implementations.py (edit, patch, publish, delete).
update_skill and delete_skill now require the caller's owner and only
match a file whose parsed owner field matches. The default of None
means 'no scope' and only matches ownerless skills, so an unsafe call
without an explicit owner is now a no-op. 'owner' is also removed from
scalar_keys so the updates dict cannot be used to reassign ownership
even when the manager is called from an in-process path that didn't
supply the owner argument.
The in-process callers in tool_implementations.py are updated to pass
owner=owner (which was already in scope at every call site) so the
HTTP and agent paths both go through the scoped check. The HTTP route
at routes/skills_routes.py:1499 was already owner-scoped via
sm.load(owner=user); the fix brings the in-process path up to the
same standard.