Compare commits

...
10 Commits
Author SHA1 Message Date
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
MACKAT05andCursor 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
MocchibirdandClaude Opus 4.8 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 DlshadandClaude Opus 4.8 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
34 changed files with 1016 additions and 353 deletions
+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>"
+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`.
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.
### 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
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,
email, calendar, deep research) runs fully native. For full **Cookbook** background
model downloads and the agent shell tool, also install
+2 -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 Session as DBSession, ChatMessage as DBChatMessage
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.model_routes import _visible_models
from routes.chat_helpers import (
@@ -930,7 +931,7 @@ def setup_chat_routes(
if effective_do_research:
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
_auth_keys = list(_r_headers.keys()) if _r_headers else []
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
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.
# Skip in compare mode — each pane is a fresh session, so every one would
+18 -8
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from datetime import datetime
from urllib.parse import urljoin, urlparse, urlunparse
from core.log_safety import redact_url
from fastapi import APIRouter, Query, Depends, Response, HTTPException
from typing import List, Dict, Optional
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
url = _resolve_resource_url(uid)
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
r = httpx.delete(url, auth=auth, timeout=10)
if r.status_code in (200, 204):
_contact_cache["fetched_at"] = None
return True
if r.status_code == 404:
# Resource not found at the resolved URL. With href resolution
# 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")
if r.status_code in (200, 204, 404):
# Invalidate cache so the next fetch sees the server truth.
_contact_cache["fetched_at"] = None
# 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:
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
return True
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
return False
+3 -1
View File
@@ -12,6 +12,7 @@ from pydantic import BaseModel
from core.database import Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler
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.
"""
if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required")
if doc.owner is not None:
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;
we therefore match the owner strictly for authenticated callers."""
if not user:
from src.auth_helpers import _auth_disabled
if user == "" or _auth_disabled():
return q
return q.filter(False)
+3 -2
View File
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
from sqlalchemy import case, func, or_
from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, _auth_disabled
from src.constants import MAIL_ATTACHMENTS_DIR
logger = logging.getLogger(__name__)
@@ -388,7 +388,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
db = SessionLocal()
try:
if not user:
raise HTTPException(403, "Authentication required")
if not _auth_disabled():
raise HTTPException(403, "Authentication required")
# v2 review HIGH-9: raise 403 explicitly when the caller
# can't see this session, instead of returning [] which the
# UI treats identically to "no docs" and silently masks
+3 -14
View File
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
from pydantic import BaseModel
from fastapi.responses import StreamingResponse
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 src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
from src.tls_overrides import llm_verify
@@ -582,18 +583,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
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:
return provider == "chatgpt-subscription"
@@ -810,9 +799,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)
except Exception as e:
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 []
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
# the OpenAI-compatible /v1/models path is unavailable.
+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
# previous "silently no-op when cfg has no smtp_host" was invisible).
logger.info(
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
f"from={from_addr!r} recipient={recipient!r} "
f"account_name={cfg.get('account_name')!r}"
"dispatch_reminder[email] note_id=%s owner=%r "
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
note_id, owner,
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
bool(from_addr), bool(recipient),
)
missing = []
if not cfg.get("smtp_host"):
+37 -5
View File
@@ -538,6 +538,32 @@ def _powershell_exe():
path so we don't depend on a particular PATH ordering."""
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():
"""Detect Windows hardware via PowerShell/WMI.
@@ -600,9 +626,8 @@ def _detect_windows():
"""
)
if _remote_host:
# Remote: ship a single command string over SSH. The remote shell parses
# the quoting; PowerShell on the far side runs the -Command payload.
out = _run(f'powershell -Command "{ps_cmd}"')
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
out = _powershell_encoded_for_ssh(ps_cmd.strip())
else:
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
# 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
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)
now = time.time()
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
_cache_by_host[cache_key] = (now, result)
return result
# If Windows detection failed, return error
result = {"error": f"Cannot connect to {host}", "host": host}
# SSH may work while the PowerShell hardware probe still fails.
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
_remote_host = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
+24 -6
View File
@@ -3215,9 +3215,12 @@ async def stream_agent_loop(
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
)
# ask_user: the agent posed a multiple-choice question. Emit it so the
# frontend renders clickable options, then end the turn (below) and
# wait — the user's pick becomes the next message.
# ask_user: remember the payload now, but emit the interactive event
# only *after* tool_output below. Emitting it before tool_output let
# 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:
# The question lives in the tool args. ChatMessage.to_dict()
# replays only role+content to the model next turn — tool_event
@@ -3232,9 +3235,7 @@ async def stream_agent_loop(
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
full_response += _auq_delta
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
yield (
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
)
_pending_ask_user_event = _auq
_awaiting_user = True
# update_plan: agent wrote back to the plan (ticked a step / revised).
@@ -3289,6 +3290,10 @@ async def stream_agent_loop(
# 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")}
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:
tool_output_data["ui_event"] = result["ui_event"]
for k in (
@@ -3319,6 +3324,14 @@ async def stream_agent_loop(
tool_output_data["diff"] = result["diff"]
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.
# 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"
@@ -3376,6 +3389,11 @@ async def stream_agent_loop(
# this the diff shows live but vanishes from saved history.
if result.get("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)
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
_effectful_used = True
+2 -2
View File
@@ -1667,7 +1667,7 @@ class TaskScheduler:
msg["X-Odysseus-Ref"] = str(task.id)
msg.set_content(result or "")
_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:
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
raise
@@ -2029,7 +2029,7 @@ class TaskScheduler:
# silent SMTP failure is easier to spot in the logs.
logger.info(
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:
logger.error(f"Task {task.id} MCP delivery failed: {e}")
+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).",
"send_to_session": "Send a message to another chat. Cross-chat communication.",
"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.",
"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.",
+8 -2
View File
@@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
"options": {
"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": {
"type": "object",
"properties": {
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
"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"]
}
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
content = json.dumps(args)
elif tool_type == "ask_teacher":
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:
content = json.dumps(args)
+8 -8
View File
@@ -12,7 +12,7 @@ import {
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
_trashIcon, _moreIcon, _bellIcon,
_isCalBgImage, _calBgImageUrl, _calBgCss,
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
_calReadableTextColor,
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
} from './calendar/utils.js';
@@ -413,8 +413,8 @@ function _calEventFg(ev) {
// Returns '' for normal solid-color events.
function _calItemBgStyle(ev) {
if (!_isCalBgImage(ev.color)) return '';
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
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;`;
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('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
}
function _todayCount() {
@@ -1260,8 +1260,8 @@ async function _renderWeek() {
// events keep the original tinted treatment.
let bgDecl;
if (_isCalBgImage(ev.color)) {
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
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;`;
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('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
} else {
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
}
@@ -2853,7 +2853,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
let bg;
if (isCustom) {
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 {
bg = c.hex || 'var(--border)';
}
@@ -2928,7 +2928,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
// stays readable. Chrome accent falls back to the theme accent.
const url = _calBgImageUrl(hex);
_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.backgroundPosition = 'center';
_formCard.classList.add('cal-form-bg-image');
@@ -2950,7 +2950,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
if (!url) return;
const sentinel = 'bg:' + url;
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'));
dot.classList.add('active');
_applyFormTint(sentinel);
+13 -1
View File
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
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
// 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).
export function _calBgCss(c, fallback) {
if (_isCalBgImage(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)';
}
+13 -142
View File
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
import chatStream from './chatStream.js';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import spinnerModule from './spinner.js';
import presetsModule from './presets.js';
import fileHandlerModule from './fileHandler.js';
@@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else if (json.type === 'ask_user') {
if (_isBg) continue;
// The agent posed a multiple-choice question; the turn has ended.
// Render clickable options at the bottom of the history. The
// user's pick is sent as the next message and the agent resumes.
// Use the shared history renderer so the live and restored
// versions have identical behavior.
_cancelThinkingTimer();
_removeThinkingSpinner();
const _aq = 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 (_) {}
}
chatRenderer.renderAskUserCard(json.data || {});
} else if (json.type === 'plan_update') {
if (_isBg) continue;
@@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!header) return;
const node = header.closest('.agent-thread-node');
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;
}
+152
View File
@@ -3,6 +3,7 @@
import uiModule from './ui.js';
import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js';
import { addAITTSButton } from './tts-ai.js';
import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
@@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) {
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.
*/
@@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) {
const box = document.getElementById('chat-history');
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;
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
@@ -1990,6 +2132,7 @@ export function addMessage(role, content, modelName, metadata) {
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
const roundTexts = metadata.round_texts || [];
const toolEvents = metadata.tool_events;
let pendingAskUser = null;
let lastWrap = null;
let firstMsgAi = null;
let lastMsgAi = null;
@@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) {
box.appendChild(threadWrap);
}
for (const ev of roundTools) {
if (ev.ask_user) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = '';
if (ev.output && ev.output.trim()) {
@@ -2129,6 +2273,12 @@ export function addMessage(role, content, modelName, metadata) {
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
}
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;
}
@@ -2461,6 +2611,8 @@ const chatRenderer = {
copyMessageText,
safeToolScreenshotSrc,
safeDisplayImageSrc,
removeAskUserCards,
renderAskUserCard,
buildSourcesBox,
buildFindingsBox,
appendReportButton,
+5 -4
View File
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
import themeModule from '../theme.js';
import presetsModule from '../presets.js';
import markdownModule from '../markdown.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
}
let _exportMenuEl = null;
let _closeExportMenu = () => {};
function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect();
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
}
document.body.appendChild(m);
_exportMenuEl = m;
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
}
function _closeExportMenu() {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
_closeExportMenu = bindMenuDismiss(m, () => {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
}, (ev) => !m.contains(ev.target));
}
async function _exportCopyMarkdown(_btn) {
+7 -10
View File
@@ -33,6 +33,8 @@ import {
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
} from './cookbookServe.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const STORAGE_KEY = 'cookbook-presets';
const LAST_STATE_KEY = 'cookbook-last-state';
const SERVE_STATE_KEY = 'cookbook-serve-state';
@@ -1514,7 +1516,7 @@ async function _fetchDependencies() {
// Wire the installed-package menu.
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');
if (!row) return;
const pipName = row.dataset.depPip;
@@ -1535,7 +1537,7 @@ async function _fetchDependencies() {
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
close();
await _installDep(pipName, pkgName, isLocalOnly, true, null);
});
dropdown.appendChild(it);
@@ -1563,19 +1565,14 @@ async function _fetchDependencies() {
dropdown.appendChild(source);
}
document.body.appendChild(dropdown);
const close = (ev) => {
if (!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);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
}
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (document.querySelector('.cookbook-dep-menu')) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
return;
}
_showDepMenu(btn);
+21 -37
View File
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js';
import * as Modals from './modalManager.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let API_BASE = '';
let isOpen = false;
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() {
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;
}
}
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0);
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) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice();
close();
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 = {}) {
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
function showExportMenu(e, anchorRect) {
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');
if (existing) { existing.remove(); return; }
if (existing) { dismissOrRemove(existing); return; }
// Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
const item = document.createElement('button');
item.className = 'doc-overflow-item';
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);
if (opt._divider) {
const sep = document.createElement('div');
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
const close = (ev) => {
if (ev && ev.type === 'keydown') {
if (ev.key !== 'Escape') return;
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);
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); });
}
function exportAsHtml() {
+6 -11
View File
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
function _showEmailMenu(em, anchor, itemEl) {
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show';
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
_showRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
close();
a.action();
});
dropdown.appendChild(menuItem);
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
anchor.appendChild(dropdown);
const close = (e) => {
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
}
// ---- 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.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
dismissOrRemove(parentDropdown);
await _createReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
dismissOrRemove(parentDropdown);
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);
+24 -43
View File
@@ -23,6 +23,7 @@ import {
} from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js';
import { collapseSidebarToRail } from './modalSnap.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
@@ -5499,16 +5500,12 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
// Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) {
existing.remove();
anchor.classList.remove('reader-more-active');
dismissOrRemove(existing);
return;
}
// Otherwise close any other open dropdown (and clear its anchor's active
// state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(d => {
if (d._anchor) d._anchor.classList.remove('reader-more-active');
d.remove();
});
// Otherwise close any other open dropdown (its own teardown clears its
// anchor's active state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
@@ -5721,8 +5718,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
a.action();
});
dropdown.appendChild(item);
@@ -5735,25 +5731,20 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== 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');
dropdown.className = 'email-card-dropdown';
@@ -5918,8 +5909,7 @@ function _showCardMenu(em, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
a.action();
});
dropdown.appendChild(item);
@@ -5932,26 +5922,21 @@ function _showCardMenu(em, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.remove();
anchor.classList.remove('reader-more-active');
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
anchor.classList.remove('reader-more-active');
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
function _showBulkActionsMenu(anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown email-bulk-menu';
const rect = anchor.getBoundingClientRect();
@@ -5968,7 +5953,7 @@ function _showBulkActionsMenu(anchor) {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
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);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
@@ -5978,7 +5963,7 @@ function _showBulkActionsMenu(anchor) {
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
cancelIt.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.remove();
close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown.
state._selectMode = false;
@@ -5989,13 +5974,9 @@ function _showBulkActionsMenu(anchor) {
dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _updateBulkBar() {
+5 -10
View File
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -2514,7 +2515,7 @@ export function openGallery() {
// shares the exact same dropdown style/behaviour.
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
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
// matches every other menu in the app. Positioned fixed at the button.
const dropdown = document.createElement('div');
@@ -2548,17 +2549,11 @@ export function openGallery() {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
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);
}
document.body.appendChild(dropdown);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
_bulkActionsBtn?.addEventListener('click', (e) => {
@@ -2567,7 +2562,7 @@ export function openGallery() {
// should close it. The outside-click handler explicitly skips clicks on
// the anchor, so the button itself has to do its own dismiss.
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; }
_showGalleryBulkMenu(e.currentTarget);
});
+8 -15
View File
@@ -11,6 +11,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
import { topToolWindowZ } from './toolWindowZOrder.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
function _pickCustomDate() {
// 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');
menu.className = 'note-reminder-menu';
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
if (typeof dInput.showPicker === 'function') {
try { dInput.showPicker(); } catch {}
}
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
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); });
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
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 note = _notes.find(n => n.id === id);
if (!note) return;
@@ -4338,14 +4336,9 @@ function _openNoteCornerMenu(btn) {
const below = window.innerHeight - r.bottom;
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;`;
const close = (ev) => {
if (ev && menu.contains(ev.target)) return;
menu.remove();
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); });
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
}
function _positionNoteMenu(menu, btn, width = 196) {
+1
View File
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (item.querySelector('.session-rename-input')) return;
e.preventDefault();
const sid = item.dataset.sessionId;
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 { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
let initialized = false;
let modalEl = null;
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
if (lbl) lbl.textContent = text;
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 = () => {
menu.style.display = 'block';
const tRect = trig.getBoundingClientRect();
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
const above = tRect.top;
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'; }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
};
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => {
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
if (labelEl) labelEl.textContent = lbl;
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 = () => {
menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger.
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
} else {
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); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
};
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => {
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
addBtn.parentElement.style.position = 'relative';
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;
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
let _closeMenu = () => {};
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (_menuEl) { _closeMenu(); return; }
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
showForm(k, 'new');
});
});
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
});
}
+6 -6
View File
@@ -7,6 +7,7 @@
import uiModule from './ui.js';
import * as spinnerModule from './spinner.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API = window.location.origin;
let skills = [];
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
// Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete).
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');
menu.className = 'skill-kebab-menu';
const mk = (paths, label, opts, onClick) => {
const item = document.createElement('button');
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
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);
};
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
@@ -410,7 +411,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.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
close();
if (!_selectMode) _enterSelectMode();
_selectedNames.add(name);
renderSkillsList();
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
const cancelItem = document.createElement('button');
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.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
menu.appendChild(cancelItem);
document.body.appendChild(menu);
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
menu.style.overflowY = 'auto';
}
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
setTimeout(() => document.addEventListener('click', close, true), 0);
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
}
// Cards for the agent's built-in tool capabilities (from
+8 -10
View File
@@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
const dd = document.createElement('div');
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;';
@@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) {
}
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
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);
});
document.body.appendChild(dd);
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
dd.style.top = top + 'px';
dd.style.left = left + 'px';
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
// "ghost click" duplicates that were firing right after pointerup and
// removing the dropdown before the user could see it).
if (performance.now() - openedAt < 250) return;
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
};
// requestAnimationFrame so the listener is registered AFTER the current
// pointer/click event cycle has finished bubbling.
requestAnimationFrame(() => document.addEventListener('click', close));
// removing the dropdown before the user could see it) — treat as inside.
if (performance.now() - openedAt < 250) return false;
return !dd.contains(ev.target);
});
}
// ---- Presets ----
+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
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():
# TOOL_TAGS gate (serializer rejects unknown tools)
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)
)
+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."""
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
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"
+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::::")