42 Commits

Author SHA1 Message Date
Alexandre Teixeira 537f7180e6 Merge dev into fix/native-agent-loop-guard-signals 2026-06-26 13:00:59 +01:00
Kenny Van de Maele de12d4734a fix(ui): route tasks.js + skills.js dropdowns through topPortalZ() (#4768)
Fixes #4767. #4724 routed 16 body-portaled dropdowns through the shared
topPortalZ() helper so they always render just above the currently-raised tool
modal, but two were missed and still used a hardcoded z-index, so they hit the
same #4720 bug once a modal's bring-to-front counter climbed past the literal:

  - tasks.js _showTaskDropdown(): inline z-index:100000 on .task-dropdown
  - skills.js kebab menu (.skill-kebab-menu): z-index:100002 in style.css

Both now set zIndex from topPortalZ() after they are appended to the body,
matching the other migrated sites. The dead CSS z-index on .skill-kebab-menu is
removed (the inline value always wins). test_portal_dropdown_z_js.py gains a
source guard asserting both files use topPortalZ() and that no hardcoded
100000/100002 portal literal survives in either file or style.css.
2026-06-24 22:29:36 +02:00
Samy 5d23495eb2 fix(cookbook): only block model launch on real port collisions (#4760)
* Fix #4507: only block model launch on real port collisions

Quick-run hardcoded port 8000 and never called _nextAvailablePort(), so
every launch collided. Both pre-launch guards (serve panel + quick-run)
were count-based and fired regardless of port.

- quick-run now auto-assigns a free port (8080 for llama.cpp)
- both guards parse the new port and only prompt on a real overlap,
  stopping only the colliding serve
- dialog reports the actual port instead of a hardcoded 8000

* refactor(cookbook): share _taskPort for port parsing; auto-assign llama.cpp port

Addresses review on #4760:
- _taskPort regex now matches --port= as well as --port (space)
- _nextAvailablePort and both launch guards reuse _taskPort instead of inline regex
- quick-run llama.cpp no longer pins 8080, so two can run concurrently

* fix(cookbook): _taskPort also parses -p; add port-parsing tests

Addresses review on #4760:
- _taskPort now matches -p <n> too, so it's the complete single reader
  (was missing the short flag that other readers already handle)
- add tests/test_cookbook_port_parsing_js.py covering the port forms,
  shared-reader reuse, and llama.cpp auto-assign

* test(cookbook): extract pure port helpers and test behavior

Addresses review on #4760: the prior tests only asserted source strings.
- extract portOf() and nextFreePort() into static/js/cookbookPorts.js
- cookbookRunning.js imports them; _taskPort and _nextAvailablePort delegate
- tests run the helpers via node and assert real behavior: all port forms
  (--port, --port=, -p, -p=), next-free-port skipping taken ports, and the
  same-port-clash / different-port-coexist outcome

---------

Co-authored-by: samy <samy@odysseus.boukouro.com>
2026-06-24 19:44:09 +02:00
Solanki Sumit 22379fe736 fix(model-routes): harden _probe_endpoint against malformed model-list responses (#4789)
* fix(model-routes): harden _probe_endpoint against malformed model-list responses

_probe_endpoint parsed model lists with data.get(...) at four sites without
checking that data is a dict, and built the list with a truthiness-only
filter. A /models (or /api/tags) endpoint returning HTTP 200 with valid but
non-dict JSON ([], "x", null, 123) made data.get(...) raise AttributeError,
and a non-string id like 123 passed the filter and then hit .startswith() /
.lower() in the Z.AI/Kimi curated merge and _is_chat_model(). Both errors are
swallowed by the broad except Exception, but the comprehension dies mid-list
so the ENTIRE probed model list is discarded and the endpoint silently
degrades — masking a misconfigured/non-compliant upstream as "no models".

- Guard each data.get(...) with isinstance(data, dict) so a non-dict body
  falls through the existing `or []` default.
- Restrict the OpenAI and Ollama model-list comprehensions to non-empty str
  values, protecting the .startswith() merges and both _is_chat_model calls.
- Add an isinstance guard at the top of _is_chat_model (defense in depth for
  all four call sites).

No behavior change for well-formed {"data":[...]} / {"models":[...]}
responses. Adds regression tests (non-dict body via caplog, mixed/all
non-string ids, _is_chat_model boundary) that fail before the fix and pass
after.

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

* refactor(model-routes): extract _openai_model_ids / _ollama_model_names helpers

Per review on #4789: the malformed-response guards were inlined four times in
_probe_endpoint (two OpenAI-id comprehensions, two Ollama-name comprehensions).
Pull each into a small, directly-testable helper so the security-relevant
parsing lives in one place and a future malformed-shape fix doesn't have to be
applied in four spots (CONTRIBUTING flags repeated logic for this reason).

Behavior is unchanged. Adds direct unit tests for both helpers (non-dict body,
non-string ids, non-dict entries, name>model precedence).

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-24 19:05:31 +02:00
Magiomakes 4e46e415ea fix(tasks): normalize task endpoint URL to /chat/completions before model call (#4619)
Upstream bug (present in pewdiepie-archdaemon/odysseus main): the task
executor passes task.endpoint_url VERBATIM to the model HTTP call, unlike
the chat path which stores build_chat_url(normalize_base(base)) on the
session. A task carrying an explicit bare OpenAI-compatible base such as
"http://host:11434/v1" therefore POSTs to a 404 ("page not found"); the
agent loop swallows the empty body into "The model returned an empty
response" and marks the run success, so nothing surfaces the failure.

Tasks that omit an endpoint dodge this only because _resolve_defaults()
cribs an already-full URL from a recent chat session. The API/token path
(e.g. an external client that POSTs /api/tasks with endpoint_url=".../v1")
hits it every time.

Fix: route every resolved task endpoint through _normalize_chat_endpoint()
at the three resolution sites (_execute_llm_task, the persona/research
session path, and _execute_research_task). The helper is idempotent
(strips any existing chat suffix, re-appends the correct one) and leaves
native-Ollama (/api...) and already-concrete URLs untouched, so other
providers are unaffected. Proven via isolated repro: ".../v1" -> 404 ->
empty; ".../v1/chat/completions" -> 200 -> real gemma4:31b output.

Regression test asserts the bare-/v1 -> full-chat-URL mapping, idempotency,
and the native-Ollama/empty passthroughs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:02:31 +02:00
Solanki Sumit 6a2a39f892 refactor(exceptions): dedupe src/exceptions via core re-export (#4785)
src/exceptions.py was a byte-for-byte duplicate of the canonical
core/exceptions.py. Replace its class bodies with a re-export shim
(mirroring the core/constants.py -> src/constants.py pattern) so the
exception classes are defined in exactly one place. Also fix the stale
"# src/exceptions.py" header comment in core/exceptions.py.

No behavior change: both import paths resolve to the same class objects
(verified by identity), so `except SessionNotFoundError` works regardless
of which module it was imported from. Ran py_compile and
pytest tests/test_app.py (12 passed).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:50:07 +02:00
Kenny Van de Maele 5ce2056521 refactor(tools): migrate config/integration admin tools to the registry (#4742)
Part of #3629 (the `admin_tools.py` bullet). Moves the config/integration admin
tools off the legacy elif dispatch chain in tool_implementations.py onto the
agent_tools registry:

  manage_endpoints, manage_mcp, manage_webhooks, manage_tokens, manage_settings

The do_* implementations (and manage_mcp's command-allowlist / RCE guard:
_validate_mcp_command, _mcp_allowed_commands, and the _MCP_* constants) move
verbatim into the new src/agent_tools/admin_tools.py. They register through a
single ADMIN_TOOL_HANDLERS map that TOOL_HANDLERS.update()s, and the five elif
branches plus their imports are dropped from tool_execution.py, so these tools
now flow through _direct_fallback like the other migrated clusters. The names
are re-exported from src.agent_tools for back-compat.

Dedup:
  - _parse_tool_args was duplicated in tool_implementations.py and
    document_tools.py. It now lives once in src.tool_utils (which imports nothing
    from the project beyond src.constants, so this introduces no cycle) and both
    call sites import it from there. The orphaned `import json` in document_tools
    is removed with it.
  - The five tools share one _owner_adapter(fn) factory that threads ctx["owner"]
    into the owner-taking do_* signature, instead of five near-identical wrappers.

Tests: new tests/test_admin_tools_registry.py pins the registration, the
re-export back-compat, the owner-threading adapter, and the single-source
_parse_tool_args (across admin_tools and document_tools). Existing MCP /
settings / webhook suites are repointed at the new module.
2026-06-24 09:29:10 +02:00
Joel Alejandro Escareño Fernández e0ccf250a4 feat(discovery): detect llama.cpp servers and label local providers (#4729)
* feat(discovery): detect llama.cpp servers and label local providers

Scan port 8080 (llama-server) and 11435 (APFEL) during discovery, fingerprint
llama.cpp via its native /props endpoint, and label well-known local serving
ports (8080 llama.cpp, 8000 vLLM, 1234 LM Studio, 11434 Ollama) consistently
in both the Python provider helper and the JS endpoint UI. Adds a llama.cpp
hint to the /setup slash command.

* fix(discovery): don't infer the serving tool from the port alone

Per review: vLLM, SGLang, llama.cpp and plain OpenAI-compatible servers all
share 8000/8080, so labeling by port mislabels real setups (a vLLM box on 8080
shown as llama.cpp). Drop the port->tool assertions from _provider_label and
providerLabel; the authoritative signal is the /props fingerprint done during
discovery, which is unchanged. Loopback now reads a neutral 'local endpoint' /
'Local'. Tests updated to assert the neutral labels.
2026-06-23 23:39:56 +02:00
Michael 72c0bde8a9 fix: use atomic write in APIKeyManager.save() to prevent credential data loss (#4591) (#4597)
* fix: use atomic write in APIKeyManager.save() to prevent data loss

Opening api_keys.json with 'w' truncates the file before writing, so a
crash, disk-full, or mid-write error leaves all stored provider API keys
corrupted. Switch to atomic write (temp file + fsync + os.replace) so
the original file is always intact on any failure.

Fixes #4591

* chore: trigger CI re-run

* chore: update PR description

* chore: fix how-to-test section for description check

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-23 23:28:53 +02:00
Dividesbyzer0 2e16394b41 fix(agent): parse misfenced read_file calls (#4799) 2026-06-23 23:20:13 +02:00
Jakub Grula 060dbf0681 feat: Allow admins to choose if they want to share defaults (#4752)
* First bare fix

* Adding the option toggle

* toggle function fix

* Final fix, added missing /auth/

* Extended toggle text & added tests

* Comments change

* Description toggle change

* br tag fix

* description change based on suggestion
2026-06-23 23:06:45 +02:00
Skoh d9ad418195 feat(ui): add toggle for padding around chat area (#4691) 2026-06-23 22:20:17 +02:00
Rudra Sarker 08994a0a96 fix: email poller marks calendar extraction processed on LLM failure (#4622)
Move calendar processed-marker insert into the LLM success path (else branch).
Previously, the INSERT ran even after a transient LLM failure, causing the
poller to skip retrying calendar extraction on subsequent runs.

Minimal change: only touches the try/except/else control flow in
_auto_summarize_pass_single() — preserves existing formatting and line endings.
2026-06-23 20:32:30 +02:00
Solanki Sumit e9136f801a fix(setup): load .env so a pre-seeded admin password is honored on native installs (#4787)
setup.py read ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD via os.getenv()
but never loaded .env, so on native Linux/macOS installs a password
pre-seeded in .env (documented in docs/setup.md and .env.example) was
silently ignored and a random one generated, breaking the first login.
Docker was unaffected because compose passes the vars into the container env.

Call load_dotenv(BASE_DIR/.env, encoding="utf-8-sig") at the top of main(),
mirroring app.py (utf-8-sig tolerates a Notepad UTF-8 BOM). load_dotenv does
not override already-exported OS vars, so the existing precedence is kept.
python-dotenv is already a required dependency.

Adds a regression test that pre-seeds credentials only in .env (not the
shell) and asserts the stored bcrypt hash matches the pre-seeded password.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:08:05 +02:00
Ahmed Dlshad e90dbc1012 fix(routes): 500 (not 404) when the app-shell index.html is missing (#4791)
Follow-up to #4637. serve_index — the handler for / and the SPA deep-link
routes (/notes, /calendar, /cookbook, /email, /memory, /gallery, /tasks,
/library) — pre-checked os.path.exists and raised its own
HTTPException(404, "index.html not found") when the bundle was missing. So a
missing core template returned 404 before serve_html_with_nonce's 500 could
fire, the one inconsistency left after #4637.

index.html is a fixed, app-bundled template; a missing one is a broken
deployment (server fault), not a client "not found", so it should surface as a
logged 500 in 5xx alerting rather than a 404. Keep the static->root fallback,
drop the redundant existence guard and the dead-end 404, and let the shared
helper handle the missing case.

Verified against the running app: / and /notes return 200 with the bundle
present and a logged 500 when index.html is absent.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:47:22 +02:00
Pedro Barbosa d47715036a fix: Real-ESRGAN install + Cookbook deps-panel crash on the Python 3.14 image (#4694)
* fix(docker): make Real-ESRGAN installable on the Python 3.14 image

realesrgan's deps basicsr/gfpgan/facexlib (unmaintained since 2022) read
their version in setup.py via `exec(...); locals()['__version__']`, which
raises KeyError on Python 3.13+ — PEP 667 made locals() in a function an
independent snapshot that exec() can no longer mutate. That fails the
Cookbook "install realesrgan" sdist build on the python:3.14 base.

Add a `realesrgan-wheels` builder stage that fetches the pinned sdists,
patches get_version() to exec into an explicit namespace dict, and builds
wheels; the final stage installs them --no-deps so a later
`pip install realesrgan` resolves from wheels instead of rebuilding the
broken sdists. torch stays a runtime pull to keep the base image lean.

Also add the runtime libs opencv-python (cv2) needs — libgl1,
libglib2.0-0t64, libxcb1 — which the slim base omits; without them the
install succeeds but `import cv2` dies with
`libxcb.so.1: cannot open shared object file`.

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

* fix(cookbook): don't let a package's sys.exit() on import hang the deps panel

The local optional-dependency probe imports each package in-process and
catches ImportError / Exception. But a package can call sys.exit() at
import time — e.g. rembg does `sys.exit(1)` when no onnxruntime backend
loads. SystemExit is a BaseException, not Exception, so it escaped the
probe, propagated out of the list_packages endpoint, and hung the whole
Dependencies panel / worker (the UI loads forever).

Catch (Exception, SystemExit) so one broken optional package is reported
as not-usable instead of taking down the panel.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:31:00 +02:00
Kalin Stoyanov 87407b3a09 fix debugging on windows (#4679) 2026-06-23 18:44:05 +02:00
Joel Alejandro Escareño Fernández 119228a6db feat(catalog): add Gemma 4 12B/QAT entries and RTX 3050 bandwidth (#4728)
Add official Gemma 4 12B-it plus QAT-INT4/INT8 catalog entries (with their
GGUF sources), QAT quantization support across the quant tables and the
prequantized-prefix list, and the missing RTX 3050 / 3050 Ti memory
bandwidth so speed estimates stop falling back to the generic cuda value.
2026-06-23 18:23:46 +02:00
Ahmed Dlshad 8f5e36a079 fix(routes): log and cleanly 500 on unreadable HTML page (#4637)
* fix(routes): serve 404 instead of 500 when an HTML page file is missing

_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.

Fixes #4594

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

* fix(routes): distinguish missing page (404) from read failure (500)

The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.

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

* fix(routes): treat unreadable bundled HTML page as logged 500, not 404

Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.

Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:12:32 +02:00
Max Hsu 30dd789351 fix(chat): strip executed email tool fences from the live stream (#3993) (#4275)
* fix(chat): strip executed email tool fences from the live stream (#3993)

The backend strips every fenced tool block from persisted text (the regex in
src/tool_parsing.py is built from the full TOOL_TAGS set, which includes the
email tools), so a reloaded session renders cleanly. The live frontend path
uses a separate hardcoded EXEC_FENCE_RE in static/js/chatRenderer.js that only
listed web_search/read_file/write_file/create_document/edit_document/
update_document — so executed email tool fences (list_emails, etc.) lingered as
raw code blocks in the live assistant bubble until the user reloaded.

Add the nine email tool tags to EXEC_FENCE_RE so the live render settles into
the same clean layout as the history reload. bash/python stay excluded on
purpose: those are languages a user may legitimately have asked the model to
show as code, not tool invocations.

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

* refactor(chat): single-source live exec-fence tool list from TOOL_TAGS (#3993)

Per review: EXEC_FENCE_RE was a second, hand-maintained copy of the
executable-tool list, so any tool not in it — and every future tool added to
TOOL_TAGS — would leave its executed fence lingering in the live bubble until
reload (the original #3993 bug, recurring one tool at a time).

EXEC_FENCE_RE is now built from an explicit EXEC_TOOL_TAGS list that mirrors
TOOL_TAGS (src/agent_tools/__init__.py) minus bash/python, which stay excluded
as legitimate code-example languages. A new regression test
(test_exec_fence_re_covers_all_executable_tools) extracts both lists from
source and fails if they drift, so the whole class is caught in CI instead of
by a user — the "minimum acceptable middle ground" from the review, made exact
(set equality, not just coverage).

Verified: pytest tests/test_live_strip_email_tool_fences.py (5 passed);
node --check static/js/chatRenderer.js; and a node run of the built regex
confirms email/generate_image/manage_memory/ls fences strip while
bash/python/sh are preserved.

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

* refactor(chat): build live exec-fence list from /api/tools at runtime (#3993)

Make TOOL_TAGS the single source for live exec-fence stripping. chatRenderer.js
no longer hard-codes a tool list; it fetches the backend's authoritative set
once from GET /api/tools (sorted(TOOL_TAGS)) and builds EXEC_FENCE_RE from it at
load, minus bash/python. No second list to drift, and a future tool added to
TOOL_TAGS is covered automatically — without touching the streaming path.

Until the fetch resolves EXEC_FENCE_RE is null and exec fences aren't stripped
(a sub-second window before the first stream); the backend already strips
persisted history, so a reload always renders clean.

Drop test_exec_fence_re_covers_all_executable_tools (no hand-maintained list to
guard) and add source-level guards: the frontend keeps no hard-coded list and
fetches /api/tools, and the endpoint serves the full sorted(TOOL_TAGS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVCKth4g8pWh7pwFDVm4iL

* fix(chat): warn on /api/tools fetch failure instead of swallowing it (#3993)

A fresh-context review flagged that loadExecFenceRegex's catch silently
discarded errors: if the one-shot fetch fails, EXEC_FENCE_RE stays null for the
whole session and live exec fences go unstripped until reload, with zero signal.
console.warn it, and correct the comment to describe the failure mode honestly
(was understated as just a sub-second startup window).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVCKth4g8pWh7pwFDVm4iL

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:12:32 +02:00
Michael e8175c9535 fix: Images cannot be seen by model that is vision capable (#4726)
* fix: Images cannot be seen by model that is vision capable

* fix: skip http(s) image_url for Ollama (images[] is base64-only)

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-23 10:32:57 +02:00
aubrey bd9149f79a fix(llm): detect mistral.ai provider and support reasoning_effort (#4698)
* fix(llm): detect mistral.ai provider and support reasoning_effort

Four coupled bugs broke Mistral thinking model support:

1. _detect_provider() had no mistral.ai host check, so all Mistral
   endpoints fell through to the generic 'openai' provider string.
   _provider_display_name() correctly identified them as 'Mistral',
   making any 'if provider == "Mistral"' check elsewhere dead code.

2. reasoning_effort parameter was never sent in the request payload,
   so Mistral never activated thinking mode even when the user
   configured a thinking-capable model (mistral-small-latest,
   mistral-medium-latest, magistral-*).

3. Mistral returns content as a typed array
   ([{"type":"thinking",...},{"type":"text",...}]) when
   reasoning is on, not as a plain string. Both the streaming and
   non-streaming parsers expected strings and silently dropped the
   thinking content.

4. _THINKING_MODEL_PATTERNS didn't include magistral or mistral-*
   model prefixes, so the frontend wouldn't tag reasoning output
   as thinking even after the above were fixed.

Fix:
- Add mistral.ai to _detect_provider() host checks
- Add a _normalize_mistral_content() helper that splits the typed
  array into (text, thinking) strings
- Inject payload["reasoning_effort"] = "high" when provider is
  Mistral and _supports_thinking(model) is true, in both stream_llm
  and llm_call_async payload construction
- Wire the normalizer into both response parsers
- Extend _THINKING_MODEL_PATTERNS to include magistral,
  mistral-small, mistral-medium, mistral-large

Tested on Docker install with mistral-small-latest +
reasoning_effort=high. Reasoning streams correctly into the
thinking panel after the fix.

Fixes #4678

* fix(llm): address review — lowercase provider id, configurable effort, tests

Addresses vdmkenny's review on PR #4698:

1. Removed duplicate 'if provider == "mistral"' block in stream_llm
   — two back-to-back copies, one was dead-redundant.

2. Dropped personal-context comment ('free-tier limits are generous
   for this user') and made reasoning_effort configurable via env var
   ODYSSEUS_MISTRAL_REASONING_EFFORT (high / medium / low / none).
   Default remains 'high' for backward compat with the tested behavior.

3. Recased provider id from 'Mistral' to 'mistral' to match the
   lowercase convention used by every other provider id in the file
   (openai, anthropic, ollama, copilot, ...). _provider_display_name()
   still returns the Title-Case 'Mistral' for UI labels — only the
   runtime id used in 'if provider == ...' checks was recased.

4. Added tests/test_llm_core_mistral_content.py with 13 tests pinning
   _normalize_mistral_content()'s contract: string passthrough, the
   Mistral array format (thinking + text blocks), and edge cases
   (empty, garbage, None, wrong types, missing fields, string-vs-array
   inner thinking field).

Also fixed a gap the review didn't catch: the non-streaming paths
(llm_call sync + llm_call_async) were missing the reasoning_effort
injection entirely. Added the same injection to both, so Deep Research
and agent tool calls also activate Mistral thinking.

All 13 new tests pass. Existing reasoning/streaming/ollama-thinking
tests still pass (38 tests, no regressions).

Fixes #4678
2026-06-23 10:28:17 +02:00
Max Hsu fef08ed114 fix(modal): keep body-portaled dropdowns above their tool modal at any stack depth (#4720) (#4724)
* fix(memory): keep the Brain memory item menu above the modal at any stack depth

The memory item "⋮" dropdown is portaled to <body> with a hardcoded
z-index of 10001. Tool modals, however, get a monotonically increasing
z-index from modalManager's bring-to-front counter (_modalTopZ), which
climbs unbounded as modals are opened/restored over a session. Once that
counter passes 10001, the Brain modal stacks above the body-portaled
dropdown, so the menu renders behind the panel — visible only where it
spills past the modal's edge (#4720).

Derive the dropdown's z-index from the owning modal's current z-index
(+1), keeping 10001 as a floor for the common low-counter case, so the
menu always sits just above its modal however high the counter has climbed.

Verified with document.elementFromPoint at the dropdown's location: with a
high modal z-index the old build returns the modal at every sampled point
(menu behind); the fixed build returns the dropdown (menu on top). The
default low-counter case is unchanged (z stays 10001).

* refactor(modal): route body-portaled dropdowns through a shared topPortalZ() helper

The hardcoded z-index:10001 the Brain memory menu used (#4720) is the same
literal shared by ~16 body-portaled dropdowns across calendar, cookbook,
cookbookServe, documentLibrary, emailLibrary, gallery, notes, emojiPicker and
memory — each renders behind its owning tool modal once modalManager's
bring-to-front counter climbs past the literal over a long session.

Promote the per-dropdown fix into a single topPortalZ() helper in
toolWindowZOrder.js — the existing source of truth for tool-window z, already
imported by modalManager's _bringToFront and notes.js — returning
max(topToolWindowZ(), dock-chip floor) + 1, so a portaled dropdown always sits
just above the live tool-window stack however high the counter has climbed.
Route all 16 sites through it. The slashCommands tour tooltips and the
cookbookServe VRAM dialog are intentionally left out (neither is a modal-owned
portaled dropdown).

Add tests/test_portal_dropdown_z_js.py covering the helper, including the #4720
scenario (modal counter at 99999 -> dropdown at 100000). Existing
test_notes_z_order_js.py stays green.
2026-06-23 10:24:31 +02:00
nopoz 7e5db9a3c6 fix(security): redact credential-bearing URLs and PII from logs (#4750)
* fix(security): redact credential-bearing URLs and PII from logs

Several log statements emitted sensitive data in clear text:

- model_routes / chat_routes / contacts_routes logged endpoint URLs raw.
  Admin-configured URLs can embed credentials in userinfo or query
  (e.g. https://user:pass@host, ?api_key=...). Route them through a
  shared core.log_safety.redact_url() that drops userinfo/query/fragment.
- note_routes / task_scheduler logged operator email addresses (smtp_user,
  recipient). Replaced with presence booleans, which keeps the diagnostic
  ("why didn't this send") without writing PII to logs.

model_routes already had a local redactor on its HTTPStatusError branch;
the generic except branch was missed, so reuse the existing helper there.

Clears CodeQL py/clear-text-logging-sensitive-data alerts 264, 317, 324,
325, 343, 344, 528.

* fix(security): re-bracket IPv6 hosts and single-source the URL redactor

Address review on #4750:
- redact_url now re-brackets IPv6 literals so host:port stays
  unambiguous (https://[2001:db8::1]:8443/v1, not the bracket-less
  ambiguous form).
- point model_routes._redact_url_for_log at the shared helper so the
  two redactors are single-sourced (also picks up the IPv6 fix).
2026-06-22 23:12:39 +02:00
nopoz 2f246c7779 fix(security): escape backslashes in calendar bg-image CSS url() (#4712)
* fix(security): escape backslashes in calendar bg-image CSS url()

The calendar event-background CSS escaped ' -> \' for a bg: image URL but
not backslashes first. Inside a single-quoted url('...'), \ is the CSS
escape char, so a URL value ending in/containing a backslash escapes the
closing quote and breaks out of the string, injecting arbitrary CSS. The
bg:<url> value is per-event and CalDAV-syncable, hence untrusted (CodeQL
js/incomplete-sanitization).

Add a single canonical _cssUrlEscape() in calendar/utils.js that escapes
backslashes FIRST, then quotes, and route all four sinks through it:
calendar.js:416 / :1263 (the flagged #463/#464), the event-form preview
(:2931), and _calBgCss() in utils.js — the latter two share the identical
bug but were unflagged. Output is byte-identical to the old escaping for
legitimate URLs (which contain no backslashes); only malicious input differs.

Resolves CodeQL js/incomplete-sanitization #463, #464.

* fix(security): route remaining calendar bg url() sinks through _cssUrlEscape

Review (vdmkenny) flagged that the centralization missed an injectable
sibling sink: the edit-form color-picker swatch (calendar.js:2856) built
`url('${url}')` from `existing.color` (a CalDAV-syncable, untrusted `bg:`
value) raw, then interpolated it into `style="background:..."` via innerHTML
- the same `'`/`\` breakout class as the sinks already fixed. The custom-dot
preview (:2953) was likewise raw (non-exploitable - a CSSOM `.style`
assignment of a URL the current user just picked - but it broke the invariant).

Route both through `_cssUrlEscape`, and normalize the two pre-escaped-variable
sites (_calItemBgStyle, _renderWeek) to the same inline form so all five
url() interpolations in calendar.js follow one rule. Add a whole-file
invariant test asserting every `url('${...}')` calls `_cssUrlEscape` - this
catches a future missed sink, the exact failure mode here. Behavior-identical
for legitimate URLs (no visual change).
2026-06-22 21:17:52 +02:00
Rudra Sarker 8ec27fd903 fix: document read fails with 403 when auth is disabled (#4623)
* fix: document read fails with 403 when auth is disabled

Add _auth_disabled() bypass in _verify_doc_owner() and the
/api/documents/{session_id} route guard so documents remain accessible
in single-user / no-auth mode.

Minimal change: only adds the auth-disabled check alongside existing
403 raises — preserves existing formatting and line endings.

* refactor: hoist _auth_disabled import to module level

Address reviewer feedback on PR #4623 — no circular import exists
(src.auth_helpers only imports stdlib + fastapi), so the inline
imports are unnecessary. Moves the import to module top in both
document_helpers.py and document_routes.py.

* test: add regression tests for auth-disabled document access (PR #4623)
2026-06-22 21:01:11 +02:00
MACKAT05 b57989f08c fix(hwfit): repair remote Windows hardware scan over SSH (#4674)
Remote Cookbook hwfit probes failed on Windows hosts because the PowerShell script was sent as nested -Command quoting through OpenSSH. Use -EncodedCommand for remote probes, auto-detect platform when omitted (including Darwin for Mac SSH hosts), and return a clearer error when SSH works but the probe fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:59:09 +02:00
Gabriel Peña 91bba117c1 fix ask-user choices across reloads (#4669) 2026-06-22 20:49:49 +02:00
Mocchibird 4c82e4a172 fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684)
The app's ad-hoc dropdown/context menus each wire their own document-level
outside-click listener, but that listener only removes itself on an *outside*
click. Every other dismissal path -- clicking a menu item (which calls
el.remove() directly), a Cancel button, Escape, or the "close the
previously-open menu" reopen sweep -- tears the node down without
unregistering the listener, orphaning it on `document`. The stranded listener
then lingers and can break the next menu interaction: the recurring "the
button stops working until I refresh the page" class of bug (e.g. delete an
email, then the kebab/more button is dead on the other rows).

Route all 16 of these menus through the existing escMenuStack helper
(bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js
_showLibDropdown, cookbookRunning.js, and research/panel.js already do: a
single idempotent close() owns the teardown and is released on every dismissal
path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and
Escape flows through the central LIFO esc-stack arbiter. Net -49 lines.

Menus migrated: cookbook _showDepMenu; document export menu and
_openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary
_showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery
_showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings
(3 unified-integrations dropdowns); skills _openSkillMenu; tasks
_showTaskDropdown; compare _toggleExportMenu.

Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms
ghost-click guard, emailLibrary's reader-more-active anchor class and the
bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles).

Six menus with custom lifecycles (notes _openReminderMenu, sessions
long-press, document markdown-toolbar, emojiPicker, compare model selector)
are intentionally left for a follow-up -- each needs individual review.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
Ahmed Dlshad b899095f18 docs(setup): note -BindHost flag for LAN access on native Windows (#4636)
The native Windows launcher binds to 127.0.0.1 via its own -BindHost
parameter and does not read APP_BIND/ODYSSEUS_HOST from .env, so editing
.env alone leaves the server on loopback. Document the -BindHost flag in
the Native Windows setup section, with the existing keep-auth-on /
don't-expose-publicly caveats.

Fixes #4552

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:29:55 +02:00
Mostafa Eid 888e25624f fix(sessions): prevent Backspace/Delete from deleting session while renaming (#4662) 2026-06-22 20:22:52 +02:00
comatrix-1 c062c27648 Fix link to CONTRIBUTING.md in setup documentation (#4677) 2026-06-22 20:12:04 +02:00
holden093 93ec7cbb52 fix(contacts): verify UID removal after CardDAV DELETE (#4642)
Add a post-delete verification step: after the CardDAV server returns
2xx/404, force-re-fetch the contact list and confirm the UID is gone.
If the UID is still present, log a warning and return False instead of
silently reporting success.

This catches the case where _resolve_resource_url falls back to the
guessed {uid}.vcf URL but the contact's real resource URL differs —
the DELETE hits the wrong URL, server returns 404 (treated as success),
but the contact remains. Previously this caused silent persistence
failures and agent loops.
2026-06-22 18:39:44 +02:00
ooovenenoso c12b8ab6c9 fix: add OpenCode setup provider aliases (#4700)
Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-22 17:33:02 +02:00
Ashvin e812a29233 fix(markdown): preserve URLs inside inline code spans (#4681)
Inline backtick spans were converted to <code> only at the end of
mdToHtml, after the bare-URL autolink and <a>/allowed-HTML passes. A URL
inside inline code is preceded by a space, so the autolink wrapped it in
an <a> tag and swapped it for an ___ALLOWED_HTML_ placeholder, corrupting
commands like `irm http://127.0.0.1:3000/x`.

Extract inline code into placeholders before the link passes, mirroring
the existing fenced-code-block handling, and restore them last so
placeholders carried inside restored <a> blocks resolve. Escape the code
at extraction time since it now bypasses the global escape pass.
2026-06-22 17:23:55 +02:00
nopoz ca4973c41f fix(security): prevent exponential ReDoS in email→calendar extract regex (#4708)
The fallback regex in email_pollers.py that recovers a
[{"action": ...}, ...] JSON array from raw model output used lazy
[^[\]]*? runs inside a (?:,\s*\{...\}\s*)* repetition, which backtracks
exponentially (CodeQL py/redos) on inputs like [{"action"},{ + }},{{ * N.
It runs on the LLM reply to an email→calendar prompt embedding the
untrusted email body, so a crafted email can stall the background poller.

Extract the pattern to a module-level _CAL_ACTION_ARRAY_RE and rewrite the
object-content class from the lazy [^[\]]*? to a greedy brace-delimited
[^{}], which removes the quantifier ambiguity. The match is linear (a 500KB
adversarial input now resolves in <1ms) and equivalent on well-formed
arrays; it is also strictly more robust for values containing '[' or ']'
(the old class bailed on those and extracted nothing).

Resolves CodeQL py/redos #198.
2026-06-22 17:18:34 +02:00
Tom 91b4171b3f feat(a11y): add a Text size control and an OpenDyslexic font option (#4210)
* feat(a11y): add a Text size control and an OpenDyslexic font option

Text size: a Theme > Font & Layout control (Default / Larger) that scales the whole UI via CSS zoom, so the many hard-coded px sizes scale too (density only moves the root font-size). Stored globally so it persists across theme switches; applied early in the boot script to avoid a flash. OpenDyslexic: a dyslexia-friendly self-hosted font (SIL OFL 1.1), bundled as woff2 alongside Fira Code/Inter and wired into the Font select. Reuses the existing density/font pattern end to end; no new colours, spacing, or component styles.

* fix(a11y): keep modals on-screen at Larger text size

Inline vh heights on .modal-content overrode the ui-scale-125 max-height
compensation, so Cookbook (and the email/doc/skills/PDF modals) overflowed
the viewport at 125% — pushing the header and close button off-screen.
Let the compensation own those heights.

* fix(a11y): keep PDF export modal at its original 86vh on Default size
2026-06-22 13:53:46 +02:00
PewDiePie d36879bd50 Merge pull request #4706 from pewdiepie-archdaemon/sync-readme-screenshot-dev
docs: refresh README screenshot
2026-06-22 14:02:35 +09:00
pewdiepie-archdaemon b51656770d Refresh README screenshot 2026-06-22 04:54:15 +00:00
PewDiePie 5f63a3d3bd Merge pull request #4701 from pewdiepie-archdaemon/sync-dev-from-main-20260622
chore(dev): sync main cookbook and model workflow fixes
2026-06-22 11:52:26 +09:00
Alexandre Teixeira bd0c67b6d3 fix(agent): preserve loop guard stream behavior 2026-06-15 17:17:16 +01:00
Alexandre Teixeira ff5bcd9864 fix(agent): surface early loop-guard stops 2026-06-15 17:07:15 +01:00
106 changed files with 5230 additions and 1425 deletions
+1
View File
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors | | [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson | | [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois | | [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
## Python dependencies ## Python dependencies
+30
View File
@@ -1,3 +1,14 @@
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14-slim AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14-slim FROM python:3.14-slim
# System deps. tmux is required by Cookbook for background downloads/serves. # System deps. tmux is required by Cookbook for background downloads/serves.
@@ -18,8 +29,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tmux \ tmux \
openssh-client \ openssh-client \
gosu \ gosu \
libgl1 \
libglib2.0-0t64 \
libxcb1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
# Docker CLI (client only — daemon stays on the host via the # Docker CLI (client only — daemon stays on the host via the
# /var/run/docker.sock mount). The Debian `docker.io` package ships # /var/run/docker.sock mount). The Debian `docker.io` package ships
# dockerd but not the client binary on slim, so grab the static client # dockerd but not the client binary on slim, so grab the static client
@@ -46,6 +67,15 @@ COPY requirements.txt requirements-optional.txt ./
RUN pip install --no-cache-dir -r requirements.txt \ RUN pip install --no-cache-dir -r requirements.txt \
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi && if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
# pulled only when realesrgan is actually installed). With these dists already
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
# wheels instead of rebuilding the sdists that fail on Python 3.14.
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
&& rm -rf /tmp/odysseus-wheels
# Copy app code # Copy app code
COPY . . COPY . .
+21 -17
View File
@@ -2,6 +2,16 @@
import mimetypes import mimetypes
import os import os
import sys import sys
import asyncio
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
# automatically. But the VS Code debugger (and other non-uvicorn entrypoints)
# use the default SelectorEventLoop, which raises NotImplementedError on any
# subprocess call. Force ProactorEventLoop here so the right loop is always
# used, regardless of how the process is launched.
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
def register_static_mime_types() -> None: def register_static_mime_types() -> None:
@@ -44,7 +54,7 @@ from typing import Dict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse from fastapi.responses import JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -65,7 +75,7 @@ from core.exceptions import (
import bcrypt as _bcrypt import bcrypt as _bcrypt
from src.app_helpers import abs_join from src.app_helpers import abs_join, serve_html_with_nonce
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
@@ -791,23 +801,17 @@ app.include_router(setup_companion_routes())
# ========= ROUTES (kept in app.py) ========= # ========= ROUTES (kept in app.py) =========
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
with open(file_path, "r", encoding="utf-8") as f:
html = f.read()
nonce = getattr(request.state, "csp_nonce", "")
html = html.replace("{{CSP_NONCE}}", nonce)
return HTMLResponse(html)
@app.get("/") @app.get("/")
async def serve_index(request: Request): async def serve_index(request: Request):
static_path = abs_join(BASE_DIR, "static/index.html") static_path = abs_join(BASE_DIR, "static/index.html")
if os.path.exists(static_path): if os.path.exists(static_path):
return _serve_html_with_nonce(request, static_path) return serve_html_with_nonce(request, static_path)
root_path = abs_join(BASE_DIR, "index.html") # No static bundle — fall back to a root-level index.html if one is shipped.
if os.path.exists(root_path): # If neither exists, serve_html_with_nonce logs it and returns a generic 500:
return _serve_html_with_nonce(request, root_path) # a missing index.html is a broken deployment (server fault), not a client
raise HTTPException(404, "index.html not found") # "not found". This keeps the app-shell route consistent with the other
# bundled-template routes instead of mislabelling the fault as a 404.
return serve_html_with_nonce(request, abs_join(BASE_DIR, "index.html"))
@app.get("/notes") @app.get("/notes")
async def serve_notes(request: Request): async def serve_notes(request: Request):
@@ -848,13 +852,13 @@ async def serve_library(request: Request):
@app.get("/backgrounds") @app.get("/backgrounds")
async def serve_backgrounds(request: Request): async def serve_backgrounds(request: Request):
"""Sandbox page for prototyping background effects. No auth required.""" """Sandbox page for prototyping background effects. No auth required."""
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html")) return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
@app.get("/login") @app.get("/login")
async def serve_login(request: Request): async def serve_login(request: Request):
if not AUTH_ENABLED: if not AUTH_ENABLED:
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html")) return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
@app.get("/api/version") @app.get("/api/version")
async def get_version(): async def get_version():
+1 -1
View File
@@ -1,4 +1,4 @@
# src/exceptions.py # core/exceptions.py
"""Custom exceptions for the application.""" """Custom exceptions for the application."""
class SessionNotFoundError(Exception): class SessionNotFoundError(Exception):
+27
View File
@@ -0,0 +1,27 @@
"""Helpers for keeping sensitive data out of logs.
Endpoint URLs configured by admins can embed credentials in the userinfo
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
raw leaks those secrets, so route/diagnostic logs run URLs through
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
"""
from urllib.parse import urlparse, urlunparse
def redact_url(url: str) -> str:
"""Return a URL safe for logs by removing userinfo and query/fragment.
Keeps scheme, host, port and path so logs stay useful for debugging.
"""
try:
parsed = urlparse(url or "")
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
host = f"[{host}]"
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
except Exception:
return "<endpoint>"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Build patched wheels for Real-ESRGAN's unmaintained dependencies.
#
# basicsr / gfpgan / facexlib (xinntao, last released 2022) read their version
# in setup.py with:
#
# exec(compile(f.read(), version_file, 'exec'))
# return locals()['__version__']
#
# Python 3.13+ implements PEP 667: locals() inside a function returns an
# independent snapshot that exec() can no longer mutate, so the read raises
# `KeyError: '__version__'` and the sdist build fails. That is why the Cookbook
# "install realesrgan" button dies on the python:3.14 image. The packages have
# no fixed release, so we patch get_version() to exec into an explicit namespace
# dict (works on every Python) and build wheels from the patched source.
#
# Usage: build-realesrgan-wheels.sh [OUTPUT_DIR] (default: /wheels)
set -euo pipefail
OUT="${1:-/wheels}"
mkdir -p "$OUT"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
cd "$work"
# Pinned to the versions Real-ESRGAN 0.3.0 resolves to.
SPECS="basicsr==1.4.2 gfpgan==1.3.8 facexlib==0.3.0"
for spec in $SPECS; do
name="${spec%%==*}"
ver="${spec##*==}"
# pip download builds metadata (and trips the same bug), so fetch the raw
# sdist URL from the PyPI JSON API instead.
url="$(python - "$name" "$ver" <<'PY'
import json, sys, urllib.request
name, ver = sys.argv[1], sys.argv[2]
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
for f in data["urls"]:
if f["packagetype"] == "sdist":
print(f["url"]); break
else:
sys.exit(f"no sdist found for {name}=={ver}")
PY
)"
echo ">> fetching ${name} ${ver}: ${url}"
curl -fsSL "$url" -o "${name}.tar.gz"
tar xzf "${name}.tar.gz"
done
echo ">> patching get_version()"
python - <<'PY'
import pathlib
old_exec = "exec(compile(f.read(), version_file, 'exec'))"
new_exec = "_ver_ns = {}\n exec(compile(f.read(), version_file, 'exec'), _ver_ns)"
old_ret = "return locals()['__version__']"
new_ret = "return _ver_ns['__version__']"
patched = 0
for setup in pathlib.Path(".").glob("*/setup.py"):
s = setup.read_text()
if old_exec in s and old_ret in s:
setup.write_text(s.replace(old_exec, new_exec).replace(old_ret, new_ret))
print(" patched", setup)
patched += 1
assert patched == 3, f"expected to patch 3 setup.py files, patched {patched}"
PY
echo ">> building wheels into ${OUT}"
pip wheel --no-deps -w "$OUT" ./basicsr-* ./gfpgan-* ./facexlib-*
ls -l "$OUT"
+14 -1
View File
@@ -15,7 +15,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
For Docker installs, the same line is in `docker compose logs odysseus`. For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**. Use that for the first login, then change it in **Settings**.
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
pull request guidelines. pull request guidelines.
### Docker (recommended) ### Docker (recommended)
@@ -250,6 +250,19 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
If `python` points at an older interpreter, use `py -3.12` (or another installed If `python` points at an older interpreter, use `py -3.12` (or another installed
3.11+ version) for the venv step. 3.11+ version) for the venv step.
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
alone leaves the native Windows server on loopback. Pass the launcher's
`-BindHost` flag instead:
```powershell
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
```
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents, **Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
email, calendar, deep research) runs fully native. For full **Cookbook** background email, calendar, deep research) runs fully native. For full **Cookbook** background
model downloads and the agent shell tool, also install model downloads and the agent shell tool, also install
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+4 -1
View File
@@ -29,6 +29,7 @@ from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode from core.database import SessionLocal, get_session_mode, set_session_mode
from core.database import Session as DBSession, ChatMessage as DBChatMessage from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Document as DBDocument, ModelEndpoint from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
from routes.research_routes import _resolve_research_endpoint from routes.research_routes import _resolve_research_endpoint
from routes.model_routes import _visible_models from routes.model_routes import _visible_models
from routes.chat_helpers import ( from routes.chat_helpers import (
@@ -930,7 +931,7 @@ def setup_chat_routes(
if effective_do_research: if effective_do_research:
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess) _r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
_auth_keys = list(_r_headers.keys()) if _r_headers else [] _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)}") logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
# Clarification round: only for very short/vague queries on first research message. # Clarification round: only for very short/vague queries on first research message.
# Skip in compare mode — each pane is a fresh session, so every one would # Skip in compare mode — each pane is a fresh session, so every one would
@@ -1309,6 +1310,8 @@ def setup_chat_routes(
"doc_stream_open", "doc_stream_delta", "doc_stream_open", "doc_stream_delta",
"doc_update", "doc_suggestions", "ui_control", "doc_update", "doc_suggestions", "ui_control",
"rounds_exhausted", "rounds_exhausted",
"loop_breaker_triggered",
"intent_nudge_exhausted",
"ask_user", "ask_user",
"plan_update", "plan_update",
): ):
+17 -7
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from urllib.parse import urljoin, urlparse, urlunparse from urllib.parse import urljoin, urlparse, urlunparse
from core.log_safety import redact_url
from fastapi import APIRouter, Query, Depends, Response, HTTPException from fastapi import APIRouter, Query, Depends, Response, HTTPException
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
url = _resolve_resource_url(uid) url = _resolve_resource_url(uid)
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
r = httpx.delete(url, auth=auth, timeout=10) r = httpx.delete(url, auth=auth, timeout=10)
if r.status_code in (200, 204): if r.status_code in (200, 204, 404):
# Invalidate cache so the next fetch sees the server truth.
_contact_cache["fetched_at"] = None _contact_cache["fetched_at"] = None
return True # Verify: force a fresh fetch and check the UID is actually gone.
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
# lives at a different resource URL — the DELETE missed it but
# we'd silently report success. This check catches that.
fresh = _fetch_contacts(force=True)
still_there = any(c.get("uid") == uid for c in fresh)
if still_there:
logger.warning(
f"CardDAV DELETE reported success for {uid} "
f"but UID still present after re-fetch — "
f"resource URL may differ from {redact_url(url)}"
)
return False
if r.status_code == 404: if r.status_code == 404:
# Resource not found at the resolved URL. With href resolution logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
# this should be rare (genuinely already deleted). Invalidate
# the cache and report success so the UI doesn't keep a ghost.
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
_contact_cache["fetched_at"] = None
return True return True
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}") logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
return False return False
+3 -1
View File
@@ -12,6 +12,7 @@ from pydantic import BaseModel
from core.database import Document, DocumentVersion from core.database import Document, DocumentVersion
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler from src.upload_handler import UploadHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,6 +79,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
the session join for any not-yet-backfilled legacy row. the session join for any not-yet-backfilled legacy row.
""" """
if user is None: if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required") raise HTTPException(403, "Authentication required")
if doc.owner is not None: if doc.owner is not None:
if doc.owner != user: if doc.owner != user:
@@ -104,7 +107,6 @@ def _owner_session_filter(q, user):
by the time this filter is live there are no NULL-owner rows to leak; by the time this filter is live there are no NULL-owner rows to leak;
we therefore match the owner strictly for authenticated callers.""" we therefore match the owner strictly for authenticated callers."""
if not user: if not user:
from src.auth_helpers import _auth_disabled
if user == "" or _auth_disabled(): if user == "" or _auth_disabled():
return q return q
return q.filter(False) return q.filter(False)
+2 -1
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
from sqlalchemy import case, func, or_ from sqlalchemy import case, func, or_
from core.database import SessionLocal, Document, DocumentVersion from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user from src.auth_helpers import get_current_user, _auth_disabled
from src.constants import MAIL_ATTACHMENTS_DIR from src.constants import MAIL_ATTACHMENTS_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -388,6 +388,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
db = SessionLocal() db = SessionLocal()
try: try:
if not user: if not user:
if not _auth_disabled():
raise HTTPException(403, "Authentication required") raise HTTPException(403, "Authentication required")
# v2 review HIGH-9: raise 403 explicitly when the caller # v2 review HIGH-9: raise 403 explicitly when the caller
# can't see this session, instead of returning [] which the # can't see this session, instead of returning [] which the
+16 -2
View File
@@ -44,6 +44,17 @@ from routes.email_helpers import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
# fenced-block strip leaves nothing usable. Runs on model output influenced by
# untrusted email bodies, so it must not backtrack: the object content class is
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
# (CodeQL py/redos #198).
_CAL_ACTION_ARRAY_RE = re.compile(
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
re.DOTALL,
)
def _owner_for_email_account(account_id: str | None) -> str: def _owner_for_email_account(account_id: str | None) -> str:
if not account_id: if not account_id:
@@ -558,7 +569,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
cal_extract = _strip_think(_raw_original) cal_extract = _strip_think(_raw_original)
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip() cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
if not cal_extract and _raw_original: if not cal_extract and _raw_original:
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL)) matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
if matches: if matches:
cal_extract = matches[-1].group() cal_extract = matches[-1].group()
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}") logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
@@ -683,7 +694,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}") logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}")
except Exception as e: except Exception as e:
logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}") logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}")
# Record we processed this email so we don't re-LLM next run else:
# Record we processed this email so we don't re-LLM next run.
# Only mark as processed on success ? transient LLM failures
# are retried on the next poll run (matches summary/reply pattern).
try: try:
_cc = _sql3.connect(SCHEDULED_DB) _cc = _sql3.connect(SCHEDULED_DB)
_cc.execute( _cc.execute(
+49 -18
View File
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
from pydantic import BaseModel from pydantic import BaseModel
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from core.database import SessionLocal, ModelEndpoint, Session as DbSession from core.database import SessionLocal, ModelEndpoint, Session as DbSession
from core.log_safety import redact_url as _redact_url_for_log
from core.middleware import require_admin from core.middleware import require_admin
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
from src.tls_overrides import llm_verify from src.tls_overrides import llm_verify
@@ -522,6 +523,10 @@ _NON_CHAT_EXACT_PREFIXES = (
def _is_chat_model(model_id: str) -> bool: def _is_chat_model(model_id: str) -> bool:
"""Return True if the model ID looks like a chat/completions-capable model.""" """Return True if the model ID looks like a chat/completions-capable model."""
if not isinstance(model_id, str):
# Non-compliant upstreams can return non-string IDs (e.g. int/None);
# treat them as chat-capable rather than crashing on .lower().
return True
mid = model_id.lower() mid = model_id.lower()
for prefix in _NON_CHAT_PREFIXES: for prefix in _NON_CHAT_PREFIXES:
if mid.startswith(prefix): if mid.startswith(prefix):
@@ -582,18 +587,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
return {"Authorization": f"Bearer {api_key}"} if api_key else {} return {"Authorization": f"Bearer {api_key}"} if api_key else {}
def _redact_url_for_log(url: str) -> str:
"""Return a URL safe for logs by removing userinfo and query/fragment."""
try:
parsed = urlparse(url or "")
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
except Exception:
return "<endpoint>"
def _is_discovery_only_provider(provider: str) -> bool: def _is_discovery_only_provider(provider: str) -> bool:
return provider == "chatgpt-subscription" return provider == "chatgpt-subscription"
@@ -737,6 +730,34 @@ def _is_loading_model_response(resp: Any) -> bool:
def _openai_model_ids(data: Any) -> List[str]:
"""Extract OpenAI-style model IDs (``{"data": [{"id": ...}]}``).
Tolerates a non-dict body and non-string IDs from non-compliant upstreams,
returning only non-empty string IDs.
"""
items = data.get("data") if isinstance(data, dict) else None
return [m["id"] for m in (items or [])
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]]
def _ollama_model_names(data: Any) -> List[str]:
"""Extract native-Ollama model names (``{"models": [{"name"|"model": ...}]}``).
Same tolerance as :func:`_openai_model_ids`: a non-dict body or non-string
value is skipped rather than crashing, preserving name-then-model precedence.
"""
items = data.get("models") if isinstance(data, dict) else None
out: List[str] = []
for m in (items or []):
if not isinstance(m, dict):
continue
v = m.get("name") or m.get("model")
if isinstance(v, str) and v:
out.append(v)
return out
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]: def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
"""Probe a base URL's /models endpoint and return list of model IDs. """Probe a base URL's /models endpoint and return list of model IDs.
For Anthropic, queries their /v1/models API, falling back to hardcoded list.""" For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
@@ -759,7 +780,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify()) r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")] models = _openai_model_ids(data)
if models: if models:
return models return models
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -781,10 +802,10 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
# OpenAI format: {"data": [{"id": "model-name"}]} # OpenAI format: {"data": [{"id": "model-name"}]}
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")] models = _openai_model_ids(data)
# Ollama format: {"models": [{"name": "model-name"}]} # Ollama format: {"models": [{"name": "model-name"}]}
if not models: 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")] models = _ollama_model_names(data)
if models: if models:
# Z.AI coding plan omits some working models from /models; # Z.AI coding plan omits some working models from /models;
# append curated-only entries for that endpoint only. # append curated-only entries for that endpoint only.
@@ -810,9 +831,9 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e) logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
except Exception as e: except Exception as e:
if api_key: if api_key:
logger.warning(f"Failed to probe {url} with API key: {e}") logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
return [] return []
logger.warning(f"Failed to probe {url}: {e}") logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
# Older Ollama builds and some proxies expose native /api/tags even when # Older Ollama builds and some proxies expose native /api/tags even when
# the OpenAI-compatible /v1/models path is unavailable. # the OpenAI-compatible /v1/models path is unavailable.
@@ -823,7 +844,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify()) r = httpx.get(root + "/api/tags", timeout=timeout, verify=llm_verify())
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
models = [m.get("name") or m.get("model") for m in (data.get("models") or []) if m.get("name") or m.get("model")] models = _ollama_model_names(data)
if models: if models:
return [m for m in models if _is_chat_model(m)] return [m for m in models if _is_chat_model(m)]
except Exception as e: except Exception as e:
@@ -2119,6 +2140,16 @@ def setup_model_routes(model_discovery):
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
model = (_user_prefs.get("default_model") or "").strip() model = (_user_prefs.get("default_model") or "").strip()
_fallbacks = _user_prefs.get("default_model_fallbacks") or [] _fallbacks = _user_prefs.get("default_model_fallbacks") or []
# If user has no personal default, fall back to global default
# But only based on the "share_defaults_with_users" flag
# (only if share_defaults_with_users is enabled)
if settings.get("share_defaults_with_users", False):
if not ep_id:
ep_id = settings.get("default_endpoint_id", "")
if not model:
model = settings.get("default_model", "")
if not _fallbacks:
_fallbacks = settings.get("default_model_fallbacks") or []
else: else:
ep_id = settings.get("default_endpoint_id", "") ep_id = settings.get("default_endpoint_id", "")
model = settings.get("default_model", "") model = settings.get("default_model", "")
+5 -4
View File
@@ -335,10 +335,11 @@ async def dispatch_reminder(
# Loud diagnostic so we can see WHY a reminder didn't send (the # Loud diagnostic so we can see WHY a reminder didn't send (the
# previous "silently no-op when cfg has no smtp_host" was invisible). # previous "silently no-op when cfg has no smtp_host" was invisible).
logger.info( logger.info(
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} " "dispatch_reminder[email] note_id=%s owner=%r "
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} " "has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
f"from={from_addr!r} recipient={recipient!r} " note_id, owner,
f"account_name={cfg.get('account_name')!r}" bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
bool(from_addr), bool(recipient),
) )
missing = [] missing = []
if not cfg.get("smtp_host"): if not cfg.get("smtp_host"):
+8 -3
View File
@@ -1377,11 +1377,16 @@ def setup_shell_routes() -> APIRouter:
pkg["installed"] = False pkg["installed"] = False
except importlib_metadata.PackageNotFoundError: except importlib_metadata.PackageNotFoundError:
pkg["installed"] = False pkg["installed"] = False
except Exception: except (Exception, SystemExit):
# Installed but crashes on import — e.g. a CUDA build of # Installed but crashes on import — e.g. a CUDA build of
# llama-cpp-python raising FileNotFoundError when the CUDA # llama-cpp-python raising FileNotFoundError when the CUDA
# toolkit dir is absent. One broken optional package must not # toolkit dir is absent, or rembg calling sys.exit(1) when no
# 500 the entire packages panel; report it as not usable. # onnxruntime backend can be loaded. SystemExit is a
# BaseException, not Exception, so without catching it here a
# single sys.exit-on-import package escapes and takes down the
# whole packages panel / worker (the panel hangs forever). One
# broken optional package must not 500 — or hang — the entire
# panel; report it as not usable.
pkg["installed"] = False pkg["installed"] = False
# llama_cpp partial-state probe: when the package is installed # llama_cpp partial-state probe: when the package is installed
+132
View File
@@ -14059,6 +14059,138 @@
"vision" "vision"
] ]
}, },
{
"name": "google/gemma-4-12B-it",
"provider": "Google",
"parameter_count": "12.0B",
"parameters_raw": 12000000000,
"min_ram_gb": 8.5,
"recommended_ram_gb": 11.0,
"min_vram_gb": 7.5,
"quantization": "Q4_K_M",
"context_length": 131072,
"use_case": "General purpose, multimodal; unsloth/gemma-4-12B-it-GGUF Dynamic variants reduce VRAM from ~7.5 GB to ~5.5 GB",
"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": [
{
"repo": "unsloth/gemma-4-12B-it-GGUF",
"provider": "unsloth"
}
],
"capabilities": [
"vision"
]
},
{
"name": "google/gemma-4-12B-it-qat-int4",
"provider": "Google",
"parameter_count": "12.0B",
"parameters_raw": 12000000000,
"min_ram_gb": 8.0,
"recommended_ram_gb": 9.5,
"min_vram_gb": 6.5,
"quantization": "QAT-INT4",
"context_length": 131072,
"use_case": "General purpose, multimodal (QAT quantization-aware training — higher quality than post-train INT4; vLLM native; no GGUF)",
"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-12B-it-qat-int8",
"provider": "Google",
"parameter_count": "12.0B",
"parameters_raw": 12000000000,
"min_ram_gb": 15.0,
"recommended_ram_gb": 20.0,
"min_vram_gb": 13.5,
"quantization": "QAT-INT8",
"context_length": 131072,
"use_case": "General purpose, multimodal (QAT INT8 — highest quality, 2x VRAM of QAT-INT4; vLLM native; no GGUF)",
"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-12B-it-qat-q4_0-gguf",
"provider": "Google",
"parameter_count": "12.0B",
"parameters_raw": 12000000000,
"min_ram_gb": 8.5,
"recommended_ram_gb": 11.0,
"min_vram_gb": 7.5,
"quantization": "QAT-INT4",
"context_length": 262144,
"use_case": "General purpose, multimodal (vision + audio); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp/Ollama with CPU offload",
"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": [
{
"repo": "google/gemma-4-12B-it-qat-q4_0-gguf",
"provider": "Google",
"file": "gemma-4-12b-it-qat-q4_0.gguf"
}
],
"capabilities": [
"vision",
"audio"
]
},
{
"name": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
"provider": "Google",
"parameter_count": "25.2B",
"parameters_raw": 25200000000,
"min_ram_gb": 14.4,
"recommended_ram_gb": 18.0,
"min_vram_gb": 14.4,
"quantization": "QAT-INT4",
"context_length": 262144,
"use_case": "High-throughput, multimodal MoE (3.8B active); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp with CPU offload",
"is_moe": true,
"num_experts": null,
"active_experts": null,
"active_parameters": 3800000000,
"architecture": "gemma4",
"pipeline_tag": "image-text-to-text",
"release_date": "2026-04-01",
"gguf_sources": [
{
"repo": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
"provider": "Google"
}
],
"capabilities": [
"vision"
]
},
{ {
"name": "google/gemma-4-31B-it", "name": "google/gemma-4-31B-it",
"provider": "Google", "provider": "Google",
+1 -1
View File
@@ -9,7 +9,7 @@ from services.hwfit.models import (
GPU_BANDWIDTH = { GPU_BANDWIDTH = {
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256, "5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272, "4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360, "3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360, "3050 ti": 192, "3050": 224,
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336, "2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128, "1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555, "h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
+37 -5
View File
@@ -538,6 +538,32 @@ def _powershell_exe():
path so we don't depend on a particular PATH ordering.""" path so we don't depend on a particular PATH ordering."""
return shutil.which("pwsh") or shutil.which("powershell") or "powershell" return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
def _powershell_encoded_for_ssh(script: str):
"""Run a PowerShell script on a remote Windows host over SSH.
Nested quotes in powershell -Command break when passed through Windows
OpenSSH's cmd wrapper; -EncodedCommand avoids that.
"""
import base64
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
return _run(f"powershell -NoProfile -EncodedCommand {encoded}")
def _probe_remote_platform():
"""Best-effort OS detection over SSH when the caller didn't pass platform."""
out = _run("echo %OS%")
if out and "Windows_NT" in out:
return "windows"
uname = (_run(["uname", "-s"]) or "").strip().lower()
if uname == "darwin":
# Mac uses the linux detection path (_detect_apple_silicon over SSH).
return "linux"
if uname == "linux":
out = _run("test -d /data/data/com.termux && echo termux || echo linux")
if out and "termux" in out:
return "termux"
return "linux"
def _detect_windows(): def _detect_windows():
"""Detect Windows hardware via PowerShell/WMI. """Detect Windows hardware via PowerShell/WMI.
@@ -600,9 +626,8 @@ def _detect_windows():
""" """
) )
if _remote_host: if _remote_host:
# Remote: ship a single command string over SSH. The remote shell parses # Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
# the quoting; PowerShell on the far side runs the -Command payload. out = _powershell_encoded_for_ssh(ps_cmd.strip())
out = _run(f'powershell -Command "{ps_cmd}"')
else: else:
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd # Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer # to PowerShell verbatim — no fragile string-level quote escaping. Prefer
@@ -773,6 +798,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
""" """
global _remote_host, _remote_port, _remote_platform global _remote_host, _remote_port, _remote_platform
if host and not platform:
_remote_host = host
_remote_port = ssh_port or None
platform = _probe_remote_platform()
_remote_host = None
_remote_port = None
cache_key = _cache_key(host, ssh_port, platform) cache_key = _cache_key(host, ssh_port, platform)
now = time.time() now = time.time()
if not fresh and cache_key in _cache_by_host: if not fresh and cache_key in _cache_by_host:
@@ -793,8 +825,8 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
_remote_platform = None _remote_platform = None
_cache_by_host[cache_key] = (now, result) _cache_by_host[cache_key] = (now, result)
return result return result
# If Windows detection failed, return error # SSH may work while the PowerShell hardware probe still fails.
result = {"error": f"Cannot connect to {host}", "host": host} result = {"error": f"Windows hardware probe failed for {host}", "host": host}
_remote_host = None _remote_host = None
_remote_platform = None _remote_platform = None
_cache_by_host[cache_key] = (now, result) _cache_by_host[cache_key] = (now, result)
+8
View File
@@ -12,6 +12,7 @@ QUANT_BPP = {
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37, "Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0, "AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0, "GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75, "mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non- # DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
# expert dense in FP8, embeddings/LM head in BF16. By weight count the # expert dense in FP8, embeddings/LM head in BF16. By weight count the
@@ -30,6 +31,7 @@ QUANT_SPEED_MULT = {
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35, "Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85, "AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85, "GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0, "mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch "FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
"FP8-Mixed": 0.85, "FP8-Mixed": 0.85,
@@ -47,6 +49,10 @@ QUANT_QUALITY_PENALTY = {
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier. # penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0, "AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0, "GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
# Quantization-aware training recovers most of the int4 quality loss, so a
# QAT-INT4 build lands far closer to bf16 than a post-training Q4/INT4
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5, "mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16), # DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
# so the realized quality is much closer to FP8 than to pure FP4 — # so the realized quality is much closer to FP8 than to pure FP4 —
@@ -63,6 +69,7 @@ QUANT_BYTES_PER_PARAM = {
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25, "Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0, "AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0, "GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75, "mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
"FP4-MoE-Mixed": 0.55, "FP4-MoE-Mixed": 0.55,
"FP8-Mixed": 1.0, "FP8-Mixed": 1.0,
@@ -74,6 +81,7 @@ PREQUANTIZED_PREFIXES = (
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4", "AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
"INT4", "INT8", "W4A16", "W8A8", "W8A16", "INT4", "INT8", "W4A16", "W8A8", "W8A16",
"FP4-MoE-Mixed", "FP8-Mixed", "FP4-MoE-Mixed", "FP8-Mixed",
"QAT-",
) )
+9
View File
@@ -239,6 +239,15 @@ def check_arch():
def main(): def main():
print("\n=== Odysseus Setup ===\n") print("\n=== Odysseus Setup ===\n")
# Load .env so pre-seeded ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD (and
# other deployment vars) are honored on native installs, not just when they
# are exported in the shell. Mirrors app.py: encoding="utf-8-sig" tolerates a
# UTF-8 BOM in a Notepad-saved .env. load_dotenv does not override already
# exported OS env vars, so the existing precedence is preserved. python-dotenv
# is a hard dependency (requirements.txt) and is verified by check_deps below.
from dotenv import load_dotenv
load_dotenv(os.path.join(BASE_DIR, ".env"), encoding="utf-8-sig")
# Fail fast with a clear message if the CPU architecture is wrong (Apple # Fail fast with a clear message if the CPU architecture is wrong (Apple
# Silicon under an x86/Rosetta Python) before importing anything native. # Silicon under an x86/Rosetta Python) before importing anything native.
check_arch() check_arch()
+247 -20
View File
@@ -38,6 +38,167 @@ from src.agent_tools import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Redaction patterns for common secret-bearing shapes. Explicit and tested
# (see tests/test_loop_guard_signals.py) rather than one clever broad regex —
# safety first, but we try not to mangle harmless prose. Applied in order.
_REDACTED = "[redacted]"
# Cookie: ... / Set-Cookie: ... — redact the rest of the line (cookies hold spaces).
_SENSITIVE_COOKIE_RE = re.compile(
r"(?i)\b((?:set-)?cookie\s*[:=]\s*)[^\r\n]+"
)
# URL credentials, e.g. postgres://user:pass@host/db. The password half allows
# inner colons (postgres://user:pa:ss@host/db) but still stops at / and @.
_SENSITIVE_URL_CRED_RE = re.compile(
r"(?i)\b([a-z][a-z0-9+.\-]*://)[^\s:/@]+:[^\s/@]+@"
)
# Prefix-only discovery regexes. Each matches the key and its separator (the part
# we KEEP); the value that follows is found by a linear scanner rather than by a
# regex, so there is no backtracking-prone quantifier over uncontrolled input.
#
# Authorization: Bearer <tok> / Authorization: Basic "two word secret"
_AUTH_PREFIX_RE = re.compile(
r"(?i)authorization\s*[:=]\s*(?:bearer|basic)\s+"
)
# Provider-prefixed env names, e.g. OPENAI_API_KEY=..., AWS_SECRET_ACCESS_KEY=...,
# GITHUB_TOKEN=... — require a sensitive suffix preceded by `_` so benign names
# that merely end in KEY (MONKEY, TURKEY) are left alone.
_ENV_PREFIX_RE = re.compile(
r"(?:export\s+)?\b[A-Z][A-Z0-9_]*"
r"_(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIALS?)\s*=\s*"
)
# Generic sensitive key, e.g. password=..., api_key: ..., client_secret=...
_KEY_PREFIX_RE = re.compile(
r"(?i)\b(?:password|passwd|pwd|token|api[_-]?key|client_secret|secret)\b\s*[:=]\s*"
)
# Obvious provider-shaped bare tokens (no surrounding key needed).
_SENSITIVE_BARE_TOKEN_RE = re.compile(
r"\b("
r"sk-[A-Za-z0-9_\-]{16,}" # OpenAI / Anthropic style
r"|gh[pousr]_[A-Za-z0-9]{20,}" # GitHub PAT
r"|xox[baprs]-[A-Za-z0-9\-]{10,}" # Slack
r"|AKIA[0-9A-Z]{16}" # AWS access key id
r"|hf_[A-Za-z0-9]{16,}" # Hugging Face token
r"|AIza[0-9A-Za-z_\-]{20,}" # Google API key
r")\b"
)
def _consume_secret_value_end(text: str, start: int) -> int:
"""Return the exclusive end index of the secret value beginning at ``start``.
If the value is quoted, scan to the matching unescaped quote (backslash
escapes are skipped two chars at a time). Otherwise scan to the first
whitespace, comma, or semicolon. The scan is linear in the length of the
input, so it cannot exhibit catastrophic backtracking.
"""
n = len(text)
if start >= n:
return start
quote = text[start]
if quote in ("'", '"'):
i = start + 1
while i < n:
ch = text[i]
if ch == "\\":
i += 2
continue
if ch == quote:
return i + 1
i += 1
return n # unterminated quote: redact to the end
i = start
while i < n and not text[i].isspace() and text[i] not in (",", ";"):
i += 1
return i
def _redact_after_prefix(text: str, prefix_re: "re.Pattern") -> str:
"""Redact the value following each ``prefix_re`` match using a linear scan."""
result = []
pos = 0
n = len(text)
while pos < n:
match = prefix_re.search(text, pos)
if match is None:
result.append(text[pos:])
break
result.append(text[pos:match.end()])
value_end = _consume_secret_value_end(text, match.end())
if value_end > match.end():
result.append(_REDACTED)
pos = value_end
else:
# Empty value: nothing to redact; step past the prefix and continue.
pos = match.end()
if pos < n:
result.append(text[pos])
pos += 1
return "".join(result)
def _redact_private_keys(text: str) -> str:
"""Replace PEM private-key blocks with a placeholder via linear scanning.
Finds ``-----BEGIN `` markers, verifies the header names a PRIVATE KEY,
locates the matching ``-----END `` marker, and collapses the whole block.
No regex is used, so the (multi-line, uncontrolled) body cannot trigger
polynomial matching.
"""
begin_marker = "-----BEGIN "
end_marker = "-----END "
dash = "-----"
max_header = 64 # generous bound on "[TYPE ]PRIVATE KEY"
result = []
pos = 0
while True:
begin = text.find(begin_marker, pos)
if begin == -1:
result.append(text[pos:])
return "".join(result)
header_start = begin + len(begin_marker)
header_close = text.find(dash, header_start)
if (
header_close == -1
or header_close - header_start > max_header
or not text[header_start:header_close].endswith("PRIVATE KEY")
):
result.append(text[pos:header_start])
pos = header_start
continue
end = text.find(end_marker, header_close)
if end == -1:
result.append(text[pos:])
return "".join(result)
end_header_start = end + len(end_marker)
end_close = text.find(dash, end_header_start)
if (
end_close == -1
or end_close - end_header_start > max_header
or not text[end_header_start:end_close].endswith("PRIVATE KEY")
):
result.append(text[pos:header_start])
pos = header_start
continue
result.append(text[pos:begin])
result.append("[redacted private key]")
pos = end_close + len(dash)
def _redact_sensitive_text(value: object) -> str:
"""Redact obvious credential values before surfacing tool output."""
if value is None:
return ""
text = str(value)
text = _redact_private_keys(text)
text = _redact_after_prefix(text, _AUTH_PREFIX_RE)
text = _SENSITIVE_COOKIE_RE.sub(r"\1" + _REDACTED, text)
text = _SENSITIVE_URL_CRED_RE.sub(r"\1" + _REDACTED + "@", text)
text = _redact_after_prefix(text, _ENV_PREFIX_RE)
text = _redact_after_prefix(text, _KEY_PREFIX_RE)
return _SENSITIVE_BARE_TOKEN_RE.sub(_REDACTED, text)
def _load_mcp_disabled_map() -> Dict[str, set]: def _load_mcp_disabled_map() -> Dict[str, set]:
"""Load per-server disabled tool sets from the database.""" """Load per-server disabled tool sets from the database."""
@@ -2455,6 +2616,7 @@ async def stream_agent_loop(
# signatures + consecutive no-text tool rounds to bail early. # signatures + consecutive no-text tool rounds to bail early.
_recent_call_sigs = collections.deque(maxlen=6) _recent_call_sigs = collections.deque(maxlen=6)
_stuck_rounds = 0 _stuck_rounds = 0
_MAX_STUCK_ROUNDS = 4 # consecutive no-progress rounds before loop-breaker bails
# Frequency of each exact call signature (tool + args), for the runaway # Frequency of each exact call signature (tool + args), for the runaway
# backstop. Counting identical repeats — not distinct same-tool calls — # backstop. Counting identical repeats — not distinct same-tool calls —
# lets a legit batch (e.g. 18 calendar events at once) through. # lets a legit batch (e.g. 18 calendar events at once) through.
@@ -2932,17 +3094,22 @@ async def stream_agent_loop(
# promise: short response (<400 chars), no fenced code/answer, # promise: short response (<400 chars), no fenced code/answer,
# and an action-intent phrase was matched. Long answers that # and an action-intent phrase was matched. Long answers that
# happen to contain "let me know" are not stalls. # happen to contain "let me know" are not stalls.
_looks_like_promise = ( _promise_shape = (
not guide_only not guide_only
and _intent_match is not None and _intent_match is not None
and len(_intent_text) < 400 and len(_intent_text) < 400
and "```" not in _intent_text and "```" not in _intent_text
and _intent_nudge_count < _MAX_INTENT_NUDGES
) )
_looks_like_promise = _promise_shape and _intent_nudge_count < _MAX_INTENT_NUDGES
if _looks_like_promise: if _looks_like_promise:
_intent_nudge_count += 1 _intent_nudge_count += 1
_matched_phrase = _intent_match.group(0).strip() _matched_phrase = _intent_match.group(0).strip()
logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}") # Don't log the matched phrase — it's raw model text that may
# carry credentials. Structural metadata only.
logger.info(
"[agent] intent-without-action nudge #%d on round %d",
_intent_nudge_count, round_num,
)
_lower_phrase = _matched_phrase.lower() _lower_phrase = _matched_phrase.lower()
_cookbook_log_hint = "" _cookbook_log_hint = ""
if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")): if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")):
@@ -2968,6 +3135,24 @@ async def stream_agent_loop(
# Visible signal in the stream so the user knows we caught it. # Visible signal in the stream so the user knows we caught it.
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n' yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
continue continue
# The model keeps announcing actions it never takes and we've spent
# every nudge — surface why the turn is ending instead of letting it
# look like a clean completion.
if _promise_shape and _intent_nudge_count >= _MAX_INTENT_NUDGES:
_matched_phrase = _intent_match.group(0).strip()
_matched_phrase_safe = _redact_sensitive_text(_matched_phrase)
_in_message = (
f"Intent-nudge cap reached on round {round_num}: the model "
f"announced an action ({_matched_phrase_safe!r}) without a tool call "
f"after {_intent_nudge_count} nudge(s); ending the turn."
)
# Do not log the matched phrase, even redacted. It is raw model
# text and may contain credentials; keep logs structural only.
logger.warning(
"[agent] intent-nudge cap exhausted on round %d (%d/%d)",
round_num, _intent_nudge_count, _MAX_INTENT_NUDGES,
)
yield f'data: {json.dumps({"type": "intent_nudge_exhausted", "round": round_num, "nudges": _intent_nudge_count, "max_nudges": _MAX_INTENT_NUDGES, "message": _in_message})}\n\n'
break # no tools — done break # no tools — done
# ── Loop-breaker (Terminus-style stall detector) ────────────── # ── Loop-breaker (Terminus-style stall detector) ──────────────
@@ -3000,10 +3185,23 @@ async def stream_agent_loop(
# Distinct calls to one tool (a real batch) are legitimate work, so we # Distinct calls to one tool (a real batch) are legitimate work, so we
# count identical call signatures, not raw per-tool-type totals. # count identical call signatures, not raw per-tool-type totals.
_runaway = _detect_runaway_call(_call_freq) _runaway = _detect_runaway_call(_call_freq)
if _stuck_rounds >= 4 or _runaway: if _stuck_rounds >= _MAX_STUCK_ROUNDS or _runaway:
reason = (f"calling {_runaway} with identical arguments over and over" if _runaway reason = (f"calling {_runaway} with identical arguments over and over" if _runaway
else "repeating the same tool calls without new progress") else "repeating the same tool calls without new progress")
logger.warning(f"[agent] loop-breaker tripped on round {round_num} ({reason}); sig={_sig[:80]!r}") _lb_message = (
f"Loop-breaker stopped the agent on round {round_num}: {reason}. "
"Forced one tool-free round to converge on an answer or state what's blocked."
)
# Log structural metadata only — `_sig` is raw tool-call content
# that may carry credentials.
logger.warning(
"[agent] loop-breaker tripped on round %d (%s); "
"stuck_rounds=%d/%d runaway=%r",
round_num, reason, _stuck_rounds, _MAX_STUCK_ROUNDS, _runaway,
)
# Surface the stop cause to the stream so the user (and journalctl)
# can tell a guard fired, not a clean completion.
yield f'data: {json.dumps({"type": "loop_breaker_triggered", "round": round_num, "reason": reason, "stuck_rounds": _stuck_rounds, "max_stuck_rounds": _MAX_STUCK_ROUNDS, "runaway": _runaway, "message": _lb_message})}\n\n'
# The model has been executing tools, so its results are already # The model has been executing tools, so its results are already
# in context. Force ONE tool-free round to converge: write the # in context. Force ONE tool-free round to converge: write the
# answer from what it has, or state plainly what's blocking it. # answer from what it has, or state plainly what's blocking it.
@@ -3082,6 +3280,10 @@ async def stream_agent_loop(
cmd_display = block.content.split("\n")[0].strip()[:80] cmd_display = block.content.split("\n")[0].strip()[:80]
else: else:
cmd_display = block.content.strip() cmd_display = block.content.strip()
# The display string is streamed (tool_start/tool_output) and persisted;
# redact any secrets in it. block.content itself is left untouched so
# tool execution still sees the real command.
cmd_display = _redact_sensitive_text(cmd_display)
if tool_policy and tool_policy.blocks(block.tool_type): if tool_policy and tool_policy.blocks(block.tool_type):
desc = f"{block.tool_type}: BLOCKED" desc = f"{block.tool_type}: BLOCKED"
@@ -3127,8 +3329,15 @@ async def stream_agent_loop(
evt = await _progress_q.get() evt = await _progress_q.get()
if evt is None: if evt is None:
break break
# Redact secrets in the live tail before streaming — the
# final tool_output is redacted, so the progress tail must
# be too, or a secret could flash by mid-run. Copy so we
# don't mutate the tool's own event payload.
_evt = dict(evt)
if isinstance(_evt.get("tail"), str):
_evt["tail"] = _redact_sensitive_text(_evt["tail"])
yield ( yield (
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n' f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **_evt})}\n\n'
) )
desc, result = await _tool_task desc, result = await _tool_task
@@ -3194,7 +3403,7 @@ async def stream_agent_loop(
result["results"] = _clean result["results"] = _clean
elif "stdout" in result: elif "stdout" in result:
result["stdout"] = _clean result["stdout"] = _clean
except (json.JSONDecodeError, Exception): except Exception:
pass pass
# Emit doc-specific event for document tools — the frontend # Emit doc-specific event for document tools — the frontend
@@ -3215,9 +3424,12 @@ async def stream_agent_loop(
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n' f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
) )
# ask_user: the agent posed a multiple-choice question. Emit it so the # ask_user: remember the payload now, but emit the interactive event
# frontend renders clickable options, then end the turn (below) and # only *after* tool_output below. Emitting it before tool_output let
# wait — the user's pick becomes the next message. # the subsequent tool-card rewrite/scroll push the choices out of
# view. The payload is also copied into the persisted tool event so
# history reload can reconstruct an unanswered card.
_pending_ask_user_event = None
if "ask_user" in result: if "ask_user" in result:
# The question lives in the tool args. ChatMessage.to_dict() # The question lives in the tool args. ChatMessage.to_dict()
# replays only role+content to the model next turn — tool_event # replays only role+content to the model next turn — tool_event
@@ -3232,9 +3444,7 @@ async def stream_agent_loop(
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q _auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
full_response += _auq_delta full_response += _auq_delta
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n' yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
yield ( _pending_ask_user_event = _auq
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
)
_awaiting_user = True _awaiting_user = True
# update_plan: agent wrote back to the plan (ticked a step / revised). # update_plan: agent wrote back to the plan (ticked a step / revised).
@@ -3263,32 +3473,36 @@ async def stream_agent_loop(
# empty) stdout/stderr; fall back to the error so the "timed # empty) stdout/stderr; fall back to the error so the "timed
# out" reason reaches the UI instead of a blank result. # out" reason reaches the UI instead of a blank result.
raw = result["stdout"] or result["stderr"] or result.get("error", "") raw = result["stdout"] or result["stderr"] or result.get("error", "")
output_text = _truncate(raw) output_text = _truncate(_redact_sensitive_text(raw))
elif "output" in result: elif "output" in result:
# bash / python canonical result: {"output": ..., "exit_code": ...} # bash / python canonical result: {"output": ..., "exit_code": ...}
raw = result["output"] or "" raw = result["output"] or ""
output_text = _truncate(raw) output_text = _truncate(_redact_sensitive_text(raw))
elif "response" in result: elif "response" in result:
# AI interaction tools (chat_with_model, send_to_session) # AI interaction tools (chat_with_model, send_to_session)
label = result.get("model", result.get("session_name", "AI")) label = result.get("model", result.get("session_name", "AI"))
output_text = _truncate(f"{label}: {result['response']}") output_text = _truncate(_redact_sensitive_text(f"{label}: {result['response']}"))
elif "content" in result: elif "content" in result:
output_text = _truncate(result["content"]) output_text = _truncate(_redact_sensitive_text(result["content"]))
elif "results" in result: elif "results" in result:
output_text = _truncate(result["results"]) output_text = _truncate(_redact_sensitive_text(result["results"]))
elif "session_id" in result and "name" in result: elif "session_id" in result and "name" in result:
output_text = f"Session created: {result['name']} (id: {result['session_id']})" output_text = f"Session created: {result['name']} (id: {result['session_id']})"
elif "success" in result: elif "success" in result:
output_text = ( output_text = (
f"Written: {result.get('path', '')}" f"Written: {result.get('path', '')}"
if result["success"] if result["success"]
else f"Error: {result.get('error', '')}" else f"Error: {_redact_sensitive_text(result.get('error', ''))}"
) )
elif "error" in result: elif "error" in result:
output_text = _truncate(result["error"]) output_text = _truncate(_redact_sensitive_text(result["error"]))
# Emit tool_output (include ui_event data if present) # Emit tool_output (include ui_event data if present)
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")} tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
if _pending_ask_user_event:
# Keep enough state in the streamed tool result for alternate
# clients to render the prompt without depending on event order.
tool_output_data["ask_user"] = _pending_ask_user_event
if "ui_event" in result: if "ui_event" in result:
tool_output_data["ui_event"] = result["ui_event"] tool_output_data["ui_event"] = result["ui_event"]
for k in ( for k in (
@@ -3319,6 +3533,14 @@ async def stream_agent_loop(
tool_output_data["diff"] = result["diff"] tool_output_data["diff"] = result["diff"]
yield f'data: {json.dumps(tool_output_data)}\n\n' yield f'data: {json.dumps(tool_output_data)}\n\n'
# This must be the final UI event for ask_user: the frontend appends
# the card below the now-settled tool node and cancels any between-
# round spinner. The turn ends after the current tool batch.
if _pending_ask_user_event:
yield (
f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n'
)
# Native document tools open in the editor + carry the REAL doc id. # Native document tools open in the editor + carry the REAL doc id.
# Emit a doc_update so the frontend opens/activates it and sends it # Emit a doc_update so the frontend opens/activates it and sends it
# back as active_doc_id next turn (otherwise the agent can't "see" # back as active_doc_id next turn (otherwise the agent can't "see"
@@ -3376,6 +3598,11 @@ async def stream_agent_loop(
# this the diff shows live but vanishes from saved history. # this the diff shows live but vanishes from saved history.
if result.get("diff"): if result.get("diff"):
tool_event["diff"] = result["diff"] tool_event["diff"] = result["diff"]
if _pending_ask_user_event:
# Persist the structured question with the tool event. On a
# reload, chatRenderer can restore the card; a later user
# message removes it as answered.
tool_event["ask_user"] = _pending_ask_user_event
tool_events.append(tool_event) tool_events.append(tool_event)
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS: if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
_effectful_used = True _effectful_used = True
+7 -5
View File
@@ -25,6 +25,11 @@ from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocument
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
from .bg_job_tools import ManageBgJobsTool from .bg_job_tools import ManageBgJobsTool
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
from .admin_tools import (
ADMIN_TOOL_HANDLERS,
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
do_manage_tokens, do_manage_settings,
)
TOOL_HANDLERS = { TOOL_HANDLERS = {
"bash": BashTool().execute, "bash": BashTool().execute,
@@ -52,6 +57,8 @@ TOOL_HANDLERS = {
"send_to_session": SendToSessionTool().execute, "send_to_session": SendToSessionTool().execute,
"manage_session": ManageSessionTool().execute, "manage_session": ManageSessionTool().execute,
} }
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants (re-exported for backward compatibility — single source of truth # Constants (re-exported for backward compatibility — single source of truth
@@ -138,10 +145,5 @@ from src.tool_implementations import ( # noqa: E402, F401
do_search_chats, do_search_chats,
do_manage_skills, do_manage_skills,
do_manage_tasks, do_manage_tasks,
do_manage_endpoints,
do_manage_mcp,
do_manage_webhooks,
do_manage_tokens,
do_manage_settings,
do_api_call, do_api_call,
) )
+784
View File
@@ -0,0 +1,784 @@
"""Config/integration admin agent tools (TOOL_HANDLERS).
Moved verbatim from tool_implementations.py as part of the tool-registry
migration (#3629, the `admin_tools.py` bullet): manage_endpoints / manage_mcp /
manage_webhooks / manage_tokens / manage_settings, plus manage_mcp's
command-allowlist guard. Each impl keeps its `do_*(content, owner)` shape;
ADMIN_TOOL_HANDLERS wraps them into registry `execute(content, ctx)` adapters
via one factory.
"""
import json
import os
import re
import logging
from typing import Optional, Dict
from src.tool_utils import get_mcp_manager, _parse_tool_args
logger = logging.getLogger(__name__)
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
"""Manage model endpoints: list, add, delete, enable, disable."""
from core.database import SessionLocal, ModelEndpoint
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
db = SessionLocal()
try:
if action == "list":
eps = db.query(ModelEndpoint).all()
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
"is_enabled": e.is_enabled} for e in eps]
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
elif action == "add":
import uuid as _uuid
name = args.get("name", "")
base_url = args.get("base_url", "")
api_key = args.get("api_key", "")
if not base_url:
return {"error": "base_url is required", "exit_code": 1}
eid = str(_uuid.uuid4())[:8]
from datetime import datetime
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
api_key=api_key, is_enabled=True,
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(ep)
db.commit()
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
elif action == "delete":
eid = args.get("endpoint_id", "")
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
if not ep:
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
name = ep.name
db.delete(ep)
db.commit()
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
elif action in ("enable", "disable"):
eid = args.get("endpoint_id", "")
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
if not ep:
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
ep.is_enabled = (action == "enable")
db.commit()
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_endpoints error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# MCP server management tool
# ---------------------------------------------------------------------------
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
# opposite policy: that gate guards an admin-only serve command and allows
# interpreters (python3/etc) because model-serving needs them, whereas this is
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
# runners are denied here.
#
# Commands that can execute arbitrary code regardless of their arguments. These
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
# interpreter or package runner must be registered via the trusted admin route.
_MCP_DENIED_COMMANDS = frozenset({
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
"cmd", "command.com", "powershell", "pwsh",
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
})
# Argv flags that make even an allowlisted binary execute inline code. Matched
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
# exact-token form.
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
# Shell metacharacters refused in command/args. Args are passed as an argv list
# (no shell), but refusing these keeps the surface narrow and obvious.
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
# Env vars that let a child process load attacker-supplied code before main().
_MCP_DANGEROUS_ENV = frozenset({
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
})
def _mcp_allowed_commands() -> set:
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
to opt specific trusted binaries in. Denied commands are rejected even if
listed here."""
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
return {c.strip().lower() for c in raw.split(",") if c.strip()}
def _validate_mcp_command(command, args, env) -> Optional[str]:
"""Validate a model-supplied stdio MCP registration. Returns an error string
if it must be rejected, else None.
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
command/args/env straight to a subprocess spawn (issue #438): a payload
smuggled into a skill description, memory entry, fetched page, or email body
could register a stdio server running arbitrary code as the app UID.
"""
if not isinstance(command, str) or not command.strip():
return "command must be a non-empty string"
command = command.strip()
if "/" in command or "\\" in command:
return "command must be a bare executable name, not a path"
if any(ch in _MCP_SHELL_METACHARS for ch in command):
return "command contains shell metacharacters"
base = command.lower()
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
base = base.rsplit(".", 1)[0]
# Canonicalize a trailing version suffix so versioned aliases collapse to the
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
# raw basename and the canonical form are denied, so an operator cannot
# accidentally allowlist a runtime alias back into the path.
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
return (
f"command '{command}' is not allowed on the agent MCP path: "
"interpreters, runtimes, package runners, and shells can execute "
"arbitrary code. Register such a server via the admin route instead."
)
if base not in _mcp_allowed_commands():
return (
f"command '{command}' is not in the MCP allowlist. Add it to "
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
"server via the admin route."
)
if args is not None:
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
return "args must be a JSON list"
if not isinstance(args, list):
return "args must be a list"
for a in args:
if not isinstance(a, str):
return "args must all be strings"
s = a.strip()
low = s.lower()
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
return f"arg '{a}' is a code-execution flag and is not allowed"
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
return f"arg '{a}' is a code-execution flag and is not allowed"
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
return f"arg '{a}' is a remote URL and is not allowed"
if any(ch in _MCP_SHELL_METACHARS for ch in a):
return f"arg '{a}' contains shell metacharacters"
if env:
if isinstance(env, str):
try:
env = json.loads(env)
except Exception:
return "env must be a JSON object"
if not isinstance(env, dict):
return "env must be an object"
for k in env:
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
return f"env var '{k}' can inject code into the child process and is not allowed"
return None
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
if action == "list":
mcp = get_mcp_manager()
if not mcp:
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
from core.database import SessionLocal, McpServer
db = SessionLocal()
try:
servers = db.query(McpServer).all()
items = []
for s in servers:
st = mcp.get_server_status(s.id)
status = st.get("status", "disconnected")
tool_count = st.get("tool_count", 0)
items.append({"id": s.id, "name": s.name, "transport": s.transport,
"is_enabled": s.is_enabled, "status": status,
"tool_count": tool_count})
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
finally:
db.close()
elif action == "add":
from core.database import SessionLocal, McpServer
import uuid as _uuid
from datetime import datetime
name = args.get("name", "")
command = args.get("command", "")
cmd_args = args.get("args", [])
env = args.get("env", {})
if not name or not command:
return {"error": "name and command are required", "exit_code": 1}
# Validate BEFORE any DB write or spawn: a rejected registration must
# leave no enabled row (which would otherwise auto-reconnect on restart)
# and must not attempt a connection.
_mcp_err = _validate_mcp_command(command, cmd_args, env)
if _mcp_err:
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
sid = str(_uuid.uuid4())[:8]
db = SessionLocal()
try:
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
env=json.dumps(env) if isinstance(env, dict) else env,
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(srv)
db.commit()
finally:
db.close()
# Try to connect
mcp = get_mcp_manager()
tool_count = 0
if mcp:
try:
await mcp.connect_server(
sid, name, "stdio", command=command,
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
env=env if isinstance(env, dict) else json.loads(env),
)
st = mcp.get_server_status(sid)
tool_count = st.get("tool_count", 0)
except Exception as e:
logger.warning(f"MCP connect failed for {name}: {e}")
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
elif action == "delete":
sid = args.get("server_id", "")
from core.database import SessionLocal, McpServer
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == sid).first()
if not srv:
return {"error": f"Server {sid} not found", "exit_code": 1}
name = srv.name
mcp = get_mcp_manager()
if mcp:
try:
await mcp.disconnect_server(sid)
except Exception:
pass
db.delete(srv)
db.commit()
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
finally:
db.close()
elif action == "reconnect":
sid = args.get("server_id", "")
mcp = get_mcp_manager()
if not mcp:
return {"error": "MCP manager not available", "exit_code": 1}
try:
await mcp.disconnect_server(sid)
from core.database import SessionLocal, McpServer
db2 = SessionLocal()
try:
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
if srv:
_args = json.loads(srv.args) if srv.args else []
_env = json.loads(srv.env) if srv.env else {}
await mcp.connect_server(
server_id=sid,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=_args,
env=_env,
url=srv.url,
)
st = mcp.get_server_status(sid)
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
return {"error": f"Server {sid} not found", "exit_code": 1}
finally:
db2.close()
except Exception as e:
return {"error": str(e), "exit_code": 1}
elif action in ("enable", "disable"):
sid = args.get("server_id", "")
from core.database import SessionLocal, McpServer
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == sid).first()
if not srv:
return {"error": f"Server {sid} not found", "exit_code": 1}
srv.is_enabled = (action == "enable")
db.commit()
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
finally:
db.close()
elif action == "list_tools":
mcp = get_mcp_manager()
if not mcp:
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
tools = mcp.get_all_tools()
items = [{"name": t["name"], "server": t["server_name"],
"description": t.get("description", "")[:100]} for t in tools]
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
# ---------------------------------------------------------------------------
# Webhook management tool
# ---------------------------------------------------------------------------
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
"""Manage webhooks: list, add, delete, enable, disable, test."""
from core.database import SessionLocal
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
db = SessionLocal()
try:
from core.database import Webhook
if action == "list":
hooks = db.query(Webhook).all()
items = [{"id": h.id, "name": h.name, "url": h.url,
"events": h.events, "is_active": h.is_active} for h in hooks]
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
elif action == "add":
import uuid as _uuid
from datetime import datetime
from src.webhook_manager import validate_events, validate_webhook_url
name = args.get("name", "")
url = args.get("url", "")
events = args.get("events", "chat.completed")
if not url:
return {"error": "url is required", "exit_code": 1}
try:
url = validate_webhook_url(url)
events = validate_events(events)
except ValueError as e:
return {"error": str(e), "exit_code": 1}
wid = str(_uuid.uuid4())[:8]
hook = Webhook(id=wid, name=name or url, url=url,
events=events, is_active=True,
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(hook)
db.commit()
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
elif action == "delete":
wid = args.get("webhook_id", "")
hook = db.query(Webhook).filter(Webhook.id == wid).first()
if not hook:
return {"error": f"Webhook {wid} not found", "exit_code": 1}
name = hook.name
db.delete(hook)
db.commit()
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
elif action in ("enable", "disable"):
wid = args.get("webhook_id", "")
hook = db.query(Webhook).filter(Webhook.id == wid).first()
if not hook:
return {"error": f"Webhook {wid} not found", "exit_code": 1}
hook.is_active = (action == "enable")
db.commit()
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_webhooks error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# API token management tool
# ---------------------------------------------------------------------------
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
"""Manage API tokens: list, create, delete."""
from core.database import SessionLocal, ApiToken
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
db = SessionLocal()
try:
if action == "list":
tokens = db.query(ApiToken).all()
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
"is_active": t.is_active} for t in tokens]
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
elif action == "create":
import uuid as _uuid, secrets, bcrypt
from datetime import datetime
name = args.get("name", "API Token")
raw_token = secrets.token_urlsafe(32)
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
tid = str(_uuid.uuid4())[:8]
t = ApiToken(id=tid, name=name, token_hash=token_hash,
token_prefix=raw_token[:8], is_active=True,
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(t)
db.commit()
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
elif action == "delete":
tid = args.get("token_id", "")
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
if not t:
return {"error": f"Token {tid} not found", "exit_code": 1}
name = t.name
db.delete(t)
db.commit()
return {"response": f"Deleted token '{name}'", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_tokens error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# Settings/preferences management tool
# ---------------------------------------------------------------------------
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"""Manage user settings and preferences."""
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
from core.database import SessionLocal
db = SessionLocal()
try:
# set/get/list/delete operate on the REAL app settings (the same store
# the Settings panel writes), so changing a model / voice / search
# engine / reminder channel from chat actually takes effect.
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
# Secrets/credentials the agent must NOT write: kept read-only (masked)
# so API keys never flow through chat. User sets these in the panel.
_SECRET_KEYS = {
"brave_api_key", "google_pse_key", "google_pse_cx",
"tavily_api_key", "serper_api_key", "app_public_url",
}
def _is_secret(k):
# `token` must be a suffix, not a substring: otherwise the int
# setting `agent_input_token_budget` (which even has a "token budget"
# alias to set it from chat) is wrongly classified as a credential.
return (
k in _SECRET_KEYS
or k.endswith("token")
or any(t in k for t in ("api_key", "_key", "secret", "password"))
)
# Friendly aliases → real keys, so natural phrasing resolves.
_ALIASES_SET = {
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
"text to speech": "tts_enabled", "tts provider": "tts_provider",
"speech speed": "tts_speed", "voice speed": "tts_speed",
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
"search engine": "search_provider", "search provider": "search_provider",
"search results": "search_result_count", "result count": "search_result_count",
"default model": "default_model", "chat model": "default_model",
"default endpoint": "default_endpoint_id",
"task model": "task_model", "background model": "task_model",
"teacher model": "teacher_model", "teacher": "teacher_enabled",
"utility model": "utility_model", "research model": "research_model",
"research max tokens": "research_max_tokens",
"vision model": "vision_model", "vision": "vision_enabled",
"image model": "image_model", "image quality": "image_quality",
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
"ntfy topic": "reminder_ntfy_topic",
"webhook integration": "reminder_webhook_integration_id",
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
"hard max": "agent_input_token_hard_max",
"token budget cap": "agent_input_token_hard_max",
"input budget cap": "agent_input_token_hard_max",
}
def _resolve(k):
k2 = (k or "").strip().lower()
if k2 in DEFAULT_SETTINGS:
return k2
return _ALIASES_SET.get(k2, (k or "").strip())
_ENUMS = {
"image_quality": ["low", "medium", "high"],
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
}
def _coerce(value, default):
if isinstance(default, bool):
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
if isinstance(default, int):
return int(value)
return value
def _model_slug(value: str) -> str:
import re as _re
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
def _endpoint_model_from_cache(model_query: str):
"""Resolve friendly model text to an enabled endpoint + real model id.
The Settings UI stores both `<prefix>_endpoint_id` and
`<prefix>_model`; writing only the model leaves the runtime on the
old endpoint. Prefer cached model lists so this stays fast/offline.
"""
import json as _json
import re as _re
from core.database import ModelEndpoint
wanted = (model_query or "").strip()
wanted_slug = _model_slug(wanted)
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
wanted_tokens = [t for t in wanted_tokens if t]
if not wanted_slug:
return None
best = None
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
raw_models = []
try:
raw_models = _json.loads(ep.cached_models or "[]") or []
except Exception:
raw_models = []
# If cache is empty, still allow matching against endpoint name
# for callers using model@endpoint elsewhere later.
for mid in raw_models:
mid = str(mid)
mid_slug = _model_slug(mid)
if not mid_slug:
continue
exact = mid.lower() == wanted.lower()
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
if exact or compact_match or token_match:
score = 3 if exact else (2 if compact_match else 1)
if not best or score > best[0]:
best = (score, ep.id, mid)
if best:
return {"endpoint_id": best[1], "model": best[2]}
return None
def _mask(k, v):
return "••••• (set in panel)" if _is_secret(k) and v else v
if action == "list":
s = load_settings()
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
elif action == "get":
key = _resolve(args.get("key", ""))
if not key:
return {"error": "key is required", "exit_code": 1}
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
elif action == "set":
raw = args.get("key", "")
value = args.get("value")
if not raw:
return {"error": "key is required", "exit_code": 1}
key = _resolve(raw)
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
# have no safe scalar coercion; _coerce would pass a bare string
# straight through and clobber the structure. Refuse them here; they're
# edited in their dedicated panels. (reset/delete still restore the
# default structure, which is safe.)
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
return {"response": f"'{key}' is a structured setting. Edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
try:
value = _coerce(value, DEFAULT_SETTINGS[key])
except (ValueError, TypeError):
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
s = load_settings()
s[key] = value
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
resolved = _endpoint_model_from_cache(str(value))
if resolved:
prefix = key[:-6]
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
s[key] = resolved["model"]
value = resolved["model"]
save_settings(s)
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
return {"response": f"Set {key} = {value}.", "exit_code": 0}
elif action == "delete" or action == "reset":
key = _resolve(args.get("key", ""))
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
s = load_settings()
s[key] = DEFAULT_SETTINGS[key]
save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action in ("disable_tool", "enable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> "builtin_browser",
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
from src.settings import get_setting, save_settings, load_settings
_ALIASES = {
"shell": ["bash"],
"terminal": ["bash"],
"search": ["web_search", "web_fetch"],
"web": ["web_search", "web_fetch"],
"browser": ["builtin_browser"],
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
"memory": ["manage_memory"],
"skills": ["manage_skills"],
"images": ["generate_image"],
"image": ["generate_image"],
"tasks": ["manage_tasks"],
"notes": ["manage_notes"],
"calendar": ["manage_calendar"],
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog)
}
if action == "list_tools":
current = get_setting("disabled_tools", []) or []
return {
"response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email."
),
"disabled": list(current),
"exit_code": 0,
}
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
if not tool_name:
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
targets = _ALIASES.get(tool_name, [tool_name])
settings = load_settings()
current = list(settings.get("disabled_tools") or [])
before = set(current)
if action == "disable_tool":
for t in targets:
if t not in current:
current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
after = set(current)
settings["disabled_tools"] = current
save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before))
return {
"response": (
f"{verb} {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}."
),
"changed": changed,
"disabled": list(current),
"exit_code": 0,
}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_settings error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# API call tool
# ---------------------------------------------------------------------------
# ── registry adapters ────────────────────────────────────────────────────────
def _owner_adapter(fn):
"""Wrap a do_*(content, owner) impl as a registry execute(content, ctx)."""
async def _execute(content: str, ctx: dict) -> dict:
return await fn(content, ctx.get("owner"))
return _execute
ADMIN_TOOL_HANDLERS = {
"manage_endpoints": _owner_adapter(do_manage_endpoints),
"manage_mcp": _owner_adapter(do_manage_mcp),
"manage_webhooks": _owner_adapter(do_manage_webhooks),
"manage_tokens": _owner_adapter(do_manage_tokens),
"manage_settings": _owner_adapter(do_manage_settings),
}
+1 -33
View File
@@ -1,8 +1,8 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import logging import logging
import re import re
import json
from src.constants import MAX_READ_CHARS from src.constants import MAX_READ_CHARS
from src.tool_utils import _parse_tool_args
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -154,38 +154,6 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
body = new body = new
return header.rstrip() + "\n---\n" + body return header.rstrip() + "\n---\n" + body
def _parse_tool_args(content):
"""Parse a tool-call argument blob.
Accepts either a JSON string or an already-decoded dict. Unwraps the
common `{"body": {...}}` envelope that smaller models emit when they
read tool descriptions like "Body is JSON: {...}" literally they
pass `body` as a field name rather than treating it as a noun.
Returns a dict on success, raises ValueError on bad JSON.
"""
if isinstance(content, str):
try:
args = json.loads(content) if content.strip() else {}
except (json.JSONDecodeError, TypeError) as e:
raise ValueError(str(e))
elif isinstance(content, dict):
args = content
else:
args = {}
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
# and points at a dict. We don't want to clobber a legitimate `body`
# field on tools where it's a real arg (e.g. send_email body text).
if (
isinstance(args, dict)
and len(args) == 1
and "body" in args
and isinstance(args["body"], dict)
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
):
args = args["body"]
return args
def parse_edit_blocks(content: str) -> list: def parse_edit_blocks(content: str) -> list:
"""Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks.""" """Parse <<<FIND>>>...<<<REPLACE>>>...<<<END>>> blocks."""
edits = [] edits = []
+16 -1
View File
@@ -81,11 +81,26 @@ class APIKeyManager:
keys stay encrypted. Loading via load() first would decrypt them and keys stay encrypted. Loading via load() first would decrypt them and
write them back as plaintext, which then fails to decrypt on the next write them back as plaintext, which then fails to decrypt on the next
load() and silently drops those providers. load() and silently drops those providers.
Uses atomic write (temp file + os.replace) so a crash, disk-full, or
mid-write error never truncates the existing keys file.
""" """
keys = self._load_raw() keys = self._load_raw()
keys[provider] = self.encrypt_api_key(api_key) keys[provider] = self.encrypt_api_key(api_key)
with open(self.api_keys_file, 'w', encoding="utf-8") as f: tmp_file = self.api_keys_file + ".tmp"
try:
with open(tmp_file, 'w', encoding="utf-8") as f:
json.dump(keys, f) json.dump(keys, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, self.api_keys_file)
except OSError:
# Clean up temp file on failure; re-raise so callers see the error
try:
os.remove(tmp_file)
except OSError:
pass
raise
def load(self) -> Dict[str, str]: def load(self) -> Dict[str, str]:
"""Load and decrypt API keys""" """Load and decrypt API keys"""
+30 -1
View File
@@ -1,6 +1,13 @@
# src/app_helpers.py # src/app_helpers.py
import os
import base64 import base64
import logging
import os
from fastapi import HTTPException
from fastapi.responses import HTMLResponse
from starlette.requests import Request
logger = logging.getLogger(__name__)
def read_if_exists(path: str) -> str: def read_if_exists(path: str) -> str:
"""Read file if it exists, return empty string otherwise.""" """Read file if it exists, return empty string otherwise."""
@@ -20,6 +27,28 @@ def abs_join(base_dir: str, rel: str) -> str:
"""Join paths and return absolute path.""" """Join paths and return absolute path."""
return os.path.abspath(os.path.join(base_dir, rel)) return os.path.abspath(os.path.join(base_dir, rel))
def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
"""Read an app-bundled HTML page and inject the CSP nonce into inline <script> tags.
Callers pass fixed, server-owned template paths (index/login/backgrounds),
never a client-supplied path. So any read failure here a missing file
(broken deployment) or a permission/IO error is a server fault, not a
client "not found": map all of them to a logged 500 so a missing core
template surfaces in 5xx alerting instead of hiding behind a 404. If a
future caller serves a client-influenced path where 404 is correct, branch
that at the call site rather than defaulting this shared helper to 404.
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
html = f.read()
except OSError:
logger.exception("Failed to read page %s", file_path)
raise HTTPException(500, "Internal server error")
nonce = getattr(request.state, "csp_nonce", "")
html = html.replace("{{CSP_NONCE}}", nonce)
return HTMLResponse(html)
def inside_base_dir(base_dir: str, path: str) -> bool: def inside_base_dir(base_dir: str, path: str) -> bool:
"""Check if path is inside base directory.""" """Check if path is inside base directory."""
if not isinstance(base_dir, str) or not isinstance(path, str): if not isinstance(base_dir, str) or not isinstance(path, str):
+19 -26
View File
@@ -1,29 +1,22 @@
# src/exceptions.py # src/exceptions.py
"""Custom exceptions for the application.""" """Backward-compatible shim — the single source of truth is core/exceptions.py.
class SessionNotFoundError(Exception): Historically this module was a byte-for-byte duplicate of core/exceptions.py,
"""Raised when a requested session is not found.""" which is the canonical definition (imported by app.py, core/__init__.py, and
def __init__(self, session_id: str): routes/chat_routes.py). To kill the drift, this now simply re-exports the
self.session_id = session_id exception classes from core.exceptions so there is exactly one place that
super().__init__(f"Session '{session_id}' not found") defines them. Existing `from src.exceptions import ...` callers keep working.
"""
from core.exceptions import ( # noqa: F401
SessionNotFoundError,
InvalidFileUploadError,
LLMServiceError,
WebSearchError,
)
class InvalidFileUploadError(Exception): __all__ = [
"""Raised when a file upload fails validation.""" "SessionNotFoundError",
def __init__(self, message: str, filename: str = None): "InvalidFileUploadError",
self.filename = filename "LLMServiceError",
self.message = message "WebSearchError",
super().__init__(message) ]
class LLMServiceError(Exception):
"""Raised when there is an error communicating with the LLM service."""
def __init__(self, message: str, endpoint: str = None):
self.endpoint = endpoint
self.message = message
super().__init__(message)
class WebSearchError(Exception):
"""Raised when there is an error with web search functionality."""
def __init__(self, message: str, query: str = None):
self.query = query
self.message = message
super().__init__(message)
+150 -16
View File
@@ -345,24 +345,41 @@ def _normalize_ollama_url(url: str) -> str:
return base.rstrip("/") + "/chat" return base.rstrip("/") + "/chat"
def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]: def _ollama_normalize_messages(messages: List[Dict]) -> List[Dict]:
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat. """Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
Odysseus carries assistant tool calls in the OpenAI shape, where Two shape mismatches silently break requests:
`function.arguments` is a JSON *string*. Native Ollama expects it to be a
JSON *object*; given the string it fails the whole request with HTTP 400 1. Tool calls: Odysseus carries `function.arguments` as a JSON *string*.
"Value looks like object, but can't find closing '}' symbol", which aborts Native Ollama expects a JSON *object* and rejects the string form with
every follow-up (tool-result) round. Parse the arguments back into an object HTTP 400 ("Value looks like object, but can't find closing '}' symbol"),
here, on a shallow copy, leaving non-tool messages untouched. The opaque aborting every follow-up (tool-result) round. Parse the arguments back
Gemini `extra_content` (thought_signature) is dropped it is meaningless to into an object here, on a shallow copy, leaving non-tool messages
Ollama and only matters when the conversation is replayed to Gemini. untouched. The opaque Gemini `extra_content` (thought_signature) is
dropped it is meaningless to Ollama and only matters when the
conversation is replayed to Gemini.
2. Images (issue #4723): Odysseus carries multimodal user content as an
OpenAI-style list ``[{type: "text", ...}, {type: "image_url",
image_url: {url: "data:image/...;base64,XXX"}}, ...]``. Native Ollama
does not accept a list for ``content`` it wants ``content`` as a
string plus a separate ``images`` array of raw base64 strings (no
``data:`` prefix). Without this conversion the image blocks pass
through untouched, the vision-capable model never sees the picture,
and the user gets "I can't see any image" even though the request
succeeded.
""" """
out: List[Dict] = [] out: List[Dict] = []
for m in messages or []: for m in messages or []:
tcs = m.get("tool_calls") if isinstance(m, dict) else None if not isinstance(m, dict):
if not tcs:
out.append(m) out.append(m)
continue continue
nm = dict(m)
# 1. Tool-call argument strings -> objects.
tcs = nm.get("tool_calls")
if tcs:
new_calls = [] new_calls = []
for tc in tcs: for tc in tcs:
fn = tc.get("function") or {} fn = tc.get("function") or {}
@@ -376,12 +393,54 @@ def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]:
if tc.get("id"): if tc.get("id"):
call["id"] = tc["id"] call["id"] = tc["id"]
new_calls.append(call) new_calls.append(call)
nm = dict(m)
nm["tool_calls"] = new_calls nm["tool_calls"] = new_calls
# 2. Multimodal content list -> native content string + images array.
content = nm.get("content")
if isinstance(content, list):
text_parts: List[str] = []
images: List[str] = list(nm.get("images") or [])
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "text":
t = block.get("text")
if t:
text_parts.append(str(t))
elif btype == "image_url":
url = (block.get("image_url") or {}).get("url", "")
if not url:
continue
if url.startswith("data:"):
# Strip the ``data:[...];base64,`` prefix — native
# Ollama wants only the base64 bytes.
_, _, b64 = url.partition(",")
if b64:
images.append(b64)
else:
# Native Ollama images[] is base64-only; it does
# not fetch HTTP URLs. Skip unsupported schemes
# rather than sending a non-base64 string that the
# model silently ignores.
logger.warning(
"Skipping non-data image_url (Ollama images[] "
"requires base64): %s",
url[:80],
)
nm["content"] = "\n".join(text_parts).strip()
if images:
nm["images"] = images
out.append(nm) out.append(nm)
return out return out
# Backward-compatible alias for callers/tests that imported the older name
# (it only handled tool messages originally — issue #4723 broadened scope).
_ollama_normalize_tool_messages = _ollama_normalize_messages
def _build_ollama_payload( def _build_ollama_payload(
model: str, model: str,
messages: List[Dict], messages: List[Dict],
@@ -404,7 +463,7 @@ def _build_ollama_payload(
""" """
payload: Dict = { payload: Dict = {
"model": model, "model": model,
"messages": _ollama_normalize_tool_messages(messages), "messages": _ollama_normalize_messages(messages),
"stream": stream, "stream": stream,
} }
options: Dict = {} options: Dict = {}
@@ -618,6 +677,8 @@ def _detect_provider(url: str) -> str:
from src.copilot import is_copilot_base from src.copilot import is_copilot_base
if is_copilot_base(url): if is_copilot_base(url):
return "copilot" return "copilot"
if _host_match(url, "mistral.ai"):
return "mistral"
return "openai" return "openai"
@@ -716,10 +777,17 @@ def _provider_label(url: str) -> str:
pass pass
if _is_ollama_native_url(url): return "Ollama" if _is_ollama_native_url(url): return "Ollama"
try: try:
host = (urlparse(url).hostname or "").lower() _parsed_local = urlparse(url)
host = (_parsed_local.hostname or "").lower()
port = _parsed_local.port
except Exception: except Exception:
return "provider" return "provider"
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}: if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}:
# A port alone is not authoritative: vLLM, SGLang, llama.cpp and plain
# OpenAI-compatible servers all routinely share 8000/8080, so naming the
# serving tool from the port here would mislabel real setups. The tool is
# identified by probing llama-server's native /props endpoint during
# discovery (see ModelDiscovery._fingerprint_provider); this stays neutral.
return "local endpoint" return "local endpoint"
return host or "provider" return host or "provider"
@@ -906,10 +974,17 @@ def _anthropic_rejects_temperature(model: str) -> bool:
return False return False
return (int(match.group(1)), int(match.group(2))) >= (4, 7) return (int(match.group(1)), int(match.group(2))) >= (4, 7)
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
# API accepts "high", "medium", "low", "none" — see
# https://docs.mistral.ai/capabilities/reasoning/. Override via env var
# ODYSSEUS_MISTRAL_REASONING_EFFORT (e.g. set to "medium" for cheaper chat).
_MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high")
# Models that support structured thinking — may output </think> without opening tag # Models that support structured thinking — may output </think> without opening tag
_THINKING_MODEL_PATTERNS = ( _THINKING_MODEL_PATTERNS = (
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax", "qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
"m2-reap", "gemma", "stepfun", "step-3", "step3", "m2-reap", "gemma", "stepfun", "step-3", "step3",
"magistral", "mistral-small", "mistral-medium",
) )
def _supports_thinking(model: str) -> bool: def _supports_thinking(model: str) -> bool:
@@ -919,6 +994,38 @@ def _supports_thinking(model: str) -> bool:
m = model.lower() m = model.lower()
return any(p in m for p in _THINKING_MODEL_PATTERNS) return any(p in m for p in _THINKING_MODEL_PATTERNS)
def _normalize_mistral_content(content):
"""Mistral returns content as a structured array when reasoning is on:
[{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
{"type": "text", "text": "...final answer..."}]
Convert to (text, thinking) tuple of plain strings. Pass through strings
unchanged so non-Mistral OpenAI-compat endpoints are unaffected.
"""
if isinstance(content, str):
return content, ""
if not isinstance(content, list):
return "", ""
text_parts = []
thinking_parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "text":
t = block.get("text", "")
if t:
text_parts.append(t)
elif btype == "thinking":
inner = block.get("thinking", [])
if isinstance(inner, list):
for tb in inner:
if isinstance(tb, dict) and tb.get("text"):
thinking_parts.append(tb["text"])
elif isinstance(inner, str):
thinking_parts.append(inner)
return "".join(text_parts), "".join(thinking_parts)
def _convert_openai_content_to_anthropic(content): def _convert_openai_content_to_anthropic(content):
"""Convert OpenAI multimodal content blocks to Anthropic format. """Convert OpenAI multimodal content blocks to Anthropic format.
@@ -1441,6 +1548,8 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
if max_tokens and max_tokens > 0: if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = max_tokens payload[tok_key] = max_tokens
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
try: try:
note_model_activity(target_url, model) note_model_activity(target_url, model)
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout) r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
@@ -1456,7 +1565,16 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
response = _parse_ollama_response(data) response = _parse_ollama_response(data)
else: else:
msg = data["choices"][0]["message"] msg = data["choices"][0]["message"]
response = msg.get("content") or msg.get("reasoning_content") or "" content = msg.get("content")
if isinstance(content, list):
# Mistral structured content — extract thinking + text
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
response = thinking_part + "\n\n" + (text_part or "")
else:
response = text_part or msg.get("reasoning_content") or ""
else:
response = content or msg.get("reasoning_content") or ""
_set_cached_response(cache_key, response) _set_cached_response(cache_key, response)
return response return response
except Exception: except Exception:
@@ -1638,6 +1756,8 @@ async def llm_call_async(
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm. # Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
if _is_ollama_openai_compat_url(url) and _supports_thinking(model): if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
payload["think"] = False payload["think"] = False
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
_apply_local_cache_affinity(payload, url, session_id) _apply_local_cache_affinity(payload, url, session_id)
if _is_host_dead(target_url): if _is_host_dead(target_url):
@@ -1756,6 +1876,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
payload[tok_key] = max_tokens payload[tok_key] = max_tokens
if tools: if tools:
payload["tools"] = tools payload["tools"] = tools
# Mistral thinking-capable models — send reasoning_effort so Mistral
# activates thinking mode and returns structured reasoning_content.
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
# (high / medium / low / none); default "high".
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3, # For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside # gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param. # <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
@@ -2134,9 +2260,17 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
# Text content # Text content
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`. # Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`.
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or "" reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or ""
content = delta.get("content") or ""
# Mistral structured content: content is a list of typed blocks
# ({"type": "thinking", ...}, {"type": "text", ...}). Split into
# reasoning + text so thinking streams into the thinking panel.
if isinstance(content, list):
text_part, thinking_part = _normalize_mistral_content(content)
if thinking_part:
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
content = text_part
if reasoning: if reasoning:
yield _stream_delta_event(reasoning, thinking=True) yield _stream_delta_event(reasoning, thinking=True)
content = delta.get("content") or ""
if content: if content:
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE) content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE) content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
+20 -4
View File
@@ -163,6 +163,21 @@ class ModelDiscovery:
return "lmstudio" return "lmstudio"
except Exception: except Exception:
pass pass
# llama.cpp's llama-server exposes a native /props endpoint (no /v1 prefix)
# describing the loaded model, slots, and chat template — distinct from
# LM Studio (/api/v1/models) and vLLM (/version, /metrics).
try:
r = httpx.get(f"http://{host}:{port}/props", timeout=1.5)
if r.is_success:
props = r.json() or {}
if isinstance(props, dict) and (
"default_generation_settings" in props
or "total_slots" in props
or "chat_template" in props
):
return "llamacpp"
except Exception:
pass
return None return None
def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]: def _check_port(self, host: str, port: int) -> Optional[Dict[str, Any]]:
@@ -194,10 +209,11 @@ class ModelDiscovery:
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}") logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
# Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook), # Well-known ports: 8000-8020 (vLLM, SGLang, Cookbook), 8080 (llama.cpp /
# 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is # llama-server default), 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL
# occupied by Ollama. The env vars can add more ports which will be merged in. # as its default port is occupied by Ollama. The env vars can add more
ports = list(range(8000, 8021)) + [1234, 11434, 11435] # ports which will be merged in.
ports = list(range(8000, 8021)) + [8080, 1234, 11434, 11435]
ports += [p for p in sorted(self._extra_ports) if p not in ports] ports += [p for p in sorted(self._extra_ports) if p not in ports]
targets = [(h, p) for h in hosts for p in ports] targets = [(h, p) for h in hosts for p in ports]
+4
View File
@@ -141,6 +141,10 @@ DEFAULT_SETTINGS = {
# before producing output (endpoint offline / errors), the chat # before producing output (endpoint offline / errors), the chat
# dispatch retries the next entry in order. # dispatch retries the next entry in order.
"default_model_fallbacks": [], "default_model_fallbacks": [],
# When True, non-admin users inherit global default model/endpoint/fallbacks
# when they have no personal defaults. When False, users only use their
# personal defaults (no global fallback). Default is False.
"share_defaults_with_users": False,
"utility_endpoint_id": "", "utility_endpoint_id": "",
"utility_model": "", "utility_model": "",
# Ordered fallback chain for the Utility model (summarization, naming, # Ordered fallback chain for the Utility model (summarization, naming,
+42 -2
View File
@@ -289,6 +289,42 @@ def _checkin_calendar_events(db, owner, start, end):
) )
def _normalize_chat_endpoint(url: str) -> str:
"""Repair a resolved task endpoint to a full chat-completions URL.
Unlike the chat path which stores ``build_chat_url(normalize_base(base))``
on the session the task executor passes ``task.endpoint_url`` verbatim to
the model HTTP call. A bare OpenAI-compatible base such as
``http://host:11434/v1`` therefore POSTs to a 404 ("page not found") and the
model silently appears to "return an empty response".
Repair only bare OpenAI-compatible bases. Native-Ollama URLs (``/api...``)
and URLs that already point at a concrete endpoint are returned untouched, so
their own downstream normalizers keep working. Idempotent: a URL already
ending in ``/chat/completions`` is left as-is.
"""
if not url:
return url
# Imports kept function-local (endpoint_resolver pulls in heavy deps) but
# OUTSIDE the try: an import failure is a real bug that should surface, not
# be silently swallowed into the un-normalized URL this function exists to
# repair.
from urllib.parse import urlparse
from src.endpoint_resolver import normalize_base, build_chat_url
path = (urlparse(url).path or "").rstrip("/")
if path == "/api" or path.startswith("/api/"):
return url # native Ollama — handled by the native path downstream
if path.endswith(("/chat/completions", "/messages", "/responses", "/completions")):
return url # already a concrete endpoint
try:
return build_chat_url(normalize_base(url))
except Exception:
# Guard only the actual normalization. Returning the URL un-normalized
# reverts to the 404 this fixes, so make the silent revert visible.
logger.debug("task endpoint normalization failed for %r; using as-is", url, exc_info=True)
return url
class TaskScheduler: class TaskScheduler:
def __init__(self, session_manager): def __init__(self, session_manager):
self._session_manager = session_manager self._session_manager = session_manager
@@ -1357,6 +1393,7 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner) endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model: if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured") raise RuntimeError("No model/endpoint configured")
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model so _execute_task_locked can persist it on # Record the resolved model so _execute_task_locked can persist it on
# the run (tasks rarely pin a model, so this is the only record of # the run (tasks rarely pin a model, so this is the only record of
# which model actually produced the output). # which model actually produced the output).
@@ -1548,6 +1585,8 @@ class TaskScheduler:
except Exception: except Exception:
pass pass
endpoint_url = _normalize_chat_endpoint(endpoint_url)
session_id = task.session_id session_id = task.session_id
if not session_id: if not session_id:
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
@@ -1667,7 +1706,7 @@ class TaskScheduler:
msg["X-Odysseus-Ref"] = str(task.id) msg["X-Odysseus-Ref"] = str(task.id)
msg.set_content(result or "") msg.set_content(result or "")
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30) _send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
logger.info("Task %s emailed result to %s (%sb)", task.id, to_addr, len(result or "")) logger.info("Task %s emailed result (recipient_set=%s, %sb)", task.id, bool(to_addr), len(result or ""))
except Exception as e: except Exception as e:
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True) logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
raise raise
@@ -1821,6 +1860,7 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner) endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model: if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured for research") raise RuntimeError("No model/endpoint configured for research")
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model for the run record (see _execute_task_locked). # Record the resolved model for the run record (see _execute_task_locked).
self._last_run_model = model self._last_run_model = model
@@ -2029,7 +2069,7 @@ class TaskScheduler:
# silent SMTP failure is easier to spot in the logs. # silent SMTP failure is easier to spot in the logs.
logger.info( logger.info(
f"Task {task.id} delivered via MCP tool {tool_name} " f"Task {task.id} delivered via MCP tool {tool_name} "
f"(to={recipient or '<unset>'}, body={body_len}b, reply={stdout[:200]!r})" f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
) )
except Exception as e: except Exception as e:
logger.error(f"Task {task.id} MCP delivery failed: {e}") logger.error(f"Task {task.id} MCP delivery failed: {e}")
+6 -18
View File
@@ -563,9 +563,7 @@ async def _execute_tool_block_impl(
""" """
from src.tool_implementations import ( from src.tool_implementations import (
do_search_chats, do_manage_tasks, do_search_chats, do_manage_tasks,
do_manage_skills, do_api_call, do_manage_endpoints, do_manage_skills, do_api_call, do_manage_notes,
do_manage_mcp, do_manage_webhooks, do_manage_tokens,
do_manage_settings, do_manage_notes,
do_manage_calendar, do_manage_calendar,
do_download_model, do_serve_model, do_list_served_models, do_stop_served_model, do_download_model, do_serve_model, do_list_served_models, do_stop_served_model,
do_tail_serve_output, do_tail_serve_output,
@@ -808,21 +806,11 @@ async def _execute_tool_block_impl(
first_line = content.split("\n")[0].strip()[:60] first_line = content.split("\n")[0].strip()[:60]
desc = f"api_call: {first_line}" desc = f"api_call: {first_line}"
result = await do_api_call(content) result = await do_api_call(content)
elif tool == "manage_endpoints": elif tool in ("manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "manage_settings"):
desc = "manage_endpoints" # Registry-dispatched (agent_tools.admin_tools); owner threaded for ownership/admin checks.
result = await do_manage_endpoints(content, owner=owner) desc = tool
elif tool == "manage_mcp": result = await _direct_fallback(tool, content, owner=owner) \
desc = "manage_mcp" or {"error": f"{tool}: execution failed", "exit_code": 1}
result = await do_manage_mcp(content, owner=owner)
elif tool == "manage_webhooks":
desc = "manage_webhooks"
result = await do_manage_webhooks(content, owner=owner)
elif tool == "manage_tokens":
desc = "manage_tokens"
result = await do_manage_tokens(content, owner=owner)
elif tool == "manage_settings":
desc = "manage_settings"
result = await do_manage_settings(content, owner=owner)
elif tool == "manage_notes": elif tool == "manage_notes":
desc = "manage_notes" desc = "manage_notes"
result = await do_manage_notes(content, owner=owner) result = await do_manage_notes(content, owner=owner)
+2 -785
View File
@@ -14,7 +14,7 @@ from typing import Any, Dict, List, Optional
from fastapi import HTTPException from fastapi import HTTPException
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
from src.tool_utils import get_mcp_manager from src.tool_utils import get_mcp_manager, _parse_tool_args
from core.constants import internal_api_base from core.constants import internal_api_base
from routes._validators import validate_remote_host, validate_ssh_port from routes._validators import validate_remote_host, validate_ssh_port
@@ -68,38 +68,6 @@ def clear_active_email() -> None:
# Argument parsing # Argument parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_tool_args(content):
"""Parse a tool-call argument blob.
Accepts either a JSON string or an already-decoded dict. Unwraps the
common `{"body": {...}}` envelope that smaller models emit when they
read tool descriptions like "Body is JSON: {...}" literally they
pass `body` as a field name rather than treating it as a noun.
Returns a dict on success, raises ValueError on bad JSON.
"""
if isinstance(content, str):
try:
args = json.loads(content) if content.strip() else {}
except (json.JSONDecodeError, TypeError) as e:
raise ValueError(str(e))
elif isinstance(content, dict):
args = content
else:
args = {}
# Unwrap {"body": {...}} envelope — but only if `body` is the sole key
# and points at a dict. We don't want to clobber a legitimate `body`
# field on tools where it's a real arg (e.g. send_email body text).
if (
isinstance(args, dict)
and len(args) == 1
and "body" in args
and isinstance(args["body"], dict)
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
):
args = args["body"]
return args
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Search chats # Search chats
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -588,757 +556,6 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
db.close() db.close()
# ---------------------------------------------------------------------------
# Endpoint management tool
# ---------------------------------------------------------------------------
async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict:
"""Manage model endpoints: list, add, delete, enable, disable."""
from core.database import SessionLocal, ModelEndpoint
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
db = SessionLocal()
try:
if action == "list":
eps = db.query(ModelEndpoint).all()
items = [{"id": e.id, "name": e.name, "base_url": e.base_url,
"is_enabled": e.is_enabled} for e in eps]
return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0}
elif action == "add":
import uuid as _uuid
name = args.get("name", "")
base_url = args.get("base_url", "")
api_key = args.get("api_key", "")
if not base_url:
return {"error": "base_url is required", "exit_code": 1}
eid = str(_uuid.uuid4())[:8]
from datetime import datetime
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
api_key=api_key, is_enabled=True,
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(ep)
db.commit()
return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0}
elif action == "delete":
eid = args.get("endpoint_id", "")
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
if not ep:
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
name = ep.name
db.delete(ep)
db.commit()
return {"response": f"Deleted endpoint '{name}'", "exit_code": 0}
elif action in ("enable", "disable"):
eid = args.get("endpoint_id", "")
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first()
if not ep:
return {"error": f"Endpoint {eid} not found", "exit_code": 1}
ep.is_enabled = (action == "enable")
db.commit()
return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_endpoints error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# MCP server management tool
# ---------------------------------------------------------------------------
# Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the
# opposite policy: that gate guards an admin-only serve command and allows
# interpreters (python3/etc) because model-serving needs them, whereas this is
# the model/prompt-injection-reachable manage_mcp path, so interpreters and
# runners are denied here.
#
# Commands that can execute arbitrary code regardless of their arguments. These
# are NEVER accepted on the manage_mcp agent path, even if an operator lists one
# in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an
# interpreter or package runner must be registered via the trusted admin route.
_MCP_DENIED_COMMANDS = frozenset({
"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox",
"cmd", "command.com", "powershell", "pwsh",
"python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby",
"perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript",
"groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang",
"kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node",
"npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv",
"gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew",
"apt", "apt-get", "yum", "dnf", "pacman", "apk",
"env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout",
"watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo",
"doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find",
"awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval",
})
# Argv flags that make even an allowlisted binary execute inline code. Matched
# by prefix so glued forms (-cimport os, --eval=...) are caught, not just the
# exact-token form.
_MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m")
_MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require")
_MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:")
# Shell metacharacters refused in command/args. Args are passed as an argv list
# (no shell), but refusing these keeps the surface narrow and obvious.
_MCP_SHELL_METACHARS = set(";|&$`><\n\r")
# Env vars that let a child process load attacker-supplied code before main().
_MCP_DANGEROUS_ENV = frozenset({
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP",
"PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV",
"ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH",
"R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND",
})
def _mcp_allowed_commands() -> set:
"""Operator-configured allowlist of safe MCP launcher basenames for the agent
path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated)
to opt specific trusted binaries in. Denied commands are rejected even if
listed here."""
raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "")
return {c.strip().lower() for c in raw.split(",") if c.strip()}
def _validate_mcp_command(command, args, env) -> Optional[str]:
"""Validate a model-supplied stdio MCP registration. Returns an error string
if it must be rejected, else None.
Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled
command/args/env straight to a subprocess spawn (issue #438): a payload
smuggled into a skill description, memory entry, fetched page, or email body
could register a stdio server running arbitrary code as the app UID.
"""
if not isinstance(command, str) or not command.strip():
return "command must be a non-empty string"
command = command.strip()
if "/" in command or "\\" in command:
return "command must be a bare executable name, not a path"
if any(ch in _MCP_SHELL_METACHARS for ch in command):
return "command contains shell metacharacters"
base = command.lower()
if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"):
base = base.rsplit(".", 1)[0]
# Canonicalize a trailing version suffix so versioned aliases collapse to the
# family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the
# raw basename and the canonical form are denied, so an operator cannot
# accidentally allowlist a runtime alias back into the path.
canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base)
if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS:
return (
f"command '{command}' is not allowed on the agent MCP path: "
"interpreters, runtimes, package runners, and shells can execute "
"arbitrary code. Register such a server via the admin route instead."
)
if base not in _mcp_allowed_commands():
return (
f"command '{command}' is not in the MCP allowlist. Add it to "
"ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the "
"server via the admin route."
)
if args is not None:
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
return "args must be a JSON list"
if not isinstance(args, list):
return "args must be a list"
for a in args:
if not isinstance(a, str):
return "args must all be strings"
s = a.strip()
low = s.lower()
if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS):
return f"arg '{a}' is a code-execution flag and is not allowed"
if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS):
return f"arg '{a}' is a code-execution flag and is not allowed"
if any(low.startswith(u) for u in _MCP_URL_SCHEMES):
return f"arg '{a}' is a remote URL and is not allowed"
if any(ch in _MCP_SHELL_METACHARS for ch in a):
return f"arg '{a}' contains shell metacharacters"
if env:
if isinstance(env, str):
try:
env = json.loads(env)
except Exception:
return "env must be a JSON object"
if not isinstance(env, dict):
return "env must be an object"
for k in env:
if str(k).strip().upper() in _MCP_DANGEROUS_ENV:
return f"env var '{k}' can inject code into the child process and is not allowed"
return None
async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict:
"""Manage MCP servers: list, add, delete, enable, disable, reconnect."""
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
if action == "list":
mcp = get_mcp_manager()
if not mcp:
return {"response": "No MCP manager available", "servers": [], "exit_code": 0}
from core.database import SessionLocal, McpServer
db = SessionLocal()
try:
servers = db.query(McpServer).all()
items = []
for s in servers:
st = mcp.get_server_status(s.id)
status = st.get("status", "disconnected")
tool_count = st.get("tool_count", 0)
items.append({"id": s.id, "name": s.name, "transport": s.transport,
"is_enabled": s.is_enabled, "status": status,
"tool_count": tool_count})
return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0}
finally:
db.close()
elif action == "add":
from core.database import SessionLocal, McpServer
import uuid as _uuid
from datetime import datetime
name = args.get("name", "")
command = args.get("command", "")
cmd_args = args.get("args", [])
env = args.get("env", {})
if not name or not command:
return {"error": "name and command are required", "exit_code": 1}
# Validate BEFORE any DB write or spawn: a rejected registration must
# leave no enabled row (which would otherwise auto-reconnect on restart)
# and must not attempt a connection.
_mcp_err = _validate_mcp_command(command, cmd_args, env)
if _mcp_err:
return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1}
sid = str(_uuid.uuid4())[:8]
db = SessionLocal()
try:
srv = McpServer(id=sid, name=name, transport="stdio", command=command,
args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args,
env=json.dumps(env) if isinstance(env, dict) else env,
is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(srv)
db.commit()
finally:
db.close()
# Try to connect
mcp = get_mcp_manager()
tool_count = 0
if mcp:
try:
await mcp.connect_server(
sid, name, "stdio", command=command,
args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args),
env=env if isinstance(env, dict) else json.loads(env),
)
st = mcp.get_server_status(sid)
tool_count = st.get("tool_count", 0)
except Exception as e:
logger.warning(f"MCP connect failed for {name}: {e}")
return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0}
elif action == "delete":
sid = args.get("server_id", "")
from core.database import SessionLocal, McpServer
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == sid).first()
if not srv:
return {"error": f"Server {sid} not found", "exit_code": 1}
name = srv.name
mcp = get_mcp_manager()
if mcp:
try:
await mcp.disconnect_server(sid)
except Exception:
pass
db.delete(srv)
db.commit()
return {"response": f"Deleted MCP server '{name}'", "exit_code": 0}
finally:
db.close()
elif action == "reconnect":
sid = args.get("server_id", "")
mcp = get_mcp_manager()
if not mcp:
return {"error": "MCP manager not available", "exit_code": 1}
try:
await mcp.disconnect_server(sid)
from core.database import SessionLocal, McpServer
db2 = SessionLocal()
try:
srv = db2.query(McpServer).filter(McpServer.id == sid).first()
if srv:
_args = json.loads(srv.args) if srv.args else []
_env = json.loads(srv.env) if srv.env else {}
await mcp.connect_server(
server_id=sid,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=_args,
env=_env,
url=srv.url,
)
st = mcp.get_server_status(sid)
return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0}
return {"error": f"Server {sid} not found", "exit_code": 1}
finally:
db2.close()
except Exception as e:
return {"error": str(e), "exit_code": 1}
elif action in ("enable", "disable"):
sid = args.get("server_id", "")
from core.database import SessionLocal, McpServer
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == sid).first()
if not srv:
return {"error": f"Server {sid} not found", "exit_code": 1}
srv.is_enabled = (action == "enable")
db.commit()
return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0}
finally:
db.close()
elif action == "list_tools":
mcp = get_mcp_manager()
if not mcp:
return {"response": "No MCP manager", "tools": [], "exit_code": 0}
tools = mcp.get_all_tools()
items = [{"name": t["name"], "server": t["server_name"],
"description": t.get("description", "")[:100]} for t in tools]
return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
# ---------------------------------------------------------------------------
# Webhook management tool
# ---------------------------------------------------------------------------
async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict:
"""Manage webhooks: list, add, delete, enable, disable, test."""
from core.database import SessionLocal
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
db = SessionLocal()
try:
from core.database import Webhook
if action == "list":
hooks = db.query(Webhook).all()
items = [{"id": h.id, "name": h.name, "url": h.url,
"events": h.events, "is_active": h.is_active} for h in hooks]
return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0}
elif action == "add":
import uuid as _uuid
from datetime import datetime
from src.webhook_manager import validate_events, validate_webhook_url
name = args.get("name", "")
url = args.get("url", "")
events = args.get("events", "chat.completed")
if not url:
return {"error": "url is required", "exit_code": 1}
try:
url = validate_webhook_url(url)
events = validate_events(events)
except ValueError as e:
return {"error": str(e), "exit_code": 1}
wid = str(_uuid.uuid4())[:8]
hook = Webhook(id=wid, name=name or url, url=url,
events=events, is_active=True,
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(hook)
db.commit()
return {"response": f"Added webhook '{name or url}'", "exit_code": 0}
elif action == "delete":
wid = args.get("webhook_id", "")
hook = db.query(Webhook).filter(Webhook.id == wid).first()
if not hook:
return {"error": f"Webhook {wid} not found", "exit_code": 1}
name = hook.name
db.delete(hook)
db.commit()
return {"response": f"Deleted webhook '{name}'", "exit_code": 0}
elif action in ("enable", "disable"):
wid = args.get("webhook_id", "")
hook = db.query(Webhook).filter(Webhook.id == wid).first()
if not hook:
return {"error": f"Webhook {wid} not found", "exit_code": 1}
hook.is_active = (action == "enable")
db.commit()
return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_webhooks error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# API token management tool
# ---------------------------------------------------------------------------
async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict:
"""Manage API tokens: list, create, delete."""
from core.database import SessionLocal, ApiToken
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
db = SessionLocal()
try:
if action == "list":
tokens = db.query(ApiToken).all()
items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...",
"is_active": t.is_active} for t in tokens]
return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0}
elif action == "create":
import uuid as _uuid, secrets, bcrypt
from datetime import datetime
name = args.get("name", "API Token")
raw_token = secrets.token_urlsafe(32)
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
tid = str(_uuid.uuid4())[:8]
t = ApiToken(id=tid, name=name, token_hash=token_hash,
token_prefix=raw_token[:8], is_active=True,
created_at=datetime.utcnow(), updated_at=datetime.utcnow())
db.add(t)
db.commit()
return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0}
elif action == "delete":
tid = args.get("token_id", "")
t = db.query(ApiToken).filter(ApiToken.id == tid).first()
if not t:
return {"error": f"Token {tid} not found", "exit_code": 1}
name = t.name
db.delete(t)
db.commit()
return {"response": f"Deleted token '{name}'", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_tokens error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# Settings/preferences management tool
# ---------------------------------------------------------------------------
async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"""Manage user settings and preferences."""
try:
args = _parse_tool_args(content)
except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1}
action = args.get("action", "list")
from core.database import SessionLocal
db = SessionLocal()
try:
# set/get/list/delete operate on the REAL app settings (the same store
# the Settings panel writes), so changing a model / voice / search
# engine / reminder channel from chat actually takes effect.
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
# Secrets/credentials the agent must NOT write — kept read-only (masked)
# so API keys never flow through chat. User sets these in the panel.
_SECRET_KEYS = {
"brave_api_key", "google_pse_key", "google_pse_cx",
"tavily_api_key", "serper_api_key", "app_public_url",
}
def _is_secret(k):
# `token` must be a suffix, not a substring: otherwise the int
# setting `agent_input_token_budget` (which even has a "token budget"
# alias to set it from chat) is wrongly classified as a credential.
return (
k in _SECRET_KEYS
or k.endswith("token")
or any(t in k for t in ("api_key", "_key", "secret", "password"))
)
# Friendly aliases → real keys, so natural phrasing resolves.
_ALIASES_SET = {
"voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled",
"text to speech": "tts_enabled", "tts provider": "tts_provider",
"speech speed": "tts_speed", "voice speed": "tts_speed",
"stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled",
"search engine": "search_provider", "search provider": "search_provider",
"search results": "search_result_count", "result count": "search_result_count",
"default model": "default_model", "chat model": "default_model",
"default endpoint": "default_endpoint_id",
"task model": "task_model", "background model": "task_model",
"teacher model": "teacher_model", "teacher": "teacher_enabled",
"utility model": "utility_model", "research model": "research_model",
"research max tokens": "research_max_tokens",
"vision model": "vision_model", "vision": "vision_enabled",
"image model": "image_model", "image quality": "image_quality",
"image gen": "image_gen_enabled", "image generation": "image_gen_enabled",
"reminder channel": "reminder_channel", "reminders": "reminder_channel",
"ntfy topic": "reminder_ntfy_topic",
"webhook integration": "reminder_webhook_integration_id",
"webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template",
"agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls",
"agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds",
"token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget",
"hard max": "agent_input_token_hard_max",
"token budget cap": "agent_input_token_hard_max",
"input budget cap": "agent_input_token_hard_max",
}
def _resolve(k):
k2 = (k or "").strip().lower()
if k2 in DEFAULT_SETTINGS:
return k2
return _ALIASES_SET.get(k2, (k or "").strip())
_ENUMS = {
"image_quality": ["low", "medium", "high"],
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
}
def _coerce(value, default):
if isinstance(default, bool):
return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled")
if isinstance(default, int):
return int(value)
return value
def _model_slug(value: str) -> str:
import re as _re
return _re.sub(r"[^a-z0-9]+", "", (value or "").lower())
def _endpoint_model_from_cache(model_query: str):
"""Resolve friendly model text to an enabled endpoint + real model id.
The Settings UI stores both `<prefix>_endpoint_id` and
`<prefix>_model`; writing only the model leaves the runtime on the
old endpoint. Prefer cached model lists so this stays fast/offline.
"""
import json as _json
import re as _re
from core.database import ModelEndpoint
wanted = (model_query or "").strip()
wanted_slug = _model_slug(wanted)
wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)]
wanted_tokens = [t for t in wanted_tokens if t]
if not wanted_slug:
return None
best = None
for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all():
raw_models = []
try:
raw_models = _json.loads(ep.cached_models or "[]") or []
except Exception:
raw_models = []
# If cache is empty, still allow matching against endpoint name
# for callers using model@endpoint elsewhere later.
for mid in raw_models:
mid = str(mid)
mid_slug = _model_slug(mid)
if not mid_slug:
continue
exact = mid.lower() == wanted.lower()
compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug
token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens)
if exact or compact_match or token_match:
score = 3 if exact else (2 if compact_match else 1)
if not best or score > best[0]:
best = (score, ep.id, mid)
if best:
return {"endpoint_id": best[1], "model": best[2]}
return None
def _mask(k, v):
return "••••• (set in panel)" if _is_secret(k) and v else v
if action == "list":
s = load_settings()
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
elif action == "get":
key = _resolve(args.get("key", ""))
if not key:
return {"error": "key is required", "exit_code": 1}
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
elif action == "set":
raw = args.get("key", "")
value = args.get("value")
if not raw:
return {"error": "key is required", "exit_code": 1}
key = _resolve(raw)
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential/secret — for security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
# have no safe scalar coercion — _coerce would pass a bare string
# straight through and clobber the structure. Refuse them here; they're
# edited in their dedicated panels. (reset/delete still restore the
# default structure, which is safe.)
if isinstance(DEFAULT_SETTINGS[key], (dict, list)):
return {"response": f"'{key}' is a structured setting — edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0}
try:
value = _coerce(value, DEFAULT_SETTINGS[key])
except (ValueError, TypeError):
return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1}
if key in _ENUMS and str(value).lower() not in _ENUMS[key]:
return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1}
s = load_settings()
s[key] = value
if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}:
resolved = _endpoint_model_from_cache(str(value))
if resolved:
prefix = key[:-6]
s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"]
s[key] = resolved["model"]
value = resolved["model"]
save_settings(s)
if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"):
return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0}
return {"response": f"Set {key} = {value}.", "exit_code": 0}
elif action == "delete" or action == "reset":
key = _resolve(args.get("key", ""))
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential — reset it in the panel.", "exit_code": 0}
s = load_settings()
s[key] = DEFAULT_SETTINGS[key]
save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action in ("disable_tool", "enable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> "builtin_browser",
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
from src.settings import get_setting, save_settings, load_settings
_ALIASES = {
"shell": ["bash"],
"terminal": ["bash"],
"search": ["web_search", "web_fetch"],
"web": ["web_search", "web_fetch"],
"browser": ["builtin_browser"],
"documents": ["create_document", "edit_document", "update_document", "suggest_document"],
"doc": ["create_document", "edit_document", "update_document", "suggest_document"],
"memory": ["manage_memory"],
"skills": ["manage_skills"],
"images": ["generate_image"],
"image": ["generate_image"],
"tasks": ["manage_tasks"],
"notes": ["manage_notes"],
"calendar": ["manage_calendar"],
"email": ["mcp__email__list_emails", "mcp__email__read_email", "mcp__email__send_email"],
"research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool — closest analog
}
if action == "list_tools":
current = get_setting("disabled_tools", []) or []
return {
"response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email."
),
"disabled": list(current),
"exit_code": 0,
}
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
if not tool_name:
return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1}
targets = _ALIASES.get(tool_name, [tool_name])
settings = load_settings()
current = list(settings.get("disabled_tools") or [])
before = set(current)
if action == "disable_tool":
for t in targets:
if t not in current:
current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
after = set(current)
settings["disabled_tools"] = current
save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before))
return {
"response": (
f"{verb} {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}."
),
"changed": changed,
"disabled": list(current),
"exit_code": 0,
}
else:
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_settings error: {e}")
return {"error": str(e), "exit_code": 1}
finally:
db.close()
# ---------------------------------------------------------------------------
# API call tool
# ---------------------------------------------------------------------------
async def do_api_call(content: str) -> Dict: async def do_api_call(content: str) -> Dict:
"""Execute an API call to a registered integration.""" """Execute an API call to a registered integration."""
from src.integrations import execute_api_call, load_integrations from src.integrations import execute_api_call, load_integrations
@@ -3452,7 +2669,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
host_only = host.split("@", 1)[-1] if host else "localhost" host_only = host.split("@", 1)[-1] if host else "localhost"
endpoint_url = f"http://{host_only}:{int(port)}/v1" endpoint_url = f"http://{host_only}:{int(port)}/v1"
try: try:
from src.tool_implementations import do_manage_endpoints # avoid forward ref issues from src.agent_tools.admin_tools import do_manage_endpoints # moved in #3629
except Exception: except Exception:
do_manage_endpoints = None do_manage_endpoints = None
if do_manage_endpoints is not None: if do_manage_endpoints is not None:
+1 -1
View File
@@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).", "list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
"send_to_session": "Send a message to another chat. Cross-chat communication.", "send_to_session": "Send a message to another chat. Cross-chat communication.",
"search_chats": "Search past session transcripts across chats.", "search_chats": "Search past session transcripts across chats.",
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.", "ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.", "update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.", "ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.", "list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
+84 -1
View File
@@ -308,6 +308,88 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
return ToolBlock("web_fetch", url) return ToolBlock("web_fetch", url)
def _parse_misfenced_read_file_lookup(content: str, *, allow_shell_style: bool = False) -> Optional[ToolBlock]:
"""Recover simple read_file calls wrapped in python/bash fences."""
stripped = content.strip()
if not stripped:
return None
try:
module = ast.parse(stripped, mode="exec")
except SyntaxError:
module = None
if module and len(module.body) == 1 and isinstance(module.body[0], ast.Expr):
call = module.body[0].value
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name):
if call.func.id.lower() != "read_file" or len(call.args) > 1:
return None
args = {}
if call.args:
path = _literal_string(call.args[0])
if not path:
return None
args["path"] = path
allowed = {"path", "file", "file_path", "offset", "limit"}
for keyword in call.keywords:
if keyword.arg not in allowed:
return None
key = "path" if keyword.arg in ("file", "file_path") else keyword.arg
if key == "path":
path = _literal_string(keyword.value)
if not path:
return None
args["path"] = path
continue
try:
value = ast.literal_eval(keyword.value)
except (ValueError, SyntaxError, TypeError):
return None
if not isinstance(value, int) or value < 0:
return None
args[key] = value
if not args.get("path"):
return None
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block("read_file", json.dumps(args))
if not allow_shell_style:
return None
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
if len(lines) != 1:
return None
match = re.fullmatch(r"read_file\s+(.+)", lines[0], re.IGNORECASE)
if not match:
return None
path = match.group(1).strip()
if not path:
return None
if path.startswith("{"):
try:
args = json.loads(path)
except json.JSONDecodeError:
return None
if not isinstance(args, dict):
return None
normalized = {}
raw_path = args.get("path") or args.get("file") or args.get("file_path")
if isinstance(raw_path, str) and raw_path.strip():
normalized["path"] = raw_path.strip()
for key in ("offset", "limit"):
value = args.get(key)
if isinstance(value, int) and value >= 0:
normalized[key] = value
if not normalized.get("path"):
return None
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block("read_file", json.dumps(normalized))
if len(path) >= 2 and path[0] == path[-1] and path[0] in "'\"":
path = path[1:-1].strip()
if not path:
return None
return ToolBlock("read_file", path)
def _coerce_raw_web_query(value) -> Optional[str]: def _coerce_raw_web_query(value) -> Optional[str]:
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
return value.strip() return value.strip()
@@ -704,7 +786,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
# _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code. # _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code.
continue continue
if tag in ("python", "bash"): if tag in ("python", "bash"):
block = _parse_misfenced_web_lookup(content) block = (_parse_misfenced_web_lookup(content)
or _parse_misfenced_read_file_lookup(content, allow_shell_style=(tag == "bash")))
if block: if block:
blocks.append(block) blocks.append(block)
continue continue
+8 -2
View File
@@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."}, "question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
"options": { "options": {
"type": "array", "type": "array",
"description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.", "description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
"required": ["label"] "required": ["label"]
} }
}, },
"multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."} "multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."}
}, },
"required": ["question", "options"] "required": ["question", "options"]
} }
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
content = json.dumps(args) content = json.dumps(args)
elif tool_type == "ask_teacher": elif tool_type == "ask_teacher":
content = args.get("model", "auto") + "\n" + args.get("problem", "") content = args.get("model", "auto") + "\n" + args.get("problem", "")
elif tool_type == "ask_user":
# Keep user-facing labels readable in the tool trace. The outer SSE
# JSON encoder will escape them for transport and JSON.parse restores
# them once; pre-escaping here caused literal ``\u00f1`` sequences to
# remain visible in the debug panel.
content = json.dumps(args, ensure_ascii=False)
else: else:
content = json.dumps(args) content = json.dumps(args)
+35
View File
@@ -4,6 +4,8 @@ src.constants which imports nothing from src). Adding a project import here
will reintroduce the circular dependency that this module exists to break. will reintroduce the circular dependency that this module exists to break.
""" """
import json
from src.constants import MAX_OUTPUT_CHARS from src.constants import MAX_OUTPUT_CHARS
_mcp_manager = None _mcp_manager = None
@@ -37,3 +39,36 @@ def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
if len(text) > limit: if len(text) > limit:
return text[:limit] + f"\n... (truncated, {len(text)} chars total)" return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
return text return text
def _parse_tool_args(content):
"""Parse a tool-call argument blob.
Accepts either a JSON string or an already-decoded dict. Unwraps the
common `{"body": {...}}` envelope that smaller models emit when they
read tool descriptions like "Body is JSON: {...}" literally and
pass `body` as a field name rather than treating it as a noun.
Returns a dict on success, raises ValueError on bad JSON.
"""
if isinstance(content, str):
try:
args = json.loads(content) if content.strip() else {}
except (json.JSONDecodeError, TypeError) as e:
raise ValueError(str(e))
elif isinstance(content, dict):
args = content
else:
args = {}
# Unwrap {"body": {...}} envelope, but only if `body` is the sole key
# and points at a dict. We don't want to clobber a legitimate `body`
# field on tools where it's a real arg (e.g. send_email body text).
if (
isinstance(args, dict)
and len(args) == 1
and "body" in args
and isinstance(args["body"], dict)
and "action" in args["body"] # extra safety: only unwrap if the inner dict looks like a tool call
):
args = args["body"]
return args
+5 -3
View File
@@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() {
if (!sessionModule) return false; if (!sessionModule) return false;
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat(); const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
if (pending && pending.url && pending.modelId) { if (pending && pending.url && pending.modelId && pending.endpointId) {
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId); sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
return true; return true;
} }
@@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() {
const sessions = sessionModule.getSessions(); const sessions = sessionModule.getSessions();
const currentId = sessionModule.getCurrentSessionId(); const currentId = sessionModule.getCurrentSessionId();
const current = sessions.find(s => s.id === currentId); const current = sessions.find(s => s.id === currentId);
if (current && current.endpoint_url && current.model) { if (current && current.endpoint_url && current.model && current.endpoint_id) {
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id); sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
return true; return true;
} }
@@ -2418,7 +2418,7 @@ function initializeEventListeners() {
}; };
// Keys hidden by default on first run (no localStorage yet) // Keys hidden by default on first run (no localStorage yet)
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis']); const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
// Keys that need admin to toggle off (reserved for future use) // Keys that need admin to toggle off (reserved for future use)
const UI_VIS_ADMIN_ONLY = new Set([]); const UI_VIS_ADMIN_ONLY = new Set([]);
@@ -2451,6 +2451,8 @@ function initializeEventListeners() {
applyTextEmojis(state['text-emojis'] === true); applyTextEmojis(state['text-emojis'] === true);
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide) // Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false); document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
// Fullwidth chat toggle (chat-fullwidth: checked=fullwidth, unchecked=big-padding
document.body.classList.toggle('fullwidth-chat', state['chat-fullwidth'] === true);
} }
// Rearrange toggles in session/model sort dropdowns // Rearrange toggles in session/model sort dropdowns
Binary file not shown.
Binary file not shown.
+31 -2
View File
@@ -76,7 +76,7 @@
} }
// Apply font early // Apply font early
if (t && t.font) { if (t && t.font) {
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"}; var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); } if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); } else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
} }
@@ -84,6 +84,12 @@
if (t && t.density && t.density !== 'comfortable') { if (t && t.density && t.density !== 'comfortable') {
document.documentElement.classList.add('density-' + t.density); document.documentElement.classList.add('density-' + t.density);
} }
// Apply UI text-size scale early (global accessibility pref, independent
// of the active theme) so there's no flash on load.
try {
var _us = localStorage.getItem('odysseus-ui-scale');
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
} catch(e){}
// Apply background pattern on body once available // Apply background pattern on body once available
if (t && t.bgPattern && t.bgPattern !== 'none') { if (t && t.bgPattern && t.bgPattern !== 'none') {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -581,6 +587,7 @@
<option value="mono">Monospace</option> <option value="mono">Monospace</option>
<option value="sans">Sans-serif</option> <option value="sans">Sans-serif</option>
<option value="serif">Serif</option> <option value="serif">Serif</option>
<option value="opendyslexic">OpenDyslexic (dyslexia-friendly)</option>
</select> </select>
</div> </div>
<div class="theme-fd-group"> <div class="theme-fd-group">
@@ -591,6 +598,13 @@
<option value="spacious">Spacious</option> <option value="spacious">Spacious</option>
</select> </select>
</div> </div>
<div class="theme-fd-group">
<label class="theme-fd-label">Text size</label>
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
<option value="100">Default</option>
<option value="125">Larger</option>
</select>
</div>
<div class="theme-fd-group" id="theme-frosted-group"> <div class="theme-fd-group" id="theme-frosted-group">
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label> <label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
<label class="admin-switch" style="margin-top:4px;"> <label class="admin-switch" style="margin-top:4px;">
@@ -1318,7 +1332,7 @@
<!-- Cookbook Modal --> <!-- Cookbook Modal -->
<div id="cookbook-modal" class="modal hidden"> <div id="cookbook-modal" class="modal hidden">
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);"> <div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); background: var(--bg);">
<div class="modal-header"> <div class="modal-header">
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4> <h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook"></button> <button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook"></button>
@@ -1806,6 +1820,11 @@
<span class="vis-label">Session Header <span class="vis-hint">Model name &amp; export above chat</span></span> <span class="vis-label">Session Header <span class="vis-hint">Model name &amp; export above chat</span></span>
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span> <input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
</label> </label>
<label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16"/><path d="M4 10h8"/></svg></span>
<span class="vis-label">Full-width chat <span class="vis-hint">Use the full window width (desktop)</span></span>
<input type="checkbox" data-ui-key="chat-fullwidth"><span class="vis-switch"></span>
</label>
<label class="vis-row"> <label class="vis-row">
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span> <span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span>
<span class="vis-label">Welcome Message <span class="vis-hint">Logo &amp; tips on empty chat</span></span> <span class="vis-label">Welcome Message <span class="vis-hint">Logo &amp; tips on empty chat</span></span>
@@ -2046,6 +2065,16 @@
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label> <label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
</div> </div>
</div> </div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15v3m-3-3h6M12 3v2m0 16v-2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M3 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/><circle cx="12" cy="12" r="3"/></svg>Model Defaults</h2>
<div class="admin-toggle-row">
<div>
<div class="admin-toggle-label">Share defaults with users</div>
<div class="admin-toggle-sub">When on, users without a personal default inherit the global default model (only if those models are allowed for them).</div>
</div>
<label class="admin-switch"><input type="checkbox" id="adm-shareDefaultsToggle"><span class="admin-slider"></span></label>
</div>
</div>
<div class="admin-card"> <div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2> <h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
<div id="adm-userList"><div class="admin-empty">Loading...</div></div> <div id="adm-userList"><div class="admin-empty">Loading...</div></div>
+43 -4
View File
@@ -343,6 +343,28 @@ function initSignupToggle() {
}); });
} }
function initShareDefaultsToggle() {
const toggle = el('adm-shareDefaultsToggle');
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
.catch(e => console.warn('Settings fetch failed:', e));
toggle.addEventListener('change', async () => {
try {
const res = await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ share_defaults_with_users: toggle.checked }),
});
const data = await res.json();
toggle.checked = !!data.share_defaults_with_users;
} catch (e) {
toggle.checked = !toggle.checked;
}
});
}
function initAddUser() { function initAddUser() {
fetch('/api/auth/policy', { credentials: 'same-origin' }) fetch('/api/auth/policy', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
@@ -1581,8 +1603,8 @@ function initEndpointForm() {
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;'; wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
wrap.appendChild(wp.element); wrap.appendChild(wp.element);
const txt = document.createElement('span'); const txt = document.createElement('span');
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...'; txt.textContent = 'Scanning ports 8000-8020, 8080, 1234, 11434, and 11435 for model servers...';
txt.style.cssText = 'opacity:0.7;'; txt.style.cssText = 'font-size:12px;opacity:0.7;';
wrap.appendChild(txt); wrap.appendChild(txt);
msg.appendChild(wrap); msg.appendChild(wrap);
discoverBtn._wp = wp; discoverBtn._wp = wp;
@@ -1597,12 +1619,24 @@ function initEndpointForm() {
} else { } else {
// Auto-add each discovered endpoint. Server dedupes on base_url // Auto-add each discovered endpoint. Server dedupes on base_url
// and returns `existing: true` for already-registered ones. // and returns `existing: true` for already-registered ones.
// Map fingerprinted provider IDs to friendly display names.
const _PROVIDER_DISPLAY = {
llamacpp: 'llama.cpp', lmstudio: 'LM Studio', vllm: 'vLLM',
ollama: 'Ollama',
};
let added = 0; let added = 0;
let skipped = 0; let skipped = 0;
for (const item of items) { for (const item of items) {
const base = item.url.replace('/chat/completions', '').replace(/\/$/, ''); const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
const providerDisplay = _PROVIDER_DISPLAY[item.provider] || null;
const fd = new FormData(); const fd = new FormData();
fd.append('base_url', base); fd.append('base_url', base);
if (providerDisplay) {
// Use "Provider (host:port)" so the endpoint is immediately
// identifiable in the list, e.g. "llama.cpp (localhost:8080)".
const hostPart = base.replace(/^https?:\/\//, '').split('/')[0];
fd.append('name', `${providerDisplay} (${hostPart})`);
}
fd.append('endpoint_kind', 'local'); fd.append('endpoint_kind', 'local');
fd.append('model_refresh_mode', 'auto'); fd.append('model_refresh_mode', 'auto');
fd.append('skip_probe', 'false'); fd.append('skip_probe', 'false');
@@ -1616,7 +1650,12 @@ function initEndpointForm() {
} }
} }
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0); const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
const parts = [`Found ${items.length} server${items.length !== 1 ? 's' : ''} with ${totalModels} model${totalModels !== 1 ? 's' : ''}`]; const serverNames = items.map(i =>
(_PROVIDER_DISPLAY[i.provider] || i.url.replace(/^https?:\/\//, '').split('/')[0])
);
const parts = [
`Found ${items.length} server${items.length !== 1 ? 's' : ''} (${serverNames.join(', ')}) with ${totalModels} model${totalModels !== 1 ? 's' : ''}`,
];
if (added) parts.push(`added ${added} new`); if (added) parts.push(`added ${added} new`);
if (skipped) parts.push(`${skipped} already added`); if (skipped) parts.push(`${skipped} already added`);
msg.innerHTML = parts.join(' — '); msg.innerHTML = parts.join(' — ');
@@ -2986,7 +3025,7 @@ function initLogsView() {
function initAll() { function initAll() {
modalEl = el('settings-modal'); modalEl = el('settings-modal');
const inits = [ const inits = [
initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm,
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView, initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
() => settingsModule.initIntegrations() () => settingsModule.initIntegrations()
]; ];
+10 -9
View File
@@ -5,6 +5,7 @@
import uiModule from './ui.js'; import uiModule from './ui.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js'; import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js'; import { bindMenuDismiss } from './escMenuStack.js';
@@ -12,7 +13,7 @@ import {
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT, WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE, CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
_trashIcon, _moreIcon, _bellIcon, _trashIcon, _moreIcon, _bellIcon,
_isCalBgImage, _calBgImageUrl, _calBgCss, _isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
_calReadableTextColor, _calReadableTextColor,
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf, _ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
} from './calendar/utils.js'; } from './calendar/utils.js';
@@ -413,8 +414,8 @@ function _calEventFg(ev) {
// Returns '' for normal solid-color events. // Returns '' for normal solid-color events.
function _calItemBgStyle(ev) { function _calItemBgStyle(ev) {
if (!_isCalBgImage(ev.color)) return ''; if (!_isCalBgImage(ev.color)) return '';
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22"); const url = _calBgImageUrl(ev.color);
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`; return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
} }
function _todayCount() { function _todayCount() {
@@ -470,7 +471,7 @@ function _showEventMoreMenu(ev, anchor) {
dropdown.className = 'cal-event-dropdown'; dropdown.className = 'cal-event-dropdown';
let closeMenu = () => dropdown.remove(); let closeMenu = () => dropdown.remove();
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`; dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
const _item = (icon, label, onClick, danger) => { const _item = (icon, label, onClick, danger) => {
const it = document.createElement('div'); const it = document.createElement('div');
@@ -1260,8 +1261,8 @@ async function _renderWeek() {
// events keep the original tinted treatment. // events keep the original tinted treatment.
let bgDecl; let bgDecl;
if (_isCalBgImage(ev.color)) { if (_isCalBgImage(ev.color)) {
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22"); const _url = _calBgImageUrl(ev.color);
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`; bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
} else { } else {
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`; bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
} }
@@ -2853,7 +2854,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
let bg; let bg;
if (isCustom) { if (isCustom) {
const url = _calBgImageUrl(cur); const url = _calBgImageUrl(cur);
bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT; bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT;
} else { } else {
bg = c.hex || 'var(--border)'; bg = c.hex || 'var(--border)';
} }
@@ -2928,7 +2929,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
// stays readable. Chrome accent falls back to the theme accent. // stays readable. Chrome accent falls back to the theme accent.
const url = _calBgImageUrl(hex); const url = _calBgImageUrl(hex);
_formCard.style.setProperty('--ev-color', 'var(--accent)'); _formCard.style.setProperty('--ev-color', 'var(--accent)');
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`; _formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`;
_formCard.style.backgroundSize = 'cover'; _formCard.style.backgroundSize = 'cover';
_formCard.style.backgroundPosition = 'center'; _formCard.style.backgroundPosition = 'center';
_formCard.classList.add('cal-form-bg-image'); _formCard.classList.add('cal-form-bg-image');
@@ -2950,7 +2951,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
if (!url) return; if (!url) return;
const sentinel = 'bg:' + url; const sentinel = 'bg:' + url;
dot.dataset.color = sentinel; dot.dataset.color = sentinel;
dot.style.background = `center/cover no-repeat url('${url}')`; dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`;
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active')); document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
dot.classList.add('active'); dot.classList.add('active');
_applyFormTint(sentinel); _applyFormTint(sentinel);
+13 -1
View File
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
return _isCalBgImage(c) ? c.slice(3) : ''; return _isCalBgImage(c) ? c.slice(3) : '';
} }
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
// add for `'` and let the value break out of the string (CodeQL
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
export function _cssUrlEscape(s) {
return String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '%22');
}
// Returns a value safe to drop into `style="background:..."`. Falls back to // Returns a value safe to drop into `style="background:..."`. Falls back to
// the calendar default for bg-image events in spots where an image would be // the calendar default for bg-image events in spots where an image would be
// too small to render usefully (small grid dots, multi-day bars). // too small to render usefully (small grid dots, multi-day bars).
export function _calBgCss(c, fallback) { export function _calBgCss(c, fallback) {
if (_isCalBgImage(c)) { if (_isCalBgImage(c)) {
const u = _calBgImageUrl(c); const u = _calBgImageUrl(c);
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)'); return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
} }
return c || fallback || 'var(--accent)'; return c || fallback || 'var(--accent)';
} }
+30 -142
View File
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
import chatStream from './chatStream.js'; import chatStream from './chatStream.js';
import { addAITTSButton } from './tts-ai.js'; import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js'; import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import presetsModule from './presets.js'; import presetsModule from './presets.js';
import fileHandlerModule from './fileHandler.js'; import fileHandlerModule from './fileHandler.js';
@@ -1921,6 +1920,23 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_chatBox.appendChild(note); _chatBox.appendChild(note);
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); } try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
} }
} else if (json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted') {
// A loop guard ended the turn — surface why so it isn't mistaken
// for a clean completion or a silent stall.
const _chatBox = document.getElementById('chat-history');
if (!_isBg && _chatBox) {
const note = document.createElement('div');
note.className = 'stopped-indicator loop-guard-stop';
const label = document.createElement('span');
label.className = 'rounds-exhausted-label';
label.textContent = json.message ||
(json.type === 'loop_breaker_triggered'
? 'Stopped by the loop-breaker (no new progress).'
: 'Stopped: announced an action but never called the tool.');
note.appendChild(label);
_chatBox.appendChild(note);
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
}
} else if (json.type === 'model_actual') { } else if (json.type === 'model_actual') {
if (!_isBg && holder) { if (!_isBg && holder) {
holder._requestedModel = json.requested_model || holder._requestedModel || modelName; holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
@@ -2321,148 +2337,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else if (json.type === 'ask_user') { } else if (json.type === 'ask_user') {
if (_isBg) continue; if (_isBg) continue;
// The agent posed a multiple-choice question; the turn has ended. // The agent posed a multiple-choice question; the turn has ended.
// Render clickable options at the bottom of the history. The // Use the shared history renderer so the live and restored
// user's pick is sent as the next message and the agent resumes. // versions have identical behavior.
_cancelThinkingTimer(); _cancelThinkingTimer();
_removeThinkingSpinner(); _removeThinkingSpinner();
const _aq = json.data || {}; chatRenderer.renderAskUserCard(json.data || {});
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
if (_aq.question && _opts.length) {
const chatBox = document.getElementById('chat-history');
// Drop any prior unanswered card so only the latest shows.
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
const card = document.createElement('div');
card.className = 'ask-user-card';
const multi = !!_aq.multi;
// Group the choices for assistive tech and label the group with
// the question (set below); make the card focusable so it can be
// moved to when it appears.
card.setAttribute('role', 'group');
card.tabIndex = -1;
// Render any emoji in agent-supplied text through the app's
// pipeline: escape, then svgify to monochrome theme-tinted
// glyphs (project rule: never colorful emoji; respects the
// "Text-only Emojis" setting like the rest of the chat).
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
// Header row holds the close (×) to dismiss the affordances and
// just type a reply instead.
const head = document.createElement('div');
head.className = 'ask-user-head';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const mi = uiModule.el('message');
if (mi) mi.focus();
});
head.appendChild(closeBtn);
card.appendChild(head);
// Render the question inside the card so it's self-contained:
// some models call ask_user without first narrating the question
// as assistant text, in which case the card would otherwise show
// bare options with no prompt.
if (_aq.question) {
const q = document.createElement('div');
q.className = 'ask-user-question';
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
q.innerHTML = _emo(_aq.question);
card.appendChild(q);
// Label the choice group with the question for screen readers.
card.setAttribute('aria-labelledby', q.id);
} else {
card.setAttribute('aria-label', 'Question from the assistant');
}
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
const _send = (text) => {
if (!text) return;
// Remove the card once answered — the choice is sent as a
// normal user message (and the question persists as the
// assistant text above), so the affordances are spent.
card.remove();
const mi = uiModule.el('message');
if (mi) mi.value = text;
const sb = document.querySelector('.send-btn');
if (sb) sb.click();
};
_opts.forEach((opt, i) => {
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
if (!label) return;
const descr = (opt && opt.description) ? String(opt.description) : '';
const row = document.createElement(multi ? 'label' : 'button');
row.className = 'ask-user-option';
if (multi) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = label;
row.appendChild(cb);
}
const txt = document.createElement('span');
txt.className = 'ask-user-option-label';
txt.innerHTML = _emo(label);
row.appendChild(txt);
if (descr) {
const d = document.createElement('span');
d.className = 'ask-user-option-desc';
d.innerHTML = _emo(descr);
row.appendChild(d);
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => _send(label));
}
list.appendChild(row);
});
// Free-text "Other" — type a custom answer + send (Enter or →).
const other = document.createElement('div');
other.className = 'ask-user-other';
const otherInput = document.createElement('input');
otherInput.type = 'text';
otherInput.className = 'styled-prompt-input ask-user-other-input';
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
const otherSend = document.createElement('button');
otherSend.type = 'button';
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
otherSend.setAttribute('aria-label', 'Send answer');
otherSend.textContent = multi ? 'Send selection' : 'Send';
const _submit = () => {
const free = otherInput.value.trim();
if (multi) {
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
if (free) picked.push(free);
if (picked.length) _send(picked.join(', '));
} else if (free) {
_send(free);
}
};
otherSend.addEventListener('click', _submit);
otherInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
_submit();
}
});
other.appendChild(otherInput);
other.appendChild(otherSend);
card.appendChild(other);
chatBox.appendChild(card);
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Move focus to the card so keyboard/screen-reader users land on
// the question + choices when it appears.
try { card.focus(); } catch (_) {}
}
} else if (json.type === 'plan_update') { } else if (json.type === 'plan_update') {
if (_isBg) continue; if (_isBg) continue;
@@ -5019,7 +4898,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!header) return; if (!header) return;
const node = header.closest('.agent-thread-node'); const node = header.closest('.agent-thread-node');
if (!node) return; if (!node) return;
node.classList.toggle('open'); const opened = node.classList.toggle('open');
if (opened) {
// Expanding the final tool trace can push a pending ask_user card below
// the viewport. Keep that immediately-adjacent prompt visible.
const thread = node.closest('.agent-thread');
const pendingCard = thread?.nextElementSibling;
if (pendingCard?.classList.contains('ask-user-card')) {
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
}
}
}); });
window.__odysseus_thread_click_bound = true; window.__odysseus_thread_click_bound = true;
} }
+191 -3
View File
@@ -3,6 +3,7 @@
import uiModule from './ui.js'; import uiModule from './ui.js';
import markdownModule from './markdown.js'; import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import { addAITTSButton } from './tts-ai.js'; import { addAITTSButton } from './tts-ai.js';
import { providerLogo, providerLabel } from './providers.js'; import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js'; import settingsModule from './settings.js';
@@ -406,8 +407,44 @@ function _openVisionEditor(att, userMsgEl) {
// Tool call syntax patterns to strip from displayed text // Tool call syntax patterns to strip from displayed text
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi; const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples // Strip fenced tool-call blocks that look like structured invocations, not
const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi; // regular code examples. The tool tags are NOT hard-coded here — they are the
// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and
// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is
// thus the single source: the live-strip list can never drift from the backend
// or miss a future tool (#3993). bash/python are carved out on purpose — they
// are languages a user may legitimately have asked the model to show, not tool
// invocations.
//
// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't
// stripped — normally a sub-second window before the first stream. If the fetch
// fails it stays null for the rest of the session (logged below), so live exec
// fences won't be stripped until reload. Either way the backend already strips
// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS),
// so a reload always renders clean.
let EXEC_FENCE_RE = null;
const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']);
async function loadExecFenceRegex() {
try {
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
const tags = (data.tools || [])
.map((t) => t.id)
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
if (tags.length) {
EXEC_FENCE_RE = new RegExp(
'```(?:' + tags.join('|') + ')\\s*\\n[\\s\\S]*?```', 'gi'
);
}
} catch (err) {
// Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null,
// so this session won't strip live exec fences until reload (persisted path
// stays clean regardless).
console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err);
}
}
loadExecFenceRegex();
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke> // XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi; const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi; const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
@@ -852,7 +889,7 @@ export function roleTimestamp(when) {
*/ */
export function stripToolBlocks(text) { export function stripToolBlocks(text) {
let cleaned = text.replace(TOOL_CALL_RE, ''); let cleaned = text.replace(TOOL_CALL_RE, '');
cleaned = cleaned.replace(EXEC_FENCE_RE, ''); if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, '');
cleaned = cleaned.replace(DSML_TOOL_RE, ''); cleaned = cleaned.replace(DSML_TOOL_RE, '');
cleaned = cleaned.replace(DSML_STRAY_RE, ''); cleaned = cleaned.replace(DSML_STRAY_RE, '');
cleaned = cleaned.replace(XML_TOOL_CALL_RE, ''); cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
@@ -1974,6 +2011,142 @@ export function displayMetrics(messageElement, metrics) {
if (uiModule) uiModule.scrollHistory(); if (uiModule) uiModule.scrollHistory();
} }
/** Remove any unanswered multiple-choice cards currently in the chat. */
export function removeAskUserCards(root) {
const scope = root || document.getElementById('chat-history') || document;
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
}
/**
* Render an ask_user payload as a durable choice card.
*
* This lives in the history renderer rather than the streaming loop so the
* same UI can be used both for a live SSE event and for a persisted tool event
* after a session reload.
*/
export function renderAskUserCard(payload, options) {
const aq = payload || {};
const opts = Array.isArray(aq.options) ? aq.options : [];
const chatBox = document.getElementById('chat-history');
if (!chatBox || !aq.question || opts.length < 2) return null;
const renderOptions = options || {};
removeAskUserCards(chatBox);
const card = document.createElement('div');
card.className = 'ask-user-card';
card.setAttribute('role', 'group');
card.tabIndex = -1;
const multi = !!aq.multi;
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const head = document.createElement('div');
head.className = 'ask-user-head';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const input = uiModule.el('message');
if (input) input.focus();
});
head.appendChild(closeBtn);
card.appendChild(head);
const question = document.createElement('div');
question.className = 'ask-user-question';
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
question.innerHTML = emojiText(aq.question);
card.appendChild(question);
card.setAttribute('aria-labelledby', question.id);
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
const send = (text) => {
if (!text) return;
card.remove();
const input = uiModule.el('message');
if (input) input.value = text;
const sendButton = document.querySelector('.send-btn');
if (sendButton) sendButton.click();
};
opts.forEach((opt) => {
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
if (!label) return;
const description = (opt && opt.description) ? String(opt.description) : '';
const row = document.createElement(multi ? 'label' : 'button');
row.className = 'ask-user-option';
if (multi) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = label;
row.appendChild(checkbox);
}
const labelText = document.createElement('span');
labelText.className = 'ask-user-option-label';
labelText.innerHTML = emojiText(label);
row.appendChild(labelText);
if (description) {
const descriptionText = document.createElement('span');
descriptionText.className = 'ask-user-option-desc';
descriptionText.innerHTML = emojiText(description);
row.appendChild(descriptionText);
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => send(label));
}
list.appendChild(row);
});
const other = document.createElement('div');
other.className = 'ask-user-other';
const otherInput = document.createElement('input');
otherInput.type = 'text';
otherInput.className = 'styled-prompt-input ask-user-other-input';
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
const otherSend = document.createElement('button');
otherSend.type = 'button';
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
otherSend.setAttribute('aria-label', 'Send answer');
otherSend.textContent = multi ? 'Send selection' : 'Send';
const submit = () => {
const freeText = otherInput.value.trim();
if (multi) {
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
if (freeText) picked.push(freeText);
if (picked.length) send(picked.join(', '));
} else if (freeText) {
send(freeText);
}
};
otherSend.addEventListener('click', submit);
otherInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
});
other.appendChild(otherInput);
other.appendChild(otherSend);
card.appendChild(other);
chatBox.appendChild(card);
if (renderOptions.scroll !== false) {
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (renderOptions.focus !== false) {
try { card.focus(); } catch (_) {}
}
return card;
}
/** /**
* Add a message to the chat history. * Add a message to the chat history.
*/ */
@@ -1983,6 +2156,11 @@ export function addMessage(role, content, modelName, metadata) {
const box = document.getElementById('chat-history'); const box = document.getElementById('chat-history');
if (!box) { console.error('Chat history element not found'); return; } if (!box) { console.error('Chat history element not found'); return; }
// Loading a later user message means any earlier ask_user card was
// answered. This also removes the live card as soon as a manual reply is
// appended, even when the user did not click one of its buttons.
if (role === 'user') removeAskUserCards(box);
var esc = uiModule.esc; var esc = uiModule.esc;
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
@@ -1990,6 +2168,7 @@ export function addMessage(role, content, modelName, metadata) {
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
const roundTexts = metadata.round_texts || []; const roundTexts = metadata.round_texts || [];
const toolEvents = metadata.tool_events; const toolEvents = metadata.tool_events;
let pendingAskUser = null;
let lastWrap = null; let lastWrap = null;
let firstMsgAi = null; let firstMsgAi = null;
let lastMsgAi = null; let lastMsgAi = null;
@@ -2066,6 +2245,7 @@ export function addMessage(role, content, modelName, metadata) {
box.appendChild(threadWrap); box.appendChild(threadWrap);
} }
for (const ev of roundTools) { for (const ev of roundTools) {
if (ev.ask_user) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null); const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = ''; let outHtml = '';
if (ev.output && ev.output.trim()) { if (ev.output && ev.output.trim()) {
@@ -2129,6 +2309,12 @@ export function addMessage(role, content, modelName, metadata) {
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
} }
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
if (pendingAskUser) {
// Session history is rendered oldest-to-newest. A later user message
// removes this card; if there is none, the pending choice survives a
// refresh. Avoid stealing focus while the history is loading.
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
}
return lastWrap; return lastWrap;
} }
@@ -2461,6 +2647,8 @@ const chatRenderer = {
copyMessageText, copyMessageText,
safeToolScreenshotSrc, safeToolScreenshotSrc,
safeDisplayImageSrc, safeDisplayImageSrc,
removeAskUserCards,
renderAskUserCard,
buildSourcesBox, buildSourcesBox,
buildFindingsBox, buildFindingsBox,
appendReportButton, appendReportButton,
+4 -3
View File
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
import themeModule from '../theme.js'; import themeModule from '../theme.js';
import presetsModule from '../presets.js'; import presetsModule from '../presets.js';
import markdownModule from '../markdown.js'; import markdownModule from '../markdown.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc; var escapeHtml = uiModule.esc;
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
} }
let _exportMenuEl = null; let _exportMenuEl = null;
let _closeExportMenu = () => {};
function _toggleExportMenu(btn) { function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; } if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect(); const r = btn.getBoundingClientRect();
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
} }
document.body.appendChild(m); document.body.appendChild(m);
_exportMenuEl = m; _exportMenuEl = m;
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0); _closeExportMenu = bindMenuDismiss(m, () => {
}
function _closeExportMenu() {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; } if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
}, (ev) => !m.contains(ev.target));
} }
async function _exportCopyMarkdown(_btn) { async function _exportCopyMarkdown(_btn) {
+19 -22
View File
@@ -31,7 +31,7 @@ import {
} from './cookbook.js'; } from './cookbook.js';
import uiModule from './ui.js'; import uiModule from './ui.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { _loadTasks, _tmuxGracefulKill } from './cookbookRunning.js'; import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js'; import { openCookbookDependencies } from './cookbook-diagnosis.js';
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name // Map a serve-backend code (vllm / sglang / llamacpp) → the package name
@@ -1493,36 +1493,34 @@ export function _expandModelRow(row, modelData) {
} }
return; return;
} }
// Detect backend and port now — the pre-launch guard below needs them.
const _qrBackendDetect = _detectBackend(modelData);
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
const _qrPort = _nextAvailablePort();
// ─── Pre-launch: stop the model already serving on this host ─────── // ─── Pre-launch: stop colliding serves on the same port ───────
// Two servers can't share port 8000. Without this, the new launch // Different ports coexist fine (e.g. vLLM on 8000 + Qwen VL on
// silently collided and the user saw no feedback. We surface the // 8001). Only block when the new model's port genuinely collides
// conflict and offer to kill the running one first as the default // with a running serve. (Issue #4507)
// action (it's almost always what the user wants).
try { try {
const _qrHostStr = _envState.remoteHost || ''; const _qrHostStr = _envState.remoteHost || '';
const _activeServes = _loadTasks().filter(t => const _allServes = _loadTasks().filter(t =>
t && t.type === 'serve' t && t.type === 'serve'
&& (t.remoteHost || '') === _qrHostStr && (t.remoteHost || '') === _qrHostStr
&& (t.status === 'running' || t.status === 'ready' || t._serveReady) && (t.status === 'running' || t.status === 'ready' || t._serveReady)
); );
if (_activeServes.length) { const _clashing = _allServes.filter(t => _taskPort(t) === _qrPort);
const _names = _activeServes.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean); if (_clashing.length) {
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
const _ok = await window.styledConfirm?.( const _ok = await window.styledConfirm?.(
`${_names.length} model${_names.length === 1 ? '' : 's'} already serving on ${_qrHostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`, `${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`,
{ confirmText: 'Stop & launch', cancelText: 'Cancel' } { confirmText: 'Stop & launch', cancelText: 'Cancel' }
); );
if (!_ok) return; if (!_ok) return;
// Mark + kill each running serve, then wait briefly for the
// tmux session to actually go down before we kick off the new
// launch. Otherwise vLLM still races against the dying socket.
quickRunBtn.disabled = true; quickRunBtn.disabled = true;
quickRunBtn.textContent = 'Stopping…'; quickRunBtn.textContent = 'Stopping…';
for (const t of _activeServes) { for (const t of _clashing) {
try { try {
// Use that task's own Stop button if it's rendered (handles
// endpoint cleanup, Ollama unload, fade-out). Falls back to
// a direct tmux kill if the Active tab isn't in the DOM yet.
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`); const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop'); const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
if (_stopBtn) { if (_stopBtn) {
@@ -1537,11 +1535,12 @@ export function _expandModelRow(row, modelData) {
} }
} catch (_killErr) { /* best-effort */ } } catch (_killErr) { /* best-effort */ }
} }
// Give the OS a beat to release port 8000.
await new Promise(r => setTimeout(r, 2500)); await new Promise(r => setTimeout(r, 2500));
} }
} catch (_e) { /* best-effort */ } } catch (_e) { /* best-effort */ }
// -- Launch ───────────────────────────────────────────────────
// ─── Pre-launch driver check ───────────────────────────────────── // ─── Pre-launch driver check ─────────────────────────────────────
// vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures // vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures
// surface as system.gpu_error from our hardware probe; "no GPU // surface as system.gpu_error from our hardware probe; "no GPU
@@ -1550,8 +1549,6 @@ export function _expandModelRow(row, modelData) {
// user watches `pip install vllm` finish, then sees a cryptic CUDA // user watches `pip install vllm` finish, then sees a cryptic CUDA
// error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks // error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks
// so they skip this gate.) // so they skip this gate.)
const _qrBackendDetect = _detectBackend(modelData);
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') { if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
const _sys = _hwfitCache?.system || {}; const _sys = _hwfitCache?.system || {};
if (_sys.gpu_error) { if (_sys.gpu_error) {
@@ -1658,7 +1655,7 @@ export function _expandModelRow(row, modelData) {
const host = _envState.remoteHost || ''; const host = _envState.remoteHost || '';
const hostIp = host.includes('@') ? host.split('@').pop() : host; const hostIp = host.includes('@') ? host.split('@').pop() : host;
const port = '8000'; const port = _qrPort;
const detected = _detectBackend(modelData); const detected = _detectBackend(modelData);
const runBackend = detected.backend || 'vllm'; const runBackend = detected.backend || 'vllm';
@@ -1673,7 +1670,7 @@ export function _expandModelRow(row, modelData) {
} else if (runBackend === 'llamacpp') { } else if (runBackend === 'llamacpp') {
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`; const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`; const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} --flash-attn auto`; cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port ${port} -ngl 99 -c ${maxCtx} --flash-attn auto`;
} else { } else {
cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`; cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`;
cmd += ` --tensor-parallel-size ${tp}`; cmd += ` --tensor-parallel-size ${tp}`;
+9 -11
View File
@@ -33,6 +33,9 @@ import {
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, _fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
} from './cookbookServe.js'; } from './cookbookServe.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { topPortalZ } from './toolWindowZOrder.js';
const STORAGE_KEY = 'cookbook-presets'; const STORAGE_KEY = 'cookbook-presets';
const LAST_STATE_KEY = 'cookbook-last-state'; const LAST_STATE_KEY = 'cookbook-last-state';
const SERVE_STATE_KEY = 'cookbook-serve-state'; const SERVE_STATE_KEY = 'cookbook-serve-state';
@@ -1514,7 +1517,7 @@ async function _fetchDependencies() {
// Wire the installed-package menu. // Wire the installed-package menu.
function _showDepMenu(anchor) { function _showDepMenu(anchor) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
const row = anchor.closest('.cookbook-dep-row'); const row = anchor.closest('.cookbook-dep-row');
if (!row) return; if (!row) return;
const pipName = row.dataset.depPip; const pipName = row.dataset.depPip;
@@ -1527,7 +1530,7 @@ async function _fetchDependencies() {
const minW = 150; const minW = 150;
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8); let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
left = Math.max(8, left); left = Math.max(8, left);
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>'; const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
const it = document.createElement('div'); const it = document.createElement('div');
it.className = 'dropdown-item-compact'; it.className = 'dropdown-item-compact';
@@ -1535,7 +1538,7 @@ async function _fetchDependencies() {
it.title = `Update ${pkgName} to the latest version (pip install -U)`; it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => { it.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
await _installDep(pipName, pkgName, isLocalOnly, true, null); await _installDep(pipName, pkgName, isLocalOnly, true, null);
}); });
dropdown.appendChild(it); dropdown.appendChild(it);
@@ -1563,19 +1566,14 @@ async function _fetchDependencies() {
dropdown.appendChild(source); dropdown.appendChild(source);
} }
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) { !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => { list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (document.querySelector('.cookbook-dep-menu')) { if (document.querySelector('.cookbook-dep-menu')) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
return; return;
} }
_showDepMenu(btn); _showDepMenu(btn);
+19
View File
@@ -0,0 +1,19 @@
// Pure port helpers extracted so they're unit-testable without the
// browser-bound rest of cookbookRunning.js (issue #4507 follow-up).
// Read the port out of a serve launch command. Handles --port 8000,
// --port=8000, -p 8000, and -p=8000. Returns '' when none is present.
export function portOf(cmd) {
const s = cmd || '';
const m = s.match(/--port[=\s]+(\d+)/) || s.match(/(?:^|\s)-p[=\s]+(\d+)/);
return m ? m[1] : '';
}
// Lowest free port >= start that isn't in usedPorts (array or Set of
// numbers/strings). Returns a string to match the serve command format.
export function nextFreePort(usedPorts, start = 8000) {
const used = new Set([...usedPorts].map(p => parseInt(p, 10)));
let port = start;
while (used.has(port)) port++;
return String(port);
}
+6 -9
View File
@@ -8,6 +8,7 @@ import uiModule from './ui.js';
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js'; import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
import { registerMenuDismiss } from './escMenuStack.js'; import { registerMenuDismiss } from './escMenuStack.js';
import { computeProgressSignal } from './cookbookProgressSignal.js'; import { computeProgressSignal } from './cookbookProgressSignal.js';
import { portOf, nextFreePort } from './cookbookPorts.js';
// Human-friendly badge label for a task's internal status. Avoids surfacing // Human-friendly badge label for a task's internal status. Avoids surfacing
// the word "error" in the sidebar — a server the user stopped or one that // the word "error" in the sidebar — a server the user stopped or one that
@@ -266,9 +267,7 @@ function _taskHostLabel(task) {
} }
function _taskPort(task) { function _taskPort(task) {
const cmd = task?.payload?._cmd || ''; return portOf(task?.payload?._cmd || '');
const match = cmd.match(/--port\s+(\d+)/);
return match ? match[1] : '';
} }
function _buildCrashReport(task, outputText) { function _buildCrashReport(task, outputText) {
@@ -455,16 +454,14 @@ function _nextAvailablePort() {
const usedPorts = new Set(); const usedPorts = new Set();
tasks.forEach(t => { tasks.forEach(t => {
if (t.type === 'serve' && (t.status === 'running' || t.status === 'queued')) { if (t.type === 'serve' && (t.status === 'running' || t.status === 'queued')) {
const m = t.payload?._cmd?.match(/--port\s+(\d+)/); const p = _taskPort(t);
if (m) usedPorts.add(parseInt(m[1])); if (p) usedPorts.add(parseInt(p));
} }
}); });
presets.forEach(p => { presets.forEach(p => {
if (p.port) usedPorts.add(parseInt(p.port)); if (p.port) usedPorts.add(parseInt(p.port));
}); });
let port = 8000; return nextFreePort(usedPorts);
while (usedPorts.has(port)) port++;
return String(port);
} }
// ── Endpoint cleanup ── // ── Endpoint cleanup ──
@@ -3987,4 +3984,4 @@ export function initRunning(shared) {
} }
// Also export _retryDownload and _nextAvailablePort for use by other modules // Also export _retryDownload and _nextAvailablePort for use by other modules
export { _retryDownload, _nextAvailablePort, _processQueue }; export { _retryDownload, _nextAvailablePort, _processQueue, _taskPort };
+17 -8
View File
@@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js'; import { openCookbookDependencies } from './cookbook-diagnosis.js';
import { _hwfitCache } from './cookbook-hwfit.js'; import { _hwfitCache } from './cookbook-hwfit.js';
import { topPortalZ } from './toolWindowZOrder.js';
// Shared state/functions injected by init() // Shared state/functions injected by init()
let _envState; let _envState;
@@ -1019,7 +1020,7 @@ function _rerenderCachedModels() {
cancelDiv.addEventListener('click', () => { closeDropdown(); }); cancelDiv.addEventListener('click', () => { closeDropdown(); });
dropdown.appendChild(cancelDiv); dropdown.appendChild(cancelDiv);
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`; dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ // Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
// on mobile under the dynamic toolbar). Flip above the button if there's no // on mobile under the dynamic toolbar). Flip above the button if there's no
@@ -2166,7 +2167,7 @@ function _rerenderCachedModels() {
// Cap width/height to the viewport and start hidden — we clamp the final // Cap width/height to the viewport and start hidden — we clamp the final
// position after mount (below) using the menu's real measured size, so it // position after mount (below) using the menu's real measured size, so it
// can't run off-screen on a narrow mobile viewport. // can't run off-screen on a narrow mobile viewport.
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
if (!modelSlots.length) { if (!modelSlots.length) {
const empty = document.createElement('div'); const empty = document.createElement('div');
@@ -2957,17 +2958,25 @@ function _rerenderCachedModels() {
&& ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr) && ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr)
&& (t.status === 'running' || t.status === 'ready' || t._serveReady) && (t.status === 'running' || t.status === 'ready' || t._serveReady)
); );
// Only block when the new model's port genuinely collides with
// a running serve. Different ports coexist fine (issue #4507).
if (_active.length) { if (_active.length) {
const _names = _active.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean); const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || '';
const _clashing = _newPort
? _active.filter(t => _runningMod._taskPort(t) === _newPort)
: _active;
if (_clashing.length) {
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
const _portNote = _newPort ? ` on port ${_newPort}` : '';
const _ok = await window.styledConfirm( const _ok = await window.styledConfirm(
`${_active.length} model${_active.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`, `${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`,
{ title: 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' }, { title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
); );
if (!_ok) { _restoreLaunchBtn(); return; } if (!_ok) { _restoreLaunchBtn(); return; }
// Kill each active serve; prefer the rendered Stop button so // Kill each clashing serve; prefer the rendered Stop button so
// endpoint cleanup + Ollama unload run normally. Fall back to // endpoint cleanup + Ollama unload run normally. Fall back to
// a raw tmux kill when the Active tab isn't in the DOM. // a raw tmux kill when the Active tab isn't in the DOM.
for (const t of _active) { for (const t of _clashing) {
try { try {
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`); const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
const _btn = _el?.querySelector('.cookbook-task-action-stop'); const _btn = _el?.querySelector('.cookbook-task-action-stop');
@@ -2982,9 +2991,9 @@ function _rerenderCachedModels() {
} }
} catch (_killErr) { /* best-effort */ } } catch (_killErr) { /* best-effort */ }
} }
// Give the OS a beat to release port 8000.
await new Promise(r => setTimeout(r, 2500)); await new Promise(r => setTimeout(r, 2500));
} }
}
} catch (_e) { /* best-effort */ } } catch (_e) { /* best-effort */ }
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState); const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
+22 -38
View File
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js'; import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js'; import signatureModule from './signature.js';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let API_BASE = ''; let API_BASE = '';
let isOpen = false; let isOpen = false;
@@ -666,7 +667,7 @@ import * as Modals from './modalManager.js';
overlay.className = 'modal pdf-export-overlay'; overlay.className = 'modal pdf-export-overlay';
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);'; overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
overlay.innerHTML = ` overlay.innerHTML = `
<div class="modal-content" style="width:min(780px,94vw);max-height:86vh;"> <div class="modal-content" style="width:min(780px,94vw);">
<div class="modal-header"> <div class="modal-header">
<h4>Export filled PDF</h4> <h4>Export filled PDF</h4>
<button id="pdf-export-close" class="modal-close" title="Close">×</button> <button id="pdf-export-close" class="modal-close" title="Close">×</button>
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
let _docAiReplyChoiceMenu = null; let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() { function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) { if (_docAiReplyChoiceMenu) {
try { _docAiReplyChoiceMenu.remove(); } catch (_) {} // Tear down through the menu's registered dismiss (drops its outside-click
// listener + Escape-stack entry) rather than orphaning them with a raw
// remove(); the onClose below nulls the ref.
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
_docAiReplyChoiceMenu = null; _docAiReplyChoiceMenu = null;
} }
} }
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
const noteInput = menu.querySelector('[data-note-input]'); const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0); setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation()); menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
const close = bindMenuDismiss(menu, () => {
try { menu.remove(); } catch (_) {}
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
});
menu.addEventListener('click', async (ev) => { menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]'); const choice = ev.target.closest('[data-mode]');
if (!choice) return; if (!choice) return;
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
ev.stopPropagation(); ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast'; const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim(); const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice(); close();
await _aiReply({ mode, noteHint }); await _aiReply({ mode, noteHint });
}); });
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
_closeDocAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
// Esc to close.
const escClose = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
document.removeEventListener('keydown', escClose, true);
_closeDocAiReplyChoice();
}
};
document.addEventListener('keydown', escClose, true);
} }
async function _aiReply(opts = {}) { async function _aiReply(opts = {}) {
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
function showExportMenu(e, anchorRect) { function showExportMenu(e, anchorRect) {
if (e) e.stopPropagation(); if (e) e.stopPropagation();
// Remove existing menu if any // Remove existing menu if any (toggle off) — tear it down through its
// registered dismiss so the outside-click listener + Escape-stack entry go.
const existing = document.getElementById('doc-export-menu'); const existing = document.getElementById('doc-export-menu');
if (existing) { existing.remove(); return; } if (existing) { dismissOrRemove(existing); return; }
// Position from provided rect, clicked element, or fallback to language select // Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect const rect = anchorRect
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'doc-overflow-item'; item.className = 'doc-overflow-item';
item.textContent = opt.label; item.textContent = opt.label;
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); }); item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
menu.appendChild(item); menu.appendChild(item);
if (opt._divider) { if (opt._divider) {
const sep = document.createElement('div'); const sep = document.createElement('div');
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
menu.style.top = 'auto'; menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
} }
const close = (ev) => { // Outside-click AND Escape both route through the central esc-stack via
if (ev && ev.type === 'keydown') { // bindMenuDismiss; onClose owns the actual node removal.
if (ev.key !== 'Escape') return; const close = bindMenuDismiss(menu, () => { menu.remove(); });
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation?.();
} else if (ev && menu.contains(ev.target)) {
return;
}
menu.remove();
document.removeEventListener('click', close);
document.removeEventListener('keydown', close, true);
};
setTimeout(() => document.addEventListener('click', close), 100);
document.addEventListener('keydown', close, true);
} }
function exportAsHtml() { function exportAsHtml() {
+4 -3
View File
@@ -4,6 +4,7 @@
* Extracted from document.js to reduce file size. * Extracted from document.js to reduce file size.
*/ */
import { topPortalZ } from './toolWindowZOrder.js';
import uiModule from './ui.js'; import uiModule from './ui.js';
import sessionModule from './sessions.js'; import sessionModule from './sessions.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
@@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
dd.style.right = (window.innerWidth - rect.right) + 'px'; dd.style.right = (window.innerWidth - rect.right) + 'px';
dd.style.top = (rect.bottom + 2) + 'px'; dd.style.top = (rect.bottom + 2) + 'px';
dd.style.display = 'block'; dd.style.display = 'block';
dd.style.zIndex = '100000'; dd.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => { requestAnimationFrame(() => {
const mr = dd.getBoundingClientRect(); const mr = dd.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 8) { if (mr.bottom > window.innerHeight - 8) {
@@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const rect = menuBtn.getBoundingClientRect(); const rect = menuBtn.getBoundingClientRect();
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
dropdown.dataset.owner = doc.id; dropdown.dataset.owner = doc.id;
dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;'; dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;`;
dropdown.style.top = (rect.bottom + 4) + 'px'; dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = 'auto'; dropdown.style.left = 'auto';
dropdown.style.right = (window.innerWidth - rect.right) + 'px'; dropdown.style.right = (window.innerWidth - rect.right) + 'px';
@@ -1595,7 +1596,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
modal.className = 'modal'; modal.className = 'modal';
modal.id = 'doclib-modal'; modal.id = 'doclib-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);"> <div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);background:var(--bg);">
<div class="modal-header"> <div class="modal-header">
<!-- Header title + icon mirror the currently-active sub-tab (Chats / <!-- Header title + icon mirror the currently-active sub-tab (Chats /
Documents / Research / Archive) so the user sees ONE icon at Documents / Research / Archive) so the user sees ONE icon at
+6 -11
View File
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js'; import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js'; import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount const _acct = () => window.__odysseusActiveEmailAccount
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
} }
function _showEmailMenu(em, anchor, itemEl) { function _showEmailMenu(em, anchor, itemEl) {
document.querySelectorAll('.email-dropdown').forEach(d => d.remove()); document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show'; dropdown.className = 'dropdown email-dropdown show';
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
_showRemindSubmenu(em, dropdown); _showRemindSubmenu(em, dropdown);
return; return;
} }
dropdown.remove(); close();
a.action(); a.action();
}); });
dropdown.appendChild(menuItem); dropdown.appendChild(menuItem);
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
anchor.appendChild(dropdown); anchor.appendChild(dropdown);
const close = (e) => { const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
// ---- Reminder submenu (creates a Note with a reminder for this email) ---- // ---- Reminder submenu (creates a Note with a reminder for this email) ----
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`; item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
item.addEventListener('click', async (e) => { item.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
parentDropdown.remove(); dismissOrRemove(parentDropdown);
await _createReplyReminder(em, p.date); await _createReplyReminder(em, p.date);
}); });
parentDropdown.appendChild(item); parentDropdown.appendChild(item);
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
customItem.innerHTML = '<span>Pick date and time…</span>'; customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', async (e) => { customItem.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
parentDropdown.remove(); dismissOrRemove(parentDropdown);
const tmp = document.createElement('input'); const tmp = document.createElement('input');
tmp.type = 'datetime-local'; tmp.type = 'datetime-local';
const def = new Date(tomorrow); const def = new Date(tomorrow);
+27 -45
View File
@@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
import { folderDisplayName, sortedFolders } from './emailInbox.js'; import { folderDisplayName, sortedFolders } from './emailInbox.js';
import settingsModule from './settings.js'; import settingsModule from './settings.js';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { import {
_esc, _escLinkify, _extractName, _parseTurnMeta, _esc, _escLinkify, _extractName, _parseTurnMeta,
@@ -23,6 +24,7 @@ import {
} from './emailLibrary/signatureFold.js'; } from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js'; import { state } from './emailLibrary/state.js';
import { collapseSidebarToRail } from './modalSnap.js'; import { collapseSidebarToRail } from './modalSnap.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false; let _emailUnreadChipClickWired = false;
@@ -858,7 +860,7 @@ export function openEmailLibrary(opts = {}) {
modal.className = 'modal'; modal.className = 'modal';
modal.id = 'email-lib-modal'; modal.id = 'email-lib-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);"> <div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);background:var(--bg);">
<div class="modal-header"> <div class="modal-header">
<h4> <h4>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
@@ -4866,7 +4868,7 @@ async function _openEmailAsTab(em, folder) {
modal.className = 'modal email-reader-tab-modal'; modal.className = 'modal email-reader-tab-modal';
modal.id = modalId; modal.id = modalId;
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);max-height:85vh;display:flex;flex-direction:column;"> <div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
<div class="modal-header"> <div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;"> <h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
@@ -5101,7 +5103,7 @@ async function _openEmailWindow(em, folder) {
modal.id = winId; modal.id = winId;
modal.style.cssText = 'pointer-events:none;background:transparent;'; modal.style.cssText = 'pointer-events:none;background:transparent;';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content email-window-content" style="width:min(640px, 92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);"> <div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
<div class="modal-header"> <div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;"> <h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
@@ -5499,23 +5501,19 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
// Toggle: if a dropdown for THIS anchor is already open, close it. // Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown'); const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) { if (existing && existing._anchor === anchor) {
existing.remove(); dismissOrRemove(existing);
anchor.classList.remove('reader-more-active');
return; return;
} }
// Otherwise close any other open dropdown (and clear its anchor's active // Otherwise close any other open dropdown (its own teardown clears its
// state) before opening a fresh one. // anchor's active state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(d => { document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
if (d._anchor) d._anchor.classList.remove('reader-more-active');
d.remove();
});
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown'; dropdown.className = 'email-card-dropdown';
dropdown._anchor = anchor; dropdown._anchor = anchor;
anchor.classList.add('reader-more-active'); anchor.classList.add('reader-more-active');
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`; dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`; const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>'; const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
@@ -5721,8 +5719,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_showLibRemindSubmenu(em, dropdown); _showLibRemindSubmenu(em, dropdown);
return; return;
} }
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
a.action(); a.action();
}); });
dropdown.appendChild(item); dropdown.appendChild(item);
@@ -5735,30 +5732,25 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>'; cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { cancelItem.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
}); });
dropdown.appendChild(cancelItem); dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect); _fitEmailDropdown(dropdown, rect);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove(); dropdown.remove();
anchor.classList.remove('reader-more-active'); anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
function _showCardMenu(em, anchor) { function _showCardMenu(em, anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown'; dropdown.className = 'email-card-dropdown';
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`; dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`; const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>'; const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
@@ -5918,8 +5910,7 @@ function _showCardMenu(em, anchor) {
_showLibRemindSubmenu(em, dropdown); _showLibRemindSubmenu(em, dropdown);
return; return;
} }
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
a.action(); a.action();
}); });
dropdown.appendChild(item); dropdown.appendChild(item);
@@ -5932,30 +5923,25 @@ function _showCardMenu(em, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>'; cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { cancelItem.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
anchor.classList.remove('reader-more-active');
}); });
dropdown.appendChild(cancelItem); dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect); _fitEmailDropdown(dropdown, rect);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove(); dropdown.remove();
anchor.classList.remove('reader-more-active'); anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button. // Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
function _showBulkActionsMenu(anchor) { function _showBulkActionsMenu(anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown email-bulk-menu'; dropdown.className = 'email-card-dropdown email-bulk-menu';
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`; dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>'; const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>'; const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
@@ -5968,7 +5954,7 @@ function _showBulkActionsMenu(anchor) {
const it = document.createElement('div'); const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`; it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it); dropdown.appendChild(it);
} }
// Mobile-only Cancel — matches the per-card and sidebar dropdowns. // Mobile-only Cancel — matches the per-card and sidebar dropdowns.
@@ -5978,7 +5964,7 @@ function _showBulkActionsMenu(anchor) {
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`; cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
cancelIt.addEventListener('click', (e) => { cancelIt.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the // Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown. // documents bulk dropdown.
state._selectMode = false; state._selectMode = false;
@@ -5989,13 +5975,9 @@ function _showBulkActionsMenu(anchor) {
dropdown.appendChild(cancelIt); dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect); _fitEmailDropdown(dropdown, rect);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove(); dropdown.remove();
document.removeEventListener('click', close, true); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
function _updateBulkBar() { function _updateBulkBar() {
@@ -6240,7 +6222,7 @@ function _showAiReplyChoice(btn, em, data) {
`max-height:${window.innerHeight - 16}px`, `max-height:${window.innerHeight - 16}px`,
'overflow:auto', 'overflow:auto',
'box-sizing:border-box', 'box-sizing:border-box',
'z-index:10060', `z-index:${topPortalZ()}`,
'display:flex', 'display:flex',
'gap:6px', 'gap:6px',
'padding:6px', 'padding:6px',
+3 -1
View File
@@ -8,6 +8,8 @@
* faces (😂, 👍, 😎) have no text form and are intentionally excluded. * faces (😂, 👍, 😎) have no text form and are intentionally excluded.
*/ */
import { topPortalZ } from './toolWindowZOrder.js';
// Each entry: [char, label, svgPath OR svg] // Each entry: [char, label, svgPath OR svg]
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke) // SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`; const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
@@ -158,7 +160,7 @@ function togglePicker(anchor, target) {
_pickerEl.style.position = 'fixed'; _pickerEl.style.position = 'fixed';
_pickerEl.style.top = (rect.bottom + 4) + 'px'; _pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px'; _pickerEl.style.left = rect.left + 'px';
_pickerEl.style.zIndex = '10000'; _pickerEl.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => { requestAnimationFrame(() => {
const pr = _pickerEl.getBoundingClientRect(); const pr = _pickerEl.getBoundingClientRect();
+7 -11
View File
@@ -6,6 +6,8 @@ import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js'; import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { topPortalZ } from './toolWindowZOrder.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _open = false; let _open = false;
@@ -2514,7 +2516,7 @@ export function openGallery() {
// shares the exact same dropdown style/behaviour. // shares the exact same dropdown style/behaviour.
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions'); const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
function _showGalleryBulkMenu(anchor) { function _showGalleryBulkMenu(anchor) {
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove()); document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it // Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
// matches every other menu in the app. Positioned fixed at the button. // matches every other menu in the app. Positioned fixed at the button.
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
@@ -2523,7 +2525,7 @@ export function openGallery() {
const left = Math.min(rect.left, window.innerWidth - 200); const left = Math.min(rect.left, window.innerWidth - 200);
// Inline the standard dropdown look so it renders correctly even where the // Inline the standard dropdown look so it renders correctly even where the
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile). // `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>'; const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>'; const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'; const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
@@ -2548,17 +2550,11 @@ export function openGallery() {
const it = document.createElement('div'); const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`; it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it); dropdown.appendChild(it);
} }
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
const close = (ev) => { const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
} }
_bulkActionsBtn?.addEventListener('click', (e) => { _bulkActionsBtn?.addEventListener('click', (e) => {
@@ -2567,7 +2563,7 @@ export function openGallery() {
// should close it. The outside-click handler explicitly skips clicks on // should close it. The outside-click handler explicitly skips clicks on
// the anchor, so the button itself has to do its own dismiss. // the anchor, so the button itself has to do its own dismiss.
const existing = document.querySelector('.gallery-bulk-menu'); const existing = document.querySelector('.gallery-bulk-menu');
if (existing) { existing.remove(); return; } if (existing) { dismissOrRemove(existing); return; }
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; } if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
_showGalleryBulkMenu(e.currentTarget); _showGalleryBulkMenu(e.currentTarget);
}); });
+22 -6
View File
@@ -483,6 +483,7 @@ export function processWithThinking(text) {
export function mdToHtml(src, opts) { export function mdToHtml(src, opts) {
const allowedHtmlBlocks = []; const allowedHtmlBlocks = [];
const codeBlocks = []; const codeBlocks = [];
const inlineCodeBlocks = [];
const mermaidBlocks = []; const mermaidBlocks = [];
let s = (src ?? ''); let s = (src ?? '');
@@ -521,6 +522,19 @@ export function mdToHtml(src, opts) {
return placeholder; return placeholder;
}); });
// Extract inline code spans before the link/autolink/HTML passes, mirroring
// the fenced-block handling above. A URL inside `inline code` (e.g.
// `irm http://127.0.0.1:3000/x`) is preceded by a space, so the bare-URL
// autolink matches it, wraps it in an <a> tag, and swaps that for an
// ___ALLOWED_HTML_ placeholder — corrupting the command. The old inline-code
// pass ran after those passes, too late to protect it.
s = s.replace(/`([^`]+?)`/g, (match, code) => {
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___MERMAID_BLOCK_')) return match;
const placeholder = `___INLINE_CODE_${inlineCodeBlocks.length}___`;
inlineCodeBlocks.push(`<code>${escapeHtml(code)}</code>`);
return placeholder;
});
// Repair common ways the agent mangles the entity-anchor convention // Repair common ways the agent mangles the entity-anchor convention
// (`[Name](#kind-<id>)`). Models reliably get the single-link case // (`[Name](#kind-<id>)`). Models reliably get the single-link case
// right but slip into other formats when listing many in a table. // right but slip into other formats when listing many in a table.
@@ -678,12 +692,6 @@ export function mdToHtml(src, opts) {
return html; return html;
}); });
// Inline code (but not placeholders)
s = s.replace(/`([^`]+?)`/g, (match, code) => {
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
return `<code>${code}</code>`;
});
// Horizontal rules (must come before bold/italic to avoid * conflicts) // Horizontal rules (must come before bold/italic to avoid * conflicts)
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>'); s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
@@ -756,6 +764,14 @@ export function mdToHtml(src, opts) {
s = s.replace(`___CODE_BLOCK_${index}___`, block); s = s.replace(`___CODE_BLOCK_${index}___`, block);
}); });
// Restore inline code spans last, so placeholders carried inside restored
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
// as a regex back-reference.
inlineCodeBlocks.forEach((block, index) => {
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
});
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s; return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
} }
+8 -1
View File
@@ -6,6 +6,7 @@ import sessionModule from './sessions.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js'; import { snapModalToZone } from './tileManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
var escapeHtml = uiModule.esc; var escapeHtml = uiModule.esc;
@@ -865,7 +866,13 @@ export function renderMemoryList() {
dropdown.style.top = rect.bottom + 2 + 'px'; dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.right = (window.innerWidth - rect.right) + 'px'; dropdown.style.right = (window.innerWidth - rect.right) + 'px';
dropdown.style.left = 'auto'; dropdown.style.left = 'auto';
dropdown.style.zIndex = '10001'; // Portaled to <body>, so it must outrank the Brain modal it belongs to.
// Tool modals get a monotonically increasing z-index from modalManager's
// bring-to-front counter, which climbs unbounded over a long session —
// once it passed the old hardcoded 10001 the menu rendered behind the
// panel (#4720). topPortalZ() derives the value from the live tool-window
// stack so the menu always sits just above, however high it has climbed.
dropdown.style.zIndex = String(topPortalZ());
dropdown.style.display = 'block'; dropdown.style.display = 'block';
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
// Keep on-screen (mobile): flip above the button if it overflows the // Keep on-screen (mobile): flip above the button if it overflows the
+11 -18
View File
@@ -10,7 +10,8 @@ import { attachColorPicker } from './colorPicker.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js'; import { snapModalToZone } from './tileManager.js';
import { applyEdgeDock, clearDockSide } from './modalSnap.js'; import { applyEdgeDock, clearDockSide } from './modalSnap.js';
import { topToolWindowZ } from './toolWindowZOrder.js'; import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _open = false; let _open = false;
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
function _pickCustomDate() { function _pickCustomDate() {
// Replace the dropdown menu with a small inline picker // Replace the dropdown menu with a small inline picker
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'note-reminder-menu'; menu.className = 'note-reminder-menu';
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
if (typeof dInput.showPicker === 'function') { if (typeof dInput.showPicker === 'function') {
try { dInput.showPicker(); } catch {} try { dInput.showPicker(); } catch {}
} }
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
if (dInput.value) _setReminder(dInput.value); if (dInput.value) _setReminder(dInput.value);
menu.remove(); close();
}); });
setTimeout(() => {
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
document.addEventListener('click', close);
}, 0);
} }
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut. // toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
// ── ⋯ corner menu (Copy + Agent) ─────────────────────────────────── // ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
function _openNoteCornerMenu(btn) { function _openNoteCornerMenu(btn) {
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove()); document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
const id = btn.dataset.noteId; const id = btn.dataset.noteId;
const note = _notes.find(n => n.id === id); const note = _notes.find(n => n.id === id);
if (!note) return; if (!note) return;
@@ -4337,15 +4335,10 @@ function _openNoteCornerMenu(btn) {
const mh = menu.offsetHeight || 96; const mh = menu.offsetHeight || 96;
const below = window.innerHeight - r.bottom; const below = window.innerHeight - r.bottom;
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`; menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`;
const close = (ev) => { const close = bindMenuDismiss(menu, () => { menu.remove(); });
if (ev && menu.contains(ev.target)) return; menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
menu.remove(); menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
document.removeEventListener('click', close, true);
};
setTimeout(() => document.addEventListener('click', close, true), 0);
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
} }
function _positionNoteMenu(menu, btn, width = 196) { function _positionNoteMenu(menu, btn, width = 196) {
@@ -4356,7 +4349,7 @@ function _positionNoteMenu(menu, btn, width = 196) {
const mh = menu.offsetHeight || 112; const mh = menu.offsetHeight || 112;
const below = window.innerHeight - r.bottom; const below = window.innerHeight - r.bottom;
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`; menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
const close = (ev) => { const close = (ev) => {
if (ev && menu.contains(ev.target)) return; if (ev && menu.contains(ev.target)) return;
menu.remove(); menu.remove();
+12 -3
View File
@@ -133,11 +133,20 @@ export function providerLabel(endpointUrl) {
try { try {
host = new URL(endpointUrl).hostname; host = new URL(endpointUrl).hostname;
} catch (_) { } catch (_) {
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort. // Not a full URL (e.g. bare host[:port]) — strip scheme/path best-effort.
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0]; const stripped = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0];
const colonIdx = stripped.lastIndexOf(":");
host = colonIdx >= 0 ? stripped.slice(0, colonIdx) : stripped;
} }
if (!host) return null; if (!host) return null;
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) { const isLoopback = /^(localhost|127\.|0\.0\.0\.0|::1)/.test(host);
if (isLoopback) {
// Don't name the serving tool from the port — it isn't authoritative
// (vLLM/SGLang/llama.cpp share 8000/8080). Discovery identifies the tool by
// probing /props and stores the result as the endpoint's name instead.
return "Local";
}
if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
return "Local"; return "Local";
} }
for (const [re, label] of _ENDPOINT_LABELS) { for (const [re, label] of _ENDPOINT_LABELS) {
+1
View File
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
} }
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
if (item.querySelector('.session-rename-input')) return;
e.preventDefault(); e.preventDefault();
const sid = item.dataset.sessionId; const sid = item.dataset.sessionId;
const s = sessions.find(x => x.id === sid); const s = sessions.find(x => x.id === sid);
+16 -9
View File
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js'; import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js'; import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js'; import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
let initialized = false; let initialized = false;
let modalEl = null; let modalEl = null;
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
if (lbl) lbl.textContent = text; if (lbl) lbl.textContent = text;
if (ico) ico.innerHTML = _apiIconFor(k); if (ico) ico.innerHTML = _apiIconFor(k);
}; };
const _close = () => { menu.style.display = 'none'; }; // Menu is reused (hidden, not recreated). close() hides it and tears down
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _open).
let _close = () => { menu.style.display = 'none'; };
const _open = () => { const _open = () => {
menu.style.display = 'block'; menu.style.display = 'block';
const tRect = trig.getBoundingClientRect(); const tRect = trig.getBoundingClientRect();
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
const above = tRect.top; const above = tRect.top;
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; } if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; } else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } }; _close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
}; };
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); }); trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => { menu.querySelectorAll('.ufapi-option').forEach(btn => {
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
if (labelEl) labelEl.textContent = lbl; if (labelEl) labelEl.textContent = lbl;
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo; if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
}; };
const _closeMenu = () => { menu.style.display = 'none'; }; // Menu is reused (hidden, not recreated). _closeMenu hides it and tears
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _openMenu).
let _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => { const _openMenu = () => {
menu.style.display = 'block'; menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger. // Drop-up when there's not enough room below the trigger.
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
} else { } else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
} }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; _closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
}; };
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); }); trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => { menu.querySelectorAll('.ufp-option').forEach(btn => {
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
addBtn.parentElement.style.position = 'relative'; addBtn.parentElement.style.position = 'relative';
addBtn.parentElement.classList.add('uf-add-anchor'); addBtn.parentElement.classList.add('uf-add-anchor');
} }
// Menu is created per open and removed on close. _closeMenu routes through
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
// listener + Escape-stack entry are torn down alongside the node removal.
let _menuEl = null; let _menuEl = null;
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } }; let _closeMenu = () => {};
addBtn.addEventListener('click', (e) => { addBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (_menuEl) { _closeMenu(); return; } if (_menuEl) { _closeMenu(); return; }
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
showForm(k, 'new'); showForm(k, 'new');
}); });
}); });
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; _closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
}); });
} }
+12 -7
View File
@@ -7,6 +7,8 @@
import uiModule from './ui.js'; import uiModule from './ui.js';
import * as spinnerModule from './spinner.js'; import * as spinnerModule from './spinner.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { topPortalZ } from './toolWindowZOrder.js';
const API = window.location.origin; const API = window.location.origin;
let skills = []; let skills = [];
@@ -391,14 +393,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
// Kebab dropdown for a collapsed skill card — same actions + icons as the // Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete). // expanded footer (Publish/Unpublish · Edit · Delete).
function _openSkillMenu(btn, card, sk, name, isPublished) { function _openSkillMenu(btn, card, sk, name, isPublished) {
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove()); document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'skill-kebab-menu'; menu.className = 'skill-kebab-menu';
const mk = (paths, label, opts, onClick) => { const mk = (paths, label, opts, onClick) => {
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : ''); item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`; item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); }); item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
menu.appendChild(item); menu.appendChild(item);
}; };
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft')); if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
@@ -410,7 +412,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>'; selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
selItem.addEventListener('click', (e) => { selItem.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
menu.remove(); close();
if (!_selectMode) _enterSelectMode(); if (!_selectMode) _enterSelectMode();
_selectedNames.add(name); _selectedNames.add(name);
renderSkillsList(); renderSkillsList();
@@ -432,10 +434,14 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
const cancelItem = document.createElement('button'); const cancelItem = document.createElement('button');
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile'; cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>'; cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); }); cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
menu.appendChild(cancelItem); menu.appendChild(cancelItem);
document.body.appendChild(menu); document.body.appendChild(menu);
// Override the CSS z-index (100002) with a value derived from the live
// tool-window stack so the kebab menu stays above its modal even after the
// bring-to-front counter climbs past the static value (#4720).
menu.style.zIndex = String(topPortalZ());
const r = btn.getBoundingClientRect(); const r = btn.getBoundingClientRect();
menu.style.top = (r.bottom + 4) + 'px'; menu.style.top = (r.bottom + 4) + 'px';
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px'; menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
@@ -453,8 +459,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px'; menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
menu.style.overflowY = 'auto'; menu.style.overflowY = 'auto';
} }
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } }; const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
setTimeout(() => document.addEventListener('click', close, true), 0);
} }
// Cards for the agent's built-in tool capabilities (from // Cards for the agent's built-in tool capabilities (from
@@ -1802,7 +1807,7 @@ async function _showSkillSource(name) {
wrap.className = 'modal'; wrap.className = 'modal';
wrap.style.display = 'block'; wrap.style.display = 'block';
wrap.innerHTML = ` wrap.innerHTML = `
<div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column"> <div class="modal-content" style="max-width:760px;display:flex;flex-direction:column">
<div class="modal-header"> <div class="modal-header">
<h4>SKILL.md <code>${esc(name)}</code></h4> <h4>SKILL.md <code>${esc(name)}</code></h4>
<span style="flex:1"></span> <span style="flex:1"></span>
+12
View File
@@ -101,6 +101,8 @@ function _setupProviderFromInput(input) {
xai: 'xai', xai: 'xai',
grok: 'xai', grok: 'xai',
nvidia: 'nvidia', nvidia: 'nvidia',
opencodezen: 'opencode-zen',
opencodego: 'opencode-go',
}; };
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null; return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
} }
@@ -129,6 +131,8 @@ function _extractSetupProviderCredential(input) {
['google', 'gemini'], ['gemini', 'gemini'], ['google', 'gemini'], ['gemini', 'gemini'],
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'], ['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
['nvidia', 'nvidia'], ['nvidia', 'nvidia'],
['opencode zen', 'opencode-zen'], ['opencode-zen', 'opencode-zen'],
['opencode go', 'opencode-go'], ['opencode-go', 'opencode-go'],
]; ];
for (const [alias, key] of providerAliases) { for (const [alias, key] of providerAliases) {
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i'); const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
@@ -204,6 +208,8 @@ function _showSetupEndpointChoices() {
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' + '<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' +
'<div style="margin-top:4px;">or</div>' + '<div style="margin-top:4px;">or</div>' +
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' + '<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' +
'<div style="margin-top:4px;">or llama.cpp (llama-server):</div>' +
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:8080/v1</code></pre>' +
'</div>' + '</div>' +
'<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' + '<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' +
'<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' + '<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' +
@@ -234,6 +240,12 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
text: 'http://llm-host.local:8000/v1', text: 'http://llm-host.local:8000/v1',
copyText: 'http://llm-host.local:8000/v1', copyText: 'http://llm-host.local:8000/v1',
}, },
{ kind: 'p', text: 'or llama.cpp (llama-server):' },
{
kind: 'code',
text: 'http://localhost:8080/v1',
copyText: 'http://localhost:8080/v1',
},
{ kind: 'heading', html: SETUP_API_ICON + 'API setup' }, { kind: 'heading', html: SETUP_API_ICON + 'API setup' },
{ kind: 'p', text: 'Paste provider name then API key (example):' }, { kind: 'p', text: 'Paste provider name then API key (example):' },
{ {
+1
View File
@@ -24,6 +24,7 @@ export const KEYS = {
SECTION_ORDER: 'sidebar-section-order', SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab', ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density', DENSITY: 'odysseus-density',
UI_SCALE: 'odysseus-ui-scale',
WORKSPACE: 'odysseus-workspace' WORKSPACE: 'odysseus-workspace'
}; };
+14 -11
View File
@@ -6,8 +6,10 @@ import uiModule from './ui.js';
import markdownModule from './markdown.js'; import markdownModule from './markdown.js';
import * as spinnerModule from './spinner.js'; import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js'; import { makeWindowDraggable } from './windowDrag.js';
import { topPortalZ } from './toolWindowZOrder.js';
import { sortModelIds } from './modelSort.js'; import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js'; import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
let _open = false; let _open = false;
@@ -899,10 +901,10 @@ function _attachTaskLongPress(card, menuBtn) {
function _showTaskDropdown(anchor, items) { function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown // Remove any existing dropdown
document.querySelectorAll('.task-dropdown').forEach(d => d.remove()); document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
const dd = document.createElement('div'); const dd = document.createElement('div');
dd.className = 'task-dropdown'; dd.className = 'task-dropdown';
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;'; dd.style.cssText = 'position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
items.forEach(item => { items.forEach(item => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;'; btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;';
@@ -914,10 +916,14 @@ function _showTaskDropdown(anchor, items) {
} }
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); }); btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
dd.appendChild(btn); dd.appendChild(btn);
}); });
document.body.appendChild(dd); document.body.appendChild(dd);
// Sit above the currently-raised tool modal at any stack depth (#4720): the
// modal bring-to-front counter climbs unbounded, so a hardcoded z eventually
// loses. topPortalZ() derives the value from the live tool-window stack.
dd.style.zIndex = String(topPortalZ());
const rect = anchor.getBoundingClientRect(); const rect = anchor.getBoundingClientRect();
let top = rect.bottom + 4; let top = rect.bottom + 4;
let left = rect.right - dd.offsetWidth; let left = rect.right - dd.offsetWidth;
@@ -926,16 +932,13 @@ function _showTaskDropdown(anchor, items) {
dd.style.top = top + 'px'; dd.style.top = top + 'px';
dd.style.left = left + 'px'; dd.style.left = left + 'px';
const openedAt = performance.now(); const openedAt = performance.now();
const close = (e) => { const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
// Ignore any clicks that occur within 250ms of the open (covers touch // Ignore any clicks that occur within 250ms of the open (covers touch
// "ghost click" duplicates that were firing right after pointerup and // "ghost click" duplicates that were firing right after pointerup and
// removing the dropdown before the user could see it). // removing the dropdown before the user could see it) — treat as inside.
if (performance.now() - openedAt < 250) return; if (performance.now() - openedAt < 250) return false;
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } return !dd.contains(ev.target);
}; });
// requestAnimationFrame so the listener is registered AFTER the current
// pointer/click event cycle has finished bubbling.
requestAnimationFrame(() => document.addEventListener('click', close));
} }
// ---- Presets ---- // ---- Presets ----
+27
View File
@@ -39,6 +39,7 @@ const FONT_MAP = {
mono: "'Fira Code', monospace", mono: "'Fira Code', monospace",
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif", sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
serif: "Georgia, 'Times New Roman', serif", serif: "Georgia, 'Times New Roman', serif",
opendyslexic: "'OpenDyslexic', sans-serif",
}; };
const DEFAULT_FONT = 'mono'; const DEFAULT_FONT = 'mono';
const DEFAULT_DENSITY = 'comfortable'; const DEFAULT_DENSITY = 'comfortable';
@@ -387,6 +388,20 @@ export function applyFontDensity(font, density) {
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d); if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
} }
// UI text-size scale (accessibility). Global and independent of the active
// theme, so the chosen size persists across theme switches. Stored as a plain
// percentage string ('100' | '110' | '125' | '150').
const UI_SCALE_KEY = 'odysseus-ui-scale';
const DEFAULT_UI_SCALE = '100';
export function applyUiScale(scale) {
const s = scale || DEFAULT_UI_SCALE;
// Only one non-default scale ('125'). Remove any legacy classes too so an
// older stored value can't leave a stale zoom applied.
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
if (s === '125') document.documentElement.classList.add('ui-scale-125');
}
const _BG_CLASSES = ['bg-pattern-dots', const _BG_CLASSES = ['bg-pattern-dots',
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations', 'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
'bg-pattern-perlin-flow', 'bg-pattern-perlin-flow',
@@ -1133,6 +1148,18 @@ export function initThemeUI() {
const s = getSaved(); if (s) _saveFull(s.name, s.colors); const s = getSaved(); if (s) _saveFull(s.name, s.colors);
}); });
} }
const textSizeSelect = document.getElementById('theme-text-size-select');
if (textSizeSelect) {
const nts = textSizeSelect.cloneNode(true); textSizeSelect.parentNode.replaceChild(nts, textSizeSelect);
let initScale = DEFAULT_UI_SCALE;
try { initScale = localStorage.getItem(UI_SCALE_KEY) || DEFAULT_UI_SCALE; } catch (e) {}
nts.value = initScale;
applyUiScale(initScale);
nts.addEventListener('change', () => {
applyUiScale(nts.value);
try { localStorage.setItem(UI_SCALE_KEY, nts.value); } catch (e) {}
});
}
if (patternSelect) { if (patternSelect) {
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect); const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
np.value = _initPattern; np.value = _initPattern;
+17
View File
@@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) {
if (Number.isFinite(currentZ) && currentZ > top) return currentZ; if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
return top + 1; return top + 1;
} }
// Dock chips pinned by the minimized-dock drag interactions reach z 10030
// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled
// dropdown has to clear those too, not just the open tool-window stack, so this
// floor keeps it above a chip even when no modal is currently raised.
const DOCK_OVERLAY_FLOOR = 10030;
// The z a body-portaled dropdown/menu needs so it always sits just above every
// open tool window (and the dock chips) right now. Tool modals get a
// monotonically increasing z from the bring-to-front counter (modalManager),
// which climbs unbounded over a long session — so the hardcoded `z-index: 10001`
// these dropdowns historically used eventually rendered them BEHIND their own
// modal (#4720). Derive the value from the live stack instead, sharing the same
// single source of truth as nextToolWindowZ().
export function topPortalZ(options = {}) {
return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1;
}
+45 -1
View File
@@ -114,6 +114,10 @@ body {
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Code block baseline */ /* Code block baseline */
pre, code, .hljs { pre, code, .hljs {
font-size: 0.95em; font-size: 0.95em;
@@ -158,6 +162,39 @@ html {
:root.density-spacious .list-item { padding: 8px 12px; } :root.density-spacious .list-item { padding: 8px 12px; }
:root.density-spacious .sidebar .section { padding: 0; } :root.density-spacious .sidebar .section { padding: 0; }
/* UI text-size scale (accessibility)
Density only changes the root font-size, which can't move the many
hard-coded px sizes. `zoom` scales the whole UI uniformly (px text
included) while keeping layout intact, unlike `transform: scale`. */
:root.ui-scale-125 { zoom: 1.25; }
/* `zoom` makes the 100dvh shell render taller than the real viewport, which
pushes the bottom-pinned sidebar account/settings row below the fold (and
body's overflow:hidden then clips it). Shrink the shell by the same factor
so it fits the viewport exactly. */
:root.ui-scale-125 body { height: calc(100dvh / 1.25); }
/* Modals/panels under the 1.25x scale: zoom renders a centred, viewport-sized
panel ~1.25x taller, pushing its draggable header + close button off-screen
(a catch-22 you can't reach the control to turn the size back down). Divide
each max-height by the same factor to keep the original on-screen footprint.
Desktop only the mobile `!important` full-sheet rules win on small screens
and stay top-anchored, so their headers are already visible. */
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
/* Cookbook is the one modal that set its height inline (94vh), which beat the
.modal-content compensation above and overflowed the viewport at 1.25x
(header + close button pushed off-screen). Own its height here so the same
zoom compensation applies. */
#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; }
:root.ui-scale-125 #cookbook-modal .modal-content { height: calc(94dvh / 1.25); max-height: calc(94dvh / 1.25); }
/* PDF export modal also set its height inline (86vh) at v1.0; that inline cap
beat the .modal-content compensation above and shifted ~1vh at Default when
removed. Own its height here so Default is byte-for-byte 86vh and the same
1.25x compensation applies. */
.pdf-export-overlay .modal-content { max-height: 86vh; }
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
/* ── Background Patterns ── */ /* ── Background Patterns ── */
:root { --bg-effect-intensity: 1; } :root { --bg-effect-intensity: 1; }
@@ -8627,6 +8664,12 @@ button.hamburger {
/* Hide thinking sections globally via settings toggle */ /* Hide thinking sections globally via settings toggle */
body.hide-thinking .thinking-section { display: none !important; } body.hide-thinking .thinking-section { display: none !important; }
/* Widen chat area via settings toggle */
body.fullwidth-chat .chat-history {
padding-left: 0 !important;
padding-right: 12px !important;
}
/* Thinking process styles — colors follow theme accent */ /* Thinking process styles — colors follow theme accent */
.msg .body .stream-content { .msg .body .stream-content {
width: 100%; width: 100%;
@@ -16917,7 +16960,8 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
/* Kebab dropdown */ /* Kebab dropdown */
.skill-kebab-menu { .skill-kebab-menu {
position: fixed; position: fixed;
z-index: 100002; /* z-index is set inline via topPortalZ() at open time (#4720); a static
value here loses once the modal bring-to-front counter climbs past it. */
min-width: 150px; min-width: 150px;
padding: 4px; padding: 4px;
background: var(--panel, var(--bg)); background: var(--panel, var(--bg));
+69
View File
@@ -0,0 +1,69 @@
"""Registry wiring for the config/integration admin tools (#3629).
manage_endpoints/mcp/webhooks/tokens/settings moved from tool_implementations
into agent_tools.admin_tools. These pin the registration + the single
owner-threading adapter factory, without touching the DB (the do_* impls
themselves are exercised by their own suites).
"""
import asyncio
from src.agent_tools import TOOL_HANDLERS
from src.agent_tools.admin_tools import (
ADMIN_TOOL_HANDLERS, _owner_adapter,
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
do_manage_tokens, do_manage_settings,
)
_NAMES = ["manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "manage_settings"]
def test_all_registered_in_tool_handlers():
for n in _NAMES:
assert n in TOOL_HANDLERS, f"{n} missing from TOOL_HANDLERS"
assert n in ADMIN_TOOL_HANDLERS
def test_re_exported_from_agent_tools():
# Back-compat: importers that used `from src.agent_tools import do_manage_*`
# keep working after the move.
from src.agent_tools import ( # noqa: F401
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
do_manage_tokens, do_manage_settings,
)
def test_owner_adapter_threads_owner_from_ctx():
seen = {}
async def _spy(content, owner):
seen["content"] = content
seen["owner"] = owner
return {"response": "ok", "exit_code": 0}
handler = _owner_adapter(_spy)
res = asyncio.run(handler('{"action":"list"}', {"owner": "alice", "session_id": "s1"}))
assert res["exit_code"] == 0
assert seen == {"content": '{"action":"list"}', "owner": "alice"}
def test_owner_adapter_defaults_owner_to_none():
captured = {}
async def _spy(content, owner):
captured["owner"] = owner
return {"exit_code": 0}
asyncio.run(_owner_adapter(_spy)("{}", {})) # ctx without owner
assert captured["owner"] is None
def test_parse_tool_args_lives_in_tool_utils_single_source():
# The helper was de-duplicated into tool_utils; admin_tools imports it
# from there rather than carrying its own copy.
from src.tool_utils import _parse_tool_args
from src.agent_tools import admin_tools, document_tools
assert admin_tools._parse_tool_args is _parse_tool_args
assert document_tools._parse_tool_args is _parse_tool_args
assert _parse_tool_args('{"action":"add"}') == {"action": "add"}
# body-envelope unwrap still works
assert _parse_tool_args('{"body":{"action":"x"}}') == {"action": "x"}
+79
View File
@@ -0,0 +1,79 @@
"""Test that APIKeyManager.save() uses atomic write to prevent data loss."""
import os
import json
import pytest
from unittest.mock import patch, mock_open
from src.api_key_manager import APIKeyManager
def test_save_creates_atomic_tmp_file(tmp_path):
"""Verify save() writes to a temp file and replaces atomically."""
mgr = APIKeyManager(str(tmp_path))
mgr.save("openai", "sk-test")
# The final file should exist with the correct content
assert os.path.exists(mgr.api_keys_file)
with open(mgr.api_keys_file, "r", encoding="utf-8") as f:
keys = json.load(f)
assert "openai" in keys
# The temp file should NOT remain after successful save
tmp_file = mgr.api_keys_file + ".tmp"
assert not os.path.exists(tmp_file)
def test_save_preserves_existing_keys_atomically(tmp_path):
"""Verify atomic save doesn't corrupt other providers' keys."""
mgr = APIKeyManager(str(tmp_path))
mgr.save("openai", "sk-openai")
mgr.save("anthropic", "sk-anthropic")
loaded = mgr.load()
assert loaded["openai"] == "sk-openai"
assert loaded["anthropic"] == "sk-anthropic"
def test_save_preserves_original_on_write_failure(tmp_path):
"""If the temp file write fails, the original keys file must survive intact."""
mgr = APIKeyManager(str(tmp_path))
mgr.save("openai", "sk-original")
# Now attempt a save that will fail during json.dump
with patch("builtins.open", side_effect=OSError("disk full")):
with pytest.raises(OSError, match="disk full"):
mgr.save("anthropic", "sk-new")
# Original file must still be intact with the original key
loaded = mgr.load()
assert loaded == {"openai": "sk-original"}
assert "anthropic" not in loaded
def test_save_cleans_up_tmp_on_failure(tmp_path):
"""Temp file should be removed if the write fails."""
mgr = APIKeyManager(str(tmp_path))
mgr.save("openai", "sk-original")
tmp_file = mgr.api_keys_file + ".tmp"
# Force a failure after the temp file is opened
original_open = open
def failing_open(*args, **kwargs):
f = original_open(*args, **kwargs)
if args and isinstance(args[0], str) and args[0].endswith(".tmp"):
# Close the file then raise
f.close()
raise OSError("simulated write failure")
return f
with patch("builtins.open", side_effect=failing_open):
with pytest.raises(OSError):
mgr.save("anthropic", "sk-new")
# Temp file should be cleaned up
assert not os.path.exists(tmp_file)
# Original should be intact
loaded = mgr.load()
assert loaded == {"openai": "sk-original"}
+97
View File
@@ -0,0 +1,97 @@
"""Regression coverage for durable ``ask_user`` choice cards.
The live event must arrive after ``tool_output`` so the settled tool trace
cannot cover/push away the card. The same payload must be persisted inside
``tool_events`` so chat history can reconstruct it after a reload.
"""
import asyncio
import json
from pathlib import Path
import src.agent_loop as agent_loop
ROOT = Path(__file__).resolve().parents[1]
def _collect(gen):
async def _run():
return [chunk async for chunk in gen]
return asyncio.run(_run())
def _events(chunks):
events = []
for chunk in chunks:
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
events.append(json.loads(chunk[6:]))
return events
def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
payload = {
"question": "¿Qué proyecto prefieres?",
"options": [
{"label": "Análisis de reseñas"},
{"label": "Clasificación temática"},
],
"multi": False,
}
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
async def fake_stream(_candidates, messages, **kwargs):
call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)}
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
yield "data: [DONE]\n\n"
async def fake_execute(block, *args, **kwargs):
parsed = json.loads(block.content)
return (
"ask_user",
{
"ask_user": parsed,
"output": "Awaiting their selection.",
"exit_code": 0,
},
)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False)
chunks = _collect(
agent_loop.stream_agent_loop(
"https://api.openai.com/v1",
"gpt-4o",
[{"role": "user", "content": "Ayúdame a elegir un proyecto."}],
relevant_tools={"ask_user"},
_is_teacher_run=True,
)
)
events = _events(chunks)
tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output")
ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user")
assert tool_output_index < ask_user_index
tool_output = events[tool_output_index]
assert tool_output["ask_user"] == payload
assert "¿Qué proyecto prefieres?" in tool_output["command"]
assert "\\u00" not in tool_output["command"]
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
assert metrics["tool_events"][0]["ask_user"] == payload
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
assert "export function renderAskUserCard" in renderer
assert "renderAskUserCard(pendingAskUser" in renderer
assert "if (role === 'user') removeAskUserCards(box)" in renderer
+13
View File
@@ -85,6 +85,19 @@ def test_serializer_round_trips_structured_args():
assert json.loads(block.content) == args assert json.loads(block.content) == args
def test_serializer_keeps_unicode_readable_for_tool_trace():
from src.tool_schemas import function_call_to_tool_block
args = {
"question": "¿Qué proyecto prefieres?",
"options": [{"label": "Reseñas"}, {"label": "Clasificación"}],
}
block = function_call_to_tool_block("ask_user", json.dumps(args, ensure_ascii=False))
assert "¿Qué proyecto prefieres?" in block.content
assert "Reseñas" in block.content
assert "\\u00" not in block.content
def test_registered_everywhere(): def test_registered_everywhere():
# TOOL_TAGS gate (serializer rejects unknown tools) # TOOL_TAGS gate (serializer rejects unknown tools)
assert "ask_user" in TOOL_TAGS assert "ask_user" in TOOL_TAGS
+280
View File
@@ -0,0 +1,280 @@
"""Regression tests for auth-disabled document access (PR #4623).
Validates that the _auth_disabled() bypass in _verify_doc_owner and
list_documents restores single-user / no-auth mode WITHOUT weakening the
authenticated path. Three pinned directions:
1. AUTH_DISABLED + None user -> list_documents + doc read SUCCEEDS
(the bug being fixed).
2. AUTH_ENABLED + None user -> still 403.
3. AUTH_ENABLED + wrong owner -> _verify_doc_owner still raises 404/403.
Route handlers are called directly (same pattern as
test_document_session_owner_scope.py) so coverage lands on the real
closures without spinning up middleware.
"""
import tempfile
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb
import routes.document_routes as droutes
from core.database import Document
from core.database import Session as DbSession
from routes.document_helpers import _verify_doc_owner, _owner_session_filter
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
# ------------------------------------------------------------------ helpers
def _req(user=None):
"""Build a minimal fake Request whose state.current_user returns *user*."""
return SimpleNamespace(state=SimpleNamespace(current_user=user))
def _endpoint(method, path):
"""Resolve a route endpoint from the document router."""
router = droutes.setup_document_routes(MagicMock(), None)
for route in router.routes:
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
return route.endpoint
raise RuntimeError(f"{method} {path} not found")
def _bind_test_db():
previous = droutes.SessionLocal
droutes.SessionLocal = _TS
return previous
def _seed(owner="alice"):
"""Create one session + one owned document. Returns (session_id, doc_id)."""
session_id = f"{owner}-" + uuid.uuid4().hex[:8]
doc_id = str(uuid.uuid4())
db = _TS()
try:
db.add(DbSession(
id=session_id, owner=owner, name=owner,
model="m", endpoint_url="http://x",
))
db.add(Document(
id=doc_id,
session_id=session_id,
title=f"{owner} doc",
language="markdown",
current_content=f"{owner} body",
version_count=1,
is_active=True,
owner=owner,
))
db.commit()
return session_id, doc_id
finally:
db.close()
# ------------------------------------------------------ 1. auth DISABLED +
# None user -> succeeds
@pytest.mark.asyncio
async def test_list_documents_allows_none_user_when_auth_disabled(monkeypatch):
"""AUTH_ENABLED=false + user=None must NOT raise 403 on list_documents."""
monkeypatch.setenv("AUTH_ENABLED", "false")
previous = _bind_test_db()
try:
list_docs = _endpoint("GET", "/api/documents/{session_id}")
session_id, doc_id = _seed()
# Must succeed — this is the bug fix.
rows = await list_docs(_req(None), session_id)
ids = [row["id"] for row in rows]
assert doc_id in ids, "own doc must be visible in auth-disabled mode"
finally:
droutes.SessionLocal = previous
@pytest.mark.asyncio
async def test_get_document_allows_none_user_when_auth_disabled(monkeypatch):
"""AUTH_ENABLED=false + user=None must NOT raise 403 on get_document."""
monkeypatch.setenv("AUTH_ENABLED", "false")
previous = _bind_test_db()
try:
get_doc = _endpoint("GET", "/api/document/{doc_id}")
_session_id, doc_id = _seed()
# Must succeed — _verify_doc_owner bypasses when auth is disabled.
result = await get_doc(_req(None), doc_id)
assert result["id"] == doc_id
finally:
droutes.SessionLocal = previous
def test_verify_doc_owner_allows_none_user_when_auth_disabled(monkeypatch):
"""_verify_doc_owner with user=None + AUTH_ENABLED=false must pass."""
monkeypatch.setenv("AUTH_ENABLED", "false")
_session_id, doc_id = _seed()
db = _TS()
try:
doc = db.query(Document).filter(Document.id == doc_id).first()
# Must NOT raise — the bypass allows single-user access.
_verify_doc_owner(db, doc, None)
finally:
db.close()
def test_owner_session_filter_noops_for_none_user_when_auth_disabled(monkeypatch):
"""_owner_session_filter with user=None + AUTH_ENABLED=false returns query unchanged."""
monkeypatch.setenv("AUTH_ENABLED", "false")
_session_id, doc_id = _seed()
db = _TS()
try:
q = db.query(Document).filter(Document.id == doc_id)
result = _owner_session_filter(q, None)
# Filter was a no-op; document is still reachable.
assert result.first().id == doc_id
finally:
db.close()
# ------------------------------------------------------ 2. auth ENABLED +
# None user -> 403
@pytest.mark.asyncio
async def test_list_documents_rejects_none_user_when_auth_enabled(monkeypatch):
"""AUTH_ENABLED=true (default) + user=None must raise 403."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
list_docs = _endpoint("GET", "/api/documents/{session_id}")
session_id, _doc_id = _seed()
with pytest.raises(HTTPException) as exc:
await list_docs(_req(None), session_id)
assert exc.value.status_code == 403
finally:
droutes.SessionLocal = previous
@pytest.mark.asyncio
async def test_get_document_rejects_none_user_when_auth_enabled(monkeypatch):
"""AUTH_ENABLED=true (default) + user=None must raise 403 via _verify_doc_owner."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
get_doc = _endpoint("GET", "/api/document/{doc_id}")
_session_id, doc_id = _seed()
with pytest.raises(HTTPException) as exc:
await get_doc(_req(None), doc_id)
assert exc.value.status_code == 403
finally:
droutes.SessionLocal = previous
def test_verify_doc_owner_rejects_none_user_when_auth_enabled(monkeypatch):
"""_verify_doc_owner with user=None + AUTH_ENABLED=true must raise 403."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
_session_id, doc_id = _seed()
db = _TS()
try:
doc = db.query(Document).filter(Document.id == doc_id).first()
with pytest.raises(HTTPException) as exc:
_verify_doc_owner(db, doc, None)
assert exc.value.status_code == 403
finally:
db.close()
# ------------------------------------------ 3. auth ENABLED + wrong owner ->
# _verify_doc_owner raises 404
def test_verify_doc_owner_rejects_wrong_owner_when_auth_enabled(monkeypatch):
"""_verify_doc_owner with a mismatched owner must raise 404 (not 403).
This confirms the authenticated path is untouched by the no-auth bypass."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
session_id, doc_id = _seed(owner="alice")
db = _TS()
try:
doc = db.query(Document).filter(Document.id == doc_id).first()
with pytest.raises(HTTPException) as exc:
_verify_doc_owner(db, doc, "bob") # bob != alice
assert exc.value.status_code == 404
finally:
db.close()
@pytest.mark.asyncio
async def test_get_document_rejects_wrong_owner(monkeypatch):
"""GET /api/document/{doc_id} with wrong authenticated user -> 404."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
get_doc = _endpoint("GET", "/api/document/{doc_id}")
_session_id, doc_id = _seed(owner="alice")
with pytest.raises(HTTPException) as exc:
await get_doc(_req("bob"), doc_id)
assert exc.value.status_code == 404
finally:
droutes.SessionLocal = previous
@pytest.mark.asyncio
async def test_list_documents_hides_wrong_owner_docs(monkeypatch):
"""list_documents for alice must not show bob's documents."""
monkeypatch.delenv("AUTH_ENABLED", raising=False)
previous = _bind_test_db()
try:
list_docs = _endpoint("GET", "/api/documents/{session_id}")
# Seed alice's session with a doc
alice_session, alice_doc = _seed(owner="alice")
# Create bob's session+doc in the SAME session so ownership filter kicks in
bob_session = "bob-" + uuid.uuid4().hex[:8]
bob_doc = str(uuid.uuid4())
db = _TS()
try:
db.add(DbSession(id=bob_session, owner="bob", name="bob", model="m", endpoint_url="http://x"))
db.add(Document(
id=bob_doc, session_id=alice_session, # same session!
title="bob doc", language="markdown", current_content="bob body",
version_count=1, is_active=True, owner="bob",
))
db.commit()
finally:
db.close()
rows = await list_docs(_req("alice"), alice_session)
ids = [row["id"] for row in rows]
assert alice_doc in ids
assert bob_doc not in ids, "wrong-owner docs must be hidden"
finally:
droutes.SessionLocal = previous
+107
View File
@@ -0,0 +1,107 @@
r"""DOM/CSS-injection regression for calendar background-image URL escaping.
CodeQL `js/incomplete-sanitization` (#463 calendar.js:416, #464 calendar.js:1263)
flagged event-background CSS that escaped `'` -> `\'` without first escaping
backslashes. A `bg:`-color value (settable per event, and CalDAV-syncable, so
untrusted) ending in or containing a backslash can then consume the closing
quote of `url('...')` and break out of the CSS string.
The fix is a single canonical escaper, `_cssUrlEscape`, in calendar/utils.js,
used by both inline sinks and by `_calBgCss` (which had the same incomplete
escaping). These tests pin the escaper: backslashes are doubled FIRST, then
quotes, so no input can terminate the `url('...')` string early.
"""
import json
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_UTILS = (_REPO / "static" / "js" / "calendar" / "utils.js").as_posix()
_CALENDAR_JS = _REPO / "static" / "js" / "calendar.js"
_HAS_NODE = shutil.which("node") is not None
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def _run(js: str) -> str:
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
def test_cssurlescape_doubles_backslashes_before_quotes():
js = textwrap.dedent(
f"""
const {{ _cssUrlEscape }} = await import('{_UTILS}');
console.log(JSON.stringify({{
backslash: _cssUrlEscape('a\\\\b'),
trailing: _cssUrlEscape('img\\\\'),
quote: _cssUrlEscape("a'b"),
dquote: _cssUrlEscape('a"b'),
}}));
"""
)
out = json.loads(_run(js))
# one backslash -> two; the escape for "'" is not itself re-escaped
assert out["backslash"] == r"a\\b"
assert out["trailing"] == "img\\\\" # 'img\' -> 'img\\'
assert out["quote"] == r"a\'b"
assert out["dquote"] == "a%22b"
def test_backslash_breakout_payload_cannot_close_the_url_string():
# Without the backslash-first escape, "x\" would render url('x\') and the
# trailing backslash escapes the closing quote -> breakout. After the fix the
# backslash is doubled, so the quote we add still terminates the string.
js = textwrap.dedent(
f"""
const {{ _cssUrlEscape, _calBgCss }} = await import('{_UTILS}');
const payload = 'x\\\\'; // a string ending in one backslash
console.log(JSON.stringify({{
esc: _cssUrlEscape(payload),
css: _calBgCss('bg:' + payload, 'var(--accent)'),
}}));
"""
)
out = json.loads(_run(js))
assert out["esc"] == "x\\\\" # doubled backslash
# The rendered declaration keeps the backslash doubled inside url('...').
assert "url('x\\\\')" in out["css"]
def test_calbgcss_escapes_quote_breakout():
js = textwrap.dedent(
f"""
const {{ _calBgCss }} = await import('{_UTILS}');
console.log(JSON.stringify(_calBgCss("bg:a'); X{{}}//", 'var(--accent)')));
"""
)
css = json.loads(_run(js))
# the injected single quote is escaped, so the url() string is not closed early
assert r"\'" in css
assert "url('a\\'); X{}//')" in css
def test_every_calendar_url_interpolation_is_escaped():
# Whole-file invariant: every CSS `url('${...}')` built in calendar.js must
# route its (CalDAV-syncable, untrusted) value through `_cssUrlEscape`. This
# is the guard that catches a *newly added* bg-image sink the centralization
# forgot - the failure mode that left calendar.js:2856 (edit-form color
# swatch) and :2953 (custom-dot preview) raw before this change.
src = _CALENDAR_JS.read_text(encoding="utf-8")
interps = re.findall(r"url\('\$\{([^}]*)\}'\)", src)
assert interps, "expected at least one url('${...}') interpolation in calendar.js"
unescaped = [expr for expr in interps if "_cssUrlEscape(" not in expr]
assert not unescaped, (
"bg-image url() interpolation(s) not routed through _cssUrlEscape: "
+ ", ".join(repr(e) for e in unescaped)
)
+2 -1
View File
@@ -86,7 +86,8 @@ def test_default_settings_registers_hard_max_key():
def test_alias_map_registers_friendly_names(): def test_alias_map_registers_friendly_names():
"""`manage_settings` should accept 'hard max' and friends.""" """`manage_settings` should accept 'hard max' and friends."""
from pathlib import Path from pathlib import Path
src = Path("src/tool_implementations.py").read_text() # manage_settings (and its alias map) moved to agent_tools/admin_tools.py in #3629.
src = Path("src/agent_tools/admin_tools.py").read_text()
assert '"hard max": "agent_input_token_hard_max"' in src assert '"hard max": "agent_input_token_hard_max"' in src
assert '"token budget cap": "agent_input_token_hard_max"' in src assert '"token budget cap": "agent_input_token_hard_max"' in src
assert '"input budget cap": "agent_input_token_hard_max"' in src assert '"input budget cap": "agent_input_token_hard_max"' in src
+53
View File
@@ -0,0 +1,53 @@
"""Behavioral tests for Cookbook port parsing / picking (#4507 follow-up).
Driven through `node --input-type=module` (same approach as the other
*_js.py tests); skips when `node` is not installed.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HELPER = _REPO / "static" / "js" / "cookbookPorts.js"
_HAS_NODE = shutil.which("node") is not None
def _run(expr):
js = (
f"import {{ portOf, nextFreePort }} from '{_HELPER.as_posix()}';"
f"console.log(JSON.stringify({expr}));"
)
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_port_of_handles_all_forms():
assert _run("portOf('vllm serve m --host 0.0.0.0 --port 8000')") == "8000"
assert _run("portOf('x --port=8001')") == "8001"
assert _run("portOf('llama-server -p 8002')") == "8002"
assert _run("portOf('llama-server -p=8003')") == "8003"
assert _run("portOf('serve with no port flag')") == ""
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_next_free_port_skips_taken_including_eq_and_short_flag():
# a --port= serve and a -p serve are both 'taken'; picker skips them
taken = "[portOf('a --port=8000'), portOf('b -p 8001')]"
assert _run(f"nextFreePort({taken})") == "8002"
assert _run("nextFreePort([])") == "8000"
assert _run("nextFreePort(['8000', '8002'])") == "8001"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_clash_outcome_same_port_flagged_different_ignored():
# the guard's predicate is portOf(cmd) === target
assert _run("portOf('m --port 8000') === '8000'") is True
assert _run("portOf('m --port 8001') === '8000'") is False
+66
View File
@@ -53,6 +53,8 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
_resolve_probe_key, _resolve_probe_key,
_classify_endpoint, _classify_endpoint,
_rewrite_loopback_for_docker, _rewrite_loopback_for_docker,
_openai_model_ids,
_ollama_model_names,
_PROVIDER_CURATED, _PROVIDER_CURATED,
) )
@@ -74,6 +76,33 @@ def _resp(status, *, json=None, headers=None, url="https://api.example.com/v1/mo
return httpx.Response(status, **kwargs) return httpx.Response(status, **kwargs)
# ── _openai_model_ids / _ollama_model_names: parsing helpers ──
class TestModelListHelpers:
@pytest.mark.parametrize("data,expected", [
({"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]}, ["gpt-4o", "gpt-4o-mini"]),
({"data": [{"id": None}, {"id": 123}, {"id": "gpt-4o"}]}, ["gpt-4o"]), # non-string ids dropped
({"data": ["x", {"id": "ok"}]}, ["ok"]), # non-dict entries dropped
({"data": []}, []),
({"data": "oops"}, []), # non-list "data"
([], []), ("nope", []), (None, []), (123, []), # non-dict body
])
def test_openai_model_ids(self, data, expected):
assert _openai_model_ids(data) == expected
@pytest.mark.parametrize("data,expected", [
({"models": [{"name": "llama3:8b"}, {"model": "qwen3:4b"}]}, ["llama3:8b", "qwen3:4b"]),
({"models": [{"name": "a", "model": "b"}]}, ["a"]), # name precedence over model
({"models": [{"name": 123}, {"model": None}, {"name": "ok"}]}, ["ok"]), # non-string values dropped
({"models": ["x", {"name": "ok"}]}, ["ok"]), # non-dict entries dropped
({"models": []}, []),
({"models": "oops"}, []),
([], []), (None, []), (42, []), # non-dict body
])
def test_ollama_model_names(self, data, expected):
assert _ollama_model_names(data) == expected
# ── _probe_endpoint: model-list parsing ── # ── _probe_endpoint: model-list parsing ──
class TestProbeEndpointParsing: class TestProbeEndpointParsing:
@@ -121,6 +150,43 @@ class TestProbeEndpointParsing:
) )
assert _probe_endpoint("https://api.example.com/v1") == [] assert _probe_endpoint("https://api.example.com/v1") == []
@pytest.mark.parametrize("body", [[], "invalid", 123, True])
def test_non_dict_json_body_degrades_to_empty(self, monkeypatch, caplog, body):
# HTTP 200 with valid-but-non-dict JSON must not crash the probe with an
# AttributeError (data.get(...) on a list/str/int); it should fall through
# to the empty/curated path. caplog gives this test teeth: pre-fix the
# swallowed AttributeError logs "Failed to probe"; post-fix it does not.
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(200, json=body),
)
with caplog.at_level("WARNING", logger="routes.model_routes"):
assert _probe_endpoint("https://api.example.com/v1") == []
assert "Failed to probe" not in caplog.text
def test_skips_non_string_model_ids(self, monkeypatch):
# A non-compliant upstream returns int/None IDs alongside a valid one.
# The probe must not crash on .lower()/.startswith and must still surface
# the valid string model.
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
200, json={"data": [{"id": None}, {"id": 123}, {"id": "gpt-4o"}]}),
)
assert _probe_endpoint("https://api.example.com/v1", "key") == ["gpt-4o"]
def test_all_non_string_ids_returns_empty(self, monkeypatch):
# Every id is non-string -> empty result, no exception, no curated leak.
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
200, json={"data": [{"id": 123}, {"id": None}]}),
)
assert _probe_endpoint("https://api.example.com/v1") == []
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch): def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
_patch_resolve(monkeypatch) _patch_resolve(monkeypatch)
calls = [] calls = []
+51
View File
@@ -0,0 +1,51 @@
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, is_prequantized
def _8gb_vram_system():
return {
"has_gpu": True,
"backend": "cuda",
"gpu_name": "NVIDIA GeForce RTX 4060",
"gpu_vram_gb": 8.0,
"gpu_count": 1,
"available_ram_gb": 32.0,
"total_ram_gb": 32.0,
}
def test_gemma4_12b_in_catalog():
catalog = {m["name"]: m for m in get_models()}
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
def test_gemma4_12b_has_gguf_source():
catalog = {m["name"]: m for m in get_models()}
entry = catalog["google/gemma-4-12B-it"]
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
repos = [s["repo"] for s in entry["gguf_sources"]]
assert "unsloth/gemma-4-12B-it-GGUF" in repos
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
names = [r["name"] for r in results]
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
def test_gemma4_12b_qat_entries_in_catalog():
catalog = {m["name"]: m for m in get_models()}
assert "google/gemma-4-12B-it-qat-int4" in catalog
assert "google/gemma-4-12B-it-qat-int8" in catalog
def test_gemma4_12b_qat_entries_are_prequantized():
catalog = {m["name"]: m for m in get_models()}
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
def test_gemma4_12b_qat_entries_have_no_gguf():
catalog = {m["name"]: m for m in get_models()}
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
+47
View File
@@ -72,3 +72,50 @@ def test_gguf_alternate_still_recommended_on_windows():
still appear on Windows even though the AWQ variant is hidden.""" still appear on Windows even though the AWQ variant is hidden."""
names = {r["name"] for r in rank_models(_windows_system(), limit=900)} names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
assert "Qwen/Qwen2.5-3B-Instruct" in names assert "Qwen/Qwen2.5-3B-Instruct" in names
def test_remote_windows_probe_uses_encoded_command(monkeypatch):
"""Remote Windows hwfit must not use nested -Command quoting over SSH."""
from services.hwfit import hardware
calls = []
monkeypatch.setattr(hardware, "_remote_host", "user@winpc")
monkeypatch.setattr(hardware, "_remote_port", None)
def fake_run(cmd):
calls.append(cmd)
if isinstance(cmd, str) and "EncodedCommand" in cmd:
return (
'{"ram_gb":64,"avail_gb":32,"cpu_name":"Test CPU",'
'"cpu_cores":8,"arch":64}'
)
return None
monkeypatch.setattr(hardware, "_run", fake_run)
result = hardware._detect_windows()
assert result is not None
assert result["total_ram_gb"] == 64
assert len(calls) == 1
assert "EncodedCommand" in calls[0]
assert '-Command "' not in calls[0]
def test_probe_remote_platform_detects_windows(monkeypatch):
from services.hwfit import hardware
monkeypatch.setattr(hardware, "_run", lambda cmd: "Windows_NT\n")
assert hardware._probe_remote_platform() == "windows"
def test_probe_remote_platform_detects_darwin(monkeypatch):
from services.hwfit import hardware
def fake_run(cmd):
if cmd == "echo %OS%":
return "%OS%"
if cmd == ["uname", "-s"]:
return "Darwin"
raise AssertionError(f"unexpected probe cmd: {cmd!r}")
monkeypatch.setattr(hardware, "_run", fake_run)
assert hardware._probe_remote_platform() == "linux"
+119
View File
@@ -0,0 +1,119 @@
"""Regression test for #3993 — live chat leaves executed tool fences visible.
The backend strips every fenced tool block (``src/tool_parsing.py`` builds its
regex from the full ``TOOL_TAGS`` set), so a reloaded session renders cleanly.
The live frontend path uses its own regex, ``EXEC_FENCE_RE`` in
``static/js/chatRenderer.js``.
Originally that regex came from a hand-maintained subset, so any executable tool
not in it and every *future* tool added to ``TOOL_TAGS`` left its executed
fence lingering as a raw code block in the live bubble until reload. The fix
makes ``TOOL_TAGS`` the single source: ``chatRenderer.js`` no longer hard-codes a
tool list at all. It fetches the backend's authoritative set once from
``GET /api/tools`` (which serves ``sorted(TOOL_TAGS)``) and builds
``EXEC_FENCE_RE`` from it at load, minus ``bash``/``python`` (legitimate code
examples a user may have asked the model to show). There is no second list to
drift.
``chatRenderer.js`` pulls browser globals and can't be imported under node, so
the behavioral tests exercise an equivalent Python regex built straight from the
backend ``TOOL_TAGS`` the same source the live regex now derives from and
source-level guards assert the frontend keeps no hard-coded list.
"""
import re
from pathlib import Path
_SRC = Path("static/js/chatRenderer.js")
_TOOLS_SRC = Path("src/agent_tools/__init__.py")
_ROUTES_SRC = Path("routes/model_routes.py")
# Deliberately NOT stripped: legitimate code-example languages, not tool
# invocations. Must match the carve-out in chatRenderer.js.
_NON_STRIPPED = {"bash", "python"}
def _tool_tags() -> set[str]:
"""Extract the backend TOOL_TAGS set from src/agent_tools/__init__.py (source-level)."""
source = _TOOLS_SRC.read_text(encoding="utf-8")
m = re.search(r"TOOL_TAGS\s*=\s*\{(?P<body>.*?)\}", source, re.DOTALL)
assert m, "TOOL_TAGS literal not found in src/agent_tools/__init__.py"
return set(re.findall(r'"([a-z_]+)"', m.group("body")))
def _exec_fence_regex() -> re.Pattern:
"""Rebuild EXEC_FENCE_RE's behavior from the same source the live regex now
derives from: the backend TOOL_TAGS (served via /api/tools) minus bash/python."""
tags = _tool_tags() - _NON_STRIPPED
assert tags, "TOOL_TAGS is empty"
return re.compile(r"```(?:" + "|".join(sorted(tags)) + r")\s*\n[\s\S]*?```", re.IGNORECASE)
def test_strips_executed_email_tool_fences():
rx = _exec_fence_regex()
# The exact shape the reporter observed lingering in the live bubble.
text = 'Here are emails\n\n```list_emails\n{"max_results":10}\n```'
assert rx.sub("", text).strip() == "Here are emails"
def test_strips_every_named_email_tool_fence():
rx = _exec_fence_regex()
email_tools = [
"list_email_accounts", "send_email", "list_emails", "read_email",
"reply_to_email", "bulk_email", "archive_email", "delete_email",
"mark_email_read",
]
for tool in email_tools:
fence = f"```{tool}\n{{}}\n```"
assert rx.sub("", fence).strip() == "", f"{tool} fence not stripped"
def test_preserves_existing_web_search_stripping():
rx = _exec_fence_regex()
fence = '```web_search\n{"q":"x"}\n```'
assert rx.sub("", fence).strip() == ""
def test_does_not_strip_bash_or_python_code_examples():
"""bash/python fences are deliberately excluded — they are legitimate code
examples a user may have asked the model to show, not tool invocations."""
rx = _exec_fence_regex()
for lang in sorted(_NON_STRIPPED):
example = f"```{lang}\nls -la\n```"
assert rx.sub("", example) == example, f"{lang} example wrongly stripped"
def test_frontend_keeps_no_hardcoded_tool_list():
"""Root-cause guard for #3993: chatRenderer.js must NOT reintroduce a
hand-maintained tool list. A hard-coded mirror of TOOL_TAGS silently drifts
when a new tool is added leaving its executed fence in the live bubble
until reload. The live regex must instead be built from the backend's
authoritative set fetched at runtime."""
source = _SRC.read_text(encoding="utf-8")
assert "EXEC_TOOL_TAGS" not in source, (
"chatRenderer.js reintroduced a hard-coded EXEC_TOOL_TAGS list; the "
"live-strip tags must come from GET /api/tools so TOOL_TAGS stays the "
"single source (#3993)."
)
assert "/api/tools" in source, (
"chatRenderer.js must fetch the tool set from /api/tools to build "
"EXEC_FENCE_RE."
)
# The bash/python carve-out must survive the move to the runtime list.
m = re.search(r"EXEC_FENCE_NON_TOOL\s*=\s*new Set\(\[(?P<body>.*?)\]\)", source, re.DOTALL)
assert m, "bash/python carve-out (EXEC_FENCE_NON_TOOL) not found in chatRenderer.js"
carve_out = set(re.findall(r"['\"]([a-z_]+)['\"]", m.group("body")))
assert carve_out == _NON_STRIPPED, (
f"EXEC_FENCE_NON_TOOL must carve out exactly {sorted(_NON_STRIPPED)}, "
f"got {sorted(carve_out)}"
)
def test_api_tools_endpoint_serves_full_tool_tags():
"""The frontend's single source is GET /api/tools. Guard that the endpoint
serves the complete TOOL_TAGS set (sorted) if it ever served a subset, the
live-strip list would silently shrink with no second list to catch it."""
source = _ROUTES_SRC.read_text(encoding="utf-8")
assert re.search(r"for\s+tag\s+in\s+sorted\(\s*TOOL_TAGS\s*\)", source), (
"GET /api/tools must iterate sorted(TOOL_TAGS) so the frontend's "
"EXEC_FENCE_RE covers every executable tool (#3993)."
)
+178
View File
@@ -0,0 +1,178 @@
"""Tests for llama.cpp (llama-server) local discovery: the default scan list
includes llama-server's port 8080, and `_fingerprint_provider` identifies a
llama-server via its native ``/props`` endpoint without misfiring on LM Studio,
Ollama, or plain OpenAI-compatible servers.
Companion to test_lmstudio_discovery.py; the llama.cpp fingerprint is checked
*after* the LM Studio one, so LM Studio still wins when both could match.
"""
from src.model_discovery import ModelDiscovery
class _FakeResponse:
def __init__(self, payload, ok=True):
self._payload = payload
self.is_success = ok
def json(self):
return self._payload
# ════════════════════════════════════════════════════════════
# discover_models — scan list includes 8080 (llama-server default)
# ════════════════════════════════════════════════════════════
class TestLlamaCppScanPort:
def test_discover_models_scans_port_8080(self, monkeypatch):
"""llama-server's default port 8080 must be among the scan targets."""
discovery = ModelDiscovery(default_host="localhost")
scanned_ports = []
def fake_check_port(host, port):
scanned_ports.append(port)
return None
monkeypatch.setattr(discovery, "_check_port", fake_check_port)
monkeypatch.setattr(
"src.model_discovery.discover_tailscale_hosts", lambda: [],
)
discovery.discover_models()
assert 8080 in scanned_ports
# ════════════════════════════════════════════════════════════
# _fingerprint_provider — llama-server via /props
# ════════════════════════════════════════════════════════════
class TestLlamaCppFingerprint:
# A representative llama-server /props payload (trimmed to the keys the
# fingerprint relies on).
LLAMACPP_PROPS = {
"default_generation_settings": {"n_ctx": 4096, "temperature": 0.8},
"total_slots": 1,
"chat_template": "{{ messages }}",
"model_path": "/models/gemma-4-12b-it-Q4_K_M.gguf",
}
def test_llamacpp_props_detected(self, monkeypatch):
"""A server that isn't LM Studio but answers /props as llama-server →
'llamacpp'."""
discovery = ModelDiscovery(default_host="localhost")
def fake_get(url, timeout=None):
if url.endswith("/api/v1/models"):
# OpenAI-compatible shape, not the LM Studio native shape.
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
if url.endswith("/props"):
return _FakeResponse(self.LLAMACPP_PROPS)
return _FakeResponse({}, ok=False)
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
assert discovery._fingerprint_provider("localhost", 8080) == "llamacpp"
def test_lmstudio_still_wins_when_both_match(self, monkeypatch):
"""If /api/v1/models reports the LM Studio native shape, LM Studio is
returned even when /props would also match."""
discovery = ModelDiscovery(default_host="localhost")
lmstudio_native = {
"models": [{"type": "llm", "key": "qwen3.6-27b",
"architecture": "qwen35", "format": "gguf"}]
}
def fake_get(url, timeout=None):
if url.endswith("/api/v1/models"):
return _FakeResponse(lmstudio_native)
if url.endswith("/props"):
return _FakeResponse(self.LLAMACPP_PROPS)
return _FakeResponse({}, ok=False)
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
assert discovery._fingerprint_provider("localhost", 8080) == "lmstudio"
def test_props_without_llamacpp_keys_not_detected(self, monkeypatch):
"""A /props-style response lacking llama-server marker keys → None."""
discovery = ModelDiscovery(default_host="localhost")
def fake_get(url, timeout=None):
if url.endswith("/api/v1/models"):
return _FakeResponse({"data": []})
if url.endswith("/props"):
return _FakeResponse({"unrelated": "value"})
return _FakeResponse({}, ok=False)
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
assert discovery._fingerprint_provider("localhost", 8080) is None
def test_props_unreachable_returns_none(self, monkeypatch):
"""No /api/v1/models and a failing /props → None (not an exception)."""
discovery = ModelDiscovery(default_host="localhost")
def fake_get(url, timeout=None):
if url.endswith("/api/v1/models"):
return _FakeResponse({}, ok=False)
raise OSError("connection refused")
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
assert discovery._fingerprint_provider("localhost", 8080) is None
def test_check_port_attaches_llamacpp_provider(self, monkeypatch):
"""End-to-end: _check_port tags a discovered llama-server as 'llamacpp'."""
discovery = ModelDiscovery(default_host="localhost")
def fake_get(url, timeout=None):
if url.endswith("/v1/models"):
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
if url.endswith("/api/v1/models"):
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
if url.endswith("/props"):
return _FakeResponse(self.LLAMACPP_PROPS)
return _FakeResponse({}, ok=False)
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
result = discovery._check_port("localhost", 8080)
assert result is not None
assert result["provider"] == "llamacpp"
assert result["models"] == ["gemma-4-12b"]
# ════════════════════════════════════════════════════════════
# Docker loopback rewrite — host.docker.internal:8080 in scan
# ════════════════════════════════════════════════════════════
class TestDockerLoopbackScan:
def test_host_docker_internal_in_scan_hosts(self, monkeypatch):
"""When no LLM_HOSTS env override is set, host.docker.internal must be
included in the scan host list so llama-server on the Docker host is
discovered from inside the container."""
monkeypatch.delenv("LLM_HOSTS", raising=False)
monkeypatch.setattr(
"src.model_discovery.discover_tailscale_hosts", lambda: [],
)
discovery = ModelDiscovery(default_host="localhost")
hosts = discovery._get_hosts()
assert "host.docker.internal" in hosts
def test_discovered_endpoint_url_uses_provided_host(self, monkeypatch):
"""When host.docker.internal:8080 is probed, the returned base_url
contains host.docker.internal not a rewritten 127.0.0.1."""
from src.model_discovery import ModelDiscovery as _MD
discovery = _MD(default_host="localhost")
def fake_get(url, timeout=None):
if url.endswith("/v1/models") or url.endswith("/api/v1/models"):
return _FakeResponse({"data": [{"id": "gemma-4-12b"}]})
if url.endswith("/props"):
return _FakeResponse({
"default_generation_settings": {"n_ctx": 4096},
"total_slots": 1,
"chat_template": "{{ messages }}",
})
return _FakeResponse({}, ok=False)
monkeypatch.setattr("src.model_discovery.httpx.get", fake_get)
result = discovery._check_port("host.docker.internal", 8080)
assert result is not None
assert "host.docker.internal" in result["url"]
assert "127.0.0.1" not in result["url"]
+156
View File
@@ -0,0 +1,156 @@
"""Tests for _normalize_mistral_content() — Mistral's structured content parser.
Mistral's chat completions API returns content as a typed array when reasoning
is enabled, instead of the plain string most OpenAI-compat servers use:
"content": [
{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
{"type": "text", "text": "..."}
]
_normalize_mistral_content() splits that into (text, thinking) plain strings.
The function is called from three sites:
- llm_call (sync, non-streaming response parser)
- llm_call_async (async, non-streaming response parser)
- stream_llm (streaming delta parser)
These tests pin the contract: string passthrough, the array shape, and the
edge cases (empty, garbage, missing fields) so a refactor doesn't silently
drop thinking content or break non-Mistral providers.
"""
from src.llm_core import _normalize_mistral_content
def test_string_passthrough_returns_text_with_empty_thinking():
"""Plain string content (the common case) passes through unchanged."""
text, thinking = _normalize_mistral_content("hello world")
assert text == "hello world"
assert thinking == ""
def test_empty_string_passthrough():
text, thinking = _normalize_mistral_content("")
assert text == ""
assert thinking == ""
def test_array_with_thinking_and_text_blocks():
"""Mistral's documented format: thinking block + text block."""
content = [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Let me work through this..."}],
"closed": True,
},
{"type": "text", "text": "The answer is 42."},
]
text, thinking = _normalize_mistral_content(content)
assert text == "The answer is 42."
assert thinking == "Let me work through this..."
def test_array_with_only_thinking_block():
"""Streaming deltas often contain only a thinking fragment (no text block yet)."""
content = [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Okay, let's"}],
"closed": True,
}
]
text, thinking = _normalize_mistral_content(content)
assert text == ""
assert thinking == "Okay, let's"
def test_array_with_only_text_block():
"""Final answer delta — only the text block, no thinking."""
content = [{"type": "text", "text": "Final answer."}]
text, thinking = _normalize_mistral_content(content)
assert text == "Final answer."
assert thinking == ""
def test_array_concatenates_multiple_text_blocks():
"""Multiple text blocks are concatenated in order."""
content = [
{"type": "text", "text": "part 1 "},
{"type": "text", "text": "part 2"},
]
text, thinking = _normalize_mistral_content(content)
assert text == "part 1 part 2"
def test_array_concatenates_multiple_thinking_fragments():
"""Multiple thinking sub-blocks are concatenated in order."""
content = [
{
"type": "thinking",
"thinking": [
{"type": "text", "text": "first "},
{"type": "text", "text": "second"},
],
"closed": True,
}
]
text, thinking = _normalize_mistral_content(content)
assert text == ""
assert thinking == "first second"
def test_empty_array_returns_empty_strings():
text, thinking = _normalize_mistral_content([])
assert text == ""
assert thinking == ""
def test_array_with_garbage_entries_skips_them():
"""Non-dict entries, missing type, missing text — all silently skipped."""
content = [
"not a dict",
None,
{"type": "unknown_type", "text": "should be ignored"},
{"type": "text"}, # missing text key
{"type": "thinking"}, # missing thinking key
{"type": "text", "text": "valid text"},
]
text, thinking = _normalize_mistral_content(content)
assert text == "valid text"
assert thinking == ""
def test_none_returns_empty_strings():
"""Defensive: None content (server bug or schema drift) doesn't crash."""
text, thinking = _normalize_mistral_content(None)
assert text == ""
assert thinking == ""
def test_int_returns_empty_strings():
"""Defensive: wrong-typed content doesn't crash."""
text, thinking = _normalize_mistral_content(42)
assert text == ""
assert thinking == ""
def test_thinking_block_with_string_inner():
"""Some Mistral API versions may use a string instead of an array for
the inner 'thinking' field. Accept both shapes."""
content = [
{"type": "thinking", "thinking": "inline string thinking"},
{"type": "text", "text": "answer"},
]
text, thinking = _normalize_mistral_content(content)
assert text == "answer"
assert thinking == "inline string thinking"
def test_thinking_block_with_empty_text_field():
"""Empty text fields don't pollute the output."""
content = [
{"type": "thinking", "thinking": [{"type": "text", "text": ""}], "closed": True},
{"type": "text", "text": ""},
]
text, thinking = _normalize_mistral_content(content)
assert text == ""
assert thinking == ""
+32
View File
@@ -0,0 +1,32 @@
from core.log_safety import redact_url
def test_strips_userinfo():
assert redact_url("https://user:pass@host.example/v1/models") == "https://host.example/v1/models"
def test_strips_query_and_fragment():
assert redact_url("https://host.example/v1?api_key=secret#frag") == "https://host.example/v1"
def test_keeps_port_and_path():
assert redact_url("http://host.example:8080/api/tags") == "http://host.example:8080/api/tags"
def test_ipv6_host_keeps_brackets():
assert redact_url("https://user:pass@[2001:db8::1]:8443/v1") == "https://[2001:db8::1]:8443/v1"
assert redact_url("https://[2001:db8::1]/v1") == "https://[2001:db8::1]/v1"
def test_no_credentials_passthrough():
assert redact_url("https://host.example/v1/models") == "https://host.example/v1/models"
def test_empty_and_none():
assert redact_url("") == ""
assert redact_url(None) == ""
def test_garbage_does_not_raise():
# urlparse is lenient; just assert no credential-looking userinfo survives.
assert "@" not in redact_url("::::not a url::::")
+350
View File
@@ -0,0 +1,350 @@
"""Regression: stream_agent_loop surfaces *why* a guard ended the turn.
Two internal guards used to stop the agent in ways that looked like a clean
completion or a vague blocked message:
* the loop-breaker stall detector -> now emits `loop_breaker_triggered`
* the intent-without-action nudge cap -> now emits `intent_nudge_exhausted`
These tests run the real loop body against a fake LLM stream (no model calls,
no sleeps) and assert the structured stop event is emitted.
"""
import asyncio
import json
import logging
import pytest
import src.agent_loop as al
def _collect(gen):
async def _run():
return [c async for c in gen]
return asyncio.run(_run())
def _types(chunks):
out = []
for c in chunks:
if c.startswith("data: ") and not c.startswith("data: [DONE]"):
try:
out.append(json.loads(c[6:]))
except Exception:
pass
return out
def _patch_common(monkeypatch):
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
async def _fake_exec(block, *a, **k):
return ("bash", {"output": "ok", "exit_code": 0})
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
def _run_loop(monkeypatch, round_text, max_rounds, relevant_tools={"bash"}):
async def _fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": round_text})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
gen = al.stream_agent_loop(
"http://x/v1", "m",
[{"role": "user", "content": "do a long multi-step task"}],
max_rounds=max_rounds,
relevant_tools=relevant_tools,
)
return _types(_collect(gen))
def test_emits_loop_breaker_triggered_on_repeated_no_progress(monkeypatch):
_patch_common(monkeypatch)
# Same exact tool call every round, no answer text -> stuck-round streak
# trips the loop-breaker once the cap is reached.
events = _run_loop(monkeypatch, "```bash\necho hi\n```", max_rounds=8)
lb = [e for e in events if e.get("type") == "loop_breaker_triggered"]
assert lb, events
e = lb[0]
assert e["reason"]
assert e["max_stuck_rounds"] == 4
assert e["stuck_rounds"] >= 4
assert "message" in e
def test_no_loop_breaker_on_normal_finish(monkeypatch):
_patch_common(monkeypatch)
events = _run_loop(monkeypatch, "All done, here is your answer.", max_rounds=8)
assert not any(e.get("type") == "loop_breaker_triggered" for e in events), events
def test_emits_intent_nudge_exhausted_when_cap_reached(monkeypatch):
_patch_common(monkeypatch)
# The model keeps announcing an action with no tool call. After the nudge
# cap is spent, the turn ends with an explicit intent_nudge_exhausted event.
events = _run_loop(monkeypatch, "Let me check the logs now", max_rounds=5)
inx = [e for e in events if e.get("type") == "intent_nudge_exhausted"]
assert inx, events
e = inx[0]
assert e["max_nudges"] == 2
assert e["nudges"] >= 2
assert "message" in e
def test_no_intent_nudge_exhausted_on_normal_finish(monkeypatch):
_patch_common(monkeypatch)
events = _run_loop(monkeypatch, "Here is the complete answer to your question.", max_rounds=5)
assert not any(e.get("type") == "intent_nudge_exhausted" for e in events), events
def _assert_guard_log_safe(caplog, *, structural, secret="secret123"):
"""The guard's own structural log line fired, and that record carries no raw
secret. Scoped to the guard's records on purpose: an unrelated, pre-existing
round-summary log echoes raw model text and is out of scope for this PR."""
records = [r for r in caplog.records if structural in r.getMessage()]
assert records, caplog.text
for r in records:
assert secret not in r.getMessage(), r.getMessage()
def test_intent_nudge_logging_does_not_leak_secret(monkeypatch, caplog):
# The model announces an action (no tool call) with a secret in the text.
# The nudge logger must record only structural metadata, never the matched
# phrase — so the credential never lands in journalctl.
_patch_common(monkeypatch)
with caplog.at_level(logging.INFO, logger="src.agent_loop"):
events = _run_loop(monkeypatch, "Let me check api_key=secret123 now", max_rounds=5)
assert any(e.get("type") == "intent_nudge_exhausted" for e in events), events
_assert_guard_log_safe(caplog, structural="intent-without-action nudge")
def test_loop_breaker_logging_does_not_leak_secret(monkeypatch, caplog):
# A repeated tool command carrying a secret trips the loop-breaker. The
# structural log must not contain `_sig` / raw tool-call content.
_patch_common(monkeypatch)
with caplog.at_level(logging.INFO, logger="src.agent_loop"):
events = _run_loop(monkeypatch, "```bash\necho api_key=secret123\n```", max_rounds=8)
assert any(e.get("type") == "loop_breaker_triggered" for e in events), events
_assert_guard_log_safe(caplog, structural="loop-breaker tripped")
def test_redacts_sensitive_tool_output_before_surfacing():
text = al._redact_sensitive_text(
"password: private-value\n"
"api_key=private-key\n"
"Authorization: Bearer private-token\n"
"normal output"
)
assert "private-value" not in text
assert "private-key" not in text
assert "private-token" not in text
assert "password: [redacted]" in text
assert "api_key=[redacted]" in text
assert "Authorization: Bearer [redacted]" in text
assert "normal output" in text
_GCP_API_KEY_SAMPLE = "AI" + "za" + ("A" * 35)
# (input, secret substring that must be gone, expected substring that must remain)
_REDACTION_CASES = [
("Authorization: Bearer abc123tok", "abc123tok", "Authorization: Bearer [redacted]"),
("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz", "Authorization: Basic [redacted]"),
# Quoted Authorization value (spaces) must be redacted whole.
('Authorization: Bearer "two word secret"', "two word secret", "Authorization: Bearer [redacted]"),
# Escaped quote inside a quoted secret must not leak the tail.
(r'password="abc\"def secret"', "def secret", "password=[redacted]"),
# URL password containing a colon must still be redacted whole.
("postgres://user:pa:ss@host/db", "pa:ss", "postgres://[redacted]@host/db"),
# Provider-shaped bare tokens.
("token is hf_abcdefghij1234567890XYZ", "hf_abcdefghij1234567890XYZ", "[redacted]"),
("key " + _GCP_API_KEY_SAMPLE, _GCP_API_KEY_SAMPLE, "[redacted]"),
("Cookie: session=abc123secret", "abc123secret", "Cookie: [redacted]"),
("Set-Cookie: sid=xyz789; HttpOnly", "xyz789", "Set-Cookie: [redacted]"),
("postgres://user:pa55word@host/db", "pa55word", "postgres://[redacted]@host/db"),
("client_secret=supersecretvalue", "supersecretvalue", "client_secret=[redacted]"),
("OPENAI_API_KEY=abcd1234deadbeef", "abcd1234deadbeef", "OPENAI_API_KEY=[redacted]"),
# Quoted multi-word env value must be fully redacted, not clipped at the space.
('OPENAI_API_KEY="two word secret"', "two word secret", "OPENAI_API_KEY=[redacted]"),
('password: "my secret value"', "my secret value", "password: [redacted]"),
("here is sk-abcdefghij1234567890", "sk-abcdefghij1234567890", "[redacted]"),
(
"-----BEGIN PRIVATE KEY-----\nMIIfakeKEYbody\n-----END PRIVATE KEY-----",
"MIIfakeKEYbody",
"[redacted private key]",
),
]
@pytest.mark.parametrize("raw, secret, expected", _REDACTION_CASES)
def test_redaction_covers_requested_secret_shapes(raw, secret, expected):
out = al._redact_sensitive_text(raw)
assert secret not in out, out
assert expected in out, out
@pytest.mark.parametrize("raw", [
"the build completed in 3.2s with 0 errors",
"password reset email sent to the user",
"Listing 5 files: a.py b.py c.py d.py e.py",
"https://example.com/path?page=2",
# Benign uppercase names that merely end in KEY must not be redacted.
"MONKEY=banana",
"TURKEY=dinner",
])
def test_redaction_keeps_normal_output_readable(raw):
assert al._redact_sensitive_text(raw) == raw
def test_redacts_before_truncating():
# A secret near the start must be gone even if truncation would otherwise
# only clip the tail — redaction runs first.
raw = "api_key=topsecretvalue " + ("x" * 50_000)
out = al._truncate(al._redact_sensitive_text(raw))
assert "topsecretvalue" not in out
assert "api_key=[redacted]" in out
def _run_tool_result(monkeypatch, tool, exec_result, max_rounds=2):
"""Drive one tool round whose execution returns `exec_result`, and collect
the streamed events. Used to assert restored per-tool-result emissions."""
_patch_common(monkeypatch)
async def _fake_exec(block, *a, **k):
return (tool, exec_result)
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
round_text = f"```{tool}\n{{}}\n```"
async def _fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": round_text})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
gen = al.stream_agent_loop(
"http://x/v1", "m",
[{"role": "user", "content": "do something"}],
max_rounds=max_rounds,
relevant_tools={tool},
)
return _types(_collect(gen))
def test_restores_doc_suggestions_event(monkeypatch):
events = _run_tool_result(
monkeypatch, "suggest_document",
{"action": "suggest", "doc_id": "d1", "suggestions": [{"text": "x"}], "exit_code": 0},
)
assert any(e.get("type") == "doc_suggestions" for e in events), events
def test_restores_doc_update_event(monkeypatch):
events = _run_tool_result(
monkeypatch, "edit_document",
{"action": "edit", "doc_id": "d1", "content": "body", "version": 2,
"title": "T", "language": "md", "exit_code": 0},
)
# A native document block also emits doc_update AFTER tool_output, so a plain
# "any doc_update" check would pass even if the restored generic block were
# gone. Prove the restored block fires BEFORE the first tool_output.
types = [e.get("type") for e in events]
assert "doc_update" in types, events
assert "tool_output" in types, events
assert types.index("doc_update") < types.index("tool_output"), types
def test_restores_ui_control_event(monkeypatch):
events = _run_tool_result(
monkeypatch, "ui_control",
{"ui_event": "toggle", "toggle_name": "bash", "state": "off", "exit_code": 0},
)
assert any(e.get("type") == "ui_control" for e in events), events
def test_restores_plan_update_event(monkeypatch):
events = _run_tool_result(
monkeypatch, "update_plan",
{"plan_update": {"steps": [{"text": "step", "done": True}]}, "exit_code": 0},
)
assert any(e.get("type") == "plan_update" for e in events), events
def test_restores_ask_user_event_and_persists_question(monkeypatch):
events = _run_tool_result(
monkeypatch, "ask_user",
{"ask_user": {"question": "Which option?", "options": [{"label": "A"}, {"label": "B"}]},
"exit_code": 0},
)
# Exactly one ask_user event — not re-emitted on a follow-up round.
_ask_events = [e for e in events if e.get("type") == "ask_user"]
assert len(_ask_events) == 1, events
# The question is streamed as assistant text so it persists for replay.
# Upstream prepends "\n\n" when full_response already holds streamed text,
# so match on containment — and it must be streamed exactly once.
_q_deltas = [e for e in events if "Which option?" in (e.get("delta") or "")]
assert len(_q_deltas) == 1, events
# Setting `_awaiting_user` breaks the loop, so the turn does NOT advance into
# another agent round (which would emit an agent_step event) after the ask.
assert not any(e.get("type") == "agent_step" for e in events), events
def test_redacts_command_display_in_streamed_events(monkeypatch):
# A tool command line can carry a secret. The streamed command display
# (tool_start / tool_output) must be redacted, even though the real command
# passed to execution is left untouched.
_patch_common(monkeypatch)
round_text = "```bash\necho api_key=secret123\n```"
async def _fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": round_text})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
gen = al.stream_agent_loop(
"http://x/v1", "m",
[{"role": "user", "content": "run it"}],
max_rounds=2,
relevant_tools={"bash"},
)
events = _types(_collect(gen))
cmds = [e for e in events if e.get("type") in ("tool_start", "tool_output")]
assert cmds, events
assert all("secret123" not in (e.get("command") or "") for e in cmds), cmds
assert any("api_key=[redacted]" in (e.get("command") or "") for e in cmds), cmds
def test_redacts_live_tool_progress_tail(monkeypatch):
# A secret in the live progress tail must be redacted before streaming —
# otherwise it flashes by before the (already redacted) final tool_output.
_patch_common(monkeypatch)
async def _fake_exec(block, *a, **k):
await k["progress_cb"]({"tail": "api_key=secret123", "elapsed_s": 1})
return ("bash", {"output": "done", "exit_code": 0})
monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False)
round_text = "```bash\necho hi\n```"
async def _fake_stream(_candidates, messages, **kwargs):
yield f'data: {json.dumps({"delta": round_text})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
gen = al.stream_agent_loop(
"http://x/v1", "m",
[{"role": "user", "content": "run it"}],
max_rounds=2,
relevant_tools={"bash"},
)
events = _types(_collect(gen))
prog = [e for e in events if e.get("type") == "tool_progress"]
assert prog, events
assert all("secret123" not in (e.get("tail") or "") for e in prog), prog
assert any("api_key=[redacted]" in (e.get("tail") or "") for e in prog), prog
# Other fields are preserved.
assert any(e.get("elapsed_s") == 1 for e in prog), prog
+2 -2
View File
@@ -26,8 +26,8 @@ clear_fake_database_modules()
import core.database as cdb import core.database as cdb
from core.database import McpServer from core.database import McpServer
import src.tool_implementations as ti import src.agent_tools.admin_tools as ti # do_manage_mcp/get_mcp_manager moved here in the registry migration
from src.tool_implementations import _validate_mcp_command from src.agent_tools.admin_tools import _validate_mcp_command
_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata) _TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata)
+1 -1
View File
@@ -3,7 +3,7 @@ import asyncio
import json import json
import src.settings as settings_mod import src.settings as settings_mod
from src.tool_implementations import do_manage_settings from src.agent_tools.admin_tools import do_manage_settings
def test_set_token_budget_is_not_refused_as_secret(monkeypatch): def test_set_token_budget_is_not_refused_as_secret(monkeypatch):
+30
View File
@@ -170,6 +170,36 @@ def test_extract_thinking_blocks_handles_thought_tag(node_available):
assert result["content"] == "Final answer." assert result["content"] == "Final answer."
def test_url_inside_inline_code_is_not_autolinked(node_available):
# A URL inside a backtick span is preceded by a space, so the bare-URL
# autolink used to wrap it in an <a> tag (then swap it for an
# ___ALLOWED_HTML_ placeholder), corrupting the command shown to the user.
html = _run_markdown_case("Run `$j = irm http://127.0.0.1:3000/x` to fetch.")
assert "<code>$j = irm http://127.0.0.1:3000/x</code>" in html
assert "___ALLOWED_HTML_" not in html
assert "<a " not in html
assert 'href="http://127.0.0.1:3000/x"' not in html
def test_url_outside_inline_code_is_still_autolinked(node_available):
# Inline code must not disable autolinking for bare URLs elsewhere in the
# same line.
html = _run_markdown_case("Use `irm` then visit https://example.com/page now.")
assert "<code>irm</code>" in html
assert 'href="https://example.com/page"' in html
def test_inline_code_content_is_html_escaped(node_available):
# Inline code is now extracted before the global escape pass, so it must be
# escaped at extraction time (matching the fenced-code-block handling).
html = _run_markdown_case("Render `<b>$1 & 'q'</b>` literally.")
assert "<code>&lt;b&gt;$1 &amp; &#39;q&#39;&lt;/b&gt;</code>" in html
assert "<b>" not in html
def test_dotted_python_import_paths_are_not_autolinked(node_available): def test_dotted_python_import_paths_are_not_autolinked(node_available):
html = _run_markdown_case( html = _run_markdown_case(
"from imblearn.combine import SMOTETomek\n" "from imblearn.combine import SMOTETomek\n"
+2 -2
View File
@@ -8,7 +8,7 @@ from types import SimpleNamespace
def test_reconnect_passes_full_server_config(): def test_reconnect_passes_full_server_config():
"""do_manage_mcp reconnect must pass name/transport/command/args/env/url.""" """do_manage_mcp reconnect must pass name/transport/command/args/env/url."""
from src.tool_implementations import do_manage_mcp from src.agent_tools.admin_tools import do_manage_mcp
fake_mcp = MagicMock() fake_mcp = MagicMock()
fake_mcp.disconnect_server = AsyncMock() fake_mcp.disconnect_server = AsyncMock()
@@ -28,7 +28,7 @@ def test_reconnect_passes_full_server_config():
fake_db = MagicMock() fake_db = MagicMock()
fake_db.query.return_value.filter.return_value.first.return_value = fake_srv fake_db.query.return_value.filter.return_value.first.return_value = fake_srv
with patch("src.tool_implementations.get_mcp_manager", return_value=fake_mcp), \ with patch("src.agent_tools.admin_tools.get_mcp_manager", return_value=fake_mcp), \
patch("core.database.SessionLocal", return_value=fake_db): patch("core.database.SessionLocal", return_value=fake_db):
result = asyncio.run(do_manage_mcp( result = asyncio.run(do_manage_mcp(
json.dumps({"action": "reconnect", "server_id": "srv-123"}) json.dumps({"action": "reconnect", "server_id": "srv-123"})
@@ -0,0 +1,70 @@
import json
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
def test_bash_fenced_read_file_function_call_runs_as_read_file():
blocks = parse_tool_blocks('```bash\nread_file("notes/todo.md")\n```')
assert len(blocks) == 1
assert blocks[0].tool_type == "read_file"
assert blocks[0].content == "notes/todo.md"
def test_python_fenced_read_file_function_call_runs_as_read_file():
blocks = parse_tool_blocks('```python\nread_file(path="notes/todo.md", offset=3, limit=2)\n```')
assert len(blocks) == 1
assert blocks[0].tool_type == "read_file"
assert json.loads(blocks[0].content) == {
"path": "notes/todo.md",
"offset": 3,
"limit": 2,
}
def test_bash_fenced_read_file_command_runs_as_read_file():
blocks = parse_tool_blocks('```bash\nread_file "notes/todo.md"\n```')
assert len(blocks) == 1
assert blocks[0].tool_type == "read_file"
assert blocks[0].content == "notes/todo.md"
def test_bash_fenced_read_file_json_command_runs_as_read_file():
blocks = parse_tool_blocks('```bash\nread_file {"path":"notes/todo.md","offset":1,"limit":4}\n```')
assert len(blocks) == 1
assert blocks[0].tool_type == "read_file"
assert json.loads(blocks[0].content) == {
"path": "notes/todo.md",
"offset": 1,
"limit": 4,
}
def test_multiline_bash_read_file_block_stays_bash():
blocks = parse_tool_blocks('```bash\nread_file notes/todo.md\necho done\n```')
assert len(blocks) == 1
assert blocks[0].tool_type == "bash"
assert "read_file notes/todo.md" in blocks[0].content
def test_nontrivial_python_read_file_name_stays_python_code():
blocks = parse_tool_blocks('```python\nprint(read_file("notes/todo.md"))\n```')
assert len(blocks) == 1
assert blocks[0].tool_type == "python"
def test_strip_tool_blocks_removes_rescued_read_file_fence():
text = 'Opening file:\n```bash\nread_file "notes/todo.md"\n```\nDone.'
cleaned = strip_tool_blocks(text)
assert "```" not in cleaned
assert "read_file" not in cleaned
assert "Opening file:" in cleaned
assert "Done." in cleaned
+173
View File
@@ -0,0 +1,173 @@
"""Tests for share_defaults_with_users setting"""
import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.import_state import preserve_import_state
from tests.helpers.db_stubs import make_core_db_stub
with preserve_import_state("core.database", "src.database", "routes.model_routes", "routes.prefs_routes"):
import routes.model_routes as model_routes
import routes.prefs_routes as prefs_routes
import src.auth_helpers as auth_helpers
### Helper Classes
class _FakeEndpoint:
"""Minimal fake endpoint for testing"""
def __init__(self, id, base_url, is_enabled=True, owner=None):
self.id = id
self.base_url = base_url
self.is_enabled = is_enabled
self.owner = owner
self.cached_models = None
self.hidden_models = None
self.pinned_models = None
class _FakeQuery:
"""Fake query object for testing"""
def __init__(self, endpoints, user=None, include_shared=True):
self._endpoints = endpoints
self._user = user
self._include_shared = include_shared
def filter(self, *conditions):
for cond in conditions:
cond_str = str(cond)
print(f"Filter condition: {cond_str}")
if 'owner' in cond_str and 'IS NULL' not in cond_str:
self._include_shared = False
return self
def first(self):
"""Return first endpoint respecting owner filter"""
if not self._endpoints:
return None
if self._user:
for ep in self._endpoints:
ep_owner = getattr(ep, 'owner', None)
if ep_owner == self._user:
return ep
if self._include_shared and ep_owner is None:
return ep
return None
return self._endpoints[0]
def _make_db_session(endpoints, user=None):
"""Create a fake DB session that returns our fake query"""
fake_session = MagicMock()
fake_query = _FakeQuery(endpoints, user)
fake_session.query.return_value = fake_query
return fake_session
def _get_default_chat_route(router):
"""Extract the /api/default-chat GET route from the router"""
for route in router.routes:
if getattr(route, "path", "") == "/api/default-chat" and "GET" in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError("GET /api/default-chat route not found")
def _make_request(user=None, auth_manager=None):
"""Create a fake request for testing"""
return SimpleNamespace(
state=SimpleNamespace(current_user=user),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
client=SimpleNamespace(host="127.0.0.1"),
)
### Shared test logic
def _run_get_default_chat_test(monkeypatch, share_defaults_enabled, second_endpoint_only=False):
"""Helper function that runs get_default_chat with the given share_defaults_with_users setting."""
global_settings = {
"default_endpoint_id": "global-ep-123",
"default_model": "qwen-3.6",
"default_model_fallbacks": [
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
],
"share_defaults_with_users": share_defaults_enabled
}
monkeypatch.setattr(model_routes, "_load_settings", lambda: global_settings)
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: {})
fake_auth_manager = MagicMock()
fake_auth_manager.is_admin = lambda user: False
endpoints = [
_FakeEndpoint(
id="global-ep-123",
base_url="http://global-endpoint:8000/v1",
is_enabled=True
),
_FakeEndpoint(
id="fallback-ep",
base_url="http://fallback-endpoint:8000/v1",
is_enabled=True
)
]
# When testing fallback scenario, removes the primary endpoint
if second_endpoint_only:
endpoints = [endpoints[1]]
fake_db = _make_db_session(endpoints, user="regular_user")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
router = model_routes.setup_model_routes(model_discovery=None)
get_default_chat = _get_default_chat_route(router)
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
result = get_default_chat(fake_request)
return result
### Test Functions
def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypatch):
"""
Non-admin user without personal preferences should resolve to empty
ep_id, model, and fallbacks when share_defaults_with_users is disabled.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=False)
assert test_data["endpoint_id"] == "", "Should get empty endpoint_id"
assert test_data["model"] == "", "Should get empty model"
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
"""
Non-admin user without personal preferences should resolve to global
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
assert test_data["model"] == "qwen-3.6", \
"model should be resolved from global default_model"
assert test_data["endpoint_id"] == "global-ep-123", \
"Should get global endpoint_id"
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
"""
Non-admin user without personal preferences should resolve to global
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
"""
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
assert test_data["model"] == "qwen-3.6", \
"model should be resolved from global default_model"
assert test_data["endpoint_id"] == "fallback-ep", \
"Should get global endpoint_id"
+6
View File
@@ -403,6 +403,12 @@ class TestIsChatModel:
def test_legacy_openai_instruct_is_not_chat(self): def test_legacy_openai_instruct_is_not_chat(self):
assert _is_chat_model("gpt-3.5-turbo-instruct") is False assert _is_chat_model("gpt-3.5-turbo-instruct") is False
@pytest.mark.parametrize("bad", [None, 123, 4.5, ["x"], {"a": 1}])
def test_non_string_id_is_treated_as_chat(self, bad):
# Defensive boundary: a non-compliant upstream can yield a non-string
# model id; it must not crash on .lower() (treated as chat-capable).
assert _is_chat_model(bad) is True
# ── _classify_endpoint ── # ── _classify_endpoint ──
+109
View File
@@ -0,0 +1,109 @@
"""Regression tests for Ollama-native multimodal image routing (issue #4723).
Odysseus builds user messages in OpenAI style::
{"role": "user", "content": [
{"type": "text", "text": "..."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
]}
Native Ollama ``/api/chat`` does **not** accept a list for ``content``. It
expects ``content`` to be a string and images carried separately on
``images`` (a list of raw base64 strings, no ``data:`` prefix). Without
this conversion the image block silently never reaches the vision model
the model reports "I can't see the image" even though it is vision-capable
and the request succeeded.
"""
from src import llm_core
def _multimodal_msg():
return {
"role": "user",
"content": [
{"type": "text", "text": "What is in this picture?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBBB"}},
],
}
def test_ollama_payload_converts_openai_image_blocks_to_native_images_array():
payload = llm_core._build_ollama_payload(
"gemma4:e4b", [_multimodal_msg()], temperature=0.0, max_tokens=0,
)
msg = payload["messages"][0]
# Content must be a string, not a list — native Ollama rejects lists.
assert isinstance(msg["content"], str)
assert "What is in this picture?" in msg["content"]
# Base64 data extracted into the native images array (no data: prefix).
assert msg["images"] == ["AAAA", "BBBB"]
def test_ollama_payload_skips_http_image_url():
"""Non-data-URI image_url values are skipped with a warning because
native Ollama images[] accepts base64 only."""
msg = {
"role": "user",
"content": [
{"type": "text", "text": "Look"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert out["content"] == "Look"
# HTTP URL is NOT added to images — Ollama cannot fetch it.
assert "images" not in out
def test_ollama_payload_preserves_native_images_array():
"""If the caller already used Ollama's native shape, leave it alone."""
msg = {
"role": "user",
"content": "Describe",
"images": ["XXXX"],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert out["content"] == "Describe"
assert out["images"] == ["XXXX"]
def test_ollama_payload_merges_native_and_openai_images():
"""A message that carries both native ``images`` and OpenAI ``image_url``
blocks (e.g. assembled by different code paths) must produce one combined
list rather than drop either half."""
msg = {
"role": "user",
"content": [
{"type": "text", "text": "Hi"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,OPENAI"}},
],
"images": ["NATIVE"],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert out["content"] == "Hi"
assert out["images"] == ["NATIVE", "OPENAI"]
def test_ollama_payload_text_only_message_untouched():
msgs = [{"role": "user", "content": "hello"}]
payload = llm_core._build_ollama_payload("gemma4:e4b", msgs, temperature=0.0, max_tokens=0)
assert payload["messages"][0] == {"role": "user", "content": "hello"}
def test_ollama_payload_string_content_with_only_image_block():
"""A message whose content list has only image_url blocks (no text part)
still yields a non-empty content string so native Ollama accepts it."""
msg = {
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QQ=="}},
],
}
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
out = payload["messages"][0]
assert isinstance(out["content"], str)
assert out["images"] == ["QQ=="]
+109
View File
@@ -0,0 +1,109 @@
"""Node-driven regression coverage for body-portaled dropdown z-order.
Tool-modal z climbs unbounded via modalManager's bring-to-front counter, so the
old hardcoded `z-index: 10001` shared by ~16 body-portaled dropdowns eventually
rendered them BEHIND their own modal in a long session (#4720). topPortalZ()
replaces every one of those literals with a value derived from the live
tool-window stack. These tests pin that it always clears both the modal stack
and the dock-chip floor, without importing the browser-heavy UI modules.
"""
import json
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js"
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
def _node_eval(source: str):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
cwd=ROOT,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_portal_z_clears_dock_chip_floor_when_no_modal_is_open():
# No tool window raised → topToolWindowZ floors at 250, but a portaled
# dropdown must still clear the dock chips pinned up to 10030, so it lands
# just above that floor.
values = _node_eval(
textwrap.dedent(
f"""
import {{ topPortalZ }} from '{HELPER.as_uri()}';
const root = {{ querySelectorAll() {{ return []; }} }};
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: () => ({{}}) }}) }}));
"""
)
)
assert values == {"z": 10031}
def test_portal_z_sits_above_a_modal_whose_counter_has_climbed_past_10001():
# The #4720 scenario: a long session bumped the owning modal's bring-to-front
# z to 99999. A hardcoded 10001 dropdown rendered BEHIND it; topPortalZ must
# land one above the live modal z.
values = _node_eval(
textwrap.dedent(
f"""
import {{ topPortalZ }} from '{HELPER.as_uri()}';
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
const modal = {{ id: 'memory-modal', classList: cls(), style: {{ zIndex: '99999' }} }};
const root = {{ querySelectorAll() {{ return [modal]; }} }};
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
"""
)
)
assert values == {"z": 100000}
def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it():
# A modal raised to 5000 is still below the dock-chip floor, so the floor
# (10030) wins and the dropdown lands at 10031 — never below a pinned chip.
values = _node_eval(
textwrap.dedent(
f"""
import {{ topPortalZ }} from '{HELPER.as_uri()}';
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
const modal = {{ id: 'cookbook-modal', classList: cls(), style: {{ zIndex: '5000' }} }};
const root = {{ querySelectorAll() {{ return [modal]; }} }};
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
"""
)
)
assert values == {"z": 10031}
# tasks.js and skills.js were not in #4724's batch; #4767 routes their portaled
# dropdowns through the same helper. Pin that they use topPortalZ() and carry no
# hardcoded portal z-index, so they cannot regress to the #4720 bug.
@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js"])
def test_late_routed_dropdowns_use_top_portal_z(rel):
src = (ROOT / rel).read_text()
assert "topPortalZ" in src, f"{rel} must import/use topPortalZ()"
assert "topPortalZ()" in src, f"{rel} must call topPortalZ() for its dropdown z"
@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "static/style.css"])
def test_no_hardcoded_portal_z_literals_remain(rel):
src = (ROOT / rel).read_text()
# Match the exact 100000/100002 these dropdowns used; the trailing-digit
# guard avoids false-matching an unrelated 1000000 elsewhere.
hits = re.findall(r"z-index:\s*10000[02](?!\d)", src)
assert not hits, f"{rel} still has hardcoded portal z: {hits}"
+13 -4
View File
@@ -93,10 +93,19 @@ class TestProviderLabel:
def test_known_labels(self, url, expected): def test_known_labels(self, url, expected):
assert _provider_label(url) == expected assert _provider_label(url) == expected
def test_local_non_ollama_endpoint(self): @pytest.mark.parametrize("url", [
# A loopback host that isn't on the native Ollama /api path is just a "http://localhost:8080/v1",
# generic local endpoint (e.g. an OpenAI-compatible local server). "http://127.0.0.1:8080/v1",
assert _provider_label("http://localhost:8080/v1") == "local endpoint" "http://localhost:8000/v1",
"http://localhost:1234/v1",
"http://localhost:9999/v1",
])
def test_local_non_ollama_endpoint(self, url):
# The serving tool is NOT inferred from the port: vLLM, SGLang, llama.cpp
# and plain OpenAI-compatible servers all share 8000/8080, so a port-only
# label would mislabel real setups. The tool is identified by /props
# fingerprinting during discovery; this helper stays neutral.
assert _provider_label(url) == "local endpoint"
def test_unknown_host_returns_host(self): def test_unknown_host_returns_host(self):
assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example" assert _provider_label("https://api.unknown-llm.example/v1") == "api.unknown-llm.example"
+54
View File
@@ -0,0 +1,54 @@
"""providerLabel() in providers.js must NOT name the serving tool from the port,
mirroring the Python _provider_label() in src/llm_core.py.
A port is not authoritative: vLLM, SGLang, llama.cpp and plain OpenAI-compatible
servers all routinely share 8000/8080, so a port-only label would mislabel real
setups (e.g. a vLLM box on :8080 shown as "llama.cpp"). The actual tool is
identified by probing /props during discovery and stored as the endpoint's name.
The rule here: loopback "Local"; private-LAN IPs "Local"; known remote
provider hosts their provider name.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_SRC = _REPO / "static" / "js" / "providers.js"
_HAS_NODE = shutil.which("node") is not None
def _provider_label(url: str) -> str | None:
src = _SRC.read_text(encoding="utf-8")
# Strip the `export` keyword so the module runs standalone.
src_runnable = src.replace("export function providerLabel", "function providerLabel")
src_runnable = src_runnable.replace("export default {", "const _default = {")
js = src_runnable + f"\nconsole.log(JSON.stringify(providerLabel({json.dumps(url)})));"
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, encoding="utf-8",
cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
@pytest.mark.parametrize("url,expected", [
# Loopback never names the tool from the port — it isn't authoritative.
("http://localhost:8080/v1", "Local"),
("http://127.0.0.1:8080/v1", "Local"),
("http://localhost:8000/v1", "Local"),
("http://localhost:1234/v1", "Local"),
("http://localhost:11434/api", "Local"),
("http://localhost:9999/v1", "Local"),
# Known remote provider hosts are still labeled by host suffix.
("https://api.openai.com/v1", "OpenAI"),
("https://api.groq.com/openai/v1","Groq"),
("http://192.168.1.50:8080", "Local"), # private LAN: no port branding
])
def test_provider_label_neutral_for_loopback(url, expected):
assert _provider_label(url) == expected
+49
View File
@@ -0,0 +1,49 @@
r"""Regression test for ReDoS in the calendar-extract fallback regex.
CodeQL `py/redos` (#198) flagged the inline array-matcher in
`email_pollers.py` that recovers a `[{"action": ...}, ...]` JSON array from
raw LLM output (influenced by attacker-supplied email bodies). The original
pattern used `[^[\]]*?` lazy runs inside a `(...)*` repetition, which
backtracks *exponentially* on inputs like `[{"action"},{` + `}},{{` * N.
The regex is now a module-level constant so it can be pinned here. These tests
assert it (a) still extracts well-formed action arrays and (b) returns
promptly on the adversarial input that hung the old pattern.
"""
import time
from routes.email_pollers import _CAL_ACTION_ARRAY_RE
def _matches(s):
return [m.group() for m in _CAL_ACTION_ARRAY_RE.finditer(s)]
def test_extracts_action_array_from_prose():
s = 'Here you go:\n[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]\nThanks!'
assert _matches(s) == ['[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]']
def test_extracts_multi_object_array():
s = 'prose [{"action":"add","title":"A"},{"action":"cancel","uid":"x"}] tail'
assert _matches(s) == ['[{"action":"add","title":"A"},{"action":"cancel","uid":"x"}]']
def test_no_array_returns_no_match():
assert _matches("no array here at all") == []
def test_bracket_in_string_value_still_extracts():
# The old `[^[\]]` class bailed on a '[' inside a value and matched nothing;
# the linear `[^{}]` form correctly recovers the array.
s = '[{"action":"add","title":"Meeting [urgent]","start":"x"}]'
assert _matches(s) == [s]
def test_adversarial_input_is_fast():
evil = '[{"action"},{' + '}},{{' * 100_000 # exploded the old exponential pattern
start = time.perf_counter()
_CAL_ACTION_ARRAY_RE.search(evil)
dt = time.perf_counter() - start
assert dt < 1.0, f"_CAL_ACTION_ARRAY_RE took {dt:.2f}s on adversarial input"

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